From f6e3a15c40ea8d8c8add4c4a0189642eeb5288dd Mon Sep 17 00:00:00 2001 From: tbphp Date: Fri, 18 Sep 2026 12:46:18 +0800 Subject: [PATCH] fix(ui): restore classic contracts and usage trend buckets (#687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): 修复新旧界面的共享接口兼容回归 * fix(usage): 修正五分钟趋势的请求时间归桶 --- internal/control/classic_api_contract_test.go | 196 ++++++++++++++++++ internal/requestlog/channel_identity_test.go | 6 +- internal/requestlog/usage_external_test.go | 31 +++ internal/requestlog/usage_query.go | 4 + .../requestlog/usage_query_minute_test.go | 34 +-- internal/requestlog/usage_query_test.go | 24 +-- .../requestlog/usage_query_window_test.go | 56 ++++- .../classic/app/resources/channels.ts | 2 + .../frontends/classic/app/resources/home.ts | 2 + .../classic/app/resources/request-logs.ts | 2 + 10 files changed, 324 insertions(+), 33 deletions(-) create mode 100644 internal/control/classic_api_contract_test.go diff --git a/internal/control/classic_api_contract_test.go b/internal/control/classic_api_contract_test.go new file mode 100644 index 00000000..8b3ea4b4 --- /dev/null +++ b/internal/control/classic_api_contract_test.go @@ -0,0 +1,196 @@ +package control + +import ( + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + + "gpt-load/internal/channel" + "gpt-load/internal/outboundproxy" +) + +// 核对后端公开 JSON 字段与经典版读取契约,不执行前端或浏览器测试。 +// 包含 omitempty 字段,防止只在有账号、额度、错误等数据时才暴露兼容回归。 +func TestSharedAPIResponseFieldsMatchClassicContracts(t *testing.T) { + t.Parallel() + for _, test := range []struct { + resource string + fields string + response any + }{ + {"channels", "listFields", ChannelListResponse{}}, + {"channels", "channelFields", ChannelListItem{}}, + {"channels", "fieldFields", channel.FieldDescriptor{}}, + {"channels", "connectionFields", channel.ConnectionDescriptor{}}, + {"channels", "capabilityFields", channel.CapabilityDescriptor{}}, + {"channels", "noticeFields", channel.NoticeDescriptor{}}, + {"channels", "routeFields", channel.RouteDescriptor{}}, + {"groups", "groupCollectionFields", GroupCollectionResponse{}}, + {"groups", "groupCollectionSummaryFields", GroupCollectionSummary{}}, + {"groups", "groupCollectionItemFields", GroupCollectionItem{}}, + {"groups", "groupCollectionPaginationFields", GroupCollectionPagination{}}, + {"groups", "credentialCountFields", GroupCollectionCredentialCounts{}}, + {"groups", "groupSummaryFields", GroupSummaryResponse{}}, + {"groups", "groupSettingsFields", GroupSettingsResponse{}}, + {"groups", "runtimeSettingFields", GroupEffectiveConfigResponse{}}, + {"groups", "groupModelsFields", GroupModelsResponse{}}, + {"groups", "groupModelItemFields", GroupModelResponse{}}, + {"groups", "groupOptionFields", GroupOption{}}, + {"credentials", "credentialCollectionFields", CredentialCollectionResponse{}}, + {"credentials", "credentialSummaryFields", CredentialSummaryResponse{}}, + {"credentials", "credentialItemFields", CredentialItemResponse{}}, + {"credentials", "credentialDetailFields", CredentialDetailResponse{}}, + {"credentials", "credentialDailyUsageFields", CredentialDailyUsageResponse{}}, + {"credentials", "credentialRecoveryFields", CredentialRecoveryResponse{}}, + {"credentials", "credentialPaginationFields", CredentialPaginationResponse{}}, + {"credentials", "credentialBatchFields", CredentialBatchResponse{}}, + {"credentials", "credentialTestResultFields", CredentialProbeResponse{}}, + {"credentials", "accountFields", CredentialAccountResponse{}}, + {"credentials", "observationFields", CredentialObservationResponse{}}, + {"credentials", "observationSnapshotFields", CredentialObservationSnapshot{}}, + {"credentials", "planFields", ObservationPlanSummary{}}, + {"credentials", "observationAccountFields", ObservationAccountSummary{}}, + {"credentials", "quotaWindowFields", ObservationQuotaWindow{}}, + {"credentials", "observedWindowUsageFields", ObservationWindowUsage{}}, + {"credentials", "resetCreditFields", ObservationResetCredit{}}, + {"credentials", "resetCreditConsumeFields", ResetCreditConsumeResponse{}}, + {"credential-stages", "stageFields", CredentialStageResult{}}, + {"credential-stages", "accountFields", CredentialStageAccount{}}, + {"access-keys", "metadataFields", AccessKeyMetadata{}}, + {"access-keys", "optionFields", AccessKeyOption{}}, + {"access-keys", "collectionFields", AccessKeyCollectionResponse{}}, + {"access-keys", "collectionSummaryFields", AccessKeyCollectionSummary{}}, + {"access-keys", "collectionItemFields", AccessKeyCollectionItem{}}, + {"access-keys", "collectionPaginationFields", AccessKeyCollectionPagination{}}, + {"access-keys", "costLimitRuleFields", AccessKeyCostLimitRule{}}, + {"access-keys", "costLimitRuleStatusFields", AccessKeyCostLimitRuleStatus{}}, + {"access-keys", "costLimitStatusFields", AccessKeyCostLimitStatus{}}, + {"home", "homeBaseFields", homeResponse{}}, + {"home", "inventoryFields", HomeInventory{}}, + {"home", "accessKeyFields", HomeAccessKey{}}, + {"home", "statisticsFields", homeStatisticsResponse{}}, + {"home", "summaryFields", homeStatisticsAggregateResponse{}}, + {"home", "trendPointFields", homeStatisticsSeriesResponse{}}, + {"home", "rankingsFields", homeStatisticsRankingsResponse{}}, + {"home", "statisticsRefFields", homeStatisticsRefResponse{}}, + {"home", "modelRankingFields", homeModelRankingResponse{}}, + {"home", "groupRankingFields", homeGroupRankingResponse{}}, + {"home", "accessKeyRankingFields", homeAccessKeyRankingResponse{}}, + {"home", "subscriptionAccountsFields", HomeSubscriptionAccountsResponse{}}, + {"home", "subscriptionAccountFields", HomeSubscriptionAccountResponse{}}, + {"home", "subscriptionCapabilitiesFields", channel.CapabilityDescriptor{}}, + {"health", "healthFields", runtimeHealthResponse{}}, + {"health", "countFields", healthCountsResponse{}}, + {"health", "problemCredentialFields", healthProblemCredentialResponse{}}, + {"health", "quotaCredentialFields", healthQuotaCredentialResponse{}}, + {"health", "expiringResetCreditFields", healthExpiringResetCreditResponse{}}, + {"health", "blockedAccessKeyFields", healthAccessKeyCostLimitResponse{}}, + {"health", "requestLogFields", requestLogHealthResponse{}}, + {"request-logs", "itemFields", requestLogItemResponse{}}, + {"usage", "aggregateFields", usageAggregateResponse{}}, + {"usage", "distributionAggregateFields", usageDistributionAggregateResponse{}}, + {"usage", "reportFields", usageResponse{}}, + {"models", "collectionFields", ProjectModelListResponse{}}, + {"models", "clientModelFields", ProjectModelDTO{}}, + {"models", "upstreamModelFields", ProjectUpstreamModelDTO{}}, + {"models", "upstreamDetailFields", UpstreamModelDetailDTO{}}, + {"models", "associationFields", UpstreamModelAssociationDTO{}}, + {"models", "catalogReferenceFields", ProjectModelCatalogReferenceDTO{}}, + {"models", "groupFields", ProjectModelGroupDTO{}}, + {"models", "catalogModelFields", ProjectModelCatalogDTO{}}, + {"models", "modalitiesFields", ProjectModelModalitiesDTO{}}, + {"models", "limitsFields", ProjectModelLimitsDTO{}}, + {"models", "capabilitiesFields", ProjectModelCapabilitiesDTO{}}, + {"models", "paginationFields", ProjectModelPaginationDTO{}}, + {"models", "summaryFields", ProjectModelSummaryDTO{}}, + {"models", "catalogFields", ProjectModelCatalogStatusDTO{}}, + {"model-prices", "collectionFields", ModelPriceListResponse{}}, + {"model-prices", "paginationFields", ModelPricePaginationDTO{}}, + {"model-prices", "itemFields", ModelPriceDTO{}}, + {"model-prices", "priceFields", PriceSlotsDTO{}}, + {"model-prices", "contextTierFields", ModelPriceContextTierDTO{}}, + {"model-prices", "modeScheduleFields", ModelPriceScheduleDTO{}}, + {"providers", "modelCandidateFields", ModelCandidate{}}, + {"providers", "catalogSyncStatusFields", CatalogSyncStatus{}}, + {"settings", "settingsFields", SettingsResponse{}}, + {"settings", "settingsValueFields", SettingsValuesResponse{}}, + {"proxy", "proxyViewFields", outboundproxy.View{}}, + {"system-update", "systemUpdateFields", systemUpdateResponse{}}, + {"system-update", "releaseUpdateFields", releaseUpdateResponse{}}, + } { + t.Run(test.resource+"/"+test.fields, func(t *testing.T) { + path := filepath.Join("web", "src", "frontends", "classic", "app", "resources", test.resource+".ts") + allowed := readUIContractArray(t, readUIContractFile(t, path), test.fields) + for _, field := range reflect.VisibleFields(reflect.TypeOf(test.response)) { + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "-" || !field.IsExported() || (field.Anonymous && name == "") { + continue + } + if name == "" { + name = field.Name + } + if !allowed[name] { + t.Errorf("shared API %T field %q is rejected by classic %s", test.response, name, test.fields) + } + } + }) + } +} + +func TestSharedAPIOperationsMatchBothUIContracts(t *testing.T) { + t.Parallel() + content := readUIContractFile(t, filepath.Join("internal", "execution", "contracts.go")) + operations := regexp.MustCompile(`Operation\w+\s+Operation\s*=\s*"([^"]+)"`).FindAllStringSubmatch(content, -1) + if len(operations) == 0 { + t.Fatal("execution operation contract is empty") + } + for _, test := range []struct { + path string + array string + }{ + {"frontends/classic/app/resources/channels.ts", "operations"}, + {"frontends/classic/app/resources/request-logs.ts", "operations"}, + {"frontends/modern/api/logs.ts", "logOperations"}, + } { + t.Run(test.path, func(t *testing.T) { + allowed := readUIContractArray(t, readUIContractFile(t, filepath.Join("web", "src", test.path)), test.array) + for _, operation := range operations { + if !allowed[operation[1]] { + t.Errorf("shared API operation %q is rejected by %s", operation[1], test.path) + } + } + }) + } +} + +func readUIContractFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join("..", "..", path)) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +func readUIContractArray(t *testing.T, content, name string) map[string]bool { + t.Helper() + pattern := `(?s)\bconst\s+` + regexp.QuoteMeta(name) + `\s*=\s*\[(.*?)\]\s*as const` + match := regexp.MustCompile(pattern).FindStringSubmatch(content) + if match == nil { + t.Fatalf("UI contract array %q not found", name) + } + values := make(map[string]bool) + for _, value := range regexp.MustCompile(`'([^']+)'|\.\.\.(\w+)`).FindAllStringSubmatch(match[1], -1) { + if value[1] != "" { + values[value[1]] = true + continue + } + for field := range readUIContractArray(t, content, value[2]) { + values[field] = true + } + } + return values +} diff --git a/internal/requestlog/channel_identity_test.go b/internal/requestlog/channel_identity_test.go index fe1d63c9..6d42c1d3 100644 --- a/internal/requestlog/channel_identity_test.go +++ b/internal/requestlog/channel_identity_test.go @@ -202,7 +202,7 @@ func TestQueryUsageFiltersAndProjectsChannelCredential(t *testing.T) { credentialID := uint(11) report, err := newRequestLogTestService(db).QueryUsage(t.Context(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, ChannelID: channel.OpenAI, CredentialID: &credentialID, @@ -237,7 +237,7 @@ func TestAccessScopedUsageDistributionCollapsesHiddenRoutesByModel(t *testing.T) accessKeyID := uint(41) report, err := newRequestLogTestService(db).QueryUsage(t.Context(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, AccessKeyID: &accessKeyID, SelfScoped: true, @@ -268,7 +268,7 @@ func TestAccessScopedUsageReturnsOnlyModelDistributions(t *testing.T) { accessKeyID := uint(41) report, err := newRequestLogTestService(db).QueryUsage(t.Context(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, AccessKeyID: &accessKeyID, SelfScoped: true, diff --git a/internal/requestlog/usage_external_test.go b/internal/requestlog/usage_external_test.go index 2c750ac8..cb6f5c3d 100644 --- a/internal/requestlog/usage_external_test.go +++ b/internal/requestlog/usage_external_test.go @@ -137,6 +137,37 @@ func TestExternalDatabaseUsageExactWindow(t *testing.T) { short := query short.ToMS = from.Add(time.Hour).UnixMilli() assertCount(short, 23) + for _, test := range []struct { + span time.Duration + count int64 + }{ + {3 * time.Hour, 23}, + {6 * time.Hour, 45}, + } { + t.Run("five-minute buckets over "+test.span.String(), func(t *testing.T) { + input := query + input.ToMS = from.Add(test.span).UnixMilli() + report := assertCount(input, test.count) + want := make(map[int64]int64) + for _, row := range rows { + if row.CompletedAtMS >= input.FromMS && row.CompletedAtMS < input.ToMS { + bucket := row.CompletedAtMS - row.CompletedAtMS%UsageFiveMinuteBucketMS + want[max(bucket, input.FromMS)]++ + } + } + if len(report.Series) != len(want) { + t.Fatalf("five-minute bucket count = %d, want %d", len(report.Series), len(want)) + } + for _, point := range report.Series { + bucket := point.BucketStartMS - point.BucketStartMS%UsageFiveMinuteBucketMS + if point.RequestCount != want[point.BucketStartMS] || + point.BucketStartMS != max(bucket, input.FromMS) || + point.BucketEndMS != min(bucket+UsageFiveMinuteBucketMS, input.ToMS) { + t.Fatalf("five-minute bucket = %+v, want actual completion-time distribution %v", point, want) + } + } + }) + } // 中间日志已清理时仍只取小时聚合;首尾日志清理后直接返回剩余数据。 fullFrom, fullTo := base.Add(time.Hour).UnixMilli(), to.Truncate(time.Hour).UnixMilli() diff --git a/internal/requestlog/usage_query.go b/internal/requestlog/usage_query.go index 74bf60d6..4f4e7d8a 100644 --- a/internal/requestlog/usage_query.go +++ b/internal/requestlog/usage_query.go @@ -38,6 +38,10 @@ func (service *Service) QueryUsage(ctx context.Context, input UsageQuery) (Usage Operation: "usage read transaction", }, func(connection *gorm.DB) error { scope := usageWindowScope(connection, input) + // 5 分钟趋势不能由小时汇总还原;总览、趋势与分布共用请求明细来源。 + if bucketWidthMS == UsageFiveMinuteBucketMS { + scope = usageRequestLogScope(connection, input) + } if err := validateUsageIntegrity(scope, 0); err != nil { return err } diff --git a/internal/requestlog/usage_query_minute_test.go b/internal/requestlog/usage_query_minute_test.go index 04b2731b..8baa662d 100644 --- a/internal/requestlog/usage_query_minute_test.go +++ b/internal/requestlog/usage_query_minute_test.go @@ -133,7 +133,7 @@ func TestQueryUsageMinuteMatchesFrozenHourlyAccounting(t *testing.T) { } hourQuery := minuteUsageQuery(start) hourQuery.Granularity = UsageGranularityHour - hourQuery.ToMS = start.Add(2 * time.Hour).UnixMilli() + hourQuery.ToMS = start.Add(7 * time.Hour).UnixMilli() hour, err := service.QueryUsage(context.Background(), hourQuery) if err != nil { t.Fatalf("hour QueryUsage() error = %v", err) @@ -182,19 +182,25 @@ func TestQueryUsageMinuteFiltersFinalAttributionAndAccessKey(t *testing.T) { query.GroupID, query.ChannelID, query.CredentialID = &groupID, channel.OpenAI, &credentialID query.AccessKeyID, query.UpstreamModel = &accessKeyID, "final-model" query.SelfScoped = true - report, err := newRequestLogTestService(db).QueryUsage(context.Background(), query) - if err != nil { - t.Fatalf("QueryUsage() error = %v", err) - } - if report.Summary.RequestCount != 1 || len(report.Distributions.Group) != 0 || len(report.Distributions.AccessKey) != 0 { - t.Fatalf("scoped report = %#v", report) - } - assertMinuteUsageReportTotals(t, report) - query.GroupID, query.ChannelID, query.CredentialID = nil, "", nil - query.UpstreamModel = "earlier-model" - report, err = newRequestLogTestService(db).QueryUsage(context.Background(), query) - if err != nil || report.Summary.RequestCount != 0 { - t.Fatalf("earlier attempt filter = %#v/%v, want empty report", report, err) + for _, span := range []time.Duration{time.Hour, 3 * time.Hour, 6 * time.Hour} { + t.Run(span.String(), func(t *testing.T) { + input := query + input.ToMS = start.Add(span).UnixMilli() + report, err := newRequestLogTestService(db).QueryUsage(t.Context(), input) + if err != nil { + t.Fatalf("QueryUsage() error = %v", err) + } + if report.Summary.RequestCount != 1 || len(report.Distributions.Group) != 0 || len(report.Distributions.AccessKey) != 0 { + t.Fatalf("scoped report = %#v", report) + } + assertMinuteUsageReportTotals(t, report) + input.GroupID, input.ChannelID, input.CredentialID = nil, "", nil + input.UpstreamModel = "earlier-model" + report, err = newRequestLogTestService(db).QueryUsage(t.Context(), input) + if err != nil || report.Summary.RequestCount != 0 { + t.Fatalf("earlier attempt filter = %#v/%v, want empty report", report, err) + } + }) } } diff --git a/internal/requestlog/usage_query_test.go b/internal/requestlog/usage_query_test.go index ef2be3ec..54a935b3 100644 --- a/internal/requestlog/usage_query_test.go +++ b/internal/requestlog/usage_query_test.go @@ -93,7 +93,7 @@ func TestQueryUsageExcludesLegacyZeroAttemptAggregates(t *testing.T) { report, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err != nil { @@ -132,7 +132,7 @@ func TestQueryUsageScopesAccessKeyAndDistributesByModel(t *testing.T) { accessKeyID := uint(41) report, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, AccessKeyID: &accessKeyID, SelfScoped: true, @@ -164,7 +164,7 @@ func TestQueryUsageRejectsZeroAccessKeyScope(t *testing.T) { _, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, AccessKeyID: &zero, }) @@ -193,7 +193,7 @@ func TestQueryUsageDistributionAggregatesCredentialsAndFoldsRemainder(t *testing report, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err != nil { @@ -251,7 +251,7 @@ func TestQueryUsageGroupDistributionKeepsOnlyPersistedGroupsInTopFive(t *testing } { report, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err != nil { @@ -317,7 +317,7 @@ func TestQueryUsageAccessKeyTokenDistributionKeepsOnlyPersistedKeys(t *testing.T report, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err != nil { @@ -361,7 +361,7 @@ func TestQueryUsageModelDistributionAggregatesGroupsAndCredentials(t *testing.T) report, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err != nil { @@ -527,7 +527,7 @@ func TestQueryUsageOrdersDistributionByRequestsAndCost(t *testing.T) { t.Run(test.name, func(t *testing.T) { report, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err != nil { @@ -591,7 +591,7 @@ func TestQueryUsageUsesOneReadSnapshot(t *testing.T) { report, err := service.QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err != nil { @@ -631,7 +631,7 @@ func TestQueryUsageCancelsAfterBeginWithoutPoisoningDatabaseConnection(t *testin _, err := service.QueryUsage(ctx, UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err == nil || !cancelled { @@ -687,7 +687,7 @@ func TestQueryUsageRejectsCorruptRowsOutsideTopDistribution(t *testing.T) { input := UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, } summary, err := queryUsageSummary(usageStatScope(db, input)) @@ -813,7 +813,7 @@ func TestQueryUsageRejectsCorruptAggregates(t *testing.T) { createCorruptUsageStats(t, db, test.rows...) _, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{ FromMS: start.UnixMilli(), - ToMS: start.Add(2 * time.Hour).UnixMilli(), + ToMS: start.Add(7 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, }) if err == nil { diff --git a/internal/requestlog/usage_query_window_test.go b/internal/requestlog/usage_query_window_test.go index 2bf7a0ed..48bf6466 100644 --- a/internal/requestlog/usage_query_window_test.go +++ b/internal/requestlog/usage_query_window_test.go @@ -62,6 +62,54 @@ func TestQueryUsageExactWindowBoundaries(t *testing.T) { } } +func TestQueryUsagePreservesCompletionTimeBuckets(t *testing.T) { + base := time.Date(2026, time.September, 18, 9, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + from time.Duration + span time.Duration + completed time.Duration + width time.Duration + }{ + {"one hour", 0, time.Hour, 37 * time.Minute, 5 * time.Minute}, + {"over one hour", 0, time.Hour + time.Millisecond, 37 * time.Minute, 5 * time.Minute}, + {"three hours", 0, 3 * time.Hour, 37 * time.Minute, 5 * time.Minute}, + {"six hours", 0, 6 * time.Hour, 37 * time.Minute, 5 * time.Minute}, + {"rolling three hours", 17 * time.Minute, 3 * time.Hour, 97 * time.Minute, 5 * time.Minute}, + {"over six hours", 0, 6*time.Hour + time.Millisecond, 37 * time.Minute, time.Hour}, + {"one day", 0, 24 * time.Hour, 37 * time.Minute, time.Hour}, + } { + t.Run(test.name, func(t *testing.T) { + db := openRequestLogQueryDB(t) + from, completed := base.Add(test.from), base.Add(test.completed) + row := aggregationRow(aggregationRequestID(9918), completed, 7, "model") + if err := (&gormBatchWriter{db: db}).WriteBatch(t.Context(), []models.RequestLog{row}); err != nil { + t.Fatal(err) + } + report, err := newRequestLogTestService(db).QueryUsage(t.Context(), UsageQuery{ + FromMS: from.UnixMilli(), ToMS: from.Add(test.span).UnixMilli(), + }) + if err != nil { + t.Fatal(err) + } + if report.Summary.RequestCount != 1 || report.Summary.UncachedInputTokens != row.UncachedInputTokens || + report.Summary.OutputTokens != row.OutputTokens || report.Summary.EstimatedCostNanoUSD != row.EstimatedCostNanoUSD || + len(report.Series) != 1 { + t.Fatalf("usage totals changed: summary=%+v buckets=%d", report.Summary, len(report.Series)) + } + start := completed.Truncate(test.width) + point := report.Series[0] + if point.BucketStartMS != start.UnixMilli() || point.BucketEndMS != start.Add(test.width).UnixMilli() { + t.Fatalf("completion %s mapped to %s-%s, want %s-%s", completed.Format(time.RFC3339), + time.UnixMilli(point.BucketStartMS).UTC().Format(time.RFC3339), + time.UnixMilli(point.BucketEndMS).UTC().Format(time.RFC3339), + start.Format(time.RFC3339), start.Add(test.width).Format(time.RFC3339)) + } + assertMinuteUsageReportTotals(t, report) + }) + } +} + func TestResolveUsageTimeBucket(t *testing.T) { hour, day := epochms.MillisecondsPerHour, epochms.MillisecondsPerDay for _, test := range []struct { @@ -145,7 +193,7 @@ func TestQueryUsageMergesSourcesBeforeRankingEveryDimension(t *testing.T) { } } report, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{ - FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(90 * time.Minute).UnixMilli(), + FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(7*time.Hour + 30*time.Minute).UnixMilli(), }) if err != nil { t.Fatal(err) @@ -186,7 +234,7 @@ func TestQueryUsageMixedSourcesApplyAllFilters(t *testing.T) { stat.Model = "other" } createUsageStats(t, db, stat) - for _, offset := range []time.Duration{-time.Minute, 61 * time.Minute} { + for _, offset := range []time.Duration{-time.Minute, 7*time.Hour + time.Minute} { row := aggregationRow(fmt.Sprintf("filter-%d-%d", index, offset), start.Add(offset), stat.GroupID, stat.Model) row.AccessKeyID, row.CredentialID, row.ChannelID = stat.AccessKeyID, stat.CredentialID, stat.ChannelID if err := db.Create(&row).Error; err != nil { @@ -196,7 +244,7 @@ func TestQueryUsageMixedSourcesApplyAllFilters(t *testing.T) { } groupID, credentialID, accessKeyID := uint(7), uint(11), uint(41) report, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{ - FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(90 * time.Minute).UnixMilli(), + FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(7*time.Hour + 30*time.Minute).UnixMilli(), GroupID: &groupID, CredentialID: &credentialID, AccessKeyID: &accessKeyID, ChannelID: channel.OpenAI, UpstreamModel: "model", }) if err != nil || report.Summary.RequestCount != 3 { @@ -295,7 +343,7 @@ func TestQueryUsageMixedSourcesRejectCorruptRowsBeforeCombining(t *testing.T) { t.Fatal(err) } if _, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{ - FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(90 * time.Minute).UnixMilli(), + FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(7*time.Hour + 30*time.Minute).UnixMilli(), }); err == nil { t.Fatal("corrupt source accepted after other rows compensated its totals") } diff --git a/web/src/frontends/classic/app/resources/channels.ts b/web/src/frontends/classic/app/resources/channels.ts index 1afd3108..3417afea 100644 --- a/web/src/frontends/classic/app/resources/channels.ts +++ b/web/src/frontends/classic/app/resources/channels.ts @@ -52,6 +52,7 @@ export type ChannelOperation = | 'responses_input_items' | 'responses_compact' | 'responses_input_tokens' + | 'web_search' | 'count_tokens' | 'responses_passthrough' | 'images_generate' @@ -158,6 +159,7 @@ const operations = [ 'responses_input_items', 'responses_compact', 'responses_input_tokens', + 'web_search', 'count_tokens', 'responses_passthrough', 'images_generate', diff --git a/web/src/frontends/classic/app/resources/home.ts b/web/src/frontends/classic/app/resources/home.ts index 389edc9b..25bb8df8 100644 --- a/web/src/frontends/classic/app/resources/home.ts +++ b/web/src/frontends/classic/app/resources/home.ts @@ -191,6 +191,8 @@ const subscriptionAccountFields = [ 'channel_icon', 'capabilities', 'group_count', + 'group_id', + 'credential_key', 'available_group_count', 'credential', ] as const diff --git a/web/src/frontends/classic/app/resources/request-logs.ts b/web/src/frontends/classic/app/resources/request-logs.ts index ec59cc37..2453150f 100644 --- a/web/src/frontends/classic/app/resources/request-logs.ts +++ b/web/src/frontends/classic/app/resources/request-logs.ts @@ -50,6 +50,7 @@ export type RequestLogOperation = | 'responses_input_items' | 'responses_compact' | 'responses_input_tokens' + | 'web_search' | 'count_tokens' | 'responses_passthrough' | 'images_generate' @@ -250,6 +251,7 @@ const operations = [ 'responses_input_items', 'responses_compact', 'responses_input_tokens', + 'web_search', 'count_tokens', 'responses_passthrough', 'images_generate',