diff --git a/apps/manager-server/internal/model/model_price.go b/apps/manager-server/internal/model/model_price.go index dc02b9803..fb4e818a9 100644 --- a/apps/manager-server/internal/model/model_price.go +++ b/apps/manager-server/internal/model/model_price.go @@ -12,6 +12,8 @@ type ModelPrice struct { CacheCreationConfigured bool `json:"cacheCreationConfigured,omitempty"` Source string `json:"source,omitempty"` SourceModelID string `json:"sourceModelId,omitempty"` + BillingUnit string `json:"billingUnit,omitempty"` + BillingRate string `json:"billingRate,omitempty"` RawJSON string `json:"rawJson,omitempty"` ContextTiers []ModelPriceContextTier `json:"contextTiers,omitempty"` ServiceTiers []ModelPriceServiceTier `json:"serviceTiers,omitempty"` diff --git a/apps/manager-server/internal/repository/modelprice/repository.go b/apps/manager-server/internal/repository/modelprice/repository.go index 62f8fab5d..69d24431d 100644 --- a/apps/manager-server/internal/repository/modelprice/repository.go +++ b/apps/manager-server/internal/repository/modelprice/repository.go @@ -42,7 +42,7 @@ func (r *repository) LoadAll(ctx context.Context) (map[string]model.ModelPrice, func (r *repository) LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]model.ModelPrice, error) { rows, err := tx.QueryContext(ctx, `select model, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m, - prompt_configured, completion_configured, cache_read_configured, cache_creation_configured, source, source_model_id, raw_json, + prompt_configured, completion_configured, cache_read_configured, cache_creation_configured, source, source_model_id, billing_unit, billing_rate, raw_json, updated_at_ms, synced_at_ms from model_prices order by model`) if err != nil { @@ -53,7 +53,7 @@ func (r *repository) LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]mode for rows.Next() { var modelID string var price model.ModelPrice - var source, sourceModelID, rawJSON sql.NullString + var source, sourceModelID, billingUnit, billingRate, rawJSON sql.NullString var syncedAt sql.NullInt64 var promptConfigured, completionConfigured, cacheReadConfigured, cacheCreationConfigured int if err := rows.Scan( @@ -69,6 +69,8 @@ func (r *repository) LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]mode &cacheCreationConfigured, &source, &sourceModelID, + &billingUnit, + &billingRate, &rawJSON, &price.UpdatedAtMS, &syncedAt, @@ -81,6 +83,8 @@ func (r *repository) LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]mode price.CacheReadConfigured = cacheReadConfigured != 0 price.CacheCreationConfigured = cacheCreationConfigured != 0 price.SourceModelID = sourceModelID.String + price.BillingUnit = billingUnit.String + price.BillingRate = billingRate.String price.RawJSON = rawJSON.String if syncedAt.Valid { value := syncedAt.Int64 @@ -234,8 +238,8 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod stmt, err := tx.PrepareContext(ctx, `insert into model_prices ( model, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m, prompt_configured, completion_configured, cache_read_configured, cache_creation_configured, source, source_model_id, - raw_json, updated_at_ms, synced_at_ms - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + billing_unit, billing_rate, raw_json, updated_at_ms, synced_at_ms + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return err } @@ -267,6 +271,8 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod price.CacheCreationConfigured, nullString(price.Source), nullString(price.SourceModelID), + nullString(price.BillingUnit), + nullString(price.BillingRate), nullString(price.RawJSON), now, nullInt(price.SyncedAtMS), @@ -298,8 +304,8 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M stmt, err := tx.PrepareContext(ctx, `insert into model_prices ( model, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m, prompt_configured, completion_configured, cache_read_configured, cache_creation_configured, source, source_model_id, - raw_json, updated_at_ms, synced_at_ms - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + billing_unit, billing_rate, raw_json, updated_at_ms, synced_at_ms + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(model) do update set prompt_per_1m = excluded.prompt_per_1m, completion_per_1m = excluded.completion_per_1m, @@ -312,6 +318,8 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M cache_creation_configured = excluded.cache_creation_configured, source = excluded.source, source_model_id = excluded.source_model_id, + billing_unit = excluded.billing_unit, + billing_rate = excluded.billing_rate, raw_json = excluded.raw_json, updated_at_ms = excluded.updated_at_ms, synced_at_ms = excluded.synced_at_ms`) @@ -379,6 +387,8 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M price.CacheCreationConfigured, nullString(price.Source), nullString(price.SourceModelID), + nullString(price.BillingUnit), + nullString(price.BillingRate), nullString(price.RawJSON), now, now, diff --git a/apps/manager-server/internal/repository/modelprice/repository_test.go b/apps/manager-server/internal/repository/modelprice/repository_test.go new file mode 100644 index 000000000..5e4e0eda6 --- /dev/null +++ b/apps/manager-server/internal/repository/modelprice/repository_test.go @@ -0,0 +1,55 @@ +package modelprice + +import ( + "context" + "path/filepath" + "testing" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite" +) + +func TestRepositoryRoundTripsBillingMetadata(t *testing.T) { + db, err := sqlite.Open(filepath.Join(t.TempDir(), "model-prices.sqlite")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + repository := New(db) + ctx := context.Background() + prices := map[string]model.ModelPrice{ + "grok-imagine-image": { + Prompt: 0, + Completion: 0, + Cache: 0, + PromptConfigured: true, + CompletionConfigured: true, + Source: "xAI official", + SourceModelID: "$0.02/image", + BillingUnit: "image", + BillingRate: "$0.02/image", + }, + } + if err := repository.ReplaceAll(ctx, prices); err != nil { + t.Fatalf("replace model prices: %v", err) + } + + loaded, err := repository.LoadAll(ctx) + if err != nil { + t.Fatalf("load model prices: %v", err) + } + got, ok := loaded["grok-imagine-image"] + if !ok { + t.Fatalf("loaded prices missing grok-imagine-image") + } + if got.BillingUnit != "image" || got.BillingRate != "$0.02/image" { + t.Fatalf("billing metadata = %q / %q, want image / $0.02/image", got.BillingUnit, got.BillingRate) + } + if got.Source != "xAI official" || got.SourceModelID != "$0.02/image" { + t.Fatalf("source metadata = %q / %q, want xAI official / $0.02/image", got.Source, got.SourceModelID) + } + if got.PromptConfigured != true || got.CompletionConfigured != true { + t.Fatalf("configured flags = %t / %t, want true/true", got.PromptConfigured, got.CompletionConfigured) + } +} diff --git a/apps/manager-server/internal/repository/sqlite/migrate.go b/apps/manager-server/internal/repository/sqlite/migrate.go index 4f62d7cf3..2ca752b27 100644 --- a/apps/manager-server/internal/repository/sqlite/migrate.go +++ b/apps/manager-server/internal/repository/sqlite/migrate.go @@ -622,6 +622,8 @@ func Migrate(db *sql.DB) error { cache_creation_configured integer not null default 0, source text, source_model_id text, + billing_unit text, + billing_rate text, raw_json text, updated_at_ms integer not null, synced_at_ms integer @@ -2732,6 +2734,8 @@ func ensureModelPriceColumns(db *sql.DB) error { {name: "completion_configured", definition: "integer not null default 0"}, {name: "cache_read_configured", definition: "integer not null default 0"}, {name: "cache_creation_configured", definition: "integer not null default 0"}, + {name: "billing_unit", definition: "text"}, + {name: "billing_rate", definition: "text"}, } added := map[string]bool{} for _, column := range columns { diff --git a/apps/manager-server/internal/repository/sqlite/migrate_test.go b/apps/manager-server/internal/repository/sqlite/migrate_test.go index 5aaf2512d..5628e5109 100644 --- a/apps/manager-server/internal/repository/sqlite/migrate_test.go +++ b/apps/manager-server/internal/repository/sqlite/migrate_test.go @@ -2907,6 +2907,14 @@ func TestEnsureModelPriceColumnsPreservesLegacyZeroBasePrices(t *testing.T) { if promptConfigured != 1 || completionConfigured != 1 || cacheReadConfigured != 0 || cacheCreationConfigured != 0 { t.Fatalf("configured flags = %d/%d/%d/%d", promptConfigured, completionConfigured, cacheReadConfigured, cacheCreationConfigured) } + var billingUnit, billingRate sql.NullString + if err := db.QueryRow(`select billing_unit, billing_rate + from model_prices where model = 'gpt-5.6-sol'`).Scan(&billingUnit, &billingRate); err != nil { + t.Fatalf("read migrated billing metadata: %v", err) + } + if billingUnit.Valid || billingRate.Valid { + t.Fatalf("billing metadata unexpectedly populated: %q / %q", billingUnit.String, billingRate.String) + } } func TestMigrateCreatesModelPriceServiceTierTableWithCascade(t *testing.T) { diff --git a/apps/web/src/features/monitoring/ModelPricesPage.module.scss b/apps/web/src/features/monitoring/ModelPricesPage.module.scss index 938cde077..50bbca27d 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.module.scss +++ b/apps/web/src/features/monitoring/ModelPricesPage.module.scss @@ -249,6 +249,13 @@ padding-right: 9px; } +.billingFields { + display: grid; + grid-template-columns: repeat(2, minmax(140px, 1fr)); + gap: 8px; + min-width: 0; +} + .compactEditorActions { flex-wrap: nowrap; justify-content: flex-end; @@ -374,6 +381,11 @@ font: inherit; } +.billingRate { + color: var(--pricing-accent-strong); + font-weight: 700; +} + .actionsCell { width: 88px; min-width: 88px; diff --git a/apps/web/src/features/monitoring/ModelPricesPage.tsx b/apps/web/src/features/monitoring/ModelPricesPage.tsx index 01641ccf7..cf4d0af74 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.tsx +++ b/apps/web/src/features/monitoring/ModelPricesPage.tsx @@ -352,6 +352,22 @@ export function ModelPricesPage() { placeholder={t('model_prices.optional_price_placeholder')} step="0.0001" /> +