diff --git a/README.md b/README.md index 9a0cac25..afaed6cf 100644 --- a/README.md +++ b/README.md @@ -264,13 +264,7 @@ make build Full local quality gates: ```console -corepack pnpm --dir web install --frozen-lockfile -corepack pnpm --dir web run lint -corepack pnpm --dir web run format -corepack pnpm --dir web run type-check -corepack pnpm --dir web run build -go build -o gpt-load . -go test . ./internal/... +make check ``` Frontend unit tests and browser E2E tests are not part of the project workflow. Frontend verification consists of dependency installation, linting, formatting, type-checking, and building. diff --git a/README_CN.md b/README_CN.md index 394b5f9f..614380d7 100644 --- a/README_CN.md +++ b/README_CN.md @@ -263,13 +263,7 @@ make build 完整的本地质量门禁: ```console -corepack pnpm --dir web install --frozen-lockfile -corepack pnpm --dir web run lint -corepack pnpm --dir web run format -corepack pnpm --dir web run type-check -corepack pnpm --dir web run build -go build -o gpt-load . -go test . ./internal/... +make check ``` 项目工作流不包含前端单元测试或浏览器 E2E;前端验证范围为依赖安装、lint、format、type-check 与 build。 diff --git a/README_JP.md b/README_JP.md index 85072345..0bc105a3 100644 --- a/README_JP.md +++ b/README_JP.md @@ -264,13 +264,7 @@ make build ローカルの完全な品質ゲート: ```console -corepack pnpm --dir web install --frozen-lockfile -corepack pnpm --dir web run lint -corepack pnpm --dir web run format -corepack pnpm --dir web run type-check -corepack pnpm --dir web run build -go build -o gpt-load . -go test . ./internal/... +make check ``` プロジェクトのワークフローには、フロントエンドのユニットテストとブラウザE2Eテストを含めません。フロントエンドの検証範囲は、依存関係のインストール、lint、format、type-check、buildです。 diff --git a/internal/app/app_test.go b/internal/app/app_test.go index ea417101..736b6816 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -370,10 +370,11 @@ func TestAppStartUsesEncryptionForSchemaV1UpgradeBeforeRuntimeLoad(t *testing.T) t.Fatalf("Encrypt() error = %v", err) } if err := db.Exec(`INSERT INTO access_keys - (name,key_value,key_hash,status,filters,rpm_limit,daily_cost_limit,monthly_cost_limit) - VALUES (?,?,?,?,?,?,?,?)`, + (name,key_value,key_hash,status,filters,rpm_limit,daily_cost_limit,monthly_cost_limit,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, "legacy", ciphertext, keyService.Hash(plaintext), "active", `{"groups":[],"protocols":[],"models":[]}`, 0, 0, 0, + "2026-07-30T00:00:00Z", "2026-07-30T00:00:00Z", ).Error; err != nil { t.Fatalf("insert legacy AccessKey: %v", err) } diff --git a/internal/container/container.go b/internal/container/container.go index c8ecb82d..e642b386 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -69,6 +69,9 @@ func BuildContainer() (*dig.Container, error) { func(service *requestlog.Service) control.UsageStatReader { return service }, + func(service *requestlog.Service) control.HomeStatisticsReader { + return service + }, func(service *requestlog.Service) control.RequestLogStatsReader { return service }, diff --git a/internal/container/container_test.go b/internal/container/container_test.go index f625aef8..d30f0944 100644 --- a/internal/container/container_test.go +++ b/internal/container/container_test.go @@ -125,6 +125,42 @@ func TestBuildContainerWiresUsageReaderToSingletonRequestLogService(t *testing.T } } +func TestBuildContainerWiresHomeStatisticsReaderToSingletonRequestLogService( + t *testing.T, +) { + t.Setenv("AUTH_KEY", "test-auth-key") + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("DATABASE_DSN", ":memory:") + t.Setenv("ENCRYPTION_KEY", "test-master-key-long") + + dependencyContainer, err := BuildContainer() + if err != nil { + t.Fatalf("BuildContainer() error = %v", err) + } + err = dependencyContainer.Invoke(func( + service *requestlog.Service, + usageReader control.UsageStatReader, + homeReader control.HomeStatisticsReader, + db *gorm.DB, + ) { + sqlDB, dbErr := db.DB() + if dbErr == nil { + t.Cleanup(func() { _ = sqlDB.Close() }) + } + if usageReader != service || homeReader != service { + t.Fatalf( + "statistics readers = %T/%T, want singleton %p", + usageReader, + homeReader, + service, + ) + } + }) + if err != nil { + t.Fatalf("resolve Home statistics reader graph: %v", err) + } +} + func TestBuildContainerWiresSingletonPriceRuntime(t *testing.T) { t.Setenv("AUTH_KEY", "test-auth-key") t.Setenv("DATA_DIR", t.TempDir()) diff --git a/internal/control/access_key_idempotency.go b/internal/control/access_key_idempotency.go index 1393d92a..deb30e58 100644 --- a/internal/control/access_key_idempotency.go +++ b/internal/control/access_key_idempotency.go @@ -77,7 +77,7 @@ func (s *Service) CreateAccessKeyIdempotent( metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{ ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix, Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit, - CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + CreatedAtMS: row.CreatedAtMS, UpdatedAtMS: row.UpdatedAtMS, }) if err != nil { return idempotentMutationResult{}, err diff --git a/internal/control/access_keys.go b/internal/control/access_keys.go index 612eca21..7328963e 100644 --- a/internal/control/access_keys.go +++ b/internal/control/access_keys.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "strings" - "time" "gorm.io/gorm" @@ -57,14 +56,14 @@ type AccessKeyUpdateRequest struct { } type AccessKeyMetadata struct { - ID uint `json:"id"` - Name string `json:"name"` - MaskedKey string `json:"masked_key"` - Status state.AccessKeyStatus `json:"status"` - Filters AccessKeyFilters `json:"filters"` - RPMLimit int64 `json:"rpm_limit"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + Name string `json:"name"` + MaskedKey string `json:"masked_key"` + Status state.AccessKeyStatus `json:"status"` + Filters AccessKeyFilters `json:"filters"` + RPMLimit int64 `json:"rpm_limit"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` } type AccessKeyCreateResult struct { @@ -80,20 +79,20 @@ type AccessKeyOption struct { } type AccessKeyRevealResult struct { - ID uint `json:"id"` - Key string `json:"key"` - RevealedAt time.Time `json:"revealed_at"` + ID uint `json:"id"` + Key string `json:"key"` + RevealedAtMS int64 `json:"revealed_at_ms"` } type accessKeyMetadataRow struct { - ID uint - Name string - KeySuffix string - Status string - Filters models.JSON - RPMLimit int64 - CreatedAt time.Time - UpdatedAt time.Time + ID uint + Name string + KeySuffix string + Status string + Filters models.JSON + RPMLimit int64 + CreatedAtMS int64 + UpdatedAtMS int64 } const accessKeyPrefix = "sk-gl-" @@ -159,7 +158,7 @@ func (s *Service) CreateAccessKey( metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{ ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix, Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit, - CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + CreatedAtMS: row.CreatedAtMS, UpdatedAtMS: row.UpdatedAtMS, }) if err != nil { return err @@ -182,7 +181,7 @@ func (s *Service) ListAccessKeys(ctx context.Context) ([]AccessKeyMetadata, erro Model(&models.AccessKey{}). Select( "id", "name", "key_suffix", "status", "filters", "rpm_limit", - "created_at", "updated_at", + "created_at_ms", "updated_at_ms", ). Order("id ASC"). Scan(&rows).Error; err != nil { @@ -246,7 +245,7 @@ func (s *Service) UpdateAccessKey( if err := tx.Model(&models.AccessKey{}). Select( "id", "name", "key_suffix", "status", "filters", "rpm_limit", - "created_at", "updated_at", + "created_at_ms", "updated_at_ms", ). Where("id = ?", id). Take(&row).Error; err != nil { @@ -302,7 +301,7 @@ func (s *Service) UpdateAccessKey( if err := tx.Model(&models.AccessKey{}). Select( "id", "name", "key_suffix", "status", "filters", "rpm_limit", - "created_at", "updated_at", + "created_at_ms", "updated_at_ms", ). Where("id = ?", row.ID). Take(&row).Error; err != nil { @@ -365,14 +364,32 @@ func (s *Service) RevealAccessKey( app_errors.ErrInternalServer, ) } + revealedAtMS, err := safeEpochMilliseconds(s.now()) + if err != nil { + return AccessKeyRevealResult{}, app_errors.ErrInternalServer + } return AccessKeyRevealResult{ - ID: row.ID, - Key: plaintext, - RevealedAt: s.now().UTC(), + ID: row.ID, + Key: plaintext, + RevealedAtMS: revealedAtMS, }, nil } func mapAccessKeyMetadataRow(row accessKeyMetadataRow) (AccessKeyMetadata, error) { + if err := validateSafeMilliseconds(row.CreatedAtMS); err != nil { + return AccessKeyMetadata{}, fmt.Errorf( + "access key %d has invalid created_at_ms: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + if err := validateSafeMilliseconds(row.UpdatedAtMS); err != nil { + return AccessKeyMetadata{}, fmt.Errorf( + "access key %d has invalid updated_at_ms: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } status := state.AccessKeyStatus(row.Status) if status != state.AccessKeyStatusActive && status != state.AccessKeyStatusDisabled { return AccessKeyMetadata{}, fmt.Errorf( @@ -398,12 +415,16 @@ func mapAccessKeyMetadataRow(row accessKeyMetadataRow) (AccessKeyMetadata, error } return AccessKeyMetadata{ ID: row.ID, Name: row.Name, - MaskedKey: accessKeyPrefix + "••••••••" + row.KeySuffix, + MaskedKey: maskedAccessKey(row.KeySuffix), Status: status, Filters: filters, RPMLimit: row.RPMLimit, - CreatedAt: row.CreatedAt.UTC(), UpdatedAt: row.UpdatedAt.UTC(), + CreatedAtMS: row.CreatedAtMS, UpdatedAtMS: row.UpdatedAtMS, }, nil } +func maskedAccessKey(suffix string) string { + return accessKeyPrefix + "****" + suffix +} + func validAccessKeySuffix(value string) bool { if len(value) != 4 { return false diff --git a/internal/control/access_keys_phase1_test.go b/internal/control/access_keys_phase1_test.go index 5e1e53fd..0029829f 100644 --- a/internal/control/access_keys_phase1_test.go +++ b/internal/control/access_keys_phase1_test.go @@ -48,7 +48,7 @@ func TestAccessKeyMetadataListAndUpdateNeverDecryptCiphertext(t *testing.T) { t.Fatalf("ListAccessKeys() error = %v", err) } if len(list) != 1 || list[0].ID != created.ID || - list[0].MaskedKey != "sk-gl-••••••••0000" { + list[0].MaskedKey != "sk-gl-****0000" { t.Fatalf("ListAccessKeys() = %#v", list) } encoded, err := json.Marshal(list) @@ -175,7 +175,7 @@ func TestRevealAccessKeyIsTheExplicitMetadataDecryptPath(t *testing.T) { t.Fatalf("RevealAccessKey() error = %v", err) } if revealed.ID != created.ID || revealed.Key != created.Key || - revealed.RevealedAt.IsZero() { + revealed.RevealedAtMS <= 0 { t.Fatalf("RevealAccessKey() = %#v", revealed) } if spy.decryptCalls != 1 { diff --git a/internal/control/access_keys_test.go b/internal/control/access_keys_test.go index c5c05ae4..a5085342 100644 --- a/internal/control/access_keys_test.go +++ b/internal/control/access_keys_test.go @@ -225,9 +225,9 @@ func TestListAccessKeysReturnsMaskedMetadataInIDOrderWithoutDecrypting(t *testin } if len(listed) != 2 || listed[0].ID != first.ID || - listed[0].MaskedKey != "sk-gl-••••••••0e0f" || + listed[0].MaskedKey != "sk-gl-****0e0f" || listed[1].ID != second.ID || - listed[1].MaskedKey != "sk-gl-••••••••1e1f" { + listed[1].MaskedKey != "sk-gl-****1e1f" { t.Fatalf("ListAccessKeys() = %#v", listed) } diff --git a/internal/control/bootstrap_test.go b/internal/control/bootstrap_test.go index 9776ce47..84b100de 100644 --- a/internal/control/bootstrap_test.go +++ b/internal/control/bootstrap_test.go @@ -35,7 +35,7 @@ func TestEnsureInitialStatePublishesBuiltinPricesWithAndWithoutMarker(t *testing t.Fatalf("EnsureInitialState() error = %v", err) } - assertPublishedPrice(t, fixture.priceRuntime, "gpt-4o", pricing.SourceBuiltin, 2.5) + assertPublishedPrice(t, fixture.priceRuntime, "gpt-4o", pricing.SourceBuiltin, 2_500_000_000) if got := fixture.manager.Current().Revision; got != beforeRevision { t.Fatalf("ConfigSnapshot revision = %d, want unchanged %d", got, beforeRevision) } @@ -44,15 +44,18 @@ func TestEnsureInitialStatePublishesBuiltinPricesWithAndWithoutMarker(t *testing t.Run("existing marker and user row", func(t *testing.T) { fixture := newServiceFixture(t) - zero := 0.0 - output := 12.0 + zero := int64(0) + output := int64(12_000_000_000) if err := fixture.db.Create(&models.SystemSetting{ Key: bootstrapMarkerForTest, Value: "true", }).Error; err != nil { t.Fatalf("create marker: %v", err) } if err := fixture.db.Create(&models.ModelPrice{ - Pattern: "gpt-*", InputPrice: &zero, OutputPrice: &output, Source: "user", + Pattern: "gpt-*", + InputPriceNanoUSDPerMillionTokens: &zero, + OutputPriceNanoUSDPerMillionTokens: &output, + Source: "user", }).Error; err != nil { t.Fatalf("create user ModelPrice: %v", err) } @@ -84,7 +87,7 @@ func TestEnsureInitialStatePublishesBuiltinPricesWithAndWithoutMarker(t *testing t.Fatalf("second EnsureInitialState() error = %v", err) } - assertPublishedPrice(t, second.priceRuntime, "gpt-4o", pricing.SourceBuiltin, 2.5) + assertPublishedPrice(t, second.priceRuntime, "gpt-4o", pricing.SourceBuiltin, 2_500_000_000) if got := second.manager.Current().Revision; got != beforeRevision { t.Fatalf("ConfigSnapshot revision = %d, want unchanged %d", got, beforeRevision) } @@ -93,9 +96,11 @@ func TestEnsureInitialStatePublishesBuiltinPricesWithAndWithoutMarker(t *testing func TestEnsureInitialStateRejectsInvalidPersistedModelPrice(t *testing.T) { fixture := newServiceFixture(t) - negative := -1.0 + value := int64(1_000_000_000) if err := fixture.db.Create(&models.ModelPrice{ - Pattern: "invalid-price", InputPrice: &negative, Source: "user", + Pattern: "invalid?pattern", + InputPriceNanoUSDPerMillionTokens: &value, + Source: "user", }).Error; err != nil { t.Fatalf("create invalid persisted ModelPrice: %v", err) } @@ -128,9 +133,21 @@ func TestEnsureInitialStateRejectsNonUserPersistedModelPrice(t *testing.T) { } }) if err := fixture.db.Exec(` - INSERT INTO model_prices (pattern, output_price, source) - VALUES (?, ?, ?) - `, privatePattern, 73456.125, privateSource).Error; err != nil { + INSERT INTO model_prices ( + pattern, + output_price_nano_usd_per_million_tokens, + source, + created_at_ms, + updated_at_ms + ) + VALUES (?, ?, ?, ?, ?) + `, + privatePattern, + int64(73_456_125_000_000), + privateSource, + time.Now().UnixMilli(), + time.Now().UnixMilli(), + ).Error; err != nil { t.Fatalf("insert invalid persisted ModelPrice source: %v", err) } if err := fixture.db.Exec("PRAGMA ignore_check_constraints = OFF").Error; err != nil { @@ -141,7 +158,7 @@ func TestEnsureInitialStateRejectsNonUserPersistedModelPrice(t *testing.T) { if !errors.Is(err, app_errors.ErrInternalServer) { t.Fatalf("EnsureInitialState() error = %v, want internal error", err) } - for _, privateValue := range []string{privatePattern, privateSource, "73456.125"} { + for _, privateValue := range []string{privatePattern, privateSource, "73456125000000"} { if strings.Contains(err.Error(), privateValue) { t.Fatalf("EnsureInitialState() error leaked persisted value %q: %v", privateValue, err) } @@ -428,7 +445,7 @@ func assertPublishedPrice( runtime *PriceRuntime, model string, wantSource pricing.Source, - wantInput float64, + wantInput pricing.NanoUSD, ) { t.Helper() table := runtime.Load() @@ -440,7 +457,7 @@ func assertPublishedPrice( t.Fatalf("PriceTable.Match(%q) missed", model) } if rule.Source != wantSource || !rule.Prices.UncachedInput.Set || - rule.Prices.UncachedInput.Value != wantInput { + rule.Prices.UncachedInput.NanoUSDPerMillion != wantInput { t.Fatalf("PriceTable.Match(%q) = %+v", model, rule) } } diff --git a/internal/control/group_delete_test.go b/internal/control/group_delete_test.go index 710f7eb9..aff6d9c2 100644 --- a/internal/control/group_delete_test.go +++ b/internal/control/group_delete_test.go @@ -238,14 +238,15 @@ func TestDeleteGroupAcceptsRegistryAlreadyAtTargetStateAndRetainsHistory(t *test fixture.stats.RecordFailure(key.ID, health.FailureCategoryAmbiguous, 0, now) beforeStats := fixture.stats.Snapshot(key.ID, now) if err := fixture.db.Create(&models.RequestLog{ - ID: "delete-history-log", CreatedAt: now, AccessKeyID: 1, + ID: "delete-history-log", CompletedAtMS: now.UnixMilli(), AccessKeyID: 1, Protocol: "openai-completions", ClientModel: "model", UpstreamModel: "model", Status: "success", StatusCode: http.StatusOK, }).Error; err != nil { t.Fatal(err) } if err := fixture.db.Create(&models.UsageStat{ - HourBucket: now, GroupID: groupID, Model: "model", RequestCount: 1, + BucketStartMS: now.UnixMilli(), AccessKeyID: 1, + GroupID: groupID, Model: "model", RequestCount: 1, SuccessCount: 1, }).Error; err != nil { t.Fatal(err) } diff --git a/internal/control/health.go b/internal/control/health.go index 5d61bdae..9a0d5b53 100644 --- a/internal/control/health.go +++ b/internal/control/health.go @@ -36,9 +36,9 @@ type healthGroupResponse struct { } type healthRecoveryResponse struct { - Automatic bool `json:"automatic"` - Mode string `json:"mode"` - At *time.Time `json:"at"` + Automatic bool `json:"automatic"` + Mode string `json:"mode"` + AtMS *int64 `json:"at_ms"` } type healthProblemKeyResponse struct { @@ -48,7 +48,7 @@ type healthProblemKeyResponse struct { Mask string `json:"mask"` LastFailureCategory string `json:"last_failure_category"` LastStatusCode *int `json:"last_status_code"` - CooldownUntil *time.Time `json:"cooldown_until,omitempty"` + CooldownUntilMS *int64 `json:"cooldown_until_ms"` FailureCount int `json:"failure_count"` RecentSuccessCount uint64 `json:"recent_success_count"` RecentFailureCount uint64 `json:"recent_failure_count"` @@ -59,24 +59,24 @@ type healthProblemKeyResponse struct { } type requestLogHealthResponse struct { - EnqueuedTotal uint64 `json:"enqueued_total"` - PersistedTotal uint64 `json:"persisted_total"` - DroppedNotRunningTotal uint64 `json:"dropped_not_running_total"` - DroppedQueueFullTotal uint64 `json:"dropped_queue_full_total"` - DroppedStoppingTotal uint64 `json:"dropped_stopping_total"` - DroppedPersistFailedTotal uint64 `json:"dropped_persist_failed_total"` - DroppedShutdownTotal uint64 `json:"dropped_shutdown_total"` - DroppedTotal uint64 `json:"dropped_total"` - WriteFailureTotal uint64 `json:"write_failure_total"` - RetentionDeleteFailureTotal uint64 `json:"retention_delete_failure_total"` - QueueDepth int `json:"queue_depth"` - QueueCapacity int `json:"queue_capacity"` - LastWriteFailureAt *time.Time `json:"last_write_failure_at"` - LastRetentionFailureAt *time.Time `json:"last_retention_failure_at"` + EnqueuedTotal uint64 `json:"enqueued_total"` + PersistedTotal uint64 `json:"persisted_total"` + DroppedNotRunningTotal uint64 `json:"dropped_not_running_total"` + DroppedQueueFullTotal uint64 `json:"dropped_queue_full_total"` + DroppedStoppingTotal uint64 `json:"dropped_stopping_total"` + DroppedPersistFailedTotal uint64 `json:"dropped_persist_failed_total"` + DroppedShutdownTotal uint64 `json:"dropped_shutdown_total"` + DroppedTotal uint64 `json:"dropped_total"` + WriteFailureTotal uint64 `json:"write_failure_total"` + RetentionDeleteFailureTotal uint64 `json:"retention_delete_failure_total"` + QueueDepth int `json:"queue_depth"` + QueueCapacity int `json:"queue_capacity"` + LastWriteFailureAtMS *int64 `json:"last_write_failure_at_ms"` + LastRetentionFailureAtMS *int64 `json:"last_retention_failure_at_ms"` } type runtimeHealthResponse struct { - ObservedAt time.Time `json:"observed_at"` + ObservedAtMS int64 `json:"observed_at_ms"` Version string `json:"version"` UptimeSeconds int64 `json:"uptime_seconds"` SnapshotRevision uint64 `json:"snapshot_revision"` @@ -132,14 +132,6 @@ func addHealthCount(counts *healthCountsResponse, bucket healthBucket) { } } -func optionalUTC(value time.Time) *time.Time { - if value.IsZero() { - return nil - } - value = value.UTC() - return &value -} - func cloneInt(value *int) *int { if value == nil { return nil @@ -205,8 +197,12 @@ func (service *Service) RuntimeHealth() (runtimeHealthResponse, error) { app_errors.ErrInternalServer, ) } + observedAtMS, err := safeEpochMilliseconds(observation.observedAt) + if err != nil { + return runtimeHealthResponse{}, fmt.Errorf("map runtime health observed_at_ms: %w", err) + } result := runtimeHealthResponse{ - ObservedAt: observation.observedAt, + ObservedAtMS: observedAtMS, SnapshotRevision: observation.snapshot.Revision, StatsWindowSeconds: int64(health.StatsWindow / time.Second), Groups: []healthGroupResponse{}, @@ -261,11 +257,18 @@ func (service *Service) RuntimeHealth() (runtimeHealthResponse, error) { WeightAuto: key.WeightAuto, } if bucket == healthBucketCooldown { - detail.CooldownUntil = optionalUTC(key.CooldownUntil) + cooldownUntilMS, err := optionalSafeEpochMilliseconds(key.CooldownUntil) + if err != nil { + return runtimeHealthResponse{}, fmt.Errorf( + "map runtime health cooldown_until_ms: %w", + err, + ) + } + detail.CooldownUntilMS = cooldownUntilMS detail.Recovery = healthRecoveryResponse{ Automatic: true, Mode: "cooldown_expiry", - At: optionalUTC(key.CooldownUntil), + AtMS: cooldownUntilMS, } result.CooldownKeys = append(result.CooldownKeys, detail) } else { @@ -305,6 +308,14 @@ func mapRequestLogHealth(stats requestlog.Stats) (requestLogHealthResponse, erro stats.QueueCapacity < 0 || uint64(stats.QueueCapacity) > uint64(maxSafeInteger) { return requestLogHealthResponse{}, fmt.Errorf("map request log health: unsafe queue") } + lastWriteFailureAtMS, err := optionalSafeEpochMilliseconds(stats.LastWriteFailureAt) + if err != nil { + return requestLogHealthResponse{}, fmt.Errorf("map last write failure timestamp: %w", err) + } + lastRetentionFailureAtMS, err := optionalSafeEpochMilliseconds(stats.LastRetentionFailureAt) + if err != nil { + return requestLogHealthResponse{}, fmt.Errorf("map last retention failure timestamp: %w", err) + } return requestLogHealthResponse{ EnqueuedTotal: stats.EnqueuedTotal, PersistedTotal: stats.PersistedTotal, @@ -318,8 +329,8 @@ func mapRequestLogHealth(stats requestlog.Stats) (requestLogHealthResponse, erro RetentionDeleteFailureTotal: stats.RetentionDeleteFailureTotal, QueueDepth: stats.QueueDepth, QueueCapacity: stats.QueueCapacity, - LastWriteFailureAt: optionalUTC(stats.LastWriteFailureAt), - LastRetentionFailureAt: optionalUTC(stats.LastRetentionFailureAt), + LastWriteFailureAtMS: lastWriteFailureAtMS, + LastRetentionFailureAtMS: lastRetentionFailureAtMS, }, nil } diff --git a/internal/control/health_test.go b/internal/control/health_test.go index 195728fa..21044e8c 100644 --- a/internal/control/health_test.go +++ b/internal/control/health_test.go @@ -106,7 +106,7 @@ func TestRuntimeHealthReturnsMutuallyExclusiveCurrentState(t *testing.T) { if err != nil { t.Fatalf("RuntimeHealth() error = %v", err) } - if !got.ObservedAt.Equal(now) || got.SnapshotRevision != fixture.manager.Current().Revision || + if got.ObservedAtMS != now.UnixMilli() || got.SnapshotRevision != fixture.manager.Current().Revision || got.StatsWindowSeconds != 300 { t.Fatalf("observation metadata = %#v", got) } @@ -147,12 +147,13 @@ func TestRuntimeHealthReturnsMutuallyExclusiveCurrentState(t *testing.T) { *got.BlacklistedKeys[0].LastStatusCode != 401 || got.BlacklistedKeys[0].ConsecutiveFailureCount != 1 || got.BlacklistedKeys[0].Recovery.Mode != "validation_probe" || - got.BlacklistedKeys[0].Recovery.At != nil { + got.BlacklistedKeys[0].Recovery.AtMS != nil { t.Fatalf("blacklisted details = %#v", got.BlacklistedKeys) } if got.RequestLog.DroppedTotal != 2 || - got.RequestLog.LastWriteFailureAt == nil || - got.RequestLog.LastRetentionFailureAt != nil { + got.RequestLog.LastWriteFailureAtMS == nil || + *got.RequestLog.LastWriteFailureAtMS != now.Add(-time.Minute).UnixMilli() || + got.RequestLog.LastRetentionFailureAtMS != nil { t.Fatalf("request log stats = %#v", got.RequestLog) } } @@ -285,8 +286,8 @@ func TestRuntimeHealthJSONOmitsScoresCredentialsAndZeroTimes(t *testing.T) { "retention_delete_failure_total": {}, "queue_depth": {}, "queue_capacity": {}, - "last_write_failure_at": {}, - "last_retention_failure_at": {}, + "last_write_failure_at_ms": {}, + "last_retention_failure_at_ms": {}, } if !reflect.DeepEqual(requestLogFields, wantRequestLogFields) { t.Fatalf("request_log fields = %#v, want %#v", requestLogFields, wantRequestLogFields) @@ -422,7 +423,7 @@ func TestRuntimeHealthEndpointRequiresManagementAuthentication(t *testing.T) { if err := json.Unmarshal(success.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode authenticated response: %v", err) } - if envelope.Code != 0 || !envelope.Data.ObservedAt.Equal(healthNow()) || + if envelope.Code != 0 || envelope.Data.ObservedAtMS != healthNow().UnixMilli() || envelope.Data.StatsWindowSeconds != 300 { t.Fatalf("authenticated envelope = %#v", envelope) } diff --git a/internal/control/home.go b/internal/control/home.go new file mode 100644 index 00000000..a747cad4 --- /dev/null +++ b/internal/control/home.go @@ -0,0 +1,357 @@ +package control + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "gpt-load/internal/platform/epochms" + app_errors "gpt-load/internal/platform/errors" + "gpt-load/internal/platform/response" + "gpt-load/internal/platform/version" + "gpt-load/internal/protocol" + "gpt-load/internal/state" + "gpt-load/internal/storage/models" +) + +type HomeInventory struct { + GroupCount int64 `json:"group_count"` + UpstreamKeyCount int64 `json:"upstream_key_count"` + AvailableUpstreamKeyCount int64 `json:"available_upstream_key_count"` + ModelCount int64 `json:"model_count"` +} + +type HomeAccessKey struct { + ID uint `json:"id"` + Name string `json:"name"` + MaskedKey string `json:"masked_key"` + Protocols []protocol.Protocol `json:"protocols"` +} + +type HomeBase struct { + Inventory HomeInventory `json:"inventory"` + AccessKeys []HomeAccessKey `json:"access_keys"` +} + +type homeResponse struct { + ServerNowMS int64 `json:"server_now_ms"` + StartedAtMS int64 `json:"started_at_ms"` + Version string `json:"version"` + HomeBase +} + +type homeUpstreamKeyRow struct { + ID uint + GroupID uint + Status models.UpstreamKeyStatus +} + +type homeAccessKeyRow struct { + ID uint + Name string + KeySuffix string + Filters models.JSON +} + +type homeReadRows struct { + groupCount int64 + upstreamKeys []homeUpstreamKeyRow + accessKeys []homeAccessKeyRow +} + +func (s *Service) ReadHomeBase( + ctx context.Context, + nowMS int64, +) (HomeBase, error) { + if s == nil || s.db == nil || s.manager == nil || s.registrySnapshot == nil { + return HomeBase{}, fmt.Errorf( + "read home base: dependencies unavailable: %w", + app_errors.ErrInternalServer, + ) + } + if ctx == nil { + ctx = context.Background() + } + if err := validateSafeMilliseconds(nowMS); err != nil { + return HomeBase{}, fmt.Errorf( + "read home base: invalid observation time: %w", + app_errors.ErrInternalServer, + ) + } + now, err := epochms.ToTime(nowMS) + if err != nil { + return HomeBase{}, fmt.Errorf( + "read home base: invalid observation time: %w", + app_errors.ErrInternalServer, + ) + } + + s.writeMu.RLock() + defer s.writeMu.RUnlock() + + snapshot := s.manager.Current() + if snapshot == nil { + return HomeBase{}, fmt.Errorf( + "read home base: runtime snapshot unavailable: %w", + app_errors.ErrInternalServer, + ) + } + runtimeKeys := s.registrySnapshot() + rows, err := s.readHomeRows(ctx) + if err != nil { + return HomeBase{}, app_errors.ParseDBError(err) + } + + inventory := HomeInventory{ + GroupCount: rows.groupCount, + UpstreamKeyCount: int64(len(rows.upstreamKeys)), + ModelCount: countHomeModels(snapshot), + } + inventory.AvailableUpstreamKeyCount, err = countAvailableHomeKeys( + snapshot, + rows.upstreamKeys, + runtimeKeys, + now, + ) + if err != nil { + return HomeBase{}, err + } + if err := validateHomeInventory(inventory); err != nil { + return HomeBase{}, err + } + accessKeys, err := mapHomeAccessKeys(rows.accessKeys) + if err != nil { + return HomeBase{}, err + } + return HomeBase{ + Inventory: inventory, + AccessKeys: accessKeys, + }, nil +} + +func (s *Server) handleHome(c *gin.Context) { + if c.Request.URL.RawQuery != "" || c.Request.URL.ForceQuery { + writeServiceError(c, "home", app_errors.ErrBadRequest) + return + } + serverNowMS, err := safeEpochMilliseconds(s.now()) + if err != nil { + writeServiceError(c, "home", err) + return + } + startedAtMS, err := safeEpochMilliseconds(s.startedAt) + if err != nil { + writeServiceError(c, "home", err) + return + } + base, err := s.service.ReadHomeBase(c.Request.Context(), serverNowMS) + if err != nil { + writeServiceError(c, "home", err) + return + } + response.SuccessI18n(c, "common.success", homeResponse{ + ServerNowMS: serverNowMS, + StartedAtMS: startedAtMS, + Version: version.Version, + HomeBase: base, + }) +} + +func (s *Service) readHomeRows( + ctx context.Context, +) (homeReadRows, error) { + var result homeReadRows + err := s.withReadSnapshot(ctx, func(tx *gorm.DB) error { + if err := tx.Model(&models.Group{}).Count(&result.groupCount).Error; err != nil { + return fmt.Errorf("count home groups: %w", err) + } + if err := tx.Model(&models.UpstreamKey{}). + Select("id", "group_id", "status"). + Order("id ASC"). + Find(&result.upstreamKeys).Error; err != nil { + return fmt.Errorf("query home upstream keys: %w", err) + } + if err := tx.Model(&models.AccessKey{}). + Select("id", "name", "key_suffix", "filters"). + Where("status = ?", state.AccessKeyStatusActive). + Order("id ASC"). + Find(&result.accessKeys).Error; err != nil { + return fmt.Errorf("query home access keys: %w", err) + } + return nil + }) + if err != nil { + return homeReadRows{}, err + } + return result, nil +} + +func countHomeModels(snapshot *state.ConfigSnapshot) int64 { + names := make(map[string]struct{}) + for _, group := range snapshot.Groups { + for _, model := range group.Models { + name := strings.TrimSpace(model.Alias) + if name == "" { + name = strings.TrimSpace(model.ID) + } + if name != "" { + names[name] = struct{}{} + } + } + } + return int64(len(names)) +} + +func countAvailableHomeKeys( + snapshot *state.ConfigSnapshot, + persisted []homeUpstreamKeyRow, + runtime []state.KeyRuntimeView, + now time.Time, +) (int64, error) { + runtimeByID := make(map[uint]state.KeyRuntimeView, len(runtime)) + for _, key := range runtime { + if key.ID == 0 { + return 0, fmt.Errorf( + "count available home keys: runtime key has zero id: %w", + app_errors.ErrInternalServer, + ) + } + if _, duplicate := runtimeByID[key.ID]; duplicate { + return 0, fmt.Errorf( + "count available home keys: duplicate runtime key %d: %w", + key.ID, + app_errors.ErrInternalServer, + ) + } + runtimeByID[key.ID] = key + } + + seen := make(map[uint]struct{}, len(persisted)) + var available int64 + for _, row := range persisted { + if row.ID == 0 { + return 0, fmt.Errorf( + "count available home keys: persisted key has zero id: %w", + app_errors.ErrInternalServer, + ) + } + if _, duplicate := seen[row.ID]; duplicate { + return 0, fmt.Errorf( + "count available home keys: duplicate persisted key %d: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + seen[row.ID] = struct{}{} + group, groupExists := snapshot.GroupCatalog[row.GroupID] + key, keyExists := runtimeByID[row.ID] + if !groupExists || !keyExists || key.GroupID != row.GroupID { + return 0, fmt.Errorf( + "count available home key %d: runtime configuration mismatch: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + + var status state.KeyStatus + switch row.Status { + case models.UpstreamKeyStatusActive: + status = state.KeyStatusActive + case models.UpstreamKeyStatusDisabled: + status = state.KeyStatusDisabled + default: + return 0, fmt.Errorf( + "count available home key %d: invalid persisted status: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + if key.Status != status { + return 0, fmt.Errorf( + "count available home key %d: runtime status mismatch: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + if group.Enabled && + status == state.KeyStatusActive && + key.RuntimeState(now) == state.KeyRuntimeAvailable { + available++ + } + } + if len(runtimeByID) != len(seen) { + return 0, fmt.Errorf( + "count available home keys: runtime key set mismatch: %w", + app_errors.ErrInternalServer, + ) + } + return available, nil +} + +func mapHomeAccessKeys(rows []homeAccessKeyRow) ([]HomeAccessKey, error) { + result := make([]HomeAccessKey, 0, len(rows)) + for _, row := range rows { + if uint64(row.ID) > uint64(maxSafeInteger) { + return nil, fmt.Errorf( + "map home access key %d: unsafe id: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + if !validAccessKeySuffix(row.KeySuffix) { + return nil, fmt.Errorf( + "map home access key %d: invalid persisted suffix: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + filters, err := decodeStoredAccessKeyFilters(row.Filters) + if err != nil { + return nil, fmt.Errorf( + "map home access key %d filters: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + protocols := filters.Protocols + if len(protocols) == 0 { + protocols = protocol.DataPlaneProtocols() + } else { + protocols = append([]protocol.Protocol(nil), protocols...) + } + result = append(result, HomeAccessKey{ + ID: row.ID, Name: row.Name, + MaskedKey: maskedAccessKey(row.KeySuffix), + Protocols: protocols, + }) + } + return result, nil +} + +func validateHomeInventory(value HomeInventory) error { + for _, field := range []struct { + name string + value int64 + }{ + {name: "group count", value: value.GroupCount}, + {name: "upstream key count", value: value.UpstreamKeyCount}, + { + name: "available upstream key count", + value: value.AvailableUpstreamKeyCount, + }, + {name: "model count", value: value.ModelCount}, + } { + if field.value < 0 || field.value > maxSafeInteger { + return fmt.Errorf( + "map home inventory %s: unsafe integer: %w", + field.name, + app_errors.ErrInternalServer, + ) + } + } + return nil +} diff --git a/internal/control/home_statistics.go b/internal/control/home_statistics.go new file mode 100644 index 00000000..553aa03e --- /dev/null +++ b/internal/control/home_statistics.go @@ -0,0 +1,385 @@ +package control + +import ( + "context" + "fmt" + "net/url" + + "github.com/gin-gonic/gin" + + "gpt-load/internal/platform/epochms" + app_errors "gpt-load/internal/platform/errors" + "gpt-load/internal/platform/response" + "gpt-load/internal/requestlog" +) + +const homeStatisticsRankingLimit = 5 + +type HomeStatisticsReader interface { + QueryHomeStatistics( + context.Context, + requestlog.HomeStatisticsQuery, + ) (requestlog.HomeStatisticsReport, error) +} + +type homeStatisticsAggregateResponse struct { + RequestCount int64 `json:"request_count"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` + UsageMissingCount int64 `json:"usage_missing_count"` + PartialCount int64 `json:"partial_count"` + UnpricedRequestCount int64 `json:"unpriced_request_count"` +} + +type homeStatisticsSeriesResponse struct { + BucketStartMS int64 `json:"bucket_start_ms"` + BucketEndMS int64 `json:"bucket_end_ms"` + RequestCount int64 `json:"request_count"` + FailureCount int64 `json:"failure_count"` +} + +type homeStatisticsRefResponse struct { + ID uint `json:"id"` + Name *string `json:"name"` + Deleted bool `json:"deleted"` +} + +type homeModelRankingResponse struct { + Model string `json:"model"` + Group homeStatisticsRefResponse `json:"group"` + RequestCount int64 `json:"request_count"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` +} + +type homeGroupRankingResponse struct { + Group homeStatisticsRefResponse `json:"group"` + RequestCount int64 `json:"request_count"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` +} + +type homeAccessKeyRankingResponse struct { + AccessKey homeStatisticsRefResponse `json:"access_key"` + RequestCount int64 `json:"request_count"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` +} + +type homeStatisticsRankingsResponse struct { + Models []homeModelRankingResponse `json:"models"` + Groups []homeGroupRankingResponse `json:"groups"` + AccessKeys []homeAccessKeyRankingResponse `json:"access_keys"` +} + +type homeStatisticsResponse struct { + Range requestlog.HomeStatisticsRange `json:"range"` + Granularity requestlog.UsageGranularity `json:"granularity"` + FromMS int64 `json:"from_ms"` + ToMS int64 `json:"to_ms"` + ObservedAtMS int64 `json:"observed_at_ms"` + Summary homeStatisticsAggregateResponse `json:"summary"` + Series []homeStatisticsSeriesResponse `json:"series"` + Rankings homeStatisticsRankingsResponse `json:"rankings"` +} + +func (s *Service) QueryHomeStatistics( + ctx context.Context, + query requestlog.HomeStatisticsQuery, +) (requestlog.HomeStatisticsReport, error) { + if s == nil || s.homeStatistics == nil { + return requestlog.HomeStatisticsReport{}, app_errors.ErrInternalServer + } + report, err := s.homeStatistics.QueryHomeStatistics(ctx, query) + if err != nil { + return requestlog.HomeStatisticsReport{}, app_errors.ParseDBError(err) + } + return report, nil +} + +func (s *Server) handleHomeStatistics(c *gin.Context) { + observedAtMS, err := safeEpochMilliseconds(s.now()) + if err != nil { + writeServiceError(c, "home_statistics", err) + return + } + query, apiErr := parseHomeStatisticsQuery( + c.Request.URL.RawQuery, + observedAtMS, + ) + if apiErr != nil { + writeServiceError(c, "home_statistics", apiErr) + return + } + report, err := s.service.QueryHomeStatistics( + c.Request.Context(), + query, + ) + if err != nil { + writeServiceError(c, "home_statistics", err) + return + } + result, err := mapHomeStatisticsResponse(query, report) + if err != nil { + writeServiceError(c, "home_statistics", err) + return + } + response.SuccessI18n(c, "common.success", result) +} + +func parseHomeStatisticsQuery( + rawQuery string, + observedAtMS int64, +) (requestlog.HomeStatisticsQuery, *app_errors.APIError) { + if err := validateSafeMilliseconds(observedAtMS); err != nil { + return requestlog.HomeStatisticsQuery{}, app_errors.ErrInternalServer + } + values, err := url.ParseQuery(rawQuery) + if err != nil { + return requestlog.HomeStatisticsQuery{}, app_errors.ErrBadRequest + } + for key, entries := range values { + if key != "range" || len(entries) != 1 { + return requestlog.HomeStatisticsQuery{}, app_errors.ErrBadRequest + } + } + rangeValue := requestlog.HomeStatistics24H + if entries, exists := values["range"]; exists { + rangeValue = requestlog.HomeStatisticsRange(entries[0]) + } + width, count, _, ok := homeStatisticsRangeContract(rangeValue) + if !ok { + return requestlog.HomeStatisticsQuery{}, app_errors.ErrValidation + } + fromMS, toMS, err := epochms.WindowEndingAt( + observedAtMS, + width, + count, + ) + if err != nil || + validateSafeMilliseconds(fromMS) != nil || + validateSafeMilliseconds(toMS) != nil { + return requestlog.HomeStatisticsQuery{}, app_errors.ErrInternalServer + } + return requestlog.HomeStatisticsQuery{ + Range: rangeValue, + ObservedAtMS: observedAtMS, + }, nil +} + +func mapHomeStatisticsResponse( + query requestlog.HomeStatisticsQuery, + report requestlog.HomeStatisticsReport, +) (homeStatisticsResponse, error) { + width, count, granularity, ok := homeStatisticsRangeContract(query.Range) + if !ok { + return homeStatisticsResponse{}, fmt.Errorf( + "map home statistics: invalid query range", + ) + } + fromMS, toMS, err := epochms.WindowEndingAt( + query.ObservedAtMS, + width, + count, + ) + if err != nil { + return homeStatisticsResponse{}, fmt.Errorf( + "map home statistics window: %w", + err, + ) + } + if report.Range != query.Range || + report.ObservedAtMS != query.ObservedAtMS || + report.FromMS != fromMS || + report.ToMS != toMS || + report.Granularity != granularity { + return homeStatisticsResponse{}, fmt.Errorf( + "map home statistics: report metadata mismatch", + ) + } + for _, value := range []int64{ + report.FromMS, + report.ToMS, + report.ObservedAtMS, + } { + if err := validateSafeMilliseconds(value); err != nil { + return homeStatisticsResponse{}, fmt.Errorf( + "map home statistics time: %w", + err, + ) + } + } + summary, err := mapHomeStatisticsAggregate(report.Summary) + if err != nil { + return homeStatisticsResponse{}, err + } + if len(report.Series) != count { + return homeStatisticsResponse{}, fmt.Errorf( + "map home statistics: series length mismatch", + ) + } + if len(report.TopModels) > homeStatisticsRankingLimit || + len(report.TopGroups) > homeStatisticsRankingLimit || + len(report.TopAccessKeys) > homeStatisticsRankingLimit { + return homeStatisticsResponse{}, fmt.Errorf( + "map home statistics: ranking limit exceeded", + ) + } + + result := homeStatisticsResponse{ + Range: report.Range, + Granularity: report.Granularity, + FromMS: report.FromMS, + ToMS: report.ToMS, + ObservedAtMS: report.ObservedAtMS, + Summary: summary, + Series: make([]homeStatisticsSeriesResponse, 0, count), + Rankings: homeStatisticsRankingsResponse{ + Models: make([]homeModelRankingResponse, 0, len(report.TopModels)), + Groups: make([]homeGroupRankingResponse, 0, len(report.TopGroups)), + AccessKeys: make( + []homeAccessKeyRankingResponse, + 0, + len(report.TopAccessKeys), + ), + }, + } + for index, point := range report.Series { + wantStartMS := fromMS + int64(index)*width + if point.BucketStartMS != wantStartMS || + point.BucketEndMS != wantStartMS+width { + return homeStatisticsResponse{}, fmt.Errorf( + "map home statistics: non-dense series", + ) + } + if _, err := mapHomeStatisticsAggregate(point.UsageAggregate); err != nil { + return homeStatisticsResponse{}, err + } + result.Series = append(result.Series, homeStatisticsSeriesResponse{ + BucketStartMS: point.BucketStartMS, + BucketEndMS: point.BucketEndMS, + RequestCount: point.RequestCount, + FailureCount: point.FailureCount, + }) + } + for _, row := range report.TopModels { + aggregate, err := mapHomeStatisticsAggregate(row.UsageAggregate) + if err != nil { + return homeStatisticsResponse{}, err + } + group, err := mapHomeStatisticsRef(row.Group) + if err != nil { + return homeStatisticsResponse{}, err + } + result.Rankings.Models = append( + result.Rankings.Models, + homeModelRankingResponse{ + Model: row.Model, Group: group, + RequestCount: aggregate.RequestCount, + TotalTokens: aggregate.TotalTokens, + EstimatedCostNanoUSD: aggregate.EstimatedCostNanoUSD, + }, + ) + } + for _, row := range report.TopGroups { + aggregate, err := mapHomeStatisticsAggregate(row.UsageAggregate) + if err != nil { + return homeStatisticsResponse{}, err + } + group, err := mapHomeStatisticsRef(row.Group) + if err != nil { + return homeStatisticsResponse{}, err + } + result.Rankings.Groups = append( + result.Rankings.Groups, + homeGroupRankingResponse{ + Group: group, RequestCount: aggregate.RequestCount, + TotalTokens: aggregate.TotalTokens, + EstimatedCostNanoUSD: aggregate.EstimatedCostNanoUSD, + }, + ) + } + for _, row := range report.TopAccessKeys { + aggregate, err := mapHomeStatisticsAggregate(row.UsageAggregate) + if err != nil { + return homeStatisticsResponse{}, err + } + accessKey, err := mapHomeStatisticsRef(row.AccessKey) + if err != nil { + return homeStatisticsResponse{}, err + } + result.Rankings.AccessKeys = append( + result.Rankings.AccessKeys, + homeAccessKeyRankingResponse{ + AccessKey: accessKey, + RequestCount: aggregate.RequestCount, + TotalTokens: aggregate.TotalTokens, + EstimatedCostNanoUSD: aggregate.EstimatedCostNanoUSD, + }, + ) + } + return result, nil +} + +func homeStatisticsRangeContract( + value requestlog.HomeStatisticsRange, +) (int64, int, requestlog.UsageGranularity, bool) { + switch value { + case requestlog.HomeStatistics24H: + return epochms.MillisecondsPerHour, 24, requestlog.UsageGranularityHour, true + case requestlog.HomeStatistics30D: + return epochms.MillisecondsPerDay, 30, requestlog.UsageGranularityDay, true + default: + return 0, 0, "", false + } +} + +func mapHomeStatisticsAggregate( + source requestlog.UsageAggregate, +) (homeStatisticsAggregateResponse, error) { + mapped, err := mapUsageAggregate(source) + if err != nil { + return homeStatisticsAggregateResponse{}, fmt.Errorf( + "map home statistics aggregate: %w", + err, + ) + } + if source.SuccessCount > maxSafeInteger-source.FailureCount || + source.SuccessCount+source.FailureCount != source.RequestCount { + return homeStatisticsAggregateResponse{}, fmt.Errorf( + "map home statistics aggregate: invalid request count", + ) + } + return homeStatisticsAggregateResponse{ + RequestCount: mapped.RequestCount, + SuccessCount: mapped.SuccessCount, + FailureCount: mapped.FailureCount, + TotalTokens: mapped.TotalTokens, + EstimatedCostNanoUSD: mapped.EstimatedCostNanoUSD, + UsageMissingCount: mapped.UsageMissingCount, + PartialCount: mapped.PartialCount, + UnpricedRequestCount: mapped.UnpricedRequestCount, + }, nil +} + +func mapHomeStatisticsRef( + source requestlog.HomeStatisticsRef, +) (homeStatisticsRefResponse, error) { + if uint64(source.ID) > uint64(maxSafeInteger) || + source.Deleted && source.Name != nil || + !source.Deleted && source.Name == nil { + return homeStatisticsRefResponse{}, fmt.Errorf( + "map home statistics reference: invalid reference", + ) + } + var name *string + if source.Name != nil { + cloned := *source.Name + name = &cloned + } + return homeStatisticsRefResponse{ + ID: source.ID, Name: name, Deleted: source.Deleted, + }, nil +} diff --git a/internal/control/home_statistics_test.go b/internal/control/home_statistics_test.go new file mode 100644 index 00000000..e0685418 --- /dev/null +++ b/internal/control/home_statistics_test.go @@ -0,0 +1,616 @@ +package control + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "gpt-load/internal/platform/config" + "gpt-load/internal/platform/epochms" + "gpt-load/internal/platform/version" + "gpt-load/internal/requestlog" +) + +func TestHomeBaseHTTPUsesAuthenticationEnvelopeAndServerClock(t *testing.T) { + initControlI18n(t) + fixture := newServiceFixture(t) + server := NewServer( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + ) + now := time.Date(2026, time.July, 30, 12, 34, 56, 789, time.UTC) + startedAt := time.Date(2026, time.July, 29, 1, 2, 3, 456, time.UTC) + server.now = func() time.Time { return now } + server.startedAt = startedAt + engine := gin.New() + server.RegisterRoutes(engine) + + unauthorized := performHomeRequest(engine, "/api/home", "") + if unauthorized.Code != http.StatusUnauthorized { + t.Fatalf( + "unauthorized GET /api/home = %d %s", + unauthorized.Code, + unauthorized.Body.String(), + ) + } + + recorder := performHomeRequest(engine, "/api/home", "test-auth-key") + if recorder.Code != http.StatusOK { + t.Fatalf( + "GET /api/home = %d %s, want 200", + recorder.Code, + recorder.Body.String(), + ) + } + var envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + ServerNowMS int64 `json:"server_now_ms"` + StartedAtMS int64 `json:"started_at_ms"` + Version string `json:"version"` + Inventory HomeInventory `json:"inventory"` + AccessKeys []HomeAccessKey `json:"access_keys"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode home response: %v", err) + } + if envelope.Code != 0 || envelope.Message == "" || + envelope.Data.ServerNowMS != now.UnixMilli() || + envelope.Data.StartedAtMS != startedAt.UnixMilli() || + envelope.Data.Version != version.Version || + envelope.Data.Inventory != (HomeInventory{}) || + envelope.Data.AccessKeys == nil || + len(envelope.Data.AccessKeys) != 0 { + t.Fatalf("home response = %#v", envelope) + } + assertManagementWireObject( + t, + envelope.Data, + []string{ + "server_now_ms", + "started_at_ms", + "version", + "inventory", + "access_keys", + }, + ) +} + +func TestHomeBaseHTTPRejectsEveryQueryBeforeReading(t *testing.T) { + initControlI18n(t) + fixture := newServiceFixture(t) + fixture.service.db = nil + server := NewServer( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + ) + engine := gin.New() + server.RegisterRoutes(engine) + + for _, target := range []string{ + "/api/home?unknown=1", + "/api/home?range=24h", + "/api/home?unknown=", + } { + recorder := performHomeRequest(engine, target, "test-auth-key") + assertHomeHTTPError(t, recorder, http.StatusBadRequest, "BAD_REQUEST") + } +} + +func TestParseHomeStatisticsQueryIsStrictAndDefaultsTo24Hours(t *testing.T) { + const observedAtMS = int64(1_785_412_345_678) + tests := []struct { + rawQuery string + want requestlog.HomeStatisticsRange + code string + }{ + {rawQuery: "", want: requestlog.HomeStatistics24H}, + {rawQuery: "range=24h", want: requestlog.HomeStatistics24H}, + {rawQuery: "range=30d", want: requestlog.HomeStatistics30D}, + {rawQuery: "range=", code: "VALIDATION_FAILED"}, + {rawQuery: "range=1h", code: "VALIDATION_FAILED"}, + {rawQuery: "range=24h&range=30d", code: "BAD_REQUEST"}, + {rawQuery: "unknown=1", code: "BAD_REQUEST"}, + {rawQuery: "range=24h&unknown=1", code: "BAD_REQUEST"}, + } + for _, test := range tests { + t.Run(test.rawQuery, func(t *testing.T) { + query, apiErr := parseHomeStatisticsQuery( + test.rawQuery, + observedAtMS, + ) + if test.code != "" { + if apiErr == nil || apiErr.Code != test.code { + t.Fatalf( + "parseHomeStatisticsQuery() error = %v, want %s", + apiErr, + test.code, + ) + } + return + } + if apiErr != nil { + t.Fatalf("parseHomeStatisticsQuery() error = %v", apiErr) + } + if query.Range != test.want || + query.ObservedAtMS != observedAtMS { + t.Fatalf("query = %#v", query) + } + }) + } + + for _, observedAtMS := range []int64{-1, maxSafeInteger + 1} { + if _, apiErr := parseHomeStatisticsQuery("", observedAtMS); apiErr == nil || + apiErr.Code != "INTERNAL_SERVER_ERROR" { + t.Fatalf( + "parseHomeStatisticsQuery(observed=%d) error = %v", + observedAtMS, + apiErr, + ) + } + } +} + +func TestHomeStatisticsHTTPDefaultsToDense24HoursAndMapsExactWire(t *testing.T) { + now := time.Date(2026, time.July, 30, 12, 34, 56, 789, time.UTC) + currentGroupName := "current-group" + currentAccessKeyName := "current-access" + reader := &recordingHomeStatisticsReader{ + fn: func(query requestlog.HomeStatisticsQuery) requestlog.HomeStatisticsReport { + report := homeStatisticsReportForQuery(t, query) + report.Summary = requestlog.UsageAggregate{ + RequestCount: 8, SuccessCount: 6, FailureCount: 2, + UncachedInputTokens: 11, CacheReadTokens: 12, + CacheWrite5MTokens: 13, CacheWrite1HTokens: 14, + OutputTokens: 15, EstimatedCostNanoUSD: 58_200_000_000, + UsageMissingCount: 1, PartialCount: 2, + UnpricedRequestCount: 3, + } + report.Series[0].UsageAggregate = requestlog.UsageAggregate{ + RequestCount: 4, + SuccessCount: 3, + FailureCount: 1, + } + report.TopModels = []requestlog.HomeModelRanking{{ + Group: requestlog.HomeStatisticsRef{ + ID: 7, Name: ¤tGroupName, + }, + Model: "sonnet-4.5", + UsageAggregate: requestlog.UsageAggregate{ + RequestCount: 3, SuccessCount: 3, UncachedInputTokens: 20, + OutputTokens: 30, EstimatedCostNanoUSD: 18_400_000_000, + }, + }} + report.TopGroups = []requestlog.HomeGroupRanking{{ + Group: requestlog.HomeStatisticsRef{ + ID: 9, Deleted: true, + }, + UsageAggregate: requestlog.UsageAggregate{ + RequestCount: 4, SuccessCount: 4, UncachedInputTokens: 40, + OutputTokens: 50, EstimatedCostNanoUSD: 24_100_000_000, + }, + }} + report.TopAccessKeys = []requestlog.HomeAccessKeyRanking{{ + AccessKey: requestlog.HomeStatisticsRef{ + ID: 3, Name: ¤tAccessKeyName, + }, + UsageAggregate: requestlog.UsageAggregate{ + RequestCount: 5, SuccessCount: 5, UncachedInputTokens: 60, + OutputTokens: 70, EstimatedCostNanoUSD: 28_440_000_000, + }, + }} + return report + }, + } + engine := newHomeStatisticsTestEngine(t, now, reader) + recorder := performHomeRequest( + engine, + "/api/home/statistics", + "test-auth-key", + ) + if recorder.Code != http.StatusOK { + t.Fatalf( + "GET /api/home/statistics = %d %s", + recorder.Code, + recorder.Body.String(), + ) + } + if len(reader.queries) != 1 || + reader.queries[0].Range != requestlog.HomeStatistics24H || + reader.queries[0].ObservedAtMS != now.UnixMilli() { + t.Fatalf("QueryHomeStatistics calls = %#v", reader.queries) + } + + var envelope struct { + Code int `json:"code"` + Data struct { + Range string `json:"range"` + Granularity requestlog.UsageGranularity `json:"granularity"` + FromMS int64 `json:"from_ms"` + ToMS int64 `json:"to_ms"` + ObservedAtMS int64 `json:"observed_at_ms"` + Summary struct { + RequestCount int64 `json:"request_count"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` + UsageMissingCount int64 `json:"usage_missing_count"` + PartialCount int64 `json:"partial_count"` + UnpricedRequestCount int64 `json:"unpriced_request_count"` + } `json:"summary"` + Series []struct { + BucketStartMS int64 `json:"bucket_start_ms"` + BucketEndMS int64 `json:"bucket_end_ms"` + RequestCount int64 `json:"request_count"` + FailureCount int64 `json:"failure_count"` + } `json:"series"` + Rankings struct { + Models []struct { + Model string `json:"model"` + Group struct { + ID uint `json:"id"` + Name *string `json:"name"` + Deleted bool `json:"deleted"` + } `json:"group"` + RequestCount int64 `json:"request_count"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` + } `json:"models"` + Groups []struct { + Group struct { + ID uint `json:"id"` + Name *string `json:"name"` + Deleted bool `json:"deleted"` + } `json:"group"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` + } `json:"groups"` + AccessKeys []struct { + AccessKey struct { + ID uint `json:"id"` + Name *string `json:"name"` + Deleted bool `json:"deleted"` + } `json:"access_key"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` + } `json:"access_keys"` + } `json:"rankings"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode home statistics response: %v", err) + } + if envelope.Code != 0 || + envelope.Data.Range != "24h" || + envelope.Data.Granularity != requestlog.UsageGranularityHour || + envelope.Data.ObservedAtMS != now.UnixMilli() || + envelope.Data.ToMS-envelope.Data.FromMS != + 24*epochms.MillisecondsPerHour || + envelope.Data.Summary.RequestCount != 8 || + envelope.Data.Summary.SuccessCount != 6 || + envelope.Data.Summary.FailureCount != 2 || + envelope.Data.Summary.TotalTokens != 65 || + envelope.Data.Summary.EstimatedCostNanoUSD != "58200000000" || + envelope.Data.Summary.UsageMissingCount != 1 || + envelope.Data.Summary.PartialCount != 2 || + envelope.Data.Summary.UnpricedRequestCount != 3 || + len(envelope.Data.Series) != 24 || + envelope.Data.Series[0].RequestCount != 4 || + envelope.Data.Series[0].FailureCount != 1 || + len(envelope.Data.Rankings.Models) != 1 || + envelope.Data.Rankings.Models[0].Model != "sonnet-4.5" || + envelope.Data.Rankings.Models[0].Group.Name == nil || + *envelope.Data.Rankings.Models[0].Group.Name != currentGroupName || + envelope.Data.Rankings.Models[0].EstimatedCostNanoUSD != "18400000000" || + len(envelope.Data.Rankings.Groups) != 1 || + envelope.Data.Rankings.Groups[0].Group.ID != 9 || + envelope.Data.Rankings.Groups[0].Group.Name != nil || + !envelope.Data.Rankings.Groups[0].Group.Deleted || + envelope.Data.Rankings.Groups[0].EstimatedCostNanoUSD != "24100000000" || + len(envelope.Data.Rankings.AccessKeys) != 1 || + envelope.Data.Rankings.AccessKeys[0].AccessKey.Name == nil || + *envelope.Data.Rankings.AccessKeys[0].AccessKey.Name != currentAccessKeyName || + envelope.Data.Rankings.AccessKeys[0].EstimatedCostNanoUSD != "28440000000" { + t.Fatalf("home statistics response = %#v", envelope.Data) + } + if strings.Contains(recorder.Body.String(), "2026-") { + t.Fatalf("response exposes RFC 3339 timestamp: %s", recorder.Body.String()) + } + var rawEnvelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &rawEnvelope); err != nil { + t.Fatalf("decode raw home statistics response: %v", err) + } + for _, forbidden := range []string{"health", "timezone", "link"} { + if _, exists := rawEnvelope.Data[forbidden]; exists { + t.Fatalf( + "home statistics response exposes %q: %s", + forbidden, + recorder.Body.String(), + ) + } + } + assertManagementWireObject( + t, + envelope.Data, + []string{ + "range", + "granularity", + "from_ms", + "to_ms", + "observed_at_ms", + "summary", + "series", + "rankings", + }, + ) +} + +func TestHomeStatisticsHTTPAcceptsOnlyOneSupportedRange(t *testing.T) { + now := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC) + reader := &recordingHomeStatisticsReader{} + engine := newHomeStatisticsTestEngine(t, now, reader) + + recorder := performHomeRequest( + engine, + "/api/home/statistics?range=30d", + "test-auth-key", + ) + if recorder.Code != http.StatusOK { + t.Fatalf("30d response = %d %s", recorder.Code, recorder.Body.String()) + } + if len(reader.queries) != 1 || + reader.queries[0].Range != requestlog.HomeStatistics30D { + t.Fatalf("30d queries = %#v", reader.queries) + } + var response struct { + Data struct { + Granularity requestlog.UsageGranularity `json:"granularity"` + Series []json.RawMessage `json:"series"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode 30d response: %v", err) + } + if response.Data.Granularity != requestlog.UsageGranularityDay || + len(response.Data.Series) != 30 { + t.Fatalf("30d response = %#v", response.Data) + } + + validCalls := len(reader.queries) + for _, target := range []string{ + "/api/home/statistics?unknown=1", + "/api/home/statistics?range=24h&range=30d", + "/api/home/statistics?range=1h", + "/api/home/statistics?range=", + } { + recorder := performHomeRequest(engine, target, "test-auth-key") + if recorder.Code != http.StatusBadRequest { + t.Fatalf( + "%s response = %d %s", + target, + recorder.Code, + recorder.Body.String(), + ) + } + } + if len(reader.queries) != validCalls { + t.Fatalf( + "QueryHomeStatistics calls = %d after invalid queries, want %d", + len(reader.queries), + validCalls, + ) + } +} + +func TestHomeStatisticsHTTPFailsClosedOnUnsafeDataAndReadError(t *testing.T) { + now := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC) + t.Run("unsafe aggregate", func(t *testing.T) { + reader := &recordingHomeStatisticsReader{ + fn: func(query requestlog.HomeStatisticsQuery) requestlog.HomeStatisticsReport { + report := homeStatisticsReportForQuery(t, query) + report.Summary.RequestCount = maxSafeInteger + 1 + return report + }, + } + recorder := performHomeRequest( + newHomeStatisticsTestEngine(t, now, reader), + "/api/home/statistics", + "test-auth-key", + ) + assertHomeHTTPError( + t, + recorder, + http.StatusInternalServerError, + "INTERNAL_SERVER_ERROR", + ) + }) + + t.Run("too many rankings", func(t *testing.T) { + reader := &recordingHomeStatisticsReader{ + fn: func(query requestlog.HomeStatisticsQuery) requestlog.HomeStatisticsReport { + report := homeStatisticsReportForQuery(t, query) + report.TopGroups = make([]requestlog.HomeGroupRanking, 6) + return report + }, + } + recorder := performHomeRequest( + newHomeStatisticsTestEngine(t, now, reader), + "/api/home/statistics", + "test-auth-key", + ) + assertHomeHTTPError( + t, + recorder, + http.StatusInternalServerError, + "INTERNAL_SERVER_ERROR", + ) + }) + + t.Run("reader error", func(t *testing.T) { + reader := &recordingHomeStatisticsReader{ + err: errors.New("private database detail"), + } + recorder := performHomeRequest( + newHomeStatisticsTestEngine(t, now, reader), + "/api/home/statistics", + "test-auth-key", + ) + assertHomeHTTPError( + t, + recorder, + http.StatusInternalServerError, + "DATABASE_ERROR", + ) + if strings.Contains(recorder.Body.String(), "private database detail") { + t.Fatalf("response leaks reader error: %s", recorder.Body.String()) + } + }) +} + +type recordingHomeStatisticsReader struct { + queries []requestlog.HomeStatisticsQuery + report requestlog.HomeStatisticsReport + fn func(requestlog.HomeStatisticsQuery) requestlog.HomeStatisticsReport + err error +} + +func (reader *recordingHomeStatisticsReader) QueryHomeStatistics( + _ context.Context, + query requestlog.HomeStatisticsQuery, +) (requestlog.HomeStatisticsReport, error) { + reader.queries = append(reader.queries, query) + if reader.err != nil { + return requestlog.HomeStatisticsReport{}, reader.err + } + if reader.fn != nil { + return reader.fn(query), nil + } + if reader.report.Range != "" { + return reader.report, nil + } + return homeStatisticsReportForQuery(nil, query), nil +} + +func homeStatisticsReportForQuery( + t *testing.T, + query requestlog.HomeStatisticsQuery, +) requestlog.HomeStatisticsReport { + if t != nil { + t.Helper() + } + var width int64 + var count int + var granularity requestlog.UsageGranularity + switch query.Range { + case requestlog.HomeStatistics24H: + width = epochms.MillisecondsPerHour + count = 24 + granularity = requestlog.UsageGranularityHour + case requestlog.HomeStatistics30D: + width = epochms.MillisecondsPerDay + count = 30 + granularity = requestlog.UsageGranularityDay + default: + if t != nil { + t.Fatalf("unsupported test range %q", query.Range) + } + return requestlog.HomeStatisticsReport{} + } + fromMS, toMS, err := epochms.WindowEndingAt( + query.ObservedAtMS, + width, + count, + ) + if err != nil { + if t != nil { + t.Fatalf("WindowEndingAt() error = %v", err) + } + return requestlog.HomeStatisticsReport{} + } + report := requestlog.HomeStatisticsReport{ + Range: query.Range, + ObservedAtMS: query.ObservedAtMS, + FromMS: fromMS, + ToMS: toMS, + Granularity: granularity, + Series: make([]requestlog.UsageSeriesPoint, 0, count), + TopModels: []requestlog.HomeModelRanking{}, + TopGroups: []requestlog.HomeGroupRanking{}, + TopAccessKeys: []requestlog.HomeAccessKeyRanking{}, + } + for startMS := fromMS; startMS < toMS; startMS += width { + report.Series = append(report.Series, requestlog.UsageSeriesPoint{ + BucketStartMS: startMS, + BucketEndMS: startMS + width, + }) + } + return report +} + +func newHomeStatisticsTestEngine( + t *testing.T, + now time.Time, + reader *recordingHomeStatisticsReader, +) *gin.Engine { + t.Helper() + initControlI18n(t) + fixture := newServiceFixture(t) + fixture.service.homeStatistics = reader + server := NewServer( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + ) + server.now = func() time.Time { return now } + engine := gin.New() + server.RegisterRoutes(engine) + return engine +} + +func performHomeRequest( + engine *gin.Engine, + target string, + authKey string, +) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodGet, target, nil) + if authKey != "" { + request.Header.Set("Authorization", "Bearer "+authKey) + } + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + return recorder +} + +func assertHomeHTTPError( + t *testing.T, + recorder *httptest.ResponseRecorder, + status int, + code string, +) { + t.Helper() + var envelope struct { + Code string `json:"code"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode error response: %v; body=%s", err, recorder.Body.String()) + } + if recorder.Code != status || envelope.Code != code { + t.Fatalf( + "error response = %d/%q, want %d/%q; body=%s", + recorder.Code, + envelope.Code, + status, + code, + recorder.Body.String(), + ) + } +} diff --git a/internal/control/home_test.go b/internal/control/home_test.go new file mode 100644 index 00000000..ea10dfa5 --- /dev/null +++ b/internal/control/home_test.go @@ -0,0 +1,307 @@ +package control + +import ( + "context" + "reflect" + "sync/atomic" + "testing" + "time" + + "gorm.io/gorm" + + "gpt-load/internal/protocol" + "gpt-load/internal/state" + stateloader "gpt-load/internal/state/loader" + "gpt-load/internal/storage" + "gpt-load/internal/storage/models" +) + +func TestReadHomeBaseUsesPersistedAndRuntimeSnapshots(t *testing.T) { + fixture := newServiceFixture(t) + now := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC) + nowMS := now.UnixMilli() + + enabled := validControlGroup("home-enabled") + enabled.Models = models.JSON(`[ + {"id":"gpt-4o","alias":"client-primary"}, + {"id":"client-secondary"}, + {"id":"ignored-empty","alias":" "} + ]`) + enabledTwo := validControlGroup("home-enabled-two") + enabledTwo.Models = models.JSON(`[ + {"id":"upstream-duplicate","alias":"client-primary"}, + {"id":"client-secondary"}, + {"id":"client-third","alias":"client-third"} + ]`) + disabled := validControlGroup("home-disabled") + disabled.Enabled = false + disabled.Models = models.JSON(`[{"id":"disabled-model"}]`) + for _, group := range []*models.Group{enabled, enabledTwo, disabled} { + if err := fixture.db.Create(group).Error; err != nil { + t.Fatalf("create group %q: %v", group.Name, err) + } + } + if err := fixture.db.Model(disabled).Update("enabled", false).Error; err != nil { + t.Fatalf("disable group: %v", err) + } + + keys := []models.UpstreamKey{ + { + ID: 1, GroupID: enabled.ID, KeyValue: "cipher-1", KeyHash: "hash-1", + Status: models.UpstreamKeyStatusActive, + }, + { + ID: 2, GroupID: enabled.ID, KeyValue: "cipher-2", KeyHash: "hash-2", + Status: models.UpstreamKeyStatusActive, + }, + { + ID: 3, GroupID: enabledTwo.ID, KeyValue: "cipher-3", KeyHash: "hash-3", + Status: models.UpstreamKeyStatusDisabled, + }, + { + ID: 4, GroupID: disabled.ID, KeyValue: "cipher-4", KeyHash: "hash-4", + Status: models.UpstreamKeyStatusActive, + }, + } + if err := fixture.db.Create(&keys).Error; err != nil { + t.Fatalf("create upstream keys: %v", err) + } + if err := fixture.registry.Replace([]state.KeyEntry{ + { + ID: 1, GroupID: enabled.ID, Status: state.KeyStatusActive, + EncryptedValue: "cipher-1", + }, + { + ID: 2, GroupID: enabled.ID, Status: state.KeyStatusActive, + CooldownUntil: now.Add(time.Hour), EncryptedValue: "cipher-2", + }, + { + ID: 3, GroupID: enabledTwo.ID, Status: state.KeyStatusDisabled, + EncryptedValue: "cipher-3", + }, + { + ID: 4, GroupID: disabled.ID, Status: state.KeyStatusActive, + EncryptedValue: "cipher-4", + }, + }); err != nil { + t.Fatalf("registry.Replace() error = %v", err) + } + + accessKeys := []models.AccessKey{ + { + ID: 9, Name: "all protocols", KeyValue: "cipher-access-9", + KeyHash: "access-hash-9", KeySuffix: "c0de", + Status: string(state.AccessKeyStatusActive), + Filters: models.JSON( + `{"groups":[],"protocols":[],"models":[]}`, + ), + }, + { + ID: 3, Name: "filtered", KeyValue: "cipher-access-3", + KeyHash: "access-hash-3", KeySuffix: "88ab", + Status: string(state.AccessKeyStatusActive), + Filters: models.JSON( + `{"groups":[],"protocols":["gemini","openai-completions","gemini"],"models":[]}`, + ), + }, + { + ID: 5, Name: "disabled", KeyValue: "cipher-access-5", + KeyHash: "access-hash-5", KeySuffix: "dead", + Status: string(state.AccessKeyStatusDisabled), + Filters: models.JSON( + `{"groups":[],"protocols":[],"models":[]}`, + ), + }, + } + if err := fixture.db.Create(&accessKeys).Error; err != nil { + t.Fatalf("create access keys: %v", err) + } + input, err := stateloader.BuildCompileInput(context.Background(), fixture.db) + if err != nil { + t.Fatalf("BuildCompileInput() error = %v", err) + } + if _, err := fixture.manager.Publish(input); err != nil { + t.Fatalf("manager.Publish() error = %v", err) + } + + registrySnapshotCalls := 0 + fixture.service.registrySnapshot = func() []state.KeyRuntimeView { + registrySnapshotCalls++ + return fixture.registry.Snapshot() + } + + got, err := fixture.service.ReadHomeBase(context.Background(), nowMS) + if err != nil { + t.Fatalf("ReadHomeBase() error = %v", err) + } + want := HomeBase{ + Inventory: HomeInventory{ + GroupCount: 3, + UpstreamKeyCount: 4, + AvailableUpstreamKeyCount: 1, + ModelCount: 4, + }, + AccessKeys: []HomeAccessKey{ + { + ID: 3, Name: "filtered", + MaskedKey: "sk-gl-****88ab", + Protocols: []protocol.Protocol{ + protocol.OpenAICompletions, + protocol.Gemini, + }, + }, + { + ID: 9, Name: "all protocols", + MaskedKey: "sk-gl-****c0de", + Protocols: protocol.DataPlaneProtocols(), + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ReadHomeBase() = %#v, want %#v", got, want) + } + if registrySnapshotCalls != 1 { + t.Fatalf("registry Snapshot calls = %d, want 1", registrySnapshotCalls) + } +} + +func TestReadHomeBaseFailsClosed(t *testing.T) { + t.Run("invalid time", func(t *testing.T) { + fixture := newServiceFixture(t) + for _, nowMS := range []int64{-1, maxSafeInteger + 1} { + if _, err := fixture.service.ReadHomeBase( + context.Background(), + nowMS, + ); err == nil { + t.Fatalf("ReadHomeBase(nowMS=%d) error = nil", nowMS) + } + } + }) + + t.Run("invalid stored filter", func(t *testing.T) { + fixture := newServiceFixture(t) + row := models.AccessKey{ + Name: "corrupt", KeyValue: "cipher", KeyHash: "corrupt-hash", + KeySuffix: "cafe", Status: string(state.AccessKeyStatusActive), + Filters: models.JSON(`{"unknown":[]}`), + } + if err := fixture.db.Create(&row).Error; err != nil { + t.Fatalf("create access key: %v", err) + } + if _, err := fixture.service.ReadHomeBase( + context.Background(), + 1, + ); err == nil { + t.Fatal("ReadHomeBase() error = nil") + } + }) + + t.Run("query failure", func(t *testing.T) { + fixture := newServiceFixture(t) + fixture.service.db = nil + if _, err := fixture.service.ReadHomeBase( + context.Background(), + 1, + ); err == nil { + t.Fatal("ReadHomeBase() error = nil") + } + }) + + t.Run("unsafe inventory", func(t *testing.T) { + if err := validateHomeInventory(HomeInventory{ + GroupCount: maxSafeInteger + 1, + }); err == nil { + t.Fatal("validateHomeInventory() error = nil") + } + }) +} + +func TestReadHomeBaseKeepsDatabaseRowsInOneReadSnapshot(t *testing.T) { + fixture, dsn := newFileServiceFixture(t) + group := validControlGroup("home-snapshot") + if err := fixture.db.Create(group).Error; err != nil { + t.Fatalf("create group: %v", err) + } + input, err := stateloader.BuildCompileInput(t.Context(), fixture.db) + if err != nil { + t.Fatalf("BuildCompileInput() error = %v", err) + } + if _, err := fixture.manager.Publish(input); err != nil { + t.Fatalf("manager.Publish() error = %v", err) + } + + writer, err := storage.Open(dsn) + if err != nil { + t.Fatalf("storage.Open(writer) error = %v", err) + } + writerSQL, err := writer.DB() + if err != nil { + t.Fatalf("writer.DB() error = %v", err) + } + t.Cleanup(func() { + if err := writerSQL.Close(); err != nil { + t.Errorf("close writer database: %v", err) + } + }) + + var held atomic.Bool + firstSelectDone := make(chan struct{}) + releaseRead := make(chan struct{}) + const callbackName = "test:hold-home-after-group-count" + if err := fixture.db.Callback().Query(). + After("gorm:query"). + Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Table == "groups" && held.CompareAndSwap(false, true) { + close(firstSelectDone) + <-releaseRead + } + }); err != nil { + t.Fatalf("register query callback: %v", err) + } + t.Cleanup(func() { + fixture.db.Callback().Query().Remove(callbackName) + }) + + type readResult struct { + value HomeBase + err error + } + readDone := make(chan readResult, 1) + go func() { + value, err := fixture.service.ReadHomeBase(t.Context(), 1) + readDone <- readResult{value: value, err: err} + }() + select { + case <-firstSelectDone: + case <-time.After(time.Second): + t.Fatal("ReadHomeBase did not complete the first SELECT") + } + + newAccessKey := models.AccessKey{ + Name: "newer", KeyValue: "cipher", KeyHash: "newer-hash", + KeySuffix: "abcd", Status: string(state.AccessKeyStatusActive), + Filters: models.JSON( + `{"groups":[],"protocols":[],"models":[]}`, + ), + } + if err := writer.Create(&newAccessKey).Error; err != nil { + t.Fatalf("WAL writer create access key: %v", err) + } + close(releaseRead) + + select { + case result := <-readDone: + if result.err != nil { + t.Fatalf("ReadHomeBase() error = %v", result.err) + } + if result.value.Inventory.GroupCount != 1 || + len(result.value.AccessKeys) != 0 { + t.Fatalf( + "ReadHomeBase() mixed database versions: %#v", + result.value, + ) + } + case <-time.After(time.Second): + t.Fatal("ReadHomeBase did not finish") + } +} diff --git a/internal/control/http_routes.go b/internal/control/http_routes.go index ed59674a..75895150 100644 --- a/internal/control/http_routes.go +++ b/internal/control/http_routes.go @@ -43,6 +43,13 @@ func (s *Server) HTTPModule() httproute.Module { MethodNotAllowed: controlMethodNotAllowed, Routes: []httproute.Route{ controlRoute("control.auth.session", http.MethodGet, "/auth/session", s.handleAuthSession), + controlRoute("control.home", http.MethodGet, "/home", s.handleHome), + controlRoute( + "control.home.statistics", + http.MethodGet, + "/home/statistics", + s.handleHomeStatistics, + ), controlRoute("control.health", http.MethodGet, "/health", s.handleRuntimeHealth), controlRoute("control.logs.list", http.MethodGet, "/logs", s.handleListRequestLogs), controlRoute("control.usage", http.MethodGet, "/usage", s.handleUsage), diff --git a/internal/control/idempotency_operation.go b/internal/control/idempotency_operation.go index 3c8790cd..5131b5c4 100644 --- a/internal/control/idempotency_operation.go +++ b/internal/control/idempotency_operation.go @@ -9,11 +9,11 @@ import ( "errors" "fmt" "io" - "time" "gorm.io/gorm" "gpt-load/internal/platform/canonicaljson" + "gpt-load/internal/platform/epochms" app_errors "gpt-load/internal/platform/errors" stateloader "gpt-load/internal/state/loader" "gpt-load/internal/storage/models" @@ -61,7 +61,7 @@ type operationExpiredData struct { OperationID string `json:"operation_id"` OperationKind operationKind `json:"operation_kind"` ResourceIdentity string `json:"resource_identity"` - CompletedAt time.Time `json:"completed_at"` + CompletedAtMS int64 `json:"completed_at_ms"` } func operationRequiredStages(kind operationKind) ([]operationStage, error) { @@ -205,7 +205,10 @@ func (s *Service) executeIdempotentOperation( app_errors.ErrInternalServer, ) } - now := s.now().UTC() + nowMS, timeErr := epochms.FromTime(s.now()) + if timeErr != nil { + return app_errors.ErrInternalServer + } operation = models.ControlOperation{ OperationID: operationID, IdempotencyKey: input.IdempotencyKey, @@ -216,8 +219,8 @@ func (s *Service) executeIdempotentOperation( CanonicalResult: append([]byte(nil), mutationResult.CanonicalResult...), RequiredStages: models.JSON(encodedStages), LastCompletedStage: string(operationStageDBCommitted), - CreatedAt: now, - UpdatedAt: now, + CreatedAtMS: nowMS, + UpdatedAtMS: nowMS, } if err := tx.Create(&operation).Error; err != nil { return app_errors.ParseDBError(err) @@ -248,8 +251,11 @@ func (s *Service) replayIdempotentOperationLocked( if err := validateOperationComparator(operation, input); err != nil { return idempotentOperationResult{}, err } - if operation.CompactedAt != nil { - if operation.CompletedAt == nil { + if operation.CompactedAtMS != nil { + if operation.CompletedAtMS == nil { + return idempotentOperationResult{}, app_errors.ErrInternalServer + } + if err := validateSafeMilliseconds(*operation.CompletedAtMS); err != nil { return idempotentOperationResult{}, app_errors.ErrInternalServer } return idempotentOperationResult{}, app_errors.NewAPIErrorWithData( @@ -258,7 +264,7 @@ func (s *Service) replayIdempotentOperationLocked( OperationID: operation.OperationID, OperationKind: operationKind(operation.OperationKind), ResourceIdentity: operation.ResourceIdentity, - CompletedAt: operation.CompletedAt.UTC(), + CompletedAtMS: *operation.CompletedAtMS, }, ) } @@ -268,7 +274,7 @@ func (s *Service) replayIdempotentOperationLocked( return idempotentOperationResult{}, s.operationIncompleteError(*operation) } } - if operation.CompletedAt == nil || len(operation.CanonicalResult) == 0 { + if operation.CompletedAtMS == nil || len(operation.CanonicalResult) == 0 { return idempotentOperationResult{}, app_errors.ErrInternalServer } return idempotentOperationResult{ @@ -392,14 +398,17 @@ func (s *Service) advanceOperationStageLocked( } } previous := operation.LastCompletedStage - now := s.now().UTC() + nowMS, timeErr := epochms.FromTime(s.now()) + if timeErr != nil { + return app_errors.ErrInternalServer + } updates := map[string]any{ "last_completed_stage": string(stage), "failed_stage": "", - "updated_at": now, + "updated_at_ms": nowMS, } if stage == operationStageCompleted { - updates["completed_at"] = now + updates["completed_at_ms"] = nowMS } err := s.withControlTransaction(ctx, func(tx *gorm.DB) error { result := tx.Model(&models.ControlOperation{}). @@ -422,9 +431,9 @@ func (s *Service) advanceOperationStageLocked( } operation.LastCompletedStage = string(stage) operation.FailedStage = "" - operation.UpdatedAt = now + operation.UpdatedAtMS = nowMS if stage == operationStageCompleted { - operation.CompletedAt = &now + operation.CompletedAtMS = &nowMS } return nil } @@ -434,18 +443,21 @@ func (s *Service) recordOperationFailureLocked( operation *models.ControlOperation, stage operationStage, ) error { - now := s.now().UTC() + nowMS, timeErr := epochms.FromTime(s.now()) + if timeErr != nil { + return app_errors.ErrInternalServer + } err := s.withControlTransaction(ctx, func(tx *gorm.DB) error { return tx.Model(&models.ControlOperation{}). Where("commit_sequence = ?", operation.CommitSequence). Updates(map[string]any{ - "failed_stage": string(stage), - "updated_at": now, + "failed_stage": string(stage), + "updated_at_ms": nowMS, }).Error }) if err == nil { operation.FailedStage = string(stage) - operation.UpdatedAt = now + operation.UpdatedAtMS = nowMS } return err } diff --git a/internal/control/idempotency_operation_test.go b/internal/control/idempotency_operation_test.go index a51e2067..64552dc6 100644 --- a/internal/control/idempotency_operation_test.go +++ b/internal/control/idempotency_operation_test.go @@ -159,7 +159,7 @@ func TestExecuteIdempotentOperationCommitsResultAndReplaysWithoutMutation(t *tes } if row.CommitSequence == 0 || row.ResourceIdentity != "access-key:7" || row.LastCompletedStage != string(operationStageCompleted) || - row.CompletedAt == nil || row.CompactedAt != nil { + row.CompletedAtMS == nil || row.CompactedAtMS != nil { t.Fatalf("operation row = %#v", row) } var stages []operationStage diff --git a/internal/control/model_price.go b/internal/control/model_price.go index 053d6ad4..6faff83b 100644 --- a/internal/control/model_price.go +++ b/internal/control/model_price.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + "gpt-load/internal/platform/epochms" app_errors "gpt-load/internal/platform/errors" "gpt-load/internal/pricing" "gpt-load/internal/storage/models" @@ -15,11 +16,11 @@ import ( type ModelPriceInput struct { Pattern string - UncachedInput *float64 - CacheRead *float64 - CacheWrite5M *float64 - CacheWrite1H *float64 - Output *float64 + UncachedInput *pricing.NanoUSD + CacheRead *pricing.NanoUSD + CacheWrite5M *pricing.NanoUSD + CacheWrite1H *pricing.NanoUSD + Output *pricing.NanoUSD } func (s *Service) UpsertModelPrice(ctx context.Context, input ModelPriceInput) error { @@ -27,27 +28,30 @@ func (s *Service) UpsertModelPrice(ctx context.Context, input ModelPriceInput) e return app_errors.ErrValidation } return s.writePriceTable(ctx, func(tx *gorm.DB) error { - now := s.now() - row := models.ModelPrice{ - Pattern: input.Pattern, - InputPrice: input.UncachedInput, - OutputPrice: input.Output, - CacheReadPrice: input.CacheRead, - CacheWrite5MPrice: input.CacheWrite5M, - CacheWrite1HPrice: input.CacheWrite1H, - Source: string(pricing.SourceUser), - UpdatedAt: now, + nowMS, err := safeEpochMilliseconds(s.now()) + if err != nil { + return app_errors.ErrInternalServer } - err := tx.Clauses(clause.OnConflict{ + row := models.ModelPrice{ + Pattern: input.Pattern, + InputPriceNanoUSDPerMillionTokens: nanoUSDStoragePointer(input.UncachedInput), + OutputPriceNanoUSDPerMillionTokens: nanoUSDStoragePointer(input.Output), + CacheReadPriceNanoUSDPerMillionTokens: nanoUSDStoragePointer(input.CacheRead), + CacheWrite5MPriceNanoUSDPerMillionTokens: nanoUSDStoragePointer(input.CacheWrite5M), + CacheWrite1HPriceNanoUSDPerMillionTokens: nanoUSDStoragePointer(input.CacheWrite1H), + Source: string(pricing.SourceUser), + UpdatedAtMS: nowMS, + } + err = tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "pattern"}}, DoUpdates: clause.Assignments(map[string]any{ - "input_price": input.UncachedInput, - "output_price": input.Output, - "cache_read_price": input.CacheRead, - "cache_write_5m_price": input.CacheWrite5M, - "cache_write_1h_price": input.CacheWrite1H, - "source": string(pricing.SourceUser), - "updated_at": now, + "input_price_nano_usd_per_million_tokens": nanoUSDStoragePointer(input.UncachedInput), + "output_price_nano_usd_per_million_tokens": nanoUSDStoragePointer(input.Output), + "cache_read_price_nano_usd_per_million_tokens": nanoUSDStoragePointer(input.CacheRead), + "cache_write_5m_price_nano_usd_per_million_tokens": nanoUSDStoragePointer(input.CacheWrite5M), + "cache_write_1h_price_nano_usd_per_million_tokens": nanoUSDStoragePointer(input.CacheWrite1H), + "source": string(pricing.SourceUser), + "updated_at_ms": nowMS, }), }).Create(&row).Error if err != nil { @@ -88,13 +92,25 @@ func (s *Service) ListModelPrices(ctx context.Context) (modelPriceListResponse, Overrides: make([]modelPriceRuleResponse, 0, len(rows)), } for _, rule := range builtinRules { - result.Builtin = append(result.Builtin, newModelPriceRuleResponse(rule)) + response, err := newModelPriceRuleResponse(rule) + if err != nil { + return modelPriceListResponse{}, err + } + result.Builtin = append(result.Builtin, response) } for _, row := range rows { if row.Source != string(pricing.SourceUser) { return modelPriceListResponse{}, app_errors.ErrInternalServer } - result.Overrides = append(result.Overrides, newModelPriceRuleResponse(modelPriceRule(row))) + rule, err := modelPriceRule(row) + if err != nil { + return modelPriceListResponse{}, err + } + response, err := newModelPriceRuleResponse(rule) + if err != nil { + return modelPriceListResponse{}, err + } + result.Overrides = append(result.Overrides, response) } return result, nil } @@ -139,7 +155,11 @@ func loadPriceTable(ctx context.Context, tx *gorm.DB) (*pricing.Table, error) { app_errors.ErrInternalServer, ) } - rules = append(rules, modelPriceRule(row)) + rule, err := modelPriceRule(row) + if err != nil { + return nil, err + } + rules = append(rules, rule) } table, err := pricing.Compile(rules) if err != nil { @@ -162,25 +182,50 @@ func modelPriceInputRule(input ModelPriceInput) pricing.Rule { } } -func modelPriceRule(row models.ModelPrice) pricing.Rule { +func modelPriceRule(row models.ModelPrice) (pricing.Rule, error) { + updatedAt, err := epochms.ToTime(row.UpdatedAtMS) + if err != nil { + return pricing.Rule{}, fmt.Errorf( + "validate persisted model price time: %w", + app_errors.ErrInternalServer, + ) + } return pricing.Rule{ Pattern: row.Pattern, Prices: pricing.Prices{ - UncachedInput: priceFromPointer(row.InputPrice), - CacheRead: priceFromPointer(row.CacheReadPrice), - CacheWrite5M: priceFromPointer(row.CacheWrite5MPrice), - CacheWrite1H: priceFromPointer(row.CacheWrite1HPrice), - Output: priceFromPointer(row.OutputPrice), + UncachedInput: priceFromStoragePointer(row.InputPriceNanoUSDPerMillionTokens), + CacheRead: priceFromStoragePointer(row.CacheReadPriceNanoUSDPerMillionTokens), + CacheWrite5M: priceFromStoragePointer(row.CacheWrite5MPriceNanoUSDPerMillionTokens), + CacheWrite1H: priceFromStoragePointer(row.CacheWrite1HPriceNanoUSDPerMillionTokens), + Output: priceFromStoragePointer(row.OutputPriceNanoUSDPerMillionTokens), }, Source: pricing.SourceUser, SourceURL: "", - UpdatedAt: row.UpdatedAt, - } + UpdatedAt: updatedAt, + }, nil } -func priceFromPointer(value *float64) pricing.Price { +func priceFromPointer(value *pricing.NanoUSD) pricing.Price { if value == nil { return pricing.Price{} } - return pricing.Price{Value: *value, Set: true} + return pricing.Price{NanoUSDPerMillion: *value, Set: true} +} + +func priceFromStoragePointer(value *int64) pricing.Price { + if value == nil { + return pricing.Price{} + } + return pricing.Price{ + NanoUSDPerMillion: pricing.NanoUSD(*value), + Set: true, + } +} + +func nanoUSDStoragePointer(value *pricing.NanoUSD) *int64 { + if value == nil { + return nil + } + result := int64(*value) + return &result } diff --git a/internal/control/model_price_test.go b/internal/control/model_price_test.go index c9330973..41d6e26f 100644 --- a/internal/control/model_price_test.go +++ b/internal/control/model_price_test.go @@ -3,7 +3,6 @@ package control import ( "context" "errors" - "math" "strings" "sync" "testing" @@ -26,16 +25,16 @@ func TestPriceRuntimePublishesImmutableTablesAtomically(t *testing.T) { oldTable := mustCompilePriceTable(t, pricing.Rule{ Pattern: "atomic-model", Prices: pricing.Prices{ - UncachedInput: pricing.Price{Value: 1, Set: true}, - Output: pricing.Price{Value: 2, Set: true}, + UncachedInput: pricing.Price{NanoUSDPerMillion: 1, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 2, Set: true}, }, Source: pricing.SourceUser, }) newTable := mustCompilePriceTable(t, pricing.Rule{ Pattern: "atomic-model", Prices: pricing.Prices{ - UncachedInput: pricing.Price{Value: 10, Set: true}, - Output: pricing.Price{Value: 20, Set: true}, + UncachedInput: pricing.Price{NanoUSDPerMillion: 10, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 20, Set: true}, }, Source: pricing.SourceUser, }) @@ -63,8 +62,8 @@ func TestPriceRuntimePublishesImmutableTablesAtomically(t *testing.T) { errs <- "published table did not match" return } - input := rule.Prices.UncachedInput.Value - output := rule.Prices.Output.Value + input := rule.Prices.UncachedInput.NanoUSDPerMillion + output := rule.Prices.Output.NanoUSDPerMillion if (input != 1 || output != 2) && (input != 10 || output != 20) { errs <- "reader observed a partial table" return @@ -103,7 +102,9 @@ func TestUpsertModelPricePublishesCompleteUserOverride(t *testing.T) { fixture := newServiceFixture(t) mustEnsureInitialPrices(t, fixture) - input, cacheRead, cacheWrite5M, cacheWrite1H, output := 9.1, 9.2, 9.3, 9.4, 9.5 + input, cacheRead, cacheWrite5M, cacheWrite1H, output := pricing.NanoUSD(9_100_000_000), + pricing.NanoUSD(9_200_000_000), pricing.NanoUSD(9_300_000_000), + pricing.NanoUSD(9_400_000_000), pricing.NanoUSD(9_500_000_000) err := fixture.service.UpsertModelPrice(context.Background(), ModelPriceInput{ Pattern: "gpt-*", UncachedInput: &input, @@ -122,11 +123,11 @@ func TestUpsertModelPricePublishesCompleteUserOverride(t *testing.T) { t.Fatal("published PriceTable missed gpt-4o") } wantPrices := pricing.Prices{ - UncachedInput: pricing.Price{Value: input, Set: true}, - CacheRead: pricing.Price{Value: cacheRead, Set: true}, - CacheWrite5M: pricing.Price{Value: cacheWrite5M, Set: true}, - CacheWrite1H: pricing.Price{Value: cacheWrite1H, Set: true}, - Output: pricing.Price{Value: output, Set: true}, + UncachedInput: pricing.Price{NanoUSDPerMillion: input, Set: true}, + CacheRead: pricing.Price{NanoUSDPerMillion: cacheRead, Set: true}, + CacheWrite5M: pricing.Price{NanoUSDPerMillion: cacheWrite5M, Set: true}, + CacheWrite1H: pricing.Price{NanoUSDPerMillion: cacheWrite1H, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: output, Set: true}, } if rule.Source != pricing.SourceUser || rule.Pattern != "gpt-*" || rule.Prices != wantPrices { @@ -146,7 +147,7 @@ func TestUpsertModelPriceWritesNullAndExplicitZero(t *testing.T) { fixture := newServiceFixture(t) mustEnsureInitialPrices(t, fixture) - first := 1.0 + first := pricing.NanoUSD(1_000_000_000) if err := fixture.service.UpsertModelPrice(context.Background(), ModelPriceInput{ Pattern: "round-trip-model", UncachedInput: &first, @@ -158,8 +159,8 @@ func TestUpsertModelPriceWritesNullAndExplicitZero(t *testing.T) { t.Fatalf("first UpsertModelPrice() error = %v", err) } - zero := 0.0 - output := 2.0 + zero := pricing.NanoUSD(0) + output := pricing.NanoUSD(2_000_000_000) if err := fixture.service.UpsertModelPrice(context.Background(), ModelPriceInput{ Pattern: "round-trip-model", UncachedInput: nil, @@ -175,53 +176,44 @@ func TestUpsertModelPriceWritesNullAndExplicitZero(t *testing.T) { if err := fixture.db.Where("pattern = ?", "round-trip-model").Take(&row).Error; err != nil { t.Fatalf("query round-trip ModelPrice: %v", err) } - if row.InputPrice != nil || row.CacheWrite5MPrice != nil { - t.Fatalf("nullable columns = input:%v cache_write_5m:%v, want nil", row.InputPrice, row.CacheWrite5MPrice) + if row.InputPriceNanoUSDPerMillionTokens != nil || + row.CacheWrite5MPriceNanoUSDPerMillionTokens != nil { + t.Fatalf( + "nullable columns = input:%v cache_write_5m:%v, want nil", + row.InputPriceNanoUSDPerMillionTokens, + row.CacheWrite5MPriceNanoUSDPerMillionTokens, + ) } - if row.CacheReadPrice == nil || *row.CacheReadPrice != 0 || - row.CacheWrite1HPrice == nil || *row.CacheWrite1HPrice != 0 || - row.OutputPrice == nil || *row.OutputPrice != output { + if row.CacheReadPriceNanoUSDPerMillionTokens == nil || + *row.CacheReadPriceNanoUSDPerMillionTokens != 0 || + row.CacheWrite1HPriceNanoUSDPerMillionTokens == nil || + *row.CacheWrite1HPriceNanoUSDPerMillionTokens != 0 || + row.OutputPriceNanoUSDPerMillionTokens == nil || + *row.OutputPriceNanoUSDPerMillionTokens != int64(output) { t.Fatalf("explicit numeric columns = %+v", row) } rule, ok := fixture.priceRuntime.Load().Match("round-trip-model") if !ok || rule.Prices.UncachedInput.Set || rule.Prices.CacheWrite5M.Set || - !rule.Prices.CacheRead.Set || rule.Prices.CacheRead.Value != 0 || - !rule.Prices.CacheWrite1H.Set || rule.Prices.CacheWrite1H.Value != 0 { + !rule.Prices.CacheRead.Set || rule.Prices.CacheRead.NanoUSDPerMillion != 0 || + !rule.Prices.CacheWrite1H.Set || rule.Prices.CacheWrite1H.NanoUSDPerMillion != 0 { t.Fatalf("published nullable/zero prices = %+v, matched=%v", rule.Prices, ok) } } func TestUpsertModelPriceRejectsInvalidInputBeforePersistence(t *testing.T) { - one := 1.0 - nan := math.NaN() - positiveInfinity := math.Inf(1) - negativeInfinity := math.Inf(-1) - negative := -1.0 + one := pricing.NanoUSD(1_000_000_000) + negative := pricing.NanoUSD(-1) tests := []struct { name string input ModelPriceInput }{ { - name: "NaN with another valid price", + name: "negative input with another valid price", input: ModelPriceInput{ - Pattern: "private-invalid-pattern", - CacheRead: &nan, - Output: &one, - }, - }, - { - name: "positive infinity", - input: ModelPriceInput{ - Pattern: "private-invalid-pattern", - Output: &positiveInfinity, - }, - }, - { - name: "negative infinity", - input: ModelPriceInput{ - Pattern: "private-invalid-pattern", - Output: &negativeInfinity, + Pattern: "private-invalid-pattern", + UncachedInput: &negative, + Output: &one, }, }, { @@ -264,7 +256,7 @@ func TestUpsertModelPriceFailureRollsBackAndKeepsRuntime(t *testing.T) { mustEnsureInitialPrices(t, fixture) beforeTable := fixture.priceRuntime.Load() - value := 1.0 + value := pricing.NanoUSD(1_000_000_000) err := fixture.service.UpsertModelPrice(context.Background(), ModelPriceInput{ Pattern: "invalid?pattern", UncachedInput: &value, }) @@ -291,7 +283,7 @@ func TestUpsertModelPriceFailureRollsBackAndKeepsRuntime(t *testing.T) { t.Fatalf("create rejection trigger: %v", err) } - value := 1.0 + value := pricing.NanoUSD(1_000_000_000) err := fixture.service.UpsertModelPrice(context.Background(), ModelPriceInput{ Pattern: "rejected-model", Output: &value, }) @@ -310,7 +302,7 @@ func TestUpsertModelPriceFailureRollsBackAndKeepsRuntime(t *testing.T) { beforeTable := fixture.priceRuntime.Load() releaseReader := holdRollbackJournalReadLock(t, fixture.db, dsn) - value := 1.0 + value := pricing.NanoUSD(1_000_000_000) err := fixture.service.UpsertModelPrice(context.Background(), ModelPriceInput{ Pattern: "commit-failure-model", Output: &value, }) @@ -329,7 +321,7 @@ func TestResetModelPriceRestoresBuiltinAndIsIdempotent(t *testing.T) { fixture := newServiceFixture(t) mustEnsureInitialPrices(t, fixture) - input, output := 99.0, 100.0 + input, output := pricing.NanoUSD(99_000_000_000), pricing.NanoUSD(100_000_000_000) if err := fixture.service.UpsertModelPrice(context.Background(), ModelPriceInput{ Pattern: "gpt-4o", UncachedInput: &input, Output: &output, }); err != nil { @@ -340,7 +332,7 @@ func TestResetModelPriceRestoresBuiltinAndIsIdempotent(t *testing.T) { if err := fixture.service.ResetModelPrice(context.Background(), "gpt-4o"); err != nil { t.Fatalf("ResetModelPrice() error = %v", err) } - assertPublishedPrice(t, fixture.priceRuntime, "gpt-4o", pricing.SourceBuiltin, 2.5) + assertPublishedPrice(t, fixture.priceRuntime, "gpt-4o", pricing.SourceBuiltin, 2_500_000_000) assertModelPriceCount(t, fixture, 0) beforeTable := fixture.priceRuntime.Load() @@ -350,7 +342,7 @@ func TestResetModelPriceRestoresBuiltinAndIsIdempotent(t *testing.T) { if fixture.priceRuntime.Load() == nil || fixture.priceRuntime.Load() == beforeTable { t.Fatal("idempotent reset did not publish the precompiled complete table") } - assertPublishedPrice(t, fixture.priceRuntime, "gpt-4o", pricing.SourceBuiltin, 2.5) + assertPublishedPrice(t, fixture.priceRuntime, "gpt-4o", pricing.SourceBuiltin, 2_500_000_000) assertModelPriceCount(t, fixture, 0) } @@ -373,7 +365,7 @@ func TestModelPriceWritesDoNotPublishConfigSnapshot(t *testing.T) { fixture := newServiceFixture(t) mustEnsureInitialPrices(t, fixture) beforeSnapshot := fixture.manager.Current() - value := 3.0 + value := pricing.NanoUSD(3_000_000_000) if err := fixture.service.UpsertModelPrice(context.Background(), ModelPriceInput{ Pattern: "snapshot-model", Output: &value, @@ -394,7 +386,7 @@ func TestModelPriceWritesDoNotPublishConfigSnapshot(t *testing.T) { func TestModelPriceConcurrentReadersSeeCompleteTables(t *testing.T) { fixture := newServiceFixture(t) mustEnsureInitialPrices(t, fixture) - input, output := 40.0, 80.0 + input, output := pricing.NanoUSD(40_000_000_000), pricing.NanoUSD(80_000_000_000) override := ModelPriceInput{ Pattern: "gpt-4o", UncachedInput: &input, Output: &output, } @@ -422,9 +414,11 @@ func TestModelPriceConcurrentReadersSeeCompleteTables(t *testing.T) { } prices := rule.Prices builtin := rule.Source == pricing.SourceBuiltin && - prices.UncachedInput.Value == 2.5 && prices.Output.Value == 10 + prices.UncachedInput.NanoUSDPerMillion == 2_500_000_000 && + prices.Output.NanoUSDPerMillion == 10_000_000_000 user := rule.Source == pricing.SourceUser && - prices.UncachedInput.Value == input && prices.Output.Value == output + prices.UncachedInput.NanoUSDPerMillion == input && + prices.Output.NanoUSDPerMillion == output if !builtin && !user { errs <- "concurrent Match observed partial prices" return diff --git a/internal/control/model_prices_api.go b/internal/control/model_prices_api.go index 7ed338a4..7361de4b 100644 --- a/internal/control/model_prices_api.go +++ b/internal/control/model_prices_api.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/url" - "time" "github.com/gin-gonic/gin" @@ -15,11 +14,19 @@ import ( ) type modelPriceValuesResponse struct { - UncachedInput *float64 `json:"uncached_input"` - CacheRead *float64 `json:"cache_read"` - CacheWrite5M *float64 `json:"cache_write_5m"` - CacheWrite1H *float64 `json:"cache_write_1h"` - Output *float64 `json:"output"` + InputPrice *string `json:"input_price_usd_per_million_tokens"` + OutputPrice *string `json:"output_price_usd_per_million_tokens"` + CacheReadPrice *string `json:"cache_read_price_usd_per_million_tokens"` + CacheWrite5MPrice *string `json:"cache_write_5m_price_usd_per_million_tokens"` + CacheWrite1HPrice *string `json:"cache_write_1h_price_usd_per_million_tokens"` +} + +type modelPriceValuesInput struct { + UncachedInput *pricing.NanoUSD + CacheRead *pricing.NanoUSD + CacheWrite5M *pricing.NanoUSD + CacheWrite1H *pricing.NanoUSD + Output *pricing.NanoUSD } type modelPricePolicyResponse struct { @@ -33,7 +40,7 @@ type modelPriceRuleResponse struct { Source pricing.Source `json:"source"` Prices modelPriceValuesResponse `json:"prices"` SourceURL *string `json:"source_url"` - UpdatedAt time.Time `json:"updated_at"` + UpdatedAtMS int64 `json:"updated_at_ms"` PricingPolicy *modelPricePolicyResponse `json:"pricing_policy"` } @@ -82,48 +89,59 @@ func (request *modelPriceUpsertRequest) UnmarshalJSON(data []byte) error { return nil } -func decodeModelPriceValues(data json.RawMessage) (modelPriceValuesResponse, error) { +func decodeModelPriceValues(data json.RawMessage) (modelPriceValuesInput, error) { trimmed := bytes.TrimSpace(data) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) || trimmed[0] != '{' { - return modelPriceValuesResponse{}, fmt.Errorf("model price prices must be an object") + return modelPriceValuesInput{}, fmt.Errorf("model price prices must be an object") } var object map[string]json.RawMessage if err := json.Unmarshal(trimmed, &object); err != nil { - return modelPriceValuesResponse{}, err + return modelPriceValuesInput{}, err } if len(object) != 5 { - return modelPriceValuesResponse{}, fmt.Errorf("model price prices must contain five values") + return modelPriceValuesInput{}, fmt.Errorf("model price prices must contain five values") } keys := []string{ - "uncached_input", "cache_read", "cache_write_5m", "cache_write_1h", "output", + "input_price_usd_per_million_tokens", + "output_price_usd_per_million_tokens", + "cache_read_price_usd_per_million_tokens", + "cache_write_5m_price_usd_per_million_tokens", + "cache_write_1h_price_usd_per_million_tokens", } - values := make([]*float64, len(keys)) + values := make([]*pricing.NanoUSD, len(keys)) for index, key := range keys { raw, exists := object[key] if !exists { - return modelPriceValuesResponse{}, fmt.Errorf("model price prices must contain %s", key) + return modelPriceValuesInput{}, fmt.Errorf("model price prices must contain %s", key) } value, err := decodeNullableModelPrice(raw) if err != nil { - return modelPriceValuesResponse{}, fmt.Errorf("decode model price %s: %w", key, err) + return modelPriceValuesInput{}, fmt.Errorf("decode model price %s: %w", key, err) } values[index] = value } - return modelPriceValuesResponse{ - UncachedInput: values[0], CacheRead: values[1], CacheWrite5M: values[2], - CacheWrite1H: values[3], Output: values[4], + return modelPriceValuesInput{ + UncachedInput: values[0], + Output: values[1], + CacheRead: values[2], + CacheWrite5M: values[3], + CacheWrite1H: values[4], }, nil } -func decodeNullableModelPrice(raw json.RawMessage) (*float64, error) { +func decodeNullableModelPrice(raw json.RawMessage) (*pricing.NanoUSD, error) { if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return nil, nil } - var value float64 + var value string if err := json.Unmarshal(raw, &value); err != nil { return nil, err } - return &value, nil + parsed, err := pricing.ParseUSD(value) + if err != nil { + return nil, err + } + return &parsed, nil } func (s *Server) handleListModelPrices(c *gin.Context) { @@ -183,15 +201,19 @@ func parseModelPricePattern(rawQuery string) (string, *app_errors.APIError) { return patternValues[0], nil } -func newModelPriceRuleResponse(rule pricing.Rule) modelPriceRuleResponse { +func newModelPriceRuleResponse(rule pricing.Rule) (modelPriceRuleResponse, error) { + updatedAtMS, err := safeEpochMilliseconds(rule.UpdatedAt) + if err != nil { + return modelPriceRuleResponse{}, app_errors.ErrInternalServer + } result := modelPriceRuleResponse{ - Pattern: rule.Pattern, Source: rule.Source, UpdatedAt: rule.UpdatedAt, + Pattern: rule.Pattern, Source: rule.Source, UpdatedAtMS: updatedAtMS, Prices: modelPriceValuesResponse{ - UncachedInput: modelPriceValuePointer(rule.Prices.UncachedInput), - CacheRead: modelPriceValuePointer(rule.Prices.CacheRead), - CacheWrite5M: modelPriceValuePointer(rule.Prices.CacheWrite5M), - CacheWrite1H: modelPriceValuePointer(rule.Prices.CacheWrite1H), - Output: modelPriceValuePointer(rule.Prices.Output), + InputPrice: modelPriceValuePointer(rule.Prices.UncachedInput), + OutputPrice: modelPriceValuePointer(rule.Prices.Output), + CacheReadPrice: modelPriceValuePointer(rule.Prices.CacheRead), + CacheWrite5MPrice: modelPriceValuePointer(rule.Prices.CacheWrite5M), + CacheWrite1HPrice: modelPriceValuePointer(rule.Prices.CacheWrite1H), }, } if rule.SourceURL != "" { @@ -201,17 +223,21 @@ func newModelPriceRuleResponse(rule pricing.Rule) modelPriceRuleResponse { if rule.LongContextPolicy != nil { result.PricingPolicy = &modelPricePolicyResponse{ InputThresholdTokens: rule.LongContextPolicy.InputThresholdTokens, - InputMultiplier: rule.LongContextPolicy.InputMultiplier, - OutputMultiplier: rule.LongContextPolicy.OutputMultiplier, + InputMultiplier: modelPriceMultiplier(rule.LongContextPolicy.InputMultiplier), + OutputMultiplier: modelPriceMultiplier(rule.LongContextPolicy.OutputMultiplier), } } - return result + return result, nil } -func modelPriceValuePointer(price pricing.Price) *float64 { +func modelPriceValuePointer(price pricing.Price) *string { if !price.Set { return nil } - value := price.Value + value := pricing.FormatUSD(price.NanoUSDPerMillion) return &value } + +func modelPriceMultiplier(value pricing.Multiplier) float64 { + return float64(value.Numerator) / float64(value.Denominator) +} diff --git a/internal/control/model_prices_api_test.go b/internal/control/model_prices_api_test.go index d2766b89..c9b70b32 100644 --- a/internal/control/model_prices_api_test.go +++ b/internal/control/model_prices_api_test.go @@ -22,8 +22,8 @@ func TestModelPriceAPIListsStableBuiltinAndUserRules(t *testing.T) { fixture.service.now = func() time.Time { return time.Date(2026, time.July, 27, 12, 30, 0, 0, time.UTC) } - zero := 0.0 - output := 7.5 + zero := pricing.NanoUSD(0) + output := pricing.NanoUSD(7_500_000_000) if err := fixture.service.UpsertModelPrice(t.Context(), ModelPriceInput{ Pattern: "z-user", UncachedInput: &zero, Output: &output, }); err != nil { @@ -83,19 +83,24 @@ func TestModelPriceAPIListsStableBuiltinAndUserRules(t *testing.T) { } if builtin == nil || builtin.Source != string(pricing.SourceBuiltin) || builtin.SourceURL == nil || *builtin.SourceURL != "https://developers.openai.com/api/docs/pricing" || - builtin.UpdatedAt.Format(time.RFC3339) != "2026-07-26T00:00:00Z" { + builtin.UpdatedAtMS != time.Date(2026, time.July, 26, 0, 0, 0, 0, time.UTC).UnixMilli() { t.Fatalf("builtin response = %#v", builtin) } - if len(builtin.Prices) != 5 || builtin.Prices["cache_write_5m"] != nil || builtin.Prices["cache_write_1h"] != nil { + if len(builtin.Prices) != 5 || + builtin.Prices["cache_write_5m_price_usd_per_million_tokens"] != nil || + builtin.Prices["cache_write_1h_price_usd_per_million_tokens"] != nil { t.Fatalf("builtin prices = %#v, want five values including null cache writes", builtin.Prices) } if userA == nil || userA.Source != string(pricing.SourceUser) || userA.SourceURL != nil || - userA.UpdatedAt.Format(time.RFC3339) != "2026-07-27T12:30:00Z" { + userA.UpdatedAtMS != time.Date(2026, time.July, 27, 12, 30, 0, 0, time.UTC).UnixMilli() { t.Fatalf("a-user response = %#v", userA) } - if userZ == nil || len(userZ.Prices) != 5 || userZ.Prices["uncached_input"] == nil || - *userZ.Prices["uncached_input"] != 0 || userZ.Prices["cache_read"] != nil || - userZ.Prices["output"] == nil || *userZ.Prices["output"] != 7.5 { + if userZ == nil || len(userZ.Prices) != 5 || + userZ.Prices["input_price_usd_per_million_tokens"] == nil || + *userZ.Prices["input_price_usd_per_million_tokens"] != "0" || + userZ.Prices["cache_read_price_usd_per_million_tokens"] != nil || + userZ.Prices["output_price_usd_per_million_tokens"] == nil || + *userZ.Prices["output_price_usd_per_million_tokens"] != "7.5" { t.Fatalf("z-user prices = %#v", userZ) } } @@ -103,7 +108,7 @@ func TestModelPriceAPIListsStableBuiltinAndUserRules(t *testing.T) { func TestModelPriceAPIListsReadOnlyPricingPolicy(t *testing.T) { fixture := newServiceFixture(t) mustEnsureInitialPrices(t, fixture) - output := 7.5 + output := pricing.NanoUSD(7_500_000_000) if err := fixture.service.UpsertModelPrice(t.Context(), ModelPriceInput{ Pattern: "user-model", Output: &output, }); err != nil { @@ -178,33 +183,39 @@ func TestModelPriceAPIPutStrictlyReplacesFivePrices(t *testing.T) { mustEnsureInitialPrices(t, fixture) engine := newModelPriceTestEngine(t, fixture) - valid := `{"pattern":"replace-model","prices":{"uncached_input":1,"cache_read":2,"cache_write_5m":3,"cache_write_1h":4,"output":5}}` + valid := `{"pattern":"replace-model","prices":{"input_price_usd_per_million_tokens":"1","output_price_usd_per_million_tokens":"5","cache_read_price_usd_per_million_tokens":"2","cache_write_5m_price_usd_per_million_tokens":"3","cache_write_1h_price_usd_per_million_tokens":"4"}}` first := serveModelPriceRequest(engine, http.MethodPut, "/api/model-prices", valid, true) if first.Code != http.StatusOK { t.Fatalf("first PUT = %d %s", first.Code, first.Body.String()) } - replacement := `{"pattern":"replace-model","prices":{"uncached_input":null,"cache_read":0,"cache_write_5m":null,"cache_write_1h":null,"output":9}}` + replacement := `{"pattern":"replace-model","prices":{"input_price_usd_per_million_tokens":null,"output_price_usd_per_million_tokens":"9","cache_read_price_usd_per_million_tokens":"0","cache_write_5m_price_usd_per_million_tokens":null,"cache_write_1h_price_usd_per_million_tokens":null}}` second := serveModelPriceRequest(engine, http.MethodPut, "/api/model-prices", replacement, true) if second.Code != http.StatusOK { t.Fatalf("replacement PUT = %d %s", second.Code, second.Body.String()) } - var row struct { - InputPrice, CacheReadPrice, CacheWrite5MPrice, CacheWrite1HPrice, OutputPrice *float64 - } - if err := fixture.db.Table("model_prices").Where("pattern = ?", "replace-model").Take(&row).Error; err != nil { + var row models.ModelPrice + if err := fixture.db.Where("pattern = ?", "replace-model").Take(&row).Error; err != nil { t.Fatalf("read replacement row: %v", err) } - if row.InputPrice != nil || row.CacheReadPrice == nil || *row.CacheReadPrice != 0 || - row.CacheWrite5MPrice != nil || row.CacheWrite1HPrice != nil || row.OutputPrice == nil || *row.OutputPrice != 9 { + if row.InputPriceNanoUSDPerMillionTokens != nil || + row.CacheReadPriceNanoUSDPerMillionTokens == nil || + *row.CacheReadPriceNanoUSDPerMillionTokens != 0 || + row.CacheWrite5MPriceNanoUSDPerMillionTokens != nil || + row.CacheWrite1HPriceNanoUSDPerMillionTokens != nil || + row.OutputPriceNanoUSDPerMillionTokens == nil || + *row.OutputPriceNanoUSDPerMillionTokens != 9_000_000_000 { t.Fatalf("replacement row = %#v", row) } for _, body := range []string{ - `{"pattern":"strict-model","prices":{"uncached_input":1,"cache_read":2,"cache_write_5m":3,"cache_write_1h":4,"output":5},"unknown":true}`, - `{"pattern":"strict-model","prices":{"uncached_input":1,"cache_read":2,"cache_write_5m":3,"cache_write_1h":4,"output":5},"pricing_policy":null}`, - `{"pattern":"strict-model","pattern":"other","prices":{"uncached_input":1,"cache_read":2,"cache_write_5m":3,"cache_write_1h":4,"output":5}}`, - `{"pattern":"strict-model","prices":{"uncached_input":1,"cache_read":2,"cache_read":3,"cache_write_5m":3,"cache_write_1h":4,"output":5}}`, - `{"pattern":"strict-model","prices":{"uncached_input":1,"cache_read":2,"cache_write_5m":3,"cache_write_1h":4}}`, + strings.TrimSuffix(valid, "}") + `,"unknown":true}`, + strings.TrimSuffix(valid, "}") + `,"pricing_policy":null}`, + strings.Replace(valid, `"pattern":"replace-model"`, `"pattern":"strict-model","pattern":"other"`, 1), + strings.Replace(valid, `"cache_read_price_usd_per_million_tokens":"2"`, `"cache_read_price_usd_per_million_tokens":"2","cache_read_price_usd_per_million_tokens":"3"`, 1), + strings.Replace(valid, `,"cache_write_1h_price_usd_per_million_tokens":"4"`, "", 1), + strings.Replace(valid, `"1"`, "1", 1), + strings.Replace(valid, `"1"`, `"1e0"`, 1), + strings.Replace(valid, `"1"`, `" 1"`, 1), valid + ` {}`, } { recorder := serveModelPriceRequest(engine, http.MethodPut, "/api/model-prices", body, true) @@ -215,7 +226,7 @@ func TestModelPriceAPIPutStrictlyReplacesFivePrices(t *testing.T) { func TestModelPriceAPIDeleteValidatesSinglePatternAndIsIdempotent(t *testing.T) { fixture := newServiceFixture(t) mustEnsureInitialPrices(t, fixture) - value := 99.0 + value := pricing.NanoUSD(99_000_000_000) if err := fixture.service.UpsertModelPrice(t.Context(), ModelPriceInput{Pattern: "gpt-4o", Output: &value}); err != nil { t.Fatalf("seed override: %v", err) } @@ -239,7 +250,8 @@ func TestModelPriceAPIDeleteValidatesSinglePatternAndIsIdempotent(t *testing.T) } } rule, ok := fixture.priceRuntime.Load().Match("gpt-4o") - if !ok || rule.Source != pricing.SourceBuiltin || rule.Prices.Output.Value != 10 { + if !ok || rule.Source != pricing.SourceBuiltin || + rule.Prices.Output.NanoUSDPerMillion != 10_000_000_000 { t.Fatalf("runtime after idempotent DELETE = %#v, %t", rule, ok) } } @@ -259,9 +271,11 @@ func TestModelPriceAPIRequiresAuthAndGetWaitsForPriceWrite(t *testing.T) { fixture.service.writeMu.Unlock() } }() - value := 88.0 + value := int64(88_000_000_000) if err := fixture.db.Create(&models.ModelPrice{ - Pattern: "locked-write-model", OutputPrice: &value, Source: string(pricing.SourceUser), + Pattern: "locked-write-model", + OutputPriceNanoUSDPerMillionTokens: &value, + Source: string(pricing.SourceUser), }).Error; err != nil { t.Fatalf("persist locked write row: %v", err) } @@ -335,11 +349,11 @@ func assertModelPriceAPIError(t *testing.T, recorder *httptest.ResponseRecorder, } type modelPriceListTestResponse struct { - Pattern string `json:"pattern"` - Source string `json:"source"` - Prices map[string]*float64 `json:"prices"` - SourceURL *string `json:"source_url"` - UpdatedAt time.Time `json:"updated_at"` + Pattern string `json:"pattern"` + Source string `json:"source"` + Prices map[string]*string `json:"prices"` + SourceURL *string `json:"source_url"` + UpdatedAtMS int64 `json:"updated_at_ms"` } func assertModelPriceResponseSorted(t *testing.T, rules []modelPriceListTestResponse) { diff --git a/internal/control/mutation_audit_routes_test.go b/internal/control/mutation_audit_routes_test.go index d9345a87..2a9f916f 100644 --- a/internal/control/mutation_audit_routes_test.go +++ b/internal/control/mutation_audit_routes_test.go @@ -335,8 +335,11 @@ func TestSettingsMutationAudit(t *testing.T) { func TestModelPriceMutationAudit(t *testing.T) { const upsertBody = `{"pattern":"audit-model","prices":{` + - `"uncached_input":1,"cache_read":null,` + - `"cache_write_5m":null,"cache_write_1h":null,"output":2}}` + `"input_price_usd_per_million_tokens":"1",` + + `"output_price_usd_per_million_tokens":"2",` + + `"cache_read_price_usd_per_million_tokens":null,` + + `"cache_write_5m_price_usd_per_million_tokens":null,` + + `"cache_write_1h_price_usd_per_million_tokens":null}}` t.Run("upsert success", func(t *testing.T) { fixture := newServiceFixture(t) @@ -397,9 +400,11 @@ func TestModelPriceMutationAudit(t *testing.T) { method: http.MethodPut, path: "/api/model-prices", body: `{"pattern":"","prices":{` + - `"uncached_input":1,"cache_read":null,` + - `"cache_write_5m":null,` + - `"cache_write_1h":null,"output":2}}`, + `"input_price_usd_per_million_tokens":"1",` + + `"output_price_usd_per_million_tokens":"2",` + + `"cache_read_price_usd_per_million_tokens":null,` + + `"cache_write_5m_price_usd_per_million_tokens":null,` + + `"cache_write_1h_price_usd_per_million_tokens":null}}`, }, ) assertMutationEvent( @@ -1198,8 +1203,8 @@ func assertAccessKeyAuditLogExcludes( func seedAuditModelPrice(t *testing.T, fixture serviceFixture) { t.Helper() - uncachedInput := 1.0 - output := 2.0 + uncachedInput := pricing.NanoUSD(1_000_000_000) + output := pricing.NanoUSD(2_000_000_000) if err := fixture.service.UpsertModelPrice( t.Context(), ModelPriceInput{ diff --git a/internal/control/operation_recovery.go b/internal/control/operation_recovery.go index bbd76ede..385ea03b 100644 --- a/internal/control/operation_recovery.go +++ b/internal/control/operation_recovery.go @@ -12,6 +12,7 @@ import ( "gorm.io/gorm" "gpt-load/internal/platform/canonicaljson" + "gpt-load/internal/platform/epochms" app_errors "gpt-load/internal/platform/errors" "gpt-load/internal/platform/utils" "gpt-load/internal/storage/models" @@ -45,7 +46,7 @@ func (s *Service) recoverPendingOperationsLocked( beforeCommitSequence uint64, ) (*models.ControlOperation, error) { query := s.db.WithContext(ctx). - Where("completed_at IS NULL"). + Where("completed_at_ms IS NULL"). Order("commit_sequence ASC") if beforeCommitSequence != 0 { query = query.Where("commit_sequence < ?", beforeCommitSequence) @@ -108,19 +109,27 @@ func (s *Service) compactCompletedOperationsLocked( ) (int64, error) { now = now.UTC() cutoff := now.Add(-operationResultRetention) + nowMS, err := epochms.FromTime(now) + if err != nil { + return 0, app_errors.ErrInternalServer + } + cutoffMS, err := epochms.FromTime(cutoff) + if err != nil { + cutoffMS = 0 + } var rowsAffected int64 - err := s.withControlTransaction(ctx, func(tx *gorm.DB) error { + err = s.withControlTransaction(ctx, func(tx *gorm.DB) error { result := tx.Model(&models.ControlOperation{}). - Where("completed_at IS NOT NULL"). - Where("completed_at <= ?", cutoff). - Where("compacted_at IS NULL"). + Where("completed_at_ms IS NOT NULL"). + Where("completed_at_ms <= ?", cutoffMS). + Where("compacted_at_ms IS NULL"). Updates(map[string]any{ "canonical_result": gorm.Expr("NULL"), "required_stages": gorm.Expr("NULL"), "last_completed_stage": "", "failed_stage": "", - "compacted_at": now, - "updated_at": now, + "compacted_at_ms": nowMS, + "updated_at_ms": nowMS, }) if result.Error != nil { return app_errors.ParseDBError(result.Error) @@ -223,7 +232,7 @@ func validateRecoverableOperation(operation *models.ControlOperation) error { operation.DigestVersion != 1 || len(operation.RequestDigest) != 32 || !operationKind(operation.OperationKind).valid() || - operation.CompactedAt != nil { + operation.CompactedAtMS != nil { return fmt.Errorf("invalid durable operation comparator: %w", app_errors.ErrInternalServer) } if err := validateIdempotencyKey(operation.OperationID); err != nil { diff --git a/internal/control/operation_recovery_test.go b/internal/control/operation_recovery_test.go index 2c5857e2..42dd54c6 100644 --- a/internal/control/operation_recovery_test.go +++ b/internal/control/operation_recovery_test.go @@ -236,8 +236,8 @@ func TestDrainCommittedOperationsFailsClosedOnUnsupportedDigestVersion(t *testin CanonicalResult: []byte(`{"id":1}`), RequiredStages: models.JSON(stages), LastCompletedStage: string(operationStageDBCommitted), - CreatedAt: now, - UpdatedAt: now, + CreatedAtMS: now.UnixMilli(), + UpdatedAtMS: now.UnixMilli(), } if err := fixture.db.Create(&row).Error; err != nil { t.Fatalf("seed unsupported operation: %v", err) @@ -314,7 +314,7 @@ func TestCompactCompletedOperationsKeepsPermanentComparatorTombstone(t *testing. if err := fixture.db.First(&row).Error; err != nil { t.Fatalf("read compacted operation: %v", err) } - if row.CompactedAt == nil || len(row.CanonicalResult) != 0 || + if row.CompactedAtMS == nil || len(row.CanonicalResult) != 0 || len(row.RequiredStages) != 0 || row.LastCompletedStage != "" || row.OperationID == "" || row.ResourceIdentity != "access-key:7" || len(row.RequestDigest) != 32 { diff --git a/internal/control/request_logs.go b/internal/control/request_logs.go index 1e1bc95c..dc8b7695 100644 --- a/internal/control/request_logs.go +++ b/internal/control/request_logs.go @@ -5,12 +5,12 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/url" "regexp" "strconv" - "time" "github.com/gin-gonic/gin" @@ -25,7 +25,7 @@ import ( const ( defaultRequestLogLimit = 50 maxRequestLogLimit = 200 - requestLogCursorV1 = 1 + requestLogCursorV2 = 2 ) var canonicalLowercaseUUIDv4 = regexp.MustCompile( @@ -37,9 +37,9 @@ type RequestLogReader interface { } type requestLogCursorPayload struct { - Version int `json:"v"` - CompletedAt string `json:"completed_at"` - RequestID string `json:"request_id"` + Version int `json:"v"` + CompletedAtMS int64 `json:"completed_at_ms"` + RequestID string `json:"request_id"` } type requestLogAccessKeyResponse struct { @@ -65,28 +65,28 @@ type requestLogAttemptResponse struct { } type requestLogItemResponse struct { - RequestID string `json:"request_id"` - CompletedAt string `json:"completed_at"` - AccessKey requestLogAccessKeyResponse `json:"access_key"` - Protocol string `json:"protocol"` - ClientModel *string `json:"client_model"` - UpstreamModel *string `json:"upstream_model"` - Status telemetry.RequestStatus `json:"status"` - StatusCode int `json:"status_code"` - DurationMs int64 `json:"duration_ms"` - ErrorCode string `json:"error_code"` - ErrorSummary string `json:"error_summary"` - AffinityHit bool `json:"affinity_hit"` - Attempts []requestLogAttemptResponse `json:"attempts"` - GroupID *uint `json:"group_id"` - UsageState usage.State `json:"usage_state"` - CostState pricing.CostState `json:"cost_state"` - UncachedInputTokens int64 `json:"uncached_input_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWrite5MTokens int64 `json:"cache_write_5m_tokens"` - CacheWrite1HTokens int64 `json:"cache_write_1h_tokens"` - OutputTokens int64 `json:"output_tokens"` - EstimatedCostUSD json.Number `json:"estimated_cost_usd"` + RequestID string `json:"request_id"` + CompletedAtMS int64 `json:"completed_at_ms"` + AccessKey requestLogAccessKeyResponse `json:"access_key"` + Protocol string `json:"protocol"` + ClientModel *string `json:"client_model"` + UpstreamModel *string `json:"upstream_model"` + Status telemetry.RequestStatus `json:"status"` + StatusCode int `json:"status_code"` + DurationMs int64 `json:"duration_ms"` + ErrorCode string `json:"error_code"` + ErrorSummary string `json:"error_summary"` + AffinityHit bool `json:"affinity_hit"` + Attempts []requestLogAttemptResponse `json:"attempts"` + GroupID *uint `json:"group_id"` + UsageState usage.State `json:"usage_state"` + CostState pricing.CostState `json:"cost_state"` + UncachedInputTokens int64 `json:"uncached_input_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWrite5MTokens int64 `json:"cache_write_5m_tokens"` + CacheWrite1HTokens int64 `json:"cache_write_1h_tokens"` + OutputTokens int64 `json:"output_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` } type requestLogListResponse struct { @@ -133,7 +133,7 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP return requestlog.ListQuery{}, app_errors.ErrBadRequest } allowed := map[string]struct{}{ - "from": {}, "to": {}, "group_id": {}, "model": {}, "access_key_id": {}, + "from_ms": {}, "to_ms": {}, "group_id": {}, "model": {}, "access_key_id": {}, "status": {}, "request_id": {}, "limit": {}, "cursor": {}, } for key, value := range values { @@ -143,23 +143,21 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP } query := requestlog.ListQuery{Limit: defaultRequestLogLimit} - if value, ok := singleQueryValue(values, "from"); ok { - parsed, err := time.Parse(time.RFC3339Nano, value) + if value, ok := singleQueryValue(values, "from_ms"); ok { + parsed, err := parseCanonicalSafeMilliseconds(value) if err != nil { return requestlog.ListQuery{}, app_errors.ErrBadRequest } - parsed = parsed.UTC() - query.From = &parsed + query.FromMS = &parsed } - if value, ok := singleQueryValue(values, "to"); ok { - parsed, err := time.Parse(time.RFC3339Nano, value) + if value, ok := singleQueryValue(values, "to_ms"); ok { + parsed, err := parseCanonicalSafeMilliseconds(value) if err != nil { return requestlog.ListQuery{}, app_errors.ErrBadRequest } - parsed = parsed.UTC() - query.To = &parsed + query.ToMS = &parsed } - if query.From != nil && query.To != nil && !query.From.Before(*query.To) { + if query.FromMS != nil && query.ToMS != nil && *query.FromMS >= *query.ToMS { return requestlog.ListQuery{}, app_errors.ErrValidation } if value, ok := singleQueryValue(values, "group_id"); ok { @@ -201,14 +199,17 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP query.RequestID = value } if value, ok := singleQueryValue(values, "limit"); ok { - parsed, err := strconv.Atoi(value) + parsed, err := parseCanonicalSafeUint(value) if err != nil { + if errors.Is(err, errUnsafeCanonicalUint) { + return requestlog.ListQuery{}, app_errors.ErrValidation + } return requestlog.ListQuery{}, app_errors.ErrBadRequest } if parsed < 1 || parsed > maxRequestLogLimit { return requestlog.ListQuery{}, app_errors.ErrValidation } - query.Limit = parsed + query.Limit = int(parsed) } if value, ok := singleQueryValue(values, "cursor"); ok { cursor, err := decodeRequestLogCursor(value) @@ -229,11 +230,14 @@ func singleQueryValue(values url.Values, key string) (string, bool) { } func parseRequestLogID(value string) (uint, *app_errors.APIError) { - parsed, err := strconv.ParseUint(value, 10, strconv.IntSize) + parsed, err := parseCanonicalSafeUint(value) if err != nil { + if errors.Is(err, errUnsafeCanonicalUint) { + return 0, app_errors.ErrValidation + } return 0, app_errors.ErrBadRequest } - if parsed == 0 { + if parsed == 0 || uint64(uint(parsed)) != parsed { return 0, app_errors.ErrValidation } return uint(parsed), nil @@ -251,19 +255,18 @@ func decodeRequestLogCursor(encoded string) (*requestlog.Cursor, error) { if err != nil { return nil, err } - if payload.Version != requestLogCursorV1 { + if payload.Version != requestLogCursorV2 { return nil, fmt.Errorf("unsupported request log cursor version") } - completedAt, err := time.Parse(time.RFC3339Nano, payload.CompletedAt) - if err != nil || payload.CompletedAt != completedAt.UTC().Format(time.RFC3339Nano) { - return nil, fmt.Errorf("invalid request log cursor completed_at") + if err := validateSafeMilliseconds(payload.CompletedAtMS); err != nil { + return nil, fmt.Errorf("invalid request log cursor completed_at_ms: %w", err) } if !canonicalLowercaseUUIDv4.MatchString(payload.RequestID) { return nil, fmt.Errorf("invalid request log cursor request_id") } return &requestlog.Cursor{ - CompletedAt: completedAt.UTC(), - RequestID: payload.RequestID, + CompletedAtMS: payload.CompletedAtMS, + RequestID: payload.RequestID, }, nil } @@ -326,14 +329,16 @@ func errorsIsEOF(err error) bool { } func encodeRequestLogCursor(cursor requestlog.Cursor) (string, error) { - completedAt := cursor.CompletedAt.UTC() + if err := validateSafeMilliseconds(cursor.CompletedAtMS); err != nil { + return "", fmt.Errorf("encode request log cursor: invalid completed_at_ms: %w", err) + } if !canonicalLowercaseUUIDv4.MatchString(cursor.RequestID) { return "", fmt.Errorf("encode request log cursor: invalid request ID") } payload := requestLogCursorPayload{ - Version: requestLogCursorV1, - CompletedAt: completedAt.Format(time.RFC3339Nano), - RequestID: cursor.RequestID, + Version: requestLogCursorV2, + CompletedAtMS: cursor.CompletedAtMS, + RequestID: cursor.RequestID, } raw, err := json.Marshal(payload) if err != nil { @@ -347,6 +352,12 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er Items: make([]requestLogItemResponse, 0, len(page.Items)), } for _, record := range page.Items { + if err := validateSafeMilliseconds(record.CompletedAtMS); err != nil { + return requestLogListResponse{}, fmt.Errorf( + "map request log completed_at_ms: %w", + err, + ) + } usageCost, err := mapRequestLogUsageCost(record) if err != nil { return requestLogListResponse{}, err @@ -370,32 +381,32 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er }) } result.Items = append(result.Items, requestLogItemResponse{ - RequestID: record.RequestID, - CompletedAt: record.CompletedAt.UTC().Format(time.RFC3339Nano), + RequestID: record.RequestID, + CompletedAtMS: record.CompletedAtMS, AccessKey: requestLogAccessKeyResponse{ ID: record.AccessKey.ID, Name: record.AccessKey.Name, Deleted: record.AccessKey.Deleted, }, - Protocol: string(record.Protocol), - ClientModel: nullableRequestLogModel(record.ClientModel), - UpstreamModel: nullableRequestLogModel(record.UpstreamModel), - Status: record.Status, - StatusCode: record.StatusCode, - DurationMs: record.DurationMs, - ErrorCode: record.ErrorCode, - ErrorSummary: record.ErrorSummary, - AffinityHit: record.AffinityHit, - Attempts: attempts, - GroupID: usageCost.groupID, - UsageState: record.UsageState, - CostState: record.CostState, - UncachedInputTokens: record.UncachedInputTokens, - CacheReadTokens: record.CacheReadTokens, - CacheWrite5MTokens: record.CacheWrite5MTokens, - CacheWrite1HTokens: record.CacheWrite1HTokens, - OutputTokens: record.OutputTokens, - EstimatedCostUSD: usageCost.estimatedCostUSD, + Protocol: string(record.Protocol), + ClientModel: nullableRequestLogModel(record.ClientModel), + UpstreamModel: nullableRequestLogModel(record.UpstreamModel), + Status: record.Status, + StatusCode: record.StatusCode, + DurationMs: record.DurationMs, + ErrorCode: record.ErrorCode, + ErrorSummary: record.ErrorSummary, + AffinityHit: record.AffinityHit, + Attempts: attempts, + GroupID: usageCost.groupID, + UsageState: record.UsageState, + CostState: record.CostState, + UncachedInputTokens: record.UncachedInputTokens, + CacheReadTokens: record.CacheReadTokens, + CacheWrite5MTokens: record.CacheWrite5MTokens, + CacheWrite1HTokens: record.CacheWrite1HTokens, + OutputTokens: record.OutputTokens, + EstimatedCostNanoUSD: usageCost.estimatedCostNanoUSD, }) } if page.NextCursor != nil { @@ -416,15 +427,15 @@ func nullableRequestLogModel(value string) *string { } type requestLogUsageCostResponse struct { - groupID *uint - estimatedCostUSD json.Number + groupID *uint + estimatedCostNanoUSD string } func mapRequestLogUsageCost(record requestlog.Record) (requestLogUsageCostResponse, error) { if err := requestlog.ValidateUsageCostState( record.UsageState, record.CostState, - record.EstimatedCostUSD, + record.EstimatedCostNanoUSD, ); err != nil { return requestLogUsageCostResponse{}, fmt.Errorf("map request log usage/cost: %w", err) } @@ -442,12 +453,13 @@ func mapRequestLogUsageCost(record requestlog.Record) (requestLogUsageCostRespon if uint64(record.GroupID) > uint64(maxSafeInteger) { return requestLogUsageCostResponse{}, fmt.Errorf("map request log final Group ID: unsafe value") } - cost, err := mapEstimatedCostUSD(record.EstimatedCostUSD) - if err != nil { - return requestLogUsageCostResponse{}, err + if record.EstimatedCostNanoUSD < 0 { + return requestLogUsageCostResponse{}, fmt.Errorf( + "map request log usage/cost: negative estimated cost", + ) } result := requestLogUsageCostResponse{ - estimatedCostUSD: cost, + estimatedCostNanoUSD: strconv.FormatInt(record.EstimatedCostNanoUSD, 10), } if record.GroupID != 0 { groupID := record.GroupID diff --git a/internal/control/request_logs_test.go b/internal/control/request_logs_test.go index 2414e2c3..71448266 100644 --- a/internal/control/request_logs_test.go +++ b/internal/control/request_logs_test.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "math" "net/http" "net/http/httptest" "net/url" @@ -26,7 +25,7 @@ import ( func TestRequestLogEndpointRejectsUnknownDuplicateAndMalformedQueries(t *testing.T) { validCursor := encodeTestCursorPayload( - `{"v":1,"completed_at":"2026-07-24T12:00:00Z","request_id":"00000000-0000-4000-8000-000000000001"}`, + `{"v":2,"completed_at_ms":1784894400000,"request_id":"00000000-0000-4000-8000-000000000001"}`, ) tests := []struct { name string @@ -35,25 +34,41 @@ func TestRequestLogEndpointRejectsUnknownDuplicateAndMalformedQueries(t *testing {name: "unknown", query: "unknown=value"}, {name: "duplicate", query: "limit=1&limit=2"}, {name: "number", query: "limit=not-a-number"}, - {name: "time", query: "from=not-a-time"}, + {name: "old time query", query: "from=1784894400000"}, + {name: "time", query: "from_ms=not-a-time"}, + {name: "time leading zero", query: "from_ms=01784894400000"}, + {name: "time plus", query: "from_ms=%2B1784894400000"}, + {name: "time negative", query: "from_ms=-1"}, + {name: "time unsafe", query: "from_ms=9007199254740992"}, {name: "group", query: "group_id=-1"}, + {name: "group leading zero", query: "group_id=01"}, + {name: "group plus", query: "group_id=%2B1"}, + {name: "group whitespace", query: "group_id=%201"}, + {name: "group duplicate", query: "group_id=1&group_id=2"}, {name: "access key", query: "access_key_id=1.5"}, + {name: "access key leading zero", query: "access_key_id=01"}, + {name: "access key plus", query: "access_key_id=%2B1"}, + {name: "access key whitespace", query: "access_key_id=%201"}, + {name: "access key duplicate", query: "access_key_id=1&access_key_id=2"}, + {name: "limit leading zero", query: "limit=01"}, + {name: "limit plus", query: "limit=%2B1"}, + {name: "limit whitespace", query: "limit=%201"}, {name: "request UUID uppercase", query: "request_id=00000000-0000-4000-8000-00000000ABCD"}, {name: "request UUID version", query: "request_id=00000000-0000-3000-8000-000000000001"}, {name: "cursor base64", query: "cursor=%25%25%25"}, {name: "cursor percent encoded newline", query: "cursor=" + validCursor + "%0A"}, {name: "cursor JSON", query: "cursor=" + encodeTestCursorPayload(`{"v":1`)}, {name: "cursor unknown field", query: "cursor=" + encodeTestCursorPayload( - `{"v":1,"completed_at":"2026-07-24T12:00:00Z","request_id":"00000000-0000-4000-8000-000000000001","extra":true}`, + `{"v":2,"completed_at_ms":1784894400000,"request_id":"00000000-0000-4000-8000-000000000001","extra":true}`, )}, - {name: "cursor version", query: "cursor=" + encodeTestCursorPayload( - `{"v":2,"completed_at":"2026-07-24T12:00:00Z","request_id":"00000000-0000-4000-8000-000000000001"}`, + {name: "cursor old version", query: "cursor=" + encodeTestCursorPayload( + `{"v":1,"completed_at":"2026-07-24T12:00:00Z","request_id":"00000000-0000-4000-8000-000000000001"}`, )}, - {name: "cursor non UTC time", query: "cursor=" + encodeTestCursorPayload( - `{"v":1,"completed_at":"2026-07-24T20:00:00+08:00","request_id":"00000000-0000-4000-8000-000000000001"}`, + {name: "cursor unsafe timestamp", query: "cursor=" + encodeTestCursorPayload( + `{"v":2,"completed_at_ms":9007199254740992,"request_id":"00000000-0000-4000-8000-000000000001"}`, )}, {name: "cursor UUID", query: "cursor=" + encodeTestCursorPayload( - `{"v":1,"completed_at":"2026-07-24T12:00:00Z","request_id":"00000000-0000-3000-8000-000000000001"}`, + `{"v":2,"completed_at_ms":1784894400000,"request_id":"00000000-0000-3000-8000-000000000001"}`, )}, } @@ -71,7 +86,7 @@ func TestRequestLogEndpointRejectsUnknownDuplicateAndMalformedQueries(t *testing } func TestRequestLogEndpointRejectsInvalidDomainValues(t *testing.T) { - equalTime := url.QueryEscape("2026-07-24T12:00:00Z") + equalTime := "1784894400000" tests := []struct { name string query string @@ -79,10 +94,12 @@ func TestRequestLogEndpointRejectsInvalidDomainValues(t *testing.T) { {name: "limit zero", query: "limit=0"}, {name: "limit above maximum", query: "limit=201"}, {name: "group zero", query: "group_id=0"}, + {name: "group unsafe", query: "group_id=9007199254740992"}, {name: "access key zero", query: "access_key_id=0"}, + {name: "access key unsafe", query: "access_key_id=9007199254740992"}, {name: "empty model", query: "model="}, - {name: "equal range", query: "from=" + equalTime + "&to=" + equalTime}, - {name: "reversed range", query: "from=2026-07-24T13%3A00%3A00Z&to=2026-07-24T12%3A00%3A00Z"}, + {name: "equal range", query: "from_ms=" + equalTime + "&to_ms=" + equalTime}, + {name: "reversed range", query: "from_ms=1784898000000&to_ms=1784894400000"}, {name: "unknown status", query: "status=unknown"}, } @@ -99,19 +116,43 @@ func TestRequestLogEndpointRejectsInvalidDomainValues(t *testing.T) { } } +func TestRequestLogEndpointAcceptsCanonicalNumericBoundaries(t *testing.T) { + if strconv.IntSize != 64 { + t.Skip("maximum JavaScript safe integer does not fit uint on this architecture") + } + reader := &recordingRequestLogReader{} + engine := newRequestLogTestEngine(t, reader) + recorder := performRequestLogRequest( + engine, + "test-auth-key", + "group_id=9007199254740991&access_key_id=9007199254740991&limit=200", + ) + if recorder.Code != http.StatusOK { + t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String()) + } + if len(reader.queries) != 1 || reader.queries[0].GroupID == nil || + reader.queries[0].AccessKeyID == nil || + uint64(*reader.queries[0].GroupID) != uint64(maxSafeInteger) || + uint64(*reader.queries[0].AccessKeyID) != uint64(maxSafeInteger) || + reader.queries[0].Limit != maxRequestLogLimit { + t.Fatalf("List() queries = %#v", reader.queries) + } +} + func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) { completedAt := time.Date(2026, time.July, 24, 12, 0, 0, 123456789, time.UTC) + completedAtMS := completedAt.UnixMilli() nextCursor := &requestlog.Cursor{ - CompletedAt: completedAt, - RequestID: "00000000-0000-4000-8000-000000000502", + CompletedAtMS: completedAtMS, + RequestID: "00000000-0000-4000-8000-000000000502", } currentName := "Renamed Access Key" reader := &recordingRequestLogReader{ pages: []requestlog.Page{ { Items: []requestlog.Record{{ - RequestID: "00000000-0000-4000-8000-000000000501", - CompletedAt: completedAt, + RequestID: "00000000-0000-4000-8000-000000000501", + CompletedAtMS: completedAtMS, AccessKey: requestlog.AccessKeyRef{ ID: 41, Name: ¤tName, }, @@ -145,8 +186,8 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) { engine := newRequestLogTestEngine(t, reader) query := strings.Join([]string{ - "from=2026-07-24T19%3A00%3A00%2B08%3A00", - "to=2026-07-24T21%3A00%3A00%2B08%3A00", + "from_ms=1784890800000", + "to_ms=1784898000000", "group_id=12", "model=client-model", "access_key_id=41", @@ -161,9 +202,9 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) { t.Fatalf("Reader calls = %d, want one", len(reader.queries)) } gotQuery := reader.queries[0] - if gotQuery.Limit != 50 || gotQuery.From == nil || gotQuery.To == nil || - gotQuery.From.Format(time.RFC3339Nano) != "2026-07-24T11:00:00Z" || - gotQuery.To.Format(time.RFC3339Nano) != "2026-07-24T13:00:00Z" || + if gotQuery.Limit != 50 || gotQuery.FromMS == nil || gotQuery.ToMS == nil || + *gotQuery.FromMS != 1784890800000 || + *gotQuery.ToMS != 1784898000000 || gotQuery.GroupID == nil || *gotQuery.GroupID != 12 || gotQuery.AccessKeyID == nil || *gotQuery.AccessKeyID != 41 || gotQuery.ClientModel != "client-model" || @@ -211,8 +252,8 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) { if err := json.Unmarshal(decodedCursor, &payload); err != nil { t.Fatalf("next_cursor JSON: %v", err) } - if len(payload) != 3 || payload["v"] != float64(1) || - payload["completed_at"] != "2026-07-24T12:00:00.123456789Z" || + if len(payload) != 3 || payload["v"] != float64(2) || + payload["completed_at_ms"] != float64(completedAtMS) || payload["request_id"] != nextCursor.RequestID { t.Fatalf("next_cursor payload = %#v", payload) } @@ -226,7 +267,7 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) { t.Fatalf("second response = %d %s", second.Code, second.Body.String()) } if len(reader.queries) != 2 || reader.queries[1].Cursor == nil || - !reader.queries[1].Cursor.CompletedAt.Equal(completedAt) || + reader.queries[1].Cursor.CompletedAtMS != completedAtMS || reader.queries[1].Cursor.RequestID != nextCursor.RequestID { t.Fatalf("decoded cursor query = %#v", reader.queries) } @@ -248,20 +289,20 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) { func TestRequestLogEndpointProjectsUsageCostAndNullGroupZero(t *testing.T) { reader := &recordingRequestLogReader{pages: []requestlog.Page{{Items: []requestlog.Record{ { - RequestID: "00000000-0000-4000-8000-000000000603", - CompletedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), - Protocol: protocol.OpenAICompletions, - Status: telemetry.RequestStatusSuccess, - GroupID: 0, - UsageState: usage.StateComplete, - CostState: pricing.CostStatePriced, - UncachedInputTokens: 1, - CacheReadTokens: 2, - CacheWrite5MTokens: 3, - CacheWrite1HTokens: 4, - OutputTokens: 5, - EstimatedCostUSD: 0.1234567890123, - Attempts: []requestlog.Attempt{}, + RequestID: "00000000-0000-4000-8000-000000000603", + CompletedAtMS: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC).UnixMilli(), + Protocol: protocol.OpenAICompletions, + Status: telemetry.RequestStatusSuccess, + GroupID: 0, + UsageState: usage.StateComplete, + CostState: pricing.CostStatePriced, + UncachedInputTokens: 1, + CacheReadTokens: 2, + CacheWrite5MTokens: 3, + CacheWrite1HTokens: 4, + OutputTokens: 5, + EstimatedCostNanoUSD: 123_456_789_012, + Attempts: []requestlog.Attempt{}, }, }}}} recorder := performRequestLogRequest(newRequestLogTestEngine(t, reader), "test-auth-key", "") @@ -271,15 +312,15 @@ func TestRequestLogEndpointProjectsUsageCostAndNullGroupZero(t *testing.T) { var envelope struct { Data struct { Items []struct { - GroupID *uint `json:"group_id"` - UsageState string `json:"usage_state"` - CostState string `json:"cost_state"` - UncachedInputTokens int64 `json:"uncached_input_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWrite5MTokens int64 `json:"cache_write_5m_tokens"` - CacheWrite1HTokens int64 `json:"cache_write_1h_tokens"` - OutputTokens int64 `json:"output_tokens"` - EstimatedCostUSD json.Number `json:"estimated_cost_usd"` + GroupID *uint `json:"group_id"` + UsageState string `json:"usage_state"` + CostState string `json:"cost_state"` + UncachedInputTokens int64 `json:"uncached_input_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWrite5MTokens int64 `json:"cache_write_5m_tokens"` + CacheWrite1HTokens int64 `json:"cache_write_1h_tokens"` + OutputTokens int64 `json:"output_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` } `json:"items"` } `json:"data"` } @@ -293,7 +334,7 @@ func TestRequestLogEndpointProjectsUsageCostAndNullGroupZero(t *testing.T) { if item.GroupID != nil || item.UsageState != "complete" || item.CostState != "priced" || item.UncachedInputTokens != 1 || item.CacheReadTokens != 2 || item.CacheWrite5MTokens != 3 || item.CacheWrite1HTokens != 4 || - item.OutputTokens != 5 || item.EstimatedCostUSD.String() != "0.123456789012" { + item.OutputTokens != 5 || item.EstimatedCostNanoUSD != "123456789012" { t.Fatalf("usage/cost projection = %#v", item) } } @@ -347,8 +388,7 @@ func TestRequestLogUsageCostProjectionRejectsUnsafeValues(t *testing.T) { {name: "cost state", mutate: func(record *requestlog.Record) { record.CostState = "invalid" }}, {name: "negative token", mutate: func(record *requestlog.Record) { record.OutputTokens = -1 }}, {name: "unsafe token", mutate: func(record *requestlog.Record) { record.OutputTokens = maxSafeInteger + 1 }}, - {name: "negative cost", mutate: func(record *requestlog.Record) { record.EstimatedCostUSD = -0.1 }}, - {name: "infinite cost", mutate: func(record *requestlog.Record) { record.EstimatedCostUSD = math.Inf(1) }}, + {name: "negative cost", mutate: func(record *requestlog.Record) { record.EstimatedCostNanoUSD = -1 }}, { name: "missing usage cannot be priced", mutate: func(record *requestlog.Record) { @@ -366,7 +406,7 @@ func TestRequestLogUsageCostProjectionRejectsUnsafeValues(t *testing.T) { name: "unpriced cost must be zero", mutate: func(record *requestlog.Record) { record.CostState = pricing.CostStateUnpriced - record.EstimatedCostUSD = 0.1 + record.EstimatedCostNanoUSD = 1 }, }, } @@ -428,7 +468,7 @@ func TestRequestLogEndpointFailsClosedOnCorruptReaderUsageCost(t *testing.T) { name: "unpriced nonzero cost", mutate: func(record *requestlog.Record) { record.CostState = pricing.CostStateUnpriced - record.EstimatedCostUSD = 0.01 + record.EstimatedCostNanoUSD = 1 }, }, } @@ -447,11 +487,11 @@ func TestRequestLogEndpointFailsClosedOnCorruptReaderUsageCost(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { record := requestlog.Record{ - RequestID: "00000000-0000-4000-8000-000000000605", - CompletedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), - UsageState: usage.StateComplete, - CostState: pricing.CostStatePriced, - Attempts: []requestlog.Attempt{}, + RequestID: "00000000-0000-4000-8000-000000000605", + CompletedAtMS: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC).UnixMilli(), + UsageState: usage.StateComplete, + CostState: pricing.CostStatePriced, + Attempts: []requestlog.Attempt{}, } test.mutate(&record) reader := &recordingRequestLogReader{ @@ -550,7 +590,7 @@ func encodeTestCursorPayload(payload string) string { } func Example_requestLogOpaqueCursor() { - payload := `{"v":1,"completed_at":"2026-07-24T12:00:00Z","request_id":"00000000-0000-4000-8000-000000000001"}` + payload := `{"v":2,"completed_at_ms":1784894400000,"request_id":"00000000-0000-4000-8000-000000000001"}` fmt.Println(encodeTestCursorPayload(payload)) - // Output: eyJ2IjoxLCJjb21wbGV0ZWRfYXQiOiIyMDI2LTA3LTI0VDEyOjAwOjAwWiIsInJlcXVlc3RfaWQiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEifQ + // Output: eyJ2IjoyLCJjb21wbGV0ZWRfYXRfbXMiOjE3ODQ4OTQ0MDAwMDAsInJlcXVlc3RfaWQiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEifQ } diff --git a/internal/control/route_inspect.go b/internal/control/route_inspect.go index 40ec2645..600d60a7 100644 --- a/internal/control/route_inspect.go +++ b/internal/control/route_inspect.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "strings" - "time" "github.com/gin-gonic/gin" @@ -34,7 +33,7 @@ type routeInspectKeyResponse struct { WeightManual *int `json:"weight_manual"` WeightAuto int `json:"weight_auto"` EffectiveWeight int64 `json:"effective_weight"` - CooldownUntil *time.Time `json:"cooldown_until"` + CooldownUntilMS *int64 `json:"cooldown_until_ms"` } type routeInspectGroupResponse struct { @@ -49,7 +48,7 @@ type routeInspectGroupResponse struct { } type routeInspectResponse struct { - ObservedAt time.Time `json:"observed_at"` + ObservedAtMS int64 `json:"observed_at_ms"` SnapshotRevision uint64 `json:"snapshot_revision"` Protocol protocol.Protocol `json:"protocol"` ExternalModel *string `json:"external_model"` @@ -118,7 +117,7 @@ func (service *Service) InspectRoute( request, accessKey, explanation, - ), nil + ) } func mapRouteInspectResponse( @@ -126,9 +125,13 @@ func mapRouteInspectResponse( request routeInspectRequest, accessKey state.AccessKeyView, explanation scheduler.Inspection, -) routeInspectResponse { +) (routeInspectResponse, error) { + observedAtMS, err := safeEpochMilliseconds(observation.observedAt) + if err != nil { + return routeInspectResponse{}, fmt.Errorf("map route inspection observed_at_ms: %w", err) + } result := routeInspectResponse{ - ObservedAt: observation.observedAt, + ObservedAtMS: observedAtMS, SnapshotRevision: observation.snapshot.Revision, Protocol: request.Protocol, ExternalModel: cloneRouteModel(request.ExternalModel), @@ -151,6 +154,13 @@ func mapRouteInspectResponse( Keys: []routeInspectKeyResponse{}, } for _, key := range group.Keys { + cooldownUntilMS, err := optionalSafeEpochMilliseconds(key.CooldownUntil) + if err != nil { + return routeInspectResponse{}, fmt.Errorf( + "map route inspection cooldown_until_ms: %w", + err, + ) + } groupResponse.Keys = append(groupResponse.Keys, routeInspectKeyResponse{ KeyID: key.KeyID, Available: key.Available, @@ -158,12 +168,12 @@ func mapRouteInspectResponse( WeightManual: cloneInt(key.WeightManual), WeightAuto: key.WeightAuto, EffectiveWeight: key.EffectiveWeight, - CooldownUntil: optionalUTC(key.CooldownUntil), + CooldownUntilMS: cooldownUntilMS, }) } result.Groups = append(result.Groups, groupResponse) } - return result + return result, nil } func cloneRouteModel(value *string) *string { diff --git a/internal/control/route_inspect_test.go b/internal/control/route_inspect_test.go index fd0791fb..4b627a56 100644 --- a/internal/control/route_inspect_test.go +++ b/internal/control/route_inspect_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "strconv" "strings" "sync/atomic" "testing" @@ -333,7 +334,7 @@ func TestRouteInspectEndpointReturnsCurrentSafeExplanation(t *testing.T) { `{"protocol":"openai-completions","external_model":"public-model","access_key_id":10}`, ) got := decodeRouteInspectSuccess(t, recorder) - if !got.ObservedAt.Equal(now) || + if got.ObservedAtMS != now.UnixMilli() || got.SnapshotRevision != fixture.manager.Current().Revision || got.Protocol != protocol.OpenAICompletions || routeModelValue(got.ExternalModel) != "public-model" || @@ -360,14 +361,14 @@ func TestRouteInspectEndpointReturnsCurrentSafeExplanation(t *testing.T) { if !available.Available || available.ReasonCode != nil || available.WeightManual == nil || *available.WeightManual != 25 || available.WeightAuto != 90 || available.EffectiveWeight != 50*25 || - available.CooldownUntil != nil { + available.CooldownUntilMS != nil { t.Fatalf("available key = %#v", available) } cooldown := primary.Keys[1] if cooldown.Available || cooldown.WeightManual != nil || cooldown.WeightAuto != 40 || cooldown.EffectiveWeight != 0 || - cooldown.CooldownUntil == nil || - !cooldown.CooldownUntil.Equal(now.Add(time.Minute).UTC()) { + cooldown.CooldownUntilMS == nil || + *cooldown.CooldownUntilMS != now.Add(time.Minute).UnixMilli() { t.Fatalf("cooldown key = %#v", cooldown) } assertRouteReason(t, cooldown.ReasonCode, scheduler.ReasonKeyCooldown) @@ -381,12 +382,12 @@ func TestRouteInspectEndpointReturnsCurrentSafeExplanation(t *testing.T) { backup.Keys[0].WeightManual != nil || backup.Keys[0].WeightAuto != 30 || backup.Keys[0].EffectiveWeight != 20*30 || - backup.Keys[0].CooldownUntil != nil { + backup.Keys[0].CooldownUntilMS != nil { t.Fatalf("backup group = %#v", backup) } body := recorder.Body.String() if strings.Count(body, `"reason_code":null`) != 5 || - strings.Count(body, `"cooldown_until":null`) != 2 { + strings.Count(body, `"cooldown_until_ms":null`) != 2 { t.Fatalf("success response must preserve explicit nulls: %s", body) } lower := strings.ToLower(body) @@ -473,7 +474,7 @@ func TestRouteInspectEndpointReturnsFilterExplanations(t *testing.T) { `{"protocol":"openai-completions","external_model":"public-model","access_key_id":10}`, ) got := decodeRouteInspectSuccess(t, recorder) - if !got.ObservedAt.Equal(now) || + if got.ObservedAtMS != now.UnixMilli() || got.SnapshotRevision != fixture.manager.Current().Revision || got.Routable { t.Fatalf("filter response = %#v", got) @@ -533,7 +534,7 @@ func TestRouteInspectEndpointReturnsNoRouteTargetExplanation(t *testing.T) { `{"protocol":"openai-completions","external_model":"missing-model","access_key_id":10}`, ) got := decodeRouteInspectSuccess(t, recorder) - if !got.ObservedAt.Equal(now) || + if got.ObservedAtMS != now.UnixMilli() || got.SnapshotRevision != fixture.manager.Current().Revision || got.Routable || got.Groups == nil || len(got.Groups) != 0 { t.Fatalf("no-target response = %#v", got) @@ -598,7 +599,7 @@ func TestRouteInspectEndpointReturnsNoAvailableKeyExplanation(t *testing.T) { `{"protocol":"openai-completions","external_model":"public-model","access_key_id":10}`, ) got := decodeRouteInspectSuccess(t, recorder) - if !got.ObservedAt.Equal(now) || + if got.ObservedAtMS != now.UnixMilli() || got.SnapshotRevision != fixture.manager.Current().Revision || got.Routable { t.Fatalf("unavailable response = %#v", got) @@ -639,25 +640,22 @@ func TestRouteInspectEndpointReturnsNoAvailableKeyExplanation(t *testing.T) { t.Fatalf("key %d manual weight = %v, want %d", index, key.WeightManual, *wantManual[index]) } if index == 3 { - if key.CooldownUntil == nil || - !key.CooldownUntil.Equal(cooldownAt.UTC()) { - t.Fatalf("cooldown = %v, want %v", key.CooldownUntil, cooldownAt.UTC()) + if key.CooldownUntilMS == nil || + *key.CooldownUntilMS != cooldownAt.UnixMilli() { + t.Fatalf("cooldown = %v, want %v", key.CooldownUntilMS, cooldownAt.UnixMilli()) } - if key.CooldownUntil.Location() != time.UTC { - t.Fatalf("cooldown location = %v, want UTC", key.CooldownUntil.Location()) - } - } else if key.CooldownUntil != nil { - t.Fatalf("key %d cooldown = %v, want nil", index, key.CooldownUntil) + } else if key.CooldownUntilMS != nil { + t.Fatalf("key %d cooldown = %v, want nil", index, key.CooldownUntilMS) } } - if strings.Count(recorder.Body.String(), `"cooldown_until":null`) != 3 { + if strings.Count(recorder.Body.String(), `"cooldown_until_ms":null`) != 3 { t.Fatalf("non-cooldown keys must encode null cooldown: %s", recorder.Body.String()) } - wantCooldownJSON := `"cooldown_until":"` + - cooldownAt.UTC().Format(time.RFC3339) + `"` + wantCooldownJSON := `"cooldown_until_ms":` + + strconv.FormatInt(cooldownAt.UnixMilli(), 10) if !strings.Contains(recorder.Body.String(), wantCooldownJSON) { t.Fatalf( - "cooldown must encode as UTC: got %s, want %s", + "cooldown must encode as milliseconds: got %s, want %s", recorder.Body.String(), wantCooldownJSON, ) diff --git a/internal/control/server_test.go b/internal/control/server_test.go index 2342e9b7..cc4ca5e4 100644 --- a/internal/control/server_test.go +++ b/internal/control/server_test.go @@ -28,6 +28,46 @@ import ( "gpt-load/internal/storage/models" ) +func TestServerHomeRoutesUseExactManagementContracts(t *testing.T) { + fixture := newServiceFixture(t) + module := NewServer( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + ).HTTPModule() + type routeContract struct { + method string + path string + } + want := map[string]routeContract{ + "control.home": { + method: http.MethodGet, + path: "/home", + }, + "control.home.statistics": { + method: http.MethodGet, + path: "/home/statistics", + }, + } + seen := make(map[string]int, len(want)) + for _, route := range module.Routes { + contract, exists := want[route.Name] + if !exists { + continue + } + seen[route.Name]++ + if len(route.Methods) != 1 || + route.Methods[0] != contract.method || + route.Path != contract.path { + t.Fatalf("route %q = %#v, want %#v", route.Name, route, contract) + } + } + for name := range want { + if seen[name] != 1 { + t.Fatalf("route %q occurrences = %d, want 1", name, seen[name]) + } + } +} + func TestSystemInfoHTTPContract(t *testing.T) { initControlI18n(t) fixture := newServiceFixture(t) @@ -210,9 +250,13 @@ func TestControlMutationRejectsDuplicateJSONWithoutSideEffects(t *testing.T) { name: "model price", method: http.MethodPut, path: "/api/model-prices", - body: `{"pattern":"duplicate-model","prices":{"uncached_input":1,` + - `"cache_read":2,"cache_read":3,"cache_write_5m":4,` + - `"cache_write_1h":5,"output":6}}`, + body: `{"pattern":"duplicate-model","prices":{` + + `"input_price_usd_per_million_tokens":"1",` + + `"output_price_usd_per_million_tokens":"6",` + + `"cache_read_price_usd_per_million_tokens":"2",` + + `"cache_read_price_usd_per_million_tokens":"3",` + + `"cache_write_5m_price_usd_per_million_tokens":"4",` + + `"cache_write_1h_price_usd_per_million_tokens":"5"}}`, }, { name: "route inspector", @@ -2084,8 +2128,8 @@ func TestSettingsHTTPFiltersPrivateRowsAndDoesNotLogValues(t *testing.T) { t.Fatalf("update response = %d %s", update.Code, update.Body.String()) } for _, row := range []models.SystemSetting{ - {Key: defaultAccessKeyMarker, Value: `"bootstrap-marker-distinctive"`, UpdatedAt: time.Now().UTC()}, - {Key: unknownKey, Value: `"unknown-value"`, UpdatedAt: time.Now().UTC()}, + {Key: defaultAccessKeyMarker, Value: `"bootstrap-marker-distinctive"`, UpdatedAtMS: time.Now().UnixMilli()}, + {Key: unknownKey, Value: `"unknown-value"`, UpdatedAtMS: time.Now().UnixMilli()}, } { if err := fixture.db.Create(&row).Error; err != nil { t.Fatal(err) @@ -2127,6 +2171,7 @@ func TestSettingsHTTPFiltersPrivateRowsAndDoesNotLogValues(t *testing.T) { fixture.service.dialects, fixture.service.requestLogs, fixture.service.usageStats, + fixture.service.homeStatistics, fixture.stats, fixture.requestLogStats, ) diff --git a/internal/control/service.go b/internal/control/service.go index 5be4c14e..5b4c2e1f 100644 --- a/internal/control/service.go +++ b/internal/control/service.go @@ -31,11 +31,13 @@ type Service struct { db *gorm.DB manager *state.Manager registry *state.KeyRegistry + registrySnapshot func() []state.KeyRuntimeView priceRuntime *PriceRuntime encryption encryption.Service dialects dialect.Set requestLogs RequestLogReader usageStats UsageStatReader + homeStatistics HomeStatisticsReader stats *health.StatsStore requestLogStats RequestLogStatsReader modelDiscoveryTimeout time.Duration @@ -62,6 +64,7 @@ func NewService( dialects dialect.Set, requestLogs RequestLogReader, usageStats UsageStatReader, + homeStatistics HomeStatisticsReader, stats *health.StatsStore, requestLogStats RequestLogStatsReader, ) *Service { @@ -69,8 +72,8 @@ func NewService( db: db, manager: manager, registry: registry, priceRuntime: priceRuntime, encryption: encryptionService, dialects: dialects, requestLogs: requestLogs, - usageStats: usageStats, - stats: stats, requestLogStats: requestLogStats, + usageStats: usageStats, homeStatistics: homeStatistics, + stats: stats, requestLogStats: requestLogStats, modelDiscoveryTimeout: defaultModelDiscoveryTimeout, random: rand.Reader, operationRandom: rand.Reader, @@ -79,6 +82,7 @@ func NewService( } service.publishSnapshot = manager.Publish service.reconcileRegistryGroup = registry.ReconcileGroup + service.registrySnapshot = registry.Snapshot return service } diff --git a/internal/control/settings.go b/internal/control/settings.go index 616782b4..b0534d73 100644 --- a/internal/control/settings.go +++ b/internal/control/settings.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + "gpt-load/internal/platform/epochms" app_errors "gpt-load/internal/platform/errors" "gpt-load/internal/state" stateloader "gpt-load/internal/state/loader" @@ -220,13 +221,17 @@ func (s *Service) applySettingUpdates( } continue } + updatedAtMS, err := epochms.FromTime(s.now()) + if err != nil { + return app_errors.ErrInternalServer + } if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "key"}}, - DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}), + DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at_ms"}), }).Create(&models.SystemSetting{ - Key: update.key, - Value: *update.value, - UpdatedAt: s.now().UTC(), + Key: update.key, + Value: *update.value, + UpdatedAtMS: updatedAtMS, }).Error; err != nil { return app_errors.ParseDBError(err) } diff --git a/internal/control/settings_occ_test.go b/internal/control/settings_occ_test.go index 1e118214..acb303bf 100644 --- a/internal/control/settings_occ_test.go +++ b/internal/control/settings_occ_test.go @@ -216,6 +216,7 @@ func TestSettingsETagIgnoresOtherResourcesAndSurvivesRuntimeReload(t *testing.T) fixture.service.dialects, fixture.service.requestLogs, fixture.service.usageStats, + fixture.service.homeStatistics, fixture.stats, fixture.requestLogStats, ) diff --git a/internal/control/settings_test.go b/internal/control/settings_test.go index 18d26811..01878436 100644 --- a/internal/control/settings_test.go +++ b/internal/control/settings_test.go @@ -95,8 +95,8 @@ func TestGetSettingsFiltersAndSortsPublicRuntimeOverrides(t *testing.T) { t.Fatal(err) } for _, row := range []models.SystemSetting{ - {Key: "unknown_public_setting", Value: `"hidden"`, UpdatedAt: time.Now().UTC()}, - {Key: models.InternalSystemSettingPrefix + "hidden", Value: `"marker"`, UpdatedAt: time.Now().UTC()}, + {Key: "unknown_public_setting", Value: `"hidden"`, UpdatedAtMS: time.Now().UnixMilli()}, + {Key: models.InternalSystemSettingPrefix + "hidden", Value: `"marker"`, UpdatedAtMS: time.Now().UnixMilli()}, } { if err := fixture.db.Create(&row).Error; err != nil { t.Fatal(err) @@ -251,8 +251,8 @@ func TestUpdateSettingsCanonicalizesValuesAndReturnsEffectiveHeaderRules(t *test t.Fatalf("persisted value = %q, want %q", row.Value, wantValue) } wantTime := time.Date(2026, time.July, 24, 4, 30, 0, 0, time.UTC) - if !row.UpdatedAt.Equal(wantTime) { - t.Fatalf("updated_at = %s, want %s", row.UpdatedAt, wantTime) + if row.UpdatedAtMS != wantTime.UnixMilli() { + t.Fatalf("updated_at_ms = %d, want %d", row.UpdatedAtMS, wantTime.UnixMilli()) } } diff --git a/internal/control/test_helpers_test.go b/internal/control/test_helpers_test.go index 89051f0e..ac52e909 100644 --- a/internal/control/test_helpers_test.go +++ b/internal/control/test_helpers_test.go @@ -134,6 +134,7 @@ func newServiceFixtureWithDSN(t *testing.T, dsn string) serviceFixture { dialect.NewSet(), nil, nil, + nil, stats, requestLogStats, ), diff --git a/internal/control/upstream_keys.go b/internal/control/upstream_keys.go index b61b54ed..4d4a06b5 100644 --- a/internal/control/upstream_keys.go +++ b/internal/control/upstream_keys.go @@ -23,7 +23,7 @@ type UpstreamKeyResponse struct { WeightManual *int `json:"weight_manual"` WeightAuto int `json:"weight_auto"` Blacklisted bool `json:"blacklisted"` - CooldownUntil *time.Time `json:"cooldown_until"` + CooldownUntilMS *int64 `json:"cooldown_until_ms"` FailureCount int `json:"failure_count"` } @@ -163,9 +163,12 @@ func (s *Service) mapGroupKeys( app_errors.ErrInternalServer, ) } - var cooldownUntil *time.Time + var cooldownUntilMS *int64 if view.CooldownUntil.After(observation.observedAt) { - cooldownUntil = optionalUTC(view.CooldownUntil) + cooldownUntilMS, err = optionalSafeEpochMilliseconds(view.CooldownUntil) + if err != nil { + return nil, fmt.Errorf("map upstream key cooldown_until_ms: %w", err) + } } result = append(result, UpstreamKeyResponse{ ID: row.ID, GroupID: row.GroupID, @@ -176,11 +179,11 @@ func (s *Service) mapGroupKeys( view, observation.observedAt, )), - WeightManual: cloneInt(view.WeightManual), - WeightAuto: view.WeightAuto, - Blacklisted: view.Blacklisted, - CooldownUntil: cooldownUntil, - FailureCount: view.FailureCount, + WeightManual: cloneInt(view.WeightManual), + WeightAuto: view.WeightAuto, + Blacklisted: view.Blacklisted, + CooldownUntilMS: cooldownUntilMS, + FailureCount: view.FailureCount, }) } return result, nil diff --git a/internal/control/upstream_keys_test.go b/internal/control/upstream_keys_test.go index bc4f997c..e133a993 100644 --- a/internal/control/upstream_keys_test.go +++ b/internal/control/upstream_keys_test.go @@ -137,8 +137,8 @@ func TestListGroupKeysReturnsMaskedStableRuntimeView(t *testing.T) { if got[0].Mask != "sk-a****wxyz" || got[0].EffectiveStatus != "cooldown" || got[0].WeightAuto != 42 || - got[0].CooldownUntil == nil || - !got[0].CooldownUntil.Equal(now.Add(time.Minute)) || + got[0].CooldownUntilMS == nil || + *got[0].CooldownUntilMS != now.Add(time.Minute).UnixMilli() || got[0].FailureCount != 1 { t.Fatalf("long response = %#v", got[0]) } @@ -146,7 +146,7 @@ func TestListGroupKeysReturnsMaskedStableRuntimeView(t *testing.T) { got[1].Status != state.KeyStatusDisabled || got[1].EffectiveStatus != "disabled" || !got[1].Blacklisted || - got[1].CooldownUntil != nil || + got[1].CooldownUntilMS != nil || got[1].FailureCount != 3 { t.Fatalf("short response = %#v", got[1]) } @@ -184,7 +184,7 @@ func TestListGroupKeysEffectiveStatusPriorityAndCooldownEquality(t *testing.T) { if err != nil { t.Fatal(err) } - if got[0].EffectiveStatus != "available" || got[0].CooldownUntil != nil { + if got[0].EffectiveStatus != "available" || got[0].CooldownUntilMS != nil { t.Fatalf("cooldown equality = %#v", got[0]) } @@ -200,7 +200,7 @@ func TestListGroupKeysEffectiveStatusPriorityAndCooldownEquality(t *testing.T) { t.Fatal(err) } if got[0].EffectiveStatus != "disabled" || !got[0].Blacklisted || - got[0].CooldownUntil == nil { + got[0].CooldownUntilMS == nil { t.Fatalf("disabled priority/raw state = %#v", got[0]) } } @@ -1086,7 +1086,7 @@ func TestUpdateGroupKeyHTTPSuccessThreeStateFields(t *testing.T) { !equalOptionalWeight(envelope.Data.WeightManual, test.wantWeight) || envelope.Data.WeightAuto != state.DefaultWeight || envelope.Data.Blacklisted || - envelope.Data.CooldownUntil != nil || + envelope.Data.CooldownUntilMS != nil || envelope.Data.FailureCount != 0 { t.Fatalf("PUT success envelope = %#v", envelope) } @@ -1665,7 +1665,7 @@ func TestUpdateGroupKeyChangesConfigAndPreservesAutomaticRuntime(t *testing.T) { if got.Status != state.KeyStatusDisabled || got.WeightManual == nil || *got.WeightManual != 0 || got.WeightAuto != 41 || !got.Blacklisted || - got.FailureCount != 3 || got.CooldownUntil == nil { + got.FailureCount != 3 || got.CooldownUntilMS == nil { t.Fatalf("updated response = %#v", got) } if fixture.manager.Current() != beforeSnapshot { @@ -1688,7 +1688,7 @@ func TestUpdateGroupKeyChangesConfigAndPreservesAutomaticRuntime(t *testing.T) { } if got.WeightManual != nil || got.WeightAuto != 41 || !got.Blacklisted || got.FailureCount != 3 || - got.CooldownUntil == nil { + got.CooldownUntilMS == nil { t.Fatalf("re-enabled response = %#v", got) } var persisted models.UpstreamKey diff --git a/internal/control/usage.go b/internal/control/usage.go index 090da49d..82fd2705 100644 --- a/internal/control/usage.go +++ b/internal/control/usage.go @@ -2,18 +2,17 @@ package control import ( "context" - "encoding/json" + "errors" "fmt" - "math" "net/url" "strconv" "strings" - "time" "unicode" "unicode/utf8" "github.com/gin-gonic/gin" + "gpt-load/internal/platform/epochms" app_errors "gpt-load/internal/platform/errors" "gpt-load/internal/platform/response" "gpt-load/internal/requestlog" @@ -23,7 +22,6 @@ const ( usageRange24Hours = "24h" usageRange30Days = "30d" usageBreakdownMax = 100 - maxSafeInteger = int64(9007199254740991) ) type UsageStatReader interface { @@ -31,24 +29,24 @@ type UsageStatReader interface { } type usageAggregateResponse struct { - RequestCount int64 `json:"request_count"` - SuccessCount int64 `json:"success_count"` - FailureCount int64 `json:"failure_count"` - UncachedInputTokens int64 `json:"uncached_input_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWrite5MTokens int64 `json:"cache_write_5m_tokens"` - CacheWrite1HTokens int64 `json:"cache_write_1h_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` - EstimatedCostUSD json.Number `json:"estimated_cost_usd"` - UsageMissingCount int64 `json:"usage_missing_count"` - PartialCount int64 `json:"partial_count"` - UnpricedRequestCount int64 `json:"unpriced_request_count"` + RequestCount int64 `json:"request_count"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + UncachedInputTokens int64 `json:"uncached_input_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWrite5MTokens int64 `json:"cache_write_5m_tokens"` + CacheWrite1HTokens int64 `json:"cache_write_1h_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` + UsageMissingCount int64 `json:"usage_missing_count"` + PartialCount int64 `json:"partial_count"` + UnpricedRequestCount int64 `json:"unpriced_request_count"` } type usageSeriesResponse struct { - BucketStart string `json:"bucket_start"` - BucketEnd string `json:"bucket_end"` + BucketStartMS int64 `json:"bucket_start_ms"` + BucketEndMS int64 `json:"bucket_end_ms"` usageAggregateResponse } @@ -59,19 +57,18 @@ type usageBreakdownResponse struct { } type usageCollectionHealthResponse struct { - Scope string `json:"scope"` - DroppedTotal uint64 `json:"dropped_total"` - WriteFailureTotal uint64 `json:"write_failure_total"` - LastWriteFailureAt *time.Time `json:"last_write_failure_at"` + Scope string `json:"scope"` + DroppedTotal uint64 `json:"dropped_total"` + WriteFailureTotal uint64 `json:"write_failure_total"` + LastWriteFailureAtMS *int64 `json:"last_write_failure_at_ms"` } type usageResponse struct { Range string `json:"range"` Granularity requestlog.UsageGranularity `json:"granularity"` - Timezone string `json:"timezone"` - From string `json:"from"` - To string `json:"to"` - ObservedAt string `json:"observed_at"` + FromMS int64 `json:"from_ms"` + ToMS int64 `json:"to_ms"` + ObservedAtMS int64 `json:"observed_at_ms"` Summary usageAggregateResponse `json:"summary"` Series []usageSeriesResponse `json:"series"` Breakdown []usageBreakdownResponse `json:"breakdown"` @@ -96,8 +93,12 @@ func (service *Service) QueryUsage( } func (server *Server) handleUsage(c *gin.Context) { - observedAt := server.service.now().UTC() - query, apiErr := parseUsageQuery(c.Request.URL.RawQuery, observedAt) + observedAtMS, err := safeEpochMilliseconds(server.service.now()) + if err != nil { + writeServiceError(c, "usage", err) + return + } + query, apiErr := parseUsageQuery(c.Request.URL.RawQuery, observedAtMS) if apiErr != nil { writeServiceError(c, "usage", apiErr) return @@ -107,7 +108,7 @@ func (server *Server) handleUsage(c *gin.Context) { writeServiceError(c, "usage", err) return } - result, err := server.service.mapUsageResponse(observedAt, query, report) + result, err := server.service.mapUsageResponse(observedAtMS, query, report) if err != nil { writeServiceError(c, "usage", err) return @@ -115,13 +116,15 @@ func (server *Server) handleUsage(c *gin.Context) { response.SuccessI18n(c, "common.success", result) } -func parseUsageQuery(rawQuery string, observedAt time.Time) (requestlog.UsageQuery, *app_errors.APIError) { +func parseUsageQuery(rawQuery string, observedAtMS int64) (requestlog.UsageQuery, *app_errors.APIError) { values, err := url.ParseQuery(rawQuery) if err != nil { return requestlog.UsageQuery{}, app_errors.ErrBadRequest } allowed := map[string]struct{}{ "range": {}, + "from_ms": {}, + "to_ms": {}, "group_id": {}, "model": {}, "breakdown_order": {}, @@ -132,7 +135,9 @@ func parseUsageQuery(rawQuery string, observedAt time.Time) (requestlog.UsageQue } } - observedAt = observedAt.UTC() + if err := validateSafeMilliseconds(observedAtMS); err != nil { + return requestlog.UsageQuery{}, app_errors.ErrInternalServer + } query := requestlog.UsageQuery{ Limit: usageBreakdownMax, BreakdownOrder: requestlog.UsageBreakdownOrderRequests, @@ -141,20 +146,65 @@ func parseUsageQuery(rawQuery string, observedAt time.Time) (requestlog.UsageQue if value, ok := singleQueryValue(values, "range"); ok { rangeValue = value } - switch rangeValue { - case usageRange24Hours: - currentHour := observedAt.Truncate(time.Hour) - query.From = currentHour.Add(-23 * time.Hour) - query.To = currentHour.Add(time.Hour) - query.Granularity = requestlog.UsageGranularityHour - case usageRange30Days: - currentDay := time.Date(observedAt.Year(), observedAt.Month(), observedAt.Day(), 0, 0, 0, 0, time.UTC) - query.From = currentDay.AddDate(0, 0, -29) - query.To = currentDay.AddDate(0, 0, 1) - query.Granularity = requestlog.UsageGranularityDay - default: + fromValue, hasFrom := singleQueryValue(values, "from_ms") + toValue, hasTo := singleQueryValue(values, "to_ms") + if hasFrom != hasTo { return requestlog.UsageQuery{}, app_errors.ErrValidation } + if hasFrom { + if _, hasRange := singleQueryValue(values, "range"); hasRange { + return requestlog.UsageQuery{}, app_errors.ErrValidation + } + fromMS, err := parseCanonicalSafeMilliseconds(fromValue) + if err != nil { + return requestlog.UsageQuery{}, app_errors.ErrBadRequest + } + toMS, err := parseCanonicalSafeMilliseconds(toValue) + if err != nil { + return requestlog.UsageQuery{}, app_errors.ErrBadRequest + } + if fromMS >= toMS { + return requestlog.UsageQuery{}, app_errors.ErrValidation + } + query.FromMS = fromMS + query.ToMS = toMS + if toMS-fromMS <= epochms.MillisecondsPerDay { + query.Granularity = requestlog.UsageGranularityHour + } else { + query.Granularity = requestlog.UsageGranularityDay + } + rangeValue = "" + } + if !hasFrom { + switch rangeValue { + case usageRange24Hours: + fromMS, toMS, err := epochms.WindowEndingAt( + observedAtMS, + epochms.MillisecondsPerHour, + 24, + ) + if err != nil { + return requestlog.UsageQuery{}, app_errors.ErrInternalServer + } + query.FromMS = fromMS + query.ToMS = toMS + query.Granularity = requestlog.UsageGranularityHour + case usageRange30Days: + fromMS, toMS, err := epochms.WindowEndingAt( + observedAtMS, + epochms.MillisecondsPerDay, + 30, + ) + if err != nil { + return requestlog.UsageQuery{}, app_errors.ErrInternalServer + } + query.FromMS = fromMS + query.ToMS = toMS + query.Granularity = requestlog.UsageGranularityDay + default: + return requestlog.UsageQuery{}, app_errors.ErrValidation + } + } if value, ok := singleQueryValue(values, "group_id"); ok { groupID, apiErr := parseUsageGroupID(value) if apiErr != nil { @@ -195,18 +245,21 @@ func validUsageModel(value string) bool { } func parseUsageGroupID(value string) (uint, *app_errors.APIError) { - parsed, err := strconv.ParseUint(value, 10, 64) + parsed, err := parseCanonicalSafeUint(value) if err != nil { + if errors.Is(err, errUnsafeCanonicalUint) { + return 0, app_errors.ErrValidation + } return 0, app_errors.ErrBadRequest } - if parsed == 0 || parsed > uint64(maxSafeInteger) || uint64(uint(parsed)) != parsed { + if parsed == 0 || uint64(uint(parsed)) != parsed { return 0, app_errors.ErrValidation } return uint(parsed), nil } func (service *Service) mapUsageResponse( - observedAt time.Time, + observedAtMS int64, query requestlog.UsageQuery, report requestlog.UsageReport, ) (usageResponse, error) { @@ -241,18 +294,30 @@ func (service *Service) mapUsageResponse( default: return usageResponse{}, fmt.Errorf("map usage response: invalid granularity") } + lastWriteFailureAtMS, err := optionalSafeEpochMilliseconds(stats.LastWriteFailureAt) + if err != nil { + return usageResponse{}, fmt.Errorf("map usage collection health: %w", err) + } + if err := validateSafeMilliseconds(query.FromMS); err != nil { + return usageResponse{}, fmt.Errorf("map usage from_ms: %w", err) + } + if err := validateSafeMilliseconds(query.ToMS); err != nil { + return usageResponse{}, fmt.Errorf("map usage to_ms: %w", err) + } + if err := validateSafeMilliseconds(observedAtMS); err != nil { + return usageResponse{}, fmt.Errorf("map usage observed_at_ms: %w", err) + } result := usageResponse{ - Range: rangeValue, - Granularity: query.Granularity, - Timezone: "UTC", - From: query.From.UTC().Format(time.RFC3339Nano), - To: query.To.UTC().Format(time.RFC3339Nano), - ObservedAt: observedAt.UTC().Format(time.RFC3339Nano), + Range: rangeValue, + Granularity: query.Granularity, + FromMS: query.FromMS, + ToMS: query.ToMS, + ObservedAtMS: observedAtMS, CollectionHealth: usageCollectionHealthResponse{ - Scope: "current_process", - DroppedTotal: stats.DroppedTotal, - WriteFailureTotal: stats.WriteFailureTotal, - LastWriteFailureAt: optionalUTC(stats.LastWriteFailureAt), + Scope: "current_process", + DroppedTotal: stats.DroppedTotal, + WriteFailureTotal: stats.WriteFailureTotal, + LastWriteFailureAtMS: lastWriteFailureAtMS, }, Summary: summary, Series: make([]usageSeriesResponse, 0, len(report.Series)), @@ -262,19 +327,25 @@ func (service *Service) mapUsageResponse( BreakdownGroupCount: report.BreakdownGroupCount, } for _, point := range report.Series { + if err := validateSafeMilliseconds(point.BucketStartMS); err != nil { + return usageResponse{}, fmt.Errorf("map usage bucket_start_ms: %w", err) + } + if err := validateSafeMilliseconds(point.BucketEndMS); err != nil { + return usageResponse{}, fmt.Errorf("map usage bucket_end_ms: %w", err) + } aggregate, err := mapUsageAggregate(point.UsageAggregate) if err != nil { return usageResponse{}, err } result.Series = append(result.Series, usageSeriesResponse{ - BucketStart: point.BucketStart.UTC().Format(time.RFC3339Nano), - BucketEnd: point.BucketEnd.UTC().Format(time.RFC3339Nano), + BucketStartMS: point.BucketStartMS, + BucketEndMS: point.BucketEndMS, usageAggregateResponse: aggregate, }) } for _, row := range report.Breakdown { - if row.GroupID == 0 || uint64(row.GroupID) > uint64(maxSafeInteger) || row.Model == "" { - return usageResponse{}, fmt.Errorf("map usage breakdown: invalid group or model") + if uint64(row.GroupID) > uint64(maxSafeInteger) { + return usageResponse{}, fmt.Errorf("map usage breakdown: unsafe group") } aggregate, err := mapUsageAggregate(row.UsageAggregate) if err != nil { @@ -309,16 +380,16 @@ func mapUsageAggregate(source requestlog.UsageAggregate) (usageAggregateResponse if err != nil { return usageAggregateResponse{}, err } - cost, err := mapEstimatedCostUSD(source.Cost) - if err != nil { - return usageAggregateResponse{}, err + if source.EstimatedCostNanoUSD < 0 { + return usageAggregateResponse{}, fmt.Errorf("map usage cost: negative value") } return usageAggregateResponse{ RequestCount: source.RequestCount, SuccessCount: source.SuccessCount, FailureCount: source.FailureCount, UncachedInputTokens: source.UncachedInputTokens, CacheReadTokens: source.CacheReadTokens, CacheWrite5MTokens: source.CacheWrite5MTokens, CacheWrite1HTokens: source.CacheWrite1HTokens, - OutputTokens: source.OutputTokens, TotalTokens: totalTokens, EstimatedCostUSD: cost, - UsageMissingCount: source.UsageMissingCount, PartialCount: source.PartialCount, + OutputTokens: source.OutputTokens, TotalTokens: totalTokens, + EstimatedCostNanoUSD: strconv.FormatInt(source.EstimatedCostNanoUSD, 10), + UsageMissingCount: source.UsageMissingCount, PartialCount: source.PartialCount, UnpricedRequestCount: source.UnpricedRequestCount, }, nil } @@ -333,13 +404,3 @@ func checkedUsageTokenTotal(tokens ...int64) (int64, error) { } return total, nil } - -func mapEstimatedCostUSD(value float64) (json.Number, error) { - if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { - return "", fmt.Errorf("map usage cost: invalid value") - } - if value == 0 { - value = 0 - } - return json.Number(strconv.FormatFloat(value, 'g', 12, 64)), nil -} diff --git a/internal/control/usage_test.go b/internal/control/usage_test.go index a6ace4d0..4df620de 100644 --- a/internal/control/usage_test.go +++ b/internal/control/usage_test.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "errors" - "math" "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -16,6 +16,7 @@ import ( "gpt-load/internal/platform/config" "gpt-load/internal/requestlog" + "gpt-load/internal/storage/models" ) func TestUsageAPIRouteUsesManagementAuthentication(t *testing.T) { @@ -66,19 +67,20 @@ func TestParseUsageQueryUsesFixedUTCAlignedWindows(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - query, apiErr := parseUsageQuery(test.rawQuery, test.observedAt) + query, apiErr := parseUsageQuery(test.rawQuery, test.observedAt.UnixMilli()) if apiErr != nil { t.Fatalf("parseUsageQuery() error = %v", apiErr) } - if !query.From.Equal(test.wantFrom) || !query.To.Equal(test.wantTo) || + if query.FromMS != test.wantFrom.UnixMilli() || + query.ToMS != test.wantTo.UnixMilli() || query.Granularity != test.granularity { t.Fatalf( - "parseUsageQuery() window = %s to %s (%s), want %s to %s (%s)", - query.From.Format(time.RFC3339Nano), - query.To.Format(time.RFC3339Nano), + "parseUsageQuery() window = %d to %d (%s), want %d to %d (%s)", + query.FromMS, + query.ToMS, query.Granularity, - test.wantFrom.Format(time.RFC3339Nano), - test.wantTo.Format(time.RFC3339Nano), + test.wantFrom.UnixMilli(), + test.wantTo.UnixMilli(), test.granularity, ) } @@ -106,8 +108,8 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing } query := reader.queries[0] if query.Granularity != requestlog.UsageGranularityHour || - query.From.Format(time.RFC3339Nano) != "2026-07-26T05:00:00Z" || - query.To.Format(time.RFC3339Nano) != "2026-07-27T05:00:00Z" || + query.FromMS != time.Date(2026, time.July, 26, 5, 0, 0, 0, time.UTC).UnixMilli() || + query.ToMS != time.Date(2026, time.July, 27, 5, 0, 0, 0, time.UTC).UnixMilli() || query.GroupID != nil || query.Model != "" || query.Limit != 100 || query.BreakdownOrder != requestlog.UsageBreakdownOrderRequests { t.Fatalf("default UsageQuery = %#v", query) @@ -115,21 +117,20 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing var envelope struct { Code int `json:"code"` Data struct { - Range string `json:"range"` - Granularity requestlog.UsageGranularity `json:"granularity"` - Timezone string `json:"timezone"` - From string `json:"from"` - To string `json:"to"` - ObservedAt string `json:"observed_at"` - Series []json.RawMessage `json:"series"` - Breakdown []json.RawMessage `json:"breakdown"` - Order requestlog.UsageBreakdownOrder `json:"breakdown_order"` - GroupCount int64 `json:"breakdown_group_count"` - Health struct { - Scope string `json:"scope"` - DroppedTotal uint64 `json:"dropped_total"` - WriteFailureTotal uint64 `json:"write_failure_total"` - LastWriteFailureAt *time.Time `json:"last_write_failure_at"` + Range string `json:"range"` + Granularity requestlog.UsageGranularity `json:"granularity"` + FromMS int64 `json:"from_ms"` + ToMS int64 `json:"to_ms"` + ObservedAtMS int64 `json:"observed_at_ms"` + Series []json.RawMessage `json:"series"` + Breakdown []json.RawMessage `json:"breakdown"` + Order requestlog.UsageBreakdownOrder `json:"breakdown_order"` + GroupCount int64 `json:"breakdown_group_count"` + Health struct { + Scope string `json:"scope"` + DroppedTotal uint64 `json:"dropped_total"` + WriteFailureTotal uint64 `json:"write_failure_total"` + LastWriteFailureAtMS *int64 `json:"last_write_failure_at_ms"` } `json:"collection_health"` } `json:"data"` } @@ -138,10 +139,9 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing } if envelope.Code != 0 || envelope.Data.Range != "24h" || envelope.Data.Granularity != requestlog.UsageGranularityHour || - envelope.Data.Timezone != "UTC" || - envelope.Data.From != "2026-07-26T05:00:00Z" || - envelope.Data.To != "2026-07-27T05:00:00Z" || - envelope.Data.ObservedAt != "2026-07-27T04:34:56.000000789Z" || + envelope.Data.FromMS != time.Date(2026, time.July, 26, 5, 0, 0, 0, time.UTC).UnixMilli() || + envelope.Data.ToMS != time.Date(2026, time.July, 27, 5, 0, 0, 0, time.UTC).UnixMilli() || + envelope.Data.ObservedAtMS != now.UnixMilli() || envelope.Data.Series == nil || len(envelope.Data.Series) != 0 || envelope.Data.Breakdown == nil || len(envelope.Data.Breakdown) != 0 || envelope.Data.Order != requestlog.UsageBreakdownOrderRequests || @@ -149,8 +149,8 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing envelope.Data.Health.Scope != "current_process" || envelope.Data.Health.DroppedTotal != 2 || envelope.Data.Health.WriteFailureTotal != 1 || - envelope.Data.Health.LastWriteFailureAt == nil || - envelope.Data.Health.LastWriteFailureAt.Format(time.RFC3339Nano) != "2026-07-27T03:00:00Z" { + envelope.Data.Health.LastWriteFailureAtMS == nil || + *envelope.Data.Health.LastWriteFailureAtMS != time.Date(2026, time.July, 27, 3, 0, 0, 0, time.UTC).UnixMilli() { t.Fatalf("default usage envelope = %#v", envelope) } var rawEnvelope struct { @@ -159,7 +159,10 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing if err := json.Unmarshal(recorder.Body.Bytes(), &rawEnvelope); err != nil { t.Fatalf("decode raw response: %v", err) } - for _, forbidden := range []string{"filters", "request_log"} { + for _, forbidden := range []string{ + "filters", "request_log", "timezone", "from", "to", "observed_at", + "estimated_cost" + "_usd", + } { if _, exists := rawEnvelope.Data[forbidden]; exists { t.Fatalf("usage response exposes forbidden %q field: %s", forbidden, recorder.Body.String()) } @@ -169,17 +172,21 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) { now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC) reader := &recordingUsageStatReader{report: requestlog.UsageReport{ - Summary: requestlog.UsageAggregate{RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3, Cost: math.Copysign(0, -1)}, + Summary: requestlog.UsageAggregate{RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3}, Series: []requestlog.UsageSeriesPoint{{ - BucketStart: time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC), - BucketEnd: time.Date(2026, time.June, 29, 0, 0, 0, 0, time.UTC), + BucketStartMS: time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli(), + BucketEndMS: time.Date(2026, time.June, 29, 0, 0, 0, 0, time.UTC).UnixMilli(), UsageAggregate: requestlog.UsageAggregate{ - RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3, Cost: 1.1234567890123, + RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3, + EstimatedCostNanoUSD: 1_123_456_789_012, }, }}, Breakdown: []requestlog.UsageBreakdown{{ GroupID: 9, Model: "upstream-model", - UsageAggregate: requestlog.UsageAggregate{RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3, Cost: 0.25}, + UsageAggregate: requestlog.UsageAggregate{ + RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3, + EstimatedCostNanoUSD: 250_000_000, + }, }}, BreakdownOrder: requestlog.UsageBreakdownOrderCost, BreakdownGroupCount: 1, @@ -198,8 +205,8 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) { } query := reader.queries[0] if query.Granularity != requestlog.UsageGranularityDay || - query.From.Format(time.RFC3339Nano) != "2026-06-28T00:00:00Z" || - query.To.Format(time.RFC3339Nano) != "2026-07-28T00:00:00Z" || + query.FromMS != time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli() || + query.ToMS != time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC).UnixMilli() || query.GroupID == nil || *query.GroupID != 9 || query.Model != "upstream-model" || query.Limit != 100 || query.BreakdownOrder != requestlog.UsageBreakdownOrderCost { @@ -209,24 +216,23 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) { Data struct { Range string `json:"range"` Granularity requestlog.UsageGranularity `json:"granularity"` - Timezone string `json:"timezone"` - From string `json:"from"` - To string `json:"to"` + FromMS int64 `json:"from_ms"` + ToMS int64 `json:"to_ms"` Order requestlog.UsageBreakdownOrder `json:"breakdown_order"` GroupCount int64 `json:"breakdown_group_count"` Summary struct { - TotalTokens int64 `json:"total_tokens"` - EstimatedCostUSD json.Number `json:"estimated_cost_usd"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` } `json:"summary"` Series []struct { - BucketStart string `json:"bucket_start"` - TotalTokens int64 `json:"total_tokens"` - EstimatedCostUSD json.Number `json:"estimated_cost_usd"` + BucketStartMS int64 `json:"bucket_start_ms"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` } `json:"series"` Breakdown []struct { - GroupID uint `json:"group_id"` - Model string `json:"model"` - Cost json.Number `json:"estimated_cost_usd"` + GroupID uint `json:"group_id"` + Model string `json:"model"` + EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"` } `json:"breakdown"` } `json:"data"` } @@ -235,17 +241,19 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) { } if envelope.Data.Range != "30d" || envelope.Data.Granularity != requestlog.UsageGranularityDay || - envelope.Data.Timezone != "UTC" || - envelope.Data.From != "2026-06-28T00:00:00Z" || - envelope.Data.To != "2026-07-28T00:00:00Z" || + envelope.Data.FromMS != time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli() || + envelope.Data.ToMS != time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC).UnixMilli() || envelope.Data.Order != requestlog.UsageBreakdownOrderCost || envelope.Data.GroupCount != 1 || envelope.Data.Summary.TotalTokens != 5 || - envelope.Data.Summary.EstimatedCostUSD.String() != "0" || - len(envelope.Data.Series) != 1 || envelope.Data.Series[0].BucketStart != "2026-06-28T00:00:00Z" || - envelope.Data.Series[0].TotalTokens != 5 || envelope.Data.Series[0].EstimatedCostUSD.String() != "1.12345678901" || + envelope.Data.Summary.EstimatedCostNanoUSD != "0" || + len(envelope.Data.Series) != 1 || + envelope.Data.Series[0].BucketStartMS != time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli() || + envelope.Data.Series[0].TotalTokens != 5 || + envelope.Data.Series[0].EstimatedCostNanoUSD != "1123456789012" || len(envelope.Data.Breakdown) != 1 || envelope.Data.Breakdown[0].GroupID != 9 || - envelope.Data.Breakdown[0].Model != "upstream-model" || envelope.Data.Breakdown[0].Cost.String() != "0.25" { + envelope.Data.Breakdown[0].Model != "upstream-model" || + envelope.Data.Breakdown[0].EstimatedCostNanoUSD != "250000000" { t.Fatalf("usage response = %#v", envelope.Data) } } @@ -304,8 +312,18 @@ func TestUsageAPIRejectsStrictInvalidQueriesWithoutCallingReader(t *testing.T) { }{ {query: "unknown=1", code: "BAD_REQUEST"}, {query: "range=24h&range=30d", code: "BAD_REQUEST"}, + {query: "from=1&to=2", code: "BAD_REQUEST"}, + {query: "from_ms=1", code: "VALIDATION_FAILED"}, + {query: "from_ms=01&to_ms=2", code: "BAD_REQUEST"}, + {query: "from_ms=-1&to_ms=2", code: "BAD_REQUEST"}, + {query: "from_ms=1&to_ms=1", code: "VALIDATION_FAILED"}, + {query: "range=24h&from_ms=1&to_ms=2", code: "VALIDATION_FAILED"}, {query: "range=1h", code: "VALIDATION_FAILED"}, {query: "group_id=0", code: "VALIDATION_FAILED"}, + {query: "group_id=01", code: "BAD_REQUEST"}, + {query: "group_id=%2B1", code: "BAD_REQUEST"}, + {query: "group_id=%201", code: "BAD_REQUEST"}, + {query: "group_id=1&group_id=2", code: "BAD_REQUEST"}, {query: "group_id=9007199254740992", code: "VALIDATION_FAILED"}, {query: "group_id=-1", code: "BAD_REQUEST"}, {query: "model=", code: "VALIDATION_FAILED"}, @@ -330,8 +348,8 @@ func TestUsageAPIRejectsStrictInvalidQueriesWithoutCallingReader(t *testing.T) { func TestMapUsageRejectsUnsafeOrMismatchedBreakdownMetadata(t *testing.T) { fixture := newServiceFixture(t) query := requestlog.UsageQuery{ - From: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), - To: time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC), + FromMS: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC).UnixMilli(), + ToMS: time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC).UnixMilli(), Granularity: requestlog.UsageGranularityHour, Limit: 100, BreakdownOrder: requestlog.UsageBreakdownOrderRequests, @@ -364,7 +382,7 @@ func TestMapUsageRejectsUnsafeOrMismatchedBreakdownMetadata(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { if _, err := fixture.service.mapUsageResponse( - query.To, + query.ToMS, query, test.report, ); err == nil { @@ -421,16 +439,66 @@ func TestUsageAPIRejectsUnsafeProcessStatsWithoutLeakingCause(t *testing.T) { } } -func TestUsageAPIRejectsUnattributedBreakdownWithoutLeakingCause(t *testing.T) { - reader := &recordingUsageStatReader{report: requestlog.UsageReport{ - Breakdown: []requestlog.UsageBreakdown{{GroupID: 0, Model: "unattributed"}}, - }} - engine, _ := newUsageTestEngine(t, time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), reader) +func TestUsageAPIReturnsUnattributedAggregateFromSQLite(t *testing.T) { + initControlI18n(t) + now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC) + fixture := newServiceFixture(t) + if err := fixture.db.Create(&models.UsageStat{ + BucketStartMS: now.Add(-time.Hour).UnixMilli(), + AccessKeyID: 0, + GroupID: 0, + Model: "", + RequestCount: 1, + SuccessCount: 1, + }).Error; err != nil { + t.Fatalf("create unattributed UsageStat: %v", err) + } + fixture.service.now = func() time.Time { return now } + fixture.service.usageStats = requestlog.NewService(fixture.db, nil, nil, nil) + engine := gin.New() + NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine) + recorder := performUsageRequest(engine, "test-auth-key", "") - if recorder.Code != http.StatusInternalServerError || - !strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") || - strings.Contains(strings.ToLower(recorder.Body.String()), "group") { - t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String()) + if recorder.Code != http.StatusOK { + t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Data struct { + Breakdown []struct { + GroupID uint `json:"group_id"` + Model string `json:"model"` + RequestCount int64 `json:"request_count"` + } `json:"breakdown"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(envelope.Data.Breakdown) != 1 || + envelope.Data.Breakdown[0].GroupID != 0 || + envelope.Data.Breakdown[0].Model != "" || + envelope.Data.Breakdown[0].RequestCount != 1 { + t.Fatalf("unattributed breakdown = %#v", envelope.Data.Breakdown) + } +} + +func TestUsageAPIAcceptsMaximumSafeCanonicalGroupID(t *testing.T) { + if strconv.IntSize != 64 { + t.Skip("maximum JavaScript safe integer does not fit uint on this architecture") + } + reader := &recordingUsageStatReader{} + engine, _ := newUsageTestEngine( + t, + time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), + reader, + ) + recorder := performUsageRequest(engine, "test-auth-key", "group_id=9007199254740991") + if recorder.Code != http.StatusOK { + t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String()) + } + if len(reader.queries) != 1 || reader.queries[0].GroupID == nil || + uint64(*reader.queries[0].GroupID) != uint64(maxSafeInteger) { + t.Fatalf("QueryUsage calls = %#v", reader.queries) } } diff --git a/internal/control/wire_contract.go b/internal/control/wire_contract.go new file mode 100644 index 00000000..db890f6d --- /dev/null +++ b/internal/control/wire_contract.go @@ -0,0 +1,67 @@ +package control + +import ( + "errors" + "fmt" + "strconv" + "time" + + "gpt-load/internal/platform/epochms" +) + +const maxSafeInteger = int64(9_007_199_254_740_991) + +var errUnsafeCanonicalUint = errors.New( + "management unsigned integer is outside the JSON safe integer range", +) + +func safeEpochMilliseconds(value time.Time) (int64, error) { + milliseconds, err := epochms.FromTime(value) + if err != nil { + return 0, fmt.Errorf("map management timestamp: %w", err) + } + if milliseconds > maxSafeInteger { + return 0, fmt.Errorf("map management timestamp: unsafe integer") + } + return milliseconds, nil +} + +func optionalSafeEpochMilliseconds(value time.Time) (*int64, error) { + if value.IsZero() { + return nil, nil + } + milliseconds, err := safeEpochMilliseconds(value) + if err != nil { + return nil, err + } + return &milliseconds, nil +} + +func validateSafeMilliseconds(value int64) error { + if value < 0 || value > maxSafeInteger { + return fmt.Errorf("management timestamp is outside the JSON safe integer range") + } + return nil +} + +func parseCanonicalSafeMilliseconds(value string) (int64, error) { + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || strconv.FormatInt(parsed, 10) != value { + return 0, fmt.Errorf("management timestamp must be canonical base-10") + } + if err := validateSafeMilliseconds(parsed); err != nil { + return 0, err + } + return parsed, nil +} + +func parseCanonicalSafeUint(value string) (uint64, error) { + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || strconv.FormatUint(parsed, 10) != value { + return 0, fmt.Errorf("management unsigned integer must be canonical base-10") + } + if parsed > uint64(maxSafeInteger) { + return 0, errUnsafeCanonicalUint + } + return parsed, nil +} diff --git a/internal/control/wire_v3_contract_test.go b/internal/control/wire_v3_contract_test.go new file mode 100644 index 00000000..32a554ac --- /dev/null +++ b/internal/control/wire_v3_contract_test.go @@ -0,0 +1,226 @@ +package control + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAccessKeyMillisWireOmitsLegacyTimestampKeys(t *testing.T) { + encoded, err := json.Marshal(AccessKeyMetadata{ + ID: 7, + CreatedAtMS: 1_784_894_400_000, + UpdatedAtMS: 1_784_898_000_000, + }) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + text := string(encoded) + if !strings.Contains(text, `"created_at_ms":1784894400000`) || + !strings.Contains(text, `"updated_at_ms":1784898000000`) || + strings.Contains(text, `"created_at"`) || + strings.Contains(text, `"updated_at"`) { + t.Fatalf("AccessKeyMetadata JSON = %s", text) + } +} + +func TestRevealMillisWireOmitsLegacyTimestampKey(t *testing.T) { + assertManagementWireObject( + t, + AccessKeyRevealResult{ID: 7, Key: "secret", RevealedAtMS: 1_784_894_400_000}, + []string{"id", "key", "revealed_at_ms"}, + ) +} + +func TestHealthMillisWireUsesNullableEpochMilliseconds(t *testing.T) { + cooldownUntilMS := int64(1_784_894_460_000) + assertManagementWireObject( + t, + runtimeHealthResponse{ + ObservedAtMS: 1_784_894_400_000, + CooldownKeys: []healthProblemKeyResponse{{ + CooldownUntilMS: &cooldownUntilMS, + Recovery: healthRecoveryResponse{ + Automatic: true, + Mode: "cooldown_expiry", + AtMS: &cooldownUntilMS, + }, + }}, + RequestLog: requestLogHealthResponse{ + LastWriteFailureAtMS: &cooldownUntilMS, + }, + }, + []string{ + "observed_at_ms", + "cooldown_keys", + "request_log", + }, + ) +} + +func TestInspectAndUpstreamMillisWireUseEpochMilliseconds(t *testing.T) { + cooldownUntilMS := int64(1_784_894_460_000) + assertManagementWireObject( + t, + routeInspectResponse{ + ObservedAtMS: 1_784_894_400_000, + Groups: []routeInspectGroupResponse{{ + Keys: []routeInspectKeyResponse{{ + CooldownUntilMS: &cooldownUntilMS, + }}, + }}, + }, + []string{"observed_at_ms", "groups"}, + ) + assertManagementWireObject( + t, + UpstreamKeyResponse{CooldownUntilMS: &cooldownUntilMS}, + []string{"cooldown_until_ms"}, + ) +} + +func TestRequestLogMillisCostWireUsesCursorV2Fields(t *testing.T) { + assertManagementWireObject( + t, + requestLogCursorPayload{ + Version: requestLogCursorV2, + CompletedAtMS: 1_784_894_400_000, + RequestID: "00000000-0000-4000-8000-000000000001", + }, + []string{"v", "completed_at_ms", "request_id"}, + ) + assertManagementWireObject( + t, + requestLogItemResponse{ + CompletedAtMS: 1_784_894_400_000, + EstimatedCostNanoUSD: "125000000", + }, + []string{"completed_at_ms", "estimated_cost_nano_usd"}, + ) +} + +func TestUsageMillisCostWireUsesIntegerBucketsAndStringCost(t *testing.T) { + assertManagementWireObject( + t, + usageResponse{ + FromMS: 1_784_894_400_000, + ToMS: 1_784_898_000_000, + ObservedAtMS: 1_784_895_000_000, + Summary: usageAggregateResponse{ + EstimatedCostNanoUSD: "125000000", + }, + Series: []usageSeriesResponse{{ + BucketStartMS: 1_784_894_400_000, + BucketEndMS: 1_784_898_000_000, + }}, + }, + []string{"from_ms", "to_ms", "observed_at_ms", "summary", "series"}, + ) +} + +func TestModelPriceWireUsesFiveNullableDecimalStrings(t *testing.T) { + zero := "0" + encoded, err := json.Marshal(modelPriceRuleResponse{ + Prices: modelPriceValuesResponse{ + InputPrice: &zero, + }, + UpdatedAtMS: 1_784_894_400_000, + }) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var document struct { + Prices map[string]*string `json:"prices"` + UpdatedAtMS int64 `json:"updated_at_ms"` + } + if err := json.Unmarshal(encoded, &document); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + wantPriceKeys := []string{ + "input_price_usd_per_million_tokens", + "output_price_usd_per_million_tokens", + "cache_read_price_usd_per_million_tokens", + "cache_write_5m_price_usd_per_million_tokens", + "cache_write_1h_price_usd_per_million_tokens", + } + if len(document.Prices) != len(wantPriceKeys) || + document.UpdatedAtMS != 1_784_894_400_000 { + t.Fatalf("ModelPrice JSON = %s", encoded) + } + for _, key := range wantPriceKeys { + if _, exists := document.Prices[key]; !exists { + t.Fatalf("ModelPrice JSON missing %q: %s", key, encoded) + } + } +} + +func TestIdempotencyOperationMillisWireOmitsLegacyTimestampKey(t *testing.T) { + assertManagementWireObject( + t, + operationExpiredData{ + OperationID: "00000000-0000-4000-8000-000000000001", + OperationKind: operationKindAccessKeyCreate, + CompletedAtMS: 1_784_894_400_000, + }, + []string{"operation_id", "operation_kind", "resource_identity", "completed_at_ms"}, + ) +} + +func assertManagementWireObject(t *testing.T, value any, requiredKeys []string) { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var document map[string]json.RawMessage + if err := json.Unmarshal(encoded, &document); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + for _, key := range requiredKeys { + if _, exists := document[key]; !exists { + t.Fatalf("management wire missing %q: %s", key, encoded) + } + } + var decoded any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + assertNoLegacyManagementWireKeys(t, decoded, encoded) +} + +func assertNoLegacyManagementWireKeys(t *testing.T, value any, encoded []byte) { + t.Helper() + legacyKeys := map[string]struct{}{} + for _, key := range []string{ + "created_at", + "updated_at", + "completed_at", + "observed_at", + "cooldown_until", + "bucket_start", + "bucket_end", + "revealed_at", + "from", + "to", + "estimated_cost" + "_usd", + } { + legacyKeys[key] = struct{}{} + } + var visit func(any) + visit = func(current any) { + switch typed := current.(type) { + case map[string]any: + for key, child := range typed { + if _, legacy := legacyKeys[key]; legacy { + t.Fatalf("management wire exposes legacy key %q: %s", key, encoded) + } + visit(child) + } + case []any: + for _, child := range typed { + visit(child) + } + } + } + visit(value) +} diff --git a/internal/dialect/anthropic_usage_test.go b/internal/dialect/anthropic_usage_test.go index 64960bd2..0b4ab0c5 100644 --- a/internal/dialect/anthropic_usage_test.go +++ b/internal/dialect/anthropic_usage_test.go @@ -533,15 +533,16 @@ func TestUsageAnthropicServerToolDetailRemainsUnpriced(t *testing.T) { table, err := pricing.Compile([]pricing.Rule{{ Pattern: "claude-test", Prices: pricing.Prices{ - UncachedInput: pricing.Price{Value: 1, Set: true}, - Output: pricing.Price{Value: 1, Set: true}, + UncachedInput: pricing.Price{NanoUSDPerMillion: 1_000_000_000, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 1_000_000_000, Set: true}, }, Source: pricing.SourceUser, }}) if err != nil { t.Fatal(err) } - if quote := table.Quote("claude-test", result); quote.State != pricing.CostStateUnpriced || quote.Cost != 0 { + if quote := table.Quote("claude-test", result); quote.State != pricing.CostStateUnpriced || + quote.EstimatedCostNanoUSD != 0 { t.Fatalf("Quote() = %+v, want unpriced zero", quote) } } @@ -550,8 +551,8 @@ func TestUsageAnthropicNonStreamServerToolDetailPricing(t *testing.T) { table, err := pricing.Compile([]pricing.Rule{{ Pattern: "claude-test", Prices: pricing.Prices{ - UncachedInput: pricing.Price{Value: 1, Set: true}, - Output: pricing.Price{Value: 1, Set: true}, + UncachedInput: pricing.Price{NanoUSDPerMillion: 1_000_000_000, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 1_000_000_000, Set: true}, }, Source: pricing.SourceUser, }}) diff --git a/internal/platform/epochms/epochms.go b/internal/platform/epochms/epochms.go new file mode 100644 index 00000000..3367cc33 --- /dev/null +++ b/internal/platform/epochms/epochms.go @@ -0,0 +1,69 @@ +// Package epochms provides checked Unix epoch millisecond primitives. +package epochms + +import ( + "errors" + "math" + "time" +) + +const ( + MillisecondsPerHour int64 = 3_600_000 + MillisecondsPerDay int64 = 86_400_000 +) + +var errInvalidEpochMilliseconds = errors.New("epoch milliseconds must be non-negative") + +// FromTime converts value to a non-negative Unix epoch millisecond timestamp. +func FromTime(value time.Time) (int64, error) { + milliseconds := value.UTC().UnixMilli() + if milliseconds < 0 { + return 0, errInvalidEpochMilliseconds + } + return milliseconds, nil +} + +// ToTime converts a non-negative Unix epoch millisecond timestamp to UTC. +func ToTime(value int64) (time.Time, error) { + if value < 0 { + return time.Time{}, errInvalidEpochMilliseconds + } + return time.Unix(value/1_000, (value%1_000)*int64(time.Millisecond)).UTC(), nil +} + +// AlignDown returns the Unix epoch boundary at or before value. +func AlignDown(value, width int64) (int64, error) { + if value < 0 { + return 0, errInvalidEpochMilliseconds + } + if width <= 0 { + return 0, errors.New("epoch millisecond width must be positive") + } + return value - value%width, nil +} + +// WindowEndingAt returns count full buckets ending after observedAt's bucket. +func WindowEndingAt(observedAt, width int64, count int) (from, to int64, err error) { + if count <= 0 { + return 0, 0, errors.New("epoch millisecond window count must be positive") + } + + start, err := AlignDown(observedAt, width) + if err != nil { + return 0, 0, err + } + if start > math.MaxInt64-width { + return 0, 0, errors.New("epoch millisecond bucket end overflows int64") + } + to = start + width + + windowWidth := int64(count) + if width > math.MaxInt64/windowWidth { + return 0, 0, errors.New("epoch millisecond window width overflows int64") + } + windowWidth *= width + if windowWidth > to { + return 0, 0, errors.New("epoch millisecond window begins before the Unix epoch") + } + return to - windowWidth, to, nil +} diff --git a/internal/platform/epochms/epochms_test.go b/internal/platform/epochms/epochms_test.go new file mode 100644 index 00000000..c2586a2f --- /dev/null +++ b/internal/platform/epochms/epochms_test.go @@ -0,0 +1,111 @@ +package epochms + +import ( + "math" + "testing" + "time" +) + +func TestFromTimeNormalizesToUTCAndTruncatesToMilliseconds(t *testing.T) { + value := time.Date(2026, 7, 30, 8, 9, 10, 987_654_321, time.FixedZone("UTC+8", 8*60*60)) + + got, err := FromTime(value) + if err != nil { + t.Fatalf("FromTime() error = %v", err) + } + if want := int64(1_785_370_150_987); got != want { + t.Fatalf("FromTime() = %d, want %d", got, want) + } +} + +func TestToTimeRestoresInstantAtMillisecondPrecision(t *testing.T) { + got, err := ToTime(1_785_370_150_987) + if err != nil { + t.Fatalf("ToTime() error = %v", err) + } + want := time.Date(2026, 7, 30, 0, 9, 10, 987_000_000, time.UTC) + if !got.Equal(want) || got.Location() != time.UTC { + t.Fatalf("ToTime() = %s (%s), want %s (UTC)", got, got.Location(), want) + } +} + +func TestAlignDownUsesUnixEpochBoundaries(t *testing.T) { + tests := []struct { + name string + value, width int64 + want int64 + }{ + {name: "hour", value: 1_785_373_750_987, width: MillisecondsPerHour, want: 1_785_373_200_000}, + {name: "day", value: 1_785_370_150_987, width: MillisecondsPerDay, want: 1_785_369_600_000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := AlignDown(tt.value, tt.width) + if err != nil { + t.Fatalf("AlignDown() error = %v", err) + } + if got != tt.want { + t.Fatalf("AlignDown(%d, %d) = %d, want %d", tt.value, tt.width, got, tt.want) + } + }) + } +} + +func TestEpochMillisecondsRejectInvalidPersistentTimeAndWindowOverflow(t *testing.T) { + if _, err := FromTime(time.Unix(-1, 0)); err == nil { + t.Fatal("FromTime() accepted a negative instant") + } + if _, err := ToTime(-1); err == nil { + t.Fatal("ToTime() accepted a negative instant") + } + for _, width := range []int64{0, -1} { + if _, err := AlignDown(1, width); err == nil { + t.Fatalf("AlignDown() accepted width %d", width) + } + } + if _, err := AlignDown(-1, MillisecondsPerHour); err == nil { + t.Fatal("AlignDown() accepted a negative instant") + } + if _, _, err := WindowEndingAt(math.MaxInt64, 2, 1); err == nil { + t.Fatal("WindowEndingAt() accepted an overflowing bucket end") + } +} + +func TestWindowEndingAtIncludesCurrentBucket(t *testing.T) { + from, to, err := WindowEndingAt(10_801_234, MillisecondsPerHour, 3) + if err != nil { + t.Fatalf("WindowEndingAt() error = %v", err) + } + if from != 3_600_000 || to != 14_400_000 { + t.Fatalf("WindowEndingAt() = [%d, %d), want [3600000, 14400000)", from, to) + } +} + +func TestWindowEndingAtRejectsInvalidCount(t *testing.T) { + for _, test := range []struct { + name string + count int + }{ + {name: "zero", count: 0}, + {name: "negative", count: -1}, + } { + t.Run(test.name, func(t *testing.T) { + if _, _, err := WindowEndingAt(0, MillisecondsPerHour, test.count); err == nil { + t.Fatalf("WindowEndingAt() accepted count %d", test.count) + } + }) + } +} + +func TestWindowEndingAtRejectsWindowWidthOverflow(t *testing.T) { + if _, _, err := WindowEndingAt(0, math.MaxInt64, 2); err == nil { + t.Fatal("WindowEndingAt() accepted a window width that overflows int64") + } +} + +func TestWindowEndingAtRejectsWindowBeforeUnixEpoch(t *testing.T) { + if _, _, err := WindowEndingAt(1, MillisecondsPerHour, 2); err == nil { + t.Fatal("WindowEndingAt() accepted a window beginning before the Unix epoch") + } +} diff --git a/internal/pricing/builtin.go b/internal/pricing/builtin.go index 34ae2f4b..bb3af807 100644 --- a/internal/pricing/builtin.go +++ b/internal/pricing/builtin.go @@ -11,41 +11,41 @@ const ( var builtinUpdatedAt = time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) var builtinRules = []Rule{ - {Pattern: "gpt-5.6", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, Output: Price{Value: 30, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, - {Pattern: "gpt-5.6-sol", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, Output: Price{Value: 30, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, - {Pattern: "gpt-5.6-terra", Prices: Prices{UncachedInput: Price{Value: 2.5, Set: true}, CacheRead: Price{Value: 0.25, Set: true}, Output: Price{Value: 15, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, - {Pattern: "gpt-5.6-luna", Prices: Prices{UncachedInput: Price{Value: 1, Set: true}, CacheRead: Price{Value: 0.1, Set: true}, Output: Price{Value: 6, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, - {Pattern: "gpt-5.5", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, Output: Price{Value: 30, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, - {Pattern: "gpt-5.5-pro", Prices: Prices{UncachedInput: Price{Value: 30, Set: true}, Output: Price{Value: 180, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gpt-5.4", Prices: Prices{UncachedInput: Price{Value: 2.5, Set: true}, CacheRead: Price{Value: 0.25, Set: true}, Output: Price{Value: 15, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, - {Pattern: "gpt-5.4-mini", Prices: Prices{UncachedInput: Price{Value: 0.75, Set: true}, CacheRead: Price{Value: 0.075, Set: true}, Output: Price{Value: 4.5, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gpt-5.4-nano", Prices: Prices{UncachedInput: Price{Value: 0.2, Set: true}, CacheRead: Price{Value: 0.02, Set: true}, Output: Price{Value: 1.25, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gpt-5.4-pro", Prices: Prices{UncachedInput: Price{Value: 30, Set: true}, Output: Price{Value: 180, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, - {Pattern: "gpt-4.1", Prices: Prices{UncachedInput: Price{Value: 2, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, Output: Price{Value: 8, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gpt-4.1-mini", Prices: Prices{UncachedInput: Price{Value: 0.4, Set: true}, CacheRead: Price{Value: 0.1, Set: true}, Output: Price{Value: 1.6, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gpt-4.1-nano", Prices: Prices{UncachedInput: Price{Value: 0.1, Set: true}, CacheRead: Price{Value: 0.025, Set: true}, Output: Price{Value: 0.4, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gpt-4o", Prices: Prices{UncachedInput: Price{Value: 2.5, Set: true}, CacheRead: Price{Value: 1.25, Set: true}, Output: Price{Value: 10, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gpt-4o-mini", Prices: Prices{UncachedInput: Price{Value: 0.15, Set: true}, CacheRead: Price{Value: 0.075, Set: true}, Output: Price{Value: 0.6, Set: true}}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-fable-5", Prices: Prices{UncachedInput: Price{Value: 10, Set: true}, CacheRead: Price{Value: 1, Set: true}, CacheWrite5M: Price{Value: 12.5, Set: true}, CacheWrite1H: Price{Value: 20, Set: true}, Output: Price{Value: 50, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-opus-5", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, CacheWrite5M: Price{Value: 6.25, Set: true}, CacheWrite1H: Price{Value: 10, Set: true}, Output: Price{Value: 25, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-opus-4-8", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, CacheWrite5M: Price{Value: 6.25, Set: true}, CacheWrite1H: Price{Value: 10, Set: true}, Output: Price{Value: 25, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-opus-4-7", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, CacheWrite5M: Price{Value: 6.25, Set: true}, CacheWrite1H: Price{Value: 10, Set: true}, Output: Price{Value: 25, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-opus-4-6", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, CacheWrite5M: Price{Value: 6.25, Set: true}, CacheWrite1H: Price{Value: 10, Set: true}, Output: Price{Value: 25, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-opus-4-5", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, CacheWrite5M: Price{Value: 6.25, Set: true}, CacheWrite1H: Price{Value: 10, Set: true}, Output: Price{Value: 25, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-opus-4-5-20251101", Prices: Prices{UncachedInput: Price{Value: 5, Set: true}, CacheRead: Price{Value: 0.5, Set: true}, CacheWrite5M: Price{Value: 6.25, Set: true}, CacheWrite1H: Price{Value: 10, Set: true}, Output: Price{Value: 25, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-sonnet-5", Prices: Prices{UncachedInput: Price{Value: 2, Set: true}, CacheRead: Price{Value: 0.2, Set: true}, CacheWrite5M: Price{Value: 2.5, Set: true}, CacheWrite1H: Price{Value: 4, Set: true}, Output: Price{Value: 10, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-sonnet-4-6", Prices: Prices{UncachedInput: Price{Value: 3, Set: true}, CacheRead: Price{Value: 0.3, Set: true}, CacheWrite5M: Price{Value: 3.75, Set: true}, CacheWrite1H: Price{Value: 6, Set: true}, Output: Price{Value: 15, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-sonnet-4-5", Prices: Prices{UncachedInput: Price{Value: 3, Set: true}, CacheRead: Price{Value: 0.3, Set: true}, CacheWrite5M: Price{Value: 3.75, Set: true}, CacheWrite1H: Price{Value: 6, Set: true}, Output: Price{Value: 15, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-sonnet-4-5-20250929", Prices: Prices{UncachedInput: Price{Value: 3, Set: true}, CacheRead: Price{Value: 0.3, Set: true}, CacheWrite5M: Price{Value: 3.75, Set: true}, CacheWrite1H: Price{Value: 6, Set: true}, Output: Price{Value: 15, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-haiku-4-5", Prices: Prices{UncachedInput: Price{Value: 1, Set: true}, CacheRead: Price{Value: 0.1, Set: true}, CacheWrite5M: Price{Value: 1.25, Set: true}, CacheWrite1H: Price{Value: 2, Set: true}, Output: Price{Value: 5, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "claude-haiku-4-5-20251001", Prices: Prices{UncachedInput: Price{Value: 1, Set: true}, CacheRead: Price{Value: 0.1, Set: true}, CacheWrite5M: Price{Value: 1.25, Set: true}, CacheWrite1H: Price{Value: 2, Set: true}, Output: Price{Value: 5, Set: true}}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gemini-3.6-flash", Prices: Prices{UncachedInput: Price{Value: 1.5, Set: true}, CacheRead: Price{Value: 0.15, Set: true}, Output: Price{Value: 7.5, Set: true}}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gemini-3.5-flash", Prices: Prices{UncachedInput: Price{Value: 1.5, Set: true}, CacheRead: Price{Value: 0.15, Set: true}, Output: Price{Value: 9, Set: true}}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gemini-3.5-flash-lite", Prices: Prices{UncachedInput: Price{Value: 0.3, Set: true}, CacheRead: Price{Value: 0.03, Set: true}, Output: Price{Value: 2.5, Set: true}}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gemini-3.1-flash-lite", Prices: Prices{UncachedInput: Price{Value: 0.25, Set: true}, CacheRead: Price{Value: 0.025, Set: true}, Output: Price{Value: 1.5, Set: true}}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gemini-2.5-pro", Prices: Prices{UncachedInput: Price{Value: 1.25, Set: true}, CacheRead: Price{Value: 0.125, Set: true}, Output: Price{Value: 10, Set: true}}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gemini-2.5-flash", Prices: Prices{UncachedInput: Price{Value: 0.3, Set: true}, CacheRead: Price{Value: 0.03, Set: true}, Output: Price{Value: 2.5, Set: true}}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, - {Pattern: "gemini-2.5-flash-lite", Prices: Prices{UncachedInput: Price{Value: 0.1, Set: true}, CacheRead: Price{Value: 0.01, Set: true}, Output: Price{Value: 0.4, Set: true}}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gpt-5.6", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), Output: mustPrice("30")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, + {Pattern: "gpt-5.6-sol", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), Output: mustPrice("30")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, + {Pattern: "gpt-5.6-terra", Prices: Prices{UncachedInput: mustPrice("2.5"), CacheRead: mustPrice("0.25"), Output: mustPrice("15")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, + {Pattern: "gpt-5.6-luna", Prices: Prices{UncachedInput: mustPrice("1"), CacheRead: mustPrice("0.1"), Output: mustPrice("6")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, + {Pattern: "gpt-5.5", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), Output: mustPrice("30")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, + {Pattern: "gpt-5.5-pro", Prices: Prices{UncachedInput: mustPrice("30"), Output: mustPrice("180")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gpt-5.4", Prices: Prices{UncachedInput: mustPrice("2.5"), CacheRead: mustPrice("0.25"), Output: mustPrice("15")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, + {Pattern: "gpt-5.4-mini", Prices: Prices{UncachedInput: mustPrice("0.75"), CacheRead: mustPrice("0.075"), Output: mustPrice("4.5")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gpt-5.4-nano", Prices: Prices{UncachedInput: mustPrice("0.2"), CacheRead: mustPrice("0.02"), Output: mustPrice("1.25")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gpt-5.4-pro", Prices: Prices{UncachedInput: mustPrice("30"), Output: mustPrice("180")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt, LongContextPolicy: newBuiltinLongContextPolicy()}, + {Pattern: "gpt-4.1", Prices: Prices{UncachedInput: mustPrice("2"), CacheRead: mustPrice("0.5"), Output: mustPrice("8")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gpt-4.1-mini", Prices: Prices{UncachedInput: mustPrice("0.4"), CacheRead: mustPrice("0.1"), Output: mustPrice("1.6")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gpt-4.1-nano", Prices: Prices{UncachedInput: mustPrice("0.1"), CacheRead: mustPrice("0.025"), Output: mustPrice("0.4")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gpt-4o", Prices: Prices{UncachedInput: mustPrice("2.5"), CacheRead: mustPrice("1.25"), Output: mustPrice("10")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gpt-4o-mini", Prices: Prices{UncachedInput: mustPrice("0.15"), CacheRead: mustPrice("0.075"), Output: mustPrice("0.6")}, Source: SourceBuiltin, SourceURL: builtinOpenAIURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-fable-5", Prices: Prices{UncachedInput: mustPrice("10"), CacheRead: mustPrice("1"), CacheWrite5M: mustPrice("12.5"), CacheWrite1H: mustPrice("20"), Output: mustPrice("50")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-opus-5", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), CacheWrite5M: mustPrice("6.25"), CacheWrite1H: mustPrice("10"), Output: mustPrice("25")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-opus-4-8", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), CacheWrite5M: mustPrice("6.25"), CacheWrite1H: mustPrice("10"), Output: mustPrice("25")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-opus-4-7", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), CacheWrite5M: mustPrice("6.25"), CacheWrite1H: mustPrice("10"), Output: mustPrice("25")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-opus-4-6", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), CacheWrite5M: mustPrice("6.25"), CacheWrite1H: mustPrice("10"), Output: mustPrice("25")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-opus-4-5", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), CacheWrite5M: mustPrice("6.25"), CacheWrite1H: mustPrice("10"), Output: mustPrice("25")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-opus-4-5-20251101", Prices: Prices{UncachedInput: mustPrice("5"), CacheRead: mustPrice("0.5"), CacheWrite5M: mustPrice("6.25"), CacheWrite1H: mustPrice("10"), Output: mustPrice("25")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-sonnet-5", Prices: Prices{UncachedInput: mustPrice("2"), CacheRead: mustPrice("0.2"), CacheWrite5M: mustPrice("2.5"), CacheWrite1H: mustPrice("4"), Output: mustPrice("10")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-sonnet-4-6", Prices: Prices{UncachedInput: mustPrice("3"), CacheRead: mustPrice("0.3"), CacheWrite5M: mustPrice("3.75"), CacheWrite1H: mustPrice("6"), Output: mustPrice("15")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-sonnet-4-5", Prices: Prices{UncachedInput: mustPrice("3"), CacheRead: mustPrice("0.3"), CacheWrite5M: mustPrice("3.75"), CacheWrite1H: mustPrice("6"), Output: mustPrice("15")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-sonnet-4-5-20250929", Prices: Prices{UncachedInput: mustPrice("3"), CacheRead: mustPrice("0.3"), CacheWrite5M: mustPrice("3.75"), CacheWrite1H: mustPrice("6"), Output: mustPrice("15")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-haiku-4-5", Prices: Prices{UncachedInput: mustPrice("1"), CacheRead: mustPrice("0.1"), CacheWrite5M: mustPrice("1.25"), CacheWrite1H: mustPrice("2"), Output: mustPrice("5")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "claude-haiku-4-5-20251001", Prices: Prices{UncachedInput: mustPrice("1"), CacheRead: mustPrice("0.1"), CacheWrite5M: mustPrice("1.25"), CacheWrite1H: mustPrice("2"), Output: mustPrice("5")}, Source: SourceBuiltin, SourceURL: builtinAnthropicURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gemini-3.6-flash", Prices: Prices{UncachedInput: mustPrice("1.5"), CacheRead: mustPrice("0.15"), Output: mustPrice("7.5")}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gemini-3.5-flash", Prices: Prices{UncachedInput: mustPrice("1.5"), CacheRead: mustPrice("0.15"), Output: mustPrice("9")}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gemini-3.5-flash-lite", Prices: Prices{UncachedInput: mustPrice("0.3"), CacheRead: mustPrice("0.03"), Output: mustPrice("2.5")}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gemini-3.1-flash-lite", Prices: Prices{UncachedInput: mustPrice("0.25"), CacheRead: mustPrice("0.025"), Output: mustPrice("1.5")}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gemini-2.5-pro", Prices: Prices{UncachedInput: mustPrice("1.25"), CacheRead: mustPrice("0.125"), Output: mustPrice("10")}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gemini-2.5-flash", Prices: Prices{UncachedInput: mustPrice("0.3"), CacheRead: mustPrice("0.03"), Output: mustPrice("2.5")}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, + {Pattern: "gemini-2.5-flash-lite", Prices: Prices{UncachedInput: mustPrice("0.1"), CacheRead: mustPrice("0.01"), Output: mustPrice("0.4")}, Source: SourceBuiltin, SourceURL: builtinGeminiURL, UpdatedAt: builtinUpdatedAt}, } // BuiltinRules returns a caller-owned copy of the built-in pricing rules. @@ -57,10 +57,18 @@ func BuiltinRules() []Rule { return result } +func mustPrice(value string) Price { + price, err := ParseUSD(value) + if err != nil { + panic("invalid builtin price " + value + ": " + err.Error()) + } + return Price{NanoUSDPerMillion: price, Set: true} +} + func newBuiltinLongContextPolicy() *LongContextPolicy { return &LongContextPolicy{ InputThresholdTokens: 272_000, - InputMultiplier: 2, - OutputMultiplier: 1.5, + InputMultiplier: Multiplier{Numerator: 2, Denominator: 1}, + OutputMultiplier: Multiplier{Numerator: 3, Denominator: 2}, } } diff --git a/internal/pricing/builtin_test.go b/internal/pricing/builtin_test.go index 943d8e1d..4c345887 100644 --- a/internal/pricing/builtin_test.go +++ b/internal/pricing/builtin_test.go @@ -14,41 +14,41 @@ func TestBuiltinRulesMatchGoldenTable(t *testing.T) { const anthropicURL = "https://platform.claude.com/docs/en/about-claude/pricing" const geminiURL = "https://ai.google.dev/gemini-api/docs/pricing" want := []Rule{ - {Pattern: "gpt-5.6", Prices: builtinPrices(5, 0.5, unsetPrice(), unsetPrice(), 30), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, - {Pattern: "gpt-5.6-sol", Prices: builtinPrices(5, 0.5, unsetPrice(), unsetPrice(), 30), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, - {Pattern: "gpt-5.6-terra", Prices: builtinPrices(2.5, 0.25, unsetPrice(), unsetPrice(), 15), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, - {Pattern: "gpt-5.6-luna", Prices: builtinPrices(1, 0.1, unsetPrice(), unsetPrice(), 6), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, - {Pattern: "gpt-5.5", Prices: builtinPrices(5, 0.5, unsetPrice(), unsetPrice(), 30), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, - {Pattern: "gpt-5.5-pro", Prices: builtinPrices(30, unsetPrice(), unsetPrice(), unsetPrice(), 180), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, - {Pattern: "gpt-5.4", Prices: builtinPrices(2.5, 0.25, unsetPrice(), unsetPrice(), 15), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, - {Pattern: "gpt-5.4-mini", Prices: builtinPrices(0.75, 0.075, unsetPrice(), unsetPrice(), 4.5), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, - {Pattern: "gpt-5.4-nano", Prices: builtinPrices(0.2, 0.02, unsetPrice(), unsetPrice(), 1.25), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, - {Pattern: "gpt-5.4-pro", Prices: builtinPrices(30, unsetPrice(), unsetPrice(), unsetPrice(), 180), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, - {Pattern: "gpt-4.1", Prices: builtinPrices(2, 0.5, unsetPrice(), unsetPrice(), 8), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, - {Pattern: "gpt-4.1-mini", Prices: builtinPrices(0.4, 0.1, unsetPrice(), unsetPrice(), 1.6), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, - {Pattern: "gpt-4.1-nano", Prices: builtinPrices(0.1, 0.025, unsetPrice(), unsetPrice(), 0.4), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, - {Pattern: "gpt-4o", Prices: builtinPrices(2.5, 1.25, unsetPrice(), unsetPrice(), 10), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, - {Pattern: "gpt-4o-mini", Prices: builtinPrices(0.15, 0.075, unsetPrice(), unsetPrice(), 0.6), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, - {Pattern: "claude-fable-5", Prices: builtinPrices(10, 1, 12.5, 20, 50), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-opus-5", Prices: builtinPrices(5, 0.5, 6.25, 10, 25), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-opus-4-8", Prices: builtinPrices(5, 0.5, 6.25, 10, 25), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-opus-4-7", Prices: builtinPrices(5, 0.5, 6.25, 10, 25), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-opus-4-6", Prices: builtinPrices(5, 0.5, 6.25, 10, 25), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-opus-4-5", Prices: builtinPrices(5, 0.5, 6.25, 10, 25), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-opus-4-5-20251101", Prices: builtinPrices(5, 0.5, 6.25, 10, 25), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-sonnet-5", Prices: builtinPrices(2, 0.2, 2.5, 4, 10), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-sonnet-4-6", Prices: builtinPrices(3, 0.3, 3.75, 6, 15), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-sonnet-4-5", Prices: builtinPrices(3, 0.3, 3.75, 6, 15), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-sonnet-4-5-20250929", Prices: builtinPrices(3, 0.3, 3.75, 6, 15), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-haiku-4-5", Prices: builtinPrices(1, 0.1, 1.25, 2, 5), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "claude-haiku-4-5-20251001", Prices: builtinPrices(1, 0.1, 1.25, 2, 5), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, - {Pattern: "gemini-3.6-flash", Prices: builtinPrices(1.5, 0.15, unsetPrice(), unsetPrice(), 7.5), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, - {Pattern: "gemini-3.5-flash", Prices: builtinPrices(1.5, 0.15, unsetPrice(), unsetPrice(), 9), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, - {Pattern: "gemini-3.5-flash-lite", Prices: builtinPrices(0.3, 0.03, unsetPrice(), unsetPrice(), 2.5), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, - {Pattern: "gemini-3.1-flash-lite", Prices: builtinPrices(0.25, 0.025, unsetPrice(), unsetPrice(), 1.5), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, - {Pattern: "gemini-2.5-pro", Prices: builtinPrices(1.25, 0.125, unsetPrice(), unsetPrice(), 10), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, - {Pattern: "gemini-2.5-flash", Prices: builtinPrices(0.3, 0.03, unsetPrice(), unsetPrice(), 2.5), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, - {Pattern: "gemini-2.5-flash-lite", Prices: builtinPrices(0.1, 0.01, unsetPrice(), unsetPrice(), 0.4), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, + {Pattern: "gpt-5.6", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), unsetPrice(), unsetPrice(), nano(30_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, + {Pattern: "gpt-5.6-sol", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), unsetPrice(), unsetPrice(), nano(30_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, + {Pattern: "gpt-5.6-terra", Prices: builtinPrices(nano(2_500_000_000), nano(250_000_000), unsetPrice(), unsetPrice(), nano(15_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, + {Pattern: "gpt-5.6-luna", Prices: builtinPrices(nano(1_000_000_000), nano(100_000_000), unsetPrice(), unsetPrice(), nano(6_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, + {Pattern: "gpt-5.5", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), unsetPrice(), unsetPrice(), nano(30_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, + {Pattern: "gpt-5.5-pro", Prices: builtinPrices(nano(30_000_000_000), unsetPrice(), unsetPrice(), unsetPrice(), nano(180_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, + {Pattern: "gpt-5.4", Prices: builtinPrices(nano(2_500_000_000), nano(250_000_000), unsetPrice(), unsetPrice(), nano(15_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, + {Pattern: "gpt-5.4-mini", Prices: builtinPrices(nano(750_000_000), nano(75_000_000), unsetPrice(), unsetPrice(), nano(4_500_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, + {Pattern: "gpt-5.4-nano", Prices: builtinPrices(nano(200_000_000), nano(20_000_000), unsetPrice(), unsetPrice(), nano(1_250_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, + {Pattern: "gpt-5.4-pro", Prices: builtinPrices(nano(30_000_000_000), unsetPrice(), unsetPrice(), unsetPrice(), nano(180_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt, LongContextPolicy: testLongContextPolicy()}, + {Pattern: "gpt-4.1", Prices: builtinPrices(nano(2_000_000_000), nano(500_000_000), unsetPrice(), unsetPrice(), nano(8_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, + {Pattern: "gpt-4.1-mini", Prices: builtinPrices(nano(400_000_000), nano(100_000_000), unsetPrice(), unsetPrice(), nano(1_600_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, + {Pattern: "gpt-4.1-nano", Prices: builtinPrices(nano(100_000_000), nano(25_000_000), unsetPrice(), unsetPrice(), nano(400_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, + {Pattern: "gpt-4o", Prices: builtinPrices(nano(2_500_000_000), nano(1_250_000_000), unsetPrice(), unsetPrice(), nano(10_000_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, + {Pattern: "gpt-4o-mini", Prices: builtinPrices(nano(150_000_000), nano(75_000_000), unsetPrice(), unsetPrice(), nano(600_000_000)), Source: SourceBuiltin, SourceURL: openAIURL, UpdatedAt: updatedAt}, + {Pattern: "claude-fable-5", Prices: builtinPrices(nano(10_000_000_000), nano(1_000_000_000), nano(12_500_000_000), nano(20_000_000_000), nano(50_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-opus-5", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), nano(6_250_000_000), nano(10_000_000_000), nano(25_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-opus-4-8", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), nano(6_250_000_000), nano(10_000_000_000), nano(25_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-opus-4-7", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), nano(6_250_000_000), nano(10_000_000_000), nano(25_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-opus-4-6", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), nano(6_250_000_000), nano(10_000_000_000), nano(25_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-opus-4-5", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), nano(6_250_000_000), nano(10_000_000_000), nano(25_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-opus-4-5-20251101", Prices: builtinPrices(nano(5_000_000_000), nano(500_000_000), nano(6_250_000_000), nano(10_000_000_000), nano(25_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-sonnet-5", Prices: builtinPrices(nano(2_000_000_000), nano(200_000_000), nano(2_500_000_000), nano(4_000_000_000), nano(10_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-sonnet-4-6", Prices: builtinPrices(nano(3_000_000_000), nano(300_000_000), nano(3_750_000_000), nano(6_000_000_000), nano(15_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-sonnet-4-5", Prices: builtinPrices(nano(3_000_000_000), nano(300_000_000), nano(3_750_000_000), nano(6_000_000_000), nano(15_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-sonnet-4-5-20250929", Prices: builtinPrices(nano(3_000_000_000), nano(300_000_000), nano(3_750_000_000), nano(6_000_000_000), nano(15_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-haiku-4-5", Prices: builtinPrices(nano(1_000_000_000), nano(100_000_000), nano(1_250_000_000), nano(2_000_000_000), nano(5_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "claude-haiku-4-5-20251001", Prices: builtinPrices(nano(1_000_000_000), nano(100_000_000), nano(1_250_000_000), nano(2_000_000_000), nano(5_000_000_000)), Source: SourceBuiltin, SourceURL: anthropicURL, UpdatedAt: updatedAt}, + {Pattern: "gemini-3.6-flash", Prices: builtinPrices(nano(1_500_000_000), nano(150_000_000), unsetPrice(), unsetPrice(), nano(7_500_000_000)), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, + {Pattern: "gemini-3.5-flash", Prices: builtinPrices(nano(1_500_000_000), nano(150_000_000), unsetPrice(), unsetPrice(), nano(9_000_000_000)), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, + {Pattern: "gemini-3.5-flash-lite", Prices: builtinPrices(nano(300_000_000), nano(30_000_000), unsetPrice(), unsetPrice(), nano(2_500_000_000)), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, + {Pattern: "gemini-3.1-flash-lite", Prices: builtinPrices(nano(250_000_000), nano(25_000_000), unsetPrice(), unsetPrice(), nano(1_500_000_000)), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, + {Pattern: "gemini-2.5-pro", Prices: builtinPrices(nano(1_250_000_000), nano(125_000_000), unsetPrice(), unsetPrice(), nano(10_000_000_000)), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, + {Pattern: "gemini-2.5-flash", Prices: builtinPrices(nano(300_000_000), nano(30_000_000), unsetPrice(), unsetPrice(), nano(2_500_000_000)), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, + {Pattern: "gemini-2.5-flash-lite", Prices: builtinPrices(nano(100_000_000), nano(10_000_000), unsetPrice(), unsetPrice(), nano(400_000_000)), Source: SourceBuiltin, SourceURL: geminiURL, UpdatedAt: updatedAt}, } got := BuiltinRules() @@ -65,11 +65,11 @@ func TestBuiltinRulesMatchGoldenTable(t *testing.T) { } got[0].Pattern = "mutated" if got[0].LongContextPolicy != nil { - got[0].LongContextPolicy.InputMultiplier = 99 + got[0].LongContextPolicy.InputMultiplier = Multiplier{Numerator: 99, Denominator: 1} } again := BuiltinRules() if again[0].Pattern != "gpt-5.6" || again[0].LongContextPolicy == nil || - again[0].LongContextPolicy.InputMultiplier != 2 { + again[0].LongContextPolicy.InputMultiplier != (Multiplier{Numerator: 2, Denominator: 1}) { t.Fatalf("BuiltinRules() returned mutable backing data: %+v", again[0]) } } @@ -103,13 +103,13 @@ func TestBuiltinRulesDoNotMatchUnsupportedModelFamilies(t *testing.T) { } } -func builtinPrices(uncachedInput, cacheRead, cacheWrite5M, cacheWrite1H, output any) Prices { +func builtinPrices(uncachedInput, cacheRead, cacheWrite5M, cacheWrite1H, output Price) Prices { return Prices{ - UncachedInput: builtinPrice(uncachedInput), - CacheRead: builtinPrice(cacheRead), - CacheWrite5M: builtinPrice(cacheWrite5M), - CacheWrite1H: builtinPrice(cacheWrite1H), - Output: builtinPrice(output), + UncachedInput: uncachedInput, + CacheRead: cacheRead, + CacheWrite5M: cacheWrite5M, + CacheWrite1H: cacheWrite1H, + Output: output, } } @@ -117,15 +117,6 @@ func unsetPrice() Price { return Price{} } -func builtinPrice(value any) Price { - switch value := value.(type) { - case float64: - return Price{Value: value, Set: true} - case int: - return Price{Value: float64(value), Set: true} - case Price: - return value - default: - panic("unsupported builtin test price") - } +func nano(value NanoUSD) Price { + return fixedPrice(value) } diff --git a/internal/pricing/compile.go b/internal/pricing/compile.go index 9d93634b..e4e4619a 100644 --- a/internal/pricing/compile.go +++ b/internal/pricing/compile.go @@ -2,7 +2,6 @@ package pricing import ( "fmt" - "math" "sort" "strings" "unicode" @@ -66,8 +65,8 @@ func validateRule(rule Rule) error { rule.Prices.CacheWrite1H, rule.Prices.Output, } { - if math.IsNaN(price.Value) || math.IsInf(price.Value, 0) || price.Value < 0 { - return fmt.Errorf("pricing price must be finite and non-negative") + if price.NanoUSDPerMillion < 0 { + return fmt.Errorf("pricing price must be non-negative") } priceSet = priceSet || price.Set } @@ -87,9 +86,9 @@ func validateRule(rule Rule) error { if policy.InputThresholdTokens <= 0 { return fmt.Errorf("long-context input threshold must be positive") } - if !isFinite(policy.InputMultiplier) || policy.InputMultiplier <= 0 || - !isFinite(policy.OutputMultiplier) || policy.OutputMultiplier <= 0 { - return fmt.Errorf("long-context multipliers must be finite and positive") + if policy.InputMultiplier.Numerator <= 0 || policy.InputMultiplier.Denominator <= 0 || + policy.OutputMultiplier.Numerator <= 0 || policy.OutputMultiplier.Denominator <= 0 { + return fmt.Errorf("long-context multipliers must be positive ratios") } } return nil diff --git a/internal/pricing/compile_test.go b/internal/pricing/compile_test.go index 8c848452..4baddabd 100644 --- a/internal/pricing/compile_test.go +++ b/internal/pricing/compile_test.go @@ -1,7 +1,6 @@ package pricing import ( - "math" "strings" "sync" "testing" @@ -107,7 +106,7 @@ func TestCompileMatchBareUserCatchAllShadowsBuiltins(t *testing.T) { for _, model := range []string{"gpt-4o", "claude-opus", "unknown-model"} { rule, ok := table.Match(model) if !ok || rule.Pattern != "*" || rule.Source != SourceUser || - rule.Prices.Output.Value != 99 { + rule.Prices.Output.NanoUSDPerMillion != 99 { t.Errorf("Match(%q) = %+v, %t; want global user override", model, rule, ok) } } @@ -132,11 +131,8 @@ func TestCompileRejectsInvalidRules(t *testing.T) { {name: "multiple stars", rule: replacePattern(validBuiltin, "gpt-**")}, {name: "invalid source", rule: Rule{Pattern: "gpt-4o", Prices: priced(1), Source: Source("remote")}}, {name: "all prices unset", rule: Rule{Pattern: "gpt-4o", Source: SourceUser}}, - {name: "negative price", rule: Rule{Pattern: "gpt-4o", Prices: Prices{Output: Price{Value: -1, Set: true}}, Source: SourceUser}}, - {name: "negative unset price", rule: Rule{Pattern: "gpt-4o", Prices: Prices{UncachedInput: Price{Value: -1}, Output: Price{Value: 1, Set: true}}, Source: SourceUser}}, - {name: "nan price", rule: Rule{Pattern: "gpt-4o", Prices: Prices{Output: Price{Value: math.NaN(), Set: true}}, Source: SourceUser}}, - {name: "positive infinity price", rule: Rule{Pattern: "gpt-4o", Prices: Prices{Output: Price{Value: math.Inf(1), Set: true}}, Source: SourceUser}}, - {name: "negative infinity price", rule: Rule{Pattern: "gpt-4o", Prices: Prices{Output: Price{Value: math.Inf(-1), Set: true}}, Source: SourceUser}}, + {name: "negative price", rule: Rule{Pattern: "gpt-4o", Prices: Prices{Output: fixedPrice(-1)}, Source: SourceUser}}, + {name: "negative unset price", rule: Rule{Pattern: "gpt-4o", Prices: Prices{UncachedInput: Price{NanoUSDPerMillion: -1}, Output: fixedPrice(1)}, Source: SourceUser}}, {name: "builtin missing URL", rule: Rule{Pattern: "gpt-4o", Prices: priced(1), Source: SourceBuiltin, UpdatedAt: updatedAt}}, {name: "builtin missing update time", rule: Rule{Pattern: "gpt-4o", Prices: priced(1), Source: SourceBuiltin, SourceURL: "https://builtin.example/pricing"}}, {name: "user source URL", rule: Rule{Pattern: "gpt-4o", Prices: priced(1), Source: SourceUser, SourceURL: "https://user.example/pricing"}}, @@ -165,8 +161,8 @@ func TestCompileRejectsUserAndPrefixLongContextPolicies(t *testing.T) { validPolicy := func() *LongContextPolicy { return &LongContextPolicy{ InputThresholdTokens: 272_000, - InputMultiplier: 2, - OutputMultiplier: 1.5, + InputMultiplier: Multiplier{Numerator: 2, Denominator: 1}, + OutputMultiplier: Multiplier{Numerator: 3, Denominator: 2}, } } validBuiltin := func(pattern string) Rule { @@ -225,31 +221,31 @@ func TestCompileRejectsUserAndPrefixLongContextPolicies(t *testing.T) { name: "zero input multiplier", rule: func() Rule { rule := validBuiltin("gpt-5.6") - rule.LongContextPolicy.InputMultiplier = 0 + rule.LongContextPolicy.InputMultiplier = Multiplier{} return rule }(), }, { - name: "negative output multiplier", + name: "negative output multiplier numerator", rule: func() Rule { rule := validBuiltin("gpt-5.6") - rule.LongContextPolicy.OutputMultiplier = -1 + rule.LongContextPolicy.OutputMultiplier = Multiplier{Numerator: -1, Denominator: 1} return rule }(), }, { - name: "nan input multiplier", + name: "negative input multiplier denominator", rule: func() Rule { rule := validBuiltin("gpt-5.6") - rule.LongContextPolicy.InputMultiplier = math.NaN() + rule.LongContextPolicy.InputMultiplier = Multiplier{Numerator: 1, Denominator: -1} return rule }(), }, { - name: "infinite output multiplier", + name: "zero output multiplier denominator", rule: func() Rule { rule := validBuiltin("gpt-5.6") - rule.LongContextPolicy.OutputMultiplier = math.Inf(1) + rule.LongContextPolicy.OutputMultiplier = Multiplier{Numerator: 1} return rule }(), }, @@ -268,8 +264,8 @@ func TestCompileCopiesLongContextPolicy(t *testing.T) { policy := &LongContextPolicy{ InputThresholdTokens: 272_000, - InputMultiplier: 2, - OutputMultiplier: 1.5, + InputMultiplier: Multiplier{Numerator: 2, Denominator: 1}, + OutputMultiplier: Multiplier{Numerator: 3, Denominator: 2}, } rules := []Rule{{ Pattern: "gpt-5.6", @@ -285,26 +281,26 @@ func TestCompileCopiesLongContextPolicy(t *testing.T) { } policy.InputThresholdTokens = 1 - policy.InputMultiplier = 99 + policy.InputMultiplier = Multiplier{Numerator: 99, Denominator: 1} first, ok := table.Match("gpt-5.6") if !ok || first.LongContextPolicy == nil { t.Fatalf("first Match() = %+v, %t; want policy", first, ok) } if first.LongContextPolicy.InputThresholdTokens != 272_000 || - first.LongContextPolicy.InputMultiplier != 2 || - first.LongContextPolicy.OutputMultiplier != 1.5 { + first.LongContextPolicy.InputMultiplier != (Multiplier{Numerator: 2, Denominator: 1}) || + first.LongContextPolicy.OutputMultiplier != (Multiplier{Numerator: 3, Denominator: 2}) { t.Fatalf("first Match() policy = %+v, want original values", first.LongContextPolicy) } if first.LongContextPolicy == policy { t.Fatal("Compile() retained the caller's policy pointer") } - first.LongContextPolicy.OutputMultiplier = 99 + first.LongContextPolicy.OutputMultiplier = Multiplier{Numerator: 99, Denominator: 1} second, ok := table.Match("gpt-5.6") if !ok || second.LongContextPolicy == nil { t.Fatalf("second Match() = %+v, %t; want policy", second, ok) } - if second.LongContextPolicy.OutputMultiplier != 1.5 { + if second.LongContextPolicy.OutputMultiplier != (Multiplier{Numerator: 3, Denominator: 2}) { t.Fatalf("second Match() policy = %+v, want immutable copy", second.LongContextPolicy) } if second.LongContextPolicy == first.LongContextPolicy { @@ -321,16 +317,16 @@ func TestCompileCopiesInputAndReturnsRuleValues(t *testing.T) { t.Fatalf("Compile() error = %v", err) } rules[0].Pattern = "other-*" - rules[0].Prices.Output.Value = 99 + rules[0].Prices.Output.NanoUSDPerMillion = 99 first, ok := table.Match("gpt-4o") - if !ok || first.Pattern != "gpt-*" || first.Prices.Output.Value != 1 { + if !ok || first.Pattern != "gpt-*" || first.Prices.Output.NanoUSDPerMillion != 1 { t.Fatalf("first Match() = %+v, %t; want unmodified gpt-* rule", first, ok) } first.Pattern = "other-*" - first.Prices.Output.Value = 99 + first.Prices.Output.NanoUSDPerMillion = 99 second, ok := table.Match("gpt-4o") - if !ok || second.Pattern != "gpt-*" || second.Prices.Output.Value != 1 { + if !ok || second.Pattern != "gpt-*" || second.Prices.Output.NanoUSDPerMillion != 1 { t.Fatalf("second Match() = %+v, %t; want unmodified gpt-* rule", second, ok) } } @@ -356,12 +352,12 @@ func TestTableMatchIsConcurrentAndImmutable(t *testing.T) { defer waitGroup.Done() for range matchesPerGoroutine { rule, ok := table.Match("gpt-4o") - if !ok || rule.Pattern != "gpt-*" || rule.Prices.Output.Value != 1 { + if !ok || rule.Pattern != "gpt-*" || rule.Prices.Output.NanoUSDPerMillion != 1 { errors <- "concurrent Match returned an unexpected rule" return } rule.Pattern = "mutated" - rule.Prices.Output.Value = 99 + rule.Prices.Output.NanoUSDPerMillion = 99 } }() } @@ -372,13 +368,13 @@ func TestTableMatchIsConcurrentAndImmutable(t *testing.T) { } rule, ok := table.Match("gpt-4o") - if !ok || rule.Pattern != "gpt-*" || rule.Prices.Output.Value != 1 { + if !ok || rule.Pattern != "gpt-*" || rule.Prices.Output.NanoUSDPerMillion != 1 { t.Fatalf("final Match() = %+v, %t; want unmodified gpt-* rule", rule, ok) } } -func priced(value float64) Prices { - return Prices{Output: Price{Value: value, Set: true}} +func priced(value NanoUSD) Prices { + return Prices{Output: fixedPrice(value)} } func replacePattern(rule Rule, pattern string) Rule { diff --git a/internal/pricing/nanousd.go b/internal/pricing/nanousd.go new file mode 100644 index 00000000..84e8f80e --- /dev/null +++ b/internal/pricing/nanousd.go @@ -0,0 +1,109 @@ +package pricing + +import ( + "errors" + "math" + "math/big" + "strconv" + "strings" +) + +const nanoUSDPerUSD int64 = 1_000_000_000 + +// NanoUSD is a USD amount represented in billionths of one USD. +type NanoUSD int64 + +// Multiplier applies an exact positive ratio to a price component. +type Multiplier struct { + Numerator int64 + Denominator int64 +} + +// ParseUSD parses a non-negative decimal USD amount with at most nine decimal places. +func ParseUSD(value string) (NanoUSD, error) { + if value == "" || strings.TrimSpace(value) != value { + return 0, errors.New("USD price must be a non-empty decimal") + } + + integer, fraction, hasFraction := strings.Cut(value, ".") + if integer == "" || (hasFraction && fraction == "") || strings.Contains(fraction, ".") || len(fraction) > 9 { + return 0, errors.New("USD price must have at most nine decimal places") + } + if !decimalDigits(integer) || (hasFraction && !decimalDigits(fraction)) { + return 0, errors.New("USD price must be a non-negative decimal") + } + + amount := new(big.Int) + if _, ok := amount.SetString(integer+fraction+strings.Repeat("0", 9-len(fraction)), 10); !ok || !amount.IsInt64() { + return 0, errors.New("USD price exceeds int64 nano USD") + } + return NanoUSD(amount.Int64()), nil +} + +// FormatUSD formats a NanoUSD amount as a plain decimal USD string. +func FormatUSD(value NanoUSD) string { + amount := big.NewInt(int64(value)) + negative := amount.Sign() < 0 + if negative { + amount.Abs(amount) + } + + whole, fraction := new(big.Int), new(big.Int) + whole.QuoRem(amount, big.NewInt(nanoUSDPerUSD), fraction) + if fraction.Sign() == 0 { + if negative { + return "-" + whole.String() + } + return whole.String() + } + + fractionText := strconv.FormatInt(fraction.Int64(), 10) + fractionText = strings.Repeat("0", 9-len(fractionText)) + fractionText + fractionText = strings.TrimRight(fractionText, "0") + prefix := "" + if negative { + prefix = "-" + } + return prefix + whole.String() + "." + fractionText +} + +// QuoteComponent returns the rounded nano USD cost of one usage component. +func QuoteComponent(tokens int64, price NanoUSD, multiplier Multiplier) (NanoUSD, bool) { + if tokens < 0 || price < 0 || multiplier.Numerator <= 0 || multiplier.Denominator <= 0 { + return 0, false + } + + numerator := big.NewInt(tokens) + numerator.Mul(numerator, big.NewInt(int64(price))) + numerator.Mul(numerator, big.NewInt(multiplier.Numerator)) + denominator := big.NewInt(tokensPerMillion) + denominator.Mul(denominator, big.NewInt(multiplier.Denominator)) + + quotient, remainder := new(big.Int), new(big.Int) + quotient.QuoRem(numerator, denominator, remainder) + remainder.Lsh(remainder, 1) + if remainder.Cmp(denominator) >= 0 { + quotient.Add(quotient, big.NewInt(1)) + } + if quotient.Sign() < 0 || !quotient.IsInt64() { + return 0, false + } + return NanoUSD(quotient.Int64()), true +} + +// CheckedAddNanoUSD adds non-negative NanoUSD values without overflow. +func CheckedAddNanoUSD(left, right NanoUSD) (NanoUSD, bool) { + if left < 0 || right < 0 || left > NanoUSD(math.MaxInt64)-right { + return 0, false + } + return left + right, true +} + +func decimalDigits(value string) bool { + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} diff --git a/internal/pricing/nanousd_test.go b/internal/pricing/nanousd_test.go new file mode 100644 index 00000000..ba366e29 --- /dev/null +++ b/internal/pricing/nanousd_test.go @@ -0,0 +1,129 @@ +package pricing + +import ( + "math" + "testing" +) + +func TestParseUSDConvertsDecimalUSDPerMillionToNanoUSDExactly(t *testing.T) { + tests := []struct { + value string + want NanoUSD + }{ + {value: "0", want: 0}, + {value: "0.000000001", want: 1}, + {value: "1.25", want: 1_250_000_000}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + got, err := ParseUSD(tt.value) + if err != nil { + t.Fatalf("ParseUSD(%q) error = %v", tt.value, err) + } + if got != tt.want { + t.Fatalf("ParseUSD(%q) = %d, want %d", tt.value, got, tt.want) + } + }) + } +} + +func TestParseUSDRejectsInvalidOrUnrepresentablePrices(t *testing.T) { + for _, value := range []string{"-1", "1e3", "0.0000000001", "", " ", "92233720368.54775808"} { + t.Run(value, func(t *testing.T) { + if _, err := ParseUSD(value); err == nil { + t.Fatalf("ParseUSD(%q) accepted an invalid price", value) + } + }) + } +} + +func TestFormatUSDUsesPlainDecimalWithoutInsignificantZeros(t *testing.T) { + tests := []struct { + value NanoUSD + want string + }{ + {value: 0, want: "0"}, + {value: 1, want: "0.000000001"}, + {value: 1_250_000_000, want: "1.25"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := FormatUSD(tt.value); got != tt.want { + t.Fatalf("FormatUSD(%d) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + +func TestQuoteComponentUsesIntegerHalfUpRounding(t *testing.T) { + tests := []struct { + name string + tokens int64 + price NanoUSD + multiplier Multiplier + want NanoUSD + }{ + {name: "large intermediate product remains representable after division", tokens: math.MaxInt64, price: 1_000_000, multiplier: Multiplier{Numerator: 1, Denominator: 1}, want: NanoUSD(math.MaxInt64)}, + {name: "below half rounds down", tokens: 499_999, price: 1, multiplier: Multiplier{Numerator: 1, Denominator: 1}, want: 0}, + {name: "half rounds up", tokens: 500_000, price: 1, multiplier: Multiplier{Numerator: 1, Denominator: 1}, want: 1}, + {name: "exact two times multiplier", tokens: 1_000_000, price: 7, multiplier: Multiplier{Numerator: 2, Denominator: 1}, want: 14}, + {name: "exact three halves multiplier", tokens: 1_000_000, price: 7, multiplier: Multiplier{Numerator: 3, Denominator: 2}, want: 11}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := QuoteComponent(tt.tokens, tt.price, tt.multiplier) + if !ok || got != tt.want { + t.Fatalf("QuoteComponent(%d, %d, %+v) = %d, %t, want %d, true", tt.tokens, tt.price, tt.multiplier, got, ok, tt.want) + } + }) + } +} + +func TestQuoteComponentRejectsFinalOverflowAndInvalidInputs(t *testing.T) { + if _, ok := QuoteComponent(math.MaxInt64, NanoUSD(math.MaxInt64), Multiplier{Numerator: 1_000_000, Denominator: 1}); ok { + t.Fatal("QuoteComponent() accepted a result above int64") + } + for _, test := range []struct { + name string + tokens int64 + price NanoUSD + multiplier Multiplier + }{ + {name: "negative tokens", tokens: -1, price: 1, multiplier: Multiplier{Numerator: 1, Denominator: 1}}, + {name: "negative price", tokens: 1, price: -1, multiplier: Multiplier{Numerator: 1, Denominator: 1}}, + {name: "zero numerator", tokens: 1, price: 1, multiplier: Multiplier{Numerator: 0, Denominator: 1}}, + {name: "zero denominator", tokens: 1, price: 1, multiplier: Multiplier{Numerator: 1, Denominator: 0}}, + } { + t.Run(test.name, func(t *testing.T) { + if _, ok := QuoteComponent(test.tokens, test.price, test.multiplier); ok { + t.Fatal("QuoteComponent() accepted invalid input") + } + }) + } +} + +func TestCheckedAddNanoUSDRejectsNegativeAndOverflowAmounts(t *testing.T) { + tests := []struct { + name string + left, right NanoUSD + want NanoUSD + wantOK bool + }{ + {name: "adds valid amounts", left: 7, right: 5, want: 12, wantOK: true}, + {name: "negative left", left: -1, right: 5}, + {name: "negative right", left: 7, right: -1}, + {name: "overflow", left: NanoUSD(math.MaxInt64), right: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := CheckedAddNanoUSD(tt.left, tt.right) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("CheckedAddNanoUSD(%d, %d) = %d, %t, want %d, %t", tt.left, tt.right, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/internal/pricing/quote.go b/internal/pricing/quote.go index fa1c451d..58b7e827 100644 --- a/internal/pricing/quote.go +++ b/internal/pricing/quote.go @@ -1,10 +1,6 @@ package pricing -import ( - "math" - - "gpt-load/internal/usage" -) +import "gpt-load/internal/usage" const tokensPerMillion = 1_000_000 @@ -29,8 +25,8 @@ func (table *Table) Quote(upstreamModel string, result usage.Result) Quote { if !ok { return Quote{State: CostStateUnpriced} } - inputMultiplier := float64(1) - outputMultiplier := float64(1) + inputMultiplier := Multiplier{Numerator: 1, Denominator: 1} + outputMultiplier := Multiplier{Numerator: 1, Denominator: 1} if policy := rule.LongContextPolicy; policy != nil && inputTokens > policy.InputThresholdTokens { inputMultiplier = policy.InputMultiplier @@ -40,7 +36,7 @@ func (table *Table) Quote(upstreamModel string, result usage.Result) Quote { components := [...]struct { tokens int64 price Price - multiplier float64 + multiplier Multiplier }{ {tokens: result.Tokens.UncachedInput, price: rule.Prices.UncachedInput, multiplier: inputMultiplier}, {tokens: result.Tokens.CacheRead, price: rule.Prices.CacheRead, multiplier: inputMultiplier}, @@ -50,7 +46,7 @@ func (table *Table) Quote(upstreamModel string, result usage.Result) Quote { } totalTokens := int64(0) - cost := float64(0) + cost := NanoUSD(0) for _, component := range components { if component.tokens < 0 { return Quote{State: CostStateUnpriced} @@ -66,20 +62,20 @@ func (table *Table) Quote(upstreamModel string, result usage.Result) Quote { if !component.price.Set { return Quote{State: CostStateUnpriced} } - effectivePrice := component.price.Value * component.multiplier - if !isFinite(effectivePrice) { + componentCost, ok := QuoteComponent( + component.tokens, + component.price.NanoUSDPerMillion, + component.multiplier, + ) + if !ok { return Quote{State: CostStateUnpriced} } - componentCost := effectivePrice * float64(component.tokens) / tokensPerMillion - if !isFinite(componentCost) { - return Quote{State: CostStateUnpriced} - } - cost += componentCost - if !isFinite(cost) { + cost, ok = CheckedAddNanoUSD(cost, componentCost) + if !ok { return Quote{State: CostStateUnpriced} } } - return Quote{State: CostStatePriced, Cost: cost} + return Quote{State: CostStatePriced, EstimatedCostNanoUSD: cost} } func checkedInputTokens(tokens usage.Tokens) (int64, bool) { @@ -117,7 +113,3 @@ func hasBlockingDiagnostic(diagnostics usage.Diagnostics) bool { } return false } - -func isFinite(value float64) bool { - return !math.IsNaN(value) && !math.IsInf(value, 0) -} diff --git a/internal/pricing/quote_test.go b/internal/pricing/quote_test.go index 191c3c8b..4b81319e 100644 --- a/internal/pricing/quote_test.go +++ b/internal/pricing/quote_test.go @@ -8,34 +8,36 @@ import ( "gpt-load/internal/usage" ) -func TestQuoteUsesFiveTokenPricesWithoutRounding(t *testing.T) { +func TestQuoteRoundsFiveTokenComponentsBeforeSumming(t *testing.T) { t.Parallel() table := mustCompile(t, Rule{ Pattern: "model", Prices: Prices{ - UncachedInput: Price{Value: 1.11, Set: true}, - CacheRead: Price{Value: 2.22, Set: true}, - CacheWrite5M: Price{Value: 3.33, Set: true}, - CacheWrite1H: Price{Value: 4.44, Set: true}, - Output: Price{Value: 5.55, Set: true}, + UncachedInput: fixedPrice(1), + CacheRead: fixedPrice(1), + CacheWrite5M: fixedPrice(1), + CacheWrite1H: fixedPrice(1), + Output: fixedPrice(1), }, Source: SourceUser, }) quote := table.Quote("model", usage.Result{ Tokens: usage.Tokens{ - UncachedInput: 1_000_000, - CacheRead: 1_000_000, - CacheWrite5M: 1_000_000, - CacheWrite1H: 1_000_000, - Output: 1_000_000, + UncachedInput: 500_000, + CacheRead: 500_000, + CacheWrite5M: 500_000, + CacheWrite1H: 500_000, + Output: 500_000, }, State: usage.StateComplete, }) if quote.State != CostStatePriced { t.Fatalf("Quote() state = %q, want %q", quote.State, CostStatePriced) } - assertCostClose(t, quote.Cost, 16.65) + if quote.EstimatedCostNanoUSD != 5 { + t.Fatalf("Quote() cost = %d, want 5 nano USD", quote.EstimatedCostNanoUSD) + } } func TestQuoteAppliesBuiltinLongContextPolicyAtStrictThreshold(t *testing.T) { @@ -44,11 +46,11 @@ func TestQuoteAppliesBuiltinLongContextPolicyAtStrictThreshold(t *testing.T) { table := mustCompile(t, Rule{ Pattern: "gpt-5.6", Prices: Prices{ - UncachedInput: Price{Value: 1, Set: true}, - CacheRead: Price{Value: 2, Set: true}, - CacheWrite5M: Price{Value: 3, Set: true}, - CacheWrite1H: Price{Value: 4, Set: true}, - Output: Price{Value: 5, Set: true}, + UncachedInput: fixedPrice(1_000_000_000), + CacheRead: fixedPrice(2_000_000_000), + CacheWrite5M: fixedPrice(3_000_000_000), + CacheWrite1H: fixedPrice(4_000_000_000), + Output: fixedPrice(5_000_000_000), }, Source: SourceBuiltin, SourceURL: "https://builtin.example/pricing", @@ -58,7 +60,7 @@ func TestQuoteAppliesBuiltinLongContextPolicyAtStrictThreshold(t *testing.T) { tests := []struct { name string tokens usage.Tokens - want float64 + want NanoUSD }{ { name: "equal threshold does not multiply", @@ -69,7 +71,7 @@ func TestQuoteAppliesBuiltinLongContextPolicyAtStrictThreshold(t *testing.T) { CacheWrite1H: 68_000, Output: 1_000_000, }, - want: 5.68, + want: 5_680_000_000, }, { name: "one token over multiplies every input price and output price", @@ -80,7 +82,7 @@ func TestQuoteAppliesBuiltinLongContextPolicyAtStrictThreshold(t *testing.T) { CacheWrite1H: 68_001, Output: 1_000_000, }, - want: 8.860008, + want: 8_860_008_000, }, } for _, test := range tests { @@ -96,7 +98,9 @@ func TestQuoteAppliesBuiltinLongContextPolicyAtStrictThreshold(t *testing.T) { if quote.State != CostStatePriced { t.Fatalf("Quote() state = %q, want %q", quote.State, CostStatePriced) } - assertCostClose(t, quote.Cost, test.want) + if quote.EstimatedCostNanoUSD != test.want { + t.Fatalf("Quote() cost = %d, want %d nano USD", quote.EstimatedCostNanoUSD, test.want) + } if result.Tokens != before { t.Fatalf("Quote() mutated tokens: got %+v, want %+v", result.Tokens, before) } @@ -104,17 +108,17 @@ func TestQuoteAppliesBuiltinLongContextPolicyAtStrictThreshold(t *testing.T) { } } -func TestQuoteLongContextPolicyFailsClosedOnOverflowAndNonFiniteCost(t *testing.T) { +func TestQuoteFailsClosedOnMissingPriceAndOverflow(t *testing.T) { t.Parallel() baseRule := Rule{ Pattern: "model", Prices: Prices{ - UncachedInput: Price{Value: 1, Set: true}, - CacheRead: Price{Value: 1, Set: true}, - CacheWrite5M: Price{Value: 1, Set: true}, - CacheWrite1H: Price{Value: 1, Set: true}, - Output: Price{Value: 1, Set: true}, + UncachedInput: fixedPrice(1), + CacheRead: fixedPrice(1), + CacheWrite5M: fixedPrice(1), + CacheWrite1H: fixedPrice(1), + Output: fixedPrice(1), }, Source: SourceBuiltin, SourceURL: "https://builtin.example/pricing", @@ -123,8 +127,11 @@ func TestQuoteLongContextPolicyFailsClosedOnOverflowAndNonFiniteCost(t *testing. } missingPrice := baseRule missingPrice.Prices.CacheWrite1H = Price{} - nonFiniteCost := baseRule - nonFiniteCost.Prices.UncachedInput = Price{Value: math.MaxFloat64, Set: true} + componentOverflow := baseRule + componentOverflow.Prices.Output = fixedPrice(math.MaxInt64) + sumOverflow := baseRule + sumOverflow.Prices.UncachedInput = fixedPrice(math.MaxInt64) + sumOverflow.Prices.Output = fixedPrice(1) tests := []struct { name string @@ -132,7 +139,7 @@ func TestQuoteLongContextPolicyFailsClosedOnOverflowAndNonFiniteCost(t *testing. tokens usage.Tokens }{ { - name: "four-input sum overflow", + name: "four-input token sum overflow", rule: baseRule, tokens: usage.Tokens{ UncachedInput: math.MaxInt64, @@ -140,7 +147,7 @@ func TestQuoteLongContextPolicyFailsClosedOnOverflowAndNonFiniteCost(t *testing. }, }, { - name: "missing multiplied component price", + name: "missing used component price", rule: missingPrice, tokens: usage.Tokens{ UncachedInput: 272_000, @@ -148,12 +155,23 @@ func TestQuoteLongContextPolicyFailsClosedOnOverflowAndNonFiniteCost(t *testing. }, }, { - name: "non-finite multiplied component cost", - rule: nonFiniteCost, + name: "component result overflow", + rule: componentOverflow, + tokens: usage.Tokens{Output: 1_000_001}, + }, + { + name: "final sum overflow", + rule: sumOverflow, tokens: usage.Tokens{ - UncachedInput: 272_001, + UncachedInput: 1_000_000, + Output: 1_000_000, }, }, + { + name: "multiplied result overflow", + rule: componentOverflow, + tokens: usage.Tokens{UncachedInput: 272_001, Output: 1_000_000}, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -161,7 +179,7 @@ func TestQuoteLongContextPolicyFailsClosedOnOverflowAndNonFiniteCost(t *testing. Tokens: test.tokens, State: usage.StateComplete, }) - if quote.State != CostStateUnpriced || quote.Cost != 0 { + if quote.State != CostStateUnpriced || quote.EstimatedCostNanoUSD != 0 { t.Fatalf("Quote() = %+v, want unpriced zero", quote) } }) @@ -177,8 +195,8 @@ func TestQuoteUserOverridesDoNotInheritBuiltinLongContextPolicy(t *testing.T) { rules = append(rules, Rule{ Pattern: pattern, Prices: Prices{ - UncachedInput: Price{Value: 1, Set: true}, - Output: Price{Value: 1, Set: true}, + UncachedInput: fixedPrice(1_000_000_000), + Output: fixedPrice(1_000_000_000), }, Source: SourceUser, }) @@ -198,7 +216,9 @@ func TestQuoteUserOverridesDoNotInheritBuiltinLongContextPolicy(t *testing.T) { if quote.State != CostStatePriced { t.Fatalf("Quote() state = %q, want %q", quote.State, CostStatePriced) } - assertCostClose(t, quote.Cost, 1.272001) + if quote.EstimatedCostNanoUSD != 1_272_001_000 { + t.Fatalf("Quote() cost = %d, want 1272001000 nano USD", quote.EstimatedCostNanoUSD) + } }) } } @@ -208,31 +228,29 @@ func TestQuoteDistinguishesUnsetFromExplicitZero(t *testing.T) { explicitZero := mustCompile(t, Rule{ Pattern: "zero", - Prices: Prices{Output: Price{Value: 0, Set: true}}, + Prices: Prices{Output: fixedPrice(0)}, Source: SourceUser, }) zeroOutput := explicitZero.Quote("zero", usage.Result{ Tokens: usage.Tokens{Output: 1}, State: usage.StateComplete, }) - if zeroOutput.State != CostStatePriced { - t.Fatalf("explicit zero output state = %q, want %q", zeroOutput.State, CostStatePriced) + if zeroOutput.State != CostStatePriced || zeroOutput.EstimatedCostNanoUSD != 0 { + t.Fatalf("explicit zero quote = %+v, want priced zero", zeroOutput) } - assertCostClose(t, zeroOutput.Cost, 0) unsetRead := explicitZero.Quote("zero", usage.Result{ Tokens: usage.Tokens{CacheRead: 1}, State: usage.StateComplete, }) - if unsetRead.State != CostStateUnpriced || unsetRead.Cost != 0 { + if unsetRead.State != CostStateUnpriced || unsetRead.EstimatedCostNanoUSD != 0 { t.Fatalf("unset cache read quote = %+v, want unpriced zero", unsetRead) } zeroTokens := explicitZero.Quote("zero", usage.Result{State: usage.StateComplete}) - if zeroTokens.State != CostStatePriced { - t.Fatalf("zero-token quote state = %q, want %q", zeroTokens.State, CostStatePriced) + if zeroTokens.State != CostStatePriced || zeroTokens.EstimatedCostNanoUSD != 0 { + t.Fatalf("zero-token quote = %+v, want priced zero", zeroTokens) } - assertCostClose(t, zeroTokens.Cost, 0) } func TestQuoteMapsUsageAndDiagnosticsToCostState(t *testing.T) { @@ -240,18 +258,20 @@ func TestQuoteMapsUsageAndDiagnosticsToCostState(t *testing.T) { table := mustCompile(t, Rule{ Pattern: "model", - Prices: Prices{Output: Price{Value: 1, Set: true}}, + Prices: Prices{Output: fixedPrice(1_000_000_000)}, Source: SourceUser, }) - if quote := table.Quote("missing", usage.Result{State: usage.StateComplete}); quote.State != CostStateUnpriced || quote.Cost != 0 { - t.Fatalf("unmatched complete quote = %+v, want unpriced zero", quote) + assertUnpriced := func(t *testing.T, quote Quote) { + t.Helper() + if quote.State != CostStateUnpriced || quote.EstimatedCostNanoUSD != 0 { + t.Fatalf("Quote() = %+v, want unpriced zero", quote) + } } - if quote := table.Quote("model", usage.Result{State: usage.StateNotApplicable}); quote.State != CostStateNotApplicable || quote.Cost != 0 { + assertUnpriced(t, table.Quote("missing", usage.Result{State: usage.StateComplete})) + if quote := table.Quote("model", usage.Result{State: usage.StateNotApplicable}); quote.State != CostStateNotApplicable || quote.EstimatedCostNanoUSD != 0 { t.Fatalf("not-applicable quote = %+v, want not_applicable zero", quote) } - if quote := table.Quote("model", usage.Result{State: usage.StateMissing}); quote.State != CostStateUnpriced || quote.Cost != 0 { - t.Fatalf("missing quote = %+v, want unpriced zero", quote) - } + assertUnpriced(t, table.Quote("model", usage.Result{State: usage.StateMissing})) for _, state := range []usage.State{usage.StateComplete, usage.StatePartial} { quote := table.Quote("model", usage.Result{Tokens: usage.Tokens{Output: 1}, State: state}) if quote.State != CostStatePriced { @@ -273,7 +293,7 @@ func TestQuoteMapsUsageAndDiagnosticsToCostState(t *testing.T) { State: usage.StateComplete, Diagnostics: diagnostics, }) - if quote.State != CostStateUnpriced || quote.Cost != 0 { + if quote.State != CostStateUnpriced || quote.EstimatedCostNanoUSD != 0 { t.Errorf("diagnostic %q quote = %+v, want unpriced zero", diagnostic, quote) } } @@ -292,41 +312,7 @@ func TestQuoteMapsUsageAndDiagnosticsToCostState(t *testing.T) { t.Errorf("diagnostic %q quote state = %q, want %q", diagnostic, quote.State, CostStatePriced) } } - if quote := table.Quote("model", usage.Result{State: usage.State("unknown")}); quote.State != CostStateUnpriced || quote.Cost != 0 { - t.Fatalf("unknown usage state quote = %+v, want unpriced zero", quote) - } -} - -func TestQuoteRejectsNonFiniteMultiplicationAndAccumulation(t *testing.T) { - t.Parallel() - - multiplication := mustCompile(t, Rule{ - Pattern: "multiply", - Prices: Prices{Output: Price{Value: math.MaxFloat64, Set: true}}, - Source: SourceUser, - }) - if quote := multiplication.Quote("multiply", usage.Result{ - Tokens: usage.Tokens{Output: 2_000_000}, - State: usage.StateComplete, - }); quote.State != CostStateUnpriced || quote.Cost != 0 { - t.Fatalf("non-finite multiplication quote = %+v, want unpriced zero", quote) - } - - accumulation := mustCompile(t, Rule{ - Pattern: "accumulate", - Prices: Prices{ - UncachedInput: Price{Value: math.MaxFloat64 / 2, Set: true}, - CacheRead: Price{Value: math.MaxFloat64 / 2, Set: true}, - Output: Price{Value: math.MaxFloat64 / 2, Set: true}, - }, - Source: SourceUser, - }) - if quote := accumulation.Quote("accumulate", usage.Result{ - Tokens: usage.Tokens{UncachedInput: 1_000_000, CacheRead: 1_000_000, Output: 1_000_000}, - State: usage.StateComplete, - }); quote.State != CostStateUnpriced || quote.Cost != 0 { - t.Fatalf("non-finite accumulation quote = %+v, want unpriced zero", quote) - } + assertUnpriced(t, table.Quote("model", usage.Result{State: usage.State("unknown")})) } func TestQuoteRejectsNegativeTokensAndTokenTotalOverflow(t *testing.T) { @@ -335,15 +321,15 @@ func TestQuoteRejectsNegativeTokensAndTokenTotalOverflow(t *testing.T) { table := mustCompile(t, Rule{ Pattern: "model", Prices: Prices{ - UncachedInput: Price{Value: 0, Set: true}, - CacheRead: Price{Value: 0, Set: true}, - CacheWrite5M: Price{Value: 0, Set: true}, - CacheWrite1H: Price{Value: 0, Set: true}, - Output: Price{Value: 0, Set: true}, + UncachedInput: fixedPrice(0), + CacheRead: fixedPrice(0), + CacheWrite5M: fixedPrice(0), + CacheWrite1H: fixedPrice(0), + Output: fixedPrice(0), }, Source: SourceUser, }) - var perFieldOverflow int64 = math.MaxInt64/5 + 1 + perFieldOverflow := int64(math.MaxInt64/5 + 1) tests := []struct { name string tokens usage.Tokens @@ -369,7 +355,7 @@ func TestQuoteRejectsNegativeTokensAndTokenTotalOverflow(t *testing.T) { Tokens: test.tokens, State: usage.StateComplete, }) - if quote.State != CostStateUnpriced || quote.Cost != 0 { + if quote.State != CostStateUnpriced || quote.EstimatedCostNanoUSD != 0 { t.Fatalf("Quote() = %+v, want unpriced zero", quote) } }) @@ -385,17 +371,14 @@ func mustCompile(t *testing.T, rules ...Rule) *Table { return table } -func assertCostClose(t *testing.T, got, want float64) { - t.Helper() - if math.Abs(got-want) > 1e-12 { - t.Fatalf("cost = %.17g, want %.17g", got, want) - } +func fixedPrice(value NanoUSD) Price { + return Price{NanoUSDPerMillion: value, Set: true} } func testLongContextPolicy() *LongContextPolicy { return &LongContextPolicy{ InputThresholdTokens: 272_000, - InputMultiplier: 2, - OutputMultiplier: 1.5, + InputMultiplier: Multiplier{Numerator: 2, Denominator: 1}, + OutputMultiplier: Multiplier{Numerator: 3, Denominator: 2}, } } diff --git a/internal/pricing/types.go b/internal/pricing/types.go index 843db8bd..9a79fa8c 100644 --- a/internal/pricing/types.go +++ b/internal/pricing/types.go @@ -5,8 +5,8 @@ import "time" // Price is a USD price per one million tokens. Set distinguishes zero from an // unavailable price. type Price struct { - Value float64 - Set bool + NanoUSDPerMillion NanoUSD + Set bool } // Prices is the provider-neutral price breakdown. @@ -21,8 +21,8 @@ type Prices struct { // LongContextPolicy adjusts component prices after an input-token threshold. type LongContextPolicy struct { InputThresholdTokens int64 - InputMultiplier float64 - OutputMultiplier float64 + InputMultiplier Multiplier + OutputMultiplier Multiplier } // Source identifies where a pricing rule came from. @@ -52,10 +52,10 @@ const ( CostStateNotApplicable CostState = "not_applicable" ) -// Quote is a calculated request cost in USD. +// Quote is a calculated request cost in nano USD. type Quote struct { - State CostState - Cost float64 + State CostState + EstimatedCostNanoUSD NanoUSD } // Table is an immutable compiled set of pricing rules. diff --git a/internal/requestlog/home_statistics.go b/internal/requestlog/home_statistics.go new file mode 100644 index 00000000..7fca1971 --- /dev/null +++ b/internal/requestlog/home_statistics.go @@ -0,0 +1,436 @@ +package requestlog + +import ( + "context" + "errors" + "fmt" + "sort" + + "gorm.io/gorm" + + "gpt-load/internal/platform/epochms" + "gpt-load/internal/storage/models" +) + +const ( + homeStatisticsRankingLimit = 5 + maxJSONSafeInteger int64 = 9_007_199_254_740_991 +) + +type HomeStatisticsRange string + +const ( + HomeStatistics24H HomeStatisticsRange = "24h" + HomeStatistics30D HomeStatisticsRange = "30d" +) + +type HomeStatisticsQuery struct { + Range HomeStatisticsRange + ObservedAtMS int64 +} + +type HomeStatisticsRef struct { + ID uint + Name *string + Deleted bool +} + +type HomeModelRanking struct { + Group HomeStatisticsRef + Model string + UsageAggregate +} + +type HomeGroupRanking struct { + Group HomeStatisticsRef + UsageAggregate +} + +type HomeAccessKeyRanking struct { + AccessKey HomeStatisticsRef + UsageAggregate +} + +type HomeStatisticsReport struct { + Range HomeStatisticsRange + ObservedAtMS int64 + FromMS int64 + ToMS int64 + Granularity UsageGranularity + Summary UsageAggregate + Series []UsageSeriesPoint + TopModels []HomeModelRanking + TopGroups []HomeGroupRanking + TopAccessKeys []HomeAccessKeyRanking +} + +func (service *Service) QueryHomeStatistics( + ctx context.Context, + input HomeStatisticsQuery, +) (HomeStatisticsReport, error) { + if service == nil || service.db == nil { + return HomeStatisticsReport{}, fmt.Errorf("query home statistics: database is nil") + } + if ctx == nil { + ctx = context.Background() + } + + width, count, granularity, err := homeStatisticsWindow(input.Range) + if err != nil { + return HomeStatisticsReport{}, err + } + fromMS, toMS, err := epochms.WindowEndingAt(input.ObservedAtMS, width, count) + if err != nil { + return HomeStatisticsReport{}, fmt.Errorf("query home statistics: invalid observed time: %w", err) + } + usageInput := UsageQuery{ + FromMS: fromMS, + ToMS: toMS, + Granularity: granularity, + Limit: homeStatisticsRankingLimit, + BreakdownOrder: UsageBreakdownOrderCost, + } + + report := HomeStatisticsReport{ + Range: input.Range, + ObservedAtMS: input.ObservedAtMS, + FromMS: fromMS, + ToMS: toMS, + Granularity: granularity, + } + err = service.db.WithContext(ctx).Connection(func(connection *gorm.DB) (operationErr error) { + if err := connection.Exec("BEGIN DEFERRED").Error; err != nil { + return fmt.Errorf("begin home statistics read transaction: %w", err) + } + committed := false + defer func() { + if !committed { + if rollbackErr := rollbackUsageReadTransaction(connection); rollbackErr != nil { + operationErr = errors.Join(operationErr, rollbackErr) + } + } + }() + + scope := usageStatScope(connection, usageInput) + if err := validateUsageStatIntegrity(scope); err != nil { + return err + } + summary, err := queryUsageSummary(usageStatScope(connection, usageInput)) + if err != nil { + return err + } + if err := validateHomeStatisticsAggregate(summary); err != nil { + return fmt.Errorf("validate home statistics summary: %w", err) + } + sparseSeries, err := queryUsageSeries( + usageStatScope(connection, usageInput), + granularity, + ) + if err != nil { + return err + } + for _, point := range sparseSeries { + if err := validateHomeStatisticsAggregate(point.UsageAggregate); err != nil { + return fmt.Errorf("validate home statistics series: %w", err) + } + } + modelRows, err := queryHomeModelRankings( + usageStatScope(connection, usageInput), + ) + if err != nil { + return err + } + groupRows, err := queryHomeGroupRankings( + usageStatScope(connection, usageInput), + ) + if err != nil { + return err + } + accessRows, err := queryHomeAccessKeyRankings( + usageStatScope(connection, usageInput), + ) + if err != nil { + return err + } + groupRefs, err := loadHomeGroupRefs(connection, modelRows, groupRows) + if err != nil { + return err + } + accessRefs, err := loadHomeAccessKeyRefs(connection, accessRows) + if err != nil { + return err + } + + report.Summary = summary + report.Series = denseHomeStatisticsSeries( + fromMS, + toMS, + width, + sparseSeries, + ) + report.TopModels = mapHomeModelRankings(modelRows, groupRefs) + report.TopGroups = mapHomeGroupRankings(groupRows, groupRefs) + report.TopAccessKeys = mapHomeAccessKeyRankings(accessRows, accessRefs) + + if err := connection.Exec("COMMIT").Error; err != nil { + return fmt.Errorf("commit home statistics read transaction: %w", err) + } + committed = true + return nil + }) + if err != nil { + return HomeStatisticsReport{}, fmt.Errorf("query home statistics: %w", err) + } + return report, nil +} + +func homeStatisticsWindow( + value HomeStatisticsRange, +) (width int64, count int, granularity UsageGranularity, err error) { + switch value { + case HomeStatistics24H: + return epochms.MillisecondsPerHour, 24, UsageGranularityHour, nil + case HomeStatistics30D: + return epochms.MillisecondsPerDay, 30, UsageGranularityDay, nil + default: + return 0, 0, "", fmt.Errorf( + "query home statistics: unsupported range %q", + value, + ) + } +} + +func denseHomeStatisticsSeries( + fromMS int64, + toMS int64, + width int64, + sparse []UsageSeriesPoint, +) []UsageSeriesPoint { + byStart := make(map[int64]UsageAggregate, len(sparse)) + for _, point := range sparse { + byStart[point.BucketStartMS] = point.UsageAggregate + } + result := make([]UsageSeriesPoint, 0, int((toMS-fromMS)/width)) + for bucketStartMS := fromMS; bucketStartMS < toMS; bucketStartMS += width { + result = append(result, UsageSeriesPoint{ + BucketStartMS: bucketStartMS, + BucketEndMS: bucketStartMS + width, + UsageAggregate: byStart[bucketStartMS], + }) + } + return result +} + +type homeModelRankingRow struct { + GroupID uint + Model string + UsageAggregate +} + +type homeGroupRankingRow struct { + GroupID uint + UsageAggregate +} + +type homeAccessKeyRankingRow struct { + AccessKeyID uint + UsageAggregate +} + +func queryHomeModelRankings(scope *gorm.DB) ([]homeModelRankingRow, error) { + var rows []homeModelRankingRow + if err := scope. + Select("group_id, model, " + usageAggregateSelect). + Group("group_id, model"). + Order("SUM(estimated_cost_nano_usd) DESC"). + Order("SUM(request_count) DESC"). + Order("group_id ASC"). + Order("model ASC"). + Limit(homeStatisticsRankingLimit). + Find(&rows).Error; err != nil { + return nil, fmt.Errorf("query home model rankings: %w", err) + } + for _, row := range rows { + if err := validateHomeStatisticsAggregate(row.UsageAggregate); err != nil { + return nil, fmt.Errorf("validate home model ranking: %w", err) + } + } + return rows, nil +} + +func queryHomeGroupRankings(scope *gorm.DB) ([]homeGroupRankingRow, error) { + var rows []homeGroupRankingRow + if err := scope. + Select("group_id, " + usageAggregateSelect). + Group("group_id"). + Order("SUM(estimated_cost_nano_usd) DESC"). + Order("SUM(request_count) DESC"). + Order("group_id ASC"). + Limit(homeStatisticsRankingLimit). + Find(&rows).Error; err != nil { + return nil, fmt.Errorf("query home group rankings: %w", err) + } + for _, row := range rows { + if err := validateHomeStatisticsAggregate(row.UsageAggregate); err != nil { + return nil, fmt.Errorf("validate home group ranking: %w", err) + } + } + return rows, nil +} + +func queryHomeAccessKeyRankings(scope *gorm.DB) ([]homeAccessKeyRankingRow, error) { + var rows []homeAccessKeyRankingRow + if err := scope. + Select("access_key_id, " + usageAggregateSelect). + Group("access_key_id"). + Order("SUM(estimated_cost_nano_usd) DESC"). + Order("SUM(request_count) DESC"). + Order("access_key_id ASC"). + Limit(homeStatisticsRankingLimit). + Find(&rows).Error; err != nil { + return nil, fmt.Errorf("query home access key rankings: %w", err) + } + for _, row := range rows { + if err := validateHomeStatisticsAggregate(row.UsageAggregate); err != nil { + return nil, fmt.Errorf("validate home access key ranking: %w", err) + } + } + return rows, nil +} + +func validateHomeStatisticsAggregate(value UsageAggregate) error { + if err := validateUsageAggregate(value); err != nil { + return err + } + for _, field := range []struct { + name string + value int64 + }{ + {name: "request count", value: value.RequestCount}, + {name: "success count", value: value.SuccessCount}, + {name: "failure count", value: value.FailureCount}, + {name: "uncached input tokens", value: value.UncachedInputTokens}, + {name: "cache read tokens", value: value.CacheReadTokens}, + {name: "cache write 5m tokens", value: value.CacheWrite5MTokens}, + {name: "cache write 1h tokens", value: value.CacheWrite1HTokens}, + {name: "output tokens", value: value.OutputTokens}, + {name: "usage missing count", value: value.UsageMissingCount}, + {name: "partial count", value: value.PartialCount}, + {name: "unpriced request count", value: value.UnpricedRequestCount}, + } { + if field.value > maxJSONSafeInteger { + return fmt.Errorf("%s exceeds JSON safe integer", field.name) + } + } + return nil +} + +func loadHomeGroupRefs( + tx *gorm.DB, + modelRows []homeModelRankingRow, + groupRows []homeGroupRankingRow, +) (map[uint]HomeStatisticsRef, error) { + ids := make(map[uint]struct{}, len(modelRows)+len(groupRows)) + for _, row := range modelRows { + ids[row.GroupID] = struct{}{} + } + for _, row := range groupRows { + ids[row.GroupID] = struct{}{} + } + return loadHomeRefs[models.Group](tx, ids, "groups") +} + +func loadHomeAccessKeyRefs( + tx *gorm.DB, + rows []homeAccessKeyRankingRow, +) (map[uint]HomeStatisticsRef, error) { + ids := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + ids[row.AccessKeyID] = struct{}{} + } + return loadHomeRefs[models.AccessKey](tx, ids, "access keys") +} + +func loadHomeRefs[T any]( + tx *gorm.DB, + idSet map[uint]struct{}, + label string, +) (map[uint]HomeStatisticsRef, error) { + ids := make([]uint, 0, len(idSet)) + for id := range idSet { + if id != 0 { + ids = append(ids, id) + } + } + sort.Slice(ids, func(left, right int) bool { return ids[left] < ids[right] }) + + refs := make(map[uint]HomeStatisticsRef, len(idSet)) + for id := range idSet { + refs[id] = HomeStatisticsRef{ID: id, Deleted: true} + } + if len(ids) == 0 { + return refs, nil + } + var rows []struct { + ID uint + Name string + } + if err := tx.Session(&gorm.Session{NewDB: true}).Model(new(T)). + Select("id", "name"). + Where("id IN ?", ids). + Find(&rows).Error; err != nil { + return nil, fmt.Errorf("query home statistics %s: %w", label, err) + } + for _, row := range rows { + name := row.Name + refs[row.ID] = HomeStatisticsRef{ + ID: row.ID, + Name: &name, + Deleted: false, + } + } + return refs, nil +} + +func mapHomeModelRankings( + rows []homeModelRankingRow, + refs map[uint]HomeStatisticsRef, +) []HomeModelRanking { + result := make([]HomeModelRanking, 0, len(rows)) + for _, row := range rows { + result = append(result, HomeModelRanking{ + Group: refs[row.GroupID], + Model: row.Model, + UsageAggregate: row.UsageAggregate, + }) + } + return result +} + +func mapHomeGroupRankings( + rows []homeGroupRankingRow, + refs map[uint]HomeStatisticsRef, +) []HomeGroupRanking { + result := make([]HomeGroupRanking, 0, len(rows)) + for _, row := range rows { + result = append(result, HomeGroupRanking{ + Group: refs[row.GroupID], + UsageAggregate: row.UsageAggregate, + }) + } + return result +} + +func mapHomeAccessKeyRankings( + rows []homeAccessKeyRankingRow, + refs map[uint]HomeStatisticsRef, +) []HomeAccessKeyRanking { + result := make([]HomeAccessKeyRanking, 0, len(rows)) + for _, row := range rows { + result = append(result, HomeAccessKeyRanking{ + AccessKey: refs[row.AccessKeyID], + UsageAggregate: row.UsageAggregate, + }) + } + return result +} diff --git a/internal/requestlog/home_statistics_test.go b/internal/requestlog/home_statistics_test.go new file mode 100644 index 00000000..ae0672ce --- /dev/null +++ b/internal/requestlog/home_statistics_test.go @@ -0,0 +1,361 @@ +package requestlog + +import ( + "context" + "math" + "testing" + + "gorm.io/gorm" + + "gpt-load/internal/storage/models" +) + +func TestQueryHomeStatisticsReturnsDenseEpochWindowsForEmptyData(t *testing.T) { + const observedAtMS int64 = 1_784_896_496_789 + tests := []struct { + name string + rangeValue HomeStatisticsRange + fromMS int64 + toMS int64 + bucketWidth int64 + buckets int + }{ + { + name: "24 hours", + rangeValue: HomeStatistics24H, + fromMS: 1_784_811_600_000, + toMS: 1_784_898_000_000, + bucketWidth: 3_600_000, + buckets: 24, + }, + { + name: "30 days", + rangeValue: HomeStatistics30D, + fromMS: 1_782_345_600_000, + toMS: 1_784_937_600_000, + bucketWidth: 86_400_000, + buckets: 30, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + report, err := newRequestLogTestService(openRequestLogQueryDB(t)). + QueryHomeStatistics(context.Background(), HomeStatisticsQuery{ + Range: test.rangeValue, + ObservedAtMS: observedAtMS, + }) + if err != nil { + t.Fatalf("QueryHomeStatistics() error = %v", err) + } + if report.Range != test.rangeValue || report.ObservedAtMS != observedAtMS || + report.FromMS != test.fromMS || report.ToMS != test.toMS || + len(report.Series) != test.buckets { + t.Fatalf("window/report = %#v", report) + } + if report.Summary != (UsageAggregate{}) || + len(report.TopModels) != 0 || + len(report.TopGroups) != 0 || + len(report.TopAccessKeys) != 0 { + t.Fatalf("empty aggregates/rankings = %#v", report) + } + for index, point := range report.Series { + wantStart := test.fromMS + int64(index)*test.bucketWidth + if point.BucketStartMS != wantStart || + point.BucketEndMS != wantStart+test.bucketWidth || + point.UsageAggregate != (UsageAggregate{}) { + t.Fatalf("series[%d] = %#v", index, point) + } + } + }) + } +} + +func TestQueryHomeStatisticsBuildsTopFiveRankingsAndDeletedRefs(t *testing.T) { + db := openRequestLogQueryDB(t) + groupA := createHomeStatisticsGroup(t, db, "Group A") + groupB := createHomeStatisticsGroup(t, db, "Group B") + accessA := createHomeStatisticsAccessKey(t, db, "Access A", "0001") + accessB := createHomeStatisticsAccessKey(t, db, "Access B", "0002") + const bucketStartMS int64 = 1_784_894_400_000 + + rows := []models.UsageStat{ + homeStatisticsStat(bucketStartMS, accessA.ID, groupA.ID, "z-model", 5, 500), + homeStatisticsStat(bucketStartMS, accessA.ID, groupA.ID, "a-model", 5, 500), + homeStatisticsStat(bucketStartMS, accessB.ID, groupB.ID, "a-model", 5, 500), + homeStatisticsStat(bucketStartMS, 999, 999, "deleted-model", 100, 400), + homeStatisticsStat(bucketStartMS, 0, 0, "", 20, 300), + homeStatisticsStat(bucketStartMS, accessB.ID, groupB.ID, "low-model", 1, 200), + homeStatisticsStat(bucketStartMS, accessB.ID, groupB.ID, "excluded-model", 1, 100), + } + createUsageStats(t, db, rows...) + + report, err := newRequestLogTestService(db).QueryHomeStatistics( + context.Background(), + HomeStatisticsQuery{ + Range: HomeStatistics24H, + ObservedAtMS: 1_784_896_496_789, + }, + ) + if err != nil { + t.Fatalf("QueryHomeStatistics() error = %v", err) + } + if report.Summary.RequestCount != 137 || + report.Summary.SuccessCount != 137 || + report.Summary.UncachedInputTokens != 1_370 || + report.Summary.EstimatedCostNanoUSD != 2_500 { + t.Fatalf("summary = %#v", report.Summary) + } + if len(report.TopModels) != 5 || + len(report.TopGroups) != 4 || + len(report.TopAccessKeys) != 4 { + t.Fatalf( + "ranking lengths = %d/%d/%d", + len(report.TopModels), + len(report.TopGroups), + len(report.TopAccessKeys), + ) + } + wantModels := []struct { + groupID uint + model string + }{ + {groupA.ID, "a-model"}, + {groupA.ID, "z-model"}, + {groupB.ID, "a-model"}, + {999, "deleted-model"}, + {0, ""}, + } + for index, want := range wantModels { + got := report.TopModels[index] + if got.Group.ID != want.groupID || got.Model != want.model { + t.Fatalf("TopModels[%d] = %#v, want group/model %d/%q", index, got, want.groupID, want.model) + } + } + if report.TopModels[0].Group.Name == nil || + *report.TopModels[0].Group.Name != "Group A" || + report.TopModels[0].Group.Deleted { + t.Fatalf("current group ref = %#v", report.TopModels[0].Group) + } + for _, index := range []int{3, 4} { + if !report.TopModels[index].Group.Deleted || + report.TopModels[index].Group.Name != nil { + t.Fatalf("deleted/zero group ref = %#v", report.TopModels[index].Group) + } + } + if report.TopGroups[0].Group.ID != groupA.ID || + report.TopGroups[0].EstimatedCostNanoUSD != 1_000 || + report.TopGroups[1].Group.ID != groupB.ID || + report.TopGroups[1].EstimatedCostNanoUSD != 800 { + t.Fatalf("TopGroups = %#v", report.TopGroups) + } + if report.TopAccessKeys[0].AccessKey.ID != accessA.ID || + report.TopAccessKeys[0].AccessKey.Name == nil || + *report.TopAccessKeys[0].AccessKey.Name != "Access A" || + report.TopAccessKeys[0].AccessKey.Deleted || + report.TopAccessKeys[1].AccessKey.ID != accessB.ID { + t.Fatalf( + "TopAccessKeys = %#v; first name = %q", + report.TopAccessKeys, + *report.TopAccessKeys[0].AccessKey.Name, + ) + } + for _, index := range []int{2, 3} { + if !report.TopAccessKeys[index].AccessKey.Deleted || + report.TopAccessKeys[index].AccessKey.Name != nil { + t.Fatalf("deleted/zero access ref = %#v", report.TopAccessKeys[index].AccessKey) + } + } +} + +func TestQueryHomeStatisticsUsesOneReadSnapshot(t *testing.T) { + db, dsn := openRequestLogFileDB(t) + const bucketStartMS int64 = 1_784_894_400_000 + createUsageStats( + t, + db, + homeStatisticsStat(bucketStartMS, 1, 1, "before", 1, 10), + ) + + writerDB, closeWriter := openUsageQueryWriterDB(t, dsn) + defer closeWriter() + inserted := false + const callbackName = "test:home_statistics_snapshot_insert" + if err := db.Callback().Query().After("gorm:query").Register(callbackName, func(tx *gorm.DB) { + if inserted || tx.Statement.Table != "usage_stats" { + return + } + inserted = true + if err := writerDB.Create(&models.UsageStat{ + BucketStartMS: bucketStartMS, + AccessKeyID: 2, + GroupID: 2, + Model: "after-first-read", + RequestCount: 100, + SuccessCount: 100, + EstimatedCostNanoUSD: 1_000, + }).Error; err != nil { + t.Errorf("insert concurrent UsageStat: %v", err) + } + }); err != nil { + t.Fatalf("register query callback: %v", err) + } + t.Cleanup(func() { + if err := db.Callback().Query().Remove(callbackName); err != nil { + t.Errorf("remove query callback: %v", err) + } + }) + + report, err := newRequestLogTestService(db).QueryHomeStatistics( + context.Background(), + HomeStatisticsQuery{ + Range: HomeStatistics24H, + ObservedAtMS: 1_784_896_496_789, + }, + ) + if err != nil { + t.Fatalf("QueryHomeStatistics() error = %v", err) + } + if !inserted || report.Summary.RequestCount != 1 || + len(report.TopModels) != 1 || report.TopModels[0].Model != "before" || + len(report.TopGroups) != 1 || len(report.TopAccessKeys) != 1 { + t.Fatalf("report did not retain one snapshot: inserted=%t report=%#v", inserted, report) + } +} + +func TestQueryHomeStatisticsRejectsUnsafeOrCorruptAggregates(t *testing.T) { + const ( + bucketStartMS int64 = 1_784_894_400_000 + observedAtMS int64 = 1_784_896_496_789 + maxSafeInt int64 = 9_007_199_254_740_991 + ) + tests := []struct { + name string + rows []models.UsageStat + }{ + { + name: "unsafe request count", + rows: []models.UsageStat{{ + BucketStartMS: bucketStartMS, + AccessKeyID: 1, + GroupID: 1, + Model: "unsafe-count", + RequestCount: maxSafeInt + 1, + SuccessCount: maxSafeInt + 1, + }}, + }, + { + name: "unsafe token count", + rows: []models.UsageStat{{ + BucketStartMS: bucketStartMS, + AccessKeyID: 1, + GroupID: 1, + Model: "unsafe-token", + RequestCount: 1, + SuccessCount: 1, + UncachedInputTokens: maxSafeInt + 1, + }}, + }, + { + name: "request invariant mismatch", + rows: []models.UsageStat{{ + BucketStartMS: bucketStartMS, + AccessKeyID: 1, + GroupID: 1, + Model: "bad-counts", + RequestCount: 3, + SuccessCount: 1, + FailureCount: 1, + }}, + }, + { + name: "cost overflow", + rows: []models.UsageStat{ + homeStatisticsStat(bucketStartMS, 1, 1, "cost-a", 1, math.MaxInt64), + homeStatisticsStat(bucketStartMS, 2, 2, "cost-b", 1, math.MaxInt64), + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db := openRequestLogQueryDB(t) + createCorruptUsageStats(t, db, test.rows...) + if _, err := newRequestLogTestService(db).QueryHomeStatistics( + context.Background(), + HomeStatisticsQuery{ + Range: HomeStatistics24H, + ObservedAtMS: observedAtMS, + }, + ); err == nil { + t.Fatal("QueryHomeStatistics() error = nil, want fail-closed rejection") + } + }) + } +} + +func TestQueryHomeStatisticsRejectsInvalidQuery(t *testing.T) { + service := newRequestLogTestService(openRequestLogQueryDB(t)) + for _, query := range []HomeStatisticsQuery{ + {Range: HomeStatisticsRange("1h"), ObservedAtMS: 1}, + {Range: HomeStatistics24H, ObservedAtMS: -1}, + } { + if _, err := service.QueryHomeStatistics(context.Background(), query); err == nil { + t.Fatalf("QueryHomeStatistics(%#v) error = nil", query) + } + } +} + +func homeStatisticsStat( + bucketStartMS int64, + accessKeyID uint, + groupID uint, + model string, + requestCount int64, + cost int64, +) models.UsageStat { + return models.UsageStat{ + BucketStartMS: bucketStartMS, + AccessKeyID: accessKeyID, + GroupID: groupID, + Model: model, + RequestCount: requestCount, + SuccessCount: requestCount, + UncachedInputTokens: requestCount * 10, + EstimatedCostNanoUSD: cost, + } +} + +func createHomeStatisticsGroup(t *testing.T, db *gorm.DB, name string) models.Group { + t.Helper() + row := models.Group{ + Name: name, + UpstreamURL: "https://example.invalid", + Protocols: models.JSON(`[]`), + Models: models.JSON(`[]`), + Enabled: true, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create Group %q: %v", name, err) + } + return row +} + +func createHomeStatisticsAccessKey( + t *testing.T, + db *gorm.DB, + name string, + suffix string, +) models.AccessKey { + t.Helper() + row := models.AccessKey{ + Name: name, + KeyValue: "encrypted-" + suffix, + KeyHash: "hash-" + suffix, + KeySuffix: suffix, + Status: "active", + Filters: models.JSON(`{}`), + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create AccessKey %q: %v", name, err) + } + return row +} diff --git a/internal/requestlog/mapper.go b/internal/requestlog/mapper.go index 8c470f31..6eec3ef9 100644 --- a/internal/requestlog/mapper.go +++ b/internal/requestlog/mapper.go @@ -2,9 +2,11 @@ package requestlog import ( "encoding/json" + "fmt" "strings" "unicode/utf8" + "gpt-load/internal/platform/epochms" "gpt-load/internal/platform/redact" "gpt-load/internal/pricing" "gpt-load/internal/storage/models" @@ -22,7 +24,12 @@ func mapEvent( redactor *redact.Redactor, event telemetry.RequestEvent, prices *pricing.Table, -) models.RequestLog { +) (models.RequestLog, error) { + completedAtMS, err := epochms.FromTime(event.CompletedAt) + if err != nil { + return models.RequestLog{}, fmt.Errorf("map request event completion time: %w", err) + } + attempts := make([]Attempt, 0, len(event.Attempts)) for _, attempt := range event.Attempts { attempts = append(attempts, Attempt{ @@ -55,29 +62,29 @@ func mapEvent( } return models.RequestLog{ - ID: event.RequestID, - CreatedAt: event.CompletedAt.UTC(), - AccessKeyID: event.AccessKeyID, - GroupID: event.Usage.GroupID, - Protocol: string(event.Protocol), - ClientModel: redactIdentityValue(redactor, projectModel(event.ClientModel)), - UpstreamModel: redactIdentityValue(redactor, projectModel(event.UpstreamModel)), - Status: string(event.Status), - StatusCode: event.StatusCode, - DurationMs: event.DurationMs, - ErrorCode: event.ErrorCode, - ErrorSummary: sanitizeSummary(redactor, event.ErrorSummary), - AffinityHit: false, - InputTokens: result.Tokens.UncachedInput, - OutputTokens: result.Tokens.Output, - CacheReadTokens: result.Tokens.CacheRead, - CacheWrite5MTokens: result.Tokens.CacheWrite5M, - CacheWrite1HTokens: result.Tokens.CacheWrite1H, - Cost: quote.Cost, - UsageState: string(result.State), - CostState: string(quote.State), - Attempts: models.JSON(encodedAttempts), - } + ID: event.RequestID, + CompletedAtMS: completedAtMS, + AccessKeyID: event.AccessKeyID, + GroupID: event.Usage.GroupID, + Protocol: string(event.Protocol), + ClientModel: redactIdentityValue(redactor, projectModel(event.ClientModel)), + UpstreamModel: redactIdentityValue(redactor, projectModel(event.UpstreamModel)), + Status: string(event.Status), + StatusCode: event.StatusCode, + DurationMs: event.DurationMs, + ErrorCode: event.ErrorCode, + ErrorSummary: sanitizeSummary(redactor, event.ErrorSummary), + AffinityHit: false, + UncachedInputTokens: result.Tokens.UncachedInput, + OutputTokens: result.Tokens.Output, + CacheReadTokens: result.Tokens.CacheRead, + CacheWrite5MTokens: result.Tokens.CacheWrite5M, + CacheWrite1HTokens: result.Tokens.CacheWrite1H, + EstimatedCostNanoUSD: int64(quote.EstimatedCostNanoUSD), + UsageState: string(result.State), + CostState: string(quote.State), + Attempts: models.JSON(encodedAttempts), + }, nil } func redactIdentityValue(redactor *redact.Redactor, value string) string { diff --git a/internal/requestlog/mapper_test.go b/internal/requestlog/mapper_test.go index ac70f7ef..08594b6c 100644 --- a/internal/requestlog/mapper_test.go +++ b/internal/requestlog/mapper_test.go @@ -46,9 +46,14 @@ func TestMapEventPersistsCompletedAtZeroUsageAndJSONArray(t *testing.T) { }}, } - row := mapEvent(redact.New(), event, nil) - if row.ID != event.RequestID || !row.CreatedAt.Equal(completedAt.UTC()) || row.CreatedAt.Location() != time.UTC { - t.Fatalf("identity/completed_at = %q/%v, want %q/%v UTC", row.ID, row.CreatedAt, event.RequestID, completedAt.UTC()) + row := mustMapEvent(t, redact.New(), event, nil) + if row.ID != event.RequestID || row.CompletedAtMS != 1_784_896_200_000 { + t.Fatalf( + "identity/completed_at_ms = %q/%d, want %q/1784896200000", + row.ID, + row.CompletedAtMS, + event.RequestID, + ) } if row.AccessKeyID != 17 || row.Protocol != string(protocol.OpenAICompletions) || row.ClientModel != "client-model" || row.UpstreamModel != "upstream-model" || row.Status != "error" || row.StatusCode != 429 || @@ -59,8 +64,8 @@ func TestMapEventPersistsCompletedAtZeroUsageAndJSONArray(t *testing.T) { if row.AffinityHit { t.Fatal("AffinityHit = true, want false in M3") } - if row.InputTokens != 0 || row.OutputTokens != 0 || row.CacheReadTokens != 0 || - row.CacheWrite5MTokens != 0 || row.CacheWrite1HTokens != 0 || row.Cost != 0 { + if row.UncachedInputTokens != 0 || row.OutputTokens != 0 || row.CacheReadTokens != 0 || + row.CacheWrite5MTokens != 0 || row.CacheWrite1HTokens != 0 || row.EstimatedCostNanoUSD != 0 { t.Fatalf("usage fields are non-zero: %+v", row) } @@ -73,12 +78,24 @@ func TestMapEventPersistsCompletedAtZeroUsageAndJSONArray(t *testing.T) { t.Fatalf("attempts = %+v", attempts) } - zeroAttempts := mapEvent(redact.New(), telemetry.RequestEvent{}, nil) + zeroEvent := telemetry.RequestEvent{ + CompletedAt: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), + } + zeroAttempts := mustMapEvent(t, redact.New(), zeroEvent, nil) if string(zeroAttempts.Attempts) != "[]" { t.Fatalf("zero attempts JSON = %q, want []", zeroAttempts.Attempts) } } +func TestMapEventRejectsPreEpochCompletion(t *testing.T) { + event := testEvent("pre-epoch-completion") + event.CompletedAt = time.Unix(-1, 0) + + if _, err := mapEvent(redact.New(), event, nil); err == nil { + t.Fatal("mapEvent() error = nil, want pre-epoch completion rejection") + } +} + func TestMapEventDefensivelyRedactsAndBoundsSummaries(t *testing.T) { const secret = "sk-this-is-a-secret-value" unsafeSummary := string([]byte{0xff}) + "\r\n\t " + secret + " " + strings.Repeat("界", 500) @@ -89,7 +106,8 @@ func TestMapEventDefensivelyRedactsAndBoundsSummaries(t *testing.T) { }}, } - row := mapEvent(redact.New(), event, nil) + event.CompletedAt = time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) + row := mustMapEvent(t, redact.New(), event, nil) if len(row.ErrorSummary) > maxSummaryBytes || !utf8.ValidString(row.ErrorSummary) { t.Fatalf("request summary bytes/UTF-8 = %d/%t", len(row.ErrorSummary), utf8.ValidString(row.ErrorSummary)) } @@ -133,9 +151,10 @@ func TestMapEventBoundsModelsAtUTF8Boundary(t *testing.T) { projectedPriceTable := compileRequestLogTestPriceTable( t, strings.Repeat("b", wantMaxModelBytes-len(truncatedMarker))+truncatedMarker, - pricing.Prices{Output: pricing.Price{Value: 1, Set: true}}, + pricing.Prices{Output: pricing.Price{NanoUSDPerMillion: 1_000_000_000, Set: true}}, ) - row := mapEvent(redact.New(), event, projectedPriceTable) + event.CompletedAt = time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) + row := mustMapEvent(t, redact.New(), event, projectedPriceTable) assertProjectedModel := func(name, projected, original string, wantTruncated bool) { t.Helper() @@ -188,7 +207,7 @@ func TestMapEventBoundsModelsAtUTF8Boundary(t *testing.T) { rawPriceTable := compileRequestLogTestPriceTable( t, rawInvalidModel, - pricing.Prices{Output: pricing.Price{Value: 2, Set: true}}, + pricing.Prices{Output: pricing.Price{NanoUSDPerMillion: 2_000_000_000, Set: true}}, ) pricedEvent := telemetry.RequestEvent{ UpstreamModel: rawInvalidModel, @@ -197,26 +216,28 @@ func TestMapEventBoundsModelsAtUTF8Boundary(t *testing.T) { Tokens: usage.Tokens{Output: 1_000_000}, }}, } - pricedRow := mapEvent(redact.New(), pricedEvent, rawPriceTable) + pricedEvent.CompletedAt = time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) + pricedRow := mustMapEvent(t, redact.New(), pricedEvent, rawPriceTable) if pricedRow.UpstreamModel == rawInvalidModel || !utf8.ValidString(pricedRow.UpstreamModel) { t.Fatalf("priced row upstream model was not projected safely: %q", pricedRow.UpstreamModel) } - if pricedRow.CostState != string(pricing.CostStatePriced) || pricedRow.Cost != 2 { + if pricedRow.CostState != string(pricing.CostStatePriced) || + pricedRow.EstimatedCostNanoUSD != 2_000_000_000 { t.Fatalf( - "raw telemetry model quote = %q/%v, want priced/2", + "raw telemetry model quote = %q/%d, want priced/2000000000", pricedRow.CostState, - pricedRow.Cost, + pricedRow.EstimatedCostNanoUSD, ) } } func TestMapEventPersistsUsageAttributionAndQuote(t *testing.T) { table := compileRequestLogTestPriceTable(t, "actual-upstream", pricing.Prices{ - UncachedInput: pricing.Price{Value: 1, Set: true}, - CacheRead: pricing.Price{Value: 2, Set: true}, - CacheWrite5M: pricing.Price{Value: 3, Set: true}, - CacheWrite1H: pricing.Price{Value: 4, Set: true}, - Output: pricing.Price{Value: 5, Set: true}, + UncachedInput: pricing.Price{NanoUSDPerMillion: 1_000_000_000, Set: true}, + CacheRead: pricing.Price{NanoUSDPerMillion: 2_000_000_000, Set: true}, + CacheWrite5M: pricing.Price{NanoUSDPerMillion: 3_000_000_000, Set: true}, + CacheWrite1H: pricing.Price{NanoUSDPerMillion: 4_000_000_000, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 5_000_000_000, Set: true}, }) t.Run("complete usage uses final attribution and all five token prices", func(t *testing.T) { @@ -238,19 +259,25 @@ func TestMapEventPersistsUsageAttributionAndQuote(t *testing.T) { }, } - row := mapEvent(redact.New(), event, table) + row := mustMapEvent(t, redact.New(), event, table) if row.GroupID != 23 || row.UpstreamModel != "actual-upstream" { t.Fatalf("attribution = group:%d model:%q, want group:23 model:actual-upstream", row.GroupID, row.UpstreamModel) } - if row.InputTokens != 1_000_000 || row.CacheReadTokens != 2_000_000 || + if row.UncachedInputTokens != 1_000_000 || row.CacheReadTokens != 2_000_000 || row.CacheWrite5MTokens != 3_000_000 || row.CacheWrite1HTokens != 4_000_000 || row.OutputTokens != 5_000_000 { t.Fatalf("persisted tokens = %+v", row) } if row.UsageState != string(usage.StateComplete) || - row.CostState != string(pricing.CostStatePriced) || row.Cost != 55 { - t.Fatalf("usage/cost = %q/%q/%v, want complete/priced/55", row.UsageState, row.CostState, row.Cost) + row.CostState != string(pricing.CostStatePriced) || + row.EstimatedCostNanoUSD != 55_000_000_000 { + t.Fatalf( + "usage/cost = %q/%q/%d, want complete/priced/55000000000", + row.UsageState, + row.CostState, + row.EstimatedCostNanoUSD, + ) } }) @@ -271,16 +298,22 @@ func TestMapEventPersistsUsageAttributionAndQuote(t *testing.T) { }, } - row := mapEvent(redact.New(), event, table) + row := mustMapEvent(t, redact.New(), event, table) - if row.InputTokens != 6 || row.CacheReadTokens != 7 || + if row.UncachedInputTokens != 6 || row.CacheReadTokens != 7 || row.CacheWrite5MTokens != 8 || row.CacheWrite1HTokens != 9 || row.OutputTokens != 10 { t.Fatalf("missing tokens were rewritten: %+v", row) } if row.UsageState != string(usage.StateMissing) || - row.CostState != string(pricing.CostStateUnpriced) || row.Cost != 0 { - t.Fatalf("missing usage/cost = %q/%q/%v", row.UsageState, row.CostState, row.Cost) + row.CostState != string(pricing.CostStateUnpriced) || + row.EstimatedCostNanoUSD != 0 { + t.Fatalf( + "missing usage/cost = %q/%q/%d", + row.UsageState, + row.CostState, + row.EstimatedCostNanoUSD, + ) } }) @@ -300,15 +333,21 @@ func TestMapEventPersistsUsageAttributionAndQuote(t *testing.T) { }, } - row := mapEvent(redact.New(), event, table) + row := mustMapEvent(t, redact.New(), event, table) if row.GroupID != 25 || row.UpstreamModel != "actual-upstream" || - row.InputTokens != 11 || row.OutputTokens != 12 { + row.UncachedInputTokens != 11 || row.OutputTokens != 12 { t.Fatalf("not-applicable attribution/tokens = %+v", row) } if row.UsageState != string(usage.StateNotApplicable) || - row.CostState != string(pricing.CostStateNotApplicable) || row.Cost != 0 { - t.Fatalf("not-applicable usage/cost = %q/%q/%v", row.UsageState, row.CostState, row.Cost) + row.CostState != string(pricing.CostStateNotApplicable) || + row.EstimatedCostNanoUSD != 0 { + t.Fatalf( + "not-applicable usage/cost = %q/%q/%d", + row.UsageState, + row.CostState, + row.EstimatedCostNanoUSD, + ) } }) } @@ -337,10 +376,10 @@ func TestMapEventHandlesNilPriceTableFailOpen(t *testing.T) { }, } - row := mapEvent(redact.New(), event, nil) + row := mustMapEvent(t, redact.New(), event, nil) if row.UsageState != string(test.usage) || row.CostState != string(test.wantState) || - row.Cost != 0 || row.InputTokens != 13 || row.CacheReadTokens != 14 || + row.EstimatedCostNanoUSD != 0 || row.UncachedInputTokens != 13 || row.CacheReadTokens != 14 || row.CacheWrite5MTokens != 15 || row.CacheWrite1HTokens != 16 || row.OutputTokens != 17 { t.Fatalf("nil-table mapping = %+v", row) diff --git a/internal/requestlog/phase4_v3_test.go b/internal/requestlog/phase4_v3_test.go new file mode 100644 index 00000000..27d47044 --- /dev/null +++ b/internal/requestlog/phase4_v3_test.go @@ -0,0 +1,53 @@ +package requestlog + +import ( + "testing" + "time" + + "gpt-load/internal/platform/redact" + "gpt-load/internal/pricing" + "gpt-load/internal/telemetry" + "gpt-load/internal/usage" +) + +func TestMapEventV3PersistsMillisecondLedgerAndQuotesUpstreamModel(t *testing.T) { + prices := compileRequestLogTestPriceTable(t, "provider-model", pricing.Prices{ + Output: pricing.Price{ + NanoUSDPerMillion: pricing.NanoUSD(2_000_000_000), + Set: true, + }, + }) + event := telemetry.RequestEvent{ + RequestID: "00000000-0000-4000-8000-000000004001", + CompletedAt: time.Date(2026, time.July, 24, 12, 34, 56, 789_000_000, time.UTC), + AccessKeyID: 41, + ClientModel: "client-alias", + UpstreamModel: "provider-model", + Status: telemetry.RequestStatusSuccess, + StatusCode: 200, + Usage: telemetry.UsageObservation{ + GroupID: 73, + Result: usage.Result{ + State: usage.StateComplete, + Tokens: usage.Tokens{Output: 1_000_000}, + }, + }, + } + + row := mustMapEvent(t, redact.New(), event, prices) + + if row.CompletedAtMS != 1_784_896_496_789 { + t.Fatalf("CompletedAtMS = %d, want 1784896496789", row.CompletedAtMS) + } + if row.ClientModel != "client-alias" || row.UpstreamModel != "provider-model" { + t.Fatalf("projected models = %q/%q", row.ClientModel, row.UpstreamModel) + } + if row.EstimatedCostNanoUSD != 2_000_000_000 || + row.CostState != string(pricing.CostStatePriced) { + t.Fatalf( + "quote = %d/%q, want 2000000000/priced", + row.EstimatedCostNanoUSD, + row.CostState, + ) + } +} diff --git a/internal/requestlog/process_log.go b/internal/requestlog/process_log.go index 9067aa0e..09fc5689 100644 --- a/internal/requestlog/process_log.go +++ b/internal/requestlog/process_log.go @@ -2,7 +2,6 @@ package requestlog import ( "net/http" - "strconv" "github.com/sirupsen/logrus" @@ -24,7 +23,10 @@ func projectProcessLog( return logrus.InfoLevel, nil, false } - row := mapEvent(redactor, event, prices) + row, err := mapEvent(redactor, event, prices) + if err != nil { + return logrus.InfoLevel, nil, false + } groupID, keyID := attributedAttempt(event) level := logrus.WarnLevel @@ -59,8 +61,8 @@ func projectProcessLog( if attemptCount := len(event.Attempts); attemptCount != 1 { fields["attempt_count"] = attemptCount } - if row.InputTokens > 0 { - fields["uncached_input_tokens"] = row.InputTokens + if row.UncachedInputTokens > 0 { + fields["uncached_input_tokens"] = row.UncachedInputTokens } if row.CacheReadTokens > 0 { fields["cache_read_tokens"] = row.CacheReadTokens @@ -86,12 +88,7 @@ func projectProcessLog( fields["cost_state"] = row.CostState } if row.CostState == string(pricing.CostStatePriced) { - fields["estimated_cost_usd"] = strconv.FormatFloat( - row.Cost, - 'g', - 12, - 64, - ) + fields["estimated_cost_nano_usd"] = row.EstimatedCostNanoUSD } if event.Status != telemetry.RequestStatusSuccess { if row.ErrorCode != "" { diff --git a/internal/requestlog/process_log_test.go b/internal/requestlog/process_log_test.go index c4e376cd..f4e1e3ce 100644 --- a/internal/requestlog/process_log_test.go +++ b/internal/requestlog/process_log_test.go @@ -50,11 +50,11 @@ func processLogEntries(t *testing.T, output []byte) []map[string]any { func TestProjectProcessLogUsesSparseFieldsAndAggregatesCacheWrite(t *testing.T) { table := compileRequestLogTestPriceTable(t, "actual-model", pricing.Prices{ - UncachedInput: pricing.Price{Value: 1, Set: true}, - CacheRead: pricing.Price{Value: 2, Set: true}, - CacheWrite5M: pricing.Price{Value: 3, Set: true}, - CacheWrite1H: pricing.Price{Value: 4, Set: true}, - Output: pricing.Price{Value: 5, Set: true}, + UncachedInput: pricing.Price{NanoUSDPerMillion: 1_000_000_000, Set: true}, + CacheRead: pricing.Price{NanoUSDPerMillion: 2_000_000_000, Set: true}, + CacheWrite5M: pricing.Price{NanoUSDPerMillion: 3_000_000_000, Set: true}, + CacheWrite1H: pricing.Price{NanoUSDPerMillion: 4_000_000_000, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 5_000_000_000, Set: true}, }) event := testEvent("00000000-0000-4000-8000-000000000501") event.Protocol = protocol.OpenAICompletions @@ -91,25 +91,25 @@ func TestProjectProcessLogUsesSparseFieldsAndAggregatesCacheWrite(t *testing.T) t.Fatalf("projection = %t/%s, want true/warning", ok, level) } want := logrus.Fields{ - "event": "data_plane_request_completed", - "request_id": event.RequestID, - "status": "error", - "status_code": http.StatusBadGateway, - "protocol": string(protocol.OpenAICompletions), - "access_key_id": uint(42), - "client_model": "client-model", - "upstream_model": "actual-model", - "group_id": uint(9), - "key_id": uint(10), - "duration_ms": int64(25), - "attempt_count": 2, - "uncached_input_tokens": int64(1), - "cache_read_tokens": int64(2), - "cache_write_tokens": int64(7), - "output_tokens": int64(5), - "estimated_cost_usd": "5.5e-05", - "error_code": "upstream_error", - "error_summary": "provider failed", + "event": "data_plane_request_completed", + "request_id": event.RequestID, + "status": "error", + "status_code": http.StatusBadGateway, + "protocol": string(protocol.OpenAICompletions), + "access_key_id": uint(42), + "client_model": "client-model", + "upstream_model": "actual-model", + "group_id": uint(9), + "key_id": uint(10), + "duration_ms": int64(25), + "attempt_count": 2, + "uncached_input_tokens": int64(1), + "cache_read_tokens": int64(2), + "cache_write_tokens": int64(7), + "output_tokens": int64(5), + "estimated_cost_nano_usd": int64(55_000), + "error_code": "upstream_error", + "error_summary": "provider failed", } assertProcessFields(t, fields, want) } @@ -346,10 +346,10 @@ func TestProjectProcessLogOmitsOverflowedCacheWriteAggregate(t *testing.T) { if value, exists := fields["cost_state"]; exists { t.Fatalf("cost_state = %#v, want omitted for priced usage", value) } - if fields["estimated_cost_usd"] != "0" { + if fields["estimated_cost_nano_usd"] != int64(0) { t.Fatalf( - "estimated_cost_usd = %#v, want 0", - fields["estimated_cost_usd"], + "estimated_cost_nano_usd = %#v, want 0", + fields["estimated_cost_nano_usd"], ) } }) @@ -375,8 +375,8 @@ func TestProjectProcessLogOmitsOverflowedCacheWriteAggregate(t *testing.T) { if fields["cost_state"] != "unpriced" { t.Fatalf("cost_state = %#v, want unpriced", fields["cost_state"]) } - if value, exists := fields["estimated_cost_usd"]; exists { - t.Fatalf("estimated_cost_usd = %#v, want omitted", value) + if value, exists := fields["estimated_cost_nano_usd"]; exists { + t.Fatalf("estimated_cost_nano_usd = %#v, want omitted", value) } }) } @@ -423,7 +423,7 @@ func TestProjectProcessLogUsesConditionalModelAttemptAndStateFields(t *testing.T func TestProjectProcessLogDoesNotRoundTinyCostToZero(t *testing.T) { table := compileRequestLogTestPriceTable(t, "tiny", pricing.Prices{ - Output: pricing.Price{Value: 0.001, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 1_000_000, Set: true}, }) event := testEvent("tiny-cost") event.UpstreamModel = "tiny" @@ -436,9 +436,8 @@ func TestProjectProcessLogDoesNotRoundTinyCostToZero(t *testing.T) { if !ok { t.Fatal("projection skipped") } - if fields["estimated_cost_usd"] == "" || - fields["estimated_cost_usd"] == "0" { - t.Fatalf("tiny cost = %#v", fields["estimated_cost_usd"]) + if fields["estimated_cost_nano_usd"] != int64(1) { + t.Fatalf("tiny cost = %#v, want 1 nano USD", fields["estimated_cost_nano_usd"]) } } @@ -577,11 +576,11 @@ func TestProcessLogFormatterSecretMatrix(t *testing.T) { func TestProjectProcessLogMatchesDurableProjection(t *testing.T) { table := compileRequestLogTestPriceTable(t, "consistent", pricing.Prices{ - UncachedInput: pricing.Price{Value: 2, Set: true}, - CacheRead: pricing.Price{Value: 3, Set: true}, - CacheWrite5M: pricing.Price{Value: 4, Set: true}, - CacheWrite1H: pricing.Price{Value: 5, Set: true}, - Output: pricing.Price{Value: 6, Set: true}, + UncachedInput: pricing.Price{NanoUSDPerMillion: 2_000_000_000, Set: true}, + CacheRead: pricing.Price{NanoUSDPerMillion: 3_000_000_000, Set: true}, + CacheWrite5M: pricing.Price{NanoUSDPerMillion: 4_000_000_000, Set: true}, + CacheWrite1H: pricing.Price{NanoUSDPerMillion: 5_000_000_000, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 6_000_000_000, Set: true}, }) event := testEvent("projection-consistency") event.Protocol = protocol.Anthropic @@ -601,23 +600,23 @@ func TestProjectProcessLogMatchesDurableProjection(t *testing.T) { }, } redactor := redact.New() - row := mapEvent(redactor, event, table) + row := mustMapEvent(t, redactor, event, table) _, fields, ok := projectProcessLog(redactor, event, table) if !ok { t.Fatal("projection skipped") } want := map[string]any{ - "request_id": row.ID, - "access_key_id": row.AccessKeyID, - "client_model": row.ClientModel, - "upstream_model": row.UpstreamModel, - "duration_ms": row.DurationMs, - "uncached_input_tokens": row.InputTokens, - "cache_read_tokens": row.CacheReadTokens, - "cache_write_tokens": int64(27), - "output_tokens": row.OutputTokens, - "estimated_cost_usd": "0.00027", + "request_id": row.ID, + "access_key_id": row.AccessKeyID, + "client_model": row.ClientModel, + "upstream_model": row.UpstreamModel, + "duration_ms": row.DurationMs, + "uncached_input_tokens": row.UncachedInputTokens, + "cache_read_tokens": row.CacheReadTokens, + "cache_write_tokens": int64(27), + "output_tokens": row.OutputTokens, + "estimated_cost_nano_usd": row.EstimatedCostNanoUSD, } for field, wantValue := range want { if got := fields[field]; !reflect.DeepEqual(got, wantValue) { @@ -645,7 +644,7 @@ func TestProcessAndDurableProjectionsRedactSensitiveIdentityFieldsConsistently( } redactor := redact.New() - row := mapEvent(redactor, event, nil) + row := mustMapEvent(t, redactor, event, nil) var attempts []Attempt if err := json.Unmarshal(row.Attempts, &attempts); err != nil { t.Fatalf("unmarshal durable attempts: %v", err) diff --git a/internal/requestlog/query.go b/internal/requestlog/query.go index cd90cd26..556e14f3 100644 --- a/internal/requestlog/query.go +++ b/internal/requestlog/query.go @@ -22,14 +22,14 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error) query := service.db.WithContext(ctx). Model(&models.RequestLog{}). - Order("created_at DESC"). + Order("completed_at_ms DESC"). Order("id DESC"). Limit(limit + 1) - if input.From != nil { - query = query.Where("created_at >= ?", input.From.UTC()) + if input.FromMS != nil { + query = query.Where("completed_at_ms >= ?", *input.FromMS) } - if input.To != nil { - query = query.Where("created_at < ?", input.To.UTC()) + if input.ToMS != nil { + query = query.Where("completed_at_ms < ?", *input.ToMS) } if input.GroupID != nil { query = query.Where(` @@ -55,9 +55,9 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error) } if input.Cursor != nil { query = query.Where( - "created_at < ? OR (created_at = ? AND id < ?)", - input.Cursor.CompletedAt.UTC(), - input.Cursor.CompletedAt.UTC(), + "completed_at_ms < ? OR (completed_at_ms = ? AND id < ?)", + input.Cursor.CompletedAtMS, + input.Cursor.CompletedAtMS, input.Cursor.RequestID, ) } @@ -83,8 +83,8 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error) if hasNext { last := records[len(records)-1] page.NextCursor = &Cursor{ - CompletedAt: last.CompletedAt, - RequestID: last.RequestID, + CompletedAtMS: last.CompletedAtMS, + RequestID: last.RequestID, } } return page, nil @@ -106,28 +106,28 @@ func decodeRequestLogRows(rows []models.RequestLog) ([]Record, error) { return nil, err } records = append(records, Record{ - RequestID: row.ID, - CompletedAt: row.CreatedAt.UTC(), - AccessKey: AccessKeyRef{ID: row.AccessKeyID, Deleted: true}, - Protocol: protocol.Protocol(row.Protocol), - ClientModel: row.ClientModel, - UpstreamModel: row.UpstreamModel, - Status: telemetry.RequestStatus(row.Status), - StatusCode: row.StatusCode, - DurationMs: row.DurationMs, - ErrorCode: row.ErrorCode, - ErrorSummary: row.ErrorSummary, - AffinityHit: row.AffinityHit, - Attempts: attempts, - GroupID: row.GroupID, - UsageState: usage.State(row.UsageState), - CostState: pricing.CostState(row.CostState), - UncachedInputTokens: row.InputTokens, - CacheReadTokens: row.CacheReadTokens, - CacheWrite5MTokens: row.CacheWrite5MTokens, - CacheWrite1HTokens: row.CacheWrite1HTokens, - OutputTokens: row.OutputTokens, - EstimatedCostUSD: row.Cost, + RequestID: row.ID, + CompletedAtMS: row.CompletedAtMS, + AccessKey: AccessKeyRef{ID: row.AccessKeyID, Deleted: true}, + Protocol: protocol.Protocol(row.Protocol), + ClientModel: row.ClientModel, + UpstreamModel: row.UpstreamModel, + Status: telemetry.RequestStatus(row.Status), + StatusCode: row.StatusCode, + DurationMs: row.DurationMs, + ErrorCode: row.ErrorCode, + ErrorSummary: row.ErrorSummary, + AffinityHit: row.AffinityHit, + Attempts: attempts, + GroupID: row.GroupID, + UsageState: usage.State(row.UsageState), + CostState: pricing.CostState(row.CostState), + UncachedInputTokens: row.UncachedInputTokens, + CacheReadTokens: row.CacheReadTokens, + CacheWrite5MTokens: row.CacheWrite5MTokens, + CacheWrite1HTokens: row.CacheWrite1HTokens, + OutputTokens: row.OutputTokens, + EstimatedCostNanoUSD: row.EstimatedCostNanoUSD, }) } return records, nil @@ -137,12 +137,12 @@ func validateRequestLogUsageCost(row models.RequestLog) error { if err := ValidateUsageCostState( usage.State(row.UsageState), pricing.CostState(row.CostState), - row.Cost, + row.EstimatedCostNanoUSD, ); err != nil { return fmt.Errorf("decode request log usage/cost: %w", err) } for _, value := range []int64{ - row.InputTokens, + row.UncachedInputTokens, row.CacheReadTokens, row.CacheWrite5MTokens, row.CacheWrite1HTokens, diff --git a/internal/requestlog/query_test.go b/internal/requestlog/query_test.go index 05786d53..058d037f 100644 --- a/internal/requestlog/query_test.go +++ b/internal/requestlog/query_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "math" "reflect" "testing" "time" @@ -21,27 +20,26 @@ import ( ) func TestDecodeRequestLogRowsPreservesUsageCostAttribution(t *testing.T) { - completedAt := time.Date(2026, time.July, 27, 1, 2, 3, 4, time.FixedZone("UTC+8", 8*60*60)) rows := []models.RequestLog{{ - ID: "00000000-0000-4000-8000-000000000601", - CreatedAt: completedAt, - AccessKeyID: 61, - GroupID: 17, - Protocol: string(protocol.OpenAICompletions), - ClientModel: "client-model", - UpstreamModel: "upstream-model", - Status: string(telemetry.RequestStatusSuccess), - StatusCode: 200, - DurationMs: 25, - InputTokens: 11, - CacheReadTokens: 12, - CacheWrite5MTokens: 13, - CacheWrite1HTokens: 14, - OutputTokens: 15, - Cost: 0.1234567890123, - UsageState: string(usage.StateComplete), - CostState: string(pricing.CostStatePriced), - Attempts: models.JSON(`[]`), + ID: "00000000-0000-4000-8000-000000000601", + CompletedAtMS: 1_785_085_323_000, + AccessKeyID: 61, + GroupID: 17, + Protocol: string(protocol.OpenAICompletions), + ClientModel: "client-model", + UpstreamModel: "upstream-model", + Status: string(telemetry.RequestStatusSuccess), + StatusCode: 200, + DurationMs: 25, + UncachedInputTokens: 11, + CacheReadTokens: 12, + CacheWrite5MTokens: 13, + CacheWrite1HTokens: 14, + OutputTokens: 15, + EstimatedCostNanoUSD: 123_456_789, + UsageState: string(usage.StateComplete), + CostState: string(pricing.CostStatePriced), + Attempts: models.JSON(`[]`), }} records, err := decodeRequestLogRows(rows) @@ -56,20 +54,20 @@ func TestDecodeRequestLogRowsPreservesUsageCostAttribution(t *testing.T) { got.CostState != pricing.CostStatePriced || got.UncachedInputTokens != 11 || got.CacheReadTokens != 12 || got.CacheWrite5MTokens != 13 || got.CacheWrite1HTokens != 14 || got.OutputTokens != 15 || - got.EstimatedCostUSD != 0.1234567890123 || - got.CompletedAt.Format(time.RFC3339Nano) != "2026-07-26T17:02:03.000000004Z" { + got.EstimatedCostNanoUSD != 123_456_789 || + got.CompletedAtMS != 1_785_085_323_000 { t.Fatalf("decoded usage/cost record = %#v", got) } } func TestDecodeRequestLogRowsIgnoresHistoricalKeyMask(t *testing.T) { rows := []models.RequestLog{{ - ID: "00000000-0000-4000-8000-000000000605", - CreatedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), - Protocol: string(protocol.OpenAICompletions), - Status: string(telemetry.RequestStatusSuccess), - UsageState: string(usage.StateNotApplicable), - CostState: string(pricing.CostStateNotApplicable), + ID: "00000000-0000-4000-8000-000000000605", + CompletedAtMS: 1_785_110_400_000, + Protocol: string(protocol.OpenAICompletions), + Status: string(telemetry.RequestStatusSuccess), + UsageState: string(usage.StateNotApplicable), + CostState: string(pricing.CostStateNotApplicable), Attempts: models.JSON( `[{"sequence":1,"group_id":7,"group_name":"Primary","key_id":11,"key_mask":"prov****safe","upstream_model":"model","status_code":200,"duration_ms":10,"failure_category":"ok","action":"terminate","will_retry":false,"error_code":"","error_summary":"","committed":true}]`, ), @@ -95,13 +93,13 @@ func TestDecodeRequestLogRowsIgnoresHistoricalKeyMask(t *testing.T) { func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) { base := models.RequestLog{ - ID: "00000000-0000-4000-8000-000000000602", - CreatedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), - Protocol: string(protocol.OpenAICompletions), - Status: string(telemetry.RequestStatusSuccess), - UsageState: string(usage.StateComplete), - CostState: string(pricing.CostStatePriced), - Attempts: models.JSON(`[]`), + ID: "00000000-0000-4000-8000-000000000602", + CompletedAtMS: 1_785_110_400_000, + Protocol: string(protocol.OpenAICompletions), + Status: string(telemetry.RequestStatusSuccess), + UsageState: string(usage.StateComplete), + CostState: string(pricing.CostStatePriced), + Attempts: models.JSON(`[]`), } tests := []struct { name string @@ -109,10 +107,8 @@ func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) { }{ {name: "usage state", mutate: func(row *models.RequestLog) { row.UsageState = "invalid" }}, {name: "cost state", mutate: func(row *models.RequestLog) { row.CostState = "invalid" }}, - {name: "negative token", mutate: func(row *models.RequestLog) { row.InputTokens = -1 }}, - {name: "negative cost", mutate: func(row *models.RequestLog) { row.Cost = -0.01 }}, - {name: "infinite cost", mutate: func(row *models.RequestLog) { row.Cost = math.Inf(1) }}, - {name: "not a number cost", mutate: func(row *models.RequestLog) { row.Cost = math.NaN() }}, + {name: "negative token", mutate: func(row *models.RequestLog) { row.UncachedInputTokens = -1 }}, + {name: "negative cost", mutate: func(row *models.RequestLog) { row.EstimatedCostNanoUSD = -1 }}, { name: "missing usage cannot be priced", mutate: func(row *models.RequestLog) { @@ -137,7 +133,7 @@ func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) { name: "unpriced cost must be zero", mutate: func(row *models.RequestLog) { row.CostState = string(pricing.CostStateUnpriced) - row.Cost = 0.01 + row.EstimatedCostNanoUSD = 1 }, }, { @@ -145,7 +141,7 @@ func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) { mutate: func(row *models.RequestLog) { row.UsageState = string(usage.StateNotApplicable) row.CostState = string(pricing.CostStateNotApplicable) - row.Cost = 0.01 + row.EstimatedCostNanoUSD = 1 }, }, } @@ -165,11 +161,11 @@ func TestDecodeRequestLogRowsAcceptsApprovedUsageCostMatrix(t *testing.T) { name string usageState usage.State costState pricing.CostState - cost float64 + cost int64 }{ - {name: "complete priced", usageState: usage.StateComplete, costState: pricing.CostStatePriced, cost: 0.01}, + {name: "complete priced", usageState: usage.StateComplete, costState: pricing.CostStatePriced, cost: 10_000_000}, {name: "complete unpriced", usageState: usage.StateComplete, costState: pricing.CostStateUnpriced}, - {name: "partial priced", usageState: usage.StatePartial, costState: pricing.CostStatePriced, cost: 0.01}, + {name: "partial priced", usageState: usage.StatePartial, costState: pricing.CostStatePriced, cost: 10_000_000}, {name: "partial unpriced", usageState: usage.StatePartial, costState: pricing.CostStateUnpriced}, {name: "missing unpriced", usageState: usage.StateMissing, costState: pricing.CostStateUnpriced}, { @@ -181,21 +177,22 @@ func TestDecodeRequestLogRowsAcceptsApprovedUsageCostMatrix(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { row := models.RequestLog{ - ID: "00000000-0000-4000-8000-000000000604", - CreatedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), - Protocol: string(protocol.OpenAICompletions), - Status: string(telemetry.RequestStatusSuccess), - UsageState: string(test.usageState), - CostState: string(test.costState), - Cost: test.cost, - Attempts: models.JSON(`[]`), + ID: "00000000-0000-4000-8000-000000000604", + CompletedAtMS: 1_785_110_400_000, + Protocol: string(protocol.OpenAICompletions), + Status: string(telemetry.RequestStatusSuccess), + UsageState: string(test.usageState), + CostState: string(test.costState), + EstimatedCostNanoUSD: test.cost, + Attempts: models.JSON(`[]`), } records, err := decodeRequestLogRows([]models.RequestLog{row}) if err != nil { t.Fatalf("decodeRequestLogRows() error = %v", err) } if len(records) != 1 || records[0].UsageState != test.usageState || - records[0].CostState != test.costState || records[0].EstimatedCostUSD != test.cost { + records[0].CostState != test.costState || + records[0].EstimatedCostNanoUSD != test.cost { t.Fatalf("records = %#v", records) } }) @@ -206,7 +203,7 @@ func TestServiceListUsesStableKeysetCursor(t *testing.T) { db := openRequestLogQueryDB(t) service := newRequestLogTestService(db) completedAt := time.Date(2026, time.July, 24, 12, 0, 0, 123, time.UTC) - older := completedAt.Add(-time.Nanosecond) + older := completedAt.Add(-time.Millisecond) for _, row := range []models.RequestLog{ requestLogQueryRow("00000000-0000-4000-8000-000000000100", older, 41, "older", nil), requestLogQueryRow("00000000-0000-4000-8000-000000000101", completedAt, 41, "same-time", nil), @@ -227,7 +224,7 @@ func TestServiceListUsesStableKeysetCursor(t *testing.T) { t.Fatalf("first page IDs = %v, want %v", got, want) } if first.NextCursor == nil || - !first.NextCursor.CompletedAt.Equal(completedAt) || + first.NextCursor.CompletedAtMS != 1_784_894_400_000 || first.NextCursor.RequestID != "00000000-0000-4000-8000-000000000102" { t.Fatalf("first NextCursor = %#v", first.NextCursor) } @@ -245,7 +242,7 @@ func TestServiceListUsesStableKeysetCursor(t *testing.T) { }; !reflect.DeepEqual(got, want) { t.Fatalf("second page IDs = %v, want %v", got, want) } - if second.Items[0].CompletedAt.Equal(second.Items[1].CompletedAt) { + if second.Items[0].CompletedAtMS == second.Items[1].CompletedAtMS { t.Fatalf("second page did not advance from tied timestamp: %+v", second.Items) } if second.NextCursor != nil { @@ -280,7 +277,7 @@ func TestServiceListAppliesAllFiltersAndGroupJSON(t *testing.T) { beforeFrom := requestLogQueryRow( "00000000-0000-4000-8000-000000000203", - from.Add(-time.Nanosecond), + from.Add(-time.Millisecond), 71, "client-model", integerAttempts, @@ -353,9 +350,11 @@ func TestServiceListAppliesAllFiltersAndGroupJSON(t *testing.T) { groupID := uint(12) accessKeyID := uint(71) + fromMS := from.UnixMilli() + toMS := to.UnixMilli() page, err := service.List(context.Background(), ListQuery{ - From: &from, - To: &to, + FromMS: &fromMS, + ToMS: &toMS, GroupID: &groupID, ClientModel: "client-model", AccessKeyID: &accessKeyID, @@ -408,7 +407,7 @@ func TestServiceListGroupFilterUsesAnyAttemptWhileAttributionUsesFinalGroup(t *t State: usage.StateNotApplicable, }, } - row := mapEvent(redact.New(), event, (*pricing.Table)(nil)) + row := mustMapEvent(t, redact.New(), event, (*pricing.Table)(nil)) if row.GroupID != 13 { t.Fatalf("top-level GroupID = %d, want final attribution 13", row.GroupID) } @@ -568,7 +567,7 @@ func requestLogQueryRow( } return models.RequestLog{ ID: id, - CreatedAt: completedAt, + CompletedAtMS: completedAt.UTC().UnixMilli(), AccessKeyID: accessKeyID, Protocol: string(protocol.OpenAICompletions), ClientModel: clientModel, @@ -576,6 +575,8 @@ func requestLogQueryRow( Status: string(telemetry.RequestStatusSuccess), StatusCode: 200, DurationMs: 25, + UsageState: string(usage.StateNotApplicable), + CostState: string(pricing.CostStateNotApplicable), Attempts: models.JSON(encodedAttempts), } } diff --git a/internal/requestlog/retention.go b/internal/requestlog/retention.go index 558f90af..30fac153 100644 --- a/internal/requestlog/retention.go +++ b/internal/requestlog/retention.go @@ -2,8 +2,10 @@ package requestlog import ( "context" + "fmt" "time" + "gpt-load/internal/platform/epochms" "gpt-load/internal/storage/models" ) @@ -11,11 +13,14 @@ type RetentionPolicyProvider interface { RequestLogRetentionDays() int } -const retentionBatchSize = 1000 +const ( + retentionBatchSize = 1000 + usageStatRetentionDays = 35 +) -// Sweep removes request logs strictly older than the configured retention -// boundary. Configuration and database failures are intentionally isolated -// from the data-plane request path. +// Sweep removes request logs and hourly usage aggregates strictly older than +// their respective retention boundaries. Failures are isolated from the data +// plane. func (service *Service) Sweep(ctx context.Context, now time.Time) { if ctx == nil { ctx = context.Background() @@ -25,17 +30,63 @@ func (service *Service) Sweep(ctx context.Context, now time.Time) { } days := service.retentionPolicy.RequestLogRetentionDays() - cutoff := now.UTC().Add(-time.Duration(days) * 24 * time.Hour) + nowMS, err := epochms.FromTime(now) + if err != nil { + service.recordRetentionDeleteFailure(now) + return + } + requestLogCutoffMS, err := retentionCutoffMS(nowMS, days) + if err != nil { + service.recordRetentionDeleteFailure(now) + return + } + usageStatCutoffMS, err := retentionCutoffMS(nowMS, usageStatRetentionDays) + if err != nil { + service.recordRetentionDeleteFailure(now) + return + } + usageStatCutoffMS, err = epochms.AlignDown( + usageStatCutoffMS, + epochms.MillisecondsPerHour, + ) + if err != nil { + service.recordRetentionDeleteFailure(now) + return + } + + service.deleteExpiredRequestLogs(ctx, requestLogCutoffMS, now) + if ctx.Err() != nil { + return + } + service.deleteExpiredUsageStats(ctx, usageStatCutoffMS, now) +} + +func retentionCutoffMS(nowMS int64, days int) (int64, error) { + if days < 0 { + return 0, fmt.Errorf("retention days must be non-negative") + } + retentionMS := int64(days) * epochms.MillisecondsPerDay + if retentionMS > nowMS { + return 0, nil + } + return nowMS - retentionMS, nil +} + +func (service *Service) deleteExpiredRequestLogs( + ctx context.Context, + cutoffMS int64, + now time.Time, +) bool { for { if ctx.Err() != nil { - return + return false } var ids []string result := service.db.WithContext(ctx). Model(&models.RequestLog{}). - Where("created_at < ?", cutoff). - Order("created_at ASC"). + Where("completed_at_ms < ?", cutoffMS). + Order("completed_at_ms ASC"). Order("id ASC"). Limit(retentionBatchSize). Pluck("id", &ids) @@ -43,10 +94,10 @@ func (service *Service) Sweep(ctx context.Context, now time.Time) { if ctx.Err() == nil { service.recordRetentionDeleteFailure(now) } - return + return false } if len(ids) == 0 { - return + return true } result = service.db.WithContext(ctx). @@ -56,10 +107,53 @@ func (service *Service) Sweep(ctx context.Context, now time.Time) { if ctx.Err() == nil { service.recordRetentionDeleteFailure(now) } - return + return false } if len(ids) < retentionBatchSize { - return + return true + } + } +} + +func (service *Service) deleteExpiredUsageStats( + ctx context.Context, + cutoffMS int64, + now time.Time, +) bool { + for { + if ctx.Err() != nil { + return false + } + + var ids []uint + result := service.db.WithContext(ctx). + Model(&models.UsageStat{}). + Where("bucket_start_ms < ?", cutoffMS). + Order("bucket_start_ms ASC"). + Order("id ASC"). + Limit(retentionBatchSize). + Pluck("id", &ids) + if result.Error != nil { + if ctx.Err() == nil { + service.recordRetentionDeleteFailure(now) + } + return false + } + if len(ids) == 0 { + return true + } + + result = service.db.WithContext(ctx). + Where("id IN ?", ids). + Delete(&models.UsageStat{}) + if result.Error != nil { + if ctx.Err() == nil { + service.recordRetentionDeleteFailure(now) + } + return false + } + if len(ids) < retentionBatchSize { + return true } } } diff --git a/internal/requestlog/retention_test.go b/internal/requestlog/retention_test.go index f82b8bbd..5015eba8 100644 --- a/internal/requestlog/retention_test.go +++ b/internal/requestlog/retention_test.go @@ -19,7 +19,7 @@ import ( "gpt-load/internal/usage" ) -func TestServiceSweepUsesSnapshotRetentionPolicy(t *testing.T) { +func TestRetentionSweepUsesSnapshotRetentionPolicy(t *testing.T) { now := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) tests := []struct { name string @@ -61,7 +61,7 @@ func TestServiceSweepUsesSnapshotRetentionPolicy(t *testing.T) { } } -func TestServiceSweepDeletesStrictlyOlderRowsInBatches(t *testing.T) { +func TestRetentionSweepDeletesStrictlyOlderRowsInBatches(t *testing.T) { db := openRequestLogQueryDB(t) service := newRequestLogTestService(db) now := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) @@ -99,7 +99,90 @@ func TestServiceSweepDeletesStrictlyOlderRowsInBatches(t *testing.T) { } } -func TestServiceSweepStopsOnContextAndDeleteFailure(t *testing.T) { +func TestRetentionSweepUsesFixedThirtyFiveDayIntegerUsageBoundary(t *testing.T) { + db := openRequestLogQueryDB(t) + service := NewService( + db, + redact.New(), + staticRetentionPolicy{days: 1}, + newStaticPriceTableProvider(), + ) + now := time.Date(2026, time.July, 24, 12, 34, 56, 789_000_000, time.UTC) + const usageCutoffMS int64 = 1_781_870_400_000 + for index, bucketStartMS := range []int64{ + usageCutoffMS - 3_600_000, + usageCutoffMS, + usageCutoffMS + 3_600_000, + } { + if err := db.Create(&models.UsageStat{ + BucketStartMS: bucketStartMS, + AccessKeyID: uint(index), + GroupID: 17, + Model: "retention-model", + RequestCount: 1, + SuccessCount: 1, + }).Error; err != nil { + t.Fatalf("create UsageStat at %d: %v", bucketStartMS, err) + } + } + + service.Sweep(context.Background(), now) + + var remaining []models.UsageStat + if err := db.Order("bucket_start_ms ASC").Find(&remaining).Error; err != nil { + t.Fatalf("query remaining UsageStats: %v", err) + } + if len(remaining) != 2 || + remaining[0].BucketStartMS != usageCutoffMS || + remaining[1].BucketStartMS != usageCutoffMS+3_600_000 { + t.Fatalf("remaining UsageStats = %+v, want cutoff and newer integer buckets", remaining) + } +} + +func TestRetentionSweepCleansUsageStatsWhenRequestLogDeleteFails(t *testing.T) { + db := openRequestLogQueryDB(t) + service := newRequestLogTestService(db) + now := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) + service.now = func() time.Time { return now } + discardRetentionWarnings(service) + createRetentionRow(t, db, 1, now.Add(-8*24*time.Hour)) + if err := db.Create(&models.UsageStat{ + BucketStartMS: now.Add(-36 * 24 * time.Hour).UnixMilli(), + AccessKeyID: 1, + GroupID: 1, + Model: "expired", + RequestCount: 1, + SuccessCount: 1, + }).Error; err != nil { + t.Fatalf("create expired UsageStat: %v", err) + } + if err := db.Exec(`CREATE TRIGGER fail_request_log_retention_delete + BEFORE DELETE ON request_logs + BEGIN + SELECT RAISE(FAIL, 'forced request log retention delete failure'); + END`).Error; err != nil { + t.Fatalf("create request log delete trigger: %v", err) + } + + service.Sweep(context.Background(), now) + + assertRetentionRows(t, db, 1) + var usageCount int64 + if err := db.Model(&models.UsageStat{}).Count(&usageCount).Error; err != nil { + t.Fatalf("count remaining UsageStats: %v", err) + } + stats := service.Stats() + if usageCount != 0 || stats.RetentionDeleteFailureTotal != 1 || + !stats.LastRetentionFailureAt.Equal(now) { + t.Fatalf( + "remaining usage/Stats = %d/%#v, want 0 and one request-log failure", + usageCount, + stats, + ) + } +} + +func TestRetentionSweepStopsOnContextAndDeleteFailure(t *testing.T) { now := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) t.Run("empty result is successful completion", func(t *testing.T) { @@ -223,6 +306,66 @@ func TestServiceSweepStopsOnContextAndDeleteFailure(t *testing.T) { t.Fatalf("Stats() = %#v, want one delete failure", stats) } }) + + t.Run("usage stat selection failure is tracked", func(t *testing.T) { + db := openRequestLogQueryDB(t) + service := newRequestLogTestService(db) + service.now = func() time.Time { return now } + discardRetentionWarnings(service) + const callbackName = "test:retention_usage_stat_query_failure" + if err := db.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Table == "usage_stats" { + tx.AddError(errors.New("forced usage stat retention query failure")) + } + }); err != nil { + t.Fatalf("register query callback: %v", err) + } + + service.Sweep(context.Background(), now) + + stats := service.Stats() + if stats.RetentionDeleteFailureTotal != 1 || + !stats.LastRetentionFailureAt.Equal(now) { + t.Fatalf("Stats() = %#v, want one usage stat selection failure", stats) + } + }) + + t.Run("usage stat delete failure is tracked", func(t *testing.T) { + db := openRequestLogQueryDB(t) + service := newRequestLogTestService(db) + service.now = func() time.Time { return now } + discardRetentionWarnings(service) + if err := db.Create(&models.UsageStat{ + BucketStartMS: now.Add(-36 * 24 * time.Hour).UnixMilli(), + AccessKeyID: 1, + GroupID: 1, + Model: "expired", + RequestCount: 1, + SuccessCount: 1, + }).Error; err != nil { + t.Fatalf("create expired UsageStat: %v", err) + } + const callbackName = "test:retention_usage_stat_delete_failure" + if err := db.Callback().Delete().Before("gorm:delete").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Table == "usage_stats" { + tx.AddError(errors.New("forced usage stat retention delete failure")) + } + }); err != nil { + t.Fatalf("register delete callback: %v", err) + } + + service.Sweep(context.Background(), now) + + var remaining int64 + if err := db.Model(&models.UsageStat{}).Count(&remaining).Error; err != nil { + t.Fatalf("count remaining UsageStats: %v", err) + } + stats := service.Stats() + if remaining != 1 || stats.RetentionDeleteFailureTotal != 1 || + !stats.LastRetentionFailureAt.Equal(now) { + t.Fatalf("remaining/Stats = %d/%#v, want 1 and one delete failure", remaining, stats) + } + }) } func TestRetentionReplayBoundaryPreservesThenReaggregatesUsageStat(t *testing.T) { diff --git a/internal/requestlog/service_test.go b/internal/requestlog/service_test.go index 4eb52308..b6b1dc48 100644 --- a/internal/requestlog/service_test.go +++ b/internal/requestlog/service_test.go @@ -478,10 +478,10 @@ func TestServiceWarningsExcludeEventContentAndThrottle(t *testing.T) { func TestEmitFreezesPriceTableBeforeWorkerFlush(t *testing.T) { tableA := compileRequestLogTestPriceTable(t, "snapshot-model", pricing.Prices{ - Output: pricing.Price{Value: 1, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 1_000_000_000, Set: true}, }) tableB := compileRequestLogTestPriceTable(t, "snapshot-model", pricing.Prices{ - Output: pricing.Price{Value: 2, Set: true}, + Output: pricing.Price{NanoUSDPerMillion: 2_000_000_000, Set: true}, }) provider := &publishingPriceTableProvider{} provider.Publish(tableA) @@ -513,7 +513,7 @@ func TestEmitFreezesPriceTableBeforeWorkerFlush(t *testing.T) { entries := processLogEntries(t, output.Bytes()) if len(entries) != 1 || entries[0]["request_id"] != "snapshot-a" || - entries[0]["estimated_cost_usd"] != "1" { + entries[0]["estimated_cost_nano_usd"] != float64(1_000_000_000) { t.Fatalf("snapshot-a completion entries = %#v", entries) } @@ -528,9 +528,11 @@ func TestEmitFreezesPriceTableBeforeWorkerFlush(t *testing.T) { timer.Fire() rows := receiveValue(t, writes) - if len(rows) != 2 || rows[0].ID != "snapshot-a" || rows[0].Cost != 1 || - rows[1].ID != "snapshot-b" || rows[1].Cost != 2 { - t.Fatalf("snapshot-priced rows = %+v, want A/1 then B/2", rows) + if len(rows) != 2 || rows[0].ID != "snapshot-a" || + rows[0].EstimatedCostNanoUSD != 1_000_000_000 || + rows[1].ID != "snapshot-b" || + rows[1].EstimatedCostNanoUSD != 2_000_000_000 { + t.Fatalf("snapshot-priced rows = %+v, want A/1000000000 then B/2000000000", rows) } if got := provider.loads.Load(); got != 3 { t.Fatalf("PriceTableProvider.Load calls = %d, want Start once plus one per Emit", got) diff --git a/internal/requestlog/test_helpers_test.go b/internal/requestlog/test_helpers_test.go index e29037aa..5d152eb1 100644 --- a/internal/requestlog/test_helpers_test.go +++ b/internal/requestlog/test_helpers_test.go @@ -234,22 +234,22 @@ func aggregationRow( model string, ) models.RequestLog { return models.RequestLog{ - ID: id, - CreatedAt: completedAt, - AccessKeyID: 1, - GroupID: groupID, - Protocol: string(protocol.OpenAICompletions), - ClientModel: "client-model", - UpstreamModel: model, - Status: string(telemetry.RequestStatusSuccess), - StatusCode: 200, - DurationMs: 10, - InputTokens: 1, - OutputTokens: 2, - Cost: 0.25, - UsageState: string(usage.StateComplete), - CostState: string(pricing.CostStatePriced), - Attempts: models.JSON(`[]`), + ID: id, + CompletedAtMS: completedAt.UTC().UnixMilli(), + AccessKeyID: 1, + GroupID: groupID, + Protocol: string(protocol.OpenAICompletions), + ClientModel: model, + UpstreamModel: "upstream-" + model, + Status: string(telemetry.RequestStatusSuccess), + StatusCode: 200, + DurationMs: 10, + UncachedInputTokens: 1, + OutputTokens: 2, + EstimatedCostNanoUSD: 250_000_000, + UsageState: string(usage.StateComplete), + CostState: string(pricing.CostStatePriced), + Attempts: models.JSON(`[]`), } } @@ -286,3 +286,17 @@ func testEvent(id string) telemetry.RequestEvent { }, } } + +func mustMapEvent( + t testing.TB, + redactor *redact.Redactor, + event telemetry.RequestEvent, + prices *pricing.Table, +) models.RequestLog { + t.Helper() + row, err := mapEvent(redactor, event, prices) + if err != nil { + t.Fatalf("mapEvent() error = %v", err) + } + return row +} diff --git a/internal/requestlog/types.go b/internal/requestlog/types.go index 4fc53679..972a7ed1 100644 --- a/internal/requestlog/types.go +++ b/internal/requestlog/types.go @@ -41,13 +41,13 @@ type Attempt struct { } type Cursor struct { - CompletedAt time.Time - RequestID string + CompletedAtMS int64 + RequestID string } type ListQuery struct { - From *time.Time - To *time.Time + FromMS *int64 + ToMS *int64 GroupID *uint ClientModel string AccessKeyID *uint @@ -64,28 +64,28 @@ type AccessKeyRef struct { } type Record struct { - RequestID string - CompletedAt time.Time - AccessKey AccessKeyRef - Protocol protocol.Protocol - ClientModel string - UpstreamModel string - Status telemetry.RequestStatus - StatusCode int - DurationMs int64 - ErrorCode string - ErrorSummary string - AffinityHit bool - Attempts []Attempt - GroupID uint - UsageState usage.State - CostState pricing.CostState - UncachedInputTokens int64 - CacheReadTokens int64 - CacheWrite5MTokens int64 - CacheWrite1HTokens int64 - OutputTokens int64 - EstimatedCostUSD float64 + RequestID string + CompletedAtMS int64 + AccessKey AccessKeyRef + Protocol protocol.Protocol + ClientModel string + UpstreamModel string + Status telemetry.RequestStatus + StatusCode int + DurationMs int64 + ErrorCode string + ErrorSummary string + AffinityHit bool + Attempts []Attempt + GroupID uint + UsageState usage.State + CostState pricing.CostState + UncachedInputTokens int64 + CacheReadTokens int64 + CacheWrite5MTokens int64 + CacheWrite1HTokens int64 + OutputTokens int64 + EstimatedCostNanoUSD int64 } type Page struct { @@ -108,8 +108,8 @@ const ( ) type UsageQuery struct { - From time.Time - To time.Time + FromMS int64 + ToMS int64 Granularity UsageGranularity GroupID *uint Model string @@ -126,15 +126,15 @@ type UsageAggregate struct { CacheWrite5MTokens int64 CacheWrite1HTokens int64 OutputTokens int64 - Cost float64 + EstimatedCostNanoUSD int64 UsageMissingCount int64 PartialCount int64 UnpricedRequestCount int64 } type UsageSeriesPoint struct { - BucketStart time.Time - BucketEnd time.Time + BucketStartMS int64 + BucketEndMS int64 UsageAggregate } diff --git a/internal/requestlog/usage_cost.go b/internal/requestlog/usage_cost.go index 40542226..488f4d3f 100644 --- a/internal/requestlog/usage_cost.go +++ b/internal/requestlog/usage_cost.go @@ -1,36 +1,15 @@ package requestlog import ( - "fmt" - "math" - "gpt-load/internal/pricing" "gpt-load/internal/usage" + "gpt-load/internal/usagecost" ) func ValidateUsageCostState( usageState usage.State, costState pricing.CostState, - cost float64, + estimatedCostNanoUSD int64, ) error { - if cost < 0 || math.IsNaN(cost) || math.IsInf(cost, 0) { - return fmt.Errorf("invalid estimated cost") - } - validCombination := false - switch usageState { - case usage.StateComplete, usage.StatePartial: - validCombination = costState == pricing.CostStatePriced || - costState == pricing.CostStateUnpriced - case usage.StateMissing: - validCombination = costState == pricing.CostStateUnpriced - case usage.StateNotApplicable: - validCombination = costState == pricing.CostStateNotApplicable - } - if !validCombination { - return fmt.Errorf("invalid usage and cost state combination") - } - if costState != pricing.CostStatePriced && cost != 0 { - return fmt.Errorf("unpriced or not-applicable cost must be zero") - } - return nil + return usagecost.Validate(usageState, costState, estimatedCostNanoUSD) } diff --git a/internal/requestlog/usage_query.go b/internal/requestlog/usage_query.go index a89188ac..64578cc6 100644 --- a/internal/requestlog/usage_query.go +++ b/internal/requestlog/usage_query.go @@ -6,11 +6,12 @@ import ( "database/sql/driver" "errors" "fmt" - "math" "time" "gorm.io/gorm" + "gpt-load/internal/platform/epochms" + "gpt-load/internal/pricing" "gpt-load/internal/storage/models" "gpt-load/internal/usage" ) @@ -33,8 +34,6 @@ func (service *Service) QueryUsage(ctx context.Context, input UsageQuery) (Usage return UsageReport{}, err } - input.From = input.From.UTC() - input.To = input.To.UTC() limit := input.Limit if limit <= 0 || limit > maxUsageBreakdownLimit { limit = defaultUsageBreakdownLimit @@ -127,10 +126,11 @@ func discardUsageReadConnection(connection *sql.Conn) error { } func validateUsageQuery(input UsageQuery) error { - if input.From.IsZero() || input.To.IsZero() || !input.From.Before(input.To) { + if input.FromMS < 0 || input.ToMS <= input.FromMS { return fmt.Errorf("query usage: invalid time range") } - if input.To.Sub(input.From) > maxUsageSeriesHours*time.Hour { + if input.ToMS-input.FromMS > + int64(maxUsageSeriesHours)*epochms.MillisecondsPerHour { return fmt.Errorf("query usage: time range exceeds %d hours", maxUsageSeriesHours) } switch input.Granularity { @@ -151,7 +151,7 @@ func validateUsageQuery(input UsageQuery) error { func usageStatScope(db *gorm.DB, input UsageQuery) *gorm.DB { scope := db.Session(&gorm.Session{NewDB: true}).Model(&models.UsageStat{}). - Where("hour_bucket >= ? AND hour_bucket < ?", input.From, input.To) + Where("bucket_start_ms >= ? AND bucket_start_ms < ?", input.FromMS, input.ToMS) if input.GroupID != nil { scope = scope.Where("group_id = ?", *input.GroupID) } @@ -164,6 +164,10 @@ func usageStatScope(db *gorm.DB, input UsageQuery) *gorm.DB { func validateUsageStatIntegrity(scope *gorm.DB) error { var integrity usageStatIntegrity if err := scope.Select(` + COALESCE(MAX(CASE + WHEN bucket_start_ms < 0 + OR bucket_start_ms % 3600000 != 0 + THEN 1 ELSE 0 END), 0) AS invalid_bucket, COALESCE(MAX(CASE WHEN request_count < 0 OR success_count < 0 OR failure_count < 0 OR usage_missing_count < 0 OR partial_count < 0 OR unpriced_request_count < 0 @@ -174,16 +178,17 @@ func validateUsageStatIntegrity(scope *gorm.DB) error { END = 1 THEN 1 ELSE 0 END), 0) AS invalid_count, COALESCE(MAX(CASE - WHEN input_tokens < 0 OR output_tokens < 0 OR cache_read_tokens < 0 + WHEN uncached_input_tokens < 0 OR output_tokens < 0 OR cache_read_tokens < 0 OR cache_write_5m_tokens < 0 OR cache_write_1h_tokens < 0 THEN 1 ELSE 0 END), 0) AS invalid_token, COALESCE(MAX(CASE - WHEN cost < 0 OR cost != cost OR cost > ? + WHEN estimated_cost_nano_usd < 0 THEN 1 ELSE 0 END), 0) AS invalid_cost - `, math.MaxFloat64).Find(&integrity).Error; err != nil { + `).Find(&integrity).Error; err != nil { return fmt.Errorf("check usage stat integrity: %w", err) } - if integrity.InvalidCount != 0 || integrity.InvalidToken != 0 || integrity.InvalidCost != 0 { + if integrity.InvalidBucket != 0 || integrity.InvalidCount != 0 || + integrity.InvalidToken != 0 || integrity.InvalidCost != 0 { return fmt.Errorf("check usage stat integrity: corrupt row") } return nil @@ -202,9 +207,9 @@ func queryUsageSummary(scope *gorm.DB) (UsageAggregate, error) { func queryUsageSeries(scope *gorm.DB, granularity UsageGranularity) ([]UsageSeriesPoint, error) { var source []usageHourPoint - if err := scope.Select("hour_bucket, " + usageAggregateSelect). - Group("hour_bucket"). - Order("hour_bucket ASC"). + if err := scope.Select("bucket_start_ms, " + usageAggregateSelect). + Group("bucket_start_ms"). + Order("bucket_start_ms ASC"). Find(&source).Error; err != nil { return nil, fmt.Errorf("query usage source series: %w", err) } @@ -217,10 +222,9 @@ func queryUsageSeries(scope *gorm.DB, granularity UsageGranularity) ([]UsageSeri if granularity == UsageGranularityHour { series := make([]UsageSeriesPoint, 0, len(source)) for _, point := range source { - start := point.HourBucket.UTC() series = append(series, UsageSeriesPoint{ - BucketStart: start, - BucketEnd: start.Add(time.Hour), + BucketStartMS: point.BucketStartMS, + BucketEndMS: point.BucketStartMS + epochms.MillisecondsPerHour, UsageAggregate: point.UsageAggregate, }) } @@ -252,10 +256,10 @@ func queryUsageBreakdown( case UsageBreakdownOrderRequests: query = query. Order("SUM(request_count) DESC"). - Order("SUM(cost) DESC") + Order("SUM(estimated_cost_nano_usd) DESC") case UsageBreakdownOrderCost: query = query. - Order("SUM(cost) DESC"). + Order("SUM(estimated_cost_nano_usd) DESC"). Order("SUM(request_count) DESC") default: return nil, false, fmt.Errorf( @@ -290,21 +294,28 @@ func queryUsageBreakdown( } func mergeUsageHoursToDays(source []usageHourPoint) ([]UsageSeriesPoint, error) { - // queryUsageSeries orders source by hour_bucket ASC; adjacent-only daily folding depends on it. + // queryUsageSeries orders source by bucket_start_ms ASC; adjacent-only + // daily folding depends on it. series := make([]UsageSeriesPoint, 0, len(source)) for _, point := range source { - dayStart := point.HourBucket.UTC().Truncate(24 * time.Hour) - if len(series) == 0 || !series[len(series)-1].BucketStart.Equal(dayStart) { + dayStartMS, err := epochms.AlignDown( + point.BucketStartMS, + epochms.MillisecondsPerDay, + ) + if err != nil { + return nil, fmt.Errorf("align usage day: %w", err) + } + if len(series) == 0 || series[len(series)-1].BucketStartMS != dayStartMS { series = append(series, UsageSeriesPoint{ - BucketStart: dayStart, - BucketEnd: dayStart.AddDate(0, 0, 1), + BucketStartMS: dayStartMS, + BucketEndMS: dayStartMS + epochms.MillisecondsPerDay, UsageAggregate: point.UsageAggregate, }) continue } merged, err := addUsageAggregates(series[len(series)-1].UsageAggregate, point.UsageAggregate) if err != nil { - return nil, fmt.Errorf("merge usage day %s: %w", dayStart.Format(time.RFC3339), err) + return nil, fmt.Errorf("merge usage day %d: %w", dayStartMS, err) } series[len(series)-1].UsageAggregate = merged } @@ -337,7 +348,14 @@ func addUsageAggregates(left, right UsageAggregate) (UsageAggregate, error) { } *field.target = value } - result.Cost = left.Cost + right.Cost + cost, ok := pricing.CheckedAddNanoUSD( + pricing.NanoUSD(left.EstimatedCostNanoUSD), + pricing.NanoUSD(right.EstimatedCostNanoUSD), + ) + if !ok { + return UsageAggregate{}, fmt.Errorf("estimated cost nano USD overflow or negative") + } + result.EstimatedCostNanoUSD = int64(cost) if err := validateUsageAggregate(result); err != nil { return UsageAggregate{}, err } @@ -384,7 +402,7 @@ func validateUsageAggregate(aggregate UsageAggregate) error { return fmt.Errorf("total tokens overflow") } } - if aggregate.Cost < 0 || math.IsNaN(aggregate.Cost) || math.IsInf(aggregate.Cost, 0) { + if aggregate.EstimatedCostNanoUSD < 0 { return fmt.Errorf("invalid cost") } return nil @@ -395,25 +413,26 @@ const usageAggregateSelect = "" + "COALESCE(SUM(request_count), 0) AS request_count, " + "COALESCE(SUM(success_count), 0) AS success_count, " + "COALESCE(SUM(failure_count), 0) AS failure_count, " + - "COALESCE(SUM(input_tokens), 0) AS uncached_input_tokens, " + + "COALESCE(SUM(uncached_input_tokens), 0) AS uncached_input_tokens, " + "COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, " + "COALESCE(SUM(cache_write_5m_tokens), 0) AS cache_write5_m_tokens, " + "COALESCE(SUM(cache_write_1h_tokens), 0) AS cache_write1_h_tokens, " + "COALESCE(SUM(output_tokens), 0) AS output_tokens, " + - "COALESCE(SUM(cost), 0) AS cost, " + + "COALESCE(SUM(estimated_cost_nano_usd), 0) AS estimated_cost_nano_usd, " + "COALESCE(SUM(usage_missing_count), 0) AS usage_missing_count, " + "COALESCE(SUM(partial_count), 0) AS partial_count, " + "COALESCE(SUM(unpriced_request_count), 0) AS unpriced_request_count" type usageHourPoint struct { - HourBucket time.Time + BucketStartMS int64 UsageAggregate } type usageStatIntegrity struct { - InvalidCount int64 - InvalidToken int64 - InvalidCost int64 + InvalidBucket int64 + InvalidCount int64 + InvalidToken int64 + InvalidCost int64 } type usageBreakdownRow struct { diff --git a/internal/requestlog/usage_query_test.go b/internal/requestlog/usage_query_test.go index efcaa085..c627a0f8 100644 --- a/internal/requestlog/usage_query_test.go +++ b/internal/requestlog/usage_query_test.go @@ -35,8 +35,8 @@ func TestQueryUsageHourAggregatesFiltersAndLeavesSparseBucketsAbsent(t *testing. groupID := uint(7) report, err := service.QueryUsage(context.Background(), UsageQuery{ - From: start, - To: start.Add(24 * time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(24 * time.Hour).UnixMilli(), Granularity: UsageGranularityHour, GroupID: &groupID, Model: "target", @@ -50,7 +50,8 @@ func TestQueryUsageHourAggregatesFiltersAndLeavesSparseBucketsAbsent(t *testing. report.Summary.FailureCount != 0 || report.Summary.UncachedInputTokens != 2920 || report.Summary.CacheReadTokens != 292 || report.Summary.CacheWrite5MTokens != 20 || report.Summary.CacheWrite1HTokens != 49 || report.Summary.OutputTokens != 584 || - !sameUsageCost(report.Summary.Cost, 29.2) || report.Summary.UsageMissingCount != 12 || + report.Summary.EstimatedCostNanoUSD != 29_200_000_000 || + report.Summary.UsageMissingCount != 12 || report.Summary.PartialCount != 1 || report.Summary.UnpricedRequestCount != 12 { t.Fatalf("summary = %#v", report.Summary) } @@ -58,11 +59,14 @@ func TestQueryUsageHourAggregatesFiltersAndLeavesSparseBucketsAbsent(t *testing. t.Fatalf("series length = %d, want 23 actual hour buckets", len(report.Series)) } for _, point := range report.Series { - if point.BucketStart.Equal(start.Add(7 * time.Hour)) { + if point.BucketStartMS == start.Add(7*time.Hour).UnixMilli() { t.Fatalf("series synthesized missing hour: %#v", point) } - if point.BucketEnd.Sub(point.BucketStart) != time.Hour { - t.Fatalf("hour point duration = %s, want 1h", point.BucketEnd.Sub(point.BucketStart)) + if point.BucketEndMS-point.BucketStartMS != 3_600_000 { + t.Fatalf( + "hour point duration = %dms, want 3600000", + point.BucketEndMS-point.BucketStartMS, + ) } } if len(report.Breakdown) != 1 || report.Breakdown[0].GroupID != 7 || @@ -87,8 +91,8 @@ func TestQueryUsageMergesHourlyRowsIntoThirtyUTCDays(t *testing.T) { createUsageStats(t, db, rows...) report, err := service.QueryUsage(context.Background(), UsageQuery{ - From: start, - To: start.AddDate(0, 0, 30), + FromMS: start.UnixMilli(), + ToMS: start.AddDate(0, 0, 30).UnixMilli(), Granularity: UsageGranularityDay, Limit: 100, BreakdownOrder: UsageBreakdownOrderRequests, @@ -101,10 +105,12 @@ func TestQueryUsageMergesHourlyRowsIntoThirtyUTCDays(t *testing.T) { } for day, point := range report.Series { wantStart := start.AddDate(0, 0, day) - if !point.BucketStart.Equal(wantStart) || !point.BucketEnd.Equal(wantStart.AddDate(0, 0, 1)) || + if point.BucketStartMS != wantStart.UnixMilli() || + point.BucketEndMS != wantStart.AddDate(0, 0, 1).UnixMilli() || point.RequestCount != 3 || point.SuccessCount != 3 || point.FailureCount != 0 || point.UncachedInputTokens != 30 || point.CacheReadTokens != 3 || point.OutputTokens != 6 || - !sameUsageCost(point.Cost, 0.3) || point.UsageMissingCount != 1 || point.UnpricedRequestCount != 1 { + point.EstimatedCostNanoUSD != 300_000_000 || + point.UsageMissingCount != 1 || point.UnpricedRequestCount != 1 { t.Fatalf("day %d point = %#v", day, point) } } @@ -121,8 +127,8 @@ func TestQueryUsageLimitsBreakdownToStableTopHundred(t *testing.T) { createUsageStats(t, db, rows...) report, err := service.QueryUsage(context.Background(), UsageQuery{ - From: start, - To: start.Add(time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour, Limit: 100, BreakdownOrder: UsageBreakdownOrderRequests, @@ -148,11 +154,11 @@ func TestQueryUsageOrdersBreakdownByRequestsAndCost(t *testing.T) { service := newRequestLogTestService(db) start := time.Date(2026, time.July, 2, 1, 0, 0, 0, time.UTC) requestHeavy := usageStat(start, 1, "request-heavy", 20) - requestHeavy.Cost = 1 + requestHeavy.EstimatedCostNanoUSD = 1_000_000_000 expensiveLowRequests := usageStat(start, 2, "expensive-low", 5) - expensiveLowRequests.Cost = 10 + expensiveLowRequests.EstimatedCostNanoUSD = 10_000_000_000 expensiveHighRequests := usageStat(start, 3, "expensive-high", 7) - expensiveHighRequests.Cost = 10 + expensiveHighRequests.EstimatedCostNanoUSD = 10_000_000_000 createUsageStats(t, db, requestHeavy, expensiveLowRequests, expensiveHighRequests) tests := []struct { @@ -174,8 +180,8 @@ func TestQueryUsageOrdersBreakdownByRequestsAndCost(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { report, err := service.QueryUsage(context.Background(), UsageQuery{ - From: start, - To: start.Add(time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour, Limit: 100, BreakdownOrder: test.order, @@ -236,8 +242,8 @@ func TestQueryUsageCountsDistinctBreakdownGroupsWithinFilters(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { report, err := service.QueryUsage(context.Background(), UsageQuery{ - From: start, - To: start.Add(time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour, GroupID: test.groupID, Model: test.model, @@ -264,8 +270,8 @@ func TestQueryUsageRejectsInvalidBreakdownOrder(t *testing.T) { service := newRequestLogTestService(db) start := time.Date(2026, time.July, 2, 3, 0, 0, 0, time.UTC) _, err := service.QueryUsage(context.Background(), UsageQuery{ - From: start, - To: start.Add(time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour, Limit: 100, BreakdownOrder: UsageBreakdownOrder("unknown"), @@ -291,11 +297,11 @@ func TestQueryUsageUsesOneReadSnapshot(t *testing.T) { } inserted = true if err := writerDB.Create(&models.UsageStat{ - HourBucket: start, - GroupID: 2, - Model: "after-summary", - RequestCount: 1, - SuccessCount: 1, + BucketStartMS: start.UnixMilli(), + GroupID: 2, + Model: "after-summary", + RequestCount: 1, + SuccessCount: 1, }).Error; err != nil { t.Errorf("insert concurrent UsageStat: %v", err) } @@ -309,8 +315,8 @@ func TestQueryUsageUsesOneReadSnapshot(t *testing.T) { }) report, err := service.QueryUsage(context.Background(), UsageQuery{ - From: start, - To: start.Add(time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour, Limit: 100, BreakdownOrder: UsageBreakdownOrderRequests, @@ -351,8 +357,8 @@ func TestQueryUsageCancelsAfterBeginWithoutPoisoningDatabaseConnection(t *testin }() _, err := service.QueryUsage(ctx, UsageQuery{ - From: start, - To: start.Add(time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour, Limit: 100, BreakdownOrder: UsageBreakdownOrderRequests, @@ -366,11 +372,11 @@ func TestQueryUsageCancelsAfterBeginWithoutPoisoningDatabaseConnection(t *testin t.Fatalf("query after cancellation = %d/%v, want 1/nil", count, err) } if err := db.Create(&models.UsageStat{ - HourBucket: start, - GroupID: 2, - Model: "after-cancellation", - RequestCount: 1, - SuccessCount: 1, + BucketStartMS: start.UnixMilli(), + GroupID: 2, + Model: "after-cancellation", + RequestCount: 1, + SuccessCount: 1, }).Error; err != nil { t.Fatalf("write after cancellation: %v", err) } @@ -387,19 +393,18 @@ func TestQueryUsageRejectsCorruptRowsOutsideTopBreakdown(t *testing.T) { compensating := usageStat(start, 101, "compensating", 2) compensating.SuccessCount = 2 compensating.FailureCount = 0 - compensating.InputTokens = 1 - compensating.Cost = 1 + compensating.UncachedInputTokens = 1 + compensating.EstimatedCostNanoUSD = 1_000_000_000 corrupt := usageStat(start, 102, "corrupt", 1) corrupt.SuccessCount = 1 corrupt.FailureCount = 0 - corrupt.InputTokens = -1 - corrupt.Cost = -1 + corrupt.UncachedInputTokens = -1 rows = append(rows, compensating, corrupt) - createUsageStats(t, db, rows...) + createCorruptUsageStats(t, db, rows...) input := UsageQuery{ - From: start, - To: start.Add(time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour, Limit: 100, BreakdownOrder: UsageBreakdownOrderRequests, @@ -459,7 +464,7 @@ func TestQueryUsageRejectsCorruptAggregates(t *testing.T) { name: "negative input tokens", rows: []models.UsageStat{func() models.UsageStat { row := usageStat(start, 1, "negative-input", 1) - row.InputTokens = -1 + row.UncachedInputTokens = -1 return row }()}, }, @@ -484,7 +489,7 @@ func TestQueryUsageRejectsCorruptAggregates(t *testing.T) { name: "checked total token overflow", rows: []models.UsageStat{func() models.UsageStat { row := usageStat(start, 1, "token-overflow", 1) - row.InputTokens = math.MaxInt64 + row.UncachedInputTokens = math.MaxInt64 row.OutputTokens = 1 return row }()}, @@ -492,27 +497,31 @@ func TestQueryUsageRejectsCorruptAggregates(t *testing.T) { { name: "checked count overflow", rows: []models.UsageStat{ - usageStat(start, 1, "overflow-a", math.MaxInt64), - usageStat(start, 2, "overflow-b", 1), + { + BucketStartMS: start.UnixMilli(), + GroupID: 1, + Model: "overflow-a", + RequestCount: math.MaxInt64, + SuccessCount: math.MaxInt64, + }, + { + BucketStartMS: start.UnixMilli(), + GroupID: 2, + Model: "overflow-b", + RequestCount: 1, + SuccessCount: 1, + }, }, }, { - name: "negative cost", - rows: []models.UsageStat{func() models.UsageStat { - row := usageStat(start, 1, "negative-cost", 1) - row.Cost = -0.1 - return row - }()}, - }, - { - name: "non-finite summed cost", + name: "checked cost overflow", rows: []models.UsageStat{func() models.UsageStat { row := usageStat(start, 1, "huge-cost-a", 1) - row.Cost = math.MaxFloat64 + row.EstimatedCostNanoUSD = math.MaxInt64 return row }(), func() models.UsageStat { row := usageStat(start, 2, "huge-cost-b", 1) - row.Cost = math.MaxFloat64 + row.EstimatedCostNanoUSD = math.MaxInt64 return row }()}, }, @@ -521,10 +530,10 @@ func TestQueryUsageRejectsCorruptAggregates(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { db := openRequestLogQueryDB(t) - createUsageStats(t, db, test.rows...) + createCorruptUsageStats(t, db, test.rows...) _, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{ - From: start, - To: start.Add(time.Hour), + FromMS: start.UnixMilli(), + ToMS: start.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour, Limit: 100, BreakdownOrder: UsageBreakdownOrderRequests, @@ -538,17 +547,17 @@ func TestQueryUsageRejectsCorruptAggregates(t *testing.T) { func usageStat(hour time.Time, groupID uint, model string, requestCount int64) models.UsageStat { return models.UsageStat{ - HourBucket: hour, + BucketStartMS: hour.UTC().UnixMilli(), GroupID: groupID, Model: model, RequestCount: requestCount, SuccessCount: requestCount, - InputTokens: requestCount * 10, + UncachedInputTokens: requestCount * 10, OutputTokens: requestCount * 2, CacheReadTokens: requestCount, CacheWrite5MTokens: requestCount / 10, CacheWrite1HTokens: requestCount / 5, - Cost: float64(requestCount) / 10, + EstimatedCostNanoUSD: requestCount * 100_000_000, UsageMissingCount: requestCount % 2, PartialCount: 0, UnpricedRequestCount: requestCount % 2, @@ -564,8 +573,19 @@ func createUsageStats(t *testing.T, db *gorm.DB, rows ...models.UsageStat) { } } -func sameUsageCost(got, want float64) bool { - return math.Abs(got-want) < 1e-12 +func createCorruptUsageStats(t *testing.T, db *gorm.DB, rows ...models.UsageStat) { + t.Helper() + if err := db.Exec(`PRAGMA ignore_check_constraints = ON`).Error; err != nil { + t.Fatalf("disable SQLite CHECK constraints: %v", err) + } + for _, row := range rows { + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create corrupt UsageStat %#v: %v", row, err) + } + } + if err := db.Exec(`PRAGMA ignore_check_constraints = OFF`).Error; err != nil { + t.Fatalf("restore SQLite CHECK constraints: %v", err) + } } func openUsageQueryWriterDB(t *testing.T, dsn string) (*gorm.DB, func()) { diff --git a/internal/requestlog/worker.go b/internal/requestlog/worker.go index 8ed00b02..9b0ad317 100644 --- a/internal/requestlog/worker.go +++ b/internal/requestlog/worker.go @@ -6,12 +6,12 @@ import ( "database/sql/driver" "errors" "fmt" - "math" "sort" "time" "gorm.io/gorm" + "gpt-load/internal/platform/epochms" "gpt-load/internal/pricing" "gpt-load/internal/storage/models" "gpt-load/internal/telemetry" @@ -22,32 +22,33 @@ const requestLogTransactionCleanupTimeout = time.Second const absoluteUsageStatUpsert = ` INSERT INTO usage_stats ( - hour_bucket, + bucket_start_ms, + access_key_id, group_id, model, request_count, success_count, failure_count, - input_tokens, + uncached_input_tokens, output_tokens, cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens, - cost, + estimated_cost_nano_usd, usage_missing_count, partial_count, unpriced_request_count -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(hour_bucket, group_id, model) DO UPDATE SET +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(bucket_start_ms, access_key_id, group_id, model) DO UPDATE SET request_count = excluded.request_count, success_count = excluded.success_count, failure_count = excluded.failure_count, - input_tokens = excluded.input_tokens, + uncached_input_tokens = excluded.uncached_input_tokens, output_tokens = excluded.output_tokens, cache_read_tokens = excluded.cache_read_tokens, cache_write_5m_tokens = excluded.cache_write_5m_tokens, cache_write_1h_tokens = excluded.cache_write_1h_tokens, - cost = excluded.cost, + estimated_cost_nano_usd = excluded.estimated_cost_nano_usd, usage_missing_count = excluded.usage_missing_count, partial_count = excluded.partial_count, unpriced_request_count = excluded.unpriced_request_count @@ -112,21 +113,22 @@ func (writer *gormBatchWriter) WriteBatch(ctx context.Context, rows []models.Req } type usageStatKey struct { - HourBucket time.Time - GroupID uint - Model string + BucketStartMS int64 + AccessKeyID uint + GroupID uint + Model string } type usageStatDelta struct { RequestCount int64 SuccessCount int64 FailureCount int64 - InputTokens int64 + UncachedInputTokens int64 OutputTokens int64 CacheReadTokens int64 CacheWrite5MTokens int64 CacheWrite1HTokens int64 - Cost float64 + EstimatedCostNanoUSD int64 UsageMissingCount int64 PartialCount int64 UnpricedRequestCount int64 @@ -187,7 +189,8 @@ func writeRequestLogBatch(tx *gorm.DB, rows []models.RequestLog) error { absolute := make([]models.UsageStat, 0, len(keys)) for _, key := range keys { stat := existingStats[key] - stat.HourBucket = key.HourBucket + stat.BucketStartMS = key.BucketStartMS + stat.AccessKeyID = key.AccessKeyID stat.GroupID = key.GroupID stat.Model = key.Model total, err := checkedUsageStatTotal(stat, deltas[key]) @@ -200,18 +203,19 @@ func writeRequestLogBatch(tx *gorm.DB, rows []models.RequestLog) error { for _, stat := range absolute { if err := tx.Exec( absoluteUsageStatUpsert, - stat.HourBucket, + stat.BucketStartMS, + stat.AccessKeyID, stat.GroupID, stat.Model, stat.RequestCount, stat.SuccessCount, stat.FailureCount, - stat.InputTokens, + stat.UncachedInputTokens, stat.OutputTokens, stat.CacheReadTokens, stat.CacheWrite5MTokens, stat.CacheWrite1HTokens, - stat.Cost, + stat.EstimatedCostNanoUSD, stat.UsageMissingCount, stat.PartialCount, stat.UnpricedRequestCount, @@ -225,13 +229,18 @@ func writeRequestLogBatch(tx *gorm.DB, rows []models.RequestLog) error { func buildUsageStatDeltas(rows []models.RequestLog) (map[usageStatKey]usageStatDelta, error) { deltas := make(map[usageStatKey]usageStatDelta) for _, row := range rows { - if row.GroupID == 0 || row.UpstreamModel == "" { - continue + bucketStartMS, err := epochms.AlignDown( + row.CompletedAtMS, + epochms.MillisecondsPerHour, + ) + if err != nil { + return nil, fmt.Errorf("aggregate request log %q completion time: %w", row.ID, err) } key := usageStatKey{ - HourBucket: row.CreatedAt.UTC().Truncate(time.Hour), - GroupID: row.GroupID, - Model: row.UpstreamModel, + BucketStartMS: bucketStartMS, + AccessKeyID: row.AccessKeyID, + GroupID: row.GroupID, + Model: row.ClientModel, } delta := deltas[key] if err := delta.addRow(row); err != nil { @@ -246,12 +255,19 @@ func (delta *usageStatDelta) addRow(row models.RequestLog) error { if err := checkedInt64Add(&delta.RequestCount, 1, "request_count"); err != nil { return err } - if row.Status == string(telemetry.RequestStatusSuccess) { + switch row.Status { + case string(telemetry.RequestStatusSuccess): if err := checkedInt64Add(&delta.SuccessCount, 1, "success_count"); err != nil { return err } - } else if err := checkedInt64Add(&delta.FailureCount, 1, "failure_count"); err != nil { - return err + case string(telemetry.RequestStatusError), + string(telemetry.RequestStatusIncomplete), + string(telemetry.RequestStatusCanceled): + if err := checkedInt64Add(&delta.FailureCount, 1, "failure_count"); err != nil { + return err + } + default: + return fmt.Errorf("aggregate request log %q: invalid status %q", row.ID, row.Status) } switch row.UsageState { @@ -263,15 +279,20 @@ func (delta *usageStatDelta) addRow(row models.RequestLog) error { if err := checkedInt64Add(&delta.PartialCount, 1, "partial_count"); err != nil { return err } + case string(usage.StateComplete), string(usage.StateNotApplicable): + default: + return fmt.Errorf("aggregate request log %q: invalid usage state %q", row.ID, row.UsageState) } - if row.CostState == string(pricing.CostStateUnpriced) { + if (row.UsageState == string(usage.StateComplete) || + row.UsageState == string(usage.StatePartial)) && + row.CostState == string(pricing.CostStateUnpriced) { if err := checkedInt64Add(&delta.UnpricedRequestCount, 1, "unpriced_request_count"); err != nil { return err } } - if row.UsageState != string(usage.StateComplete) || - row.CostState != string(pricing.CostStatePriced) { + if row.UsageState != string(usage.StateComplete) && + row.UsageState != string(usage.StatePartial) { return nil } for _, field := range []struct { @@ -279,7 +300,7 @@ func (delta *usageStatDelta) addRow(row models.RequestLog) error { target *int64 value int64 }{ - {name: "input_tokens", target: &delta.InputTokens, value: row.InputTokens}, + {name: "uncached_input_tokens", target: &delta.UncachedInputTokens, value: row.UncachedInputTokens}, {name: "output_tokens", target: &delta.OutputTokens, value: row.OutputTokens}, {name: "cache_read_tokens", target: &delta.CacheReadTokens, value: row.CacheReadTokens}, {name: "cache_write_5m_tokens", target: &delta.CacheWrite5MTokens, value: row.CacheWrite5MTokens}, @@ -289,11 +310,17 @@ func (delta *usageStatDelta) addRow(row models.RequestLog) error { return err } } - cost, ok := checkedCostAdd(delta.Cost, row.Cost) - if !ok { - return fmt.Errorf("aggregate usage stat cost: checked addition failed") + if row.CostState != string(pricing.CostStatePriced) { + return nil } - delta.Cost = cost + cost, ok := pricing.CheckedAddNanoUSD( + pricing.NanoUSD(delta.EstimatedCostNanoUSD), + pricing.NanoUSD(row.EstimatedCostNanoUSD), + ) + if !ok { + return fmt.Errorf("aggregate usage stat estimated_cost_nano_usd: checked addition failed") + } + delta.EstimatedCostNanoUSD = int64(cost) return nil } @@ -306,27 +333,17 @@ func checkedInt64Add(target *int64, value int64, field string) error { return nil } -func checkedCostAdd(left, right float64) (float64, bool) { - if left < 0 || right < 0 || - math.IsNaN(left) || math.IsNaN(right) || - math.IsInf(left, 0) || math.IsInf(right, 0) { - return 0, false - } - total := left + right - if total < 0 || math.IsNaN(total) || math.IsInf(total, 0) { - return 0, false - } - return total, true -} - func sortedUsageStatKeys(deltas map[usageStatKey]usageStatDelta) []usageStatKey { keys := make([]usageStatKey, 0, len(deltas)) for key := range deltas { keys = append(keys, key) } sort.Slice(keys, func(left, right int) bool { - if !keys[left].HourBucket.Equal(keys[right].HourBucket) { - return keys[left].HourBucket.Before(keys[right].HourBucket) + if keys[left].BucketStartMS != keys[right].BucketStartMS { + return keys[left].BucketStartMS < keys[right].BucketStartMS + } + if keys[left].AccessKeyID != keys[right].AccessKeyID { + return keys[left].AccessKeyID < keys[right].AccessKeyID } if keys[left].GroupID != keys[right].GroupID { return keys[left].GroupID < keys[right].GroupID @@ -344,16 +361,18 @@ func queryExistingUsageStats( for index, key := range keys { if index == 0 { query = query.Where( - "hour_bucket = ? AND group_id = ? AND model = ?", - key.HourBucket, + "bucket_start_ms = ? AND access_key_id = ? AND group_id = ? AND model = ?", + key.BucketStartMS, + key.AccessKeyID, key.GroupID, key.Model, ) continue } query = query.Or( - "hour_bucket = ? AND group_id = ? AND model = ?", - key.HourBucket, + "bucket_start_ms = ? AND access_key_id = ? AND group_id = ? AND model = ?", + key.BucketStartMS, + key.AccessKeyID, key.GroupID, key.Model, ) @@ -365,9 +384,10 @@ func queryExistingUsageStats( existing := make(map[usageStatKey]models.UsageStat, len(rows)) for _, row := range rows { key := usageStatKey{ - HourBucket: row.HourBucket.UTC(), - GroupID: row.GroupID, - Model: row.Model, + BucketStartMS: row.BucketStartMS, + AccessKeyID: row.AccessKeyID, + GroupID: row.GroupID, + Model: row.Model, } existing[key] = row } @@ -388,7 +408,7 @@ func checkedUsageStatTotal( {name: "request_count", left: existing.RequestCount, right: delta.RequestCount, set: func(value int64) { total.RequestCount = value }}, {name: "success_count", left: existing.SuccessCount, right: delta.SuccessCount, set: func(value int64) { total.SuccessCount = value }}, {name: "failure_count", left: existing.FailureCount, right: delta.FailureCount, set: func(value int64) { total.FailureCount = value }}, - {name: "input_tokens", left: existing.InputTokens, right: delta.InputTokens, set: func(value int64) { total.InputTokens = value }}, + {name: "uncached_input_tokens", left: existing.UncachedInputTokens, right: delta.UncachedInputTokens, set: func(value int64) { total.UncachedInputTokens = value }}, {name: "output_tokens", left: existing.OutputTokens, right: delta.OutputTokens, set: func(value int64) { total.OutputTokens = value }}, {name: "cache_read_tokens", left: existing.CacheReadTokens, right: delta.CacheReadTokens, set: func(value int64) { total.CacheReadTokens = value }}, {name: "cache_write_5m_tokens", left: existing.CacheWrite5MTokens, right: delta.CacheWrite5MTokens, set: func(value int64) { total.CacheWrite5MTokens = value }}, @@ -406,13 +426,16 @@ func checkedUsageStatTotal( } field.set(value) } - cost, ok := checkedCostAdd(existing.Cost, delta.Cost) + cost, ok := pricing.CheckedAddNanoUSD( + pricing.NanoUSD(existing.EstimatedCostNanoUSD), + pricing.NanoUSD(delta.EstimatedCostNanoUSD), + ) if !ok { return models.UsageStat{}, fmt.Errorf( - "calculate absolute usage stat cost: checked addition failed", + "calculate absolute usage stat estimated_cost_nano_usd: checked addition failed", ) } - total.Cost = cost + total.EstimatedCostNanoUSD = int64(cost) return total, nil } @@ -524,21 +547,37 @@ func (service *Service) drain(ctx context.Context, batch []queuedEvent) { } func (service *Service) writeBatch(ctx context.Context, events []queuedEvent) { - rows := make([]models.RequestLog, len(events)) - for index, event := range events { - rows[index] = mapEvent(service.redactor, event.Event, event.Prices) + rows := make([]models.RequestLog, 0, len(events)) + projectionFailures := 0 + for _, event := range events { + row, err := mapEvent(service.redactor, event.Event, event.Prices) + if err != nil { + projectionFailures++ + continue + } + rows = append(rows, row) + } + if projectionFailures > 0 { + service.recordPersistFailure("projection_failure", projectionFailures) + } + if len(rows) == 0 { + return } if err := service.writer.WriteBatch(ctx, rows); err != nil { - service.writeFailureTotal.Add(1) - service.droppedPersistFailedTotal.Add(uint64(len(events))) - service.statsMu.Lock() - service.lastWriteFailureAt = service.now().UTC() - service.statsMu.Unlock() - service.warn("write_failure", len(events)) + service.recordPersistFailure("write_failure", len(rows)) return } - service.persistedTotal.Add(uint64(len(events))) + service.persistedTotal.Add(uint64(len(rows))) +} + +func (service *Service) recordPersistFailure(reason string, count int) { + service.writeFailureTotal.Add(1) + service.droppedPersistFailedTotal.Add(uint64(count)) + service.statsMu.Lock() + service.lastWriteFailureAt = service.now().UTC() + service.statsMu.Unlock() + service.warn(reason, count) } func (service *Service) dropUnattempted(batch []queuedEvent) { diff --git a/internal/requestlog/worker_test.go b/internal/requestlog/worker_test.go index 025e0348..800238c3 100644 --- a/internal/requestlog/worker_test.go +++ b/internal/requestlog/worker_test.go @@ -116,6 +116,45 @@ func TestServiceDropsFailedBatchAndContinues(t *testing.T) { } } +func TestServiceDropsInvalidProjectionWithoutDiscardingValidRows(t *testing.T) { + timers := newManualTimerFactory() + writes := make(chan []models.RequestLog, 1) + service := newService( + batchWriterFunc(func(_ context.Context, rows []models.RequestLog) error { + writes <- append([]models.RequestLog(nil), rows...) + return nil + }), + redact.New(), + timers.New, + newStaticPriceTableProvider(), + ) + if err := service.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + invalid := testEvent("invalid-completion") + invalid.CompletedAt = time.Unix(-1, 0) + service.Emit(invalid) + service.Emit(testEvent("valid-completion")) + receiveValue(t, timers.created).Fire() + + rows := receiveValue(t, writes) + if len(rows) != 1 || rows[0].ID != "valid-completion" { + t.Fatalf("persisted rows = %+v, want only valid-completion", rows) + } + if err := service.Stop(context.Background()); err != nil { + t.Fatalf("Stop() error = %v", err) + } + stats := service.Stats() + if stats.WriteFailureTotal != 1 || + stats.DroppedPersistFailedTotal != 1 || + stats.PersistedTotal != 1 || + stats.DroppedTotal != 1 || + stats.LastWriteFailureAt.IsZero() { + t.Fatalf("stats after projection failure = %+v", stats) + } +} + func TestServiceStopDrainsAndIsIdempotent(t *testing.T) { writes := make(chan []models.RequestLog, 2) service := newService( @@ -324,43 +363,44 @@ func TestServiceConcurrentStopHonorsOwnDeadlineWithoutDuplicateDrain(t *testing. } } -func TestBatchWriterInsertsOnlyNewRequestLogsAndAggregatesUsage(t *testing.T) { +func TestWriteBatchInsertsOnlyNewRequestLogsAndAggregatesUsage(t *testing.T) { db := openRequestLogQueryDB(t) hour := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) existing := aggregationRow(aggregationRequestID(1), hour, 7, "aggregate-model") - existing.InputTokens = 99 + existing.UncachedInputTokens = 99 if err := db.Create(&existing).Error; err != nil { t.Fatalf("create existing RequestLog: %v", err) } baseline := models.UsageStat{ - HourBucket: hour, - GroupID: 7, - Model: "aggregate-model", - RequestCount: 10, - SuccessCount: 10, - InputTokens: 100, - OutputTokens: 200, - Cost: 1, + BucketStartMS: 1_784_894_400_000, + AccessKeyID: 1, + GroupID: 7, + Model: "aggregate-model", + RequestCount: 10, + SuccessCount: 10, + UncachedInputTokens: 100, + OutputTokens: 200, + EstimatedCostNanoUSD: 1_000_000_000, } if err := db.Create(&baseline).Error; err != nil { t.Fatalf("create baseline UsageStat: %v", err) } first := aggregationRow(aggregationRequestID(2), hour.Add(time.Minute), 7, "aggregate-model") - first.InputTokens = 3 + first.UncachedInputTokens = 3 first.OutputTokens = 5 - first.Cost = 0.5 + first.EstimatedCostNanoUSD = 500_000_000 duplicate := first duplicate.GroupID = 999 duplicate.UpstreamModel = "duplicate-must-lose" - duplicate.InputTokens = 1_000 + duplicate.UncachedInputTokens = 1_000 replay := existing replay.GroupID = 999 replay.UpstreamModel = "existing-must-win" second := aggregationRow(aggregationRequestID(3), hour.Add(2*time.Minute), 7, "aggregate-model") - second.InputTokens = 7 + second.UncachedInputTokens = 7 second.OutputTokens = 11 - second.Cost = 0.75 + second.EstimatedCostNanoUSD = 750_000_000 rows := []models.RequestLog{first, duplicate, replay, second} var requestLogQueries, usageStatQueries int @@ -398,26 +438,33 @@ func TestBatchWriterInsertsOnlyNewRequestLogsAndAggregatesUsage(t *testing.T) { if err := db.First(&persistedFirst, "id = ?", first.ID).Error; err != nil { t.Fatalf("query first RequestLog: %v", err) } - if persistedFirst.GroupID != 7 || persistedFirst.UpstreamModel != "aggregate-model" || - persistedFirst.InputTokens != 3 { + if persistedFirst.GroupID != 7 || persistedFirst.UpstreamModel != "upstream-aggregate-model" || + persistedFirst.UncachedInputTokens != 3 { t.Fatalf("first-write-wins row = %+v", persistedFirst) } var persistedExisting models.RequestLog if err := db.First(&persistedExisting, "id = ?", existing.ID).Error; err != nil { t.Fatalf("query existing RequestLog: %v", err) } - if persistedExisting.GroupID != 7 || persistedExisting.UpstreamModel != "aggregate-model" || - persistedExisting.InputTokens != 99 { + if persistedExisting.GroupID != 7 || persistedExisting.UpstreamModel != "upstream-aggregate-model" || + persistedExisting.UncachedInputTokens != 99 { t.Fatalf("existing row was updated: %+v", persistedExisting) } var stat models.UsageStat - if err := db.Where("hour_bucket = ? AND group_id = ? AND model = ?", hour, 7, "aggregate-model"). + if err := db.Where( + "bucket_start_ms = ? AND access_key_id = ? AND group_id = ? AND model = ?", + int64(1_784_894_400_000), + 1, + 7, + "aggregate-model", + ). Take(&stat).Error; err != nil { t.Fatalf("query UsageStat: %v", err) } if stat.RequestCount != 12 || stat.SuccessCount != 12 || stat.FailureCount != 0 || - stat.InputTokens != 110 || stat.OutputTokens != 216 || stat.Cost != 2.25 { + stat.UncachedInputTokens != 110 || stat.OutputTokens != 216 || + stat.EstimatedCostNanoUSD != 2_250_000_000 { t.Fatalf("UsageStat = %+v, want baseline plus only two new rows", stat) } @@ -433,49 +480,51 @@ func TestBatchWriterInsertsOnlyNewRequestLogsAndAggregatesUsage(t *testing.T) { } } -func TestBatchWriterAggregatesStatusQualityAndCompletePricedTotals(t *testing.T) { +func TestWriteBatchAggregatesStatusQualityAndCompletePricedTotals(t *testing.T) { db := openRequestLogQueryDB(t) hour := time.Date(2026, time.July, 24, 13, 0, 0, 0, time.UTC) complete := aggregationRow(aggregationRequestID(10), hour, 8, "quality-model") - complete.InputTokens = 1 + complete.UncachedInputTokens = 1 complete.OutputTokens = 2 complete.CacheReadTokens = 3 complete.CacheWrite5MTokens = 4 complete.CacheWrite1HTokens = 5 - complete.Cost = 1.25 + complete.EstimatedCostNanoUSD = 1_250_000_000 completeUnpriced := aggregationRow(aggregationRequestID(15), hour, 8, "quality-model") completeUnpriced.CostState = string(pricing.CostStateUnpriced) - completeUnpriced.InputTokens = 400 + completeUnpriced.UncachedInputTokens = 400 completeUnpriced.OutputTokens = 500 - completeUnpriced.Cost = 0 + completeUnpriced.EstimatedCostNanoUSD = 0 missing := aggregationRow(aggregationRequestID(11), hour, 8, "quality-model") missing.Status = string(telemetry.RequestStatusError) missing.UsageState = string(usage.StateMissing) missing.CostState = string(pricing.CostStateUnpriced) - missing.InputTokens = 100 - missing.Cost = 0 + missing.UncachedInputTokens = 100 + missing.EstimatedCostNanoUSD = 0 partialPriced := aggregationRow(aggregationRequestID(12), hour, 8, "quality-model") partialPriced.Status = string(telemetry.RequestStatusIncomplete) partialPriced.UsageState = string(usage.StatePartial) - partialPriced.InputTokens = 200 + partialPriced.UncachedInputTokens = 200 partialPriced.OutputTokens = 300 - partialPriced.Cost = 2.5 + partialPriced.EstimatedCostNanoUSD = 2_500_000_000 partialUnpriced := aggregationRow(aggregationRequestID(13), hour, 8, "quality-model") partialUnpriced.Status = string(telemetry.RequestStatusCanceled) partialUnpriced.UsageState = string(usage.StatePartial) partialUnpriced.CostState = string(pricing.CostStateUnpriced) - partialUnpriced.Cost = 0 + partialUnpriced.EstimatedCostNanoUSD = 0 - unknown := aggregationRow(aggregationRequestID(14), hour, 8, "quality-model") - unknown.Status = "future_status" - unknown.UsageState = string(usage.StateNotApplicable) - unknown.CostState = string(pricing.CostStateNotApplicable) - unknown.Cost = 0 + notApplicable := aggregationRow(aggregationRequestID(14), hour, 8, "quality-model") + notApplicable.Status = string(telemetry.RequestStatusError) + notApplicable.UsageState = string(usage.StateNotApplicable) + notApplicable.CostState = string(pricing.CostStateNotApplicable) + notApplicable.UncachedInputTokens = 700 + notApplicable.OutputTokens = 800 + notApplicable.EstimatedCostNanoUSD = 0 if err := (&gormBatchWriter{db: db}).WriteBatch( context.Background(), @@ -485,7 +534,7 @@ func TestBatchWriterAggregatesStatusQualityAndCompletePricedTotals(t *testing.T) missing, partialPriced, partialUnpriced, - unknown, + notApplicable, }, ); err != nil { t.Fatalf("WriteBatch() error = %v", err) @@ -497,33 +546,33 @@ func TestBatchWriterAggregatesStatusQualityAndCompletePricedTotals(t *testing.T) } if stat.RequestCount != 6 || stat.SuccessCount != 2 || stat.FailureCount != 4 || stat.UsageMissingCount != 1 || stat.PartialCount != 2 || - stat.UnpricedRequestCount != 3 { + stat.UnpricedRequestCount != 2 { t.Fatalf("status/quality counts = %+v", stat) } - if stat.InputTokens != 1 || stat.OutputTokens != 2 || stat.CacheReadTokens != 3 || + if stat.UncachedInputTokens != 602 || stat.OutputTokens != 804 || stat.CacheReadTokens != 3 || stat.CacheWrite5MTokens != 4 || stat.CacheWrite1HTokens != 5 || - stat.Cost != 1.25 { - t.Fatalf("complete+priced totals = %+v", stat) + stat.EstimatedCostNanoUSD != 3_750_000_000 { + t.Fatalf("complete/partial totals = %+v", stat) } var persistedPartial models.RequestLog if err := db.First(&persistedPartial, "id = ?", partialPriced.ID).Error; err != nil { t.Fatalf("query partial RequestLog: %v", err) } - if persistedPartial.InputTokens != 200 || persistedPartial.OutputTokens != 300 || - persistedPartial.Cost != 2.5 { + if persistedPartial.UncachedInputTokens != 200 || persistedPartial.OutputTokens != 300 || + persistedPartial.EstimatedCostNanoUSD != 2_500_000_000 { t.Fatalf("partial+priced RequestLog lost usage/cost: %+v", persistedPartial) } var persistedUnpriced models.RequestLog if err := db.First(&persistedUnpriced, "id = ?", completeUnpriced.ID).Error; err != nil { t.Fatalf("query complete+unpriced RequestLog: %v", err) } - if persistedUnpriced.InputTokens != 400 || persistedUnpriced.OutputTokens != 500 || - persistedUnpriced.Cost != 0 { + if persistedUnpriced.UncachedInputTokens != 400 || persistedUnpriced.OutputTokens != 500 || + persistedUnpriced.EstimatedCostNanoUSD != 0 { t.Fatalf("complete+unpriced RequestLog lost usage or gained cost: %+v", persistedUnpriced) } } -func TestBatchWriterSeparatesHourGroupAndUpstreamModel(t *testing.T) { +func TestWriteBatchSeparatesHourAccessGroupAndClientModelWithoutSkippingZeroDimensions(t *testing.T) { db := openRequestLogQueryDB(t) location := time.FixedZone("utc-plus-eight", 8*60*60) localHour := time.Date(2026, time.July, 24, 20, 0, 0, 0, location) @@ -536,32 +585,54 @@ func TestBatchWriterSeparatesHourGroupAndUpstreamModel(t *testing.T) { aggregationRow(aggregationRequestID(25), localHour.Add(4*time.Minute), 0, "model-a"), aggregationRow(aggregationRequestID(26), localHour.Add(5*time.Minute), 9, ""), } + differentAccess := aggregationRow( + aggregationRequestID(27), + localHour.Add(6*time.Minute), + 9, + "model-a", + ) + differentAccess.AccessKeyID = 2 + rows = append(rows, differentAccess) if err := (&gormBatchWriter{db: db}).WriteBatch(context.Background(), rows); err != nil { t.Fatalf("WriteBatch() error = %v", err) } var stats []models.UsageStat - if err := db.Order("hour_bucket ASC").Order("group_id ASC").Order("model ASC").Find(&stats).Error; err != nil { + if err := db.Order("bucket_start_ms ASC"). + Order("access_key_id ASC"). + Order("group_id ASC"). + Order("model ASC"). + Find(&stats).Error; err != nil { t.Fatalf("query UsageStats: %v", err) } - if len(stats) != 4 { - t.Fatalf("UsageStat rows = %d, want four attributable buckets: %+v", len(stats), stats) + if len(stats) != 7 { + t.Fatalf("UsageStat rows = %d, want seven isolated buckets: %+v", len(stats), stats) } type bucket struct { - hour time.Time - group uint - model string - count int64 + bucketMS int64 + access uint + group uint + model string + count int64 } got := make([]bucket, 0, len(stats)) for _, stat := range stats { - got = append(got, bucket{stat.HourBucket.UTC(), stat.GroupID, stat.Model, stat.RequestCount}) + got = append(got, bucket{ + stat.BucketStartMS, + stat.AccessKeyID, + stat.GroupID, + stat.Model, + stat.RequestCount, + }) } want := []bucket{ - {time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC), 9, "model-a", 2}, - {time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC), 9, "model-b", 1}, - {time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC), 10, "model-a", 1}, - {time.Date(2026, time.July, 24, 13, 0, 0, 0, time.UTC), 9, "model-a", 1}, + {1_784_894_400_000, 1, 0, "model-a", 1}, + {1_784_894_400_000, 1, 9, "", 1}, + {1_784_894_400_000, 1, 9, "model-a", 2}, + {1_784_894_400_000, 1, 9, "model-b", 1}, + {1_784_894_400_000, 1, 10, "model-a", 1}, + {1_784_894_400_000, 2, 9, "model-a", 1}, + {1_784_898_000_000, 1, 9, "model-a", 1}, } if !reflect.DeepEqual(got, want) { t.Fatalf("buckets = %#v, want %#v", got, want) @@ -575,7 +646,7 @@ func TestBatchWriterSeparatesHourGroupAndUpstreamModel(t *testing.T) { } } -func TestBatchWriterRollsBackRequestLogsAndStatsOnFailure(t *testing.T) { +func TestWriteBatchRollsBackRequestLogsAndStatsOnFailure(t *testing.T) { newRow := func() models.RequestLog { return aggregationRow( aggregationRequestID(30), @@ -637,15 +708,30 @@ func TestBatchWriterRollsBackRequestLogsAndStatsOnFailure(t *testing.T) { t.Run("existing UsageStat scan conversion", func(t *testing.T) { db := openRequestLogQueryDB(t) row := newRow() - hour := row.CreatedAt.UTC().Truncate(time.Hour) + hourMS := row.CompletedAtMS - row.CompletedAtMS%3_600_000 const invalidRequestCount = "not-an-integer" + if err := db.Exec(`PRAGMA ignore_check_constraints = ON`).Error; err != nil { + t.Fatalf("disable SQLite CHECK constraints: %v", err) + } if err := db.Exec(` INSERT INTO usage_stats ( - hour_bucket, group_id, model, request_count, success_count, cost - ) VALUES (?, ?, ?, ?, ?, ?) - `, hour, row.GroupID, row.UpstreamModel, invalidRequestCount, 7, 3.5).Error; err != nil { + bucket_start_ms, access_key_id, group_id, model, + request_count, success_count, estimated_cost_nano_usd + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + hourMS, + row.AccessKeyID, + row.GroupID, + row.ClientModel, + invalidRequestCount, + 7, + 3_500_000_000, + ).Error; err != nil { t.Fatalf("insert incompatible UsageStat: %v", err) } + if err := db.Exec(`PRAGMA ignore_check_constraints = OFF`).Error; err != nil { + t.Fatalf("restore SQLite CHECK constraints: %v", err) + } err := (&gormBatchWriter{db: db}).WriteBatch( context.Background(), @@ -657,23 +743,25 @@ func TestBatchWriterRollsBackRequestLogsAndStatsOnFailure(t *testing.T) { assertRequestLogAndUsageStatCounts(t, db, 0, 1) var persisted struct { - RequestCount string `gorm:"column:request_count"` - SuccessCount int64 `gorm:"column:success_count"` - Cost float64 `gorm:"column:cost"` + RequestCount string `gorm:"column:request_count"` + SuccessCount int64 `gorm:"column:success_count"` + EstimatedCostNanoUSD int64 `gorm:"column:estimated_cost_nano_usd"` } if err := db.Raw(` SELECT CAST(request_count AS TEXT) AS request_count, success_count, - cost + estimated_cost_nano_usd FROM usage_stats - WHERE hour_bucket = ? AND group_id = ? AND model = ? - `, hour, row.GroupID, row.UpstreamModel).Scan(&persisted).Error; err != nil { + WHERE bucket_start_ms = ? AND access_key_id = ? + AND group_id = ? AND model = ? + `, hourMS, row.AccessKeyID, row.GroupID, row.ClientModel). + Scan(&persisted).Error; err != nil { t.Fatalf("read incompatible UsageStat after rollback: %v", err) } if persisted.RequestCount != invalidRequestCount || persisted.SuccessCount != 7 || - persisted.Cost != 3.5 { + persisted.EstimatedCostNanoUSD != 3_500_000_000 { t.Fatalf("existing UsageStat changed after scan failure: %+v", persisted) } }) @@ -707,34 +795,34 @@ func TestBatchWriterRollsBackRequestLogsAndStatsOnFailure(t *testing.T) { }) } -func TestBatchWriterRejectsIntegerAndCostOverflow(t *testing.T) { +func TestWriteBatchRejectsIntegerAndCostOverflow(t *testing.T) { hour := time.Date(2026, time.July, 24, 15, 0, 0, 0, time.UTC) t.Run("batch token delta overflow", func(t *testing.T) { db := openRequestLogQueryDB(t) first := aggregationRow(aggregationRequestID(40), hour, 12, "overflow-model") - first.InputTokens = math.MaxInt64 + first.UncachedInputTokens = math.MaxInt64 second := aggregationRow(aggregationRequestID(41), hour, 12, "overflow-model") - second.InputTokens = 1 + second.UncachedInputTokens = 1 assertBatchWriterRejectsRowsWithoutChanges(t, db, nil, []models.RequestLog{first, second}) }) t.Run("batch cost overflow", func(t *testing.T) { db := openRequestLogQueryDB(t) first := aggregationRow(aggregationRequestID(42), hour, 12, "overflow-model") - first.Cost = math.MaxFloat64 + first.EstimatedCostNanoUSD = math.MaxInt64 second := aggregationRow(aggregationRequestID(43), hour, 12, "overflow-model") - second.Cost = math.MaxFloat64 assertBatchWriterRejectsRowsWithoutChanges(t, db, nil, []models.RequestLog{first, second}) }) t.Run("existing count overflow", func(t *testing.T) { db := openRequestLogQueryDB(t) existing := models.UsageStat{ - HourBucket: hour, - GroupID: 12, - Model: "overflow-model", - RequestCount: math.MaxInt64, + BucketStartMS: 1_784_905_200_000, + AccessKeyID: 1, + GroupID: 12, + Model: "overflow-model", + RequestCount: math.MaxInt64, } row := aggregationRow(aggregationRequestID(44), hour, 12, "overflow-model") assertBatchWriterRejectsRowsWithoutChanges(t, db, &existing, []models.RequestLog{row}) @@ -743,10 +831,11 @@ func TestBatchWriterRejectsIntegerAndCostOverflow(t *testing.T) { t.Run("existing token overflow", func(t *testing.T) { db := openRequestLogQueryDB(t) existing := models.UsageStat{ - HourBucket: hour, - GroupID: 12, - Model: "overflow-model", - InputTokens: math.MaxInt64, + BucketStartMS: 1_784_905_200_000, + AccessKeyID: 1, + GroupID: 12, + Model: "overflow-model", + UncachedInputTokens: math.MaxInt64, } row := aggregationRow(aggregationRequestID(45), hour, 12, "overflow-model") assertBatchWriterRejectsRowsWithoutChanges(t, db, &existing, []models.RequestLog{row}) @@ -755,23 +844,24 @@ func TestBatchWriterRejectsIntegerAndCostOverflow(t *testing.T) { t.Run("existing cost overflow", func(t *testing.T) { db := openRequestLogQueryDB(t) existing := models.UsageStat{ - HourBucket: hour, - GroupID: 12, - Model: "overflow-model", - Cost: math.MaxFloat64, + BucketStartMS: 1_784_905_200_000, + AccessKeyID: 1, + GroupID: 12, + Model: "overflow-model", + EstimatedCostNanoUSD: math.MaxInt64, } row := aggregationRow(aggregationRequestID(46), hour, 12, "overflow-model") - row.Cost = math.MaxFloat64 assertBatchWriterRejectsRowsWithoutChanges(t, db, &existing, []models.RequestLog{row}) }) t.Run("negative existing integer", func(t *testing.T) { db := openRequestLogQueryDB(t) existing := models.UsageStat{ - HourBucket: hour, - GroupID: 12, - Model: "overflow-model", - FailureCount: -1, + BucketStartMS: 1_784_905_200_000, + AccessKeyID: 1, + GroupID: 12, + Model: "overflow-model", + FailureCount: -1, } row := aggregationRow(aggregationRequestID(47), hour, 12, "overflow-model") assertBatchWriterRejectsRowsWithoutChanges(t, db, &existing, []models.RequestLog{row}) @@ -780,12 +870,12 @@ func TestBatchWriterRejectsIntegerAndCostOverflow(t *testing.T) { t.Run("negative row cost", func(t *testing.T) { db := openRequestLogQueryDB(t) row := aggregationRow(aggregationRequestID(48), hour, 12, "overflow-model") - row.Cost = -1 + row.EstimatedCostNanoUSD = -1 assertBatchWriterRejectsRowsWithoutChanges(t, db, nil, []models.RequestLog{row}) }) } -func TestBatchWriterRollsBackEarlierBucketWhenLaterUpsertFails(t *testing.T) { +func TestWriteBatchRollsBackEarlierBucketWhenLaterUpsertFails(t *testing.T) { db := openRequestLogQueryDB(t) hour := time.Date(2026, time.July, 24, 16, 0, 0, 0, time.UTC) if err := db.Exec(` @@ -797,7 +887,8 @@ func TestBatchWriterRollsBackEarlierBucketWhenLaterUpsertFails(t *testing.T) { WHEN COALESCE(( SELECT request_count FROM usage_stats - WHERE hour_bucket = NEW.hour_bucket + WHERE bucket_start_ms = NEW.bucket_start_ms + AND access_key_id = NEW.access_key_id AND group_id = NEW.group_id AND model = 'model-a' ), 0) != 1 @@ -896,9 +987,7 @@ func assertBatchWriterRejectsRowsWithoutChanges( ) { t.Helper() if existing != nil { - if err := db.Create(existing).Error; err != nil { - t.Fatalf("create existing UsageStat: %v", err) - } + createCorruptUsageStats(t, db, *existing) } var before []models.UsageStat if err := db.Order("id ASC").Find(&before).Error; err != nil { diff --git a/internal/storage/db.go b/internal/storage/db.go index 5558d00d..2c273c7f 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -25,7 +25,7 @@ import ( ) // CurrentSchemaVersion identifies the SQLite schema supported by this binary. -const CurrentSchemaVersion uint = 2 +const CurrentSchemaVersion uint = 3 const sqliteBusyTimeoutMS = 5000 @@ -159,8 +159,10 @@ func autoMigrate( return err } switch version { - case CurrentSchemaVersion: + case 3: return db.Transaction(migrateCurrentSchema) + case 2: + return db.Transaction(migrateSchemaV2ToV3) case 1: var accessKeyCount int64 if db.Migrator().HasTable(&models.AccessKey{}) { @@ -180,19 +182,19 @@ func autoMigrate( if err := migrateAccessKeysV1ToV2(tx, encryptionService); err != nil { return err } - if err := migrateCurrentSchema(tx); err != nil { + if err := ensureSchemaV2TablesForV1(tx); err != nil { return err } result := tx.Model(&schemaInfo{}). Where("version = ?", uint(1)). - Update("version", CurrentSchemaVersion) + Update("version", schemaV2Version) if result.Error != nil { - return fmt.Errorf("update schema_info: %w", result.Error) + return fmt.Errorf("advance schema_info to version 2: %w", result.Error) } if result.RowsAffected != 1 { - return fmt.Errorf("update schema_info: version changed concurrently") + return fmt.Errorf("advance schema_info to version 2: version changed concurrently") } - return nil + return migrateSchemaV2ToV3(tx) }) default: return fmt.Errorf( @@ -204,7 +206,7 @@ func autoMigrate( } func migrateCurrentSchema(db *gorm.DB) error { - if err := db.AutoMigrate( + currentTables := []any{ &models.Group{}, &models.UpstreamKey{}, &models.AccessKey{}, @@ -214,73 +216,34 @@ func migrateCurrentSchema(db *gorm.DB) error { &models.SystemSetting{}, &models.Job{}, &models.ControlOperation{}, - &schemaInfo{}, - ); err != nil { - return fmt.Errorf("auto-migrate SQLite schema: %w", err) } - if err := rebuildModelPricesIfNeeded(db); err != nil { - return err - } - return nil -} - -func rebuildModelPricesIfNeeded(db *gorm.DB) error { - if !db.Migrator().HasTable(&models.ModelPrice{}) { - return nil - } - - type columnInfo struct { - Name string - NotNull int `gorm:"column:notnull"` - } - var columns []columnInfo - if err := db.Raw("PRAGMA table_info('model_prices')").Scan(&columns).Error; err != nil { - return fmt.Errorf("inspect model_prices columns: %w", err) - } - for _, column := range columns { - switch column.Name { - case "input_price", "output_price", "cache_read_price", "cache_write_5m_price", "cache_write_1h_price": - if column.NotNull != 0 { - return rebuildModelPrices(db) - } + existingTables := 0 + for _, table := range currentTables { + if db.Migrator().HasTable(table) { + existingTables++ } } - return nil -} + if existingTables != 0 { + if existingTables != len(currentTables) { + return fmt.Errorf( + "validate SQLite schema version 3: found %d of %d required tables", + existingTables, + len(currentTables), + ) + } + return validateSchemaV3(db) + } -func rebuildModelPrices(db *gorm.DB) error { - if err := db.Exec(`CREATE TABLE model_prices__m4_rebuild ( - id integer PRIMARY KEY AUTOINCREMENT, - pattern varchar(255) NOT NULL, - input_price real, - output_price real, - cache_read_price real, - cache_write_5m_price real, - cache_write_1h_price real, - source varchar(32) NOT NULL, - created_at datetime, - updated_at datetime, - CONSTRAINT chk_model_price_source CHECK (source = 'user') -)`).Error; err != nil { - return fmt.Errorf("create rebuilt model_prices table: %w", err) + if err := createSchemaV3Tables(db, ""); err != nil { + return err } - if err := db.Exec(`INSERT INTO model_prices__m4_rebuild ( - id, pattern, input_price, output_price, cache_read_price, cache_write_5m_price, cache_write_1h_price, source, created_at, updated_at -) -SELECT id, pattern, input_price, output_price, cache_read_price, cache_write_5m_price, cache_write_1h_price, source, created_at, updated_at -FROM model_prices`).Error; err != nil { - return fmt.Errorf("copy model_prices into rebuilt table: %w", err) + if err := createSchemaV3InfoTable(db, "schema_info"); err != nil { + return err } - if err := db.Exec("DROP TABLE model_prices").Error; err != nil { - return fmt.Errorf("drop previous model_prices table: %w", err) + if err := createSchemaV3Indexes(db); err != nil { + return err } - if err := db.Exec("ALTER TABLE model_prices__m4_rebuild RENAME TO model_prices").Error; err != nil { - return fmt.Errorf("rename rebuilt model_prices table: %w", err) - } - if err := db.Exec("CREATE UNIQUE INDEX idx_model_prices_pattern ON model_prices(pattern)").Error; err != nil { - return fmt.Errorf("create rebuilt model_prices Pattern index: %w", err) - } - return nil + return validateSchemaV3(db) } func readSchemaVersion(db *gorm.DB) (uint, error) { diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index d6de55e9..42567d07 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -514,7 +514,7 @@ func TestOpenConfiguresParameterizedSQLLogging(t *testing.T) { } } -func TestAutoMigrateCreatesNineTablesAndSchemaVersion(t *testing.T) { +func TestAutoMigrateCreatesTenTablesAndSchemaVersion(t *testing.T) { t.Parallel() db := openMigratedDatabase(t) @@ -528,6 +528,7 @@ func TestAutoMigrateCreatesNineTablesAndSchemaVersion(t *testing.T) { "model_prices", "system_settings", "jobs", + "control_operations", "schema_info", } for _, table := range wantTables { @@ -556,7 +557,7 @@ func TestAutoMigrateCreatesNineTablesAndSchemaVersion(t *testing.T) { } } -func TestAutoMigrateCreatesRequestLogV1FieldsAndCompositeIndexes(t *testing.T) { +func TestAutoMigrateCreatesRequestLogV3FieldsAndCompositeIndexes(t *testing.T) { t.Parallel() dsn := filepath.Join(t.TempDir(), "fresh-request-log-v1.db") @@ -610,23 +611,23 @@ func TestAutoMigrateCreatesRequestLogV1FieldsAndCompositeIndexes(t *testing.T) { name string desc int }{ - "idx_request_logs_created_id": { - {name: "created_at", desc: 1}, + "idx_request_logs_completed_id": { + {name: "completed_at_ms", desc: 1}, {name: "id", desc: 1}, }, - "idx_request_logs_access_created_id": { + "idx_request_logs_access_completed_id": { {name: "access_key_id"}, - {name: "created_at", desc: 1}, + {name: "completed_at_ms", desc: 1}, {name: "id", desc: 1}, }, - "idx_request_logs_status_created_id": { + "idx_request_logs_status_completed_id": { {name: "status"}, - {name: "created_at", desc: 1}, + {name: "completed_at_ms", desc: 1}, {name: "id", desc: 1}, }, - "idx_request_logs_model_created_id": { + "idx_request_logs_model_completed_id": { {name: "client_model"}, - {name: "created_at", desc: 1}, + {name: "completed_at_ms", desc: 1}, {name: "id", desc: 1}, }, } @@ -664,8 +665,8 @@ func TestAutoMigrateCreatesRequestLogV1FieldsAndCompositeIndexes(t *testing.T) { } } - if storage.CurrentSchemaVersion != 2 { - t.Fatalf("CurrentSchemaVersion = %d, want 2", storage.CurrentSchemaVersion) + if storage.CurrentSchemaVersion != 3 { + t.Fatalf("CurrentSchemaVersion = %d, want 3", storage.CurrentSchemaVersion) } } @@ -680,7 +681,7 @@ type m4IndexInfo struct { Unique int } -func TestAutoMigrateCreatesM4UsagePricingColumnsAndConstraints(t *testing.T) { +func TestAutoMigrateCreatesSchemaV3UsagePricingColumnsAndConstraints(t *testing.T) { t.Parallel() db := openMigratedDatabase(t) @@ -748,14 +749,14 @@ func TestAutoMigrateCreatesM4UsagePricingColumnsAndConstraints(t *testing.T) { if err := db.Raw("PRAGMA index_list('usage_stats')").Scan(&usageIndexes).Error; err != nil { t.Fatalf("inspect usage_stats indexes: %v", err) } - if !hasUniqueIndex(usageIndexes, "idx_usage_stats_hour_group_model") { - t.Error("usage_stats unique hour/group/model index is missing") + if !hasUniqueIndex(usageIndexes, "idx_usage_stats_bucket_access_group_model") { + t.Error("usage_stats unique bucket/access/group/model index is missing") } assertM4IndexKeyColumns( t, db, - "idx_usage_stats_hour_group_model", - []string{"hour_bucket", "group_id", "model"}, + "idx_usage_stats_bucket_access_group_model", + []string{"bucket_start_ms", "access_key_id", "group_id", "model"}, ) var priceColumns []m4ColumnInfo @@ -763,7 +764,11 @@ func TestAutoMigrateCreatesM4UsagePricingColumnsAndConstraints(t *testing.T) { t.Fatalf("inspect model_prices columns: %v", err) } priceColumnNames := map[string]struct{}{ - "input_price": {}, "output_price": {}, "cache_read_price": {}, "cache_write_5m_price": {}, "cache_write_1h_price": {}, + "input_price_nano_usd_per_million_tokens": {}, + "output_price_nano_usd_per_million_tokens": {}, + "cache_read_price_nano_usd_per_million_tokens": {}, + "cache_write_5m_price_nano_usd_per_million_tokens": {}, + "cache_write_1h_price_nano_usd_per_million_tokens": {}, } for _, column := range priceColumns { if _, ok := priceColumnNames[column.Name]; ok && column.NotNull != 0 { @@ -805,8 +810,8 @@ func TestAutoMigrateCreatesM4UsagePricingColumnsAndConstraints(t *testing.T) { t.Error("builtin ModelPrice Source was accepted") } - if storage.CurrentSchemaVersion != 2 { - t.Errorf("CurrentSchemaVersion = %d, want 2", storage.CurrentSchemaVersion) + if storage.CurrentSchemaVersion != 3 { + t.Errorf("CurrentSchemaVersion = %d, want 3", storage.CurrentSchemaVersion) } if err := storage.AutoMigrate(db); err != nil { t.Fatalf("second AutoMigrate() error = %v", err) @@ -844,7 +849,7 @@ func assertM4IndexKeyColumns( } } -func TestAutoMigrateUpgradesCurrentModelPriceDDLWithoutRenamingColumns(t *testing.T) { +func TestAutoMigrateUpgradesV1ModelPriceDDLToSchemaV3FixedPoint(t *testing.T) { t.Parallel() db, err := storage.Open(":memory:") @@ -883,9 +888,14 @@ updated_at datetime if err := db.Exec(`CREATE INDEX idx_model_prices_pattern ON model_prices(pattern)`).Error; err != nil { t.Fatalf("create current model_prices Pattern index: %v", err) } - if err := db.Exec(`INSERT INTO model_prices(pattern, input_price, output_price, cache_read_price, cache_write_5m_price, cache_write_1h_price, source) VALUES -('nonzero', 1.25, 2.5, 3.75, 5, 6.25, 'user'), -('zero', 0, 0, 0, 0, 0, 'user')`).Error; err != nil { + if err := db.Exec(`INSERT INTO model_prices( + pattern, input_price, output_price, cache_read_price, + cache_write_5m_price, cache_write_1h_price, source, created_at, updated_at + ) VALUES + ('nonzero', 1.25, 2.5, 3.75, 5, 6.25, 'user', + '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z'), + ('zero', 0, 0, 0, 0, 0, 'user', + '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z')`).Error; err != nil { t.Fatalf("insert current model_prices rows: %v", err) } @@ -910,31 +920,46 @@ func assertModelPriceUpgrade(t *testing.T, db *gorm.DB) { t.Fatalf("inspect upgraded model_prices columns: %v", err) } for _, column := range columns { - if strings.Contains(column.Name, "uncached_input") { - t.Fatalf("model_prices has unexpected renamed price column %q", column.Name) - } - if column.Name == "input_price" || column.Name == "output_price" || column.Name == "cache_read_price" || column.Name == "cache_write_5m_price" || column.Name == "cache_write_1h_price" { + if strings.HasSuffix(column.Name, "_nano_usd_per_million_tokens") { if column.NotNull != 0 { t.Errorf("model_prices.%s notnull = %d, want nullable", column.Name, column.NotNull) } } + for _, legacy := range []string{ + "input_price", "output_price", "cache_read_price", + "cache_write_5m_price", "cache_write_1h_price", + } { + if column.Name == legacy { + t.Fatalf("model_prices retains legacy float column %q", column.Name) + } + } } type row struct { Pattern string - InputPrice float64 `gorm:"column:input_price"` - OutputPrice float64 `gorm:"column:output_price"` - CacheReadPrice float64 `gorm:"column:cache_read_price"` - CacheWrite5MPrice float64 `gorm:"column:cache_write_5m_price"` - CacheWrite1HPrice float64 `gorm:"column:cache_write_1h_price"` + InputPrice int64 `gorm:"column:input_price_nano_usd_per_million_tokens"` + OutputPrice int64 `gorm:"column:output_price_nano_usd_per_million_tokens"` + CacheReadPrice int64 `gorm:"column:cache_read_price_nano_usd_per_million_tokens"` + CacheWrite5MPrice int64 `gorm:"column:cache_write_5m_price_nano_usd_per_million_tokens"` + CacheWrite1HPrice int64 `gorm:"column:cache_write_1h_price_nano_usd_per_million_tokens"` } var rows []row - if err := db.Raw("SELECT pattern, input_price, output_price, cache_read_price, cache_write_5m_price, cache_write_1h_price FROM model_prices ORDER BY pattern").Scan(&rows).Error; err != nil { + if err := db.Raw(`SELECT pattern, + input_price_nano_usd_per_million_tokens, + output_price_nano_usd_per_million_tokens, + cache_read_price_nano_usd_per_million_tokens, + cache_write_5m_price_nano_usd_per_million_tokens, + cache_write_1h_price_nano_usd_per_million_tokens + FROM model_prices ORDER BY pattern`).Scan(&rows).Error; err != nil { t.Fatalf("read upgraded model_prices: %v", err) } if len(rows) != 2 { t.Fatalf("upgraded model_prices rows = %d, want 2", len(rows)) } - if rows[0] != (row{Pattern: "nonzero", InputPrice: 1.25, OutputPrice: 2.5, CacheReadPrice: 3.75, CacheWrite5MPrice: 5, CacheWrite1HPrice: 6.25}) { + if rows[0] != (row{ + Pattern: "nonzero", InputPrice: 1_250_000_000, + OutputPrice: 2_500_000_000, CacheReadPrice: 3_750_000_000, + CacheWrite5MPrice: 5_000_000_000, CacheWrite1HPrice: 6_250_000_000, + }) { t.Errorf("nonzero upgraded row = %+v", rows[0]) } if rows[1] != (row{Pattern: "zero"}) { @@ -1095,12 +1120,13 @@ func TestAutoMigrateCreatesCriticalUniqueConstraints(t *testing.T) { assertDuplicateRejected(t, db.Create(&first).Error, db.Create(&second).Error) }) - t.Run("usage hour group and model", func(t *testing.T) { + t.Run("usage bucket access group and model", func(t *testing.T) { bucket := time.Date(2026, time.July, 15, 12, 0, 0, 0, time.UTC) first := models.UsageStat{ - HourBucket: bucket, - GroupID: 202, - Model: "model-a", + BucketStartMS: bucket.UnixMilli(), + AccessKeyID: 101, + GroupID: 202, + Model: "model-a", } second := first second.ID = 0 diff --git a/internal/storage/models/access_key.go b/internal/storage/models/access_key.go index c3854cc3..541e0327 100644 --- a/internal/storage/models/access_key.go +++ b/internal/storage/models/access_key.go @@ -1,19 +1,17 @@ package models -import "time" - // AccessKey is an encrypted client credential and its persisted access policy. type AccessKey struct { - ID uint `gorm:"primaryKey;autoIncrement"` - Name string `gorm:"type:varchar(255);not null"` - KeyValue string `gorm:"type:text;not null"` - KeyHash string `gorm:"type:varchar(128);not null;uniqueIndex"` - KeySuffix string `gorm:"type:char(4);not null;check:chk_access_key_suffix,key_suffix GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]'"` - Status string `gorm:"type:varchar(32);not null;default:'active';check:chk_access_key_status,status IN ('active','disabled')"` - Filters JSON `gorm:"type:json"` - RPMLimit int64 `gorm:"not null;default:0"` - DailyCostLimit float64 `gorm:"not null;default:0"` - MonthlyCostLimit float64 `gorm:"not null;default:0"` - CreatedAt time.Time - UpdatedAt time.Time + ID uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(255);not null"` + KeyValue string `gorm:"type:text;not null"` + KeyHash string `gorm:"type:varchar(128);not null;uniqueIndex"` + KeySuffix string `gorm:"type:char(4);not null;check:chk_access_key_suffix,key_suffix GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]'"` + Status string `gorm:"type:varchar(32);not null;default:'active';check:chk_access_key_status,status IN ('active','disabled')"` + Filters JSON `gorm:"type:json"` + RPMLimit int64 `gorm:"not null;default:0"` + DailyCostLimitNanoUSD int64 `gorm:"column:daily_cost_limit_nano_usd;not null;default:0;check:chk_access_key_daily_cost_limit_nano,daily_cost_limit_nano_usd >= 0"` + MonthlyCostLimitNanoUSD int64 `gorm:"column:monthly_cost_limit_nano_usd;not null;default:0;check:chk_access_key_monthly_cost_limit_nano,monthly_cost_limit_nano_usd >= 0"` + CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli"` } diff --git a/internal/storage/models/control_operation.go b/internal/storage/models/control_operation.go index 495f6ab6..63bcd832 100644 --- a/internal/storage/models/control_operation.go +++ b/internal/storage/models/control_operation.go @@ -1,23 +1,21 @@ package models -import "time" - // ControlOperation durably identifies a create/import mutation and its // post-commit side-effect progress. It never stores plaintext credentials. type ControlOperation struct { - CommitSequence uint64 `gorm:"primaryKey;autoIncrement"` - OperationID string `gorm:"type:char(36);not null;uniqueIndex"` - IdempotencyKey string `gorm:"type:char(36);not null;uniqueIndex"` - DigestVersion uint `gorm:"not null;check:chk_control_operation_digest_version,digest_version > 0"` - RequestDigest []byte `gorm:"type:blob;not null;check:chk_control_operation_digest,length(request_digest) = 32"` - OperationKind string `gorm:"type:varchar(32);not null"` - ResourceIdentity string `gorm:"type:varchar(64);not null"` - CanonicalResult []byte `gorm:"type:blob"` - RequiredStages JSON `gorm:"type:json"` - LastCompletedStage string `gorm:"type:varchar(32)"` - FailedStage string `gorm:"type:varchar(32)"` - CompletedAt *time.Time `gorm:"index"` - CompactedAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + CommitSequence uint64 `gorm:"primaryKey;autoIncrement"` + OperationID string `gorm:"type:char(36);not null;uniqueIndex"` + IdempotencyKey string `gorm:"type:char(36);not null;uniqueIndex"` + DigestVersion uint `gorm:"not null;check:chk_control_operation_digest_version,digest_version > 0"` + RequestDigest []byte `gorm:"type:blob;not null;check:chk_control_operation_digest,length(request_digest) = 32"` + OperationKind string `gorm:"type:varchar(32);not null"` + ResourceIdentity string `gorm:"type:varchar(64);not null"` + CanonicalResult []byte `gorm:"type:blob"` + RequiredStages JSON `gorm:"type:json"` + LastCompletedStage string `gorm:"type:varchar(32)"` + FailedStage string `gorm:"type:varchar(32)"` + CompletedAtMS *int64 `gorm:"column:completed_at_ms;index"` + CompactedAtMS *int64 `gorm:"column:compacted_at_ms"` + CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli"` } diff --git a/internal/storage/models/group.go b/internal/storage/models/group.go index 8a934450..a5e65473 100644 --- a/internal/storage/models/group.go +++ b/internal/storage/models/group.go @@ -1,7 +1,5 @@ package models -import "time" - // UpstreamKeyStatus is the durable operator-controlled state of a credential. // Runtime cooldown and failure state belongs to state.KeyRegistry. type UpstreamKeyStatus string @@ -25,8 +23,8 @@ type Group struct { Config JSON `gorm:"type:json"` Enabled bool `gorm:"not null;default:true"` UpstreamKeys []UpstreamKey `gorm:"foreignKey:GroupID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - CreatedAt time.Time - UpdatedAt time.Time + CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli"` } // UpstreamKey is an encrypted provider credential that belongs to one group. @@ -37,9 +35,6 @@ type UpstreamKey struct { KeyHash string `gorm:"type:varchar(128);not null;uniqueIndex:idx_upstream_keys_group_hash,priority:2"` Status UpstreamKeyStatus `gorm:"type:varchar(32);not null;default:'active';check:chk_upstream_key_status,status IN ('active','disabled')"` WeightManual *int - RequestCount int64 `gorm:"not null;default:0"` - TokensTotal int64 `gorm:"not null;default:0"` - CostTotal float64 `gorm:"not null;default:0"` - CreatedAt time.Time - UpdatedAt time.Time + CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli"` } diff --git a/internal/storage/models/model_price.go b/internal/storage/models/model_price.go index 63c07c3e..55c41ee9 100644 --- a/internal/storage/models/model_price.go +++ b/internal/storage/models/model_price.go @@ -1,17 +1,15 @@ package models -import "time" - // ModelPrice stores prices in USD per one million tokens for an upstream model pattern. type ModelPrice struct { - ID uint `gorm:"primaryKey;autoIncrement"` - Pattern string `gorm:"type:varchar(255);not null;uniqueIndex"` - InputPrice *float64 `gorm:"column:input_price"` - OutputPrice *float64 `gorm:"column:output_price"` - CacheReadPrice *float64 `gorm:"column:cache_read_price"` - CacheWrite5MPrice *float64 `gorm:"column:cache_write_5m_price"` - CacheWrite1HPrice *float64 `gorm:"column:cache_write_1h_price"` - Source string `gorm:"type:varchar(32);not null;check:chk_model_price_source,source = 'user'"` - CreatedAt time.Time - UpdatedAt time.Time + ID uint `gorm:"primaryKey;autoIncrement"` + Pattern string `gorm:"type:varchar(255);not null;uniqueIndex"` + InputPriceNanoUSDPerMillionTokens *int64 `gorm:"column:input_price_nano_usd_per_million_tokens;check:chk_model_price_input_nano,input_price_nano_usd_per_million_tokens IS NULL OR input_price_nano_usd_per_million_tokens >= 0"` + OutputPriceNanoUSDPerMillionTokens *int64 `gorm:"column:output_price_nano_usd_per_million_tokens;check:chk_model_price_output_nano,output_price_nano_usd_per_million_tokens IS NULL OR output_price_nano_usd_per_million_tokens >= 0"` + CacheReadPriceNanoUSDPerMillionTokens *int64 `gorm:"column:cache_read_price_nano_usd_per_million_tokens;check:chk_model_price_cache_read_nano,cache_read_price_nano_usd_per_million_tokens IS NULL OR cache_read_price_nano_usd_per_million_tokens >= 0"` + CacheWrite5MPriceNanoUSDPerMillionTokens *int64 `gorm:"column:cache_write_5m_price_nano_usd_per_million_tokens;check:chk_model_price_cache_write_5m_nano,cache_write_5m_price_nano_usd_per_million_tokens IS NULL OR cache_write_5m_price_nano_usd_per_million_tokens >= 0"` + CacheWrite1HPriceNanoUSDPerMillionTokens *int64 `gorm:"column:cache_write_1h_price_nano_usd_per_million_tokens;check:chk_model_price_cache_write_1h_nano,cache_write_1h_price_nano_usd_per_million_tokens IS NULL OR cache_write_1h_price_nano_usd_per_million_tokens >= 0"` + Source string `gorm:"type:varchar(32);not null;check:chk_model_price_source,source = 'user'"` + CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli"` } diff --git a/internal/storage/models/request.go b/internal/storage/models/request.go index cd831c62..ef615958 100644 --- a/internal/storage/models/request.go +++ b/internal/storage/models/request.go @@ -1,49 +1,48 @@ package models -import "time" - // RequestLog is the durable request-level audit and usage record. type RequestLog struct { - ID string `gorm:"type:varchar(36);primaryKey;not null;index:idx_request_logs_created_id,priority:2,sort:desc;index:idx_request_logs_access_created_id,priority:3,sort:desc;index:idx_request_logs_status_created_id,priority:3,sort:desc;index:idx_request_logs_model_created_id,priority:3,sort:desc"` - CreatedAt time.Time `gorm:"not null;index:idx_request_logs_created_id,priority:1,sort:desc;index:idx_request_logs_access_created_id,priority:2,sort:desc;index:idx_request_logs_status_created_id,priority:2,sort:desc;index:idx_request_logs_model_created_id,priority:2,sort:desc"` - AccessKeyID uint `gorm:"not null;index:idx_request_logs_access_created_id,priority:1"` - GroupID uint `gorm:"not null;default:0"` - Protocol string `gorm:"type:varchar(32);not null"` - ClientModel string `gorm:"type:varchar(255);not null;index:idx_request_logs_model_created_id,priority:1"` - UpstreamModel string `gorm:"type:varchar(255);not null"` - Status string `gorm:"type:varchar(32);not null;index:idx_request_logs_status_created_id,priority:1"` - StatusCode int `gorm:"not null"` - DurationMs int64 `gorm:"not null"` - ErrorCode string `gorm:"type:varchar(64);not null;default:''"` - ErrorSummary string `gorm:"type:text;not null;default:''"` - AffinityHit bool `gorm:"not null;default:false"` - InputTokens int64 `gorm:"not null;default:0"` - OutputTokens int64 `gorm:"not null;default:0"` - CacheReadTokens int64 `gorm:"not null;default:0"` - CacheWrite5MTokens int64 `gorm:"column:cache_write_5m_tokens;not null;default:0"` - CacheWrite1HTokens int64 `gorm:"column:cache_write_1h_tokens;not null;default:0"` - Cost float64 `gorm:"not null;default:0"` - UsageState string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_usage_state,usage_state IN ('complete','partial','missing','not_applicable')"` - CostState string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_cost_state,cost_state IN ('priced','unpriced','not_applicable')"` - Attempts JSON `gorm:"type:json"` + ID string `gorm:"type:varchar(36);primaryKey;not null;index:idx_request_logs_completed_id,priority:2,sort:desc;index:idx_request_logs_access_completed_id,priority:3,sort:desc;index:idx_request_logs_status_completed_id,priority:3,sort:desc;index:idx_request_logs_model_completed_id,priority:3,sort:desc"` + CompletedAtMS int64 `gorm:"column:completed_at_ms;not null;index:idx_request_logs_completed_id,priority:1,sort:desc;index:idx_request_logs_access_completed_id,priority:2,sort:desc;index:idx_request_logs_status_completed_id,priority:2,sort:desc;index:idx_request_logs_model_completed_id,priority:2,sort:desc"` + AccessKeyID uint `gorm:"not null;index:idx_request_logs_access_completed_id,priority:1"` + GroupID uint `gorm:"not null;default:0"` + Protocol string `gorm:"type:varchar(32);not null"` + ClientModel string `gorm:"type:varchar(255);not null;index:idx_request_logs_model_completed_id,priority:1"` + UpstreamModel string `gorm:"type:varchar(255);not null"` + Status string `gorm:"type:varchar(32);not null;index:idx_request_logs_status_completed_id,priority:1"` + StatusCode int `gorm:"not null"` + DurationMs int64 `gorm:"not null"` + ErrorCode string `gorm:"type:varchar(64);not null;default:''"` + ErrorSummary string `gorm:"type:text;not null;default:''"` + AffinityHit bool `gorm:"not null;default:false"` + UncachedInputTokens int64 `gorm:"column:uncached_input_tokens;not null;default:0"` + OutputTokens int64 `gorm:"not null;default:0"` + CacheReadTokens int64 `gorm:"not null;default:0"` + CacheWrite5MTokens int64 `gorm:"column:cache_write_5m_tokens;not null;default:0"` + CacheWrite1HTokens int64 `gorm:"column:cache_write_1h_tokens;not null;default:0"` + EstimatedCostNanoUSD int64 `gorm:"column:estimated_cost_nano_usd;not null;default:0;check:chk_request_log_cost_nano,estimated_cost_nano_usd >= 0"` + UsageState string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_usage_state,usage_state IN ('complete','partial','missing','not_applicable')"` + CostState string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_cost_state,cost_state IN ('priced','unpriced','not_applicable')"` + Attempts JSON `gorm:"type:json"` } -// UsageStat is an hourly aggregate by upstream group and upstream model. +// UsageStat is an hourly aggregate by access key, upstream group, and client model. type UsageStat struct { - ID uint `gorm:"primaryKey;autoIncrement"` - HourBucket time.Time `gorm:"not null;uniqueIndex:idx_usage_stats_hour_group_model,priority:1"` - GroupID uint `gorm:"not null;uniqueIndex:idx_usage_stats_hour_group_model,priority:2"` - Model string `gorm:"type:varchar(255);not null;uniqueIndex:idx_usage_stats_hour_group_model,priority:3"` - RequestCount int64 `gorm:"not null;default:0"` - SuccessCount int64 `gorm:"not null;default:0"` - FailureCount int64 `gorm:"not null;default:0"` - InputTokens int64 `gorm:"not null;default:0"` - OutputTokens int64 `gorm:"not null;default:0"` - CacheReadTokens int64 `gorm:"not null;default:0"` - CacheWrite5MTokens int64 `gorm:"column:cache_write_5m_tokens;not null;default:0"` - CacheWrite1HTokens int64 `gorm:"column:cache_write_1h_tokens;not null;default:0"` - Cost float64 `gorm:"not null;default:0"` - UsageMissingCount int64 `gorm:"not null;default:0"` - PartialCount int64 `gorm:"not null;default:0"` - UnpricedRequestCount int64 `gorm:"not null;default:0"` + ID uint `gorm:"primaryKey;autoIncrement"` + BucketStartMS int64 `gorm:"column:bucket_start_ms;not null;uniqueIndex:idx_usage_stats_bucket_access_group_model,priority:1"` + AccessKeyID uint `gorm:"not null;uniqueIndex:idx_usage_stats_bucket_access_group_model,priority:2"` + GroupID uint `gorm:"not null;uniqueIndex:idx_usage_stats_bucket_access_group_model,priority:3"` + Model string `gorm:"type:varchar(255);not null;uniqueIndex:idx_usage_stats_bucket_access_group_model,priority:4"` + RequestCount int64 `gorm:"not null;default:0"` + SuccessCount int64 `gorm:"not null;default:0"` + FailureCount int64 `gorm:"not null;default:0"` + UncachedInputTokens int64 `gorm:"column:uncached_input_tokens;not null;default:0"` + OutputTokens int64 `gorm:"not null;default:0"` + CacheReadTokens int64 `gorm:"not null;default:0"` + CacheWrite5MTokens int64 `gorm:"column:cache_write_5m_tokens;not null;default:0"` + CacheWrite1HTokens int64 `gorm:"column:cache_write_1h_tokens;not null;default:0"` + EstimatedCostNanoUSD int64 `gorm:"not null;default:0;check:chk_usage_stat_cost_nano,estimated_cost_nano_usd >= 0"` + UsageMissingCount int64 `gorm:"not null;default:0"` + PartialCount int64 `gorm:"not null;default:0"` + UnpricedRequestCount int64 `gorm:"not null;default:0"` } diff --git a/internal/storage/models/system.go b/internal/storage/models/system.go index 31480585..6e455829 100644 --- a/internal/storage/models/system.go +++ b/internal/storage/models/system.go @@ -1,27 +1,25 @@ package models -import "time" - // InternalSystemSettingPrefix reserves persistence metadata that must never // become user-visible runtime configuration. const InternalSystemSettingPrefix = "_internal." // SystemSetting stores a dynamically configurable setting as a key-value pair. type SystemSetting struct { - Key string `gorm:"type:varchar(255);primaryKey;not null"` - Value string `gorm:"type:text;not null"` - UpdatedAt time.Time + Key string `gorm:"type:varchar(255);primaryKey;not null"` + Value string `gorm:"type:text;not null"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli"` } // Job is one durable background operation and its execution history. type Job struct { - ID string `gorm:"type:varchar(36);primaryKey;not null"` - Type string `gorm:"type:varchar(64);not null;index"` - Status string `gorm:"type:varchar(32);not null;default:'pending';index"` - Payload JSON `gorm:"type:json"` - Result JSON `gorm:"type:json"` - Error string `gorm:"type:text"` - CreatedAt time.Time `gorm:"not null;index"` - StartedAt *time.Time - FinishedAt *time.Time + ID string `gorm:"type:varchar(36);primaryKey;not null"` + Type string `gorm:"type:varchar(64);not null;index"` + Status string `gorm:"type:varchar(32);not null;default:'pending';index"` + Payload JSON `gorm:"type:json"` + Result JSON `gorm:"type:json"` + Error string `gorm:"type:text"` + CreatedAtMS int64 `gorm:"column:created_at_ms;not null;index;autoCreateTime:milli"` + StartedAtMS *int64 `gorm:"column:started_at_ms"` + FinishedAtMS *int64 `gorm:"column:finished_at_ms"` } diff --git a/internal/storage/schema_v2_test.go b/internal/storage/schema_v2_test.go index e1d5a8fc..8f761a03 100644 --- a/internal/storage/schema_v2_test.go +++ b/internal/storage/schema_v2_test.go @@ -14,10 +14,10 @@ import ( "gpt-load/internal/storage/models" ) -func TestAutoMigrateCreatesSchemaV2SecurityTables(t *testing.T) { +func TestAutoMigrateCreatesSchemaV3SecurityTables(t *testing.T) { db := openMigratedDatabase(t) - if storage.CurrentSchemaVersion != 2 { - t.Fatalf("CurrentSchemaVersion = %d, want 2", storage.CurrentSchemaVersion) + if storage.CurrentSchemaVersion != 3 { + t.Fatalf("CurrentSchemaVersion = %d, want 3", storage.CurrentSchemaVersion) } type columnInfo struct { @@ -91,10 +91,11 @@ func TestAutoMigrateUpgradesV1AccessKeyWithBackups(t *testing.T) { t.Fatalf("Encrypt() error = %v", err) } if err := db.Exec(`INSERT INTO access_keys - (name,key_value,key_hash,status,filters,rpm_limit,daily_cost_limit,monthly_cost_limit) - VALUES (?,?,?,?,?,?,?,?)`, + (name,key_value,key_hash,status,filters,rpm_limit,daily_cost_limit,monthly_cost_limit,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, "legacy", ciphertext, keyService.Hash(plaintext), "active", `{"groups":[],"protocols":[],"models":[]}`, 0, 0, 0, + "2026-07-30T00:00:00Z", "2026-07-30T00:00:00Z", ).Error; err != nil { t.Fatalf("insert legacy AccessKey: %v", err) } @@ -114,8 +115,8 @@ func TestAutoMigrateUpgradesV1AccessKeyWithBackups(t *testing.T) { if err := db.Raw("SELECT version FROM schema_info").Scan(&version).Error; err != nil { t.Fatalf("read schema version: %v", err) } - if version != 2 { - t.Fatalf("schema version = %d, want 2", version) + if version != 3 { + t.Fatalf("schema version = %d, want 3", version) } assertSingleSecureBackup(t, dbPath+".schema-v1-backup-*") assertSingleSecureBackup(t, keyPath+".schema-v1-backup-*") diff --git a/internal/storage/schema_v3.go b/internal/storage/schema_v3.go new file mode 100644 index 00000000..ec7b824a --- /dev/null +++ b/internal/storage/schema_v3.go @@ -0,0 +1,1189 @@ +package storage + +import ( + "fmt" + "math" + "math/big" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + + "gpt-load/internal/platform/epochms" + "gpt-load/internal/pricing" + "gpt-load/internal/storage/models" + "gpt-load/internal/telemetry" + "gpt-load/internal/usage" + "gpt-load/internal/usagecost" +) + +const schemaV2Version uint = 2 + +type legacyGroupV2 struct { + ID uint + Name string + UpstreamURL string + Protocols models.JSON + Models models.JSON + ConvertEnabled bool + WeightManual *int + ValidationModel *string + Config models.JSON + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type legacyUpstreamKeyV2 struct { + ID uint + GroupID uint + KeyValue string + KeyHash string + Status string + WeightManual *int + CreatedAt time.Time + UpdatedAt time.Time +} + +type legacyAccessKeyV2 struct { + ID uint + Name string + KeyValue string + KeyHash string + KeySuffix string + Status string + Filters models.JSON + RPMLimit int64 + DailyCostLimit float64 + MonthlyCostLimit float64 + CreatedAt time.Time + UpdatedAt time.Time +} + +type legacyModelPriceV2 struct { + ID uint + Pattern string + InputPrice *float64 + OutputPrice *float64 + CacheReadPrice *float64 + CacheWrite5MPrice *float64 `gorm:"column:cache_write_5m_price"` + CacheWrite1HPrice *float64 `gorm:"column:cache_write_1h_price"` + Source string + CreatedAt time.Time + UpdatedAt time.Time +} + +type legacyRequestLogV2 struct { + ID string + CreatedAt time.Time + AccessKeyID uint + GroupID uint + Protocol string + ClientModel string + UpstreamModel string + Status string + StatusCode int + DurationMs int64 + ErrorCode string + ErrorSummary string + AffinityHit bool + InputTokens int64 + OutputTokens int64 + CacheReadTokens int64 + CacheWrite5MTokens int64 `gorm:"column:cache_write_5m_tokens"` + CacheWrite1HTokens int64 `gorm:"column:cache_write_1h_tokens"` + Cost float64 + UsageState string + CostState string + Attempts models.JSON +} + +type legacySystemSettingV2 struct { + Key string + Value string + UpdatedAt time.Time +} + +type legacyJobV2 struct { + ID string + Type string + Status string + Payload models.JSON + Result models.JSON + Error string + CreatedAt time.Time + StartedAt *time.Time + FinishedAt *time.Time +} + +type legacyControlOperationV2 struct { + CommitSequence uint64 + OperationID string + IdempotencyKey string + DigestVersion uint + RequestDigest []byte + OperationKind string + ResourceIdentity string + CanonicalResult []byte + RequiredStages models.JSON + LastCompletedStage string + FailedStage string + CompletedAt *time.Time + CompactedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +func migrateSchemaV2ToV3(db *gorm.DB) error { + if err := createSchemaV3MigrationTables(db); err != nil { + return err + } + if err := copyGroupsToSchemaV3(db); err != nil { + return err + } + if err := copyUpstreamKeysToSchemaV3(db); err != nil { + return err + } + if err := copyAccessKeysToSchemaV3(db); err != nil { + return err + } + if err := copyModelPricesToSchemaV3(db); err != nil { + return err + } + if err := copyRequestLogsToSchemaV3(db); err != nil { + return err + } + if err := copySystemSettingsToSchemaV3(db); err != nil { + return err + } + if err := copyJobsToSchemaV3(db); err != nil { + return err + } + if err := copyControlOperationsToSchemaV3(db); err != nil { + return err + } + if err := backfillUsageStatsV3(db); err != nil { + return err + } + if err := replaceSchemaV2Tables(db); err != nil { + return err + } + if err := createSchemaV3Indexes(db); err != nil { + return err + } + if err := validateSchemaV3ForeignKeys(db); err != nil { + return err + } + + result := db.Model(&schemaInfo{}). + Where("version = ?", schemaV2Version). + Update("version", CurrentSchemaVersion) + if result.Error != nil { + return fmt.Errorf("update schema_info to version 3: %w", result.Error) + } + if result.RowsAffected != 1 { + return fmt.Errorf("update schema_info to version 3: version changed concurrently") + } + if err := replaceSchemaV3InfoTable(db); err != nil { + return err + } + return validateSchemaV3(db) +} + +func createSchemaV3MigrationTables(db *gorm.DB) error { + return createSchemaV3Tables(db, "__v3") +} + +func createSchemaV3Tables(db *gorm.DB, suffix string) error { + if suffix != "" && suffix != "__v3" { + return fmt.Errorf("create schema v3 tables: unsupported suffix %q", suffix) + } + statements := []string{ + `CREATE TABLE groups__v3 ( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar(255) NOT NULL, + upstream_url text NOT NULL, + protocols json NOT NULL, + models json NOT NULL, + convert_enabled numeric NOT NULL DEFAULT false, + weight_manual integer, + validation_model varchar(255), + config json, + enabled numeric NOT NULL DEFAULT true, + created_at_ms integer NOT NULL CHECK (created_at_ms >= 0), + updated_at_ms integer NOT NULL CHECK (updated_at_ms >= 0) + )`, + `CREATE TABLE upstream_keys__v3 ( + id integer PRIMARY KEY AUTOINCREMENT, + group_id integer NOT NULL, + key_value text NOT NULL, + key_hash varchar(128) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'active', + weight_manual integer, + created_at_ms integer NOT NULL CHECK (created_at_ms >= 0), + updated_at_ms integer NOT NULL CHECK (updated_at_ms >= 0), + CONSTRAINT chk_upstream_key_status + CHECK (status IN ('active','disabled')), + FOREIGN KEY (group_id) REFERENCES groups__v3(id) + ON UPDATE CASCADE ON DELETE CASCADE + )`, + `CREATE TABLE access_keys__v3 ( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar(255) NOT NULL, + key_value text NOT NULL, + key_hash varchar(128) NOT NULL, + key_suffix char(4) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'active', + filters json, + rpm_limit integer NOT NULL DEFAULT 0, + daily_cost_limit_nano_usd integer NOT NULL DEFAULT 0, + monthly_cost_limit_nano_usd integer NOT NULL DEFAULT 0, + created_at_ms integer NOT NULL CHECK (created_at_ms >= 0), + updated_at_ms integer NOT NULL CHECK (updated_at_ms >= 0), + CONSTRAINT chk_access_key_suffix + CHECK (key_suffix GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]'), + CONSTRAINT chk_access_key_status + CHECK (status IN ('active','disabled')), + CONSTRAINT chk_access_key_daily_cost_limit_nano + CHECK (daily_cost_limit_nano_usd >= 0), + CONSTRAINT chk_access_key_monthly_cost_limit_nano + CHECK (monthly_cost_limit_nano_usd >= 0) + )`, + `CREATE TABLE model_prices__v3 ( + id integer PRIMARY KEY AUTOINCREMENT, + pattern varchar(255) NOT NULL, + input_price_nano_usd_per_million_tokens integer, + output_price_nano_usd_per_million_tokens integer, + cache_read_price_nano_usd_per_million_tokens integer, + cache_write_5m_price_nano_usd_per_million_tokens integer, + cache_write_1h_price_nano_usd_per_million_tokens integer, + source varchar(32) NOT NULL, + created_at_ms integer NOT NULL CHECK (created_at_ms >= 0), + updated_at_ms integer NOT NULL CHECK (updated_at_ms >= 0), + CONSTRAINT chk_model_price_input_nano CHECK ( + input_price_nano_usd_per_million_tokens IS NULL OR + input_price_nano_usd_per_million_tokens >= 0 + ), + CONSTRAINT chk_model_price_output_nano CHECK ( + output_price_nano_usd_per_million_tokens IS NULL OR + output_price_nano_usd_per_million_tokens >= 0 + ), + CONSTRAINT chk_model_price_cache_read_nano CHECK ( + cache_read_price_nano_usd_per_million_tokens IS NULL OR + cache_read_price_nano_usd_per_million_tokens >= 0 + ), + CONSTRAINT chk_model_price_cache_write_5m_nano CHECK ( + cache_write_5m_price_nano_usd_per_million_tokens IS NULL OR + cache_write_5m_price_nano_usd_per_million_tokens >= 0 + ), + CONSTRAINT chk_model_price_cache_write_1h_nano CHECK ( + cache_write_1h_price_nano_usd_per_million_tokens IS NULL OR + cache_write_1h_price_nano_usd_per_million_tokens >= 0 + ), + CONSTRAINT chk_model_price_source CHECK (source = 'user') + )`, + `CREATE TABLE request_logs__v3 ( + id varchar(36) PRIMARY KEY NOT NULL, + completed_at_ms integer NOT NULL CHECK (completed_at_ms >= 0), + access_key_id integer NOT NULL, + group_id integer NOT NULL DEFAULT 0, + protocol varchar(32) NOT NULL, + client_model varchar(255) NOT NULL, + upstream_model varchar(255) NOT NULL, + status varchar(32) NOT NULL, + status_code integer NOT NULL, + duration_ms integer NOT NULL CHECK (duration_ms >= 0), + error_code varchar(64) NOT NULL DEFAULT '', + error_summary text NOT NULL DEFAULT '', + affinity_hit numeric NOT NULL DEFAULT false, + uncached_input_tokens integer NOT NULL DEFAULT 0 + CHECK (uncached_input_tokens >= 0), + output_tokens integer NOT NULL DEFAULT 0 CHECK (output_tokens >= 0), + cache_read_tokens integer NOT NULL DEFAULT 0 CHECK (cache_read_tokens >= 0), + cache_write_5m_tokens integer NOT NULL DEFAULT 0 + CHECK (cache_write_5m_tokens >= 0), + cache_write_1h_tokens integer NOT NULL DEFAULT 0 + CHECK (cache_write_1h_tokens >= 0), + estimated_cost_nano_usd integer NOT NULL DEFAULT 0, + usage_state varchar(32) NOT NULL DEFAULT 'not_applicable', + cost_state varchar(32) NOT NULL DEFAULT 'not_applicable', + attempts json, + CONSTRAINT chk_request_log_cost_nano + CHECK (estimated_cost_nano_usd >= 0), + CONSTRAINT chk_request_log_usage_state + CHECK (usage_state IN ('complete','partial','missing','not_applicable')), + CONSTRAINT chk_request_log_cost_state + CHECK (cost_state IN ('priced','unpriced','not_applicable')), + CONSTRAINT chk_request_log_usage_cost_state CHECK ( + (usage_state IN ('complete','partial') + AND cost_state IN ('priced','unpriced')) OR + (usage_state = 'missing' AND cost_state = 'unpriced') OR + (usage_state = 'not_applicable' AND cost_state = 'not_applicable') + ), + CONSTRAINT chk_request_log_nonpriced_cost_zero CHECK ( + cost_state = 'priced' OR estimated_cost_nano_usd = 0 + ) + )`, + `CREATE TABLE usage_stats__v3 ( + id integer PRIMARY KEY AUTOINCREMENT, + bucket_start_ms integer NOT NULL CHECK (bucket_start_ms >= 0), + access_key_id integer NOT NULL, + group_id integer NOT NULL, + model varchar(255) NOT NULL, + request_count integer NOT NULL DEFAULT 0 CHECK (request_count >= 0), + success_count integer NOT NULL DEFAULT 0 CHECK (success_count >= 0), + failure_count integer NOT NULL DEFAULT 0 CHECK (failure_count >= 0), + uncached_input_tokens integer NOT NULL DEFAULT 0 + CHECK (uncached_input_tokens >= 0), + output_tokens integer NOT NULL DEFAULT 0 CHECK (output_tokens >= 0), + cache_read_tokens integer NOT NULL DEFAULT 0 CHECK (cache_read_tokens >= 0), + cache_write_5m_tokens integer NOT NULL DEFAULT 0 + CHECK (cache_write_5m_tokens >= 0), + cache_write_1h_tokens integer NOT NULL DEFAULT 0 + CHECK (cache_write_1h_tokens >= 0), + estimated_cost_nano_usd integer NOT NULL DEFAULT 0, + usage_missing_count integer NOT NULL DEFAULT 0 + CHECK (usage_missing_count >= 0), + partial_count integer NOT NULL DEFAULT 0 CHECK (partial_count >= 0), + unpriced_request_count integer NOT NULL DEFAULT 0 + CHECK (unpriced_request_count >= 0), + CONSTRAINT chk_usage_stat_cost_nano + CHECK (estimated_cost_nano_usd >= 0), + CONSTRAINT chk_usage_stat_request_outcome + CHECK (request_count = success_count + failure_count) + )`, + `CREATE TABLE system_settings__v3 ( + key varchar(255) PRIMARY KEY NOT NULL, + value text NOT NULL, + updated_at_ms integer NOT NULL CHECK (updated_at_ms >= 0) + )`, + `CREATE TABLE jobs__v3 ( + id varchar(36) PRIMARY KEY NOT NULL, + type varchar(64) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'pending', + payload json, + result json, + error text, + created_at_ms integer NOT NULL CHECK (created_at_ms >= 0), + started_at_ms integer CHECK (started_at_ms IS NULL OR started_at_ms >= 0), + finished_at_ms integer CHECK (finished_at_ms IS NULL OR finished_at_ms >= 0) + )`, + `CREATE TABLE control_operations__v3 ( + commit_sequence integer PRIMARY KEY AUTOINCREMENT, + operation_id char(36) NOT NULL, + idempotency_key char(36) NOT NULL, + digest_version integer NOT NULL, + request_digest blob NOT NULL, + operation_kind varchar(32) NOT NULL, + resource_identity varchar(64) NOT NULL, + canonical_result blob, + required_stages json, + last_completed_stage varchar(32), + failed_stage varchar(32), + completed_at_ms integer + CHECK (completed_at_ms IS NULL OR completed_at_ms >= 0), + compacted_at_ms integer + CHECK (compacted_at_ms IS NULL OR compacted_at_ms >= 0), + created_at_ms integer NOT NULL CHECK (created_at_ms >= 0), + updated_at_ms integer NOT NULL CHECK (updated_at_ms >= 0), + CONSTRAINT chk_control_operation_digest_version + CHECK (digest_version > 0), + CONSTRAINT chk_control_operation_digest + CHECK (length(request_digest) = 32) + )`, + } + for _, statement := range statements { + statement = strings.ReplaceAll(statement, "__v3", suffix) + if err := db.Exec(statement).Error; err != nil { + return fmt.Errorf("create schema v3 table: %w", err) + } + } + return nil +} + +func copyGroupsToSchemaV3(db *gorm.DB) error { + return forEachLegacyRow[uint, legacyGroupV2]( + db, + "groups", + "id", + func(row legacyGroupV2) error { + createdAtMS, err := legacyTimeToMS("groups", row.ID, "created_at", row.CreatedAt) + if err != nil { + return err + } + updatedAtMS, err := legacyTimeToMS("groups", row.ID, "updated_at", row.UpdatedAt) + if err != nil { + return err + } + if err := db.Exec(`INSERT INTO groups__v3 ( + id, name, upstream_url, protocols, models, convert_enabled, weight_manual, + validation_model, config, enabled, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.ID, row.Name, row.UpstreamURL, row.Protocols, row.Models, + row.ConvertEnabled, row.WeightManual, row.ValidationModel, row.Config, + row.Enabled, createdAtMS, updatedAtMS, + ).Error; err != nil { + return schemaV3RowError("groups", row.ID, err) + } + return nil + }, + ) +} + +func copyUpstreamKeysToSchemaV3(db *gorm.DB) error { + return forEachLegacyRow[uint, legacyUpstreamKeyV2]( + db, + "upstream_keys", + "id", + func(row legacyUpstreamKeyV2) error { + createdAtMS, err := legacyTimeToMS("upstream_keys", row.ID, "created_at", row.CreatedAt) + if err != nil { + return err + } + updatedAtMS, err := legacyTimeToMS("upstream_keys", row.ID, "updated_at", row.UpdatedAt) + if err != nil { + return err + } + if err := db.Exec(`INSERT INTO upstream_keys__v3 ( + id, group_id, key_value, key_hash, status, weight_manual, + created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + row.ID, row.GroupID, row.KeyValue, row.KeyHash, row.Status, + row.WeightManual, createdAtMS, updatedAtMS, + ).Error; err != nil { + return schemaV3RowError("upstream_keys", row.ID, err) + } + return nil + }, + ) +} + +func copyAccessKeysToSchemaV3(db *gorm.DB) error { + return forEachLegacyRow[uint, legacyAccessKeyV2]( + db, + "access_keys", + "id", + func(row legacyAccessKeyV2) error { + createdAtMS, err := legacyTimeToMS("access_keys", row.ID, "created_at", row.CreatedAt) + if err != nil { + return err + } + updatedAtMS, err := legacyTimeToMS("access_keys", row.ID, "updated_at", row.UpdatedAt) + if err != nil { + return err + } + dailyCostLimitNanoUSD, err := legacyUSDToNano( + "access_keys", + row.ID, + "daily_cost_limit", + row.DailyCostLimit, + ) + if err != nil { + return err + } + monthlyCostLimitNanoUSD, err := legacyUSDToNano( + "access_keys", + row.ID, + "monthly_cost_limit", + row.MonthlyCostLimit, + ) + if err != nil { + return err + } + if err := db.Exec(`INSERT INTO access_keys__v3 ( + id, name, key_value, key_hash, key_suffix, status, filters, rpm_limit, + daily_cost_limit_nano_usd, monthly_cost_limit_nano_usd, + created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.ID, row.Name, row.KeyValue, row.KeyHash, row.KeySuffix, row.Status, + row.Filters, row.RPMLimit, dailyCostLimitNanoUSD, monthlyCostLimitNanoUSD, + createdAtMS, updatedAtMS, + ).Error; err != nil { + return schemaV3RowError("access_keys", row.ID, err) + } + return nil + }, + ) +} + +func copyModelPricesToSchemaV3(db *gorm.DB) error { + return forEachLegacyRow[uint, legacyModelPriceV2]( + db, + "model_prices", + "id", + func(row legacyModelPriceV2) error { + createdAtMS, err := legacyTimeToMS("model_prices", row.ID, "created_at", row.CreatedAt) + if err != nil { + return err + } + updatedAtMS, err := legacyTimeToMS("model_prices", row.ID, "updated_at", row.UpdatedAt) + if err != nil { + return err + } + input, err := legacyOptionalUSDToNano("model_prices", row.ID, "input_price", row.InputPrice) + if err != nil { + return err + } + output, err := legacyOptionalUSDToNano("model_prices", row.ID, "output_price", row.OutputPrice) + if err != nil { + return err + } + cacheRead, err := legacyOptionalUSDToNano( + "model_prices", row.ID, "cache_read_price", row.CacheReadPrice, + ) + if err != nil { + return err + } + cacheWrite5M, err := legacyOptionalUSDToNano( + "model_prices", row.ID, "cache_write_5m_price", row.CacheWrite5MPrice, + ) + if err != nil { + return err + } + cacheWrite1H, err := legacyOptionalUSDToNano( + "model_prices", row.ID, "cache_write_1h_price", row.CacheWrite1HPrice, + ) + if err != nil { + return err + } + if err := db.Exec(`INSERT INTO model_prices__v3 ( + id, pattern, + input_price_nano_usd_per_million_tokens, + output_price_nano_usd_per_million_tokens, + cache_read_price_nano_usd_per_million_tokens, + cache_write_5m_price_nano_usd_per_million_tokens, + cache_write_1h_price_nano_usd_per_million_tokens, + source, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.ID, row.Pattern, input, output, cacheRead, cacheWrite5M, + cacheWrite1H, row.Source, createdAtMS, updatedAtMS, + ).Error; err != nil { + return schemaV3RowError("model_prices", row.ID, err) + } + return nil + }, + ) +} + +func copyRequestLogsToSchemaV3(db *gorm.DB) error { + return forEachLegacyRow[string, legacyRequestLogV2]( + db, + "request_logs", + "id", + func(row legacyRequestLogV2) error { + if err := validateLegacyRequestLog(row); err != nil { + return schemaV3RowError("request_logs", row.ID, err) + } + completedAtMS, err := legacyTimeToMS( + "request_logs", row.ID, "created_at", row.CreatedAt, + ) + if err != nil { + return err + } + cost, err := legacyUSDToNano( + "request_logs", row.ID, "cost", row.Cost, + ) + if err != nil { + return err + } + if err := usagecost.Validate( + usage.State(row.UsageState), + pricing.CostState(row.CostState), + cost, + ); err != nil { + return schemaV3RowError( + "request_logs", + row.ID, + fmt.Errorf("validate usage/cost state: %w", err), + ) + } + if err := db.Exec(`INSERT INTO request_logs__v3 ( + id, completed_at_ms, access_key_id, group_id, protocol, client_model, + upstream_model, status, status_code, duration_ms, error_code, + error_summary, affinity_hit, uncached_input_tokens, output_tokens, + cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens, + estimated_cost_nano_usd, usage_state, cost_state, attempts + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.ID, completedAtMS, row.AccessKeyID, row.GroupID, row.Protocol, + row.ClientModel, row.UpstreamModel, row.Status, row.StatusCode, + row.DurationMs, row.ErrorCode, row.ErrorSummary, row.AffinityHit, + row.InputTokens, row.OutputTokens, row.CacheReadTokens, + row.CacheWrite5MTokens, row.CacheWrite1HTokens, cost, + row.UsageState, row.CostState, row.Attempts, + ).Error; err != nil { + return schemaV3RowError("request_logs", row.ID, err) + } + return nil + }, + ) +} + +func copySystemSettingsToSchemaV3(db *gorm.DB) error { + return forEachLegacyRow[string, legacySystemSettingV2]( + db, + "system_settings", + "key", + func(row legacySystemSettingV2) error { + updatedAtMS, err := legacyTimeToMS( + "system_settings", row.Key, "updated_at", row.UpdatedAt, + ) + if err != nil { + return err + } + if err := db.Exec(`INSERT INTO system_settings__v3 ( + key, value, updated_at_ms + ) VALUES (?, ?, ?)`, row.Key, row.Value, updatedAtMS).Error; err != nil { + return schemaV3RowError("system_settings", row.Key, err) + } + return nil + }, + ) +} + +func copyJobsToSchemaV3(db *gorm.DB) error { + return forEachLegacyRow[string, legacyJobV2]( + db, + "jobs", + "id", + func(row legacyJobV2) error { + createdAtMS, err := legacyTimeToMS("jobs", row.ID, "created_at", row.CreatedAt) + if err != nil { + return err + } + startedAtMS, err := legacyOptionalTimeToMS( + "jobs", row.ID, "started_at", row.StartedAt, + ) + if err != nil { + return err + } + finishedAtMS, err := legacyOptionalTimeToMS( + "jobs", row.ID, "finished_at", row.FinishedAt, + ) + if err != nil { + return err + } + if err := db.Exec(`INSERT INTO jobs__v3 ( + id, type, status, payload, result, error, created_at_ms, + started_at_ms, finished_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.ID, row.Type, row.Status, row.Payload, row.Result, row.Error, + createdAtMS, startedAtMS, finishedAtMS, + ).Error; err != nil { + return schemaV3RowError("jobs", row.ID, err) + } + return nil + }, + ) +} + +func copyControlOperationsToSchemaV3(db *gorm.DB) error { + return forEachLegacyRow[uint64, legacyControlOperationV2]( + db, + "control_operations", + "commit_sequence", + func(row legacyControlOperationV2) error { + createdAtMS, err := legacyTimeToMS( + "control_operations", row.CommitSequence, "created_at", row.CreatedAt, + ) + if err != nil { + return err + } + updatedAtMS, err := legacyTimeToMS( + "control_operations", row.CommitSequence, "updated_at", row.UpdatedAt, + ) + if err != nil { + return err + } + completedAtMS, err := legacyOptionalTimeToMS( + "control_operations", row.CommitSequence, "completed_at", row.CompletedAt, + ) + if err != nil { + return err + } + compactedAtMS, err := legacyOptionalTimeToMS( + "control_operations", row.CommitSequence, "compacted_at", row.CompactedAt, + ) + if err != nil { + return err + } + if err := db.Exec(`INSERT INTO control_operations__v3 ( + commit_sequence, operation_id, idempotency_key, digest_version, + request_digest, operation_kind, resource_identity, canonical_result, + required_stages, last_completed_stage, failed_stage, completed_at_ms, + compacted_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.CommitSequence, row.OperationID, row.IdempotencyKey, + row.DigestVersion, row.RequestDigest, row.OperationKind, + row.ResourceIdentity, row.CanonicalResult, row.RequiredStages, + row.LastCompletedStage, row.FailedStage, completedAtMS, + compactedAtMS, createdAtMS, updatedAtMS, + ).Error; err != nil { + return schemaV3RowError("control_operations", row.CommitSequence, err) + } + return nil + }, + ) +} + +func backfillUsageStatsV3(db *gorm.DB) error { + const statement = ` +INSERT INTO usage_stats__v3 ( + bucket_start_ms, + access_key_id, + group_id, + model, + request_count, + success_count, + failure_count, + uncached_input_tokens, + cache_read_tokens, + cache_write_5m_tokens, + cache_write_1h_tokens, + output_tokens, + estimated_cost_nano_usd, + usage_missing_count, + partial_count, + unpriced_request_count +) +SELECT + completed_at_ms / 3600000 * 3600000, + access_key_id, + group_id, + client_model, + COUNT(*), + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), + SUM(CASE WHEN status IN ('error', 'incomplete', 'canceled') THEN 1 ELSE 0 END), + SUM(CASE WHEN usage_state IN ('complete', 'partial') + THEN uncached_input_tokens ELSE 0 END), + SUM(CASE WHEN usage_state IN ('complete', 'partial') + THEN cache_read_tokens ELSE 0 END), + SUM(CASE WHEN usage_state IN ('complete', 'partial') + THEN cache_write_5m_tokens ELSE 0 END), + SUM(CASE WHEN usage_state IN ('complete', 'partial') + THEN cache_write_1h_tokens ELSE 0 END), + SUM(CASE WHEN usage_state IN ('complete', 'partial') + THEN output_tokens ELSE 0 END), + SUM(CASE WHEN usage_state IN ('complete', 'partial') AND cost_state = 'priced' + THEN estimated_cost_nano_usd ELSE 0 END), + SUM(CASE WHEN usage_state = 'missing' THEN 1 ELSE 0 END), + SUM(CASE WHEN usage_state = 'partial' THEN 1 ELSE 0 END), + SUM(CASE WHEN usage_state IN ('complete', 'partial') AND cost_state = 'unpriced' + THEN 1 ELSE 0 END) +FROM request_logs__v3 +GROUP BY + completed_at_ms / 3600000 * 3600000, + access_key_id, + group_id, + client_model` + if err := db.Exec(statement).Error; err != nil { + return fmt.Errorf("backfill schema v3 usage_stats from request_logs: %w", err) + } + return nil +} + +func replaceSchemaV2Tables(db *gorm.DB) error { + for _, table := range []string{ + "upstream_keys", + "groups", + "access_keys", + "usage_stats", + "request_logs", + "model_prices", + "system_settings", + "jobs", + "control_operations", + } { + if err := db.Exec("DROP TABLE " + table).Error; err != nil { + return fmt.Errorf("drop schema v2 table %s: %w", table, err) + } + } + for _, table := range []string{ + "groups", + "upstream_keys", + "access_keys", + "request_logs", + "usage_stats", + "model_prices", + "system_settings", + "jobs", + "control_operations", + } { + if err := db.Exec( + "ALTER TABLE " + table + "__v3 RENAME TO " + table, + ).Error; err != nil { + return fmt.Errorf("rename schema v3 table %s: %w", table, err) + } + } + return nil +} + +func createSchemaV3Indexes(db *gorm.DB) error { + for _, statement := range schemaV3IndexStatements() { + if err := db.Exec(statement).Error; err != nil { + return fmt.Errorf("create schema v3 index: %w", err) + } + } + return nil +} + +func schemaV3IndexStatements() []string { + return []string{ + `CREATE UNIQUE INDEX idx_groups_name ON groups(name)`, + `CREATE UNIQUE INDEX idx_upstream_keys_group_hash + ON upstream_keys(group_id, key_hash)`, + `CREATE UNIQUE INDEX idx_access_keys_key_hash ON access_keys(key_hash)`, + `CREATE INDEX idx_request_logs_completed_id + ON request_logs(completed_at_ms DESC, id DESC)`, + `CREATE INDEX idx_request_logs_access_completed_id + ON request_logs(access_key_id, completed_at_ms DESC, id DESC)`, + `CREATE INDEX idx_request_logs_status_completed_id + ON request_logs(status, completed_at_ms DESC, id DESC)`, + `CREATE INDEX idx_request_logs_model_completed_id + ON request_logs(client_model, completed_at_ms DESC, id DESC)`, + `CREATE UNIQUE INDEX idx_usage_stats_bucket_access_group_model + ON usage_stats(bucket_start_ms, access_key_id, group_id, model)`, + `CREATE UNIQUE INDEX idx_model_prices_pattern ON model_prices(pattern)`, + `CREATE INDEX idx_jobs_type ON jobs(type)`, + `CREATE INDEX idx_jobs_status ON jobs(status)`, + `CREATE INDEX idx_jobs_created_at_ms ON jobs(created_at_ms)`, + `CREATE UNIQUE INDEX idx_control_operations_operation_id + ON control_operations(operation_id)`, + `CREATE UNIQUE INDEX idx_control_operations_idempotency_key + ON control_operations(idempotency_key)`, + `CREATE INDEX idx_control_operations_completed_at_ms + ON control_operations(completed_at_ms)`, + } +} + +func validateSchemaV3ForeignKeys(db *gorm.DB) error { + var violations []struct { + Table string + RowID int64 `gorm:"column:rowid"` + } + if err := db.Raw("PRAGMA foreign_key_check").Scan(&violations).Error; err != nil { + return fmt.Errorf("validate schema v3 foreign keys: %w", err) + } + if len(violations) != 0 { + return fmt.Errorf( + "validate schema v3 foreign keys: %d violation(s)", + len(violations), + ) + } + return nil +} + +func forEachLegacyRow[K ~string | ~uint | ~uint64, R any]( + db *gorm.DB, + table string, + keyColumn string, + visit func(R) error, +) error { + var identities []K + if err := db.Table(table).Order(keyColumn+" ASC"). + Pluck(keyColumn, &identities).Error; err != nil { + return fmt.Errorf("read schema v2 %s identities: %w", table, err) + } + for _, identity := range identities { + var row R + if err := db.Table(table). + Where(keyColumn+" = ?", identity). + Take(&row).Error; err != nil { + return schemaV3RowError(table, identity, err) + } + if err := visit(row); err != nil { + return err + } + } + return nil +} + +func legacyTimeToMS( + table string, + identity any, + field string, + value time.Time, +) (int64, error) { + result, err := epochms.FromTime(value) + if err != nil { + return 0, schemaV3RowError( + table, + identity, + fmt.Errorf("convert %s to epoch milliseconds: %w", field, err), + ) + } + return result, nil +} + +func legacyOptionalTimeToMS( + table string, + identity any, + field string, + value *time.Time, +) (*int64, error) { + if value == nil { + return nil, nil + } + result, err := legacyTimeToMS(table, identity, field, *value) + if err != nil { + return nil, err + } + return &result, nil +} + +func legacyOptionalUSDToNano( + table string, + identity any, + field string, + value *float64, +) (*int64, error) { + if value == nil { + return nil, nil + } + result, err := legacyUSDToNano(table, identity, field, *value) + if err != nil { + return nil, err + } + return &result, nil +} + +func legacyUSDToNano( + table string, + identity any, + field string, + value float64, +) (int64, error) { + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 { + return 0, schemaV3RowError( + table, + identity, + fmt.Errorf("convert %s to nano USD: invalid non-negative finite amount", field), + ) + } + + decimal := strconv.FormatFloat(value, 'g', -1, 64) + rational := new(big.Rat) + if _, ok := rational.SetString(decimal); !ok || rational.Sign() < 0 { + return 0, schemaV3RowError( + table, + identity, + fmt.Errorf("convert %s to nano USD: invalid decimal", field), + ) + } + numerator := new(big.Int).Mul(rational.Num(), big.NewInt(1_000_000_000)) + denominator := rational.Denom() + rounded, remainder := new(big.Int), new(big.Int) + rounded.QuoRem(numerator, denominator, remainder) + if new(big.Int).Lsh(remainder, 1).Cmp(denominator) >= 0 { + rounded.Add(rounded, big.NewInt(1)) + } + if rounded.Sign() < 0 || !rounded.IsInt64() { + return 0, schemaV3RowError( + table, + identity, + fmt.Errorf("convert %s to nano USD: amount exceeds int64", field), + ) + } + + normalized := pricing.FormatUSD(pricing.NanoUSD(rounded.Int64())) + parsed, err := pricing.ParseUSD(normalized) + if err != nil { + return 0, schemaV3RowError( + table, + identity, + fmt.Errorf("convert %s to nano USD: %w", field, err), + ) + } + return int64(parsed), nil +} + +func validateLegacyRequestLog(row legacyRequestLogV2) error { + switch row.Status { + case string(telemetry.RequestStatusSuccess), + string(telemetry.RequestStatusError), + string(telemetry.RequestStatusIncomplete), + string(telemetry.RequestStatusCanceled): + default: + return fmt.Errorf("invalid request status %q", row.Status) + } + if row.DurationMs < 0 { + return fmt.Errorf("negative duration_ms") + } + for name, value := range map[string]int64{ + "input_tokens": row.InputTokens, + "output_tokens": row.OutputTokens, + "cache_read_tokens": row.CacheReadTokens, + "cache_write_5m_tokens": row.CacheWrite5MTokens, + "cache_write_1h_tokens": row.CacheWrite1HTokens, + } { + if value < 0 { + return fmt.Errorf("negative %s", name) + } + } + return nil +} + +func schemaV3RowError(table string, identity any, err error) error { + return fmt.Errorf("migrate %s row %v to schema v3: %w", table, identity, err) +} + +func ensureSchemaV2TablesForV1(db *gorm.DB) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS groups ( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar(255) NOT NULL, + upstream_url text NOT NULL, + protocols json NOT NULL, + models json NOT NULL, + convert_enabled numeric NOT NULL DEFAULT false, + weight_manual integer, + validation_model varchar(255), + config json, + enabled numeric NOT NULL DEFAULT true, + created_at datetime, + updated_at datetime + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name ON groups(name)`, + `CREATE TABLE IF NOT EXISTS upstream_keys ( + id integer PRIMARY KEY AUTOINCREMENT, + group_id integer NOT NULL, + key_value text NOT NULL, + key_hash varchar(128) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'active', + weight_manual integer, + request_count integer NOT NULL DEFAULT 0, + tokens_total integer NOT NULL DEFAULT 0, + cost_total real NOT NULL DEFAULT 0, + created_at datetime, + updated_at datetime, + CONSTRAINT chk_upstream_key_status + CHECK (status IN ('active','disabled')), + FOREIGN KEY (group_id) REFERENCES groups(id) + ON UPDATE CASCADE ON DELETE CASCADE + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_upstream_keys_group_hash + ON upstream_keys(group_id, key_hash)`, + `CREATE TABLE IF NOT EXISTS access_keys ( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar(255) NOT NULL, + key_value text NOT NULL, + key_hash varchar(128) NOT NULL, + key_suffix char(4) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'active', + filters json, + rpm_limit integer NOT NULL DEFAULT 0, + daily_cost_limit real NOT NULL DEFAULT 0, + monthly_cost_limit real NOT NULL DEFAULT 0, + created_at datetime, + updated_at datetime, + CONSTRAINT chk_access_key_suffix + CHECK (key_suffix GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]'), + CONSTRAINT chk_access_key_status + CHECK (status IN ('active','disabled')) + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_access_keys_key_hash + ON access_keys(key_hash)`, + `CREATE TABLE IF NOT EXISTS request_logs ( + id varchar(36) PRIMARY KEY NOT NULL, + created_at datetime NOT NULL, + access_key_id integer NOT NULL, + group_id integer NOT NULL DEFAULT 0, + protocol varchar(32) NOT NULL, + client_model varchar(255) NOT NULL, + upstream_model varchar(255) NOT NULL, + status varchar(32) NOT NULL, + status_code integer NOT NULL, + duration_ms integer NOT NULL, + error_code varchar(64) NOT NULL DEFAULT '', + error_summary text NOT NULL DEFAULT '', + affinity_hit numeric NOT NULL DEFAULT false, + input_tokens integer NOT NULL DEFAULT 0, + output_tokens integer NOT NULL DEFAULT 0, + cache_read_tokens integer NOT NULL DEFAULT 0, + cache_write_5m_tokens integer NOT NULL DEFAULT 0, + cache_write_1h_tokens integer NOT NULL DEFAULT 0, + cost real NOT NULL DEFAULT 0, + usage_state varchar(32) NOT NULL DEFAULT 'not_applicable', + cost_state varchar(32) NOT NULL DEFAULT 'not_applicable', + attempts json, + CONSTRAINT chk_request_log_usage_state + CHECK (usage_state IN ('complete','partial','missing','not_applicable')), + CONSTRAINT chk_request_log_cost_state + CHECK (cost_state IN ('priced','unpriced','not_applicable')) + )`, + `CREATE TABLE IF NOT EXISTS usage_stats ( + id integer PRIMARY KEY AUTOINCREMENT, + hour_bucket datetime NOT NULL, + group_id integer NOT NULL, + model varchar(255) NOT NULL, + request_count integer NOT NULL DEFAULT 0, + success_count integer NOT NULL DEFAULT 0, + failure_count integer NOT NULL DEFAULT 0, + input_tokens integer NOT NULL DEFAULT 0, + output_tokens integer NOT NULL DEFAULT 0, + cache_read_tokens integer NOT NULL DEFAULT 0, + cache_write_5m_tokens integer NOT NULL DEFAULT 0, + cache_write_1h_tokens integer NOT NULL DEFAULT 0, + cost real NOT NULL DEFAULT 0, + usage_missing_count integer NOT NULL DEFAULT 0, + partial_count integer NOT NULL DEFAULT 0, + unpriced_request_count integer NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS model_prices ( + id integer PRIMARY KEY AUTOINCREMENT, + pattern varchar(255) NOT NULL, + input_price real, + output_price real, + cache_read_price real, + cache_write_5m_price real, + cache_write_1h_price real, + source varchar(32) NOT NULL, + created_at datetime, + updated_at datetime, + CONSTRAINT chk_model_price_source CHECK (source = 'user') + )`, + `CREATE TABLE IF NOT EXISTS system_settings ( + key varchar(255) PRIMARY KEY NOT NULL, + value text NOT NULL, + updated_at datetime + )`, + `CREATE TABLE IF NOT EXISTS jobs ( + id varchar(36) PRIMARY KEY NOT NULL, + type varchar(64) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'pending', + payload json, + result json, + error text, + created_at datetime NOT NULL, + started_at datetime, + finished_at datetime + )`, + `CREATE TABLE IF NOT EXISTS control_operations ( + commit_sequence integer PRIMARY KEY AUTOINCREMENT, + operation_id char(36) NOT NULL, + idempotency_key char(36) NOT NULL, + digest_version integer NOT NULL, + request_digest blob NOT NULL, + operation_kind varchar(32) NOT NULL, + resource_identity varchar(64) NOT NULL, + canonical_result blob, + required_stages json, + last_completed_stage varchar(32), + failed_stage varchar(32), + completed_at datetime, + compacted_at datetime, + created_at datetime, + updated_at datetime, + CONSTRAINT chk_control_operation_digest_version + CHECK (digest_version > 0), + CONSTRAINT chk_control_operation_digest + CHECK (length(request_digest) = 32) + )`, + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + return fmt.Errorf("prepare schema v2 during version 1 upgrade: %w", err) + } + } + return nil +} diff --git a/internal/storage/schema_v3_test.go b/internal/storage/schema_v3_test.go new file mode 100644 index 00000000..d57df2c5 --- /dev/null +++ b/internal/storage/schema_v3_test.go @@ -0,0 +1,1101 @@ +package storage_test + +import ( + "math" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "gorm.io/gorm" + + "gpt-load/internal/storage" +) + +type schemaV3Column struct { + Name string + Type string + NotNull int `gorm:"column:notnull"` + DefaultValue *string `gorm:"column:dflt_value"` +} + +func TestAutoMigrateCreatesSchemaV3IntegerStorage(t *testing.T) { + db := openSchemaV3TestDatabase(t) + if err := storage.AutoMigrate(db); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + if storage.CurrentSchemaVersion != 3 { + t.Fatalf("CurrentSchemaVersion = %d, want 3", storage.CurrentSchemaVersion) + } + + for table, columns := range map[string][]string{ + "groups": {"created_at_ms", "updated_at_ms"}, + "upstream_keys": {"created_at_ms", "updated_at_ms"}, + "access_keys": {"created_at_ms", "updated_at_ms"}, + "model_prices": {"created_at_ms", "updated_at_ms"}, + "request_logs": {"completed_at_ms"}, + "system_settings": {"updated_at_ms"}, + "jobs": {"created_at_ms", "started_at_ms", "finished_at_ms"}, + "control_operations": {"completed_at_ms", "compacted_at_ms", "created_at_ms", "updated_at_ms"}, + } { + got := schemaV3Columns(t, db, table) + for _, name := range columns { + column, ok := got[name] + if !ok { + t.Errorf("%s.%s is missing", table, name) + continue + } + if !strings.EqualFold(column.Type, "INTEGER") { + t.Errorf("%s.%s type = %q, want INTEGER", table, name, column.Type) + } + oldName := strings.TrimSuffix(name, "_ms") + if _, exists := got[oldName]; exists { + t.Errorf("%s retains legacy absolute-time column %q", table, oldName) + } + } + } + + requestColumns := schemaV3Columns(t, db, "request_logs") + assertSchemaV3IntegerColumn(t, requestColumns, "estimated_cost_nano_usd", false) + if err := db.Exec(`INSERT INTO request_logs ( + id, completed_at_ms, access_key_id, group_id, protocol, client_model, + upstream_model, status, status_code, duration_ms, error_code, error_summary, + affinity_hit, uncached_input_tokens, output_tokens, cache_read_tokens, + cache_write_5m_tokens, cache_write_1h_tokens, estimated_cost_nano_usd, + usage_state, cost_state, attempts + ) VALUES ( + 'negative-cost', 1, 0, 0, 'openai-completions', '', '', 'success', 200, 1, + '', '', false, 0, 0, 0, 0, 0, -1, 'complete', 'priced', '[]' + )`).Error; err == nil { + t.Fatal("negative request log nano USD cost was accepted") + } + + accessColumns := schemaV3Columns(t, db, "access_keys") + assertSchemaV3IntegerColumn(t, accessColumns, "daily_cost_limit_nano_usd", false) + assertSchemaV3IntegerColumn(t, accessColumns, "monthly_cost_limit_nano_usd", false) + if err := db.Exec(`INSERT INTO access_keys ( + id, name, key_value, key_hash, key_suffix, status, rpm_limit, + daily_cost_limit_nano_usd, monthly_cost_limit_nano_usd, + created_at_ms, updated_at_ms + ) VALUES ( + 991, 'negative-limit', 'cipher', 'negative-limit-hash', '7f2a', 'active', + 0, -1, 0, 1, 1 + )`).Error; err == nil { + t.Fatal("negative access-key nano USD limit was accepted") + } + if err := db.Exec(`INSERT INTO access_keys ( + id, name, key_value, key_hash, key_suffix, status, rpm_limit, + daily_cost_limit_nano_usd, monthly_cost_limit_nano_usd, + created_at_ms, updated_at_ms + ) VALUES ( + 992, 'negative-monthly-limit', 'cipher', 'negative-monthly-limit-hash', + '7f2b', 'active', 0, 0, -1, 1, 1 + )`).Error; err == nil { + t.Fatal("negative access-key monthly nano USD limit was accepted") + } + + priceColumns := schemaV3Columns(t, db, "model_prices") + for _, name := range []string{ + "input_price_nano_usd_per_million_tokens", + "output_price_nano_usd_per_million_tokens", + "cache_read_price_nano_usd_per_million_tokens", + "cache_write_5m_price_nano_usd_per_million_tokens", + "cache_write_1h_price_nano_usd_per_million_tokens", + } { + assertSchemaV3IntegerColumn(t, priceColumns, name, true) + } + + upstreamColumns := schemaV3Columns(t, db, "upstream_keys") + for _, removed := range []string{"request_count", "tokens_total", "cost_total"} { + if _, ok := upstreamColumns[removed]; ok { + t.Errorf("upstream_keys retains removed column %q", removed) + } + } + + usageColumns := schemaV3Columns(t, db, "usage_stats") + for _, name := range []string{ + "bucket_start_ms", + "access_key_id", + "group_id", + "model", + "request_count", + "success_count", + "failure_count", + "uncached_input_tokens", + "cache_read_tokens", + "cache_write_5m_tokens", + "cache_write_1h_tokens", + "output_tokens", + "estimated_cost_nano_usd", + "usage_missing_count", + "partial_count", + "unpriced_request_count", + } { + if _, ok := usageColumns[name]; !ok { + t.Errorf("usage_stats.%s is missing", name) + } + } + assertSchemaV3IndexColumns(t, db, + "idx_usage_stats_bucket_access_group_model", + []string{"bucket_start_ms", "access_key_id", "group_id", "model"}, + ) + var foreignKeys []struct { + Table string + } + if err := db.Raw("PRAGMA foreign_key_list('usage_stats')").Scan(&foreignKeys).Error; err != nil { + t.Fatalf("inspect usage_stats foreign keys: %v", err) + } + if len(foreignKeys) != 0 { + t.Fatalf("usage_stats foreign keys = %+v, want none", foreignKeys) + } + + if err := storage.AutoMigrate(db); err != nil { + t.Fatalf("second AutoMigrate() error = %v", err) + } + var versions []uint + if err := db.Table("schema_info").Pluck("version", &versions).Error; err != nil { + t.Fatalf("read schema_info: %v", err) + } + if !reflect.DeepEqual(versions, []uint{3}) { + t.Fatalf("schema_info versions = %v, want [3]", versions) + } +} + +func TestSchemaV3FreshAndMigratedSchemasEnforceEquivalentConstraints(t *testing.T) { + factories := []struct { + name string + open func(*testing.T) *gorm.DB + }{ + { + name: "fresh", + open: func(t *testing.T) *gorm.DB { + db := openSchemaV3TestDatabase(t) + if err := storage.AutoMigrate(db); err != nil { + t.Fatalf("AutoMigrate(fresh) error = %v", err) + } + return db + }, + }, + { + name: "migrated", + open: func(t *testing.T) *gorm.DB { + db := openSchemaV3TestDatabase(t) + createSchemaV2Fixture(t, db) + if err := storage.AutoMigrate(db); err != nil { + t.Fatalf("AutoMigrate(v2) error = %v", err) + } + return db + }, + }, + } + invalidStatements := []struct { + name string + statement string + args []any + }{ + { + name: "negative absolute time", + statement: `INSERT INTO groups ( + id, name, upstream_url, protocols, models, convert_enabled, + enabled, created_at_ms, updated_at_ms + ) VALUES (901, 'negative-time', 'https://invalid.example', '[]', '[]', + false, true, -1, 1)`, + }, + { + name: "negative duration", + statement: schemaV3InvalidRequestInsert, + args: []any{"negative-duration", -1, 0}, + }, + { + name: "negative token", + statement: schemaV3InvalidRequestInsert, + args: []any{"negative-token", 0, -1}, + }, + { + name: "negative aggregate counter", + statement: `INSERT INTO usage_stats ( + bucket_start_ms, access_key_id, group_id, model, + request_count, success_count, failure_count + ) VALUES (3600000, 0, 0, '', -1, 0, 0)`, + }, + { + name: "aggregate outcome mismatch", + statement: `INSERT INTO usage_stats ( + bucket_start_ms, access_key_id, group_id, model, + request_count, success_count, failure_count + ) VALUES (7200000, 0, 0, '', 1, 0, 0)`, + }, + { + name: "invalid request usage cost combination", + statement: schemaV3InvalidUsageCostInsert, + args: []any{"missing-priced"}, + }, + } + + for _, factory := range factories { + t.Run(factory.name, func(t *testing.T) { + db := factory.open(t) + assertSchemaV3IndexColumns(t, db, + "idx_request_logs_completed_id", + []string{"completed_at_ms", "id"}, + ) + assertSchemaV3IndexColumns(t, db, + "idx_usage_stats_bucket_access_group_model", + []string{"bucket_start_ms", "access_key_id", "group_id", "model"}, + ) + for _, invalid := range invalidStatements { + t.Run(invalid.name, func(t *testing.T) { + if err := db.Exec(invalid.statement, invalid.args...).Error; err == nil { + t.Fatalf("%s schema accepted %s", factory.name, invalid.name) + } + }) + } + }) + } +} + +func TestAutoMigrateSchemaV3RejectsMissingConstraintsAndCriticalIndexes(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, *gorm.DB) + }{ + { + name: "critical index", + mutate: func(t *testing.T, db *gorm.DB) { + mustExecSchemaV3(t, db, `DROP INDEX idx_request_logs_completed_id`) + }, + }, + { + name: "table constraints", + mutate: func(t *testing.T, db *gorm.DB) { + mustExecSchemaV3(t, db, `ALTER TABLE usage_stats RENAME TO usage_stats_checked`) + mustExecSchemaV3(t, db, + `CREATE TABLE usage_stats AS SELECT * FROM usage_stats_checked WHERE 0`) + mustExecSchemaV3(t, db, `DROP TABLE usage_stats_checked`) + mustExecSchemaV3(t, db, `CREATE UNIQUE INDEX + idx_usage_stats_bucket_access_group_model + ON usage_stats(bucket_start_ms, access_key_id, group_id, model)`) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db := openSchemaV3TestDatabase(t) + if err := storage.AutoMigrate(db); err != nil { + t.Fatalf("AutoMigrate(fresh) error = %v", err) + } + test.mutate(t, db) + if err := storage.AutoMigrate(db); err == nil { + t.Fatal("AutoMigrate(corrupt v3) error = nil, want strict validation failure") + } + }) + } +} + +const schemaV3InvalidRequestInsert = `INSERT INTO request_logs ( + id, completed_at_ms, access_key_id, group_id, protocol, client_model, + upstream_model, status, status_code, duration_ms, error_code, error_summary, + affinity_hit, uncached_input_tokens, output_tokens, cache_read_tokens, + cache_write_5m_tokens, cache_write_1h_tokens, estimated_cost_nano_usd, + usage_state, cost_state, attempts +) VALUES ( + ?, 1, 0, 0, 'openai-completions', '', '', 'success', 200, ?, + '', '', false, ?, 0, 0, 0, 0, 0, 'complete', 'priced', '[]' +)` + +const schemaV3InvalidUsageCostInsert = `INSERT INTO request_logs ( + id, completed_at_ms, access_key_id, group_id, protocol, client_model, + upstream_model, status, status_code, duration_ms, error_code, error_summary, + affinity_hit, uncached_input_tokens, output_tokens, cache_read_tokens, + cache_write_5m_tokens, cache_write_1h_tokens, estimated_cost_nano_usd, + usage_state, cost_state, attempts +) VALUES ( + ?, 1, 0, 0, 'openai-completions', '', '', 'success', 200, 0, + '', '', false, 0, 0, 0, 0, 0, 0, 'missing', 'priced', '[]' +)` + +func TestAutoMigrateSchemaV3MigratesV2RowsAndBackfillsUsage(t *testing.T) { + db := openSchemaV3TestDatabase(t) + createSchemaV2Fixture(t, db) + + createdAt := time.Date(2026, 7, 30, 8, 15, 30, 123_000_000, time.UTC) + updatedAt := time.Date(2026, 7, 30, 9, 16, 31, 456_000_000, time.UTC) + completedAt := time.Date(2026, 7, 30, 10, 45, 12, 789_000_000, time.UTC) + finishedAt := time.Date(2026, 7, 30, 11, 0, 0, 999_000_000, time.UTC) + + mustExecSchemaV3(t, db, `INSERT INTO groups ( + id, name, upstream_url, protocols, models, convert_enabled, weight_manual, + validation_model, config, enabled, created_at, updated_at + ) VALUES (7, 'legacy-group', 'https://upstream.example/v1', + '["anthropic"]', '[{"id":"upstream-model","alias":"client-model"}]', true, 9, + 'validation-model', '{"retry":2}', false, ?, ?)`, createdAt, updatedAt) + mustExecSchemaV3(t, db, `INSERT INTO upstream_keys ( + id, group_id, key_value, key_hash, status, weight_manual, + request_count, tokens_total, cost_total, created_at, updated_at + ) VALUES (11, 7, 'cipher-upstream', 'hash-upstream', 'disabled', 3, + 91, 92, 93.5, ?, ?)`, createdAt, updatedAt) + mustExecSchemaV3(t, db, `INSERT INTO access_keys ( + id, name, key_value, key_hash, key_suffix, status, filters, rpm_limit, + daily_cost_limit, monthly_cost_limit, created_at, updated_at + ) VALUES (13, 'legacy-access', 'cipher-access', 'hash-access', '7f2a', 'active', + '{"groups":[7],"protocols":["anthropic"],"models":["client-model"]}', + 17, 18.0000000005, 19.5, ?, ?)`, createdAt, updatedAt) + mustExecSchemaV3(t, db, `INSERT INTO model_prices ( + id, pattern, input_price, output_price, cache_read_price, + cache_write_5m_price, cache_write_1h_price, source, created_at, updated_at + ) VALUES (17, 'model-*', 1.2345678905, 2.0000000004, NULL, + 0.0000000005, 4.5, 'user', ?, ?)`, createdAt, updatedAt) + mustExecSchemaV3(t, db, `INSERT INTO system_settings ( + key, value, updated_at + ) VALUES ('request_log.retention_days', '17', ?)`, updatedAt) + mustExecSchemaV3(t, db, `INSERT INTO jobs ( + id, type, status, payload, result, error, created_at, started_at, finished_at + ) VALUES ('job-1', 'discover', 'succeeded', '{"group_id":7}', '{"models":1}', + '', ?, NULL, ?)`, createdAt, finishedAt) + mustExecSchemaV3(t, db, `INSERT INTO control_operations ( + commit_sequence, operation_id, idempotency_key, digest_version, request_digest, + operation_kind, resource_identity, canonical_result, required_stages, + last_completed_stage, failed_stage, completed_at, compacted_at, created_at, updated_at + ) VALUES (23, '00000000-0000-0000-0000-000000000023', + '00000000-0000-0000-0000-000000000024', 1, zeroblob(32), 'group.create', + 'group:7', '{"id":7}', '["db","snapshot"]', 'db', '', NULL, ?, ?, ?)`, + finishedAt, createdAt, updatedAt) + + insertLegacyRequestLog(t, db, "request-complete", completedAt, 13, 7, + "client-model", "upstream-model", "success", 10, 20, 30, 40, 50, + 0.0000000005, "complete", "priced") + insertLegacyRequestLog(t, db, "request-partial", + completedAt.Add(5*time.Minute), 13, 7, + "client-model", "different-upstream", "error", 1, 2, 3, 4, 5, + 0, "partial", "unpriced") + insertLegacyRequestLog(t, db, "request-missing", + completedAt.Add(10*time.Minute), 0, 0, + "", "", "incomplete", 100, 200, 300, 400, 500, + 0, "missing", "unpriced") + insertLegacyRequestLog(t, db, "request-not-applicable", + completedAt.Add(14*time.Minute), 0, 0, + "", "", "canceled", 100, 200, 300, 400, 500, + 0, "not_applicable", "not_applicable") + + mustExecSchemaV3(t, db, `INSERT INTO usage_stats ( + hour_bucket, group_id, model, request_count, success_count, failure_count, + input_tokens, output_tokens, cache_read_tokens, cache_write_5m_tokens, + cache_write_1h_tokens, cost, usage_missing_count, partial_count, + unpriced_request_count + ) VALUES (?, 999, 'must-be-discarded', 999, 999, 0, 999, 999, 999, 999, 999, + 999, 0, 0, 0)`, completedAt.Truncate(time.Hour)) + + if err := storage.AutoMigrate(db); err != nil { + t.Fatalf("AutoMigrate(v2) error = %v", err) + } + + var version uint + if err := db.Raw("SELECT version FROM schema_info").Scan(&version).Error; err != nil { + t.Fatalf("read migrated schema version: %v", err) + } + if version != 3 { + t.Fatalf("schema version = %d, want 3", version) + } + + type migratedGroup struct { + Name string + Protocols string + Models string + Config string + Enabled bool + CreatedAtMS int64 `gorm:"column:created_at_ms"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms"` + } + var group migratedGroup + if err := db.Raw(`SELECT name, protocols, models, config, enabled, + created_at_ms, updated_at_ms FROM groups WHERE id = 7`).Scan(&group).Error; err != nil { + t.Fatalf("read migrated group: %v", err) + } + if group.Name != "legacy-group" || group.Protocols != `["anthropic"]` || + group.Models != `[{"id":"upstream-model","alias":"client-model"}]` || + group.Config != `{"retry":2}` || group.Enabled || + group.CreatedAtMS != createdAt.UnixMilli() || group.UpdatedAtMS != updatedAt.UnixMilli() { + t.Errorf("migrated group = %+v", group) + } + + type migratedCredential struct { + KeyValue string + KeyHash string + KeySuffix string + Filters string + DailyCostLimitNanoUSD int64 `gorm:"column:daily_cost_limit_nano_usd"` + MonthlyCostLimitNanoUSD int64 `gorm:"column:monthly_cost_limit_nano_usd"` + CreatedAtMS int64 `gorm:"column:created_at_ms"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms"` + } + var access migratedCredential + if err := db.Raw(`SELECT key_value, key_hash, key_suffix, filters, + daily_cost_limit_nano_usd, monthly_cost_limit_nano_usd, + created_at_ms, updated_at_ms FROM access_keys WHERE id = 13`).Scan(&access).Error; err != nil { + t.Fatalf("read migrated access key: %v", err) + } + if access.KeyValue != "cipher-access" || access.KeyHash != "hash-access" || + access.KeySuffix != "7f2a" || + access.Filters != `{"groups":[7],"protocols":["anthropic"],"models":["client-model"]}` || + access.DailyCostLimitNanoUSD != 18_000_000_001 || + access.MonthlyCostLimitNanoUSD != 19_500_000_000 || + access.CreatedAtMS != createdAt.UnixMilli() || access.UpdatedAtMS != updatedAt.UnixMilli() { + t.Errorf("migrated access key = %+v", access) + } + var upstream migratedCredential + if err := db.Raw(`SELECT key_value, key_hash, '' AS key_suffix, '' AS filters, + created_at_ms, updated_at_ms FROM upstream_keys WHERE id = 11`).Scan(&upstream).Error; err != nil { + t.Fatalf("read migrated upstream key: %v", err) + } + if upstream.KeyValue != "cipher-upstream" || upstream.KeyHash != "hash-upstream" || + upstream.CreatedAtMS != createdAt.UnixMilli() || upstream.UpdatedAtMS != updatedAt.UnixMilli() { + t.Errorf("migrated upstream key = %+v", upstream) + } + + type migratedPrice struct { + InputPrice *int64 `gorm:"column:input_price_nano_usd_per_million_tokens"` + OutputPrice *int64 `gorm:"column:output_price_nano_usd_per_million_tokens"` + CacheReadPrice *int64 `gorm:"column:cache_read_price_nano_usd_per_million_tokens"` + CacheWrite5MPrice *int64 `gorm:"column:cache_write_5m_price_nano_usd_per_million_tokens"` + CacheWrite1HPrice *int64 `gorm:"column:cache_write_1h_price_nano_usd_per_million_tokens"` + } + var price migratedPrice + if err := db.Raw(`SELECT + input_price_nano_usd_per_million_tokens, + output_price_nano_usd_per_million_tokens, + cache_read_price_nano_usd_per_million_tokens, + cache_write_5m_price_nano_usd_per_million_tokens, + cache_write_1h_price_nano_usd_per_million_tokens + FROM model_prices WHERE id = 17`).Scan(&price).Error; err != nil { + t.Fatalf("read migrated price: %v", err) + } + if price.InputPrice == nil || *price.InputPrice != 1_234_567_891 || + price.OutputPrice == nil || *price.OutputPrice != 2_000_000_000 || + price.CacheReadPrice != nil || + price.CacheWrite5MPrice == nil || *price.CacheWrite5MPrice != 1 || + price.CacheWrite1HPrice == nil || *price.CacheWrite1HPrice != 4_500_000_000 { + t.Errorf("migrated model price = %+v", price) + } + + type migratedRequest struct { + CompletedAtMS int64 `gorm:"column:completed_at_ms"` + EstimatedCostNanoUSD int64 `gorm:"column:estimated_cost_nano_usd"` + UncachedInputTokens int64 `gorm:"column:uncached_input_tokens"` + } + var request migratedRequest + if err := db.Raw(`SELECT completed_at_ms, estimated_cost_nano_usd, + uncached_input_tokens FROM request_logs WHERE id = 'request-complete'`). + Scan(&request).Error; err != nil { + t.Fatalf("read migrated request log: %v", err) + } + if request.CompletedAtMS != completedAt.UnixMilli() || + request.EstimatedCostNanoUSD != 1 || request.UncachedInputTokens != 10 { + t.Errorf("migrated request log = %+v", request) + } + + type migratedUsage struct { + BucketStartMS int64 `gorm:"column:bucket_start_ms"` + AccessKeyID uint `gorm:"column:access_key_id"` + GroupID uint `gorm:"column:group_id"` + Model string + RequestCount int64 + SuccessCount int64 + FailureCount int64 + UncachedInputTokens int64 `gorm:"column:uncached_input_tokens"` + OutputTokens int64 + CacheReadTokens int64 + CacheWrite5MTokens int64 `gorm:"column:cache_write_5m_tokens"` + CacheWrite1HTokens int64 `gorm:"column:cache_write_1h_tokens"` + EstimatedCostNanoUSD int64 `gorm:"column:estimated_cost_nano_usd"` + UsageMissingCount int64 + PartialCount int64 + UnpricedRequestCount int64 + } + var stats []migratedUsage + if err := db.Raw(`SELECT * FROM usage_stats + ORDER BY access_key_id, group_id, model`).Scan(&stats).Error; err != nil { + t.Fatalf("read migrated usage stats: %v", err) + } + wantStats := []migratedUsage{ + { + BucketStartMS: completedAt.Truncate(time.Hour).UnixMilli(), + RequestCount: 2, FailureCount: 2, UsageMissingCount: 1, + }, + { + BucketStartMS: completedAt.Truncate(time.Hour).UnixMilli(), + AccessKeyID: 13, GroupID: 7, Model: "client-model", + RequestCount: 2, SuccessCount: 1, FailureCount: 1, + UncachedInputTokens: 11, OutputTokens: 22, CacheReadTokens: 33, + CacheWrite5MTokens: 44, CacheWrite1HTokens: 55, + EstimatedCostNanoUSD: 1, PartialCount: 1, UnpricedRequestCount: 1, + }, + } + if !reflect.DeepEqual(stats, wantStats) { + t.Fatalf("migrated usage stats = %+v, want %+v", stats, wantStats) + } + + var job struct { + Payload string + Result string + StartedAtMS *int64 `gorm:"column:started_at_ms"` + FinishedAtMS *int64 `gorm:"column:finished_at_ms"` + } + if err := db.Raw(`SELECT payload, result, started_at_ms, finished_at_ms + FROM jobs WHERE id = 'job-1'`).Scan(&job).Error; err != nil { + t.Fatalf("read migrated job: %v", err) + } + if job.Payload != `{"group_id":7}` || job.Result != `{"models":1}` || + job.StartedAtMS != nil || job.FinishedAtMS == nil || + *job.FinishedAtMS != finishedAt.UnixMilli() { + t.Errorf("migrated job = %+v", job) + } + var operation struct { + CanonicalResult string + RequiredStages string + CompletedAtMS *int64 `gorm:"column:completed_at_ms"` + CompactedAtMS *int64 `gorm:"column:compacted_at_ms"` + } + if err := db.Raw(`SELECT canonical_result, required_stages, completed_at_ms, + compacted_at_ms FROM control_operations WHERE commit_sequence = 23`). + Scan(&operation).Error; err != nil { + t.Fatalf("read migrated control operation: %v", err) + } + if operation.CanonicalResult != `{"id":7}` || + operation.RequiredStages != `["db","snapshot"]` || + operation.CompletedAtMS != nil || operation.CompactedAtMS == nil || + *operation.CompactedAtMS != finishedAt.UnixMilli() { + t.Errorf("migrated control operation = %+v", operation) + } +} + +func TestAutoMigrateSchemaV3RejectsInvalidV2RowsAndRollsBack(t *testing.T) { + tests := []struct { + name string + table string + identity string + insertRow func(*testing.T, *gorm.DB) + }{ + { + name: "unparseable absolute time", table: "groups", identity: "31", + insertRow: func(t *testing.T, db *gorm.DB) { + mustExecSchemaV3(t, db, `INSERT INTO groups ( + id, name, upstream_url, protocols, models, convert_enabled, + config, enabled, created_at, updated_at + ) VALUES (31, 'bad-time', 'https://invalid.example', '[]', '[]', + false, '{}', true, 'not-an-instant', '2026-07-30T00:00:00Z')`) + }, + }, + { + name: "NaN price text", table: "model_prices", identity: "32", + insertRow: func(t *testing.T, db *gorm.DB) { + mustExecSchemaV3(t, db, `INSERT INTO model_prices ( + id, pattern, input_price, source, created_at, updated_at + ) VALUES (32, 'nan-price', 'NaN', 'user', + '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z')`) + }, + }, + { + name: "infinite price", table: "model_prices", identity: "33", + insertRow: func(t *testing.T, db *gorm.DB) { + mustExecSchemaV3(t, db, `INSERT INTO model_prices ( + id, pattern, input_price, source, created_at, updated_at + ) VALUES (33, 'infinite-price', 1e999, 'user', + '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z')`) + }, + }, + { + name: "negative price", table: "model_prices", identity: "34", + insertRow: func(t *testing.T, db *gorm.DB) { + mustExecSchemaV3(t, db, `INSERT INTO model_prices ( + id, pattern, input_price, source, created_at, updated_at + ) VALUES (34, 'negative-price', -0.1, 'user', + '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z')`) + }, + }, + { + name: "price overflows int64 nano USD", table: "model_prices", identity: "35", + insertRow: func(t *testing.T, db *gorm.DB) { + mustExecSchemaV3(t, db, `INSERT INTO model_prices ( + id, pattern, input_price, source, created_at, updated_at + ) VALUES (35, 'overflow-price', 10000000000, 'user', + '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z')`) + }, + }, + { + name: "negative access key daily limit", table: "access_keys", identity: "36", + insertRow: func(t *testing.T, db *gorm.DB) { + insertLegacyAccessKeyLimits(t, db, 36, -0.1, 0) + }, + }, + { + name: "NaN access key daily limit text", table: "access_keys", identity: "37", + insertRow: func(t *testing.T, db *gorm.DB) { + mustExecSchemaV3(t, db, `INSERT INTO access_keys ( + id, name, key_value, key_hash, key_suffix, status, rpm_limit, + daily_cost_limit, monthly_cost_limit, created_at, updated_at + ) VALUES ( + 37, 'nan-limit', 'cipher', 'nan-limit-hash', '7f2a', 'active', + 0, 'NaN', 0, '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z' + )`) + }, + }, + { + name: "infinite access key monthly limit", table: "access_keys", identity: "38", + insertRow: func(t *testing.T, db *gorm.DB) { + insertLegacyAccessKeyLimits(t, db, 38, 0, math.Inf(1)) + }, + }, + { + name: "access key daily limit overflows int64 nano USD", + table: "access_keys", identity: "39", + insertRow: func(t *testing.T, db *gorm.DB) { + insertLegacyAccessKeyLimits(t, db, 39, 10_000_000_000, 0) + }, + }, + { + name: "negative request cost", table: "request_logs", identity: "negative-cost", + insertRow: func(t *testing.T, db *gorm.DB) { + insertLegacyRequestLog(t, db, "negative-cost", + time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC), + 0, 0, "", "", "success", 0, 0, 0, 0, 0, + -0.1, "complete", "priced") + }, + }, + { + name: "request cost overflows int64 nano USD", table: "request_logs", identity: "overflow-cost", + insertRow: func(t *testing.T, db *gorm.DB) { + insertLegacyRequestLog(t, db, "overflow-cost", + time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC), + 0, 0, "", "", "success", 0, 0, 0, 0, 0, + 10_000_000_000, "complete", "priced") + }, + }, + { + name: "missing usage cannot be priced", + table: "request_logs", identity: "missing-priced", + insertRow: func(t *testing.T, db *gorm.DB) { + insertLegacyRequestLog(t, db, "missing-priced", + time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC), + 0, 0, "", "", "success", 0, 0, 0, 0, 0, + 0, "missing", "priced") + }, + }, + { + name: "unpriced request cost must be zero", + table: "request_logs", identity: "unpriced-nonzero", + insertRow: func(t *testing.T, db *gorm.DB) { + insertLegacyRequestLog(t, db, "unpriced-nonzero", + time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC), + 0, 0, "", "", "success", 0, 0, 0, 0, 0, + 0.000000001, "complete", "unpriced") + }, + }, + { + name: "not applicable request cost must be zero", + table: "request_logs", identity: "not-applicable-nonzero", + insertRow: func(t *testing.T, db *gorm.DB) { + insertLegacyRequestLog(t, db, "not-applicable-nonzero", + time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC), + 0, 0, "", "", "success", 0, 0, 0, 0, 0, + 0.000000001, "not_applicable", "not_applicable") + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db := openSchemaV3TestDatabase(t) + createSchemaV2Fixture(t, db) + test.insertRow(t, db) + + err := storage.AutoMigrate(db) + if err == nil { + t.Fatal("AutoMigrate(v2 invalid row) error = nil, want rollback") + } + if !strings.Contains(err.Error(), test.table) || + !strings.Contains(err.Error(), test.identity) { + t.Fatalf("migration error = %q, want table %q and identity %q", + err, test.table, test.identity) + } + for _, secret := range []string{"cipher-access", "cipher-upstream"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("migration error leaked credential: %v", err) + } + } + var version uint + if scanErr := db.Raw("SELECT version FROM schema_info").Scan(&version).Error; scanErr != nil { + t.Fatalf("read schema version after rollback: %v", scanErr) + } + if version != 2 { + t.Fatalf("schema version after rollback = %d, want 2", version) + } + if !db.Migrator().HasColumn("groups", "created_at") || + db.Migrator().HasColumn("groups", "created_at_ms") { + t.Fatal("failed migration did not restore the schema v2 tables") + } + }) + } +} + +func TestAutoMigrateSchemaV3PreservesNullableAbsoluteTimesAsNull(t *testing.T) { + db := openSchemaV3TestDatabase(t) + createSchemaV2Fixture(t, db) + mustExecSchemaV3(t, db, `INSERT INTO jobs ( + id, type, status, payload, result, error, created_at, started_at, finished_at + ) VALUES ('nullable-job', 'test', 'pending', '{}', '{}', '', + '2026-07-30T00:00:00Z', NULL, NULL)`) + mustExecSchemaV3(t, db, `INSERT INTO control_operations ( + commit_sequence, operation_id, idempotency_key, digest_version, request_digest, + operation_kind, resource_identity, canonical_result, required_stages, + last_completed_stage, failed_stage, completed_at, compacted_at, created_at, updated_at + ) VALUES (41, '00000000-0000-0000-0000-000000000041', + '00000000-0000-0000-0000-000000000042', 1, zeroblob(32), + 'test', 'test:41', '{}', '[]', '', '', NULL, NULL, + '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z')`) + + if err := storage.AutoMigrate(db); err != nil { + t.Fatalf("AutoMigrate(v2) error = %v", err) + } + var nullCounts struct { + JobStarted int + JobFinished int + OperationCompleted int + OperationCompacted int + } + if err := db.Raw(`SELECT + (SELECT started_at_ms IS NULL FROM jobs WHERE id = 'nullable-job') AS job_started, + (SELECT finished_at_ms IS NULL FROM jobs WHERE id = 'nullable-job') AS job_finished, + (SELECT completed_at_ms IS NULL FROM control_operations WHERE commit_sequence = 41) + AS operation_completed, + (SELECT compacted_at_ms IS NULL FROM control_operations WHERE commit_sequence = 41) + AS operation_compacted`).Scan(&nullCounts).Error; err != nil { + t.Fatalf("read nullable migrated times: %v", err) + } + if nullCounts != (struct { + JobStarted int + JobFinished int + OperationCompleted int + OperationCompacted int + }{1, 1, 1, 1}) { + t.Fatalf("nullable migrated time checks = %+v, want all 1", nullCounts) + } +} + +func openSchemaV3TestDatabase(t *testing.T) *gorm.DB { + t.Helper() + db, err := storage.Open(":memory:") + if err != nil { + t.Fatalf("Open(:memory:) error = %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("DB() error = %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close database: %v", err) + } + }) + return db +} + +func createSchemaV2Fixture(t *testing.T, db *gorm.DB) { + t.Helper() + statements := []string{ + `CREATE TABLE schema_info (version integer PRIMARY KEY)`, + `INSERT INTO schema_info(version) VALUES (2)`, + `CREATE TABLE groups ( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar(255) NOT NULL, + upstream_url text NOT NULL, + protocols json NOT NULL, + models json NOT NULL, + convert_enabled numeric NOT NULL DEFAULT false, + weight_manual integer, + validation_model varchar(255), + config json, + enabled numeric NOT NULL DEFAULT true, + created_at datetime, + updated_at datetime + )`, + `CREATE UNIQUE INDEX idx_groups_name ON groups(name)`, + `CREATE TABLE upstream_keys ( + id integer PRIMARY KEY AUTOINCREMENT, + group_id integer NOT NULL, + key_value text NOT NULL, + key_hash varchar(128) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'active' + CHECK (status IN ('active','disabled')), + weight_manual integer, + request_count integer NOT NULL DEFAULT 0, + tokens_total integer NOT NULL DEFAULT 0, + cost_total real NOT NULL DEFAULT 0, + created_at datetime, + updated_at datetime, + FOREIGN KEY (group_id) REFERENCES groups(id) ON UPDATE CASCADE ON DELETE CASCADE + )`, + `CREATE UNIQUE INDEX idx_upstream_keys_group_hash + ON upstream_keys(group_id, key_hash)`, + `CREATE TABLE access_keys ( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar(255) NOT NULL, + key_value text NOT NULL, + key_hash varchar(128) NOT NULL, + key_suffix char(4) NOT NULL + CHECK (key_suffix GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]'), + status varchar(32) NOT NULL DEFAULT 'active' + CHECK (status IN ('active','disabled')), + filters json, + rpm_limit integer NOT NULL DEFAULT 0, + daily_cost_limit real NOT NULL DEFAULT 0, + monthly_cost_limit real NOT NULL DEFAULT 0, + created_at datetime, + updated_at datetime + )`, + `CREATE UNIQUE INDEX idx_access_keys_key_hash ON access_keys(key_hash)`, + `CREATE TABLE request_logs ( + id varchar(36) PRIMARY KEY NOT NULL, + created_at datetime NOT NULL, + access_key_id integer NOT NULL, + group_id integer NOT NULL DEFAULT 0, + protocol varchar(32) NOT NULL, + client_model varchar(255) NOT NULL, + upstream_model varchar(255) NOT NULL, + status varchar(32) NOT NULL, + status_code integer NOT NULL, + duration_ms integer NOT NULL, + error_code varchar(64) NOT NULL DEFAULT '', + error_summary text NOT NULL DEFAULT '', + affinity_hit numeric NOT NULL DEFAULT false, + input_tokens integer NOT NULL DEFAULT 0, + output_tokens integer NOT NULL DEFAULT 0, + cache_read_tokens integer NOT NULL DEFAULT 0, + cache_write_5m_tokens integer NOT NULL DEFAULT 0, + cache_write_1h_tokens integer NOT NULL DEFAULT 0, + cost real NOT NULL DEFAULT 0, + usage_state varchar(32) NOT NULL DEFAULT 'not_applicable' + CHECK (usage_state IN ('complete','partial','missing','not_applicable')), + cost_state varchar(32) NOT NULL DEFAULT 'not_applicable' + CHECK (cost_state IN ('priced','unpriced','not_applicable')), + attempts json + )`, + `CREATE TABLE usage_stats ( + id integer PRIMARY KEY AUTOINCREMENT, + hour_bucket datetime NOT NULL, + group_id integer NOT NULL, + model varchar(255) NOT NULL, + request_count integer NOT NULL DEFAULT 0, + success_count integer NOT NULL DEFAULT 0, + failure_count integer NOT NULL DEFAULT 0, + input_tokens integer NOT NULL DEFAULT 0, + output_tokens integer NOT NULL DEFAULT 0, + cache_read_tokens integer NOT NULL DEFAULT 0, + cache_write_5m_tokens integer NOT NULL DEFAULT 0, + cache_write_1h_tokens integer NOT NULL DEFAULT 0, + cost real NOT NULL DEFAULT 0, + usage_missing_count integer NOT NULL DEFAULT 0, + partial_count integer NOT NULL DEFAULT 0, + unpriced_request_count integer NOT NULL DEFAULT 0 + )`, + `CREATE UNIQUE INDEX idx_usage_stats_hour_group_model + ON usage_stats(hour_bucket, group_id, model)`, + `CREATE TABLE model_prices ( + id integer PRIMARY KEY AUTOINCREMENT, + pattern varchar(255) NOT NULL, + input_price real, + output_price real, + cache_read_price real, + cache_write_5m_price real, + cache_write_1h_price real, + source varchar(32) NOT NULL CHECK (source = 'user'), + created_at datetime, + updated_at datetime + )`, + `CREATE UNIQUE INDEX idx_model_prices_pattern ON model_prices(pattern)`, + `CREATE TABLE system_settings ( + key varchar(255) PRIMARY KEY NOT NULL, + value text NOT NULL, + updated_at datetime + )`, + `CREATE TABLE jobs ( + id varchar(36) PRIMARY KEY NOT NULL, + type varchar(64) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'pending', + payload json, + result json, + error text, + created_at datetime NOT NULL, + started_at datetime, + finished_at datetime + )`, + `CREATE TABLE control_operations ( + commit_sequence integer PRIMARY KEY AUTOINCREMENT, + operation_id char(36) NOT NULL, + idempotency_key char(36) NOT NULL, + digest_version integer NOT NULL CHECK (digest_version > 0), + request_digest blob NOT NULL CHECK (length(request_digest) = 32), + operation_kind varchar(32) NOT NULL, + resource_identity varchar(64) NOT NULL, + canonical_result blob, + required_stages json, + last_completed_stage varchar(32), + failed_stage varchar(32), + completed_at datetime, + compacted_at datetime, + created_at datetime, + updated_at datetime + )`, + `CREATE UNIQUE INDEX idx_control_operations_operation_id + ON control_operations(operation_id)`, + `CREATE UNIQUE INDEX idx_control_operations_idempotency_key + ON control_operations(idempotency_key)`, + } + for _, statement := range statements { + mustExecSchemaV3(t, db, statement) + } +} + +func insertLegacyRequestLog( + t *testing.T, + db *gorm.DB, + id string, + completedAt time.Time, + accessKeyID uint, + groupID uint, + clientModel string, + upstreamModel string, + status string, + inputTokens int64, + outputTokens int64, + cacheReadTokens int64, + cacheWrite5MTokens int64, + cacheWrite1HTokens int64, + cost float64, + usageState string, + costState string, +) { + t.Helper() + mustExecSchemaV3(t, db, `INSERT INTO request_logs ( + id, created_at, access_key_id, group_id, protocol, client_model, + upstream_model, status, status_code, duration_ms, error_code, error_summary, + affinity_hit, input_tokens, output_tokens, cache_read_tokens, + cache_write_5m_tokens, cache_write_1h_tokens, cost, usage_state, cost_state, attempts + ) VALUES (?, ?, ?, ?, 'openai-completions', ?, ?, ?, 200, 123, + '', '', false, ?, ?, ?, ?, ?, ?, ?, ?, '[]')`, + id, completedAt, accessKeyID, groupID, clientModel, upstreamModel, status, + inputTokens, outputTokens, cacheReadTokens, cacheWrite5MTokens, + cacheWrite1HTokens, cost, usageState, costState, + ) +} + +func insertLegacyAccessKeyLimits( + t *testing.T, + db *gorm.DB, + id uint, + dailyCostLimit float64, + monthlyCostLimit float64, +) { + t.Helper() + mustExecSchemaV3(t, db, `INSERT INTO access_keys ( + id, name, key_value, key_hash, key_suffix, status, rpm_limit, + daily_cost_limit, monthly_cost_limit, created_at, updated_at + ) VALUES (?, ?, 'cipher', ?, '7f2a', 'active', 0, ?, ?, + '2026-07-30T00:00:00Z', '2026-07-30T00:00:00Z')`, + id, + "invalid-limit", + "invalid-limit-hash-"+strconv.FormatUint(uint64(id), 10), + dailyCostLimit, + monthlyCostLimit, + ) +} + +func schemaV3Columns(t *testing.T, db *gorm.DB, table string) map[string]schemaV3Column { + t.Helper() + var columns []schemaV3Column + if err := db.Raw("PRAGMA table_info('" + table + "')").Scan(&columns).Error; err != nil { + t.Fatalf("inspect %s columns: %v", table, err) + } + result := make(map[string]schemaV3Column, len(columns)) + for _, column := range columns { + result[column.Name] = column + } + return result +} + +func assertSchemaV3IntegerColumn( + t *testing.T, + columns map[string]schemaV3Column, + name string, + nullable bool, +) { + t.Helper() + column, ok := columns[name] + if !ok { + t.Errorf("column %q is missing", name) + return + } + if !strings.EqualFold(column.Type, "INTEGER") { + t.Errorf("%s type = %q, want INTEGER", name, column.Type) + } + wantNotNull := 1 + if nullable { + wantNotNull = 0 + } + if column.NotNull != wantNotNull { + t.Errorf("%s notnull = %d, want %d", name, column.NotNull, wantNotNull) + } +} + +func assertSchemaV3IndexColumns( + t *testing.T, + db *gorm.DB, + indexName string, + want []string, +) { + t.Helper() + type indexColumn struct { + Sequence int `gorm:"column:seqno"` + Name string `gorm:"column:name"` + Key int + } + var columns []indexColumn + if err := db.Raw("PRAGMA index_xinfo('" + indexName + "')").Scan(&columns).Error; err != nil { + t.Fatalf("inspect %s: %v", indexName, err) + } + got := make([]string, 0, len(want)) + for _, column := range columns { + if column.Key == 1 { + got = append(got, column.Name) + } + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s columns = %v, want %v", indexName, got, want) + } +} + +func mustExecSchemaV3(t *testing.T, db *gorm.DB, query string, args ...any) { + t.Helper() + if err := db.Exec(query, args...).Error; err != nil { + t.Fatalf("exec schema v3 fixture: %v\nquery: %s", err, query) + } +} + +func TestSchemaV3FixtureRejectsNonFiniteCostsAtSQLiteBoundary(t *testing.T) { + // SQLite stores NaN as NULL, so the v2 NOT NULL cost column rejects it + // before migration. Infinity remains representable and is covered by the + // migration rollback matrix above. + db := openSchemaV3TestDatabase(t) + createSchemaV2Fixture(t, db) + err := db.Exec(`INSERT INTO request_logs ( + id, created_at, access_key_id, group_id, protocol, client_model, + upstream_model, status, status_code, duration_ms, error_code, error_summary, + affinity_hit, input_tokens, output_tokens, cache_read_tokens, + cache_write_5m_tokens, cache_write_1h_tokens, cost, usage_state, cost_state, attempts + ) VALUES ('nan-cost', '2026-07-30T00:00:00Z', 0, 0, 'openai-completions', + '', '', 'success', 200, 0, '', '', false, 0, 0, 0, 0, 0, ?, + 'complete', 'priced', '[]')`, math.NaN()).Error + if err == nil { + t.Fatal("SQLite accepted NaN into the v2 NOT NULL request cost") + } +} diff --git a/internal/storage/schema_v3_validation.go b/internal/storage/schema_v3_validation.go new file mode 100644 index 00000000..dc8507b3 --- /dev/null +++ b/internal/storage/schema_v3_validation.go @@ -0,0 +1,210 @@ +package storage + +import ( + "fmt" + "strings" + + "gorm.io/gorm" +) + +const schemaV3InfoTableSQL = `CREATE TABLE schema_info ( + version integer PRIMARY KEY NOT NULL, + CONSTRAINT chk_schema_info_version CHECK (version = 3) +)` + +func createSchemaV3InfoTable(db *gorm.DB, table string) error { + if table != "schema_info" && table != "schema_info__v3" { + return fmt.Errorf("create schema v3 info table: unsupported name %q", table) + } + statement := strings.Replace(schemaV3InfoTableSQL, "schema_info", table, 1) + if err := db.Exec(statement).Error; err != nil { + return fmt.Errorf("create schema v3 info table: %w", err) + } + return nil +} + +func replaceSchemaV3InfoTable(db *gorm.DB) error { + if err := createSchemaV3InfoTable(db, "schema_info__v3"); err != nil { + return err + } + if err := db.Exec(`INSERT INTO schema_info__v3(version) + SELECT version FROM schema_info`).Error; err != nil { + return fmt.Errorf("copy schema_info to schema v3: %w", err) + } + if err := db.Exec(`DROP TABLE schema_info`).Error; err != nil { + return fmt.Errorf("drop schema v2 schema_info: %w", err) + } + if err := db.Exec(`ALTER TABLE schema_info__v3 RENAME TO schema_info`).Error; err != nil { + return fmt.Errorf("rename schema v3 schema_info: %w", err) + } + return nil +} + +func validateSchemaV3(db *gorm.DB) error { + requiredColumns := map[string][]string{ + "groups": {"created_at_ms", "updated_at_ms"}, + "upstream_keys": {"group_id", "created_at_ms", "updated_at_ms"}, + "access_keys": {"daily_cost_limit_nano_usd", "monthly_cost_limit_nano_usd", "created_at_ms", "updated_at_ms"}, + "request_logs": {"completed_at_ms", "duration_ms", "estimated_cost_nano_usd", "usage_state", "cost_state"}, + "usage_stats": {"bucket_start_ms", "request_count", "success_count", "failure_count", "estimated_cost_nano_usd"}, + "model_prices": {"input_price_nano_usd_per_million_tokens", "output_price_nano_usd_per_million_tokens"}, + "system_settings": {"updated_at_ms"}, + "jobs": {"created_at_ms", "started_at_ms", "finished_at_ms"}, + "control_operations": {"created_at_ms", "updated_at_ms", "completed_at_ms", "compacted_at_ms"}, + "schema_info": {"version"}, + } + for table, columns := range requiredColumns { + for _, column := range columns { + if !db.Migrator().HasColumn(table, column) { + return fmt.Errorf( + "validate SQLite schema version 3: %s.%s is missing", + table, + column, + ) + } + } + } + + requiredTableFragments := map[string][]string{ + "groups": { + "check (created_at_ms >= 0)", + "check (updated_at_ms >= 0)", + }, + "upstream_keys": { + "check (created_at_ms >= 0)", + "check (updated_at_ms >= 0)", + "constraint chk_upstream_key_status check (status in ('active','disabled'))", + }, + "access_keys": { + "constraint chk_access_key_suffix check (key_suffix glob '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]')", + "constraint chk_access_key_status check (status in ('active','disabled'))", + "constraint chk_access_key_daily_cost_limit_nano check (daily_cost_limit_nano_usd >= 0)", + "constraint chk_access_key_monthly_cost_limit_nano check (monthly_cost_limit_nano_usd >= 0)", + }, + "model_prices": { + "constraint chk_model_price_input_nano check", + "constraint chk_model_price_output_nano check", + "constraint chk_model_price_cache_read_nano check", + "constraint chk_model_price_cache_write_5m_nano check", + "constraint chk_model_price_cache_write_1h_nano check", + "constraint chk_model_price_source check (source = 'user')", + }, + "request_logs": { + "check (completed_at_ms >= 0)", + "check (duration_ms >= 0)", + "check (uncached_input_tokens >= 0)", + "check (output_tokens >= 0)", + "check (cache_read_tokens >= 0)", + "check (cache_write_5m_tokens >= 0)", + "check (cache_write_1h_tokens >= 0)", + "constraint chk_request_log_cost_nano check (estimated_cost_nano_usd >= 0)", + "constraint chk_request_log_usage_state check (usage_state in ('complete','partial','missing','not_applicable'))", + "constraint chk_request_log_cost_state check (cost_state in ('priced','unpriced','not_applicable'))", + "constraint chk_request_log_usage_cost_state check", + "constraint chk_request_log_nonpriced_cost_zero check", + }, + "usage_stats": { + "check (bucket_start_ms >= 0)", + "check (request_count >= 0)", + "check (success_count >= 0)", + "check (failure_count >= 0)", + "check (uncached_input_tokens >= 0)", + "check (output_tokens >= 0)", + "check (cache_read_tokens >= 0)", + "check (cache_write_5m_tokens >= 0)", + "check (cache_write_1h_tokens >= 0)", + "check (usage_missing_count >= 0)", + "check (partial_count >= 0)", + "check (unpriced_request_count >= 0)", + "constraint chk_usage_stat_cost_nano check (estimated_cost_nano_usd >= 0)", + "constraint chk_usage_stat_request_outcome check (request_count = success_count + failure_count)", + }, + "system_settings": { + "check (updated_at_ms >= 0)", + }, + "jobs": { + "check (created_at_ms >= 0)", + "check (started_at_ms is null or started_at_ms >= 0)", + "check (finished_at_ms is null or finished_at_ms >= 0)", + }, + "control_operations": { + "check (completed_at_ms is null or completed_at_ms >= 0)", + "check (compacted_at_ms is null or compacted_at_ms >= 0)", + "check (created_at_ms >= 0)", + "check (updated_at_ms >= 0)", + "constraint chk_control_operation_digest_version check (digest_version > 0)", + "constraint chk_control_operation_digest check (length(request_digest) = 32)", + }, + "schema_info": { + "constraint chk_schema_info_version check (version = 3)", + }, + } + for table, fragments := range requiredTableFragments { + statement, err := sqliteSchemaSQL(db, "table", table) + if err != nil { + return err + } + normalized := normalizeSQLiteDDL(statement) + for _, fragment := range fragments { + if !strings.Contains(normalized, normalizeSQLiteDDL(fragment)) { + return fmt.Errorf( + "validate SQLite schema version 3: %s is missing constraint %q", + table, + fragment, + ) + } + } + } + + for _, expected := range schemaV3IndexStatements() { + fields := strings.Fields(expected) + if len(fields) < 4 { + return fmt.Errorf("validate SQLite schema version 3: invalid canonical index DDL") + } + nameIndex := 2 + if strings.EqualFold(fields[1], "unique") { + nameIndex = 3 + } + name := fields[nameIndex] + actual, err := sqliteSchemaSQL(db, "index", name) + if err != nil { + return err + } + if normalizeSQLiteDDL(actual) != normalizeSQLiteDDL(expected) { + return fmt.Errorf( + "validate SQLite schema version 3: critical index %s differs", + name, + ) + } + } + return validateSchemaV3ForeignKeys(db) +} + +func sqliteSchemaSQL(db *gorm.DB, objectType, name string) (string, error) { + var statement string + result := db.Raw( + `SELECT sql FROM sqlite_master WHERE type = ? AND name = ?`, + objectType, + name, + ).Scan(&statement) + if result.Error != nil { + return "", fmt.Errorf( + "validate SQLite schema version 3: read %s %s: %w", + objectType, + name, + result.Error, + ) + } + if result.RowsAffected != 1 || strings.TrimSpace(statement) == "" { + return "", fmt.Errorf( + "validate SQLite schema version 3: %s %s is missing", + objectType, + name, + ) + } + return statement, nil +} + +func normalizeSQLiteDDL(statement string) string { + return strings.ToLower(strings.Join(strings.Fields(statement), " ")) +} diff --git a/internal/usagecost/validate.go b/internal/usagecost/validate.go new file mode 100644 index 00000000..be1e2dd2 --- /dev/null +++ b/internal/usagecost/validate.go @@ -0,0 +1,36 @@ +package usagecost + +import ( + "fmt" + + "gpt-load/internal/pricing" + "gpt-load/internal/usage" +) + +// Validate enforces the persisted usage/cost state matrix and amount invariant. +func Validate( + usageState usage.State, + costState pricing.CostState, + estimatedCostNanoUSD int64, +) error { + if estimatedCostNanoUSD < 0 { + return fmt.Errorf("invalid estimated cost") + } + validCombination := false + switch usageState { + case usage.StateComplete, usage.StatePartial: + validCombination = costState == pricing.CostStatePriced || + costState == pricing.CostStateUnpriced + case usage.StateMissing: + validCombination = costState == pricing.CostStateUnpriced + case usage.StateNotApplicable: + validCombination = costState == pricing.CostStateNotApplicable + } + if !validCombination { + return fmt.Errorf("invalid usage and cost state combination") + } + if costState != pricing.CostStatePriced && estimatedCostNanoUSD != 0 { + return fmt.Errorf("unpriced or not-applicable cost must be zero") + } + return nil +} diff --git a/internal/webui/page_routes.json b/internal/webui/page_routes.json index 3397cf82..e4e3f8d9 100644 --- a/internal/webui/page_routes.json +++ b/internal/webui/page_routes.json @@ -4,6 +4,7 @@ { "name": "home", "path": "/" }, { "name": "login", "path": "/login" }, { "name": "import", "path": "/import" }, + { "name": "groups", "path": "/groups" }, { "name": "group-detail", "path": "/groups/:id" }, { "name": "access-keys", "path": "/access-keys" }, { "name": "monitor", "path": "/monitor" }, diff --git a/internal/webui/page_routes_test.go b/internal/webui/page_routes_test.go index 9f6163b9..03f3d3fb 100644 --- a/internal/webui/page_routes_test.go +++ b/internal/webui/page_routes_test.go @@ -110,6 +110,7 @@ func TestEmbeddedPageRouteManifestContainsCurrentPages(t *testing.T) { "home": "/", "login": "/login", "import": "/import", + "groups": "/groups", "group-detail": "/groups/:id", "access-keys": "/access-keys", "monitor": "/monitor", diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index 786cc5c8..565d9334 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -87,6 +87,27 @@ func TestServerServesModelPricesDeepLinkWithoutCatchingUnknownSettingsPaths(t *t } } +func TestServerServesGroupsCollectionWithoutCatchingUnknownNestedPaths(t *testing.T) { + engine := testEngine(newServer(fstest.MapFS{ + "dist/index.html": &fstest.MapFile{Data: []byte("groups")}, + }, "dist")) + + groups := httptest.NewRecorder() + engine.ServeHTTP(groups, httptest.NewRequest(http.MethodGet, "/groups", nil)) + if groups.Code != http.StatusOK || !strings.Contains(groups.Body.String(), "groups") { + t.Fatalf("GET /groups = %d %q, want embedded index", groups.Code, groups.Body.String()) + } + + unknown := httptest.NewRecorder() + engine.ServeHTTP( + unknown, + httptest.NewRequest(http.MethodGet, "/groups/unknown/path", nil), + ) + if unknown.Code != http.StatusNotFound { + t.Fatalf("GET unknown nested groups path = %d, want 404", unknown.Code) + } +} + func TestServerFallbackReturnsNotFoundForUnknownRequests(t *testing.T) { server := newServer(fstest.MapFS{ "dist/index.html": &fstest.MapFile{Data: []byte("fallback")}, diff --git a/web/src/App.vue b/web/src/App.vue index 28f09fb7..0f2f8494 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -4,6 +4,7 @@ import { RouterView } from 'vue-router' import AppShell from '@/app/AppShell.vue' import AuthGate from '@/app/AuthGate.vue' import RouteAnnouncer from '@/app/RouteAnnouncer.vue' +import PublicShell from '@/components/layout/PublicShell.vue' diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8c0a14c0..6f349b70 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -46,44 +46,101 @@ export interface ApiClientWithResponse extends ApiClient { type Envelope = SuccessEnvelope | ErrorEnvelope -const apiPathBase = 'https://gpt-load.invalid' +const maxApiPathLength = 8192 +const maxPercentEncodingDepth = 2 -function isSafeApiPath(path: unknown): path is ApiPath { - if (typeof path !== 'string' || !path.startsWith('/api/')) { - return false +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) { + return true + } } + return false +} + +function isSafeApiQuery(query: string): boolean { try { - decodeURIComponent(path) + decodeURIComponent(query) } catch { return false } - const rawPathname = path.split(/[?#]/, 1)[0] - if (!rawPathname) { - return false - } - - for (const segment of rawPathname.split('/')) { - let decodedSegment: string - try { - decodedSegment = decodeURIComponent(segment) - } catch { + let encodedQuery = query + for (let encodingDepth = 0; encodingDepth <= maxPercentEncodingDepth; encodingDepth += 1) { + if (/%(?:0[0-9a-f]|1[0-9a-f]|7f)/i.test(encodedQuery)) { return false } + if (encodingDepth === maxPercentEncodingDepth) { + return true + } + const expandedQuery = encodedQuery.replace(/%25/gi, '%') + if (expandedQuery === encodedQuery) { + return true + } + encodedQuery = expandedQuery + } + + return false +} + +function isSafeApiPathSegment(segment: string): boolean { + let decodedSegment = segment + for (let encodingDepth = 0; encodingDepth <= maxPercentEncodingDepth; encodingDepth += 1) { if ( + decodedSegment.length === 0 || decodedSegment === '.' || decodedSegment === '..' || decodedSegment.includes('/') || - decodedSegment.includes('\\') + decodedSegment.includes('\\') || + decodedSegment.includes('?') || + decodedSegment.includes('#') || + hasControlCharacter(decodedSegment) ) { return false } + + if (!decodedSegment.includes('%')) { + return true + } + if (encodingDepth === maxPercentEncodingDepth) { + return false + } + + try { + decodedSegment = decodeURIComponent(decodedSegment) + } catch { + return false + } } - const target = new URL(path, apiPathBase) - return target.origin === apiPathBase && target.pathname.startsWith('/api/') + return false +} + +function isSafeApiPath(path: unknown): path is ApiPath { + if ( + typeof path !== 'string' || + path.length > maxApiPathLength || + !path.startsWith('/api/') || + path.startsWith('//') || + path.includes('\\') || + path.includes('#') || + hasControlCharacter(path) + ) { + return false + } + + const queryStart = path.indexOf('?') + const pathname = queryStart === -1 ? path : path.slice(0, queryStart) + const query = queryStart === -1 ? '' : path.slice(queryStart + 1) + if (!isSafeApiQuery(query)) { + return false + } + + const segments = pathname.slice('/api/'.length).split('/') + return segments.every(isSafeApiPathSegment) } function isRecord(value: unknown): value is Record { diff --git a/web/src/api/control/access-keys.ts b/web/src/api/control/access-keys.ts deleted file mode 100644 index d1ae8ff4..00000000 --- a/web/src/api/control/access-keys.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/access-keys' diff --git a/web/src/api/control/groups.ts b/web/src/api/control/groups.ts deleted file mode 100644 index 96faf6fd..00000000 --- a/web/src/api/control/groups.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/groups' diff --git a/web/src/api/control/health.ts b/web/src/api/control/health.ts deleted file mode 100644 index ef3e3506..00000000 --- a/web/src/api/control/health.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/health' diff --git a/web/src/api/control/model-prices.ts b/web/src/api/control/model-prices.ts deleted file mode 100644 index 77d322b9..00000000 --- a/web/src/api/control/model-prices.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/model-prices' diff --git a/web/src/api/control/request-logs.ts b/web/src/api/control/request-logs.ts deleted file mode 100644 index 37a944a4..00000000 --- a/web/src/api/control/request-logs.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/request-logs' diff --git a/web/src/api/control/route-inspect.ts b/web/src/api/control/route-inspect.ts deleted file mode 100644 index 59aa31b3..00000000 --- a/web/src/api/control/route-inspect.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/route-inspection' diff --git a/web/src/api/control/settings.ts b/web/src/api/control/settings.ts deleted file mode 100644 index 2c2068d6..00000000 --- a/web/src/api/control/settings.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/settings' diff --git a/web/src/api/control/system-info.ts b/web/src/api/control/system-info.ts deleted file mode 100644 index 996a6b21..00000000 --- a/web/src/api/control/system-info.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/system-info' diff --git a/web/src/api/control/types.ts b/web/src/api/control/types.ts index daef3dca..94d88230 100644 --- a/web/src/api/control/types.ts +++ b/web/src/api/control/types.ts @@ -45,14 +45,14 @@ export interface HealthGroupDto { export interface HealthRecoveryDto { automatic: boolean mode: string - at: string | null + at_ms: number | null } export interface HealthProblemKeyDto { key_id: number group_id: number group_name: string - cooldown_until?: string + cooldown_until_ms: number | null failure_count: number recent_success_count: number recent_failure_count: number @@ -78,12 +78,12 @@ export interface RequestLogHealthDto { retention_delete_failure_total: number queue_depth: number queue_capacity: number - last_write_failure_at: string | null - last_retention_failure_at: string | null + last_write_failure_at_ms: number | null + last_retention_failure_at_ms: number | null } export interface RuntimeHealthDto { - observed_at: string + observed_at_ms: number version: string uptime_seconds: number snapshot_revision: number @@ -108,8 +108,8 @@ export interface AccessKeyDto { status: 'active' | 'disabled' filters: AccessKeyFiltersDto rpm_limit: number - created_at: string - updated_at: string + created_at_ms: number + updated_at_ms: number } export interface AccessKeyOptionDto { @@ -126,5 +126,5 @@ export interface AccessKeyCreateResultDto extends AccessKeyDto { export interface AccessKeyRevealDto { id: number key: string - revealed_at: string + revealed_at_ms: number } diff --git a/web/src/api/control/upstream-keys.ts b/web/src/api/control/upstream-keys.ts deleted file mode 100644 index 73ca9dd0..00000000 --- a/web/src/api/control/upstream-keys.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/upstream-keys' diff --git a/web/src/api/control/usage.ts b/web/src/api/control/usage.ts deleted file mode 100644 index dfadfea0..00000000 --- a/web/src/api/control/usage.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/app/resources/usage' diff --git a/web/src/app/AppShell.vue b/web/src/app/AppShell.vue index cc27b5e1..a24f3610 100644 --- a/web/src/app/AppShell.vue +++ b/web/src/app/AppShell.vue @@ -1,11 +1,12 @@ diff --git a/web/src/components/charts/trend-chart.ts b/web/src/components/charts/trend-chart.ts index faab3e24..c7883721 100644 --- a/web/src/components/charts/trend-chart.ts +++ b/web/src/components/charts/trend-chart.ts @@ -1,160 +1,187 @@ export interface TrendDatum { - bucket_start: string - bucket_end: string + bucket_start_ms: number + bucket_end_ms: number request_count: number failure_count: number } +export interface TrendPoint { + x: number + y: number +} + +export interface TrendFailureBar { + x: number + y: number + width: number + height: number + value: number +} + export interface TrendGeometry { requestPath: string requestAreaPath: string - requestPoints: Array<{ x: number; y: number; value: number }> - requestMarkers: Array<{ x: number; y: number; value: number }> - failures: Array<{ x: number; height: number; value: number }> + requestPoints: TrendPoint[] + failureBars: TrendFailureBar[] + plotTop: number + baseline: number } -interface TrendPoint { +interface ParsedDatum { start: number end: number - x: number - y: number - value: number + requestCount: number + failureCount: number } function round(value: number): number { return Math.round(value * 100) / 100 } -function assertDimension(value: number): void { - if (!Number.isFinite(value) || value <= 0) { - throw new RangeError('Trend chart dimensions must be finite positive numbers') - } +function validDimension(value: number): boolean { + return Number.isFinite(value) && value > 0 } -function parseDatum(datum: TrendDatum): { start: number; end: number } { - const start = Date.parse(datum.bucket_start) - const end = Date.parse(datum.bucket_end) +function parseDatum(datum: TrendDatum): ParsedDatum | undefined { + const { + bucket_start_ms: start, + bucket_end_ms: end, + request_count: requestCount, + failure_count: failureCount, + } = datum if ( - !Number.isFinite(start) || - !Number.isFinite(end) || + !Number.isSafeInteger(start) || + start < 0 || + !Number.isSafeInteger(end) || end <= start || - !Number.isSafeInteger(datum.request_count) || - datum.request_count < 0 || - !Number.isSafeInteger(datum.failure_count) || - datum.failure_count < 0 || - datum.failure_count > datum.request_count + !Number.isSafeInteger(requestCount) || + requestCount < 0 || + !Number.isSafeInteger(failureCount) || + failureCount < 0 || + failureCount > requestCount ) { - throw new RangeError('Trend chart data is invalid') + return undefined } - return { start, end } + return { start, end, requestCount, failureCount } } -function segments(points: readonly TrendPoint[]): TrendPoint[][] { - return points.reduce((result, point, index) => { - const previous = points[index - 1] - if (previous === undefined || point.start !== previous.end) result.push([point]) - else result.at(-1)?.push(point) - return result - }, []) +export function isTrendSeriesUsable( + series: readonly TrendDatum[], + rangeStart: number, + rangeEnd: number, +): boolean { + if ( + !Number.isSafeInteger(rangeStart) || + rangeStart < 0 || + !Number.isSafeInteger(rangeEnd) || + rangeEnd <= rangeStart + ) { + return false + } + + let previousEnd = rangeStart + for (const datum of series) { + const parsed = parseDatum(datum) + if ( + !parsed || + parsed.start < rangeStart || + parsed.end > rangeEnd || + parsed.start < previousEnd + ) { + return false + } + previousEnd = parsed.end + } + return true +} + +function appendSegmentPath(points: readonly TrendPoint[]): string { + return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' ') +} + +function appendAreaPath(points: readonly TrendPoint[], baseline: number): string { + const first = points[0] + const last = points.at(-1) + if (!first || !last) return '' + return `M ${first.x} ${baseline} ${appendSegmentPath(points).replace(/^M /u, 'L ')} L ${last.x} ${baseline} Z` } export function buildTrendGeometry( series: readonly TrendDatum[], width: number, - requestHeight: number, - failureHeight: number, - rangeStart: string, - rangeEnd: string, + chartHeight: number, + maximumFailureHeight: number, + rangeStart: number, + rangeEnd: number, ): TrendGeometry { - assertDimension(width) - assertDimension(requestHeight) - assertDimension(failureHeight) - if (series.length === 0) { - return { - requestPath: '', - requestAreaPath: '', - requestPoints: [], - requestMarkers: [], - failures: [], - } + const empty: TrendGeometry = { + requestPath: '', + requestAreaPath: '', + requestPoints: [], + failureBars: [], + plotTop: 0, + baseline: 0, } - - const parsed = series.map(parseDatum) - const domainStart = Date.parse(rangeStart) - const domainEnd = Date.parse(rangeEnd) - const bucketDuration = parsed[0]!.end - parsed[0]!.start - const pointDomainEnd = domainEnd - bucketDuration if ( - !Number.isFinite(domainStart) || - !Number.isFinite(domainEnd) || - domainEnd <= domainStart || - pointDomainEnd < domainStart || - parsed.some( - (datum) => - datum.start < domainStart || - datum.end > domainEnd || - datum.end - datum.start !== bucketDuration, - ) + !validDimension(width) || + !validDimension(chartHeight) || + !validDimension(maximumFailureHeight) || + series.length === 0 || + !isTrendSeriesUsable(series, rangeStart, rangeEnd) ) { - throw new RangeError('Trend chart range is invalid') - } - for (let index = 1; index < parsed.length; index += 1) { - if (parsed[index]!.start < parsed[index - 1]!.end) { - throw new RangeError('Trend chart buckets must be ordered and non-overlapping') - } + return empty } - const domainDuration = pointDomainEnd - domainStart - const maximumRequests = Math.max(0, ...series.map((datum) => datum.request_count)) - const maximumFailures = Math.max(0, ...series.map((datum) => datum.failure_count)) - const requestTopInset = Math.min(14, requestHeight * 0.08) - const points = series.map((datum, index) => { - const timing = parsed[index]! + const parsed = series.map(parseDatum) as ParsedDatum[] + const rangeDuration = rangeEnd - rangeStart + const maximumRequests = Math.max(...parsed.map((datum) => datum.requestCount)) + const maximumFailures = Math.max(...parsed.map((datum) => datum.failureCount)) + const plotTop = Math.min(14, chartHeight * 0.1) + const plotBottomInset = Math.min(8, chartHeight * 0.1) + const baseline = chartHeight - plotBottomInset + const failureHeight = Math.min(maximumFailureHeight, baseline - plotTop) + const points = parsed.map((datum) => { + const midpoint = datum.start + (datum.end - datum.start) / 2 return { - ...timing, - x: round( - domainDuration === 0 ? width / 2 : ((timing.start - domainStart) / domainDuration) * width, - ), + x: round(((midpoint - rangeStart) / rangeDuration) * width), y: round( maximumRequests === 0 - ? requestHeight - : requestTopInset + - (1 - datum.request_count / maximumRequests) * (requestHeight - requestTopInset), + ? baseline + : plotTop + (1 - datum.requestCount / maximumRequests) * (baseline - plotTop), ), - value: datum.request_count, } }) - const requestSegments = segments(points) - const requestPath = requestSegments - .map((segment) => - segment.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '), - ) - .join(' ') - const requestAreaPath = requestSegments - .map((segment) => { - const first = segment[0]! - const last = segment.at(-1)! - const line = segment.map((point) => `L ${point.x} ${point.y}`).join(' ') - return `M ${first.x} ${requestHeight} ${line} L ${last.x} ${requestHeight} Z` - }) - .join(' ') - const requestMarkers = requestSegments.flatMap((segment, index) => - segment.length === 1 || index === requestSegments.length - 1 ? [segment.at(-1)!] : [], - ) - const failures = series.map((datum, index) => ({ - x: points[index]!.x, - height: round( - maximumFailures === 0 ? 0 : (datum.failure_count / maximumFailures) * failureHeight, - ), - value: datum.failure_count, - })) + const segments: TrendPoint[][] = [] + for (let index = 0; index < points.length; index += 1) { + const point = points[index]! + const previous = parsed[index - 1] + const current = parsed[index]! + if (!previous || current.start !== previous.end) segments.push([point]) + else segments.at(-1)?.push(point) + } return { - requestPath, - requestAreaPath, - requestPoints: points.map(({ x, y, value }) => ({ x, y, value })), - requestMarkers: requestMarkers.map(({ x, y, value }) => ({ x, y, value })), - failures, + requestPath: segments.map(appendSegmentPath).join(' '), + requestAreaPath: segments.map((segment) => appendAreaPath(segment, baseline)).join(' '), + requestPoints: points, + failureBars: parsed.map((datum, index) => { + const height = + maximumFailures === 0 || datum.failureCount === 0 + ? 0 + : round(Math.max(3, (datum.failureCount / maximumFailures) * failureHeight)) + const rawWidth = ((datum.end - datum.start) / rangeDuration) * width + const barWidth = Math.min(rawWidth, width, Math.max(3, Math.min(6, rawWidth * 0.26))) + const point = points[index]! + return { + x: round(Math.max(0, Math.min(width - barWidth, point.x - barWidth / 2))), + y: round(baseline - height), + width: round(barWidth), + height, + value: datum.failureCount, + } + }), + plotTop, + baseline, } } diff --git a/web/src/components/health/ProblemKeyRow.vue b/web/src/components/health/ProblemKeyRow.vue index 21537041..33dd321a 100644 --- a/web/src/components/health/ProblemKeyRow.vue +++ b/web/src/components/health/ProblemKeyRow.vue @@ -3,6 +3,7 @@ import { computed } from 'vue' import type { HealthProblemKeyDto } from '@/app/resources/health' import StatusBadge from '@/components/ui/StatusBadge.vue' +import { formatISOInstant, formatLocalInstant } from '@/lib/format' export interface ProblemKeyRowLabels { consecutiveFailures: string @@ -32,21 +33,27 @@ const props = withDefaults( ) const recoveryTime = computed(() => { - const value = props.problemKey.recovery.at + const value = props.problemKey.recovery.at_ms if (value === null) return null - return new Intl.DateTimeFormat(props.locale, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(value)) + return formatLocalInstant(value, props.locale) +}) +const recoveryDateTime = computed(() => { + const value = props.problemKey.recovery.at_ms + return value === null ? undefined : formatISOInstant(value) }) const recoveryClock = computed(() => { - const value = props.problemKey.recovery.at + const value = props.problemKey.recovery.at_ms if (value === null) return null - return new Intl.DateTimeFormat(props.locale, { + return formatLocalInstant(value, props.locale, { + year: undefined, + month: undefined, + day: undefined, hour: '2-digit', minute: '2-digit', + second: undefined, + timeZoneName: undefined, hourCycle: 'h23', - }).format(new Date(value)) + }) }) const compactFailureSummary = computed(() => { const unit = props.labels.failureUnit ? ` ${props.labels.failureUnit}` : '' @@ -69,7 +76,10 @@ const compactFailureSummary = computed(() => { {{ compactFailureSummary }} -