fix(ui): restore classic contracts and usage trend buckets (#687)
* fix(ui): 修复新旧界面的共享接口兼容回归 * fix(usage): 修正五分钟趋势的请求时间归桶
This commit is contained in:
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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user