feat(web): 合并 Ledger 首页与统计重构
This commit is contained in:
184 files changed
+13836
-5082
No files matched your search
@@ -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.
|
||||
|
||||
+1
-7
@@ -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。
|
||||
|
||||
+1
-7
@@ -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です。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+43
-32
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ func newServiceFixtureWithDSN(t *testing.T, dsn string) serviceFixture {
|
||||
dialect.NewSet(),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
stats,
|
||||
requestLogStats,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+137
-76
@@ -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
|
||||
}
|
||||
+137
-69
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+45
-37
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+14
-22
@@ -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)
|
||||
}
|
||||
+86
-103
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+108
-69
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+31
-68
@@ -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) {
|
||||
|
||||
+65
-39
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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-*")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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), " "))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("<!doctype html><title>groups</title>")},
|
||||
}, "dist"))
|
||||
|
||||
groups := httptest.NewRecorder()
|
||||
engine.ServeHTTP(groups, httptest.NewRequest(http.MethodGet, "/groups", nil))
|
||||
if groups.Code != http.StatusOK || !strings.Contains(groups.Body.String(), "<title>groups</title>") {
|
||||
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("<!doctype html><title>fallback</title>")},
|
||||
|
||||
+4
-1
@@ -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'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -14,6 +15,8 @@ import RouteAnnouncer from '@/app/RouteAnnouncer.vue'
|
||||
<component :is="Component" />
|
||||
</AppShell>
|
||||
</AuthGate>
|
||||
<component :is="Component" v-else />
|
||||
<PublicShell v-else>
|
||||
<component :is="Component" />
|
||||
</PublicShell>
|
||||
</RouterView>
|
||||
</template>
|
||||
+75
-18
@@ -46,44 +46,101 @@ export interface ApiClientWithResponse extends ApiClient {
|
||||
|
||||
type Envelope = SuccessEnvelope<unknown> | 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<string, unknown> {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@/app/resources/access-keys'
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@/app/resources/groups'
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@/app/resources/health'
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@/app/resources/model-prices'
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@/app/resources/request-logs'
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@/app/resources/route-inspection'
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@/app/resources/settings'
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user