fix(ui): restore classic contracts and usage trend buckets (#687)

* fix(ui): 修复新旧界面的共享接口兼容回归

* fix(usage): 修正五分钟趋势的请求时间归桶
This commit is contained in:
tbphp authored and GitHub committed 2026-09-18 12:46:18 +08:00
1 parent 37cd99fb01
commit f6e3a15c40
10 files changed
+324 -33

No files matched your search

@@ -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
}
+3 -3
View File
@@ -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,
@@ -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()
+4
View File
@@ -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
}
+20 -14
View File
@@ -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)
}
})
}
}
+12 -12
View File
@@ -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 {
+52 -4
View File
@@ -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")
}
@@ -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',
@@ -191,6 +191,8 @@ const subscriptionAccountFields = [
'channel_icon',
'capabilities',
'group_count',
'group_id',
'credential_key',
'available_group_count',
'credential',
] as const
@@ -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',