From eb9758413730ac64bf298465337d88f6e8f0711c Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 14:05:09 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20prefe?= =?UTF-8?q?r=20models.dev=20for=20model=20pricing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use models.dev as the primary provider-scoped pricing catalog while retaining LiteLLM and OpenRouter fallbacks. Add conflict-safe matching, ETag caching, indexed lookup, and conditional source fetching to reduce sync latency and network work. Preserve manual confirmation for ambiguous pricing and avoid stale-cache fallback after source failures. --- apps/manager-server/internal/app/app.go | 4 +- apps/manager-server/internal/app/context.go | 54 +- .../manager-server/internal/httpapi/server.go | 4 +- .../internal/httpapi/server_test.go | 199 ++++- .../internal/service/modelprice/service.go | 696 ++++++++++++++++-- .../modelprice/service_benchmark_test.go | 56 ++ .../service/modelprice/service_test.go | 449 +++++++++++ 7 files changed, 1408 insertions(+), 54 deletions(-) create mode 100644 apps/manager-server/internal/service/modelprice/service_benchmark_test.go diff --git a/apps/manager-server/internal/app/app.go b/apps/manager-server/internal/app/app.go index a951b25d2..f897ec5c8 100644 --- a/apps/manager-server/internal/app/app.go +++ b/apps/manager-server/internal/app/app.go @@ -15,6 +15,7 @@ import ( type Options struct { EmbeddedPanel fs.FS + ModelsDevModelPriceSyncURL *string ModelPriceSyncURL *string OpenRouterModelPriceSyncURL *string ServiceID string @@ -51,12 +52,13 @@ func New(ctx context.Context, cfg config.Config, options Options) (*Context, err if startedAt <= 0 { startedAt = time.Now().UnixMilli() } - appCtx := FromExisting( + appCtx := FromExistingWithModelsDev( cfg, st, manager, startedAt, options.EmbeddedPanel, + options.ModelsDevModelPriceSyncURL, options.ModelPriceSyncURL, options.OpenRouterModelPriceSyncURL, serviceID, diff --git a/apps/manager-server/internal/app/context.go b/apps/manager-server/internal/app/context.go index b8d1847eb..a624f4435 100644 --- a/apps/manager-server/internal/app/context.go +++ b/apps/manager-server/internal/app/context.go @@ -66,6 +66,58 @@ func FromExisting( openRouterModelPriceSyncURL *string, serviceID string, automationRuntimeService ...AutomationRuntimeService, +) *Context { + return fromExisting( + cfg, + st, + collectorManager, + startedAt, + embeddedPanel, + nil, + modelPriceSyncURL, + openRouterModelPriceSyncURL, + serviceID, + automationRuntimeService..., + ) +} + +func FromExistingWithModelsDev( + cfg config.Config, + st *store.Store, + collectorManager *collector.Manager, + startedAt int64, + embeddedPanel fs.FS, + modelsDevModelPriceSyncURL *string, + modelPriceSyncURL *string, + openRouterModelPriceSyncURL *string, + serviceID string, + automationRuntimeService ...AutomationRuntimeService, +) *Context { + return fromExisting( + cfg, + st, + collectorManager, + startedAt, + embeddedPanel, + modelsDevModelPriceSyncURL, + modelPriceSyncURL, + openRouterModelPriceSyncURL, + serviceID, + automationRuntimeService..., + ) +} + +func fromExisting( + cfg config.Config, + st *store.Store, + collectorManager *collector.Manager, + startedAt int64, + embeddedPanel fs.FS, + modelsDevModelPriceSyncURL *string, + modelPriceSyncURL *string, + openRouterModelPriceSyncURL *string, + serviceID string, + automationRuntimeService ...AutomationRuntimeService, ) *Context { var runtimeService AutomationRuntimeService if len(automationRuntimeService) > 0 { @@ -99,7 +151,7 @@ func FromExisting( DashboardService: dashboardsvc.New(st, cfg.DashboardHourlyRollupEnabled), CodexInspectionService: codexinspectionsvc.New(st, managerConfigService), MonitoringService: monitoringsvc.New(st, cfg.DashboardHourlyRollupEnabled), - ModelPriceService: modelpricesvc.NewMultiSource(st, modelPriceSyncURL, openRouterModelPriceSyncURL, managerConfigService), + ModelPriceService: modelpricesvc.NewMultiSourceWithModelsDev(st, modelsDevModelPriceSyncURL, modelPriceSyncURL, openRouterModelPriceSyncURL, managerConfigService), APIKeyAliasService: apikeyaliassvc.New(st), AccountActionService: accountactionsvc.New(st, managerConfigService), AccountProcessingPolicyService: accountProcessingPolicyService, diff --git a/apps/manager-server/internal/httpapi/server.go b/apps/manager-server/internal/httpapi/server.go index c65858517..3b74a7037 100644 --- a/apps/manager-server/internal/httpapi/server.go +++ b/apps/manager-server/internal/httpapi/server.go @@ -17,6 +17,7 @@ var embeddedPanel embed.FS const serviceID = "cpa-manager-plus" +var modelsDevModelPriceSyncURL = "https://models.dev/api.json" var modelPriceSyncURL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" var openRouterModelPriceSyncURL = "https://openrouter.ai/api/v1/models" @@ -27,12 +28,13 @@ type Server struct { func New(cfg config.Config, store *store.Store, collector *collector.Manager, automationRuntimeService ...app.AutomationRuntimeService) *Server { startedAt := time.Now().UnixMilli() - appCtx := app.FromExisting( + appCtx := app.FromExistingWithModelsDev( cfg, store, collector, startedAt, embeddedPanel, + &modelsDevModelPriceSyncURL, &modelPriceSyncURL, &openRouterModelPriceSyncURL, serviceID, diff --git a/apps/manager-server/internal/httpapi/server_test.go b/apps/manager-server/internal/httpapi/server_test.go index b4896ff50..5de8c6e0a 100644 --- a/apps/manager-server/internal/httpapi/server_test.go +++ b/apps/manager-server/internal/httpapi/server_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/collector" @@ -79,13 +80,20 @@ func newTestHandlerWithConfig(t *testing.T, cfg config.Config) http.Handler { return New(cfg, db, manager).Handler() } -func stubModelPriceSyncURLs(t *testing.T, liteLLMURL string, openRouterURL string) { +func stubModelPriceSyncURLs(t *testing.T, liteLLMURL string, openRouterURL string, modelsDevURLs ...string) { t.Helper() + oldModelsDevURL := modelsDevModelPriceSyncURL oldLiteLLMURL := modelPriceSyncURL oldOpenRouterURL := openRouterModelPriceSyncURL + modelsDevURL := "" + if len(modelsDevURLs) > 0 { + modelsDevURL = modelsDevURLs[0] + } + modelsDevModelPriceSyncURL = modelsDevURL modelPriceSyncURL = liteLLMURL openRouterModelPriceSyncURL = openRouterURL t.Cleanup(func() { + modelsDevModelPriceSyncURL = oldModelsDevURL modelPriceSyncURL = oldLiteLLMURL openRouterModelPriceSyncURL = oldOpenRouterURL }) @@ -738,6 +746,195 @@ func TestModelPricesSyncFromLiteLLMFormat(t *testing.T) { } } +func TestModelPricesSyncPrefersModelsDevProviderScopedPrices(t *testing.T) { + modelsDevSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "openai": {"models": { + "gpt-test": {"cost":{"input":9,"output":10,"cache_read":1}}, + "ambiguous": {"cost":{"input":3,"output":4}} + }}, + "azure": {"models": { + "ambiguous": {"cost":{"input":5,"output":6}} + }}, + "crossmodel": {"models": { + "openai/gpt-test": {"cost":{"input":11,"output":12}} + }} + }`)) + })) + t.Cleanup(modelsDevSource.Close) + liteLLMSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "gpt-test": {"input_cost_per_token":0.000001,"output_cost_per_token":0.000002}, + "ambiguous": {"input_cost_per_token":0.000007,"output_cost_per_token":0.000008}, + "fallback-only": {"input_cost_per_token":0.000001,"output_cost_per_token":0.000002} + }`)) + })) + t.Cleanup(liteLLMSource.Close) + stubModelPriceSyncURLs(t, liteLLMSource.URL, "", modelsDevSource.URL) + + handler := newTestHandler(t, "http://example.test", true) + req := httptest.NewRequest( + http.MethodPost, + "/v0/management/model-prices/sync", + bytes.NewBufferString(`{"models":["gpt-test","openai/gpt-test","crossmodel/openai/gpt-test","ambiguous","openai/ambiguous","fallback-only"]}`), + ) + req.Header.Set("Authorization", "Bearer "+testutil.AdminKey) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("sync status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response struct { + Sources []string `json:"sources"` + Imported int `json:"imported"` + Candidates []struct { + Model string `json:"model"` + Candidates []struct { + SourceModelID string `json:"sourceModelId"` + } `json:"candidates"` + } `json:"candidates"` + Prices map[string]struct { + Prompt float64 `json:"prompt"` + Completion float64 `json:"completion"` + Source string `json:"source"` + SourceModelID string `json:"sourceModelId"` + } `json:"prices"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(response.Sources) != 2 || response.Sources[0] != "models.dev" || response.Sources[1] != "litellm" { + t.Fatalf("source order = %#v", response.Sources) + } + if response.Imported != 4 || len(response.Candidates) != 2 { + t.Fatalf("sync selection = %#v", response) + } + candidateSources := map[string]map[string]bool{} + for _, set := range response.Candidates { + candidateSources[set.Model] = map[string]bool{} + for _, candidate := range set.Candidates { + candidateSources[set.Model][candidate.SourceModelID] = true + } + } + if !candidateSources["ambiguous"]["openai/ambiguous"] || + !candidateSources["ambiguous"]["azure/ambiguous"] || + !candidateSources["openai/gpt-test"]["openai/gpt-test"] || + !candidateSources["openai/gpt-test"]["crossmodel/openai/gpt-test"] { + t.Fatalf("candidate sources = %#v", candidateSources) + } + price, ok := response.Prices["gpt-test"] + if !ok || !closeFloat(price.Prompt, 9) || !closeFloat(price.Completion, 10) || price.Source != "models.dev" || price.SourceModelID != "openai/gpt-test" { + t.Fatalf("models.dev alias price = %#v", price) + } + scoped, ok := response.Prices["openai/ambiguous"] + if !ok || !closeFloat(scoped.Prompt, 3) || scoped.SourceModelID != "openai/ambiguous" { + t.Fatalf("provider-scoped ambiguous price = %#v", scoped) + } + crossmodel, ok := response.Prices["crossmodel/openai/gpt-test"] + if !ok || !closeFloat(crossmodel.Prompt, 11) || crossmodel.SourceModelID != "crossmodel/openai/gpt-test" { + t.Fatalf("nested provider-scoped price = %#v", crossmodel) + } + if _, ok := response.Prices["openai/gpt-test"]; ok { + t.Fatalf("colliding model was imported without confirmation: %#v", response.Prices["openai/gpt-test"]) + } + fallback, ok := response.Prices["fallback-only"] + if !ok || !closeFloat(fallback.Prompt, 1) || fallback.Source != "litellm" || fallback.SourceModelID != "fallback-only" { + t.Fatalf("fallback price = %#v", fallback) + } +} + +func TestModelPricesSyncCachesModelsDevAndSkipsCoveredFallbacks(t *testing.T) { + const etag = `"catalog-v1"` + var modelsDevRequests atomic.Int32 + modelsDevSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestNumber := modelsDevRequests.Add(1) + if requestNumber == 1 { + if received := r.Header.Get("If-None-Match"); received != "" { + http.Error(w, "unexpected conditional request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", etag) + _, _ = w.Write([]byte(`{"openai":{"models":{"gpt-test":{"cost":{"input":9,"output":10}}}}}`)) + return + } + if received := r.Header.Get("If-None-Match"); received != etag { + http.Error(w, "missing models.dev validator", http.StatusPreconditionFailed) + return + } + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNotModified) + })) + t.Cleanup(modelsDevSource.Close) + + var liteLLMRequests atomic.Int32 + liteLLMSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + liteLLMRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"gpt-test":{"input_cost_per_token":0.000001,"output_cost_per_token":0.000002}}`)) + })) + t.Cleanup(liteLLMSource.Close) + + var openRouterRequests atomic.Int32 + openRouterSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + openRouterRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-test","pricing":{"prompt":"0.000001","completion":"0.000002"}}]}`)) + })) + t.Cleanup(openRouterSource.Close) + + stubModelPriceSyncURLs(t, liteLLMSource.URL, openRouterSource.URL, modelsDevSource.URL) + handler := newTestHandler(t, "http://example.test", true) + for attempt := 1; attempt <= 2; attempt++ { + req := httptest.NewRequest( + http.MethodPost, + "/v0/management/model-prices/sync", + bytes.NewBufferString(`{"models":["gpt-test"]}`), + ) + req.Header.Set("Authorization", "Bearer "+testutil.AdminKey) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("sync attempt %d status = %d, body = %s", attempt, rr.Code, rr.Body.String()) + } + var response struct { + Sources []string `json:"sources"` + SourceResults []struct { + Source string `json:"source"` + } `json:"sourceResults"` + Prices map[string]struct { + Prompt float64 `json:"prompt"` + Source string `json:"source"` + SourceModelID string `json:"sourceModelId"` + } `json:"prices"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("decode sync attempt %d: %v", attempt, err) + } + if len(response.Sources) != 1 || response.Sources[0] != "models.dev" || + len(response.SourceResults) != 1 || response.SourceResults[0].Source != "models.dev" { + t.Fatalf("sync attempt %d sources = %#v, results = %#v", attempt, response.Sources, response.SourceResults) + } + price := response.Prices["gpt-test"] + if !closeFloat(price.Prompt, 9) || price.Source != "models.dev" || price.SourceModelID != "openai/gpt-test" { + t.Fatalf("sync attempt %d price = %#v", attempt, price) + } + } + + if got := modelsDevRequests.Load(); got != 2 { + t.Fatalf("models.dev requests = %d", got) + } + if got := liteLLMRequests.Load(); got != 0 { + t.Fatalf("LiteLLM requests = %d", got) + } + if got := openRouterRequests.Load(); got != 0 { + t.Fatalf("OpenRouter requests = %d", got) + } +} + func TestModelPricesSyncUsesCPAProxyURL(t *testing.T) { proxyObserved := make(chan string, 1) proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/manager-server/internal/service/modelprice/service.go b/apps/manager-server/internal/service/modelprice/service.go index 43ae24b33..2902dc62e 100644 --- a/apps/manager-server/internal/service/modelprice/service.go +++ b/apps/manager-server/internal/service/modelprice/service.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "errors" + "io" "math" "net/http" "net/url" "sort" "strconv" "strings" + "sync" "time" "unicode" @@ -18,6 +20,7 @@ import ( ) const ( + SyncSourceModelsDev = "models.dev" SyncSourceLiteLLM = "litellm" SyncSourceOpenRouter = "openrouter" SyncSourceMulti = "multi" @@ -94,13 +97,47 @@ func New(store *store.Store, syncURL *string, setupResolver ...SetupResolver) *S } func NewMultiSource(store *store.Store, liteLLMSyncURL *string, openRouterSyncURL *string, setupResolver ...SetupResolver) *Service { + return newMultiSource(store, nil, liteLLMSyncURL, openRouterSyncURL, setupResolver...) +} + +// NewMultiSourceWithModelsDev creates the production source chain. models.dev +// is deliberately first so its provider-scoped records win over the existing +// LiteLLM and OpenRouter fallbacks when both sources describe the same model. +func NewMultiSourceWithModelsDev( + store *store.Store, + modelsDevSyncURL *string, + liteLLMSyncURL *string, + openRouterSyncURL *string, + setupResolver ...SetupResolver, +) *Service { + return newMultiSource(store, modelsDevSyncURL, liteLLMSyncURL, openRouterSyncURL, setupResolver...) +} + +func newMultiSource( + store *store.Store, + modelsDevSyncURL *string, + liteLLMSyncURL *string, + openRouterSyncURL *string, + setupResolver ...SetupResolver, +) *Service { var resolver SetupResolver if len(setupResolver) > 0 { resolver = setupResolver[0] } - sources := []priceSyncSource{ - {Source: SyncSourceLiteLLM, URL: liteLLMSyncURL, Fetch: fetchLiteLLMModelPrices}, + sources := make([]priceSyncSource, 0, 3) + if modelsDevSyncURL != nil && strings.TrimSpace(*modelsDevSyncURL) != "" { + modelsDevCache := &modelsDevPriceCache{} + sources = append(sources, priceSyncSource{ + Source: SyncSourceModelsDev, + URL: modelsDevSyncURL, + Fetch: modelsDevCache.fetch, + }) } + sources = append(sources, priceSyncSource{ + Source: SyncSourceLiteLLM, + URL: liteLLMSyncURL, + Fetch: fetchLiteLLMModelPrices, + }) if openRouterSyncURL != nil { sources = append(sources, priceSyncSource{ Source: SyncSourceOpenRouter, @@ -134,7 +171,7 @@ func (s *Service) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) if err != nil { return SyncResult{}, err } - remotePrices, skipped, sources, sourceResults, err := s.fetchAllModelPrices(ctx, client) + remotePrices, skipped, sources, sourceResults, err := s.fetchAllModelPrices(ctx, client, req.Models) if err != nil { return SyncResult{}, err } @@ -165,9 +202,12 @@ func (s *Service) SyncFromLiteLLM(ctx context.Context, req SyncRequest) (SyncRes return s.Sync(ctx, req) } -func (s *Service) fetchAllModelPrices(ctx context.Context, client *http.Client) (map[string]store.ModelPrice, int, []string, []SyncSourceResult, error) { +func (s *Service) fetchAllModelPrices(ctx context.Context, client *http.Client, models []string) (map[string]store.ModelPrice, int, []string, []SyncSourceResult, error) { remotePrices := map[string]store.ModelPrice{} selectedPriorities := map[string]int{} + selectedNormalizedPriorities := map[string]int{} + modelsDevModelIDs := map[string]struct{}{} + requestedModels := normalizedRequestedModels(models) sources := make([]string, 0, len(s.syncSources)) sourceResults := make([]SyncSourceResult, 0, len(s.syncSources)) failures := []string{} @@ -194,8 +234,20 @@ func (s *Service) fetchAllModelPrices(ctx context.Context, client *http.Client) sourceResults = append(sourceResults, result) sources = append(sources, source.Source) totalSkipped += skipped + if source.Source == SyncSourceModelsDev { + modelsDevModelIDs = collectModelsDevModelIDs(prices) + } for modelID, price := range prices { + normalizedModelID := strings.ToLower(strings.TrimSpace(modelID)) + if source.Source != SyncSourceModelsDev { + if _, blocked := modelsDevModelIDs[normalizedModelID]; blocked { + continue + } + if selectedPriority, exists := selectedNormalizedPriorities[normalizedModelID]; exists && selectedPriority < priority { + continue + } + } if price.Source == "" { price.Source = source.Source } @@ -207,6 +259,10 @@ func (s *Service) fetchAllModelPrices(ctx context.Context, client *http.Client) } remotePrices[modelID] = price selectedPriorities[modelID] = priority + selectedNormalizedPriorities[normalizedModelID] = priority + } + if len(requestedModels) > 0 && modelPricesCoverRequested(remotePrices, requestedModels) { + break } } @@ -269,6 +325,252 @@ func defaultSyncHTTPClient() *http.Client { return &http.Client{Timeout: 30 * time.Second} } +type modelsDevPriceCache struct { + mu sync.Mutex + url string + etag string + prices map[string]store.ModelPrice + skipped int +} + +// fetchModelsDevModelPrices reads the provider-indexed models.dev catalog. +// models.dev prices are already expressed as USD per 1M tokens, unlike the +// token-level values published by LiteLLM and OpenRouter. +func fetchModelsDevModelPrices(ctx context.Context, syncURL string, client *http.Client) (map[string]store.ModelPrice, int, error) { + res, err := fetchModelsDevResponse(ctx, syncURL, client, "") + if err != nil { + return nil, 0, err + } + defer res.Body.Close() + if res.StatusCode == http.StatusNotModified { + return nil, 0, errors.New("model price sync failed: unexpected 304 Not Modified") + } + return decodeModelsDevModelPrices(res.Body) +} + +func (cache *modelsDevPriceCache) fetch(ctx context.Context, syncURL string, client *http.Client) (map[string]store.ModelPrice, int, error) { + cache.mu.Lock() + defer cache.mu.Unlock() + + if cache.url != syncURL { + cache.url = syncURL + cache.etag = "" + cache.prices = nil + cache.skipped = 0 + } + + res, err := fetchModelsDevResponse(ctx, syncURL, client, cache.etag) + if err != nil { + return nil, 0, err + } + defer res.Body.Close() + if res.StatusCode == http.StatusNotModified { + if cache.prices == nil { + return nil, 0, errors.New("model price sync failed: models.dev returned 304 without cached prices") + } + if etag := strings.TrimSpace(res.Header.Get("ETag")); etag != "" { + cache.etag = etag + } + return cache.prices, cache.skipped, nil + } + + prices, skipped, err := decodeModelsDevModelPrices(res.Body) + if err != nil { + return nil, 0, err + } + cache.etag = strings.TrimSpace(res.Header.Get("ETag")) + cache.prices = prices + cache.skipped = skipped + return prices, skipped, nil +} + +func fetchModelsDevResponse(ctx context.Context, syncURL string, client *http.Client, etag string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, syncURL, nil) + if err != nil { + return nil, errors.New("model price sync failed: " + err.Error()) + } + if etag != "" { + req.Header.Set("If-None-Match", etag) + } + if client == nil { + client = defaultSyncHTTPClient() + } + res, err := client.Do(req) + if err != nil { + return nil, errors.New("model price sync failed: " + err.Error()) + } + if res.StatusCode == http.StatusNotModified { + return res, nil + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + res.Body.Close() + return nil, errors.New("model price sync failed: " + res.Status) + } + return res, nil +} + +func decodeModelsDevModelPrices(reader io.Reader) (map[string]store.ModelPrice, int, error) { + var raw map[string]struct { + Models map[string]json.RawMessage `json:"models"` + } + decoder := json.NewDecoder(reader) + decoder.UseNumber() + if err := decoder.Decode(&raw); err != nil { + return nil, 0, err + } + + now := time.Now().UnixMilli() + prices := map[string]store.ModelPrice{} + skipped := 0 + providerIDs := make([]string, 0, len(raw)) + for providerID := range raw { + providerIDs = append(providerIDs, providerID) + } + sort.Strings(providerIDs) + + for _, rawProviderID := range providerIDs { + provider := raw[rawProviderID] + providerID := strings.TrimSpace(rawProviderID) + modelIDs := make([]string, 0, len(provider.Models)) + for modelID := range provider.Models { + modelIDs = append(modelIDs, modelID) + } + sort.Strings(modelIDs) + for _, rawModelID := range modelIDs { + modelRaw := provider.Models[rawModelID] + modelID := strings.TrimSpace(rawModelID) + if providerID == "" || modelID == "" { + skipped++ + continue + } + var entry map[string]any + if err := json.Unmarshal(modelRaw, &entry); err != nil { + skipped++ + continue + } + cost, ok := entry["cost"].(map[string]any) + if !ok { + skipped++ + continue + } + promptCost, hasPrompt := readFloat(cost, "input") + completionCost, hasCompletion := readFloat(cost, "output") + cacheReadCost, hasCacheRead := readFloat(cost, "cache_read") + cacheCreationCost, hasCacheCreation := readFloat(cost, "cache_write") + if !hasPrompt && !hasCompletion && !hasCacheRead && !hasCacheCreation { + skipped++ + continue + } + + sourceModelID := providerID + "/" + modelID + price := store.ModelPrice{ + Prompt: promptCost, + Completion: completionCost, + Cache: cacheReadCost, + CacheRead: cacheReadCost, + CacheCreation: cacheCreationCost, + PromptConfigured: hasPrompt, + CompletionConfigured: hasCompletion, + CacheReadConfigured: hasCacheRead, + CacheCreationConfigured: hasCacheCreation, + Source: SyncSourceModelsDev, + SourceModelID: sourceModelID, + RawJSON: string(modelRaw), + UpdatedAtMS: now, + SyncedAtMS: &now, + } + prices[sourceModelID] = price + } + } + + return prices, skipped, nil +} + +type basePriceRule struct { + Prompt float64 `json:"prompt"` + Completion float64 `json:"completion"` + Cache float64 `json:"cache"` + CacheRead float64 `json:"cacheRead"` + CacheCreation float64 `json:"cacheCreation"` + PromptConfigured bool `json:"promptConfigured"` + CompletionConfigured bool `json:"completionConfigured"` + CacheReadConfigured bool `json:"cacheReadConfigured"` + CacheCreationConfigured bool `json:"cacheCreationConfigured"` +} + +func basePriceRuleOf(price store.ModelPrice) basePriceRule { + return basePriceRule{ + Prompt: price.Prompt, + Completion: price.Completion, + Cache: price.Cache, + CacheRead: price.CacheRead, + CacheCreation: price.CacheCreation, + PromptConfigured: price.PromptConfigured, + CompletionConfigured: price.CompletionConfigured, + CacheReadConfigured: price.CacheReadConfigured, + CacheCreationConfigured: price.CacheCreationConfigured, + } +} + +func modelPriceRuleSignature(price store.ModelPrice) string { + if price.Source == SyncSourceModelsDev && price.RawJSON != "" { + if signature, ok := modelsDevPriceRuleSignature(price.RawJSON); ok { + return "models.dev:" + signature + } + } + rule, _ := json.Marshal(basePriceRuleOf(price)) + return "base:" + string(rule) +} + +func modelsDevPriceRuleSignature(rawJSON string) (string, bool) { + var entry map[string]any + if err := json.Unmarshal([]byte(rawJSON), &entry); err != nil { + return "", false + } + signature := map[string]any{} + if cost, ok := entry["cost"]; ok { + signature["cost"] = cost + } + if experimental, ok := entry["experimental"].(map[string]any); ok { + if modes, ok := experimental["modes"].(map[string]any); ok { + modeCosts := map[string]any{} + for modeName, rawMode := range modes { + mode, ok := rawMode.(map[string]any) + if !ok { + continue + } + if cost, ok := mode["cost"]; ok { + modeCosts[modeName] = cost + } + } + if len(modeCosts) > 0 { + signature["experimentalModeCosts"] = modeCosts + } + } + } + encoded, err := json.Marshal(signature) + return string(encoded), err == nil +} + +func modelsDevModelID(sourceModelID string) (string, bool) { + _, modelID, ok := strings.Cut(strings.TrimSpace(sourceModelID), "/") + modelID = strings.TrimSpace(modelID) + return modelID, ok && modelID != "" +} + +func collectModelsDevModelIDs(prices map[string]store.ModelPrice) map[string]struct{} { + modelIDs := map[string]struct{}{} + for _, price := range prices { + if price.Source != SyncSourceModelsDev { + continue + } + if modelID, ok := modelsDevModelID(price.SourceModelID); ok { + modelIDs[strings.ToLower(modelID)] = struct{}{} + } + } + return modelIDs +} + func fetchLiteLLMModelPrices(ctx context.Context, syncURL string, client *http.Client) (map[string]store.ModelPrice, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, syncURL, nil) if err != nil { @@ -392,15 +694,73 @@ type priceSelectionResult struct { Unmatched []string } +type modelPriceMatch struct { + Key string + Price store.ModelPrice +} + +type modelPriceEntry struct { + key string + price store.ModelPrice + identities []string + signature string + signatureReady bool +} + +type modelPriceMatcher struct { + entries []modelPriceEntry + exact map[string][]int + caseFold map[string][]int + tail map[string][]int + canonical map[string][]int +} + +func newModelPriceMatcher(prices map[string]store.ModelPrice) *modelPriceMatcher { + matcher := &modelPriceMatcher{ + entries: make([]modelPriceEntry, 0, len(prices)), + exact: make(map[string][]int, len(prices)), + caseFold: make(map[string][]int, len(prices)), + tail: make(map[string][]int, len(prices)), + canonical: make(map[string][]int, len(prices)), + } + for _, key := range sortedPriceKeys(prices) { + price := prices[key] + identities := modelPriceIdentities(key, price) + entryIndex := len(matcher.entries) + matcher.entries = append(matcher.entries, modelPriceEntry{ + key: key, + price: price, + identities: identities, + }) + for _, identity := range identities { + appendModelPriceIndex(matcher.exact, identity, entryIndex) + appendModelPriceIndex(matcher.caseFold, strings.ToLower(identity), entryIndex) + appendModelPriceIndex(matcher.tail, canonicalModelTail(identity), entryIndex) + appendModelPriceIndex(matcher.canonical, canonicalModelID(identity), entryIndex) + } + } + return matcher +} + +func appendModelPriceIndex(index map[string][]int, identity string, entryIndex int) { + if identity == "" { + return + } + matches := index[identity] + if len(matches) > 0 && matches[len(matches)-1] == entryIndex { + return + } + index[identity] = append(matches, entryIndex) +} + func selectModelPrices(prices map[string]store.ModelPrice, models []string) priceSelectionResult { result := priceSelectionResult{ Prices: map[string]store.ModelPrice{}, Matched: map[string]store.ModelPrice{}, } + matcher := newModelPriceMatcher(prices) if len(models) == 0 { - result.Prices = prices - result.Matched = prices - return result + return matcher.selectAllUnambiguousModelPrices() } seen := map[string]bool{} for _, modelID := range models { @@ -409,13 +769,18 @@ func selectModelPrices(prices map[string]store.ModelPrice, models []string) pric continue } seen[normalized] = true - price, _, ok := findAutomaticModelPrice(prices, normalized) + price, _, ok, indexedMatches := matcher.findAutomaticModelPrice(normalized) if ok { result.Prices[normalized] = price result.Matched[normalized] = price continue } - candidates := findCandidateModelPrices(prices, normalized) + var candidates []SyncCandidate + if len(indexedMatches) > 0 { + candidates = matcher.candidateModelPricesForIndexes(normalized, indexedMatches) + } else { + candidates = matcher.findCandidateModelPrices(normalized) + } if len(candidates) > 0 { result.Candidates = append(result.Candidates, SyncCandidateSet{ Model: normalized, @@ -428,53 +793,257 @@ func selectModelPrices(prices map[string]store.ModelPrice, models []string) pric return result } +func selectAllUnambiguousModelPrices(prices map[string]store.ModelPrice) priceSelectionResult { + return newModelPriceMatcher(prices).selectAllUnambiguousModelPrices() +} + +func (matcher *modelPriceMatcher) selectAllUnambiguousModelPrices() priceSelectionResult { + result := priceSelectionResult{ + Prices: map[string]store.ModelPrice{}, + Matched: map[string]store.ModelPrice{}, + } + modelIDs := map[string]struct{}{} + for entryIndex := range matcher.entries { + entry := &matcher.entries[entryIndex] + if entry.price.Source == SyncSourceModelsDev { + if modelID, ok := modelsDevModelID(entry.price.SourceModelID); ok { + modelIDs[modelID] = struct{}{} + } + continue + } + modelIDs[entry.key] = struct{}{} + } + orderedModelIDs := make([]string, 0, len(modelIDs)) + for modelID := range modelIDs { + orderedModelIDs = append(orderedModelIDs, modelID) + } + sort.Strings(orderedModelIDs) + for _, modelID := range orderedModelIDs { + price, ok := matcher.selectUnambiguousModelPrice(matcher.exact[modelID]) + if !ok { + continue + } + result.Prices[modelID] = price + result.Matched[modelID] = price + } + return result +} + func findAutomaticModelPrice(prices map[string]store.ModelPrice, modelID string) (store.ModelPrice, string, bool) { + price, reason, ok, _ := newModelPriceMatcher(prices).findAutomaticModelPrice(modelID) + return price, reason, ok +} + +func (matcher *modelPriceMatcher) findAutomaticModelPrice(modelID string) (store.ModelPrice, string, bool, []int) { + matches, reason := matcher.indexedModelPriceMatches(modelID) + if len(matches) == 0 { + return store.ModelPrice{}, "", false, nil + } + price, ok := matcher.selectUnambiguousModelPrice(matches) + return price, reason, ok, matches +} + +func (matcher *modelPriceMatcher) indexedModelPriceMatches(modelID string) ([]int, string) { modelID = strings.TrimSpace(modelID) if modelID == "" { - return store.ModelPrice{}, "", false + return nil, "" } - if price, ok := prices[modelID]; ok { - return price, "exact", true + if matches := matcher.exact[modelID]; len(matches) > 0 { + return matches, "exact" } - keys := sortedPriceKeys(prices) - if key, ok := uniqueMatch(keys, func(key string) bool { - return strings.EqualFold(key, modelID) - }); ok { - return prices[key], "case-insensitive", true + if matches := matcher.caseFold[strings.ToLower(modelID)]; len(matches) > 0 { + return matches, "case-insensitive" } modelTail := canonicalModelTail(modelID) if modelTail != "" { - if key, ok := uniqueMatch(keys, func(key string) bool { - return canonicalModelTail(key) == modelTail - }); ok { - return prices[key], "provider-prefix", true + if matches := matcher.tail[modelTail]; len(matches) > 0 { + return matches, "provider-prefix" } } modelCanonical := canonicalModelID(modelID) if modelCanonical != "" { - if key, ok := uniqueMatch(keys, func(key string) bool { - return canonicalModelID(key) == modelCanonical - }); ok { - return prices[key], "normalized", true + if matches := matcher.canonical[modelCanonical]; len(matches) > 0 { + return matches, "normalized" + } + } + return nil, "" +} + +func findModelPriceMatches(prices map[string]store.ModelPrice, matchIdentity func(string) bool) []modelPriceMatch { + matches := []modelPriceMatch{} + for _, key := range sortedPriceKeys(prices) { + price := prices[key] + for _, identity := range modelPriceIdentities(key, price) { + if !matchIdentity(identity) { + continue + } + matches = append(matches, modelPriceMatch{Key: key, Price: price}) + break + } + } + return matches +} + +func selectUnambiguousModelPrice(matches []modelPriceMatch) (store.ModelPrice, bool) { + if len(matches) == 0 { + return store.ModelPrice{}, false + } + if len(matches) == 1 { + return matches[0].Price, true + } + signature := modelPriceRuleSignature(matches[0].Price) + selected := matches[0] + for _, match := range matches[1:] { + if modelPriceRuleSignature(match.Price) != signature { + return store.ModelPrice{}, false + } + if modelPriceMatchLess(match, selected) { + selected = match + } + } + return selected.Price, true +} + +func modelPriceMatchLess(left modelPriceMatch, right modelPriceMatch) bool { + leftPriority := modelPriceSourcePriority(left.Price.Source) + rightPriority := modelPriceSourcePriority(right.Price.Source) + if leftPriority != rightPriority { + return leftPriority < rightPriority + } + leftID := strings.TrimSpace(left.Price.SourceModelID) + rightID := strings.TrimSpace(right.Price.SourceModelID) + if leftID != rightID { + return leftID < rightID + } + return left.Key < right.Key +} + +func (matcher *modelPriceMatcher) selectUnambiguousModelPrice(matches []int) (store.ModelPrice, bool) { + if len(matches) == 0 { + return store.ModelPrice{}, false + } + if len(matches) == 1 { + return matcher.entries[matches[0]].price, true + } + signature := matcher.modelPriceRuleSignature(matches[0]) + selected := matches[0] + for _, entryIndex := range matches[1:] { + if matcher.modelPriceRuleSignature(entryIndex) != signature { + return store.ModelPrice{}, false + } + if modelPriceEntryLess(&matcher.entries[entryIndex], &matcher.entries[selected]) { + selected = entryIndex } } - return store.ModelPrice{}, "", false + return matcher.entries[selected].price, true +} + +func (matcher *modelPriceMatcher) modelPriceRuleSignature(entryIndex int) string { + entry := &matcher.entries[entryIndex] + if !entry.signatureReady { + entry.signature = modelPriceRuleSignature(entry.price) + entry.signatureReady = true + } + return entry.signature +} + +func modelPriceEntryLess(left *modelPriceEntry, right *modelPriceEntry) bool { + leftPriority := modelPriceSourcePriority(left.price.Source) + rightPriority := modelPriceSourcePriority(right.price.Source) + if leftPriority != rightPriority { + return leftPriority < rightPriority + } + leftID := strings.TrimSpace(left.price.SourceModelID) + rightID := strings.TrimSpace(right.price.SourceModelID) + if leftID != rightID { + return leftID < rightID + } + return left.key < right.key +} + +func modelPriceSourcePriority(source string) int { + switch source { + case SyncSourceModelsDev: + return 0 + case SyncSourceLiteLLM: + return 1 + case SyncSourceOpenRouter: + return 2 + default: + return 3 + } +} + +func modelPriceIdentities(key string, price store.ModelPrice) []string { + identities := make([]string, 0, 3) + add := func(identity string) { + identity = strings.TrimSpace(identity) + if identity == "" { + return + } + for _, existing := range identities { + if existing == identity { + return + } + } + identities = append(identities, identity) + } + add(key) + add(price.SourceModelID) + if price.Source == SyncSourceModelsDev { + if modelID, ok := modelsDevModelID(price.SourceModelID); ok { + add(modelID) + } + } + return identities } func findCandidateModelPrices(prices map[string]store.ModelPrice, modelID string) []SyncCandidate { + return newModelPriceMatcher(prices).findCandidateModelPrices(modelID) +} + +func (matcher *modelPriceMatcher) findCandidateModelPrices(modelID string) []SyncCandidate { candidates := make([]SyncCandidate, 0, maxSyncCandidates) - for _, key := range sortedPriceKeys(prices) { - score, reason := modelSimilarity(modelID, key) - if score < minCandidateScore && !(score >= minWeakCandidateScore && isWeakRecallReason(reason)) { - continue + for entryIndex := range matcher.entries { + candidates = appendModelPriceCandidate(candidates, modelID, &matcher.entries[entryIndex]) + } + return sortAndLimitModelPriceCandidates(candidates) +} + +func (matcher *modelPriceMatcher) candidateModelPricesForIndexes(modelID string, indexes []int) []SyncCandidate { + candidates := make([]SyncCandidate, 0, maxSyncCandidates) + for _, entryIndex := range indexes { + candidates = appendModelPriceCandidate(candidates, modelID, &matcher.entries[entryIndex]) + } + return sortAndLimitModelPriceCandidates(candidates) +} + +func appendModelPriceCandidate(candidates []SyncCandidate, modelID string, entry *modelPriceEntry) []SyncCandidate { + score := 0.0 + reason := "" + for _, identity := range entry.identities { + candidateScore, candidateReason := modelIdentitySimilarity(modelID, identity) + if candidateScore > score { + score = candidateScore + reason = candidateReason } - candidates = append(candidates, SyncCandidate{ - SourceModelID: key, - Score: math.Round(score*100) / 100, - Reason: reason, - Price: prices[key], - }) } + if score < minCandidateScore && !(score >= minWeakCandidateScore && isWeakRecallReason(reason)) { + return candidates + } + sourceModelID := strings.TrimSpace(entry.price.SourceModelID) + if sourceModelID == "" { + sourceModelID = entry.key + } + return append(candidates, SyncCandidate{ + SourceModelID: sourceModelID, + Score: math.Round(score*100) / 100, + Reason: reason, + Price: entry.price, + }) +} + +func sortAndLimitModelPriceCandidates(candidates []SyncCandidate) []SyncCandidate { sort.SliceStable(candidates, func(i, j int) bool { if candidates[i].Score == candidates[j].Score { return candidates[i].SourceModelID < candidates[j].SourceModelID @@ -487,6 +1056,47 @@ func findCandidateModelPrices(prices map[string]store.ModelPrice, modelID string return candidates } +func normalizedRequestedModels(models []string) []string { + normalized := make([]string, 0, len(models)) + seen := map[string]struct{}{} + for _, modelID := range models { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + normalized = append(normalized, modelID) + } + return normalized +} + +func modelPricesCoverRequested(prices map[string]store.ModelPrice, models []string) bool { + if len(models) == 0 { + return false + } + matcher := newModelPriceMatcher(prices) + for _, modelID := range models { + matches, _ := matcher.indexedModelPriceMatches(modelID) + if len(matches) == 0 { + return false + } + } + return true +} + +func modelIdentitySimilarity(left string, right string) (float64, string) { + if left == right { + return 1, "exact-model-id" + } + if strings.EqualFold(left, right) { + return 0.98, "case-insensitive-model-id" + } + return modelSimilarity(left, right) +} + func sortedPriceKeys(prices map[string]store.ModelPrice) []string { keys := make([]string, 0, len(prices)) for key := range prices { @@ -496,20 +1106,6 @@ func sortedPriceKeys(prices map[string]store.ModelPrice) []string { return keys } -func uniqueMatch(keys []string, match func(string) bool) (string, bool) { - matchedKey := "" - for _, key := range keys { - if !match(key) { - continue - } - if matchedKey != "" { - return "", false - } - matchedKey = key - } - return matchedKey, matchedKey != "" -} - func modelSimilarity(left string, right string) (float64, string) { leftTail := canonicalModelTail(left) rightTail := canonicalModelTail(right) diff --git a/apps/manager-server/internal/service/modelprice/service_benchmark_test.go b/apps/manager-server/internal/service/modelprice/service_benchmark_test.go new file mode 100644 index 000000000..376f3e329 --- /dev/null +++ b/apps/manager-server/internal/service/modelprice/service_benchmark_test.go @@ -0,0 +1,56 @@ +package modelprice + +import ( + "fmt" + "testing" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" +) + +var benchmarkPriceSelection priceSelectionResult + +func BenchmarkSelectModelPrices(b *testing.B) { + prices := benchmarkModelPrices(7_500) + for _, modelCount := range []int{100, 1_000} { + models := benchmarkRequestedModels(7_500, modelCount) + b.Run(fmt.Sprintf("Candidates7500/Models%d", modelCount), func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for range b.N { + benchmarkPriceSelection = selectModelPrices(prices, models) + } + }) + } +} + +func benchmarkModelPrices(count int) map[string]store.ModelPrice { + prices := make(map[string]store.ModelPrice, count) + for i := range count { + modelID := fmt.Sprintf("model-%05d", i) + providerID := fmt.Sprintf("provider-%02d", i%32) + sourceModelID := providerID + "/" + modelID + price := store.ModelPrice{ + Prompt: float64(i%11) + 0.25, + Completion: float64(i%17) + 0.5, + PromptConfigured: true, + CompletionConfigured: true, + SourceModelID: sourceModelID, + } + if i < 5_400 { + price.Source = SyncSourceModelsDev + price.RawJSON = fmt.Sprintf(`{"cost":{"input":%g,"output":%g}}`, price.Prompt, price.Completion) + } else { + price.Source = SyncSourceLiteLLM + } + prices[sourceModelID] = price + } + return prices +} + +func benchmarkRequestedModels(candidateCount int, modelCount int) []string { + models := make([]string, 0, modelCount) + for i := range modelCount { + models = append(models, fmt.Sprintf("model-%05d", (i*7)%candidateCount)) + } + return models +} diff --git a/apps/manager-server/internal/service/modelprice/service_test.go b/apps/manager-server/internal/service/modelprice/service_test.go index 9930522b5..03db2c141 100644 --- a/apps/manager-server/internal/service/modelprice/service_test.go +++ b/apps/manager-server/internal/service/modelprice/service_test.go @@ -4,6 +4,9 @@ import ( "context" "net/http" "net/http/httptest" + "strings" + "sync" + "sync/atomic" "testing" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" @@ -11,6 +14,452 @@ import ( "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" ) +func TestFetchModelsDevModelPrices(t *testing.T) { + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "provider-a": {"models": { + "shared-model": {"name":"Shared A", "cost":{"input":1,"output":2,"cache_read":0.1,"cache_write":0.2,"tiers":[{"input":3,"output":4,"tier":{"type":"context","size":200000}}]}}, + "unique-model": {"cost":{"input":3,"output":4}} + }}, + "provider-b": {"models": { + "shared-model": {"cost":{"input":1.5,"output":2.5,"cache_read":0.15,"cache_write":0.25}}, + "same-rule": {"cost":{"input":5,"output":6}} + }}, + "provider-c": {"models": { + "same-rule": {"cost":{"output":6,"input":5}} + }}, + "provider-empty": {"models": {"uncosted": {"limit":{"context":1000}}}} + }`)) + })) + t.Cleanup(source.Close) + + prices, skipped, err := fetchModelsDevModelPrices(context.Background(), source.URL, source.Client()) + if err != nil { + t.Fatalf("fetch models.dev prices: %v", err) + } + if skipped != 1 { + t.Fatalf("skipped = %d", skipped) + } + + shared, ok := prices["provider-a/shared-model"] + if !ok { + t.Fatalf("missing provider-scoped model: %#v", prices) + } + if shared.Prompt != 1 || shared.Completion != 2 || shared.CacheRead != 0.1 || shared.CacheCreation != 0.2 || + !shared.PromptConfigured || !shared.CompletionConfigured || !shared.CacheReadConfigured || !shared.CacheCreationConfigured { + t.Fatalf("base price mapping = %#v", shared) + } + if shared.Source != SyncSourceModelsDev || shared.SourceModelID != "provider-a/shared-model" { + t.Fatalf("source metadata = %#v", shared) + } + if !strings.Contains(shared.RawJSON, `"tiers"`) { + t.Fatalf("raw model metadata was not retained: %s", shared.RawJSON) + } + + for _, alias := range []string{"shared-model", "unique-model", "same-rule"} { + if _, ok := prices[alias]; ok { + t.Fatalf("fetch catalog unexpectedly materialized alias %q: %#v", alias, prices[alias]) + } + } + selection := selectModelPrices(prices, []string{"unique-model", "same-rule"}) + unique, ok := selection.Prices["unique-model"] + if !ok || unique.SourceModelID != "provider-a/unique-model" { + t.Fatalf("unique alias = %#v", unique) + } + sameRule, ok := selection.Prices["same-rule"] + if !ok || sameRule.SourceModelID != "provider-b/same-rule" { + t.Fatalf("same-rule alias = %#v", sameRule) + } + if len(selection.Candidates) != 0 || len(selection.Unmatched) != 0 { + t.Fatalf("unexpected selection result = %#v", selection) + } +} + +func TestModelsDevPriceCacheReusesETagConcurrently(t *testing.T) { + const etag = `"catalog-v1"` + var requestCount atomic.Int32 + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestNumber := requestCount.Add(1) + if requestNumber == 1 { + if received := r.Header.Get("If-None-Match"); received != "" { + http.Error(w, "unexpected conditional request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", etag) + _, _ = w.Write([]byte(`{ + "provider-a":{"models":{ + "cached":{"cost":{"input":1,"output":2}}, + "uncosted":{"limit":{"context":1000}} + }} + }`)) + return + } + if received := r.Header.Get("If-None-Match"); received != etag { + http.Error(w, "missing cache validator", http.StatusPreconditionFailed) + return + } + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNotModified) + })) + t.Cleanup(source.Close) + + cache := &modelsDevPriceCache{} + prices, skipped, err := cache.fetch(context.Background(), source.URL, source.Client()) + if err != nil { + t.Fatalf("prime models.dev cache: %v", err) + } + if skipped != 1 || prices["provider-a/cached"].Prompt != 1 { + t.Fatalf("primed prices = %#v, skipped = %d", prices, skipped) + } + + const workers = 8 + type fetchResult struct { + prices map[string]store.ModelPrice + skipped int + err error + } + results := make(chan fetchResult, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + cachedPrices, cachedSkipped, fetchErr := cache.fetch(context.Background(), source.URL, source.Client()) + results <- fetchResult{prices: cachedPrices, skipped: cachedSkipped, err: fetchErr} + }() + } + wg.Wait() + close(results) + for result := range results { + if result.err != nil { + t.Fatalf("reuse models.dev cache: %v", result.err) + } + if result.skipped != 1 || result.prices["provider-a/cached"].Completion != 2 { + t.Fatalf("cached prices = %#v, skipped = %d", result.prices, result.skipped) + } + } + if got := requestCount.Load(); got != workers+1 { + t.Fatalf("request count = %d", got) + } +} + +func TestModelsDevPriceCacheDoesNotServeStaleDataAndInvalidatesURL(t *testing.T) { + var invalidResponse atomic.Bool + firstSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if invalidResponse.Load() { + w.Header().Set("ETag", `"catalog-v2"`) + _, _ = w.Write([]byte(`{"provider-a":`)) + return + } + w.Header().Set("ETag", `"catalog-v1"`) + _, _ = w.Write([]byte(`{"provider-a":{"models":{"cached":{"cost":{"input":1}}}}}`)) + })) + t.Cleanup(firstSource.Close) + + cache := &modelsDevPriceCache{} + if _, _, err := cache.fetch(context.Background(), firstSource.URL, firstSource.Client()); err != nil { + t.Fatalf("prime models.dev cache: %v", err) + } + invalidResponse.Store(true) + prices, skipped, err := cache.fetch(context.Background(), firstSource.URL, firstSource.Client()) + if err == nil || prices != nil || skipped != 0 { + t.Fatalf("stale cache served after parse failure: prices=%#v skipped=%d err=%v", prices, skipped, err) + } + + secondSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if received := r.Header.Get("If-None-Match"); received != "" { + http.Error(w, "etag leaked across URLs", http.StatusPreconditionFailed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"other-catalog"`) + _, _ = w.Write([]byte(`{"provider-b":{"models":{"fresh":{"cost":{"input":9}}}}}`)) + })) + t.Cleanup(secondSource.Close) + + prices, skipped, err = cache.fetch(context.Background(), secondSource.URL, secondSource.Client()) + if err != nil { + t.Fatalf("fetch changed models.dev URL: %v", err) + } + if skipped != 0 || prices["provider-b/fresh"].Prompt != 9 { + t.Fatalf("changed URL prices = %#v, skipped = %d", prices, skipped) + } +} + +func TestModelsDevCacheFailureFallsBackWithoutStalePrices(t *testing.T) { + var invalidResponse atomic.Bool + modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if invalidResponse.Load() { + w.Header().Set("ETag", `"catalog-v2"`) + _, _ = w.Write([]byte(`{"openai":`)) + return + } + w.Header().Set("ETag", `"catalog-v1"`) + _, _ = w.Write([]byte(`{"openai":{"models":{"gpt-test":{"cost":{"input":9,"output":10}}}}}`)) + })) + t.Cleanup(modelsDev.Close) + + var liteLLMRequests atomic.Int32 + liteLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + liteLLMRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"gpt-test":{"input_cost_per_token":0.000001,"output_cost_per_token":0.000002}}`)) + })) + t.Cleanup(liteLLM.Close) + + modelsDevURL := modelsDev.URL + liteLLMURL := liteLLM.URL + service := NewMultiSourceWithModelsDev(nil, &modelsDevURL, &liteLLMURL, nil) + prices, _, sources, _, err := service.fetchAllModelPrices(context.Background(), modelsDev.Client(), []string{"gpt-test"}) + if err != nil { + t.Fatalf("prime models.dev source: %v", err) + } + if len(sources) != 1 || sources[0] != SyncSourceModelsDev || prices["openai/gpt-test"].Prompt != 9 { + t.Fatalf("primed sources = %#v, prices = %#v", sources, prices) + } + if got := liteLLMRequests.Load(); got != 0 { + t.Fatalf("LiteLLM requests during prime = %d", got) + } + + invalidResponse.Store(true) + prices, _, sources, sourceResults, err := service.fetchAllModelPrices(context.Background(), modelsDev.Client(), []string{"gpt-test"}) + if err != nil { + t.Fatalf("fallback after models.dev failure: %v", err) + } + if len(sources) != 1 || sources[0] != SyncSourceLiteLLM { + t.Fatalf("fallback sources = %#v", sources) + } + if len(sourceResults) != 2 || sourceResults[0].Source != SyncSourceModelsDev || sourceResults[0].Error == "" || sourceResults[1].Source != SyncSourceLiteLLM { + t.Fatalf("fallback source results = %#v", sourceResults) + } + price := prices["gpt-test"] + if price.Source != SyncSourceLiteLLM || price.Prompt != 1 { + t.Fatalf("fallback price = %#v", price) + } + if _, exists := prices["openai/gpt-test"]; exists { + t.Fatalf("stale models.dev price was reused: %#v", prices) + } +} + +func TestSelectModelPricesRequiresConfirmationForScopedIdentityCollision(t *testing.T) { + prices := map[string]store.ModelPrice{ + "openai/gpt-test": { + Prompt: 1, + Completion: 2, + Source: SyncSourceModelsDev, + SourceModelID: "openai/gpt-test", + RawJSON: `{"cost":{"input":1,"output":2}}`, + PromptConfigured: true, + }, + "crossmodel/openai/gpt-test": { + Prompt: 3, + Completion: 4, + Source: SyncSourceModelsDev, + SourceModelID: "crossmodel/openai/gpt-test", + RawJSON: `{"cost":{"input":3,"output":4}}`, + PromptConfigured: true, + }, + } + + selection := selectModelPrices(prices, []string{"openai/gpt-test"}) + if len(selection.Prices) != 0 || len(selection.Candidates) != 1 { + t.Fatalf("collision selection = %#v", selection) + } + if !hasCandidate(selection, "openai/gpt-test", "openai/gpt-test") || + !hasCandidate(selection, "openai/gpt-test", "crossmodel/openai/gpt-test") { + t.Fatalf("collision candidates = %#v", selection.Candidates) + } + + scoped := selectModelPrices(prices, []string{"crossmodel/openai/gpt-test"}) + if scoped.Prices["crossmodel/openai/gpt-test"].SourceModelID != "crossmodel/openai/gpt-test" { + t.Fatalf("scoped selection = %#v", scoped) + } + + all := selectModelPrices(prices, nil) + if _, ok := all.Prices["openai/gpt-test"]; ok { + t.Fatalf("unsafe colliding alias imported by empty sync: %#v", all.Prices) + } + if all.Prices["gpt-test"].SourceModelID != "openai/gpt-test" { + t.Fatalf("safe direct alias missing from empty sync: %#v", all.Prices) + } +} + +func TestSelectModelPricesTreatsAdvancedPricingDifferencesAsAmbiguous(t *testing.T) { + prices := map[string]store.ModelPrice{ + "provider-a/shared": { + Prompt: 1, + Completion: 2, + PromptConfigured: true, + CompletionConfigured: true, + Source: SyncSourceModelsDev, + SourceModelID: "provider-a/shared", + RawJSON: `{"cost":{"input":1,"output":2,"tiers":[{"input":2,"output":4,"tier":{"type":"context","size":200000}}]}}`, + }, + "provider-b/shared": { + Prompt: 1, + Completion: 2, + PromptConfigured: true, + CompletionConfigured: true, + Source: SyncSourceModelsDev, + SourceModelID: "provider-b/shared", + RawJSON: `{"cost":{"input":1,"output":2}}`, + }, + } + + selection := selectModelPrices(prices, []string{"shared"}) + if len(selection.Prices) != 0 || len(selection.Candidates) != 1 || + !hasCandidate(selection, "shared", "provider-a/shared") || + !hasCandidate(selection, "shared", "provider-b/shared") { + t.Fatalf("advanced price conflict = %#v", selection) + } +} + +func TestModelsDevAmbiguityBlocksLowerPriorityBareFallback(t *testing.T) { + modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "provider-a":{"models":{"shared":{"cost":{"input":1,"output":2}}}}, + "provider-b":{"models":{"shared":{"cost":{"input":3,"output":4}}}} + }`)) + })) + t.Cleanup(modelsDev.Close) + liteLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"shared":{"input_cost_per_token":0.000009,"output_cost_per_token":0.000009},"fallback-only":{"input_cost_per_token":0.000001}}`)) + })) + t.Cleanup(liteLLM.Close) + + modelsDevURL := modelsDev.URL + liteLLMURL := liteLLM.URL + service := NewMultiSourceWithModelsDev(nil, &modelsDevURL, &liteLLMURL, nil) + prices, _, sources, _, err := service.fetchAllModelPrices(context.Background(), modelsDev.Client(), nil) + if err != nil { + t.Fatalf("fetch all prices: %v", err) + } + if len(sources) != 2 || sources[0] != SyncSourceModelsDev || sources[1] != SyncSourceLiteLLM { + t.Fatalf("sources = %#v", sources) + } + if _, ok := prices["shared"]; ok { + t.Fatalf("lower-priority bare fallback bypassed ambiguity protection: %#v", prices["shared"]) + } + if _, ok := prices["fallback-only"]; !ok { + t.Fatalf("unrelated fallback model missing: %#v", prices) + } +} + +func TestFetchAllModelPricesStopsAfterRequestedModelsAreCovered(t *testing.T) { + modelsDevPrices := map[string]store.ModelPrice{ + "provider-a/primary": { + Prompt: 1, + PromptConfigured: true, + Source: SyncSourceModelsDev, + SourceModelID: "provider-a/primary", + }, + "provider-a/shared": { + Prompt: 2, + PromptConfigured: true, + Source: SyncSourceModelsDev, + SourceModelID: "provider-a/shared", + RawJSON: `{"cost":{"input":2}}`, + }, + "provider-b/shared": { + Prompt: 3, + PromptConfigured: true, + Source: SyncSourceModelsDev, + SourceModelID: "provider-b/shared", + RawJSON: `{"cost":{"input":3}}`, + }, + } + liteLLMPrices := map[string]store.ModelPrice{ + "lite-only": { + Prompt: 4, + PromptConfigured: true, + Source: SyncSourceLiteLLM, + SourceModelID: "lite-only", + }, + } + openRouterPrices := map[string]store.ModelPrice{ + "router-only": { + Prompt: 5, + PromptConfigured: true, + Source: SyncSourceOpenRouter, + SourceModelID: "router-only", + }, + } + + tests := []struct { + name string + models []string + wantSources string + wantCalls [3]int32 + }{ + {name: "models.dev coverage", models: []string{"primary"}, wantSources: SyncSourceModelsDev, wantCalls: [3]int32{1, 0, 0}}, + {name: "models.dev ambiguity", models: []string{"shared"}, wantSources: SyncSourceModelsDev, wantCalls: [3]int32{1, 0, 0}}, + {name: "LiteLLM completes coverage", models: []string{"primary", "lite-only"}, wantSources: SyncSourceModelsDev + "," + SyncSourceLiteLLM, wantCalls: [3]int32{1, 1, 0}}, + {name: "OpenRouter still required", models: []string{"router-only"}, wantSources: SyncSourceModelsDev + "," + SyncSourceLiteLLM + "," + SyncSourceOpenRouter, wantCalls: [3]int32{1, 1, 1}}, + {name: "empty request fetches all", models: nil, wantSources: SyncSourceModelsDev + "," + SyncSourceLiteLLM + "," + SyncSourceOpenRouter, wantCalls: [3]int32{1, 1, 1}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var calls [3]atomic.Int32 + syncURL := "https://example.test/prices" + service := &Service{syncSources: []priceSyncSource{ + { + Source: SyncSourceModelsDev, + URL: &syncURL, + Fetch: func(context.Context, string, *http.Client) (map[string]store.ModelPrice, int, error) { + calls[0].Add(1) + return modelsDevPrices, 0, nil + }, + }, + { + Source: SyncSourceLiteLLM, + URL: &syncURL, + Fetch: func(context.Context, string, *http.Client) (map[string]store.ModelPrice, int, error) { + calls[1].Add(1) + return liteLLMPrices, 0, nil + }, + }, + { + Source: SyncSourceOpenRouter, + URL: &syncURL, + Fetch: func(context.Context, string, *http.Client) (map[string]store.ModelPrice, int, error) { + calls[2].Add(1) + return openRouterPrices, 0, nil + }, + }, + }} + + prices, _, sources, sourceResults, err := service.fetchAllModelPrices(context.Background(), nil, test.models) + if err != nil { + t.Fatalf("fetch model prices: %v", err) + } + if got := strings.Join(sources, ","); got != test.wantSources { + t.Fatalf("sources = %q", got) + } + if len(sourceResults) != len(sources) { + t.Fatalf("source results = %#v", sourceResults) + } + for index, want := range test.wantCalls { + if got := calls[index].Load(); got != want { + t.Fatalf("source %d calls = %d, want %d", index, got, want) + } + } + if test.name == "models.dev ambiguity" { + selection := selectModelPrices(prices, test.models) + if len(selection.Prices) != 0 || len(selection.Candidates) != 1 { + t.Fatalf("ambiguity selection = %#v", selection) + } + } + }) + } +} + func TestUsageSummaryUsesConfiguredRecentLimit(t *testing.T) { cfg := testutil.NewConfig(t) st := testutil.NewStore(t, cfg) From 871238e3aa3cd5ccfdeae1cf54bb44fbb3616d23 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 14:05:49 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E2=9C=A8=20feat(web):=20clarify=20model?= =?UTF-8?q?=20price=20source=20priority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update model price synchronization guidance in all supported locales. Identify models.dev as the preferred source and LiteLLM and OpenRouter as fallbacks. Keep the displayed behavior aligned with the Manager Server implementation without changing the frontend API contract. --- apps/web/src/i18n/locales/en.json | 2 +- apps/web/src/i18n/locales/ru.json | 2 +- apps/web/src/i18n/locales/zh-CN.json | 2 +- apps/web/src/i18n/locales/zh-TW.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 77856cf0d..ff96a1cbd 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -1503,7 +1503,7 @@ "summary_missing": "Missing prices", "summary_candidates": "Pending confirmation", "sync_title": "Price Sync", - "sync_idle": "Sync currently used models from LiteLLM, OpenRouter, and other pricing metadata sources. Ambiguous matches will be listed for confirmation.", + "sync_idle": "Sync currently used models from models.dev first, with LiteLLM and OpenRouter fallbacks. Ambiguous matches will be listed for confirmation.", "sync_result": "Sources {{sources}}, imported {{imported}}, skipped {{skipped}}, {{proxy}}.", "sync_success_detail": "Sync complete: imported {{imported}}, pending {{candidates}}, unmatched {{unmatched}}.", "source_result_ok": "{{models}} models, skipped {{skipped}}", diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index 8e9ae4f97..bb23bccc7 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -1503,7 +1503,7 @@ "summary_missing": "Цены не заданы", "summary_candidates": "Ждут подтверждения", "sync_title": "Синхронизация цен", - "sync_idle": "Синхронизируйте используемые модели с LiteLLM, OpenRouter и другими источниками цен. Неочевидные совпадения появятся в списке для ручного подтверждения.", + "sync_idle": "Сначала синхронизируйте используемые модели с models.dev, используя LiteLLM и OpenRouter как резервные источники. Неочевидные совпадения появятся в списке для ручного подтверждения.", "sync_result": "Sources {{sources}}, imported {{imported}}, skipped {{skipped}}, {{proxy}}.", "sync_success_detail": "Синхронизация завершена: импортировано {{imported}}, ждут подтверждения {{candidates}}, без совпадений {{unmatched}}.", "source_result_ok": "{{models}} models, skipped {{skipped}}", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index f73387cd6..448f93938 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -1503,7 +1503,7 @@ "summary_missing": "缺少价格", "summary_candidates": "待确认", "sync_title": "价格同步", - "sync_idle": "从 LiteLLM、OpenRouter 等价格元数据同步当前使用过的模型。无法确认的相似模型会进入待确认列表。", + "sync_idle": "优先从 models.dev 同步当前使用过的模型,并使用 LiteLLM、OpenRouter 回退。无法确认的相似模型会进入待确认列表。", "sync_result": "来源 {{sources}},已导入 {{imported}},跳过 {{skipped}},{{proxy}}。", "sync_success_detail": "同步完成:自动导入 {{imported}},待确认 {{candidates}},未匹配 {{unmatched}}。", "source_result_ok": "{{models}} 条,跳过 {{skipped}}", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index 866ddd664..0c1f55a36 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -1503,7 +1503,7 @@ "summary_missing": "缺少價格", "summary_candidates": "待確認", "sync_title": "價格同步", - "sync_idle": "從 LiteLLM、OpenRouter 等價格元資料同步目前使用過的模型。無法確認的相似模型會進入待確認列表。", + "sync_idle": "優先從 models.dev 同步目前使用過的模型,並使用 LiteLLM、OpenRouter 回退。無法確認的相似模型會進入待確認列表。", "sync_result": "來源 {{sources}},已匯入 {{imported}},跳過 {{skipped}},{{proxy}}。", "sync_success_detail": "同步完成:自動匯入 {{imported}},待確認 {{candidates}},未匹配 {{unmatched}}。", "source_result_ok": "{{models}} 條,跳過 {{skipped}}", From 0f7eb2853527692fbb3b2388c97383d36162c98f Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 14:06:27 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9C=A8=20feat(docs):=20document=20mode?= =?UTF-8?q?ls.dev=20price=20synchronization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document source priority, provider-scoped identity handling, and conflict confirmation. Clarify which models.dev cost fields are currently mapped into billing rules. Note that tiers, Fast Mode, and reasoning metadata remain available but are not yet billed automatically. --- README.md | 2 +- apps/docs/en/manual/model-prices.md | 6 +++++- apps/docs/manual/model-prices.md | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 21254b730..208bd2d37 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ See [Choosing A CPA Panel](https://seakee.github.io/CPA-Manager-Plus/docs/en/gui - Break down calls, tokens, cost, latency, and failures by model, provider, account, credential, API key, project, channel, and time range. - Track input, output, reasoning, cache, service tier, and long-context pricing semantics. -- Sync model prices from LiteLLM and OpenRouter, with local overrides for aliases or internal models. +- Sync model prices from models.dev first, with LiteLLM and OpenRouter fallbacks plus local overrides for aliases or internal models. - Open the [Usage Analytics Demo](https://seakee.github.io/CPA-Manager-Plus/#/demo/usage-analytics). ### Account Health, Quota, And Automation diff --git a/apps/docs/en/manual/model-prices.md b/apps/docs/en/manual/model-prices.md index fc16adbba..61503881b 100644 --- a/apps/docs/en/manual/model-prices.md +++ b/apps/docs/en/manual/model-prices.md @@ -11,12 +11,16 @@ Open the [Model Prices Demo](https://seakee.github.io/CPA-Manager-Plus/#/demo/mo ## Price Sources -- Public metadata actively synchronized from LiteLLM or OpenRouter. +- Public metadata synchronized from models.dev first, with LiteLLM and OpenRouter used as fallbacks when the preferred source is unavailable or lacks a model. - Local prices added or overridden by the user. - Entries for aliases, internal names, or provider-specific variants. Synchronization only occurs when the user triggers it and may use the current Manager Server proxy configuration. +The same models.dev model ID may be offered by multiple providers, and a real model ID may itself contain `/`. CPAMP compares source identities separately from original model IDs and only matches automatically when the complete pricing metadata, including tiers and experimental-mode prices, is consistent. Identity or price conflicts remain in the candidate-confirmation flow; a same-named LiteLLM or OpenRouter entry cannot bypass that conflict. + +The current sync maps models.dev `cost.input`, `cost.output`, `cost.cache_read`, and `cost.cache_write`. The complete model object remains available in the raw metadata, including `cost.tiers`, Fast Mode, and reasoning fields, but those advanced fields are not yet converted automatically into CPAMP billing rules. + ## Supported Billing Semantics A price rule may include: diff --git a/apps/docs/manual/model-prices.md b/apps/docs/manual/model-prices.md index 06699845c..1f3d9cb1c 100644 --- a/apps/docs/manual/model-prices.md +++ b/apps/docs/manual/model-prices.md @@ -11,12 +11,16 @@ description: 配置 CPA Manager Plus 模型价格、service tier、长上下文 ## 价格来源 -- 从 LiteLLM 或 OpenRouter 主动同步的公开元数据。 +- 首选从 models.dev 主动同步的公开元数据;当该来源不可用或缺少模型时,再使用 LiteLLM 和 OpenRouter 回退。 - 用户手动添加或覆盖的本地价格。 - 为模型别名、内部名称或 Provider 特定变体维护的条目。 同步只在用户主动触发时发生,可能使用当前 Manager Server 代理设置。 +models.dev 中同一个模型 ID 可能由多个 Provider 提供,而且真实模型 ID 本身也可能包含 `/`。CPAMP 会分别比较来源身份和原始模型 ID;只有完整价格元数据(包括阶梯和实验模式价格)明确一致时才自动匹配。任何身份或价格冲突都会进入候选确认流程,LiteLLM 或 OpenRouter 的同名条目不会绕过该冲突。 + +当前同步会映射 models.dev 的 `cost.input`、`cost.output`、`cost.cache_read` 和 `cost.cache_write`。完整模型对象仍保存在原始元数据中,包括 `cost.tiers`、Fast Mode 和 reasoning 等字段,但这些高级字段暂不会自动转换为 CPAMP 计费规则。 + ## 当前支持的计费语义 价格结构可能包括: From a41c7ccd2db1f0686d3ff25da300b76d1664e8dc Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:10:27 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20add?= =?UTF-8?q?=20tier-aware=20pricing=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create SQLite tables for context and service-tier price rules and durable pricing rollups. Add indexes, state initialization, and cascade coverage required by later repositories. This schema-only change leaves application behavior unchanged until subsequent commits. --- .../internal/repository/sqlite/migrate.go | 126 ++++++++++++++++++ .../repository/sqlite/migrate_test.go | 23 ++++ 2 files changed, 149 insertions(+) diff --git a/apps/manager-server/internal/repository/sqlite/migrate.go b/apps/manager-server/internal/repository/sqlite/migrate.go index 97331acb8..cbee088c3 100644 --- a/apps/manager-server/internal/repository/sqlite/migrate.go +++ b/apps/manager-server/internal/repository/sqlite/migrate.go @@ -206,6 +206,99 @@ func Migrate(db *sql.DB) error { coalesce((select max(id) from usage_events), 0), 0, 0`, + `create table if not exists usage_pricing_hourly_rollups_v1 ( + structure_revision text not null, + bucket_ms integer not null, + model text not null, + billing_model text not null, + pricing_model text not null, + service_tier text not null, + context_threshold_tokens integer not null, + failed integer not null, + calls integer not null default 0, + input_tokens integer not null default 0, + output_tokens integer not null default 0, + reasoning_tokens integer not null default 0, + cached_tokens integer not null default 0, + cache_read_tokens integer not null default 0, + cache_creation_tokens integer not null default 0, + long_input_tokens integer not null default 0, + long_output_tokens integer not null default 0, + long_cached_tokens integer not null default 0, + long_cache_read_tokens integer not null default 0, + long_cache_creation_tokens integer not null default 0, + total_tokens integer not null default 0, + latency_sum_ms integer not null default 0, + latency_samples integer not null default 0, + zero_token_calls integer not null default 0, + updated_at_ms integer not null, + primary key ( + structure_revision, bucket_ms, model, billing_model, pricing_model, + service_tier, context_threshold_tokens, failed + ) + )`, + `create index if not exists idx_usage_pricing_hourly_bucket + on usage_pricing_hourly_rollups_v1(structure_revision, bucket_ms)`, + `create table if not exists usage_pricing_account_rollups_v1 ( + structure_revision text not null, + account_key text not null, + account_snapshot text, + auth_label_snapshot text, + auth_provider_snapshot text, + auth_index text, + source text, + source_hash text, + model text not null, + billing_model text not null, + pricing_model text not null, + service_tier text not null, + context_threshold_tokens integer not null, + calls integer not null default 0, + success_calls integer not null default 0, + failure_calls integer not null default 0, + input_tokens integer not null default 0, + output_tokens integer not null default 0, + reasoning_tokens integer not null default 0, + cached_tokens integer not null default 0, + cache_read_tokens integer not null default 0, + cache_creation_tokens integer not null default 0, + long_input_tokens integer not null default 0, + long_output_tokens integer not null default 0, + long_cached_tokens integer not null default 0, + long_cache_read_tokens integer not null default 0, + long_cache_creation_tokens integer not null default 0, + total_tokens integer not null default 0, + first_seen_ms integer not null, + last_seen_ms integer not null, + updated_at_ms integer not null, + primary key ( + structure_revision, account_key, billing_model, pricing_model, + service_tier, context_threshold_tokens + ) + )`, + `create index if not exists idx_usage_pricing_account_key + on usage_pricing_account_rollups_v1(structure_revision, account_key)`, + `create table if not exists usage_pricing_rollup_state ( + rollup_name text primary key, + schema_version integer not null, + structure_revision text not null default '', + status text not null, + backfill_last_event_id integer not null default 0, + coverage_event_id integer not null default 0, + target_event_id integer not null default 0, + processed_events integer not null default 0, + min_bucket_ms integer, + max_bucket_ms integer, + last_run_started_at_ms integer, + updated_at_ms integer not null default 0, + finished_at_ms integer, + last_error text + )`, + `insert or ignore into usage_pricing_rollup_state ( + rollup_name, schema_version, structure_revision, status, + backfill_last_event_id, coverage_event_id, target_event_id, + processed_events, updated_at_ms + ) values ('pricing_v1', 1, '', 'pending', 0, 0, 0, 0, 0)`, `create table if not exists usage_event_identity_ledger ( event_hash text primary key, raw_event_id integer, @@ -276,6 +369,39 @@ func Migrate(db *sql.DB) error { updated_at_ms integer not null, synced_at_ms integer )`, + `create table if not exists model_price_context_tiers ( + model text not null, + threshold_tokens integer not null, + prompt_per_1m real not null default 0, + completion_per_1m real not null default 0, + cache_per_1m real not null default 0, + cache_read_per_1m real not null default 0, + cache_creation_per_1m real not null default 0, + prompt_configured integer not null default 0, + completion_configured integer not null default 0, + cache_configured integer not null default 0, + cache_read_configured integer not null default 0, + cache_creation_configured integer not null default 0, + primary key (model, threshold_tokens), + foreign key (model) references model_prices(model) on delete cascade + )`, + `create table if not exists model_price_service_tiers ( + model text not null, + mode text not null, + service_tier text not null, + prompt_per_1m real not null default 0, + completion_per_1m real not null default 0, + cache_per_1m real not null default 0, + cache_read_per_1m real not null default 0, + cache_creation_per_1m real not null default 0, + prompt_configured integer not null default 0, + completion_configured integer not null default 0, + cache_configured integer not null default 0, + cache_read_configured integer not null default 0, + cache_creation_configured integer not null default 0, + primary key (model, mode, service_tier), + foreign key (model) references model_prices(model) on delete cascade + )`, `create table if not exists api_key_aliases ( api_key_hash text primary key, alias text not null, diff --git a/apps/manager-server/internal/repository/sqlite/migrate_test.go b/apps/manager-server/internal/repository/sqlite/migrate_test.go index 078afbdce..75feffe0d 100644 --- a/apps/manager-server/internal/repository/sqlite/migrate_test.go +++ b/apps/manager-server/internal/repository/sqlite/migrate_test.go @@ -587,6 +587,29 @@ func TestEnsureModelPriceColumnsPreservesLegacyZeroBasePrices(t *testing.T) { } } +func TestMigrateCreatesModelPriceServiceTierTableWithCascade(t *testing.T) { + db, err := Open(filepath.Join(t.TempDir(), "model-price-service-tier.sqlite")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if _, err := db.Exec(`insert into model_prices ( + model, prompt_per_1m, completion_per_1m, cache_per_1m, updated_at_ms + ) values ('gpt-test', 1, 2, 0.1, 1)`); err != nil { + t.Fatalf("insert model price: %v", err) + } + if _, err := db.Exec(`insert into model_price_service_tiers ( + model, mode, service_tier, prompt_per_1m, prompt_configured + ) values ('gpt-test', 'fast', 'priority', 2.5, 1)`); err != nil { + t.Fatalf("insert model price service tier: %v", err) + } + if _, err := db.Exec(`delete from model_prices where model = 'gpt-test'`); err != nil { + t.Fatalf("delete model price: %v", err) + } + assertTableCount(t, db, "model_price_service_tiers", 0) +} + func migrationTableColumns(t *testing.T, db *sql.DB, table string) map[string]bool { t.Helper() rows, err := db.Query(`pragma table_info(` + table + `)`) From ce93bdc34c6b85cc850affc8177ead6fb0d4c19b Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:10:28 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20persi?= =?UTF-8?q?st=20tier-aware=20model=20price=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend model prices with normalized context and service-tier rules. Persist, validate, and reload those rules transactionally through the model-price repository. This establishes the price-book contract used by synchronization and cost calculation. --- .../internal/model/model_price.go | 30 +- .../internal/model/model_price_rules.go | 306 ++++++++++++++++++ .../internal/model/model_price_rules_test.go | 144 +++++++++ .../repository/modelprice/repository.go | 280 ++++++++++++++-- 4 files changed, 725 insertions(+), 35 deletions(-) create mode 100644 apps/manager-server/internal/model/model_price_rules.go create mode 100644 apps/manager-server/internal/model/model_price_rules_test.go diff --git a/apps/manager-server/internal/model/model_price.go b/apps/manager-server/internal/model/model_price.go index d74b94061..dc02b9803 100644 --- a/apps/manager-server/internal/model/model_price.go +++ b/apps/manager-server/internal/model/model_price.go @@ -1,20 +1,22 @@ package model type ModelPrice struct { - Prompt float64 `json:"prompt"` - Completion float64 `json:"completion"` - Cache float64 `json:"cache"` - CacheRead float64 `json:"cacheRead,omitempty"` - CacheCreation float64 `json:"cacheCreation,omitempty"` - PromptConfigured bool `json:"promptConfigured,omitempty"` - CompletionConfigured bool `json:"completionConfigured,omitempty"` - CacheReadConfigured bool `json:"cacheReadConfigured,omitempty"` - CacheCreationConfigured bool `json:"cacheCreationConfigured,omitempty"` - Source string `json:"source,omitempty"` - SourceModelID string `json:"sourceModelId,omitempty"` - RawJSON string `json:"rawJson,omitempty"` - UpdatedAtMS int64 `json:"updatedAtMs,omitempty"` - SyncedAtMS *int64 `json:"syncedAtMs,omitempty"` + Prompt float64 `json:"prompt"` + Completion float64 `json:"completion"` + Cache float64 `json:"cache"` + CacheRead float64 `json:"cacheRead,omitempty"` + CacheCreation float64 `json:"cacheCreation,omitempty"` + PromptConfigured bool `json:"promptConfigured,omitempty"` + CompletionConfigured bool `json:"completionConfigured,omitempty"` + CacheReadConfigured bool `json:"cacheReadConfigured,omitempty"` + CacheCreationConfigured bool `json:"cacheCreationConfigured,omitempty"` + Source string `json:"source,omitempty"` + SourceModelID string `json:"sourceModelId,omitempty"` + RawJSON string `json:"rawJson,omitempty"` + ContextTiers []ModelPriceContextTier `json:"contextTiers,omitempty"` + ServiceTiers []ModelPriceServiceTier `json:"serviceTiers,omitempty"` + UpdatedAtMS int64 `json:"updatedAtMs,omitempty"` + SyncedAtMS *int64 `json:"syncedAtMs,omitempty"` } type ModelPriceSyncResult struct { diff --git a/apps/manager-server/internal/model/model_price_rules.go b/apps/manager-server/internal/model/model_price_rules.go new file mode 100644 index 000000000..2bd265419 --- /dev/null +++ b/apps/manager-server/internal/model/model_price_rules.go @@ -0,0 +1,306 @@ +package model + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "math" + "sort" + "strings" +) + +const ( + // ModelPriceBaseContextThreshold is the internal rollup band for requests + // that do not cross a configured context-price threshold. + ModelPriceBaseContextThreshold int64 = -1 + modelPriceStructureRevisionVersion = "context-v1" +) + +// ModelPriceContextTier is a context-size price override. A request selects +// the highest threshold for which normalized input tokens are strictly greater +// than ThresholdTokens. Configured flags distinguish a missing override from +// an explicit zero price. +type ModelPriceContextTier struct { + ThresholdTokens int64 `json:"thresholdTokens"` + Prompt float64 `json:"prompt"` + Completion float64 `json:"completion"` + Cache float64 `json:"cache"` + CacheRead float64 `json:"cacheRead,omitempty"` + CacheCreation float64 `json:"cacheCreation,omitempty"` + PromptConfigured bool `json:"promptConfigured,omitempty"` + CompletionConfigured bool `json:"completionConfigured,omitempty"` + CacheConfigured bool `json:"cacheConfigured,omitempty"` + CacheReadConfigured bool `json:"cacheReadConfigured,omitempty"` + CacheCreationConfigured bool `json:"cacheCreationConfigured,omitempty"` +} + +// ModelPriceServiceTier is a complete or partial price override for a +// provider mode and its emitted service tier. Mode matches source-side names +// such as "fast", while ServiceTier matches usage values such as "priority". +// Configured flags distinguish missing overrides from explicit zero prices. +type ModelPriceServiceTier struct { + Mode string `json:"mode"` + ServiceTier string `json:"serviceTier"` + Prompt float64 `json:"prompt"` + Completion float64 `json:"completion"` + Cache float64 `json:"cache"` + CacheRead float64 `json:"cacheRead,omitempty"` + CacheCreation float64 `json:"cacheCreation,omitempty"` + PromptConfigured bool `json:"promptConfigured,omitempty"` + CompletionConfigured bool `json:"completionConfigured,omitempty"` + CacheConfigured bool `json:"cacheConfigured,omitempty"` + CacheReadConfigured bool `json:"cacheReadConfigured,omitempty"` + CacheCreationConfigured bool `json:"cacheCreationConfigured,omitempty"` +} + +// NormalizeModelPriceContextTiers validates and returns a threshold-sorted +// copy. The input slice is never mutated. +func NormalizeModelPriceContextTiers(tiers []ModelPriceContextTier) ([]ModelPriceContextTier, error) { + if len(tiers) == 0 { + return nil, nil + } + normalized := append([]ModelPriceContextTier(nil), tiers...) + sort.Slice(normalized, func(i, j int) bool { + return normalized[i].ThresholdTokens < normalized[j].ThresholdTokens + }) + for index, tier := range normalized { + if tier.ThresholdTokens <= 0 { + return nil, fmt.Errorf("context tier threshold must be positive: %d", tier.ThresholdTokens) + } + if index > 0 && normalized[index-1].ThresholdTokens == tier.ThresholdTokens { + return nil, fmt.Errorf("duplicate context tier threshold: %d", tier.ThresholdTokens) + } + if !validModelPriceRuleValue(tier.Prompt) || + !validModelPriceRuleValue(tier.Completion) || + !validModelPriceRuleValue(tier.Cache) || + !validModelPriceRuleValue(tier.CacheRead) || + !validModelPriceRuleValue(tier.CacheCreation) { + return nil, fmt.Errorf("invalid context tier price at threshold %d", tier.ThresholdTokens) + } + if !tier.PromptConfigured && !tier.CompletionConfigured && !tier.CacheConfigured && + !tier.CacheReadConfigured && !tier.CacheCreationConfigured { + return nil, fmt.Errorf("context tier at threshold %d has no configured prices", tier.ThresholdTokens) + } + } + return normalized, nil +} + +// SelectModelPriceContextTier returns the active tier and its threshold band. +// Exact-threshold requests remain in the lower band, matching models.dev's +// strictly-greater-than semantics. +func SelectModelPriceContextTier(price ModelPrice, normalizedInputTokens int64) (ModelPriceContextTier, bool) { + var selected ModelPriceContextTier + found := false + for _, tier := range price.ContextTiers { + if normalizedInputTokens <= tier.ThresholdTokens { + break + } + selected = tier + found = true + } + return selected, found +} + +// ModelPriceForContext applies the selected tier's configured overrides to the +// base price, producing the complete rate set for the whole request. +func ModelPriceForContext(price ModelPrice, normalizedInputTokens int64) (ModelPrice, int64) { + tier, ok := SelectModelPriceContextTier(price, normalizedInputTokens) + if !ok { + return price, ModelPriceBaseContextThreshold + } + return applyModelPriceContextTier(price, tier), tier.ThresholdTokens +} + +// ModelPriceForContextThreshold resolves a previously classified rollup band. +// A positive threshold must exactly match a currently configured tier. +func ModelPriceForContextThreshold(price ModelPrice, thresholdTokens int64) (ModelPrice, bool) { + if thresholdTokens == ModelPriceBaseContextThreshold { + price.ContextTiers = nil + return price, true + } + if thresholdTokens <= 0 { + return ModelPrice{}, false + } + for _, tier := range price.ContextTiers { + if tier.ThresholdTokens == thresholdTokens { + return applyModelPriceContextTier(price, tier), true + } + } + return ModelPrice{}, false +} + +func applyModelPriceContextTier(price ModelPrice, tier ModelPriceContextTier) ModelPrice { + effective := price + effective.ContextTiers = nil + effective.ServiceTiers = nil + if tier.PromptConfigured { + effective.Prompt = tier.Prompt + effective.PromptConfigured = true + } + if tier.CompletionConfigured { + effective.Completion = tier.Completion + effective.CompletionConfigured = true + } + if tier.CacheConfigured { + effective.Cache = tier.Cache + } + if tier.CacheReadConfigured { + effective.CacheRead = tier.CacheRead + effective.CacheReadConfigured = true + } + if tier.CacheCreationConfigured { + effective.CacheCreation = tier.CacheCreation + effective.CacheCreationConfigured = true + } + return effective +} + +// NormalizeModelPriceServiceTiers validates and returns a deterministic copy. +// A usage tier may match either Mode or ServiceTier, so identifiers cannot be +// shared by different rules. +func NormalizeModelPriceServiceTiers(tiers []ModelPriceServiceTier) ([]ModelPriceServiceTier, error) { + if len(tiers) == 0 { + return nil, nil + } + normalized := append([]ModelPriceServiceTier(nil), tiers...) + claimed := make(map[string]int, len(normalized)*2) + for index := range normalized { + tier := &normalized[index] + tier.Mode = strings.ToLower(strings.TrimSpace(tier.Mode)) + tier.ServiceTier = strings.ToLower(strings.TrimSpace(tier.ServiceTier)) + if tier.Mode == "" || tier.ServiceTier == "" { + return nil, errors.New("service tier mode and provider tier are required") + } + if !validModelPriceRuleValue(tier.Prompt) || + !validModelPriceRuleValue(tier.Completion) || + !validModelPriceRuleValue(tier.Cache) || + !validModelPriceRuleValue(tier.CacheRead) || + !validModelPriceRuleValue(tier.CacheCreation) { + return nil, fmt.Errorf("invalid service tier price for %s/%s", tier.Mode, tier.ServiceTier) + } + if !tier.PromptConfigured && !tier.CompletionConfigured && !tier.CacheConfigured && + !tier.CacheReadConfigured && !tier.CacheCreationConfigured { + return nil, fmt.Errorf("service tier %s/%s has no configured prices", tier.Mode, tier.ServiceTier) + } + for _, identifier := range []string{tier.Mode, tier.ServiceTier} { + if owner, exists := claimed[identifier]; exists && owner != index { + return nil, fmt.Errorf("duplicate service tier identifier: %s", identifier) + } + claimed[identifier] = index + } + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Mode != normalized[j].Mode { + return normalized[i].Mode < normalized[j].Mode + } + return normalized[i].ServiceTier < normalized[j].ServiceTier + }) + return normalized, nil +} + +// SelectModelPriceServiceTier finds an explicit price rule for a recorded +// service tier. A single models.dev fast rule therefore matches both "fast" +// request telemetry and "priority" API telemetry. +func SelectModelPriceServiceTier(price ModelPrice, serviceTier string) (ModelPriceServiceTier, bool) { + serviceTier = strings.ToLower(strings.TrimSpace(serviceTier)) + if serviceTier == "" { + return ModelPriceServiceTier{}, false + } + for _, tier := range price.ServiceTiers { + if serviceTier == tier.Mode || serviceTier == tier.ServiceTier { + return tier, true + } + } + return ModelPriceServiceTier{}, false +} + +// ModelPriceForServiceTier applies a matched rule to the base price. Missing +// fields inherit the base rates, while explicit zero values remain configured. +func ModelPriceForServiceTier(price ModelPrice, serviceTier string) (ModelPrice, bool) { + tier, ok := SelectModelPriceServiceTier(price, serviceTier) + if !ok { + return ModelPrice{}, false + } + effective := price + effective.ContextTiers = nil + effective.ServiceTiers = nil + if tier.PromptConfigured { + effective.Prompt = tier.Prompt + effective.PromptConfigured = true + } + if tier.CompletionConfigured { + effective.Completion = tier.Completion + effective.CompletionConfigured = true + } + if tier.CacheConfigured { + effective.Cache = tier.Cache + } + if tier.CacheReadConfigured { + effective.CacheRead = tier.CacheRead + effective.CacheReadConfigured = true + } + if tier.CacheCreationConfigured { + effective.CacheCreation = tier.CacheCreation + effective.CacheCreationConfigured = true + } + return effective, true +} + +// ModelPriceStructureRevision changes only when the set of priced model IDs or +// their context-tier thresholds changes. Rate-only updates, including explicit +// service-tier rules, intentionally keep the revision stable because pricing +// rollups already group usage by service tier. +func ModelPriceStructureRevision(prices map[string]ModelPrice) string { + modelIDs := make([]string, 0, len(prices)) + for modelID := range prices { + modelIDs = append(modelIDs, modelID) + } + sort.Strings(modelIDs) + hash := sha256.New() + _, _ = hash.Write([]byte(modelPriceStructureRevisionVersion)) + var encoded [8]byte + for _, modelID := range modelIDs { + binary.BigEndian.PutUint64(encoded[:], uint64(len(modelID))) + _, _ = hash.Write(encoded[:]) + _, _ = hash.Write([]byte(modelID)) + tiers := append([]ModelPriceContextTier(nil), prices[modelID].ContextTiers...) + sort.Slice(tiers, func(i, j int) bool { + return tiers[i].ThresholdTokens < tiers[j].ThresholdTokens + }) + binary.BigEndian.PutUint64(encoded[:], uint64(len(tiers))) + _, _ = hash.Write(encoded[:]) + for _, tier := range tiers { + binary.BigEndian.PutUint64(encoded[:], uint64(tier.ThresholdTokens)) + _, _ = hash.Write(encoded[:]) + } + } + return modelPriceStructureRevisionVersion + ":" + hex.EncodeToString(hash.Sum(nil)) +} + +func ValidateModelPrice(modelID string, price ModelPrice) error { + if strings.TrimSpace(modelID) == "" { + return errors.New("model is required") + } + if !validModelPriceRuleValue(price.Prompt) || + !validModelPriceRuleValue(price.Completion) || + !validModelPriceRuleValue(price.Cache) || + !validModelPriceRuleValue(price.CacheRead) || + !validModelPriceRuleValue(price.CacheCreation) { + return fmt.Errorf("invalid model price for %s", modelID) + } + _, err := NormalizeModelPriceContextTiers(price.ContextTiers) + if err != nil { + return fmt.Errorf("invalid model price for %s: %w", modelID, err) + } + _, err = NormalizeModelPriceServiceTiers(price.ServiceTiers) + if err != nil { + return fmt.Errorf("invalid model price for %s: %w", modelID, err) + } + return nil +} + +func validModelPriceRuleValue(value float64) bool { + return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +} diff --git a/apps/manager-server/internal/model/model_price_rules_test.go b/apps/manager-server/internal/model/model_price_rules_test.go new file mode 100644 index 000000000..76711ebad --- /dev/null +++ b/apps/manager-server/internal/model/model_price_rules_test.go @@ -0,0 +1,144 @@ +package model + +import "testing" + +func TestNormalizeModelPriceContextTiersSortsAndRejectsDuplicates(t *testing.T) { + tiers, err := NormalizeModelPriceContextTiers([]ModelPriceContextTier{ + {ThresholdTokens: 200_000, Prompt: 4, PromptConfigured: true}, + {ThresholdTokens: 32_000, Prompt: 2, PromptConfigured: true}, + }) + if err != nil { + t.Fatalf("normalize tiers: %v", err) + } + if len(tiers) != 2 || tiers[0].ThresholdTokens != 32_000 || tiers[1].ThresholdTokens != 200_000 { + t.Fatalf("normalized tiers = %#v", tiers) + } + if _, err := NormalizeModelPriceContextTiers([]ModelPriceContextTier{ + {ThresholdTokens: 32_000, Prompt: 2, PromptConfigured: true}, + {ThresholdTokens: 32_000, Prompt: 3, PromptConfigured: true}, + }); err == nil { + t.Fatal("duplicate threshold error = nil") + } +} + +func TestModelPriceForContextUsesStrictHighestTierAndInheritsMissingRates(t *testing.T) { + price := ModelPrice{ + Prompt: 1, Completion: 2, Cache: 0.1, CacheRead: 0.1, CacheCreation: 0.2, + PromptConfigured: true, CompletionConfigured: true, CacheReadConfigured: true, CacheCreationConfigured: true, + ContextTiers: []ModelPriceContextTier{ + {ThresholdTokens: 32_000, Prompt: 3, Completion: 4, PromptConfigured: true, CompletionConfigured: true}, + {ThresholdTokens: 200_000, Prompt: 0, Completion: 8, PromptConfigured: true, CompletionConfigured: true}, + }, + } + + atThreshold, band := ModelPriceForContext(price, 32_000) + if band != ModelPriceBaseContextThreshold || atThreshold.Prompt != 1 { + t.Fatalf("exact threshold price = %#v, band = %d", atThreshold, band) + } + firstTier, band := ModelPriceForContext(price, 200_000) + if band != 32_000 || firstTier.Prompt != 3 || firstTier.CacheRead != 0.1 { + t.Fatalf("first tier price = %#v, band = %d", firstTier, band) + } + highestTier, band := ModelPriceForContext(price, 200_001) + if band != 200_000 || highestTier.Prompt != 0 || !highestTier.PromptConfigured || highestTier.CacheCreation != 0.2 { + t.Fatalf("highest tier price = %#v, band = %d", highestTier, band) + } + resolvedTier, ok := ModelPriceForContextThreshold(price, 32_000) + if !ok || resolvedTier.Prompt != 3 || resolvedTier.CacheRead != 0.1 { + t.Fatalf("resolved tier price = %#v, ok = %v", resolvedTier, ok) + } + if _, ok := ModelPriceForContextThreshold(price, 128_000); ok { + t.Fatal("unknown threshold unexpectedly resolved") + } +} + +func TestModelPriceServiceTiersNormalizeMatchAliasesAndInheritRates(t *testing.T) { + tiers, err := NormalizeModelPriceServiceTiers([]ModelPriceServiceTier{ + { + Mode: " FAST ", ServiceTier: " Priority ", Prompt: 0, Completion: 75, + PromptConfigured: true, CompletionConfigured: true, + }, + }) + if err != nil { + t.Fatalf("normalize service tiers: %v", err) + } + if len(tiers) != 1 || tiers[0].Mode != "fast" || tiers[0].ServiceTier != "priority" { + t.Fatalf("normalized service tiers = %#v", tiers) + } + price := ModelPrice{ + Prompt: 5, Completion: 30, Cache: 0.5, CacheRead: 0.5, CacheCreation: 6.25, + PromptConfigured: true, CompletionConfigured: true, CacheReadConfigured: true, CacheCreationConfigured: true, + ContextTiers: []ModelPriceContextTier{{ThresholdTokens: 272_000, Prompt: 10, PromptConfigured: true}}, + ServiceTiers: tiers, + } + for _, identifier := range []string{"fast", "priority", " PRIORITY "} { + effective, ok := ModelPriceForServiceTier(price, identifier) + if !ok || effective.Prompt != 0 || !effective.PromptConfigured || effective.Completion != 75 || + effective.CacheRead != 0.5 || len(effective.ContextTiers) != 0 || len(effective.ServiceTiers) != 0 { + t.Fatalf("effective service tier for %q = %#v, ok = %v", identifier, effective, ok) + } + } + if _, ok := ModelPriceForServiceTier(price, "default"); ok { + t.Fatal("default unexpectedly matched service tier rule") + } +} + +func TestNormalizeModelPriceServiceTiersRejectsAmbiguousAndEmptyRules(t *testing.T) { + for name, tiers := range map[string][]ModelPriceServiceTier{ + "shared identifier": { + {Mode: "fast", ServiceTier: "priority", Prompt: 1, PromptConfigured: true}, + {Mode: "priority", ServiceTier: "turbo", Prompt: 2, PromptConfigured: true}, + }, + "missing identifier": { + {Mode: "fast", Prompt: 1, PromptConfigured: true}, + }, + "no prices": { + {Mode: "fast", ServiceTier: "priority"}, + }, + } { + t.Run(name, func(t *testing.T) { + if _, err := NormalizeModelPriceServiceTiers(tiers); err == nil { + t.Fatal("normalize error = nil") + } + }) + } +} + +func TestModelPriceStructureRevisionIgnoresRatesButTracksThresholds(t *testing.T) { + base := map[string]ModelPrice{ + "model-b": {ContextTiers: []ModelPriceContextTier{{ThresholdTokens: 200_000, Prompt: 2, PromptConfigured: true}}}, + "model-a": {Prompt: 1}, + } + revision := ModelPriceStructureRevision(base) + rateUpdate := map[string]ModelPrice{ + "model-a": {Prompt: 99}, + "model-b": {ContextTiers: []ModelPriceContextTier{{ThresholdTokens: 200_000, Prompt: 9, PromptConfigured: true}}}, + } + if got := ModelPriceStructureRevision(rateUpdate); got != revision { + t.Fatalf("rate-only revision = %q, want %q", got, revision) + } + serviceTierUpdate := map[string]ModelPrice{ + "model-a": { + Prompt: 1, + ServiceTiers: []ModelPriceServiceTier{{ + Mode: "fast", ServiceTier: "priority", Prompt: 9, PromptConfigured: true, + }}, + }, + "model-b": {ContextTiers: []ModelPriceContextTier{{ThresholdTokens: 200_000, Prompt: 2, PromptConfigured: true}}}, + } + if got := ModelPriceStructureRevision(serviceTierUpdate); got != revision { + t.Fatalf("service-tier revision = %q, want %q", got, revision) + } + modelSetUpdate := map[string]ModelPrice{ + "model-b": {ContextTiers: []ModelPriceContextTier{{ThresholdTokens: 200_000, Prompt: 2, PromptConfigured: true}}}, + } + if got := ModelPriceStructureRevision(modelSetUpdate); got == revision { + t.Fatalf("model-set revision did not change: %q", got) + } + thresholdUpdate := map[string]ModelPrice{ + "model-b": {ContextTiers: []ModelPriceContextTier{{ThresholdTokens: 256_000, Prompt: 2, PromptConfigured: true}}}, + } + if got := ModelPriceStructureRevision(thresholdUpdate); got == revision { + t.Fatalf("threshold revision did not change: %q", got) + } +} diff --git a/apps/manager-server/internal/repository/modelprice/repository.go b/apps/manager-server/internal/repository/modelprice/repository.go index e16dfdad5..62f8fab5d 100644 --- a/apps/manager-server/internal/repository/modelprice/repository.go +++ b/apps/manager-server/internal/repository/modelprice/repository.go @@ -3,9 +3,6 @@ package modelprice import ( "context" "database/sql" - "errors" - "fmt" - "math" "time" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" @@ -13,6 +10,7 @@ import ( type Repository interface { LoadAll(ctx context.Context) (map[string]model.ModelPrice, error) + LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]model.ModelPrice, error) ReplaceAll(ctx context.Context, prices map[string]model.ModelPrice) error UpsertSynced(ctx context.Context, prices map[string]model.ModelPrice) (model.ModelPriceSyncResult, error) } @@ -26,7 +24,23 @@ func New(db *sql.DB) Repository { } func (r *repository) LoadAll(ctx context.Context) (map[string]model.ModelPrice, error) { - rows, err := r.db.QueryContext(ctx, `select + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + prices, err := r.LoadAllTx(ctx, tx) + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return prices, nil +} + +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, updated_at_ms, synced_at_ms @@ -34,7 +48,6 @@ func (r *repository) LoadAll(ctx context.Context) (map[string]model.ModelPrice, if err != nil { return nil, err } - defer rows.Close() prices := map[string]model.ModelPrice{} for rows.Next() { @@ -75,7 +88,109 @@ func (r *repository) LoadAll(ctx context.Context) (map[string]model.ModelPrice, } prices[modelID] = price } - return prices, rows.Err() + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + + tierRows, err := tx.QueryContext(ctx, `select + model, threshold_tokens, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m, + prompt_configured, completion_configured, cache_configured, cache_read_configured, cache_creation_configured + from model_price_context_tiers order by model, threshold_tokens`) + if err != nil { + return nil, err + } + for tierRows.Next() { + var modelID string + var tier model.ModelPriceContextTier + var promptConfigured, completionConfigured, cacheConfigured, cacheReadConfigured, cacheCreationConfigured int + if err := tierRows.Scan( + &modelID, + &tier.ThresholdTokens, + &tier.Prompt, + &tier.Completion, + &tier.Cache, + &tier.CacheRead, + &tier.CacheCreation, + &promptConfigured, + &completionConfigured, + &cacheConfigured, + &cacheReadConfigured, + &cacheCreationConfigured, + ); err != nil { + return nil, err + } + tier.PromptConfigured = promptConfigured != 0 + tier.CompletionConfigured = completionConfigured != 0 + tier.CacheConfigured = cacheConfigured != 0 + tier.CacheReadConfigured = cacheReadConfigured != 0 + tier.CacheCreationConfigured = cacheCreationConfigured != 0 + price, ok := prices[modelID] + if !ok { + continue + } + price.ContextTiers = append(price.ContextTiers, tier) + prices[modelID] = price + } + if err := tierRows.Err(); err != nil { + _ = tierRows.Close() + return nil, err + } + if err := tierRows.Close(); err != nil { + return nil, err + } + + serviceTierRows, err := tx.QueryContext(ctx, `select + model, mode, service_tier, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m, + prompt_configured, completion_configured, cache_configured, cache_read_configured, cache_creation_configured + from model_price_service_tiers order by model, mode, service_tier`) + if err != nil { + return nil, err + } + for serviceTierRows.Next() { + var modelID string + var tier model.ModelPriceServiceTier + var promptConfigured, completionConfigured, cacheConfigured, cacheReadConfigured, cacheCreationConfigured int + if err := serviceTierRows.Scan( + &modelID, + &tier.Mode, + &tier.ServiceTier, + &tier.Prompt, + &tier.Completion, + &tier.Cache, + &tier.CacheRead, + &tier.CacheCreation, + &promptConfigured, + &completionConfigured, + &cacheConfigured, + &cacheReadConfigured, + &cacheCreationConfigured, + ); err != nil { + return nil, err + } + tier.PromptConfigured = promptConfigured != 0 + tier.CompletionConfigured = completionConfigured != 0 + tier.CacheConfigured = cacheConfigured != 0 + tier.CacheReadConfigured = cacheReadConfigured != 0 + tier.CacheCreationConfigured = cacheCreationConfigured != 0 + price, ok := prices[modelID] + if !ok { + continue + } + price.ServiceTiers = append(price.ServiceTiers, tier) + prices[modelID] = price + } + if err := serviceTierRows.Err(); err != nil { + _ = serviceTierRows.Close() + return nil, err + } + if err := serviceTierRows.Close(); err != nil { + return nil, err + } + return prices, nil } func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.ModelPrice) error { @@ -87,10 +202,32 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod _ = tx.Rollback() }() + normalizedPrices := make(map[string]model.ModelPrice, len(prices)) + for modelID, price := range prices { + if err := model.ValidateModelPrice(modelID, price); err != nil { + return err + } + price.ContextTiers, err = model.NormalizeModelPriceContextTiers(price.ContextTiers) + if err != nil { + return err + } + price.ServiceTiers, err = model.NormalizeModelPriceServiceTiers(price.ServiceTiers) + if err != nil { + return err + } + normalizedPrices[modelID] = price + } + + if _, err := tx.ExecContext(ctx, `delete from model_price_service_tiers`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `delete from model_price_context_tiers`); err != nil { + return err + } if _, err := tx.ExecContext(ctx, `delete from model_prices`); err != nil { return err } - if len(prices) == 0 { + if len(normalizedPrices) == 0 { return tx.Commit() } @@ -103,12 +240,19 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod return err } defer stmt.Close() + tierStmt, err := prepareContextTierInsert(ctx, tx) + if err != nil { + return err + } + defer tierStmt.Close() + serviceTierStmt, err := prepareServiceTierInsert(ctx, tx) + if err != nil { + return err + } + defer serviceTierStmt.Close() now := time.Now().UnixMilli() - for modelID, price := range prices { - if err := validateModelPrice(modelID, price); err != nil { - return err - } + for modelID, price := range normalizedPrices { if _, err := stmt.ExecContext( ctx, modelID, @@ -129,6 +273,12 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod ); err != nil { return err } + if err := insertContextTiers(ctx, tierStmt, modelID, price.ContextTiers); err != nil { + return err + } + if err := insertServiceTiers(ctx, serviceTierStmt, modelID, price.ServiceTiers); err != nil { + return err + } } return tx.Commit() } @@ -169,11 +319,41 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M return model.ModelPriceSyncResult{}, err } defer stmt.Close() + deleteTierStmt, err := tx.PrepareContext(ctx, `delete from model_price_context_tiers where model = ?`) + if err != nil { + return model.ModelPriceSyncResult{}, err + } + defer deleteTierStmt.Close() + tierStmt, err := prepareContextTierInsert(ctx, tx) + if err != nil { + return model.ModelPriceSyncResult{}, err + } + defer tierStmt.Close() + deleteServiceTierStmt, err := tx.PrepareContext(ctx, `delete from model_price_service_tiers where model = ?`) + if err != nil { + return model.ModelPriceSyncResult{}, err + } + defer deleteServiceTierStmt.Close() + serviceTierStmt, err := prepareServiceTierInsert(ctx, tx) + if err != nil { + return model.ModelPriceSyncResult{}, err + } + defer serviceTierStmt.Close() now := time.Now().UnixMilli() result := model.ModelPriceSyncResult{} for modelID, price := range prices { - if err := validateModelPrice(modelID, price); err != nil { + if err := model.ValidateModelPrice(modelID, price); err != nil { + result.Skipped++ + continue + } + price.ContextTiers, err = model.NormalizeModelPriceContextTiers(price.ContextTiers) + if err != nil { + result.Skipped++ + continue + } + price.ServiceTiers, err = model.NormalizeModelPriceServiceTiers(price.ServiceTiers) + if err != nil { result.Skipped++ continue } @@ -205,6 +385,18 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M ); err != nil { return model.ModelPriceSyncResult{}, err } + if _, err := deleteTierStmt.ExecContext(ctx, modelID); err != nil { + return model.ModelPriceSyncResult{}, err + } + if err := insertContextTiers(ctx, tierStmt, modelID, price.ContextTiers); err != nil { + return model.ModelPriceSyncResult{}, err + } + if _, err := deleteServiceTierStmt.ExecContext(ctx, modelID); err != nil { + return model.ModelPriceSyncResult{}, err + } + if err := insertServiceTiers(ctx, serviceTierStmt, modelID, price.ServiceTiers); err != nil { + return model.ModelPriceSyncResult{}, err + } result.Imported++ } if err := tx.Commit(); err != nil { @@ -213,19 +405,65 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M return result, nil } -func validateModelPrice(modelID string, price model.ModelPrice) error { - if modelID == "" { - return errors.New("model is required") - } - if !validPriceValue(price.Prompt) || !validPriceValue(price.Completion) || !validPriceValue(price.Cache) || - !validPriceValue(price.CacheRead) || !validPriceValue(price.CacheCreation) { - return fmt.Errorf("invalid model price for %s", modelID) +func prepareContextTierInsert(ctx context.Context, tx *sql.Tx) (*sql.Stmt, error) { + return tx.PrepareContext(ctx, `insert into model_price_context_tiers ( + model, threshold_tokens, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m, + prompt_configured, completion_configured, cache_configured, cache_read_configured, cache_creation_configured + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) +} + +func insertContextTiers(ctx context.Context, stmt *sql.Stmt, modelID string, tiers []model.ModelPriceContextTier) error { + for _, tier := range tiers { + if _, err := stmt.ExecContext( + ctx, + modelID, + tier.ThresholdTokens, + tier.Prompt, + tier.Completion, + tier.Cache, + tier.CacheRead, + tier.CacheCreation, + tier.PromptConfigured, + tier.CompletionConfigured, + tier.CacheConfigured, + tier.CacheReadConfigured, + tier.CacheCreationConfigured, + ); err != nil { + return err + } } return nil } -func validPriceValue(value float64) bool { - return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +func prepareServiceTierInsert(ctx context.Context, tx *sql.Tx) (*sql.Stmt, error) { + return tx.PrepareContext(ctx, `insert into model_price_service_tiers ( + model, mode, service_tier, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m, + prompt_configured, completion_configured, cache_configured, cache_read_configured, cache_creation_configured + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) +} + +func insertServiceTiers(ctx context.Context, stmt *sql.Stmt, modelID string, tiers []model.ModelPriceServiceTier) error { + for _, tier := range tiers { + if _, err := stmt.ExecContext( + ctx, + modelID, + tier.Mode, + tier.ServiceTier, + tier.Prompt, + tier.Completion, + tier.Cache, + tier.CacheRead, + tier.CacheCreation, + tier.PromptConfigured, + tier.CompletionConfigured, + tier.CacheConfigured, + tier.CacheReadConfigured, + tier.CacheCreationConfigured, + ); err != nil { + return err + } + } + return nil } func nullString(value string) any { From 512f6249855b6cc3a21e9e90fc444aa5b60f50c1 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:10:28 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20class?= =?UTF-8?q?ify=20usage=20for=20tier-aware=20pricing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify usage by resolved pricing model, context threshold, and effective service tier. Apply explicit context and service-tier rates while preserving legacy long-context behavior. Add query-plan and cost regression coverage for mixed pricing bands. --- .../repository/usageevent/aggregate.go | 70 +++- .../usageevent/aggregate_query_plan_test.go | 43 +++ .../repository/usageevent/analytics.go | 302 ++++++++++++------ .../internal/service/pricing/cost.go | 96 +++++- .../internal/service/pricing/cost_test.go | 289 +++++++++++++++++ apps/manager-server/internal/usage/event.go | 8 + 6 files changed, 688 insertions(+), 120 deletions(-) create mode 100644 apps/manager-server/internal/repository/usageevent/aggregate_query_plan_test.go diff --git a/apps/manager-server/internal/repository/usageevent/aggregate.go b/apps/manager-server/internal/repository/usageevent/aggregate.go index b68f8aa18..8b75a0026 100644 --- a/apps/manager-server/internal/repository/usageevent/aggregate.go +++ b/apps/manager-server/internal/repository/usageevent/aggregate.go @@ -5,9 +5,47 @@ import ( "database/sql" "fmt" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" ) +func pricingBandedUsageEventsCTEWithBaseFilter(baseFilter string) string { + whereClause := "" + if baseFilter != "" { + whereClause = "\n\twhere " + baseFilter + } + return fmt.Sprintf(`with pricing_base_events as ( + select + usage_events.*, + coalesce(nullif(resolved_model, ''), model) as billing_model_value, + coalesce(normalized_total_input_tokens, input_tokens, 0) as normalized_input_tokens_value + from usage_events%s +), pricing_resolved_events as ( + select + pricing_base_events.*, + case + when billing_price.model is not null then billing_model_value + when display_price.model is not null then pricing_base_events.model + else billing_model_value + end as pricing_model_value + from pricing_base_events + left join model_prices billing_price on billing_price.model = pricing_base_events.billing_model_value + left join model_prices display_price on display_price.model = pricing_base_events.model +), banded_usage_events as ( + select + pricing_resolved_events.*, + coalesce(( + select max(tier.threshold_tokens) + from model_price_context_tiers tier + where tier.model = pricing_resolved_events.pricing_model_value + and pricing_resolved_events.normalized_input_tokens_value > tier.threshold_tokens + ), %d) as context_threshold_tokens_value + from pricing_resolved_events +)`, whereClause, model.ModelPriceBaseContextThreshold) +} + +var pricingBandedUsageEventsCTE = pricingBandedUsageEventsCTEWithBaseFilter("") + // Aggregate captures roll-up metrics for a usage_events window. type Aggregate struct { usage.LongContextTokens @@ -29,6 +67,7 @@ type Aggregate struct { // ModelStat aggregates per-model totals. type ModelStat struct { usage.LongContextTokens + usage.PricingBand Model string BillingModel string ServiceTier string @@ -122,19 +161,20 @@ func (r *repository) AggregateBetween(ctx context.Context, fromMs, toMs int64) ( return agg, nil } -var topModelsSQL = fmt.Sprintf(`with top_models as ( +var topModelsSQL = fmt.Sprintf(pricingBandedUsageEventsCTEWithBaseFilter("timestamp_ms >= ? and timestamp_ms < ?")+`, top_models as ( select model, count(*) as model_calls - from usage_events - where timestamp_ms >= ? and timestamp_ms < ? + from banded_usage_events group by model order by model_calls desc limit ? ) select e.model, - coalesce(nullif(e.resolved_model, ''), e.model) as billing_model, + e.billing_model_value as billing_model, + e.pricing_model_value, + e.context_threshold_tokens_value, coalesce(e.service_tier, '') as service_tier, count(*) as calls, sum(case when e.failed = 0 then 1 else 0 end) as success, @@ -150,10 +190,9 @@ select coalesce(sum(case when coalesce(e.normalized_total_input_tokens, e.input_tokens) > %[1]d then e.cache_read_tokens else 0 end), 0), coalesce(sum(case when coalesce(e.normalized_total_input_tokens, e.input_tokens) > %[1]d then e.cache_creation_tokens else 0 end), 0), coalesce(sum(e.total_tokens), 0) -from usage_events e +from banded_usage_events e join top_models t on t.model = e.model -where e.timestamp_ms >= ? and e.timestamp_ms < ? -group by e.model, billing_model, coalesce(e.service_tier, '') +group by e.model, billing_model, e.pricing_model_value, e.context_threshold_tokens_value, coalesce(e.service_tier, '') order by max(t.model_calls) desc, e.model, calls desc`, usage.LongContextInputTokenThreshold) // TopModelsBetween returns the most active models ordered by call count. @@ -161,7 +200,7 @@ func (r *repository) TopModelsBetween(ctx context.Context, fromMs, toMs int64, l if limit <= 0 { limit = 5 } - rows, err := r.db.QueryContext(ctx, topModelsSQL, fromMs, toMs, limit, fromMs, toMs) + rows, err := r.db.QueryContext(ctx, topModelsSQL, fromMs, toMs, limit) if err != nil { return nil, err } @@ -173,6 +212,8 @@ func (r *repository) TopModelsBetween(ctx context.Context, fromMs, toMs int64, l if err := rows.Scan( &stat.Model, &stat.BillingModel, + &stat.PricingModel, + &stat.ContextThresholdTokens, &stat.ServiceTier, &stat.Calls, &stat.SuccessCalls, @@ -196,9 +237,12 @@ func (r *repository) TopModelsBetween(ctx context.Context, fromMs, toMs int64, l return stats, rows.Err() } -var modelStatsSQL = fmt.Sprintf(`select +var modelStatsSQL = fmt.Sprintf(pricingBandedUsageEventsCTE+` +select model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, count(*) as calls, sum(case when failed = 0 then 1 else 0 end) as success, @@ -214,9 +258,9 @@ var modelStatsSQL = fmt.Sprintf(`select coalesce(sum(case when coalesce(normalized_total_input_tokens, input_tokens) > %[1]d then cache_read_tokens else 0 end), 0), coalesce(sum(case when coalesce(normalized_total_input_tokens, input_tokens) > %[1]d then cache_creation_tokens else 0 end), 0), coalesce(sum(total_tokens), 0) -from usage_events +from banded_usage_events where timestamp_ms >= ? and timestamp_ms < ? -group by model, billing_model, coalesce(service_tier, '') +group by model, billing_model, pricing_model_value, context_threshold_tokens_value, coalesce(service_tier, '') order by calls desc`, usage.LongContextInputTokenThreshold) // ModelStatsBetween returns per-model totals for all models in a window. @@ -233,6 +277,8 @@ func (r *repository) ModelStatsBetween(ctx context.Context, fromMs, toMs int64) if err := rows.Scan( &stat.Model, &stat.BillingModel, + &stat.PricingModel, + &stat.ContextThresholdTokens, &stat.ServiceTier, &stat.Calls, &stat.SuccessCalls, diff --git a/apps/manager-server/internal/repository/usageevent/aggregate_query_plan_test.go b/apps/manager-server/internal/repository/usageevent/aggregate_query_plan_test.go new file mode 100644 index 000000000..54fd86ee2 --- /dev/null +++ b/apps/manager-server/internal/repository/usageevent/aggregate_query_plan_test.go @@ -0,0 +1,43 @@ +package usageevent + +import ( + "path/filepath" + "strings" + "testing" + + sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite" +) + +func TestTopModelsQueryUsesTimestampIndexBeforePricingMaterialization(t *testing.T) { + db, err := sqliterepo.Open(filepath.Join(t.TempDir(), "usage.sqlite")) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + rows, err := db.Query(`explain query plan `+topModelsSQL, int64(1_000), int64(2_000), 5) + if err != nil { + t.Fatalf("explain top models query: %v", err) + } + defer rows.Close() + + details := make([]string, 0, 8) + usesTimestampIndex := false + fullUsageScan := false + for rows.Next() { + var id, parent, notUsed int + var detail string + if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { + t.Fatalf("scan query plan: %v", err) + } + details = append(details, detail) + usesTimestampIndex = usesTimestampIndex || strings.Contains(detail, "SEARCH usage_events USING INDEX idx_usage_events_timestamp") + fullUsageScan = fullUsageScan || strings.Contains(detail, "SCAN usage_events") + } + if err := rows.Err(); err != nil { + t.Fatalf("query plan rows: %v", err) + } + if !usesTimestampIndex || fullUsageScan { + t.Fatalf("top models query did not constrain usage_events with the timestamp index: %v", details) + } +} diff --git a/apps/manager-server/internal/repository/usageevent/analytics.go b/apps/manager-server/internal/repository/usageevent/analytics.go index 627d53df5..9c61b5d0c 100644 --- a/apps/manager-server/internal/repository/usageevent/analytics.go +++ b/apps/manager-server/internal/repository/usageevent/analytics.go @@ -135,6 +135,7 @@ type APIKeySelectorValue struct { type TimelinePoint struct { usage.LongContextTokens + usage.PricingBand BucketMS int64 Model string BillingModel string @@ -161,6 +162,7 @@ type HourlyPoint struct { type HeatmapPoint struct { usage.LongContextTokens + usage.PricingBand Weekday int Hour int Model string @@ -181,6 +183,7 @@ type HeatmapPoint struct { type ChannelModelStat struct { usage.LongContextTokens + usage.PricingBand AuthIndex string Source string AccountSnapshot string @@ -217,6 +220,7 @@ type FailureSourceStat struct { type AccountModelStat struct { usage.LongContextTokens + usage.PricingBand AccountSnapshot string AuthLabelSnapshot string AuthProviderSnapshot string @@ -242,6 +246,7 @@ type AccountModelStat struct { type CredentialModelStat struct { usage.LongContextTokens + usage.PricingBand ID string AuthFileSnapshot string AuthIndex string @@ -270,6 +275,7 @@ type CredentialModelStat struct { type CredentialTimelinePoint struct { usage.LongContextTokens + usage.PricingBand ID string AuthFileSnapshot string AuthIndex string @@ -299,6 +305,7 @@ type CredentialTimelinePoint struct { type APIKeyTimelinePoint struct { usage.LongContextTokens + usage.PricingBand APIKeyHash string BucketMS int64 Model string @@ -320,6 +327,7 @@ type APIKeyTimelinePoint struct { type APIKeyModelStat struct { usage.LongContextTokens + usage.PricingBand APIKeyHash string AccountSnapshot string AuthLabelSnapshot string @@ -480,9 +488,12 @@ from usage_events `+where, args...) func (r *repository) ModelStatsWithFilter(ctx context.Context, filter AnalyticsFilter, limit int) ([]ModelStat, error) { where, args := analyticsWhere(filter) - query := `select + query := pricingBandedUsageEventsCTE + ` +select model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, count(*) as calls, sum(case when failed = 0 then 1 else 0 end) as success, @@ -498,12 +509,12 @@ func (r *repository) ModelStatsWithFilter(ctx context.Context, filter AnalyticsF coalesce(sum(` + longCacheReadExpr + `), 0), coalesce(sum(` + longCacheCreationExpr + `), 0), coalesce(sum(total_tokens), 0) -from usage_events ` + where + ` -group by model, billing_model, coalesce(service_tier, '') +from banded_usage_events ` + where + ` +group by model, billing_model, pricing_model_value, context_threshold_tokens_value, coalesce(service_tier, '') order by calls desc` if limit > 0 { - query = `with filtered as ( - select * from usage_events ` + where + ` + query = pricingBandedUsageEventsCTE + `, filtered as ( + select * from banded_usage_events ` + where + ` ), top_models as ( select model, count(*) as model_calls @@ -514,7 +525,9 @@ top_models as ( ) select f.model, - coalesce(nullif(f.resolved_model, ''), f.model) as billing_model, + f.billing_model_value as billing_model, + f.pricing_model_value, + f.context_threshold_tokens_value, coalesce(f.service_tier, '') as service_tier, count(*) as calls, sum(case when f.failed = 0 then 1 else 0 end) as success, @@ -532,7 +545,7 @@ select coalesce(sum(f.total_tokens), 0) from filtered f join top_models t on t.model = f.model -group by f.model, billing_model, coalesce(f.service_tier, '') +group by f.model, billing_model, f.pricing_model_value, f.context_threshold_tokens_value, coalesce(f.service_tier, '') order by max(t.model_calls) desc, f.model, calls desc` args = append(args, limit) } @@ -548,6 +561,8 @@ order by max(t.model_calls) desc, f.model, calls desc` if err := rows.Scan( &stat.Model, &stat.BillingModel, + &stat.PricingModel, + &stat.ContextThresholdTokens, &stat.ServiceTier, &stat.Calls, &stat.SuccessCalls, @@ -573,11 +588,14 @@ order by max(t.model_calls) desc, f.model, calls desc` func (r *repository) TimelineWithFilter(ctx context.Context, filter AnalyticsFilter, granularity string, location *time.Location) ([]TimelinePoint, error) { where, args := analyticsWhere(filter) - query := fmt.Sprintf(`select + query := fmt.Sprintf(pricingBandedUsageEventsCTE+` +select timestamp_ms, model, - coalesce(nullif(resolved_model, ''), model) as billing_model, - coalesce(service_tier, '') as service_tier, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, + coalesce(service_tier, '') as service_tier, failed, `+normalizedInputExpr+`, output_tokens, @@ -587,7 +605,7 @@ func (r *repository) TimelineWithFilter(ctx context.Context, filter AnalyticsFil cache_creation_tokens, total_tokens, latency_ms -from usage_events %s +from banded_usage_events %s order by timestamp_ms, model`, where) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { @@ -596,10 +614,12 @@ order by timestamp_ms, model`, where) defer rows.Close() type key struct { - bucketMS int64 - model string - billingModel string - serviceTier string + bucketMS int64 + model string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 } grouped := map[key]*TimelinePoint{} order := make([]key, 0) @@ -607,7 +627,9 @@ order by timestamp_ms, model`, where) var timestampMS int64 var model string var billingModel string + var pricingModel string var serviceTier string + var contextThresholdTokens int64 var failed int var latency sql.NullFloat64 var inputTokens int64 @@ -621,6 +643,8 @@ order by timestamp_ms, model`, where) ×tampMS, &model, &billingModel, + &pricingModel, + &contextThresholdTokens, &serviceTier, &failed, &inputTokens, @@ -635,14 +659,20 @@ order by timestamp_ms, model`, where) return nil, err } mapKey := key{ - bucketMS: usage.AnalyticsBucketMS(timestampMS, granularity, location), - model: model, - billingModel: billingModel, - serviceTier: serviceTier, + bucketMS: usage.AnalyticsBucketMS(timestampMS, granularity, location), + model: model, + billingModel: billingModel, + pricingModel: pricingModel, + serviceTier: serviceTier, + contextThresholdTokens: contextThresholdTokens, } point := grouped[mapKey] if point == nil { point = &TimelinePoint{ + PricingBand: usage.PricingBand{ + PricingModel: pricingModel, + ContextThresholdTokens: contextThresholdTokens, + }, BucketMS: mapKey.bucketMS, Model: model, BillingModel: billingModel, @@ -690,11 +720,14 @@ func (r *repository) APIKeyTimelineWithFilter(ctx context.Context, filter Analyt return nil, nil } where, args := analyticsWhere(filter) - query := fmt.Sprintf(`select + query := fmt.Sprintf(pricingBandedUsageEventsCTE+` +select timestamp_ms, coalesce(api_key_hash, ''), model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, failed, `+normalizedInputExpr+`, @@ -705,7 +738,7 @@ func (r *repository) APIKeyTimelineWithFilter(ctx context.Context, filter Analyt cache_creation_tokens, total_tokens, latency_ms -from usage_events %s +from banded_usage_events %s order by timestamp_ms, api_key_hash, model`, where) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { @@ -714,11 +747,13 @@ order by timestamp_ms, api_key_hash, model`, where) defer rows.Close() type key struct { - apiKeyHash string - bucketMS int64 - model string - billingModel string - serviceTier string + apiKeyHash string + bucketMS int64 + model string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 } grouped := map[key]*APIKeyTimelinePoint{} order := make([]key, 0) @@ -733,6 +768,8 @@ order by timestamp_ms, api_key_hash, model`, where) &point.APIKeyHash, &point.Model, &point.BillingModel, + &point.PricingModel, + &point.ContextThresholdTokens, &point.ServiceTier, &failed, &point.InputTokens, @@ -747,15 +784,18 @@ order by timestamp_ms, api_key_hash, model`, where) return nil, err } mapKey := key{ - apiKeyHash: point.APIKeyHash, - bucketMS: usage.AnalyticsBucketMS(timestampMS, granularity, location), - model: point.Model, - billingModel: point.BillingModel, - serviceTier: point.ServiceTier, + apiKeyHash: point.APIKeyHash, + bucketMS: usage.AnalyticsBucketMS(timestampMS, granularity, location), + model: point.Model, + billingModel: point.BillingModel, + pricingModel: point.PricingModel, + serviceTier: point.ServiceTier, + contextThresholdTokens: point.ContextThresholdTokens, } entry := grouped[mapKey] if entry == nil { entry = &APIKeyTimelinePoint{ + PricingBand: point.PricingBand, APIKeyHash: point.APIKeyHash, BucketMS: mapKey.bucketMS, Model: point.Model, @@ -1183,10 +1223,13 @@ order by value`, args...) func (r *repository) HeatmapWithFilter(ctx context.Context, filter AnalyticsFilter, location *time.Location) ([]HeatmapPoint, error) { where, args := analyticsWhere(filter) - rows, err := r.db.QueryContext(ctx, `select + rows, err := r.db.QueryContext(ctx, pricingBandedUsageEventsCTE+` +select timestamp_ms, model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, coalesce(api_key_hash, ''), coalesce(nullif(auth_provider_snapshot, ''), provider, ''), @@ -1197,7 +1240,7 @@ func (r *repository) HeatmapWithFilter(ctx context.Context, filter AnalyticsFilt cache_read_tokens, cache_creation_tokens, total_tokens -from usage_events `+where+` +from banded_usage_events `+where+` order by timestamp_ms, model`, args...) if err != nil { return nil, err @@ -1208,13 +1251,15 @@ order by timestamp_ms, model`, args...) location = time.UTC } type key struct { - weekday int - hour int - model string - billingModel string - serviceTier string - apiKeyHash string - provider string + weekday int + hour int + model string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 + apiKeyHash string + provider string } grouped := map[key]*HeatmapPoint{} order := make([]key, 0) @@ -1222,7 +1267,9 @@ order by timestamp_ms, model`, args...) var timestampMS int64 var model string var billingModel string + var pricingModel string var serviceTier string + var contextThresholdTokens int64 var apiKeyHash string var provider string var failed int @@ -1236,6 +1283,8 @@ order by timestamp_ms, model`, args...) ×tampMS, &model, &billingModel, + &pricingModel, + &contextThresholdTokens, &serviceTier, &apiKeyHash, &provider, @@ -1251,17 +1300,23 @@ order by timestamp_ms, model`, args...) } tm := time.UnixMilli(timestampMS).In(location) mapKey := key{ - weekday: int(tm.Weekday()), - hour: tm.Hour(), - model: model, - billingModel: billingModel, - serviceTier: serviceTier, - apiKeyHash: apiKeyHash, - provider: provider, + weekday: int(tm.Weekday()), + hour: tm.Hour(), + model: model, + billingModel: billingModel, + pricingModel: pricingModel, + serviceTier: serviceTier, + contextThresholdTokens: contextThresholdTokens, + apiKeyHash: apiKeyHash, + provider: provider, } point := grouped[mapKey] if point == nil { point = &HeatmapPoint{ + PricingBand: usage.PricingBand{ + PricingModel: pricingModel, + ContextThresholdTokens: contextThresholdTokens, + }, Weekday: mapKey.weekday, Hour: mapKey.hour, Model: model, @@ -1299,14 +1354,17 @@ order by timestamp_ms, model`, args...) func (r *repository) ChannelModelStatsWithFilter(ctx context.Context, filter AnalyticsFilter) ([]ChannelModelStat, error) { where, args := analyticsWhere(filter) - rows, err := r.db.QueryContext(ctx, `select + rows, err := r.db.QueryContext(ctx, pricingBandedUsageEventsCTE+` +select coalesce(auth_index, ''), coalesce(max(source), ''), coalesce(max(account_snapshot), ''), coalesce(max(auth_label_snapshot), ''), coalesce(nullif(max(auth_provider_snapshot), ''), max(provider), ''), model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, count(*), sum(case when failed = 0 then 1 else 0 end), @@ -1324,8 +1382,8 @@ func (r *repository) ChannelModelStatsWithFilter(ctx context.Context, filter Ana coalesce(sum(total_tokens), 0), avg(nullif(latency_ms, 0)), count(nullif(latency_ms, 0)) -from usage_events `+where+` -group by auth_index, model, billing_model, coalesce(service_tier, '') +from banded_usage_events `+where+` +group by auth_index, model, billing_model, pricing_model_value, context_threshold_tokens_value, coalesce(service_tier, '') order by count(*) desc`, args...) if err != nil { return nil, err @@ -1343,6 +1401,8 @@ order by count(*) desc`, args...) &stat.AuthProviderSnapshot, &stat.Model, &stat.BillingModel, + &stat.PricingModel, + &stat.ContextThresholdTokens, &stat.ServiceTier, &stat.Calls, &stat.SuccessCalls, @@ -1414,7 +1474,8 @@ order by sum(case when failed = 1 then 1 else 0 end) desc, max(timestamp_ms) des func (r *repository) AccountModelStatsWithFilter(ctx context.Context, filter AnalyticsFilter) ([]AccountModelStat, error) { where, args := analyticsWhere(filter) - rows, err := r.db.QueryContext(ctx, `select + rows, err := r.db.QueryContext(ctx, pricingBandedUsageEventsCTE+` +select coalesce(account_snapshot, ''), coalesce(auth_label_snapshot, ''), coalesce(nullif(auth_provider_snapshot, ''), provider, ''), @@ -1422,7 +1483,9 @@ func (r *repository) AccountModelStatsWithFilter(ctx context.Context, filter Ana coalesce(max(source), ''), coalesce(source_hash, ''), model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, count(*), sum(case when failed = 0 then 1 else 0 end), @@ -1441,8 +1504,8 @@ func (r *repository) AccountModelStatsWithFilter(ctx context.Context, filter Ana max(timestamp_ms), avg(nullif(latency_ms, 0)), count(nullif(latency_ms, 0)) -from usage_events `+where+` -group by account_snapshot, auth_label_snapshot, coalesce(nullif(auth_provider_snapshot, ''), provider, ''), auth_index, source_hash, model, billing_model, coalesce(service_tier, '') +from banded_usage_events `+where+` +group by account_snapshot, auth_label_snapshot, coalesce(nullif(auth_provider_snapshot, ''), provider, ''), auth_index, source_hash, model, billing_model, pricing_model_value, context_threshold_tokens_value, coalesce(service_tier, '') order by max(timestamp_ms) desc, count(*) desc`, args...) if err != nil { return nil, err @@ -1461,6 +1524,8 @@ order by max(timestamp_ms) desc, count(*) desc`, args...) &stat.SourceHash, &stat.Model, &stat.BillingModel, + &stat.PricingModel, + &stat.ContextThresholdTokens, &stat.ServiceTier, &stat.Calls, &stat.SuccessCalls, @@ -1489,7 +1554,8 @@ order by max(timestamp_ms) desc, count(*) desc`, args...) func (r *repository) CredentialModelStatsWithFilter(ctx context.Context, filter AnalyticsFilter) ([]CredentialModelStat, error) { where, args := analyticsWhere(filter) - rows, err := r.db.QueryContext(ctx, `select + rows, err := r.db.QueryContext(ctx, pricingBandedUsageEventsCTE+` +select `+credentialIDExpr+` as credential_id, coalesce(auth_file_snapshot, ''), coalesce(auth_index, ''), @@ -1500,7 +1566,9 @@ func (r *repository) CredentialModelStatsWithFilter(ctx context.Context, filter coalesce(nullif(max(auth_provider_snapshot), ''), max(provider), ''), coalesce(max(auth_project_id_snapshot), ''), model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, count(*), sum(case when failed = 0 then 1 else 0 end), @@ -1519,8 +1587,8 @@ func (r *repository) CredentialModelStatsWithFilter(ctx context.Context, filter max(timestamp_ms), avg(nullif(latency_ms, 0)), count(nullif(latency_ms, 0)) -from usage_events `+where+` -group by credential_id, auth_file_snapshot, auth_index, source_hash, model, billing_model, coalesce(service_tier, '') +from banded_usage_events `+where+` +group by credential_id, auth_file_snapshot, auth_index, source_hash, model, billing_model, pricing_model_value, context_threshold_tokens_value, coalesce(service_tier, '') order by max(timestamp_ms) desc, count(*) desc`, args...) if err != nil { return nil, err @@ -1542,6 +1610,8 @@ order by max(timestamp_ms) desc, count(*) desc`, args...) &stat.AuthProjectIDSnapshot, &stat.Model, &stat.BillingModel, + &stat.PricingModel, + &stat.ContextThresholdTokens, &stat.ServiceTier, &stat.Calls, &stat.SuccessCalls, @@ -1606,7 +1676,8 @@ func (r *repository) CredentialTimelineWithFilter(ctx context.Context, filter An func (r *repository) credentialTimelineRawWithFilter(ctx context.Context, filter AnalyticsFilter, granularity string, location *time.Location) ([]CredentialTimelinePoint, error) { where, args := analyticsWhere(filter) - query := fmt.Sprintf(`select + query := fmt.Sprintf(pricingBandedUsageEventsCTE+` +select timestamp_ms, `+credentialIDExpr+` as credential_id, coalesce(auth_file_snapshot, ''), @@ -1618,8 +1689,10 @@ func (r *repository) credentialTimelineRawWithFilter(ctx context.Context, filter coalesce(nullif(auth_provider_snapshot, ''), provider, ''), coalesce(auth_project_id_snapshot, ''), model, - coalesce(nullif(resolved_model, ''), model) as billing_model, - coalesce(service_tier, '') as service_tier, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, + coalesce(service_tier, '') as service_tier, failed, `+normalizedInputExpr+`, output_tokens, @@ -1629,7 +1702,7 @@ func (r *repository) credentialTimelineRawWithFilter(ctx context.Context, filter cache_creation_tokens, total_tokens, latency_ms -from usage_events %s +from banded_usage_events %s order by timestamp_ms, credential_id, model`, where) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { @@ -1638,14 +1711,16 @@ order by timestamp_ms, credential_id, model`, where) defer rows.Close() type key struct { - id string - authFileSnapshot string - authIndex string - sourceHash string - bucketMS int64 - model string - billingModel string - serviceTier string + id string + authFileSnapshot string + authIndex string + sourceHash string + bucketMS int64 + model string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 } grouped := map[key]*CredentialTimelinePoint{} order := make([]key, 0) @@ -1668,6 +1743,8 @@ order by timestamp_ms, credential_id, model`, where) &point.AuthProjectIDSnapshot, &point.Model, &point.BillingModel, + &point.PricingModel, + &point.ContextThresholdTokens, &point.ServiceTier, &failed, &point.InputTokens, @@ -1683,18 +1760,21 @@ order by timestamp_ms, credential_id, model`, where) } bucketMS := usage.AnalyticsBucketMS(timestampMS, granularity, location) mapKey := key{ - id: point.ID, - authFileSnapshot: point.AuthFileSnapshot, - authIndex: point.AuthIndex, - sourceHash: point.SourceHash, - bucketMS: bucketMS, - model: point.Model, - billingModel: point.BillingModel, - serviceTier: point.ServiceTier, + id: point.ID, + authFileSnapshot: point.AuthFileSnapshot, + authIndex: point.AuthIndex, + sourceHash: point.SourceHash, + bucketMS: bucketMS, + model: point.Model, + billingModel: point.BillingModel, + pricingModel: point.PricingModel, + serviceTier: point.ServiceTier, + contextThresholdTokens: point.ContextThresholdTokens, } entry := grouped[mapKey] if entry == nil { entry = &CredentialTimelinePoint{ + PricingBand: point.PricingBand, ID: point.ID, AuthFileSnapshot: point.AuthFileSnapshot, AuthIndex: point.AuthIndex, @@ -1749,8 +1829,8 @@ order by timestamp_ms, credential_id, model`, where) func (r *repository) credentialTimelineHourlyWithFilter(ctx context.Context, filter AnalyticsFilter, granularity string, location *time.Location) ([]CredentialTimelinePoint, error) { where, args := analyticsWhere(filter) const hourBucketExpr = "(timestamp_ms / 3600000) * 3600000" - queryPrefix := "" - queryFrom := "from usage_events\n" + queryPrefix := pricingBandedUsageEventsCTE + "\n" + queryFrom := "from banded_usage_events\n" bucketExpr := "bucket_map.bucket_ms" queryArgs := args if offsetMS, ok := analyticsConstantOffsetMS(filter.FromMS, filter.ToMS, location); ok { @@ -1764,7 +1844,7 @@ func (r *repository) credentialTimelineHourlyWithFilter(ctx context.Context, fil if !ok { return r.credentialTimelineRawWithFilter(ctx, filter, granularity, location) } - queryPrefix = "with bucket_map(hour_bucket, bucket_ms) as (values " + mapSQL + ")\n" + queryPrefix = pricingBandedUsageEventsCTE + ", bucket_map(hour_bucket, bucket_ms) as (values " + mapSQL + ")\n" queryFrom += "join bucket_map on " + hourBucketExpr + " = bucket_map.hour_bucket\n" queryArgs = append(mapArgs, args...) } @@ -1780,7 +1860,9 @@ func (r *repository) credentialTimelineHourlyWithFilter(ctx context.Context, fil coalesce(nullif(auth_provider_snapshot, ''), provider, ''), coalesce(auth_project_id_snapshot, ''), model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, count(*), coalesce(sum(total_tokens), 0), @@ -1804,7 +1886,7 @@ group by ` + bucketExpr + `, credential_id, coalesce(auth_file_snapshot, ''), coalesce(auth_index, ''), coalesce(source, ''), coalesce(source_hash, ''), coalesce(account_snapshot, ''), coalesce(auth_label_snapshot, ''), coalesce(nullif(auth_provider_snapshot, ''), provider, ''), coalesce(auth_project_id_snapshot, ''), - model, billing_model, service_tier + model, billing_model, pricing_model_value, context_threshold_tokens_value, service_tier order by min(timestamp_ms), credential_id, model` rows, err := r.db.QueryContext(ctx, query, queryArgs...) if err != nil { @@ -1828,6 +1910,8 @@ order by min(timestamp_ms), credential_id, model` &point.AuthProjectIDSnapshot, &point.Model, &point.BillingModel, + &point.PricingModel, + &point.ContextThresholdTokens, &point.ServiceTier, &point.Calls, &point.Tokens, @@ -1895,20 +1979,33 @@ func credentialBucketMapSQL(fromMS, toMS int64, granularity string, location *ti func mergeCredentialTimelineParts(parts [][]CredentialTimelinePoint) []CredentialTimelinePoint { type key struct { - id string - authFileSnapshot string - authIndex string - sourceHash string - bucketMS int64 - model string - billingModel string - serviceTier string + id string + authFileSnapshot string + authIndex string + sourceHash string + bucketMS int64 + model string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 } grouped := make(map[key]*CredentialTimelinePoint) order := make([]key, 0) for _, points := range parts { for _, point := range points { - mapKey := key{point.ID, point.AuthFileSnapshot, point.AuthIndex, point.SourceHash, point.BucketMS, point.Model, point.BillingModel, point.ServiceTier} + mapKey := key{ + id: point.ID, + authFileSnapshot: point.AuthFileSnapshot, + authIndex: point.AuthIndex, + sourceHash: point.SourceHash, + bucketMS: point.BucketMS, + model: point.Model, + billingModel: point.BillingModel, + pricingModel: point.PricingModel, + serviceTier: point.ServiceTier, + contextThresholdTokens: point.ContextThresholdTokens, + } entry := grouped[mapKey] if entry == nil { next := point @@ -1963,7 +2060,8 @@ func mergeCredentialTimelineParts(parts [][]CredentialTimelinePoint) []Credentia func (r *repository) APIKeyModelStatsWithFilter(ctx context.Context, filter AnalyticsFilter) ([]APIKeyModelStat, error) { where, args := analyticsWhere(filter) - rows, err := r.db.QueryContext(ctx, `select + rows, err := r.db.QueryContext(ctx, pricingBandedUsageEventsCTE+` +select coalesce(api_key_hash, ''), coalesce(account_snapshot, ''), coalesce(auth_label_snapshot, ''), @@ -1972,7 +2070,9 @@ func (r *repository) APIKeyModelStatsWithFilter(ctx context.Context, filter Anal coalesce(max(source), ''), coalesce(source_hash, ''), model, - coalesce(nullif(resolved_model, ''), model) as billing_model, + billing_model_value as billing_model, + pricing_model_value, + context_threshold_tokens_value, coalesce(service_tier, '') as service_tier, count(*), sum(case when failed = 0 then 1 else 0 end), @@ -1991,8 +2091,8 @@ func (r *repository) APIKeyModelStatsWithFilter(ctx context.Context, filter Anal max(timestamp_ms), avg(nullif(latency_ms, 0)), count(nullif(latency_ms, 0)) -from usage_events `+where+` -group by api_key_hash, account_snapshot, auth_label_snapshot, coalesce(nullif(auth_provider_snapshot, ''), provider, ''), auth_index, source_hash, model, billing_model, coalesce(service_tier, '') +from banded_usage_events `+where+` +group by api_key_hash, account_snapshot, auth_label_snapshot, coalesce(nullif(auth_provider_snapshot, ''), provider, ''), auth_index, source_hash, model, billing_model, pricing_model_value, context_threshold_tokens_value, coalesce(service_tier, '') order by max(timestamp_ms) desc, count(*) desc`, args...) if err != nil { return nil, err @@ -2012,6 +2112,8 @@ order by max(timestamp_ms) desc, count(*) desc`, args...) &stat.SourceHash, &stat.Model, &stat.BillingModel, + &stat.PricingModel, + &stat.ContextThresholdTokens, &stat.ServiceTier, &stat.Calls, &stat.SuccessCalls, diff --git a/apps/manager-server/internal/service/pricing/cost.go b/apps/manager-server/internal/service/pricing/cost.go index 8b5b8abc6..9cc3948e5 100644 --- a/apps/manager-server/internal/service/pricing/cost.go +++ b/apps/manager-server/internal/service/pricing/cost.go @@ -14,6 +14,8 @@ const PerMillion = 1_000_000.0 // CachedTokens is the remaining legacy/OpenAI-style cached input after any // fine-grained cache_read/cache_creation values have already been removed. type ModelTokens struct { + PricingModel string + ContextThresholdTokens int64 InputTokens int64 OutputTokens int64 CachedTokens int64 @@ -42,10 +44,26 @@ func CostForModel(modelName string, tokens ModelTokens, prices map[string]model. } func costForPrice(modelName string, tokens ModelTokens, price model.ModelPrice) float64 { + return costForPriceWithLegacyLongContext(modelName, tokens, price, true) +} + +func costForPriceWithLegacyLongContext(modelName string, tokens ModelTokens, price model.ModelPrice, allowLegacyLongContext bool) float64 { if isGPT56Model(modelName) { price = enrichGPT56BasePrice(modelName, price) } - if supportsLongContextPremium(modelName) { + if effectivePrice, ok := activeContextPrice(tokens, price); ok { + return costForSegment( + maxInt64(tokens.InputTokens, 0), + maxInt64(tokens.OutputTokens, 0), + maxInt64(tokens.CachedTokens, 0), + maxInt64(tokens.CacheReadTokens, 0), + maxInt64(tokens.CacheCreationTokens, 0), + effectivePrice, + 1, + 1, + ) + } + if allowLegacyLongContext && supportsLongContextPremium(modelName) { return costForLongContextModel(tokens, price) } return costForSegment( @@ -154,8 +172,8 @@ func ServiceTierMultiplier(modelName string, serviceTier string) float64 { } } -// CostForModelWithServiceTier computes standard token cost first, then applies -// the multiplier for the actual service_tier recorded by usage. +// CostForModelWithServiceTier selects context or explicit service-tier prices +// before falling back to the compatibility multiplier for older price books. func CostForModelWithServiceTier(modelName string, serviceTier string, tokens ModelTokens, prices map[string]model.ModelPrice) float64 { price, ok := resolveModelPrice(modelName, prices) if !ok { @@ -183,6 +201,11 @@ func CostForModelCandidatesWithServiceTier(modelNames []string, serviceTier stri if len(candidates) > 0 { behaviorModel = candidates[0] } + if pricingModel := strings.TrimSpace(tokens.PricingModel); pricingModel != "" { + if price, ok := prices[pricingModel]; ok { + return costForPriceWithServiceTier(behaviorModel, serviceTier, tokens, price) + } + } for _, modelName := range candidates { price, ok := prices[modelName] if !ok { @@ -251,14 +274,71 @@ func supportsLongContextPremium(modelName string) bool { } func costForPriceWithServiceTier(modelName, serviceTier string, tokens ModelTokens, price model.ModelPrice) float64 { - multiplier := ServiceTierMultiplier(modelName, serviceTier) - if tokens.LongInputTokens > 0 { + if activeContextTier(tokens, price) { + return costForPrice(modelName, tokens, price) + } + legacyLongContext := len(price.ContextTiers) == 0 && supportsLongContextPremium(modelName) && tokens.LongInputTokens > 0 + if legacyLongContext { tier := strings.ToLower(strings.TrimSpace(serviceTier)) - if tier == "priority" || tier == "fast" { - multiplier = 1 + if tier != "priority" && tier != "fast" { + if effectivePrice, ok := model.ModelPriceForServiceTier(price, serviceTier); ok { + return costForPriceWithLegacyLongContext(modelName, tokens, effectivePrice, true) + } + return costForPrice(modelName, tokens, price) * ServiceTierMultiplier(modelName, serviceTier) + } + shortTokens, longTokens := splitLegacyLongContextTokens(tokens) + longCost := costForPriceWithLegacyLongContext(modelName, longTokens, price, true) + if effectivePrice, ok := model.ModelPriceForServiceTier(price, serviceTier); ok { + return costForPriceWithLegacyLongContext(modelName, shortTokens, effectivePrice, false) + longCost } + return costForPriceWithLegacyLongContext(modelName, shortTokens, price, false)*ServiceTierMultiplier(modelName, serviceTier) + longCost + } + if effectivePrice, ok := model.ModelPriceForServiceTier(price, serviceTier); ok { + return costForPriceWithLegacyLongContext(modelName, tokens, effectivePrice, len(price.ContextTiers) == 0) + } + return costForPrice(modelName, tokens, price) * ServiceTierMultiplier(modelName, serviceTier) +} + +func splitLegacyLongContextTokens(tokens ModelTokens) (ModelTokens, ModelTokens) { + longTokens := ModelTokens{ + PricingModel: tokens.PricingModel, + InputTokens: clampTokens(tokens.LongInputTokens, maxInt64(tokens.InputTokens, 0)), + OutputTokens: clampTokens(tokens.LongOutputTokens, maxInt64(tokens.OutputTokens, 0)), + CachedTokens: clampTokens(tokens.LongCachedTokens, maxInt64(tokens.CachedTokens, 0)), + CacheReadTokens: clampTokens(tokens.LongCacheReadTokens, maxInt64(tokens.CacheReadTokens, 0)), + CacheCreationTokens: clampTokens(tokens.LongCacheCreationTokens, maxInt64(tokens.CacheCreationTokens, 0)), + } + longTokens.LongInputTokens = longTokens.InputTokens + longTokens.LongOutputTokens = longTokens.OutputTokens + longTokens.LongCachedTokens = longTokens.CachedTokens + longTokens.LongCacheReadTokens = longTokens.CacheReadTokens + longTokens.LongCacheCreationTokens = longTokens.CacheCreationTokens + + shortTokens := ModelTokens{ + PricingModel: tokens.PricingModel, + InputTokens: maxInt64(tokens.InputTokens, 0) - longTokens.InputTokens, + OutputTokens: maxInt64(tokens.OutputTokens, 0) - longTokens.OutputTokens, + CachedTokens: maxInt64(tokens.CachedTokens, 0) - longTokens.CachedTokens, + CacheReadTokens: maxInt64(tokens.CacheReadTokens, 0) - longTokens.CacheReadTokens, + CacheCreationTokens: maxInt64(tokens.CacheCreationTokens, 0) - longTokens.CacheCreationTokens, + } + return shortTokens, longTokens +} + +func activeContextTier(tokens ModelTokens, price model.ModelPrice) bool { + if tokens.ContextThresholdTokens <= 0 { + return false + } + _, ok := model.ModelPriceForContextThreshold(price, tokens.ContextThresholdTokens) + return ok +} + +func activeContextPrice(tokens ModelTokens, price model.ModelPrice) (model.ModelPrice, bool) { + if len(price.ContextTiers) == 0 || tokens.ContextThresholdTokens == 0 { + return model.ModelPrice{}, false } - return costForPrice(modelName, tokens, price) * multiplier + effective, ok := model.ModelPriceForContextThreshold(price, tokens.ContextThresholdTokens) + return effective, ok } func resolveModelPrice(modelName string, prices map[string]model.ModelPrice) (model.ModelPrice, bool) { diff --git a/apps/manager-server/internal/service/pricing/cost_test.go b/apps/manager-server/internal/service/pricing/cost_test.go index ee61dabc2..07d357808 100644 --- a/apps/manager-server/internal/service/pricing/cost_test.go +++ b/apps/manager-server/internal/service/pricing/cost_test.go @@ -86,6 +86,268 @@ func TestCostForModelDoesNotDoubleBillOpenAICacheMirror(t *testing.T) { } } +func TestCostForModelAppliesContextTierToWholeAggregate(t *testing.T) { + prices := map[string]model.ModelPrice{ + "tiered-model": { + Prompt: 1, Completion: 2, + ContextTiers: []model.ModelPriceContextTier{ + {ThresholdTokens: 100, Prompt: 3, Completion: 4, PromptConfigured: true, CompletionConfigured: true}, + }, + }, + } + cost := CostForModel("tiered-model", ModelTokens{ + ContextThresholdTokens: 100, + InputTokens: 1_000_000, + OutputTokens: 500_000, + }, prices) + if math.Abs(cost-5) > 0.000001 { + t.Fatalf("tiered cost = %v, want 5", cost) + } + baseCost := CostForModel("tiered-model", ModelTokens{ + ContextThresholdTokens: model.ModelPriceBaseContextThreshold, + InputTokens: 1_000_000, + OutputTokens: 500_000, + }, prices) + if math.Abs(baseCost-2) > 0.000001 { + t.Fatalf("base-band cost = %v, want 2", baseCost) + } +} + +func TestContextTierOverridesLegacyLongContextPricing(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-5.6-sol": { + Prompt: 5, Completion: 30, + ContextTiers: []model.ModelPriceContextTier{ + {ThresholdTokens: 272_000, Prompt: 10, Completion: 40, PromptConfigured: true, CompletionConfigured: true}, + }, + }, + } + cost := CostForModel("gpt-5.6-sol", ModelTokens{ + ContextThresholdTokens: 272_000, + InputTokens: 1_000_000, + LongInputTokens: 1_000_000, + }, prices) + if math.Abs(cost-10) > 0.000001 { + t.Fatalf("generic context-tier cost = %v, want 10", cost) + } +} + +func TestContextTierDoesNotStackPriorityMultiplier(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-5.6-sol": { + Prompt: 5, Completion: 30, + ContextTiers: []model.ModelPriceContextTier{ + {ThresholdTokens: 272_000, Prompt: 10, Completion: 40, PromptConfigured: true, CompletionConfigured: true}, + }, + ServiceTiers: []model.ModelPriceServiceTier{ + {Mode: "fast", ServiceTier: "priority", Prompt: 12.5, Completion: 75, PromptConfigured: true, CompletionConfigured: true}, + }, + }, + } + tokens := ModelTokens{ + ContextThresholdTokens: 272_000, + InputTokens: 1_000_000, + LongInputTokens: 1_000_000, + } + standard := CostForModelWithServiceTier("gpt-5.6-sol", "default", tokens, prices) + priority := CostForModelWithServiceTier("gpt-5.6-sol", "priority", tokens, prices) + if math.Abs(priority-standard) > 0.000001 { + t.Fatalf("priority context-tier cost = %v, want standard context-tier cost %v", priority, standard) + } +} + +func TestExplicitFastPriceAppliesToBaseContextBandForFastAndPriority(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-5.5": { + Prompt: 5, Completion: 30, + ContextTiers: []model.ModelPriceContextTier{ + {ThresholdTokens: 272_000, Prompt: 10, Completion: 45, PromptConfigured: true, CompletionConfigured: true}, + }, + ServiceTiers: []model.ModelPriceServiceTier{ + {Mode: "fast", ServiceTier: "priority", Prompt: 12.5, Completion: 75, PromptConfigured: true, CompletionConfigured: true}, + }, + }, + } + tokens := ModelTokens{ + ContextThresholdTokens: model.ModelPriceBaseContextThreshold, + InputTokens: 100_000, + OutputTokens: 10_000, + } + for _, tier := range []string{"fast", "priority"} { + if got := CostForModelWithServiceTier("gpt-5.5", tier, tokens, prices); math.Abs(got-2) > 0.000001 { + t.Fatalf("%s base-band cost = %v, want 2", tier, got) + } + } + if got := CostForModelWithServiceTier("gpt-5.5", "default", tokens, prices); math.Abs(got-0.8) > 0.000001 { + t.Fatalf("default base-band cost = %v, want 0.8", got) + } +} + +func TestExplicitFastBaseBandDoesNotReenableLegacyLongContext(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-5.5": { + Prompt: 5, Completion: 30, + ContextTiers: []model.ModelPriceContextTier{ + {ThresholdTokens: 500_000, Prompt: 10, Completion: 45, PromptConfigured: true, CompletionConfigured: true}, + }, + ServiceTiers: []model.ModelPriceServiceTier{ + {Mode: "fast", ServiceTier: "priority", Prompt: 10, Completion: 20, PromptConfigured: true, CompletionConfigured: true}, + }, + }, + } + tokens := ModelTokens{ + ContextThresholdTokens: model.ModelPriceBaseContextThreshold, + InputTokens: 300_000, + OutputTokens: 100_000, + LongInputTokens: 300_000, + LongOutputTokens: 100_000, + } + if got := CostForModelWithServiceTier("gpt-5.5", "priority", tokens, prices); math.Abs(got-5) > 0.000001 { + t.Fatalf("priority base-band cost = %v, want 5", got) + } +} + +func TestLegacyLongContextPriceOverridesExplicitFastPrice(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-5.5": { + Prompt: 5, Completion: 30, + ServiceTiers: []model.ModelPriceServiceTier{ + {Mode: "fast", ServiceTier: "priority", Prompt: 12.5, Completion: 75, PromptConfigured: true, CompletionConfigured: true}, + }, + }, + } + tokens := ModelTokens{ + InputTokens: 300_000, + OutputTokens: 100_000, + LongInputTokens: 300_000, + LongOutputTokens: 100_000, + } + if got := CostForModelWithServiceTier("gpt-5.5", "priority", tokens, prices); math.Abs(got-7.5) > 0.000001 { + t.Fatalf("priority long-context cost = %v, want 7.5", got) + } +} + +func TestLegacyLongContextAppliesServiceTierOnlyToShortSegment(t *testing.T) { + tokens := ModelTokens{ + InputTokens: 400_000, + LongInputTokens: 300_000, + } + tests := []struct { + name string + prices map[string]model.ModelPrice + }{ + { + name: "explicit fast price", + prices: map[string]model.ModelPrice{ + "gpt-5.5": { + Prompt: 5, + ServiceTiers: []model.ModelPriceServiceTier{ + {Mode: "fast", ServiceTier: "priority", Prompt: 12.5, PromptConfigured: true}, + }, + }, + }, + }, + { + name: "compatibility multiplier", + prices: map[string]model.ModelPrice{ + "gpt-5.5": {Prompt: 5}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CostForModelWithServiceTier("gpt-5.5", "priority", tokens, tt.prices); math.Abs(got-4.25) > 0.000001 { + t.Fatalf("mixed priority cost = %v, want 4.25", got) + } + }) + } +} + +func TestLegacyLongContextSplitsEveryTokenBucketForPriorityPricing(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-5.5": { + Prompt: 5, Completion: 10, Cache: 1, CacheRead: 2, CacheCreation: 3, + ServiceTiers: []model.ModelPriceServiceTier{ + { + Mode: "fast", ServiceTier: "priority", + Prompt: 10, Completion: 20, Cache: 2, CacheRead: 4, CacheCreation: 6, + PromptConfigured: true, CompletionConfigured: true, CacheConfigured: true, + CacheReadConfigured: true, CacheCreationConfigured: true, + }, + }, + }, + } + tokens := ModelTokens{ + InputTokens: 400_000, + OutputTokens: 40_000, + CachedTokens: 40_000, + CacheReadTokens: 40_000, + CacheCreationTokens: 40_000, + LongInputTokens: 300_000, + LongOutputTokens: 30_000, + LongCachedTokens: 30_000, + LongCacheReadTokens: 30_000, + LongCacheCreationTokens: 30_000, + } + if got := CostForModelWithServiceTier("gpt-5.5", "priority", tokens, prices); math.Abs(got-3.93) > 0.000001 { + t.Fatalf("mixed priority token-bucket cost = %v, want 3.93", got) + } +} + +func TestLegacyLongContextKeepsFlexAndBatchDiscounts(t *testing.T) { + prices := map[string]model.ModelPrice{"gpt-5.5": {Prompt: 5}} + tokens := ModelTokens{ + InputTokens: 400_000, + LongInputTokens: 300_000, + } + standard := CostForModelWithServiceTier("gpt-5.5", "default", tokens, prices) + for _, tier := range []string{"flex", "batch"} { + if got := CostForModelWithServiceTier("gpt-5.5", tier, tokens, prices); math.Abs(got-standard*0.5) > 0.000001 { + t.Fatalf("%s mixed long-context cost = %v, want %v", tier, got, standard*0.5) + } + } +} + +func TestLegacyLongContextUsesExplicitNonPriorityServiceTierPrice(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-5.5": { + Prompt: 5, + ServiceTiers: []model.ModelPriceServiceTier{ + {Mode: "batch", ServiceTier: "batch", Prompt: 2, PromptConfigured: true}, + }, + }, + } + tokens := ModelTokens{InputTokens: 300_000, LongInputTokens: 300_000} + if got := CostForModelWithServiceTier("gpt-5.5", "batch", tokens, prices); math.Abs(got-1.2) > 0.000001 { + t.Fatalf("explicit batch long-context cost = %v, want 1.2", got) + } +} + +func TestCostCandidatesUseClassifiedPricingModel(t *testing.T) { + prices := map[string]model.ModelPrice{ + "display-model": {Prompt: 1}, + "priced-alias": { + Prompt: 2, + ContextTiers: []model.ModelPriceContextTier{ + {ThresholdTokens: 100, Prompt: 7, PromptConfigured: true}, + }, + }, + } + cost := CostForModelCandidatesWithServiceTier( + []string{"missing-resolved", "display-model"}, + "default", + ModelTokens{ + PricingModel: "priced-alias", + ContextThresholdTokens: 100, + InputTokens: 1_000_000, + }, + prices, + ) + if math.Abs(cost-7) > 0.000001 { + t.Fatalf("classified pricing-model cost = %v, want 7", cost) + } +} + func TestServiceTierMultiplier(t *testing.T) { tests := []struct { name string @@ -342,3 +604,30 @@ func TestFlexUsesHalfPrice(t *testing.T) { t.Fatalf("flex cost = %v, want 2.5", got) } } + +func BenchmarkCostForModelWithExplicitServiceTier(b *testing.B) { + prices := map[string]model.ModelPrice{ + "gpt-5.5": { + Prompt: 5, Completion: 30, Cache: 0.5, + ContextTiers: []model.ModelPriceContextTier{ + {ThresholdTokens: 272_000, Prompt: 10, Completion: 45, PromptConfigured: true, CompletionConfigured: true}, + }, + ServiceTiers: []model.ModelPriceServiceTier{ + {Mode: "fast", ServiceTier: "priority", Prompt: 12.5, Completion: 75, PromptConfigured: true, CompletionConfigured: true}, + }, + }, + } + tokens := ModelTokens{ + ContextThresholdTokens: model.ModelPriceBaseContextThreshold, + InputTokens: 100_000, + OutputTokens: 10_000, + } + b.ReportAllocs() + var cost float64 + for b.Loop() { + cost = CostForModelWithServiceTier("gpt-5.5", "priority", tokens, prices) + } + if cost == 0 { + b.Fatal("cost = 0") + } +} diff --git a/apps/manager-server/internal/usage/event.go b/apps/manager-server/internal/usage/event.go index d8ba68a7c..462f66cb1 100644 --- a/apps/manager-server/internal/usage/event.go +++ b/apps/manager-server/internal/usage/event.go @@ -99,6 +99,14 @@ type LongContextTokens struct { LongCacheCreationTokens int64 } +// PricingBand identifies the exact price rule used to aggregate a request. +// ContextThresholdTokens is zero only for legacy/unclassified aggregates; +// classified base-rate requests use model.ModelPriceBaseContextThreshold. +type PricingBand struct { + PricingModel string + ContextThresholdTokens int64 +} + func (tokens *LongContextTokens) AddIfLongContext(input, output, cached, cacheRead, cacheCreation int64) { if tokens == nil || !IsLongContextInput(input) { return From 3b1338bce5bbda73d4cf8082f6c50b96e9b228d4 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:10:28 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20add?= =?UTF-8?q?=20durable=20usage=20pricing=20rollups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist hourly and account pricing-band aggregates with migration-aware rebuild state. Expose transaction-consistent snapshots and block price mutations during multi-query reads. This keeps analytics fast while preserving a coherent price book during concurrent synchronization. --- .../repository/datamigration/repository.go | 15 + .../datamigration/repository_test.go | 77 + .../repository/usageaggregate/repository.go | 28 +- .../repository/usagepricing/repository.go | 1262 +++++++++++++++++ .../usagepricing/repository_test.go | 162 +++ apps/manager-server/internal/store/store.go | 132 +- .../internal/store/store_compat_test.go | 45 +- .../store/usage_pricing_snapshot_test.go | 250 ++++ 8 files changed, 1957 insertions(+), 14 deletions(-) create mode 100644 apps/manager-server/internal/repository/usagepricing/repository.go create mode 100644 apps/manager-server/internal/repository/usagepricing/repository_test.go create mode 100644 apps/manager-server/internal/store/usage_pricing_snapshot_test.go diff --git a/apps/manager-server/internal/repository/datamigration/repository.go b/apps/manager-server/internal/repository/datamigration/repository.go index 55e8aef1d..f5c58f783 100644 --- a/apps/manager-server/internal/repository/datamigration/repository.go +++ b/apps/manager-server/internal/repository/datamigration/repository.go @@ -502,6 +502,21 @@ func completeInTx(ctx context.Context, tx *sql.Tx, state State) (State, error) { finished_at_ms = null, last_error = null where aggregate_name = 'hourly_core' and schema_version = 1`, + `delete from usage_pricing_hourly_rollups_v1`, + `delete from usage_pricing_account_rollups_v1`, + `update usage_pricing_rollup_state set + status = case when exists (select 1 from usage_events limit 1) then 'pending' else 'ready' end, + backfill_last_event_id = 0, + coverage_event_id = 0, + target_event_id = coalesce((select max(id) from usage_events), 0), + processed_events = 0, + min_bucket_ms = null, + max_bucket_ms = null, + last_run_started_at_ms = null, + updated_at_ms = 0, + finished_at_ms = null, + last_error = null + where rollup_name = 'pricing_v1' and schema_version = 1`, } { if _, err := tx.ExecContext(ctx, statement); err != nil { return State{}, err diff --git a/apps/manager-server/internal/repository/datamigration/repository_test.go b/apps/manager-server/internal/repository/datamigration/repository_test.go index fb7d8454d..562151e21 100644 --- a/apps/manager-server/internal/repository/datamigration/repository_test.go +++ b/apps/manager-server/internal/repository/datamigration/repository_test.go @@ -14,6 +14,7 @@ import ( func TestDiscoverUsageCacheAccountingCompletesEmptyDatabaseWithoutResettingRollups(t *testing.T) { db := openMigrationTestDB(t) insertRollupFixtures(t, db) + insertPricingRollupFixtures(t, db, 9, 9, 9) state, err := New(db).DiscoverUsageCacheAccounting(context.Background()) if err != nil { @@ -24,6 +25,9 @@ func TestDiscoverUsageCacheAccountingCompletesEmptyDatabaseWithoutResettingRollu } assertCount(t, db, "usage_account_model_rollups", 1) assertCount(t, db, "usage_dashboard_hourly_rollups", 1) + assertCount(t, db, "usage_pricing_hourly_rollups_v1", 1) + assertCount(t, db, "usage_pricing_account_rollups_v1", 1) + assertPricingAggregateState(t, db, "backfilling", 9, 9, 9) assertCheckpoint(t, db, "account_history", 9) assertCheckpoint(t, db, "dashboard_hourly", 9) } @@ -36,6 +40,7 @@ func TestUsageCacheAccountingMigratesInBatchesExcludesNewRowsAndInvalidatesAtCom markMigrationDiscovering(t, db) insertRollupFixtures(t, db) insertPermanentAggregateFixture(t, db, "legacy-anthropic") + insertPricingRollupFixtures(t, db, 1, 1, 3) repo := New(db) state, err := repo.DiscoverUsageCacheAccounting(context.Background()) @@ -47,6 +52,9 @@ func TestUsageCacheAccountingMigratesInBatchesExcludesNewRowsAndInvalidatesAtCom } assertCount(t, db, "usage_account_model_rollups", 1) assertCount(t, db, "usage_dashboard_hourly_rollups", 1) + assertCount(t, db, "usage_pricing_hourly_rollups_v1", 1) + assertCount(t, db, "usage_pricing_account_rollups_v1", 1) + assertPricingAggregateState(t, db, "backfilling", 1, 1, 3) if _, err := db.Exec(`insert into usage_events ( event_hash, timestamp_ms, timestamp, provider, model, cache_input_mode, @@ -71,6 +79,9 @@ func TestUsageCacheAccountingMigratesInBatchesExcludesNewRowsAndInvalidatesAtCom assertCount(t, db, "usage_account_model_rollups", 1) assertCount(t, db, "usage_hourly_aggregate_v1", 1) assertPermanentAggregateState(t, db, "backfilling", 1, 1, 3) + assertCount(t, db, "usage_pricing_hourly_rollups_v1", 1) + assertCount(t, db, "usage_pricing_account_rollups_v1", 1) + assertPricingAggregateState(t, db, "backfilling", 1, 1, 3) assertCheckpoint(t, db, "account_history", 9) second, err := repo.RunUsageCacheAccountingBatch(context.Background(), 2) @@ -88,7 +99,10 @@ func TestUsageCacheAccountingMigratesInBatchesExcludesNewRowsAndInvalidatesAtCom assertCount(t, db, "usage_account_model_rollups", 0) assertCount(t, db, "usage_dashboard_hourly_rollups", 0) assertCount(t, db, "usage_hourly_aggregate_v1", 0) + assertCount(t, db, "usage_pricing_hourly_rollups_v1", 0) + assertCount(t, db, "usage_pricing_account_rollups_v1", 0) assertPermanentAggregateState(t, db, "pending", 0, 0, 4) + assertPricingAggregateState(t, db, "pending", 0, 0, 4) assertIdentityAggregateVersion(t, db, "legacy-anthropic", 0) assertCheckpoint(t, db, "account_history", 0) assertCheckpoint(t, db, "dashboard_hourly", 0) @@ -484,6 +498,41 @@ func insertPermanentAggregateFixture(t *testing.T, db *sql.DB, eventHash string) } } +func insertPricingRollupFixtures(t *testing.T, db *sql.DB, checkpoint, coverage, target int64) { + t.Helper() + statements := []struct { + query string + args []any + }{ + { + query: `insert into usage_pricing_hourly_rollups_v1 ( + structure_revision, bucket_ms, model, billing_model, pricing_model, + service_tier, context_threshold_tokens, failed, calls, updated_at_ms + ) values ('fixture', 0, 'model', 'model', 'model', '', -1, 0, 1, 1)`, + }, + { + query: `insert into usage_pricing_account_rollups_v1 ( + structure_revision, account_key, model, billing_model, pricing_model, + service_tier, context_threshold_tokens, calls, first_seen_ms, last_seen_ms, updated_at_ms + ) values ('fixture', 'account', 'model', 'model', 'model', '', -1, 1, 1, 1, 1)`, + }, + { + query: `update usage_pricing_rollup_state set + structure_revision = 'fixture', status = 'backfilling', + backfill_last_event_id = ?, coverage_event_id = ?, target_event_id = ?, + processed_events = 1, min_bucket_ms = 0, max_bucket_ms = 0, + updated_at_ms = 1, finished_at_ms = null + where rollup_name = 'pricing_v1' and schema_version = 1`, + args: []any{checkpoint, coverage, target}, + }, + } + for _, statement := range statements { + if _, err := db.Exec(statement.query, statement.args...); err != nil { + t.Fatalf("insert pricing rollup fixture: %v", err) + } + } +} + func markMigrationDiscovering(t *testing.T, db *sql.DB) { t.Helper() if _, err := db.Exec(`update usage_data_migrations set @@ -545,6 +594,34 @@ func assertPermanentAggregateState(t *testing.T, db *sql.DB, wantStatus string, } } +func assertPricingAggregateState(t *testing.T, db *sql.DB, wantStatus string, wantCheckpoint, wantCoverage, wantTarget int64) { + t.Helper() + var status string + var checkpoint, coverage, target int64 + if err := db.QueryRow(`select status, backfill_last_event_id, coverage_event_id, target_event_id + from usage_pricing_rollup_state where rollup_name = 'pricing_v1'`).Scan( + &status, + &checkpoint, + &coverage, + &target, + ); err != nil { + t.Fatalf("read pricing aggregate state: %v", err) + } + if status != wantStatus || checkpoint != wantCheckpoint || coverage != wantCoverage || target != wantTarget { + t.Fatalf( + "pricing aggregate state = status:%q checkpoint:%d coverage:%d target:%d, want status:%q checkpoint:%d coverage:%d target:%d", + status, + checkpoint, + coverage, + target, + wantStatus, + wantCheckpoint, + wantCoverage, + wantTarget, + ) + } +} + func assertIdentityAggregateVersion(t *testing.T, db *sql.DB, eventHash string, want int) { t.Helper() var got int diff --git a/apps/manager-server/internal/repository/usageaggregate/repository.go b/apps/manager-server/internal/repository/usageaggregate/repository.go index ab4ef6989..9df0107ab 100644 --- a/apps/manager-server/internal/repository/usageaggregate/repository.go +++ b/apps/manager-server/internal/repository/usageaggregate/repository.go @@ -25,6 +25,7 @@ type Repository interface { RecordFailure(ctx context.Context, aggregateErr error, nowMS int64) error State(ctx context.Context) (State, error) LoadRows(ctx context.Context, filter Filter) ([]Row, State, bool, error) + LoadRowsTx(ctx context.Context, tx *sql.Tx, filter Filter) ([]Row, State, bool, error) } type State struct { @@ -250,8 +251,24 @@ func (r *repository) State(ctx context.Context) (State, error) { } func (r *repository) LoadRows(ctx context.Context, filter Filter) ([]Row, State, bool, error) { + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, State{}, false, err + } + defer func() { _ = tx.Rollback() }() + rows, state, available, err := r.LoadRowsTx(ctx, tx, filter) + if err != nil { + return nil, State{}, false, err + } + if err := tx.Commit(); err != nil { + return nil, State{}, false, err + } + return rows, state, available, nil +} + +func (r *repository) LoadRowsTx(ctx context.Context, tx *sql.Tx, filter Filter) ([]Row, State, bool, error) { if filter.FromMS >= filter.ToMS { - state, err := r.State(ctx) + state, err := stateQuery(ctx, tx, AggregateName) return []Row{}, state, err == nil && state.SchemaVersion == SchemaVersion, err } fullStartMS := ceilHourMS(filter.FromMS) @@ -260,12 +277,6 @@ func (r *repository) LoadRows(ctx context.Context, filter Filter) ([]Row, State, return nil, State{}, false, nil } - tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return nil, State{}, false, err - } - defer func() { _ = tx.Rollback() }() - state, err := stateQuery(ctx, tx, AggregateName) if err != nil { return nil, State{}, false, err @@ -292,9 +303,6 @@ func (r *repository) LoadRows(ctx context.Context, filter Filter) ([]Row, State, return nil, State{}, false, err } } - if err := tx.Commit(); err != nil { - return nil, State{}, false, err - } return sortedRows(grouped), state, true, nil } diff --git a/apps/manager-server/internal/repository/usagepricing/repository.go b/apps/manager-server/internal/repository/usagepricing/repository.go new file mode 100644 index 000000000..2666d05ea --- /dev/null +++ b/apps/manager-server/internal/repository/usagepricing/repository.go @@ -0,0 +1,1262 @@ +package usagepricing + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +const ( + RollupName = "pricing_v1" + SchemaVersion = 1 + hourMS = int64(time.Hour / time.Millisecond) +) + +var ErrUnsupportedSchema = errors.New("unsupported usage pricing rollup schema") + +type Repository interface { + CatchUp(ctx context.Context, limit int, nowMS int64) (CatchUpResult, error) + RecordFailure(ctx context.Context, rollupErr error, nowMS int64) error + State(ctx context.Context) (State, error) + LoadHourlyRows(ctx context.Context, filter HourlyFilter) ([]HourlyRow, State, bool, error) + LoadHourlyRowsTx(ctx context.Context, tx *sql.Tx, filter HourlyFilter) ([]HourlyRow, State, bool, error) + LoadAccountRows(ctx context.Context, accountKeys []string) ([]AccountRow, State, bool, error) + LoadAccountRowsTx(ctx context.Context, tx *sql.Tx, accountKeys []string) ([]AccountRow, State, bool, error) +} + +type State struct { + RollupName string + SchemaVersion int + StructureRevision string + Status string + BackfillLastEventID int64 + CoverageEventID int64 + TargetEventID int64 + ProcessedEvents int64 + MinBucketMS sql.NullInt64 + MaxBucketMS sql.NullInt64 + LastRunStartedAtMS sql.NullInt64 + UpdatedAtMS int64 + FinishedAtMS sql.NullInt64 + LastError string +} + +type CatchUpResult struct { + Processed int + LastEventID int64 + CoverageEventID int64 + TargetEventID int64 + Pending bool + Rebuilt bool +} + +type HourlyFilter struct { + FromMS int64 + ToMS int64 + Models []string + IncludeFailed bool + FailedOnly bool + CollapseBuckets bool +} + +type HourlyRow struct { + usage.LongContextTokens + usage.PricingBand + BucketMS int64 + Model string + BillingModel string + ServiceTier string + Failed bool + Calls int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + CacheReadTokens int64 + CacheCreationTokens int64 + TotalTokens int64 + LatencySumMS int64 + LatencySamples int64 + ZeroTokenCalls int64 +} + +type AccountRow struct { + usage.LongContextTokens + usage.PricingBand + AccountKey string + AccountSnapshot string + AuthLabelSnapshot string + AuthProviderSnapshot string + AuthIndex string + Source string + SourceHash string + Model string + BillingModel string + ServiceTier string + Calls int64 + SuccessCalls int64 + FailureCalls int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + CacheReadTokens int64 + CacheCreationTokens int64 + TotalTokens int64 + FirstSeenMS int64 + LastSeenMS int64 + UpdatedAtMS int64 +} + +type hourlyKey struct { + bucketMS int64 + model string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 + failed bool +} + +type accountKey struct { + accountKey string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 +} + +type repository struct { + db *sql.DB + catchUpGate chan struct{} +} + +func New(db *sql.DB) Repository { + return &repository{db: db, catchUpGate: make(chan struct{}, 1)} +} + +func (r *repository) CatchUp(ctx context.Context, limit int, nowMS int64) (CatchUpResult, error) { + if limit <= 0 { + limit = 1000 + } + if nowMS <= 0 { + return CatchUpResult{}, errors.New("nowMS must be greater than 0") + } + if err := r.acquireCatchUp(ctx); err != nil { + return CatchUpResult{}, err + } + defer r.releaseCatchUp() + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return CatchUpResult{}, err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `update usage_pricing_rollup_state set + last_run_started_at_ms = ? + where rollup_name = ?`, nowMS, RollupName); err != nil { + return CatchUpResult{}, err + } + + state, err := stateQuery(ctx, tx) + if err != nil { + return CatchUpResult{}, err + } + if state.SchemaVersion != SchemaVersion { + return CatchUpResult{}, fmt.Errorf("%w: got %d, want %d", ErrUnsupportedSchema, state.SchemaVersion, SchemaVersion) + } + revision, err := structureRevision(ctx, tx) + if err != nil { + return CatchUpResult{}, err + } + latestID, err := latestEventID(ctx, tx) + if err != nil { + return CatchUpResult{}, err + } + rebuilt := false + if state.StructureRevision != revision { + if err := resetForRevision(ctx, tx, revision, latestID, nowMS); err != nil { + return CatchUpResult{}, err + } + state = State{ + RollupName: RollupName, + SchemaVersion: SchemaVersion, + StructureRevision: revision, + Status: "rebuilding", + TargetEventID: latestID, + UpdatedAtMS: nowMS, + } + rebuilt = true + } + + ids, err := eventIDsAfter(ctx, tx, state.BackfillLastEventID, limit) + if err != nil { + return CatchUpResult{}, err + } + if len(ids) == 0 { + if _, err := tx.ExecContext(ctx, `update usage_pricing_rollup_state set + status = 'ready', + target_event_id = max(target_event_id, ?), + last_run_started_at_ms = ?, + updated_at_ms = ?, + finished_at_ms = ?, + last_error = null + where rollup_name = ? and structure_revision = ?`, + latestID, nowMS, nowMS, nowMS, RollupName, revision, + ); err != nil { + return CatchUpResult{}, err + } + if err := tx.Commit(); err != nil { + return CatchUpResult{}, err + } + return CatchUpResult{ + LastEventID: state.BackfillLastEventID, + CoverageEventID: state.CoverageEventID, + TargetEventID: max(state.TargetEventID, latestID), + Rebuilt: rebuilt, + }, nil + } + + lastEventID := ids[len(ids)-1] + if err := upsertHourlyBatch(ctx, tx, revision, state.BackfillLastEventID, lastEventID, nowMS); err != nil { + return CatchUpResult{}, err + } + if err := upsertAccountBatch(ctx, tx, revision, state.BackfillLastEventID, lastEventID, nowMS); err != nil { + return CatchUpResult{}, err + } + minBucket, maxBucket, err := batchBucketRange(ctx, tx, state.BackfillLastEventID, lastEventID) + if err != nil { + return CatchUpResult{}, err + } + pending := latestID > lastEventID + status := "ready" + if pending { + status = "rebuilding" + } + if _, err := tx.ExecContext(ctx, `update usage_pricing_rollup_state set + status = ?, + backfill_last_event_id = ?, + coverage_event_id = ?, + target_event_id = max(target_event_id, ?), + processed_events = processed_events + ?, + min_bucket_ms = case + when ? is null then min_bucket_ms + when min_bucket_ms is null then ? + else min(min_bucket_ms, ?) + end, + max_bucket_ms = case + when ? is null then max_bucket_ms + when max_bucket_ms is null then ? + else max(max_bucket_ms, ?) + end, + last_run_started_at_ms = ?, + updated_at_ms = ?, + finished_at_ms = case when ? then null else ? end, + last_error = null + where rollup_name = ? and structure_revision = ?`, + status, + lastEventID, + lastEventID, + latestID, + len(ids), + nullInt64(minBucket), nullInt64(minBucket), nullInt64(minBucket), + nullInt64(maxBucket), nullInt64(maxBucket), nullInt64(maxBucket), + nowMS, + nowMS, + pending, + nowMS, + RollupName, + revision, + ); err != nil { + return CatchUpResult{}, err + } + if err := tx.Commit(); err != nil { + return CatchUpResult{}, err + } + return CatchUpResult{ + Processed: len(ids), + LastEventID: lastEventID, + CoverageEventID: lastEventID, + TargetEventID: max(state.TargetEventID, latestID), + Pending: pending, + Rebuilt: rebuilt, + }, nil +} + +func (r *repository) RecordFailure(ctx context.Context, rollupErr error, nowMS int64) error { + if rollupErr == nil || nowMS <= 0 { + return nil + } + _, err := r.db.ExecContext(ctx, `update usage_pricing_rollup_state set + status = 'failed', updated_at_ms = ?, finished_at_ms = ?, last_error = ? + where rollup_name = ?`, nowMS, nowMS, rollupErr.Error(), RollupName) + return err +} + +func (r *repository) State(ctx context.Context) (State, error) { + return stateQuery(ctx, r.db) +} + +func resetForRevision(ctx context.Context, tx *sql.Tx, revision string, latestID, nowMS int64) error { + for _, statement := range []string{ + `delete from usage_pricing_hourly_rollups_v1`, + `delete from usage_pricing_account_rollups_v1`, + } { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return err + } + } + _, err := tx.ExecContext(ctx, `update usage_pricing_rollup_state set + structure_revision = ?, + status = 'rebuilding', + backfill_last_event_id = 0, + coverage_event_id = 0, + target_event_id = ?, + processed_events = 0, + min_bucket_ms = null, + max_bucket_ms = null, + last_run_started_at_ms = ?, + updated_at_ms = ?, + finished_at_ms = null, + last_error = null + where rollup_name = ?`, revision, latestID, nowMS, nowMS, RollupName) + return err +} + +type stateQuerier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func stateQuery(ctx context.Context, db stateQuerier) (State, error) { + var state State + var lastError sql.NullString + err := db.QueryRowContext(ctx, `select + rollup_name, schema_version, structure_revision, status, + backfill_last_event_id, coverage_event_id, target_event_id, + processed_events, min_bucket_ms, max_bucket_ms, + last_run_started_at_ms, updated_at_ms, finished_at_ms, last_error + from usage_pricing_rollup_state where rollup_name = ?`, RollupName).Scan( + &state.RollupName, + &state.SchemaVersion, + &state.StructureRevision, + &state.Status, + &state.BackfillLastEventID, + &state.CoverageEventID, + &state.TargetEventID, + &state.ProcessedEvents, + &state.MinBucketMS, + &state.MaxBucketMS, + &state.LastRunStartedAtMS, + &state.UpdatedAtMS, + &state.FinishedAtMS, + &lastError, + ) + if err != nil { + return State{}, err + } + state.LastError = lastError.String + return state, nil +} + +type rowQuerier interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} + +func structureRevision(ctx context.Context, db rowQuerier) (string, error) { + rows, err := db.QueryContext(ctx, `select p.model, t.threshold_tokens + from model_prices p + left join model_price_context_tiers t on t.model = p.model + order by p.model, t.threshold_tokens`) + if err != nil { + return "", err + } + defer rows.Close() + prices := map[string]model.ModelPrice{} + for rows.Next() { + var modelID string + var threshold sql.NullInt64 + if err := rows.Scan(&modelID, &threshold); err != nil { + return "", err + } + price := prices[modelID] + if threshold.Valid { + price.ContextTiers = append(price.ContextTiers, model.ModelPriceContextTier{ThresholdTokens: threshold.Int64}) + } + prices[modelID] = price + } + if err := rows.Err(); err != nil { + return "", err + } + return model.ModelPriceStructureRevision(prices), nil +} + +func latestEventID(ctx context.Context, tx *sql.Tx) (int64, error) { + var id int64 + if err := tx.QueryRowContext(ctx, `select coalesce(max(id), 0) from usage_events`).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +func eventIDsAfter(ctx context.Context, tx *sql.Tx, lastEventID int64, limit int) ([]int64, error) { + rows, err := tx.QueryContext(ctx, `select id from usage_events where id > ? order by id limit ?`, lastEventID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + ids := make([]int64, 0, limit) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func batchBucketRange(ctx context.Context, tx *sql.Tx, afterID, throughID int64) (sql.NullInt64, sql.NullInt64, error) { + var minBucket, maxBucket sql.NullInt64 + err := tx.QueryRowContext(ctx, fmt.Sprintf(`select + min(timestamp_ms - (timestamp_ms %% %d)), + max(timestamp_ms - (timestamp_ms %% %d)) + from usage_events where id > ? and id <= ?`, hourMS, hourMS), afterID, throughID).Scan(&minBucket, &maxBucket) + return minBucket, maxBucket, err +} + +func bandedEventsCTE(whereClause string) string { + return fmt.Sprintf(`with base_events as ( + select + e.*, + coalesce(nullif(e.resolved_model, ''), e.model) as billing_model_value, + coalesce(e.normalized_total_input_tokens, e.input_tokens, 0) as normalized_input_tokens_value, + max( + max(coalesce(e.cached_tokens, 0), coalesce(e.cache_tokens, 0)) - + max(coalesce(e.cache_read_tokens, 0), 0) - + max(coalesce(e.cache_creation_tokens, 0), 0), + 0 + ) as compatible_cached_tokens_value, + case + when trim(coalesce(e.account_snapshot, '')) != '' then e.account_snapshot + when trim(coalesce(e.auth_label_snapshot, '')) != '' then e.auth_label_snapshot + when trim(coalesce(e.source, '')) != '' then e.source + when trim(coalesce(e.auth_index, '')) != '' then e.auth_index + else '-' + end as account_key_value + from usage_events e + where %s + ), priced_events as ( + select + base_events.*, + case + when billing_price.model is not null then billing_model_value + when display_price.model is not null then base_events.model + else billing_model_value + end as pricing_model_value + from base_events + left join model_prices billing_price on billing_price.model = base_events.billing_model_value + left join model_prices display_price on display_price.model = base_events.model + ), banded_events as ( + select + priced_events.*, + coalesce(( + select max(tier.threshold_tokens) + from model_price_context_tiers tier + where tier.model = priced_events.pricing_model_value + and priced_events.normalized_input_tokens_value > tier.threshold_tokens + ), %d) as context_threshold_tokens_value + from priced_events + )`, whereClause, model.ModelPriceBaseContextThreshold) +} + +func upsertHourlyBatch(ctx context.Context, tx *sql.Tx, revision string, afterID, throughID, nowMS int64) error { + query := bandedEventsCTE("e.id > ? and e.id <= ?") + fmt.Sprintf(` + insert into usage_pricing_hourly_rollups_v1 ( + structure_revision, bucket_ms, model, billing_model, pricing_model, + service_tier, context_threshold_tokens, failed, calls, + input_tokens, output_tokens, reasoning_tokens, cached_tokens, + cache_read_tokens, cache_creation_tokens, + long_input_tokens, long_output_tokens, long_cached_tokens, + long_cache_read_tokens, long_cache_creation_tokens, + total_tokens, latency_sum_ms, latency_samples, zero_token_calls, updated_at_ms + ) + select + ?, + timestamp_ms - (timestamp_ms %% %d), + model, + billing_model_value, + pricing_model_value, + coalesce(service_tier, ''), + context_threshold_tokens_value, + failed, + count(*), + coalesce(sum(normalized_input_tokens_value), 0), + coalesce(sum(output_tokens), 0), + coalesce(sum(reasoning_tokens), 0), + coalesce(sum(compatible_cached_tokens_value), 0), + coalesce(sum(cache_read_tokens), 0), + coalesce(sum(cache_creation_tokens), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then normalized_input_tokens_value else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then output_tokens else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then compatible_cached_tokens_value else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then cache_read_tokens else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then cache_creation_tokens else 0 end), 0), + coalesce(sum(total_tokens), 0), + coalesce(sum(case when latency_ms is not null and latency_ms != 0 then latency_ms else 0 end), 0), + count(nullif(latency_ms, 0)), + coalesce(sum(case when total_tokens = 0 and failed = 0 then 1 else 0 end), 0), + ? + from banded_events + group by 2, 3, 4, 5, 6, 7, 8 + on conflict( + structure_revision, bucket_ms, model, billing_model, pricing_model, + service_tier, context_threshold_tokens, failed + ) do update set + calls = usage_pricing_hourly_rollups_v1.calls + excluded.calls, + input_tokens = usage_pricing_hourly_rollups_v1.input_tokens + excluded.input_tokens, + output_tokens = usage_pricing_hourly_rollups_v1.output_tokens + excluded.output_tokens, + reasoning_tokens = usage_pricing_hourly_rollups_v1.reasoning_tokens + excluded.reasoning_tokens, + cached_tokens = usage_pricing_hourly_rollups_v1.cached_tokens + excluded.cached_tokens, + cache_read_tokens = usage_pricing_hourly_rollups_v1.cache_read_tokens + excluded.cache_read_tokens, + cache_creation_tokens = usage_pricing_hourly_rollups_v1.cache_creation_tokens + excluded.cache_creation_tokens, + long_input_tokens = usage_pricing_hourly_rollups_v1.long_input_tokens + excluded.long_input_tokens, + long_output_tokens = usage_pricing_hourly_rollups_v1.long_output_tokens + excluded.long_output_tokens, + long_cached_tokens = usage_pricing_hourly_rollups_v1.long_cached_tokens + excluded.long_cached_tokens, + long_cache_read_tokens = usage_pricing_hourly_rollups_v1.long_cache_read_tokens + excluded.long_cache_read_tokens, + long_cache_creation_tokens = usage_pricing_hourly_rollups_v1.long_cache_creation_tokens + excluded.long_cache_creation_tokens, + total_tokens = usage_pricing_hourly_rollups_v1.total_tokens + excluded.total_tokens, + latency_sum_ms = usage_pricing_hourly_rollups_v1.latency_sum_ms + excluded.latency_sum_ms, + latency_samples = usage_pricing_hourly_rollups_v1.latency_samples + excluded.latency_samples, + zero_token_calls = usage_pricing_hourly_rollups_v1.zero_token_calls + excluded.zero_token_calls, + updated_at_ms = excluded.updated_at_ms`, + hourMS, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + ) + _, err := tx.ExecContext(ctx, query, afterID, throughID, revision, nowMS) + return err +} + +func upsertAccountBatch(ctx context.Context, tx *sql.Tx, revision string, afterID, throughID, nowMS int64) error { + query := bandedEventsCTE("e.id > ? and e.id <= ?") + fmt.Sprintf(` + insert into usage_pricing_account_rollups_v1 ( + structure_revision, account_key, account_snapshot, auth_label_snapshot, + auth_provider_snapshot, auth_index, source, source_hash, model, + billing_model, pricing_model, service_tier, context_threshold_tokens, + calls, success_calls, failure_calls, input_tokens, output_tokens, + reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, + long_input_tokens, long_output_tokens, long_cached_tokens, + long_cache_read_tokens, long_cache_creation_tokens, total_tokens, + first_seen_ms, last_seen_ms, updated_at_ms + ) + select + ?, + account_key_value, + max(nullif(account_snapshot, '')), + max(nullif(auth_label_snapshot, '')), + max(nullif(coalesce(nullif(auth_provider_snapshot, ''), provider, ''), '')), + max(nullif(auth_index, '')), + max(nullif(source, '')), + max(nullif(source_hash, '')), + min(model), + billing_model_value, + pricing_model_value, + coalesce(service_tier, ''), + context_threshold_tokens_value, + count(*), + coalesce(sum(case when failed = 0 then 1 else 0 end), 0), + coalesce(sum(case when failed = 1 then 1 else 0 end), 0), + coalesce(sum(normalized_input_tokens_value), 0), + coalesce(sum(output_tokens), 0), + coalesce(sum(reasoning_tokens), 0), + coalesce(sum(compatible_cached_tokens_value), 0), + coalesce(sum(cache_read_tokens), 0), + coalesce(sum(cache_creation_tokens), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then normalized_input_tokens_value else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then output_tokens else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then compatible_cached_tokens_value else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then cache_read_tokens else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then cache_creation_tokens else 0 end), 0), + coalesce(sum(total_tokens), 0), + min(timestamp_ms), + max(timestamp_ms), + ? + from banded_events + group by account_key_value, billing_model_value, pricing_model_value, + coalesce(service_tier, ''), context_threshold_tokens_value + on conflict( + structure_revision, account_key, billing_model, pricing_model, + service_tier, context_threshold_tokens + ) do update set + account_snapshot = coalesce(nullif(excluded.account_snapshot, ''), usage_pricing_account_rollups_v1.account_snapshot), + auth_label_snapshot = coalesce(nullif(excluded.auth_label_snapshot, ''), usage_pricing_account_rollups_v1.auth_label_snapshot), + auth_provider_snapshot = coalesce(nullif(excluded.auth_provider_snapshot, ''), usage_pricing_account_rollups_v1.auth_provider_snapshot), + auth_index = coalesce(nullif(excluded.auth_index, ''), usage_pricing_account_rollups_v1.auth_index), + source = coalesce(nullif(excluded.source, ''), usage_pricing_account_rollups_v1.source), + source_hash = coalesce(nullif(excluded.source_hash, ''), usage_pricing_account_rollups_v1.source_hash), + model = coalesce(nullif(excluded.model, ''), usage_pricing_account_rollups_v1.model), + calls = usage_pricing_account_rollups_v1.calls + excluded.calls, + success_calls = usage_pricing_account_rollups_v1.success_calls + excluded.success_calls, + failure_calls = usage_pricing_account_rollups_v1.failure_calls + excluded.failure_calls, + input_tokens = usage_pricing_account_rollups_v1.input_tokens + excluded.input_tokens, + output_tokens = usage_pricing_account_rollups_v1.output_tokens + excluded.output_tokens, + reasoning_tokens = usage_pricing_account_rollups_v1.reasoning_tokens + excluded.reasoning_tokens, + cached_tokens = usage_pricing_account_rollups_v1.cached_tokens + excluded.cached_tokens, + cache_read_tokens = usage_pricing_account_rollups_v1.cache_read_tokens + excluded.cache_read_tokens, + cache_creation_tokens = usage_pricing_account_rollups_v1.cache_creation_tokens + excluded.cache_creation_tokens, + long_input_tokens = usage_pricing_account_rollups_v1.long_input_tokens + excluded.long_input_tokens, + long_output_tokens = usage_pricing_account_rollups_v1.long_output_tokens + excluded.long_output_tokens, + long_cached_tokens = usage_pricing_account_rollups_v1.long_cached_tokens + excluded.long_cached_tokens, + long_cache_read_tokens = usage_pricing_account_rollups_v1.long_cache_read_tokens + excluded.long_cache_read_tokens, + long_cache_creation_tokens = usage_pricing_account_rollups_v1.long_cache_creation_tokens + excluded.long_cache_creation_tokens, + total_tokens = usage_pricing_account_rollups_v1.total_tokens + excluded.total_tokens, + first_seen_ms = min(usage_pricing_account_rollups_v1.first_seen_ms, excluded.first_seen_ms), + last_seen_ms = max(usage_pricing_account_rollups_v1.last_seen_ms, excluded.last_seen_ms), + updated_at_ms = excluded.updated_at_ms`, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + ) + _, err := tx.ExecContext(ctx, query, afterID, throughID, revision, nowMS) + return err +} + +func (r *repository) LoadHourlyRows(ctx context.Context, filter HourlyFilter) ([]HourlyRow, State, bool, error) { + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, State{}, false, err + } + defer func() { _ = tx.Rollback() }() + rows, state, available, err := r.LoadHourlyRowsTx(ctx, tx, filter) + if err != nil { + return nil, State{}, false, err + } + if err := tx.Commit(); err != nil { + return nil, State{}, false, err + } + return rows, state, available, nil +} + +func (r *repository) LoadHourlyRowsTx(ctx context.Context, tx *sql.Tx, filter HourlyFilter) ([]HourlyRow, State, bool, error) { + if filter.FromMS >= filter.ToMS { + state, err := stateQuery(ctx, tx) + return []HourlyRow{}, state, err == nil && state.SchemaVersion == SchemaVersion, err + } + state, err := stateQuery(ctx, tx) + if err != nil { + return nil, State{}, false, err + } + if state.SchemaVersion != SchemaVersion { + return nil, state, false, nil + } + revision, err := structureRevision(ctx, tx) + if err != nil { + return nil, State{}, false, err + } + if state.StructureRevision != revision { + grouped := map[hourlyKey]*HourlyRow{} + if err := mergeRawHourlyRows(ctx, tx, filter, filter.FromMS, filter.ToMS, 0, false, grouped); err != nil { + return nil, State{}, false, err + } + return sortedHourlyRows(grouped), state, true, nil + } + + grouped := map[hourlyKey]*HourlyRow{} + fullStartMS := ceilHourMS(filter.FromMS) + fullEndMS := floorHourMS(filter.ToMS) + if fullStartMS < fullEndMS { + if err := mergeStoredHourlyRows(ctx, tx, revision, filter, fullStartMS, fullEndMS, grouped); err != nil { + return nil, State{}, false, err + } + if err := mergeRawHourlyRows(ctx, tx, filter, fullStartMS, fullEndMS, state.CoverageEventID, true, grouped); err != nil { + return nil, State{}, false, err + } + } + if filter.FromMS < fullStartMS { + if err := mergeRawHourlyRows(ctx, tx, filter, filter.FromMS, min(fullStartMS, filter.ToMS), 0, false, grouped); err != nil { + return nil, State{}, false, err + } + } + if fullEndMS < filter.ToMS { + if err := mergeRawHourlyRows(ctx, tx, filter, max(fullEndMS, filter.FromMS), filter.ToMS, 0, false, grouped); err != nil { + return nil, State{}, false, err + } + } + return sortedHourlyRows(grouped), state, true, nil +} + +func mergeStoredHourlyRows( + ctx context.Context, + tx *sql.Tx, + revision string, + filter HourlyFilter, + fromMS int64, + toMS int64, + grouped map[hourlyKey]*HourlyRow, +) error { + conditions, args := storedHourlyConditions(revision, filter, fromMS, toMS) + bucketExpr := "bucket_ms" + if filter.CollapseBuckets { + bucketExpr = "0" + } + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`select + %s, + model, billing_model, pricing_model, service_tier, context_threshold_tokens, failed, + sum(calls), sum(input_tokens), sum(output_tokens), sum(reasoning_tokens), + sum(cached_tokens), sum(cache_read_tokens), sum(cache_creation_tokens), + sum(long_input_tokens), sum(long_output_tokens), sum(long_cached_tokens), + sum(long_cache_read_tokens), sum(long_cache_creation_tokens), + sum(total_tokens), sum(latency_sum_ms), sum(latency_samples), sum(zero_token_calls) + from usage_pricing_hourly_rollups_v1 + where %s + group by 1, 2, 3, 4, 5, 6, 7 + order by 1, 2, 3, 4, 5, 6, 7`, bucketExpr, strings.Join(conditions, " and ")), args...) + if err != nil { + return err + } + defer rows.Close() + return scanAndMergeHourlyRows(rows, grouped) +} + +func mergeRawHourlyRows( + ctx context.Context, + tx *sql.Tx, + filter HourlyFilter, + fromMS int64, + toMS int64, + afterID int64, + useAfterID bool, + grouped map[hourlyKey]*HourlyRow, +) error { + query, args := rawHourlyStatement(filter, fromMS, toMS, afterID, useAfterID) + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + return scanAndMergeHourlyRows(rows, grouped) +} + +func rawHourlyStatement(filter HourlyFilter, fromMS, toMS, afterID int64, useAfterID bool) (string, []any) { + conditions := []string{"e.timestamp_ms >= ?", "e.timestamp_ms < ?"} + args := []any{fromMS, toMS} + if useAfterID { + conditions = append(conditions, "e.id > ?") + args = append(args, afterID) + } + models := normalizeValues(filter.Models) + if len(models) > 0 { + placeholders := strings.TrimRight(strings.Repeat("?,", len(models)), ",") + conditions = append(conditions, "e.model in ("+placeholders+")") + for _, modelID := range models { + args = append(args, modelID) + } + } + if !filter.IncludeFailed { + conditions = append(conditions, "e.failed = 0") + } + if filter.FailedOnly { + conditions = append(conditions, "e.failed = 1") + } + bucketExpr := fmt.Sprintf("timestamp_ms - (timestamp_ms %% %d)", hourMS) + if filter.CollapseBuckets { + bucketExpr = "0" + } + query := bandedEventsCTE(strings.Join(conditions, " and ")) + fmt.Sprintf(` + select + %s, + model, billing_model_value, pricing_model_value, coalesce(service_tier, ''), + context_threshold_tokens_value, failed, count(*), + coalesce(sum(normalized_input_tokens_value), 0), + coalesce(sum(output_tokens), 0), + coalesce(sum(reasoning_tokens), 0), + coalesce(sum(compatible_cached_tokens_value), 0), + coalesce(sum(cache_read_tokens), 0), + coalesce(sum(cache_creation_tokens), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then normalized_input_tokens_value else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then output_tokens else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then compatible_cached_tokens_value else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then cache_read_tokens else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then cache_creation_tokens else 0 end), 0), + coalesce(sum(total_tokens), 0), + coalesce(sum(case when latency_ms is not null and latency_ms != 0 then latency_ms else 0 end), 0), + count(nullif(latency_ms, 0)), + coalesce(sum(case when total_tokens = 0 and failed = 0 then 1 else 0 end), 0) + from banded_events + group by 1, 2, 3, 4, 5, 6, 7 + order by 1, 2, 3, 4, 5, 6, 7`, + bucketExpr, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + ) + return query, args +} + +func scanAndMergeHourlyRows(rows *sql.Rows, grouped map[hourlyKey]*HourlyRow) error { + for rows.Next() { + var row HourlyRow + var failed int + if err := rows.Scan( + &row.BucketMS, + &row.Model, + &row.BillingModel, + &row.PricingModel, + &row.ServiceTier, + &row.ContextThresholdTokens, + &failed, + &row.Calls, + &row.InputTokens, + &row.OutputTokens, + &row.ReasoningTokens, + &row.CachedTokens, + &row.CacheReadTokens, + &row.CacheCreationTokens, + &row.LongInputTokens, + &row.LongOutputTokens, + &row.LongCachedTokens, + &row.LongCacheReadTokens, + &row.LongCacheCreationTokens, + &row.TotalTokens, + &row.LatencySumMS, + &row.LatencySamples, + &row.ZeroTokenCalls, + ); err != nil { + return err + } + row.Failed = failed != 0 + mergeHourlyRow(grouped, row) + } + return rows.Err() +} + +func mergeHourlyRow(grouped map[hourlyKey]*HourlyRow, row HourlyRow) { + key := hourlyKey{ + bucketMS: row.BucketMS, + model: row.Model, + billingModel: row.BillingModel, + pricingModel: row.PricingModel, + serviceTier: row.ServiceTier, + contextThresholdTokens: row.ContextThresholdTokens, + failed: row.Failed, + } + entry := grouped[key] + if entry == nil { + copy := row + grouped[key] = © + return + } + entry.Calls += row.Calls + entry.InputTokens += row.InputTokens + entry.OutputTokens += row.OutputTokens + entry.ReasoningTokens += row.ReasoningTokens + entry.CachedTokens += row.CachedTokens + entry.CacheReadTokens += row.CacheReadTokens + entry.CacheCreationTokens += row.CacheCreationTokens + entry.LongInputTokens += row.LongInputTokens + entry.LongOutputTokens += row.LongOutputTokens + entry.LongCachedTokens += row.LongCachedTokens + entry.LongCacheReadTokens += row.LongCacheReadTokens + entry.LongCacheCreationTokens += row.LongCacheCreationTokens + entry.TotalTokens += row.TotalTokens + entry.LatencySumMS += row.LatencySumMS + entry.LatencySamples += row.LatencySamples + entry.ZeroTokenCalls += row.ZeroTokenCalls +} + +func sortedHourlyRows(grouped map[hourlyKey]*HourlyRow) []HourlyRow { + result := make([]HourlyRow, 0, len(grouped)) + for _, row := range grouped { + result = append(result, *row) + } + sort.Slice(result, func(i, j int) bool { + left, right := result[i], result[j] + if left.BucketMS != right.BucketMS { + return left.BucketMS < right.BucketMS + } + if left.Model != right.Model { + return left.Model < right.Model + } + if left.BillingModel != right.BillingModel { + return left.BillingModel < right.BillingModel + } + if left.PricingModel != right.PricingModel { + return left.PricingModel < right.PricingModel + } + if left.ServiceTier != right.ServiceTier { + return left.ServiceTier < right.ServiceTier + } + if left.ContextThresholdTokens != right.ContextThresholdTokens { + return left.ContextThresholdTokens < right.ContextThresholdTokens + } + return !left.Failed && right.Failed + }) + return result +} + +func (r *repository) LoadAccountRows(ctx context.Context, accountKeys []string) ([]AccountRow, State, bool, error) { + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, State{}, false, err + } + defer func() { _ = tx.Rollback() }() + rows, state, available, err := r.LoadAccountRowsTx(ctx, tx, accountKeys) + if err != nil { + return nil, State{}, false, err + } + if err := tx.Commit(); err != nil { + return nil, State{}, false, err + } + return rows, state, available, nil +} + +func (r *repository) LoadAccountRowsTx(ctx context.Context, tx *sql.Tx, accountKeys []string) ([]AccountRow, State, bool, error) { + keys := normalizeValues(accountKeys) + if len(keys) == 0 { + state, err := stateQuery(ctx, tx) + return []AccountRow{}, state, err == nil && state.SchemaVersion == SchemaVersion, err + } + state, err := stateQuery(ctx, tx) + if err != nil { + return nil, State{}, false, err + } + if state.SchemaVersion != SchemaVersion { + return nil, state, false, nil + } + revision, err := structureRevision(ctx, tx) + if err != nil { + return nil, State{}, false, err + } + if state.StructureRevision != revision { + grouped := map[accountKey]*AccountRow{} + if err := mergeRawAccountRows(ctx, tx, 0, keys, grouped); err != nil { + return nil, State{}, false, err + } + return sortedAccountRows(grouped), state, true, nil + } + + grouped := map[accountKey]*AccountRow{} + if err := mergeStoredAccountRows(ctx, tx, revision, keys, grouped); err != nil { + return nil, State{}, false, err + } + if err := mergeRawAccountRows(ctx, tx, state.CoverageEventID, keys, grouped); err != nil { + return nil, State{}, false, err + } + return sortedAccountRows(grouped), state, true, nil +} + +func mergeStoredAccountRows( + ctx context.Context, + tx *sql.Tx, + revision string, + accountKeys []string, + grouped map[accountKey]*AccountRow, +) error { + placeholders := strings.TrimRight(strings.Repeat("?,", len(accountKeys)), ",") + args := make([]any, 0, len(accountKeys)+1) + args = append(args, revision) + for _, key := range accountKeys { + args = append(args, key) + } + rows, err := tx.QueryContext(ctx, `select + account_key, + coalesce(account_snapshot, ''), + coalesce(auth_label_snapshot, ''), + coalesce(auth_provider_snapshot, ''), + coalesce(auth_index, ''), + coalesce(source, ''), + coalesce(source_hash, ''), + model, billing_model, pricing_model, service_tier, context_threshold_tokens, + calls, success_calls, failure_calls, + input_tokens, output_tokens, reasoning_tokens, cached_tokens, + cache_read_tokens, cache_creation_tokens, + long_input_tokens, long_output_tokens, long_cached_tokens, + long_cache_read_tokens, long_cache_creation_tokens, + total_tokens, first_seen_ms, last_seen_ms, updated_at_ms + from usage_pricing_account_rollups_v1 + where structure_revision = ? and account_key in (`+placeholders+`) + order by account_key, last_seen_ms desc`, args...) + if err != nil { + return err + } + defer rows.Close() + return scanAndMergeAccountRows(rows, grouped) +} + +func mergeRawAccountRows( + ctx context.Context, + tx *sql.Tx, + afterID int64, + accountKeys []string, + grouped map[accountKey]*AccountRow, +) error { + placeholders := strings.TrimRight(strings.Repeat("?,", len(accountKeys)), ",") + query := bandedEventsCTE("e.id > ?") + fmt.Sprintf(` + select + account_key_value, + coalesce(max(nullif(account_snapshot, '')), ''), + coalesce(max(nullif(auth_label_snapshot, '')), ''), + coalesce(max(nullif(coalesce(nullif(auth_provider_snapshot, ''), provider, ''), '')), ''), + coalesce(max(nullif(auth_index, '')), ''), + coalesce(max(nullif(source, '')), ''), + coalesce(max(nullif(source_hash, '')), ''), + min(model), billing_model_value, pricing_model_value, coalesce(service_tier, ''), + context_threshold_tokens_value, + count(*), + coalesce(sum(case when failed = 0 then 1 else 0 end), 0), + coalesce(sum(case when failed = 1 then 1 else 0 end), 0), + coalesce(sum(normalized_input_tokens_value), 0), + coalesce(sum(output_tokens), 0), + coalesce(sum(reasoning_tokens), 0), + coalesce(sum(compatible_cached_tokens_value), 0), + coalesce(sum(cache_read_tokens), 0), + coalesce(sum(cache_creation_tokens), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then normalized_input_tokens_value else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then output_tokens else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then compatible_cached_tokens_value else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then cache_read_tokens else 0 end), 0), + coalesce(sum(case when normalized_input_tokens_value > %d then cache_creation_tokens else 0 end), 0), + coalesce(sum(total_tokens), 0), min(timestamp_ms), max(timestamp_ms), 0 + from banded_events + where account_key_value in (%s) + group by account_key_value, billing_model_value, pricing_model_value, + coalesce(service_tier, ''), context_threshold_tokens_value + order by account_key_value, max(timestamp_ms) desc`, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + usage.LongContextInputTokenThreshold, + placeholders, + ) + args := make([]any, 0, len(accountKeys)+1) + args = append(args, afterID) + for _, key := range accountKeys { + args = append(args, key) + } + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + return scanAndMergeAccountRows(rows, grouped) +} + +func scanAndMergeAccountRows(rows *sql.Rows, grouped map[accountKey]*AccountRow) error { + for rows.Next() { + var row AccountRow + if err := rows.Scan( + &row.AccountKey, + &row.AccountSnapshot, + &row.AuthLabelSnapshot, + &row.AuthProviderSnapshot, + &row.AuthIndex, + &row.Source, + &row.SourceHash, + &row.Model, + &row.BillingModel, + &row.PricingModel, + &row.ServiceTier, + &row.ContextThresholdTokens, + &row.Calls, + &row.SuccessCalls, + &row.FailureCalls, + &row.InputTokens, + &row.OutputTokens, + &row.ReasoningTokens, + &row.CachedTokens, + &row.CacheReadTokens, + &row.CacheCreationTokens, + &row.LongInputTokens, + &row.LongOutputTokens, + &row.LongCachedTokens, + &row.LongCacheReadTokens, + &row.LongCacheCreationTokens, + &row.TotalTokens, + &row.FirstSeenMS, + &row.LastSeenMS, + &row.UpdatedAtMS, + ); err != nil { + return err + } + mergeAccountRow(grouped, row) + } + return rows.Err() +} + +func mergeAccountRow(grouped map[accountKey]*AccountRow, row AccountRow) { + key := accountKey{ + accountKey: row.AccountKey, + billingModel: row.BillingModel, + pricingModel: row.PricingModel, + serviceTier: row.ServiceTier, + contextThresholdTokens: row.ContextThresholdTokens, + } + entry := grouped[key] + if entry == nil { + copy := row + grouped[key] = © + return + } + fillAccountSnapshots(entry, row) + entry.Calls += row.Calls + entry.SuccessCalls += row.SuccessCalls + entry.FailureCalls += row.FailureCalls + entry.InputTokens += row.InputTokens + entry.OutputTokens += row.OutputTokens + entry.ReasoningTokens += row.ReasoningTokens + entry.CachedTokens += row.CachedTokens + entry.CacheReadTokens += row.CacheReadTokens + entry.CacheCreationTokens += row.CacheCreationTokens + entry.LongInputTokens += row.LongInputTokens + entry.LongOutputTokens += row.LongOutputTokens + entry.LongCachedTokens += row.LongCachedTokens + entry.LongCacheReadTokens += row.LongCacheReadTokens + entry.LongCacheCreationTokens += row.LongCacheCreationTokens + entry.TotalTokens += row.TotalTokens + if entry.FirstSeenMS == 0 || (row.FirstSeenMS > 0 && row.FirstSeenMS < entry.FirstSeenMS) { + entry.FirstSeenMS = row.FirstSeenMS + } + if row.LastSeenMS > entry.LastSeenMS { + entry.LastSeenMS = row.LastSeenMS + } + if row.UpdatedAtMS > entry.UpdatedAtMS { + entry.UpdatedAtMS = row.UpdatedAtMS + } +} + +func fillAccountSnapshots(target *AccountRow, source AccountRow) { + if target.AccountSnapshot == "" { + target.AccountSnapshot = source.AccountSnapshot + } + if target.AuthLabelSnapshot == "" { + target.AuthLabelSnapshot = source.AuthLabelSnapshot + } + if target.AuthProviderSnapshot == "" { + target.AuthProviderSnapshot = source.AuthProviderSnapshot + } + if target.AuthIndex == "" { + target.AuthIndex = source.AuthIndex + } + if target.Source == "" { + target.Source = source.Source + } + if target.SourceHash == "" { + target.SourceHash = source.SourceHash + } + if target.Model == "" { + target.Model = source.Model + } +} + +func sortedAccountRows(grouped map[accountKey]*AccountRow) []AccountRow { + result := make([]AccountRow, 0, len(grouped)) + for _, row := range grouped { + result = append(result, *row) + } + sort.Slice(result, func(i, j int) bool { + left, right := result[i], result[j] + if left.AccountKey != right.AccountKey { + return left.AccountKey < right.AccountKey + } + if left.LastSeenMS != right.LastSeenMS { + return left.LastSeenMS > right.LastSeenMS + } + if left.BillingModel != right.BillingModel { + return left.BillingModel < right.BillingModel + } + if left.PricingModel != right.PricingModel { + return left.PricingModel < right.PricingModel + } + if left.ServiceTier != right.ServiceTier { + return left.ServiceTier < right.ServiceTier + } + return left.ContextThresholdTokens < right.ContextThresholdTokens + }) + return result +} + +func storedHourlyConditions(revision string, filter HourlyFilter, fromMS, toMS int64) ([]string, []any) { + conditions := []string{"structure_revision = ?", "bucket_ms >= ?", "bucket_ms < ?"} + args := []any{revision, fromMS, toMS} + models := normalizeValues(filter.Models) + if len(models) > 0 { + placeholders := strings.TrimRight(strings.Repeat("?,", len(models)), ",") + conditions = append(conditions, "model in ("+placeholders+")") + for _, modelID := range models { + args = append(args, modelID) + } + } + if !filter.IncludeFailed { + conditions = append(conditions, "failed = 0") + } + if filter.FailedOnly { + conditions = append(conditions, "failed = 1") + } + return conditions, args +} + +func normalizeValues(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func ceilHourMS(value int64) int64 { + if value%hourMS == 0 { + return value + } + return value - value%hourMS + hourMS +} + +func floorHourMS(value int64) int64 { + return value - value%hourMS +} + +func nullInt64(value sql.NullInt64) any { + if !value.Valid { + return nil + } + return value.Int64 +} + +func (r *repository) acquireCatchUp(ctx context.Context) error { + select { + case r.catchUpGate <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *repository) releaseCatchUp() { + select { + case <-r.catchUpGate: + default: + } +} diff --git a/apps/manager-server/internal/repository/usagepricing/repository_test.go b/apps/manager-server/internal/repository/usagepricing/repository_test.go new file mode 100644 index 000000000..7b3959136 --- /dev/null +++ b/apps/manager-server/internal/repository/usagepricing/repository_test.go @@ -0,0 +1,162 @@ +package usagepricing_test + +import ( + "context" + "testing" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/testutil" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +func TestPricingRollupBandsStrictThresholdsAndMergesRawDelta(t *testing.T) { + ctx := context.Background() + cfg := testutil.NewConfig(t) + st := testutil.NewStore(t, cfg) + if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "resolved-model": { + Prompt: 1, + ContextTiers: []store.ModelPriceContextTier{ + {ThresholdTokens: 100, Prompt: 2, PromptConfigured: true}, + {ThresholdTokens: 200, Prompt: 3, PromptConfigured: true}, + }, + }, + }); err != nil { + t.Fatalf("save prices: %v", err) + } + events := []usage.Event{ + pricingEvent("base", 3_600_001, 100), + pricingEvent("tier-one", 3_600_002, 101), + pricingEvent("tier-two", 3_600_003, 201), + } + if _, err := st.UsageEvents.InsertBatch(ctx, events); err != nil { + t.Fatalf("insert events: %v", err) + } + + result, err := st.CatchUpUsagePricing(ctx, 2, 10_000) + if err != nil { + t.Fatalf("catch up pricing: %v", err) + } + if result.Processed != 2 || !result.Pending || !result.Rebuilt { + t.Fatalf("catch-up result = %#v", result) + } + rows, state, available, err := st.UsagePricingHourlyRows(ctx, store.UsagePricingHourlyFilter{ + FromMS: 3_600_000, + ToMS: 7_200_000, + IncludeFailed: true, + }) + if err != nil { + t.Fatalf("load pricing rows: %v", err) + } + if !available || state.StructureRevision == "" || len(rows) != 3 { + t.Fatalf("pricing rows available=%v state=%#v rows=%#v", available, state, rows) + } + byThreshold := map[int64]store.UsagePricingHourlyRow{} + for _, row := range rows { + byThreshold[row.ContextThresholdTokens] = row + } + if byThreshold[model.ModelPriceBaseContextThreshold].Calls != 1 || + byThreshold[100].Calls != 1 || byThreshold[200].Calls != 1 { + t.Fatalf("threshold rows = %#v", byThreshold) + } + if byThreshold[100].PricingModel != "resolved-model" || byThreshold[100].InputTokens != 101 { + t.Fatalf("tier-one row = %#v", byThreshold[100]) + } + + accountRows, _, available, err := st.UsagePricingAccountRows(ctx, []string{"team-a"}) + if err != nil { + t.Fatalf("load account pricing rows: %v", err) + } + if !available || len(accountRows) != 3 { + t.Fatalf("account pricing rows available=%v rows=%#v", available, accountRows) + } +} + +func TestPricingRollupRateUpdatesKeepRevisionAndThresholdUpdatesRebuild(t *testing.T) { + ctx := context.Background() + cfg := testutil.NewConfig(t) + st := testutil.NewStore(t, cfg) + price := store.ModelPrice{ + Prompt: 1, + ContextTiers: []store.ModelPriceContextTier{{ThresholdTokens: 100, Prompt: 2, PromptConfigured: true}}, + ServiceTiers: []store.ModelPriceServiceTier{{ + Mode: "fast", ServiceTier: "priority", Prompt: 3, PromptConfigured: true, + }}, + } + if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{"resolved-model": price}); err != nil { + t.Fatalf("save prices: %v", err) + } + if _, err := st.UsageEvents.InsertBatch(ctx, []usage.Event{pricingEvent("event", 3_600_001, 150)}); err != nil { + t.Fatalf("insert event: %v", err) + } + first, err := st.CatchUpUsagePricing(ctx, 10, 10_000) + if err != nil { + t.Fatalf("initial catch up: %v", err) + } + if !first.Rebuilt { + t.Fatalf("initial catch up did not initialize revision: %#v", first) + } + initialState, err := st.UsagePricingState(ctx) + if err != nil { + t.Fatalf("initial state: %v", err) + } + + price.ContextTiers[0].Prompt = 9 + price.ServiceTiers[0].Prompt = 11 + if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{"resolved-model": price}); err != nil { + t.Fatalf("save rate update: %v", err) + } + rateResult, err := st.CatchUpUsagePricing(ctx, 10, 20_000) + if err != nil { + t.Fatalf("catch up rate update: %v", err) + } + if rateResult.Rebuilt { + t.Fatalf("rate-only update rebuilt rollup: %#v", rateResult) + } + rateState, err := st.UsagePricingState(ctx) + if err != nil { + t.Fatalf("rate state: %v", err) + } + if rateState.StructureRevision != initialState.StructureRevision { + t.Fatalf("rate revision = %q, want %q", rateState.StructureRevision, initialState.StructureRevision) + } + + price.ContextTiers[0].ThresholdTokens = 200 + if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{"resolved-model": price}); err != nil { + t.Fatalf("save threshold update: %v", err) + } + thresholdResult, err := st.CatchUpUsagePricing(ctx, 10, 30_000) + if err != nil { + t.Fatalf("catch up threshold update: %v", err) + } + if !thresholdResult.Rebuilt || thresholdResult.Processed != 1 { + t.Fatalf("threshold update result = %#v", thresholdResult) + } + rows, _, available, err := st.UsagePricingHourlyRows(ctx, store.UsagePricingHourlyFilter{ + FromMS: 3_600_000, + ToMS: 7_200_000, + IncludeFailed: true, + }) + if err != nil || !available || len(rows) != 1 { + t.Fatalf("rebuilt rows available=%v err=%v rows=%#v", available, err, rows) + } + if rows[0].ContextThresholdTokens != model.ModelPriceBaseContextThreshold { + t.Fatalf("rebuilt threshold = %d", rows[0].ContextThresholdTokens) + } +} + +func pricingEvent(hash string, timestampMS int64, inputTokens int64) usage.Event { + return usage.Event{ + EventHash: hash, + TimestampMS: timestampMS, + Timestamp: "1970-01-01T01:00:00Z", + Model: "display-model", + ResolvedModel: "resolved-model", + AccountSnapshot: "team-a", + InputTokens: inputTokens, + OutputTokens: 10, + TotalTokens: inputTokens + 10, + CreatedAtMS: timestampMS, + } +} diff --git a/apps/manager-server/internal/store/store.go b/apps/manager-server/internal/store/store.go index 84d4860e9..756251a3b 100644 --- a/apps/manager-server/internal/store/store.go +++ b/apps/manager-server/internal/store/store.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "io" + "sync" "time" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" @@ -19,6 +20,7 @@ import ( sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usageaggregate" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usageevent" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usagepricing" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usagerollup" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/security" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" @@ -40,6 +42,8 @@ type CodexInspectionDisableOwnership = model.CodexInspectionDisableOwnership type CodexInspectionLease = model.CodexInspectionLease type InsertResult = model.InsertResult type ModelPrice = model.ModelPrice +type ModelPriceContextTier = model.ModelPriceContextTier +type ModelPriceServiceTier = model.ModelPriceServiceTier type ModelPriceSyncResult = model.ModelPriceSyncResult type ModelUsageStat = model.ModelUsageStat type ModelUsageSummary = model.ModelUsageSummary @@ -86,9 +90,32 @@ type UsageHourlyAggregateState = usageaggregate.State type UsageHourlyAggregateCatchUpResult = usageaggregate.CatchUpResult type UsageHourlyAggregateFilter = usageaggregate.Filter type UsageHourlyAggregateRow = usageaggregate.Row +type UsagePricingState = usagepricing.State +type UsagePricingCatchUpResult = usagepricing.CatchUpResult +type UsagePricingHourlyFilter = usagepricing.HourlyFilter +type UsagePricingHourlyRow = usagepricing.HourlyRow +type UsagePricingAccountRow = usagepricing.AccountRow + +type UsageHourlyPricingSnapshot struct { + AggregateRows []UsageHourlyAggregateRow + AggregateState UsageHourlyAggregateState + AggregateAvailable bool + PricingRows []UsagePricingHourlyRow + PricingState UsagePricingState + PricingAvailable bool + Prices map[string]ModelPrice +} + +type UsagePricingAccountSnapshot struct { + Rows []UsagePricingAccountRow + State UsagePricingState + Available bool + Prices map[string]ModelPrice +} type Store struct { - db *sql.DB + db *sql.DB + modelPricesMu sync.RWMutex Settings setting.Repository UsageEvents usageevent.Repository @@ -100,6 +127,7 @@ type Store struct { DataMigrations datamigration.Repository QuotaCooldowns quotacooldown.Repository UsageAggregates usageaggregate.Repository + UsagePricing usagepricing.Repository UsageRollups usagerollup.Repository } @@ -124,6 +152,7 @@ func New(db *sql.DB, protector ...*security.Protector) *Store { DataMigrations: datamigration.New(db), QuotaCooldowns: quotacooldown.New(db), UsageAggregates: usageaggregate.New(db), + UsagePricing: usagepricing.New(db), UsageRollups: usagerollup.New(db), } } @@ -184,13 +213,25 @@ func (s *Store) LoadModelPrices(ctx context.Context) (map[string]ModelPrice, err } func (s *Store) SaveModelPrices(ctx context.Context, prices map[string]ModelPrice) error { + s.modelPricesMu.Lock() + defer s.modelPricesMu.Unlock() return s.ModelPrices.ReplaceAll(ctx, prices) } func (s *Store) UpsertSyncedModelPrices(ctx context.Context, prices map[string]ModelPrice) (ModelPriceSyncResult, error) { + s.modelPricesMu.Lock() + defer s.modelPricesMu.Unlock() return s.ModelPrices.UpsertSynced(ctx, prices) } +// WithModelPriceSnapshot prevents model-price mutations while a service reads +// usage bands and applies the corresponding price book across multiple queries. +func (s *Store) WithModelPriceSnapshot(read func() error) error { + s.modelPricesMu.RLock() + defer s.modelPricesMu.RUnlock() + return read() +} + func (s *Store) ModelUsageSummary(ctx context.Context, limit int) (ModelUsageSummary, error) { return s.UsageEvents.ModelUsageSummary(ctx, limit) } @@ -396,6 +437,95 @@ func (s *Store) UsageHourlyAggregateRows(ctx context.Context, filter UsageHourly return s.UsageAggregates.LoadRows(ctx, filter) } +func (s *Store) CatchUpUsagePricing(ctx context.Context, limit int, nowMS int64) (UsagePricingCatchUpResult, error) { + ready, err := s.UsageCacheAccountingMigrationReady(ctx) + if err != nil { + return UsagePricingCatchUpResult{}, err + } + if !ready { + return UsagePricingCatchUpResult{Pending: true}, nil + } + return s.UsagePricing.CatchUp(ctx, limit, nowMS) +} + +func (s *Store) RecordUsagePricingFailure(ctx context.Context, rollupErr error, nowMS int64) error { + return s.UsagePricing.RecordFailure(ctx, rollupErr, nowMS) +} + +func (s *Store) UsagePricingState(ctx context.Context) (UsagePricingState, error) { + return s.UsagePricing.State(ctx) +} + +func (s *Store) UsagePricingHourlyRows(ctx context.Context, filter UsagePricingHourlyFilter) ([]UsagePricingHourlyRow, UsagePricingState, bool, error) { + return s.UsagePricing.LoadHourlyRows(ctx, filter) +} + +func (s *Store) LoadUsageHourlyPricingSnapshot( + ctx context.Context, + aggregateFilter UsageHourlyAggregateFilter, + pricingFilter UsagePricingHourlyFilter, +) (UsageHourlyPricingSnapshot, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return UsageHourlyPricingSnapshot{}, err + } + defer func() { _ = tx.Rollback() }() + + aggregateRows, aggregateState, aggregateAvailable, err := s.UsageAggregates.LoadRowsTx(ctx, tx, aggregateFilter) + if err != nil { + return UsageHourlyPricingSnapshot{}, err + } + pricingRows, pricingState, pricingAvailable, err := s.UsagePricing.LoadHourlyRowsTx(ctx, tx, pricingFilter) + if err != nil { + return UsageHourlyPricingSnapshot{}, err + } + prices, err := s.ModelPrices.LoadAllTx(ctx, tx) + if err != nil { + return UsageHourlyPricingSnapshot{}, err + } + if err := tx.Commit(); err != nil { + return UsageHourlyPricingSnapshot{}, err + } + return UsageHourlyPricingSnapshot{ + AggregateRows: aggregateRows, + AggregateState: aggregateState, + AggregateAvailable: aggregateAvailable, + PricingRows: pricingRows, + PricingState: pricingState, + PricingAvailable: pricingAvailable, + Prices: prices, + }, nil +} + +func (s *Store) UsagePricingAccountRows(ctx context.Context, accountKeys []string) ([]UsagePricingAccountRow, UsagePricingState, bool, error) { + return s.UsagePricing.LoadAccountRows(ctx, accountKeys) +} + +func (s *Store) LoadUsagePricingAccountSnapshot(ctx context.Context, accountKeys []string) (UsagePricingAccountSnapshot, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return UsagePricingAccountSnapshot{}, err + } + defer func() { _ = tx.Rollback() }() + rows, state, available, err := s.UsagePricing.LoadAccountRowsTx(ctx, tx, accountKeys) + if err != nil { + return UsagePricingAccountSnapshot{}, err + } + prices, err := s.ModelPrices.LoadAllTx(ctx, tx) + if err != nil { + return UsagePricingAccountSnapshot{}, err + } + if err := tx.Commit(); err != nil { + return UsagePricingAccountSnapshot{}, err + } + return UsagePricingAccountSnapshot{ + Rows: rows, + State: state, + Available: available, + Prices: prices, + }, nil +} + func (s *Store) CatchUpAccountHistoryRollups(ctx context.Context, limit int, nowMS int64) (UsageRollupCatchUpResult, error) { ready, err := s.UsageCacheAccountingMigrationReady(ctx) if err != nil { diff --git a/apps/manager-server/internal/store/store_compat_test.go b/apps/manager-server/internal/store/store_compat_test.go index 6419c83cd..a81951300 100644 --- a/apps/manager-server/internal/store/store_compat_test.go +++ b/apps/manager-server/internal/store/store_compat_test.go @@ -356,6 +356,13 @@ func TestStoreCompatModelPricesAndAPIKeyAliases(t *testing.T) { "gpt-a": { Prompt: 1, Completion: 2, Cache: 0.5, CacheRead: 0.25, CacheCreation: 1.5, PromptConfigured: true, CompletionConfigured: true, CacheReadConfigured: true, CacheCreationConfigured: true, + ContextTiers: []ModelPriceContextTier{ + {ThresholdTokens: 200_000, Prompt: 0, Completion: 8, PromptConfigured: true, CompletionConfigured: true}, + {ThresholdTokens: 32_000, Prompt: 3, Completion: 4, CacheRead: 0, PromptConfigured: true, CompletionConfigured: true, CacheReadConfigured: true}, + }, + ServiceTiers: []ModelPriceServiceTier{ + {Mode: "fast", ServiceTier: "priority", Prompt: 2.5, Completion: 5, PromptConfigured: true, CompletionConfigured: true}, + }, }, "gpt-b": {Prompt: 0, Completion: 0, Cache: 0, PromptConfigured: true, CompletionConfigured: true}, }) @@ -369,7 +376,11 @@ func TestStoreCompatModelPricesAndAPIKeyAliases(t *testing.T) { if len(prices) != 2 || prices["gpt-a"].Prompt != 1 || prices["gpt-a"].CacheRead != 0.25 || prices["gpt-a"].CacheCreation != 1.5 || !prices["gpt-a"].CacheReadConfigured || !prices["gpt-a"].CacheCreationConfigured || prices["gpt-b"].Completion != 0 || - !prices["gpt-b"].PromptConfigured || !prices["gpt-b"].CompletionConfigured { + !prices["gpt-b"].PromptConfigured || !prices["gpt-b"].CompletionConfigured || + len(prices["gpt-a"].ContextTiers) != 2 || prices["gpt-a"].ContextTiers[0].ThresholdTokens != 32_000 || + prices["gpt-a"].ContextTiers[1].Prompt != 0 || !prices["gpt-a"].ContextTiers[1].PromptConfigured || + len(prices["gpt-a"].ServiceTiers) != 1 || prices["gpt-a"].ServiceTiers[0].Mode != "fast" || + prices["gpt-a"].ServiceTiers[0].ServiceTier != "priority" { t.Fatalf("prices = %#v", prices) } @@ -378,8 +389,20 @@ func TestStoreCompatModelPricesAndAPIKeyAliases(t *testing.T) { Prompt: 5, Completion: 6, Cache: 1, CacheRead: 0.75, CacheCreation: 4, PromptConfigured: true, CompletionConfigured: true, CacheReadConfigured: true, CacheCreationConfigured: true, Source: "litellm", + ContextTiers: []ModelPriceContextTier{ + {ThresholdTokens: 128_000, Prompt: 7, Completion: 9, PromptConfigured: true, CompletionConfigured: true}, + }, + ServiceTiers: []ModelPriceServiceTier{ + {Mode: "FAST", ServiceTier: "PRIORITY", Prompt: 11, PromptConfigured: true}, + }, + }, + "bad": { + Prompt: 1, + ContextTiers: []ModelPriceContextTier{ + {ThresholdTokens: 32_000, Prompt: 2, PromptConfigured: true}, + {ThresholdTokens: 32_000, Prompt: 3, PromptConfigured: true}, + }, }, - "bad": {Prompt: -1, Completion: 0, Cache: 0}, }) if err != nil { t.Fatalf("upsert synced prices: %v", err) @@ -393,10 +416,26 @@ func TestStoreCompatModelPricesAndAPIKeyAliases(t *testing.T) { } if prices["gpt-a"].Prompt != 5 || prices["gpt-a"].CacheRead != 0.75 || prices["gpt-a"].CacheCreation != 4 || prices["gpt-a"].SyncedAtMS == nil || - prices["gpt-a"].Source != "litellm" { + prices["gpt-a"].Source != "litellm" || len(prices["gpt-a"].ContextTiers) != 1 || + prices["gpt-a"].ContextTiers[0].ThresholdTokens != 128_000 || len(prices["gpt-a"].ServiceTiers) != 1 || + prices["gpt-a"].ServiceTiers[0].Mode != "fast" || prices["gpt-a"].ServiceTiers[0].Prompt != 11 { t.Fatalf("synced price = %#v", prices["gpt-a"]) } + if err := db.SaveModelPrices(context.Background(), map[string]ModelPrice{ + "gpt-a": {Prompt: 6, Completion: 12, Source: "manual"}, + }); err != nil { + t.Fatalf("save manual replacement: %v", err) + } + prices, err = db.LoadModelPrices(context.Background()) + if err != nil { + t.Fatalf("reload manual replacement: %v", err) + } + if len(prices) != 1 || prices["gpt-a"].Source != "manual" || len(prices["gpt-a"].ContextTiers) != 0 || + len(prices["gpt-a"].ServiceTiers) != 0 { + t.Fatalf("manual replacement retained synchronized rules: %#v", prices) + } + const hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" if err := db.UpsertAPIKeyAliases(context.Background(), []APIKeyAlias{{APIKeyHash: hash, Alias: "Team A"}}); err != nil { t.Fatalf("upsert alias: %v", err) diff --git a/apps/manager-server/internal/store/usage_pricing_snapshot_test.go b/apps/manager-server/internal/store/usage_pricing_snapshot_test.go new file mode 100644 index 000000000..bc0737264 --- /dev/null +++ b/apps/manager-server/internal/store/usage_pricing_snapshot_test.go @@ -0,0 +1,250 @@ +package store + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usageaggregate" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +type blockingUsageAggregateRepository struct { + usageaggregate.Repository + afterRead chan struct{} + resume <-chan struct{} +} + +func (r *blockingUsageAggregateRepository) LoadRowsTx(ctx context.Context, tx *sql.Tx, filter usageaggregate.Filter) ([]usageaggregate.Row, usageaggregate.State, bool, error) { + rows, state, available, err := r.Repository.LoadRowsTx(ctx, tx, filter) + close(r.afterRead) + select { + case <-r.resume: + case <-ctx.Done(): + return nil, usageaggregate.State{}, false, ctx.Err() + } + return rows, state, available, err +} + +func TestLoadUsageHourlyPricingSnapshotIsConsistentDuringConcurrentWrites(t *testing.T) { + ctx := context.Background() + db, err := Open(t.TempDir() + "/usage.sqlite") + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + fromMS := int64(1_800_000_000_000) + toMS := fromMS + 2*int64(time.Hour/time.Millisecond) + if err := db.SaveModelPrices(ctx, map[string]ModelPrice{ + "model-a": {Prompt: 1, PromptConfigured: true}, + }); err != nil { + t.Fatalf("save initial prices: %v", err) + } + if _, err := db.InsertEvents(ctx, []usage.Event{snapshotTestEvent("snapshot-first", fromMS+1_000, 100_000)}); err != nil { + t.Fatalf("insert initial event: %v", err) + } + catchUpUsagePricingSnapshot(t, ctx, db) + + aggregateFilter := UsageHourlyAggregateFilter{FromMS: fromMS, ToMS: toMS, IncludeFailed: true} + pricingFilter := UsagePricingHourlyFilter{FromMS: fromMS, ToMS: toMS, IncludeFailed: true} + originalAggregateRepository := db.UsageAggregates + afterAggregateRead := make(chan struct{}) + resumeSnapshot := make(chan struct{}) + db.UsageAggregates = &blockingUsageAggregateRepository{ + Repository: originalAggregateRepository, + afterRead: afterAggregateRead, + resume: resumeSnapshot, + } + + type snapshotResult struct { + snapshot UsageHourlyPricingSnapshot + err error + } + snapshotDone := make(chan snapshotResult, 1) + go func() { + snapshot, snapshotErr := db.LoadUsageHourlyPricingSnapshot(ctx, aggregateFilter, pricingFilter) + snapshotDone <- snapshotResult{snapshot: snapshot, err: snapshotErr} + }() + <-afterAggregateRead + + writerDone := make(chan error, 1) + go func() { + if saveErr := db.SaveModelPrices(ctx, map[string]ModelPrice{ + "model-a": { + Prompt: 2, PromptConfigured: true, + ContextTiers: []ModelPriceContextTier{{ + ThresholdTokens: 200_000, + Prompt: 4, + PromptConfigured: true, + }}, + }, + }); saveErr != nil { + writerDone <- saveErr + return + } + _, insertErr := db.InsertEvents(ctx, []usage.Event{snapshotTestEvent("snapshot-second", fromMS+2_000, 300_000)}) + writerDone <- insertErr + }() + + var writerErr error + writerCompleted := false + select { + case writerErr = <-writerDone: + writerCompleted = true + case <-time.After(250 * time.Millisecond): + } + close(resumeSnapshot) + result := <-snapshotDone + if result.err != nil { + t.Fatalf("load concurrent snapshot: %v", result.err) + } + if !writerCompleted { + writerErr = <-writerDone + } + if writerErr != nil { + t.Fatalf("concurrent writer: %v", writerErr) + } + db.UsageAggregates = originalAggregateRepository + + if calls := aggregateSnapshotCalls(result.snapshot.AggregateRows); calls != 1 { + t.Fatalf("aggregate snapshot calls = %d, want 1", calls) + } + if calls := pricingSnapshotCalls(result.snapshot.PricingRows); calls != 1 { + t.Fatalf("pricing snapshot calls = %d, want 1", calls) + } + price := result.snapshot.Prices["model-a"] + if price.Prompt != 1 || len(price.ContextTiers) != 0 { + t.Fatalf("snapshot price = %#v, want initial price", price) + } + + latest, err := db.LoadUsageHourlyPricingSnapshot(ctx, aggregateFilter, pricingFilter) + if err != nil { + t.Fatalf("load latest snapshot: %v", err) + } + if calls := aggregateSnapshotCalls(latest.AggregateRows); calls != 2 { + t.Fatalf("latest aggregate calls = %d, want 2", calls) + } + if calls := pricingSnapshotCalls(latest.PricingRows); calls != 2 { + t.Fatalf("latest pricing calls = %d, want 2", calls) + } + latestPrice := latest.Prices["model-a"] + if latestPrice.Prompt != 2 || len(latestPrice.ContextTiers) != 1 || latestPrice.ContextTiers[0].ThresholdTokens != 200_000 { + t.Fatalf("latest price = %#v, want structural update", latestPrice) + } + foundLongBand := false + for _, row := range latest.PricingRows { + if row.ContextThresholdTokens == 200_000 { + foundLongBand = true + } + } + if !foundLongBand { + t.Fatalf("latest pricing rows did not use updated structure: %#v", latest.PricingRows) + } +} + +func TestWithModelPriceSnapshotBlocksMutationsUntilReadCompletes(t *testing.T) { + ctx := context.Background() + db, err := Open(t.TempDir() + "/usage.sqlite") + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.SaveModelPrices(ctx, map[string]ModelPrice{"model-a": {Prompt: 1}}); err != nil { + t.Fatalf("save initial price: %v", err) + } + + readStarted := make(chan struct{}) + releaseRead := make(chan struct{}) + readDone := make(chan error, 1) + go func() { + readDone <- db.WithModelPriceSnapshot(func() error { + close(readStarted) + <-releaseRead + return nil + }) + }() + <-readStarted + + writerStarted := make(chan struct{}) + writerDone := make(chan error, 1) + go func() { + close(writerStarted) + writerDone <- db.SaveModelPrices(ctx, map[string]ModelPrice{"model-a": {Prompt: 2}}) + }() + <-writerStarted + select { + case err := <-writerDone: + t.Fatalf("price mutation completed during read snapshot: %v", err) + case <-time.After(50 * time.Millisecond): + } + + close(releaseRead) + if err := <-readDone; err != nil { + t.Fatalf("read snapshot: %v", err) + } + if err := <-writerDone; err != nil { + t.Fatalf("price mutation after read snapshot: %v", err) + } + prices, err := db.LoadModelPrices(ctx) + if err != nil { + t.Fatalf("load updated price: %v", err) + } + if prices["model-a"].Prompt != 2 { + t.Fatalf("updated price = %#v", prices["model-a"]) + } +} + +func catchUpUsagePricingSnapshot(t *testing.T, ctx context.Context, db *Store) { + t.Helper() + for { + result, err := db.CatchUpUsageHourlyAggregate(ctx, 100, time.Now().UnixMilli()) + if err != nil { + t.Fatalf("catch up hourly aggregate: %v", err) + } + if !result.Pending { + break + } + } + for { + result, err := db.CatchUpUsagePricing(ctx, 100, time.Now().UnixMilli()) + if err != nil { + t.Fatalf("catch up pricing aggregate: %v", err) + } + if !result.Pending { + break + } + } +} + +func snapshotTestEvent(hash string, timestampMS, inputTokens int64) usage.Event { + return usage.Event{ + EventHash: hash, + TimestampMS: timestampMS, + Timestamp: time.UnixMilli(timestampMS).UTC().Format(time.RFC3339Nano), + Model: "model-a", + Endpoint: "POST /v1/chat/completions", + Method: "POST", + Path: "/v1/chat/completions", + InputTokens: inputTokens, + TotalTokens: inputTokens, + ResolvedModel: "model-a", + } +} + +func aggregateSnapshotCalls(rows []UsageHourlyAggregateRow) int64 { + var calls int64 + for _, row := range rows { + calls += row.Calls + } + return calls +} + +func pricingSnapshotCalls(rows []UsagePricingHourlyRow) int64 { + var calls int64 + for _, row := range rows { + calls += row.Calls + } + return calls +} From fbe6b6c3ea2e923bb33e11dc516dae4eb6975bb6 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:10:51 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20harde?= =?UTF-8?q?n=20models.dev=20tiered=20price=20synchronization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse context and Priority/Fast pricing rules from models.dev and notify rollup refreshes. Bound source latency, retain valid ETag data, reject unusable catalogs, and preserve failed-source prices. This keeps models.dev preferred without losing last-known-good pricing during outages. --- .../internal/service/modelprice/service.go | 260 ++++++++++- .../service/modelprice/service_test.go | 428 +++++++++++++++++- 2 files changed, 679 insertions(+), 9 deletions(-) diff --git a/apps/manager-server/internal/service/modelprice/service.go b/apps/manager-server/internal/service/modelprice/service.go index 2902dc62e..87c8523ad 100644 --- a/apps/manager-server/internal/service/modelprice/service.go +++ b/apps/manager-server/internal/service/modelprice/service.go @@ -15,6 +15,7 @@ import ( "time" "unicode" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/service/cpa" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" ) @@ -33,6 +34,8 @@ const ( const maxSyncCandidates = 8 const minCandidateScore = 0.55 const minWeakCandidateScore = 0.34 +const defaultSyncSourceTimeout = 10 * time.Second +const defaultSyncProxyResolutionTimeout = 5 * time.Second type UpdateRequest struct { Prices map[string]store.ModelPrice `json:"prices"` @@ -50,6 +53,7 @@ type SyncResult struct { Matched map[string]store.ModelPrice `json:"matched,omitempty"` Candidates []SyncCandidateSet `json:"candidates,omitempty"` Unmatched []string `json:"unmatched,omitempty"` + Preserved []string `json:"preserved,omitempty"` ProxyUsed bool `json:"proxyUsed,omitempty"` SourceResults []SyncSourceResult `json:"sourceResults,omitempty"` Prices map[string]store.ModelPrice `json:"prices"` @@ -79,9 +83,13 @@ type SetupResolver interface { } type Service struct { - store *store.Store - syncSources []priceSyncSource - setupResolver SetupResolver + store *store.Store + syncSources []priceSyncSource + syncSourceTimeout time.Duration + syncProxyTimeout time.Duration + setupResolver SetupResolver + notifierMu sync.RWMutex + pricesChangedNotifier func() } type fetchModelPricesFunc func(context.Context, string, *http.Client) (map[string]store.ModelPrice, int, error) @@ -145,7 +153,13 @@ func newMultiSource( Fetch: fetchOpenRouterModelPrices, }) } - return &Service{store: store, syncSources: sources, setupResolver: resolver} + return &Service{ + store: store, + syncSources: sources, + syncSourceTimeout: defaultSyncSourceTimeout, + syncProxyTimeout: defaultSyncProxyResolutionTimeout, + setupResolver: resolver, + } } func (s *Service) List(ctx context.Context) (map[string]store.ModelPrice, error) { @@ -156,6 +170,21 @@ func (s *Service) UsageSummary(ctx context.Context, limit int) (store.ModelUsage return s.store.ModelUsageSummary(ctx, limit) } +func (s *Service) SetPricesChangedNotifier(notifier func()) { + s.notifierMu.Lock() + s.pricesChangedNotifier = notifier + s.notifierMu.Unlock() +} + +func (s *Service) notifyPricesChanged() { + s.notifierMu.RLock() + notifier := s.pricesChangedNotifier + s.notifierMu.RUnlock() + if notifier != nil { + notifier() + } +} + func (s *Service) Replace(ctx context.Context, prices map[string]store.ModelPrice) (map[string]store.ModelPrice, error) { if prices == nil { return nil, errors.New("prices are required") @@ -163,6 +192,7 @@ func (s *Service) Replace(ctx context.Context, prices map[string]store.ModelPric if err := s.store.SaveModelPrices(ctx, prices); err != nil { return nil, err } + s.notifyPricesChanged() return s.store.LoadModelPrices(ctx) } @@ -176,10 +206,21 @@ func (s *Service) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) return SyncResult{}, err } selection := selectModelPrices(remotePrices, req.Models) + preserved := []string(nil) + if hasFailedSyncSource(sourceResults) { + existingPrices, err := s.store.LoadModelPrices(ctx) + if err != nil { + return SyncResult{}, err + } + selection, preserved = preserveFailedSourcePrices(selection, existingPrices, sourceResults, req.Models) + } result, err := s.store.UpsertSyncedModelPrices(ctx, selection.Prices) if err != nil { return SyncResult{}, err } + if result.Imported > 0 { + s.notifyPricesChanged() + } prices, err := s.store.LoadModelPrices(ctx) if err != nil { return SyncResult{}, err @@ -192,6 +233,7 @@ func (s *Service) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) Matched: selection.Matched, Candidates: selection.Candidates, Unmatched: selection.Unmatched, + Preserved: preserved, ProxyUsed: proxyUsed, SourceResults: sourceResults, Prices: prices, @@ -222,7 +264,13 @@ func (s *Service) fetchAllModelPrices(ctx context.Context, client *http.Client, failures = append(failures, source.Source+": "+result.Error) continue } - prices, skipped, err := source.Fetch(ctx, syncURL, client) + sourceCtx := ctx + cancel := func() {} + if s.syncSourceTimeout > 0 { + sourceCtx, cancel = context.WithTimeout(ctx, s.syncSourceTimeout) + } + prices, skipped, err := source.Fetch(sourceCtx, syncURL, client) + cancel() result.Skipped = skipped if err != nil { result.Error = err.Error() @@ -270,11 +318,75 @@ func (s *Service) fetchAllModelPrices(ctx context.Context, client *http.Client, if len(failures) == 0 { failures = append(failures, "no price sync sources configured") } - return nil, 0, nil, sourceResults, errors.New("model price sync failed: " + strings.Join(failures, "; ")) + return nil, 0, nil, sourceResults, errors.New("model price sync failed; existing prices were not changed: " + strings.Join(failures, "; ")) } return remotePrices, totalSkipped, sources, sourceResults, nil } +// preserveFailedSourcePrices prevents a transient failure of a preferred +// source from automatically downgrading an existing price to a lower-priority +// source. A successful preferred-source response that omits a model still +// permits the normal fallback behavior. +func preserveFailedSourcePrices( + selection priceSelectionResult, + existingPrices map[string]store.ModelPrice, + sourceResults []SyncSourceResult, + requestedModels []string, +) (priceSelectionResult, []string) { + failedSources := make(map[string]bool, len(sourceResults)) + for _, result := range sourceResults { + if result.Error != "" { + failedSources[result.Source] = true + } + } + if len(failedSources) == 0 { + return selection, nil + } + requestedScope := requestedModelScope(requestedModels) + preserved := make([]string, 0) + for modelID, existing := range existingPrices { + if requestedScope != nil && !requestedScope[modelID] { + continue + } + if !failedSources[existing.Source] { + continue + } + candidate, hasCandidate := selection.Prices[modelID] + if hasCandidate && modelPriceSourcePriority(existing.Source) >= modelPriceSourcePriority(candidate.Source) { + continue + } + if hasCandidate { + delete(selection.Prices, modelID) + delete(selection.Matched, modelID) + } + preserved = append(preserved, modelID) + } + sort.Strings(preserved) + return selection, preserved +} + +func requestedModelScope(models []string) map[string]bool { + if len(models) == 0 { + return nil + } + scope := make(map[string]bool, len(models)) + for _, modelID := range models { + if normalized := strings.TrimSpace(modelID); normalized != "" { + scope[normalized] = true + } + } + return scope +} + +func hasFailedSyncSource(sourceResults []SyncSourceResult) bool { + for _, result := range sourceResults { + if result.Error != "" { + return true + } + } + return false +} + func (source priceSyncSource) currentURL() string { if source.URL == nil { return "" @@ -293,7 +405,13 @@ func syncResultSource(sources []string) string { } func (s *Service) syncHTTPClient(ctx context.Context) (*http.Client, bool, error) { - proxyURL := s.resolveCPAProxyURL(ctx) + proxyCtx := ctx + cancel := func() {} + if s.syncProxyTimeout > 0 { + proxyCtx, cancel = context.WithTimeout(ctx, s.syncProxyTimeout) + } + proxyURL := s.resolveCPAProxyURL(proxyCtx) + cancel() if proxyURL == "" { return defaultSyncHTTPClient(), false, nil } @@ -376,7 +494,7 @@ func (cache *modelsDevPriceCache) fetch(ctx context.Context, syncURL string, cli prices, skipped, err := decodeModelsDevModelPrices(res.Body) if err != nil { - return nil, 0, err + return nil, skipped, err } cache.etag = strings.TrimSpace(res.Header.Get("ETag")) cache.prices = prices @@ -476,16 +594,112 @@ func decodeModelsDevModelPrices(reader io.Reader) (map[string]store.ModelPrice, Source: SyncSourceModelsDev, SourceModelID: sourceModelID, RawJSON: string(modelRaw), + ContextTiers: readModelsDevContextTiers(cost), + ServiceTiers: readModelsDevServiceTiers(entry), UpdatedAtMS: now, SyncedAtMS: &now, } prices[sourceModelID] = price } } + if len(prices) == 0 { + return nil, skipped, errors.New("model price sync failed: models.dev catalog contained no usable prices") + } return prices, skipped, nil } +func readModelsDevContextTiers(cost map[string]any) []store.ModelPriceContextTier { + rawTiers, ok := cost["tiers"].([]any) + if !ok || len(rawTiers) == 0 { + return nil + } + tiers := make([]store.ModelPriceContextTier, 0, len(rawTiers)) + for _, rawTier := range rawTiers { + entry, ok := rawTier.(map[string]any) + if !ok { + continue + } + descriptor, ok := entry["tier"].(map[string]any) + if !ok || !strings.EqualFold(readString(descriptor, "type"), "context") { + continue + } + threshold, ok := readPositiveInt64(descriptor, "size") + if !ok { + return nil + } + prompt, hasPrompt := readFloat(entry, "input") + completion, hasCompletion := readFloat(entry, "output") + cacheRead, hasCacheRead := readFloat(entry, "cache_read") + cacheCreation, hasCacheCreation := readFloat(entry, "cache_write") + if !hasPrompt && !hasCompletion && !hasCacheRead && !hasCacheCreation { + return nil + } + tiers = append(tiers, store.ModelPriceContextTier{ + ThresholdTokens: threshold, + Prompt: prompt, + Completion: completion, + Cache: cacheRead, + CacheRead: cacheRead, + CacheCreation: cacheCreation, + PromptConfigured: hasPrompt, + CompletionConfigured: hasCompletion, + CacheConfigured: hasCacheRead, + CacheReadConfigured: hasCacheRead, + CacheCreationConfigured: hasCacheCreation, + }) + } + normalized, err := model.NormalizeModelPriceContextTiers(tiers) + if err != nil { + return nil + } + return normalized +} + +func readModelsDevServiceTiers(entry map[string]any) []store.ModelPriceServiceTier { + experimental, ok := entry["experimental"].(map[string]any) + if !ok { + return nil + } + modes, ok := experimental["modes"].(map[string]any) + if !ok { + return nil + } + fast, ok := modes["fast"].(map[string]any) + if !ok { + return nil + } + cost, ok := fast["cost"].(map[string]any) + if !ok { + return nil + } + prompt, hasPrompt := readFloat(cost, "input") + completion, hasCompletion := readFloat(cost, "output") + cacheRead, hasCacheRead := readFloat(cost, "cache_read") + cacheCreation, hasCacheCreation := readFloat(cost, "cache_write") + if !hasPrompt && !hasCompletion && !hasCacheRead && !hasCacheCreation { + return nil + } + tiers, err := model.NormalizeModelPriceServiceTiers([]store.ModelPriceServiceTier{{ + Mode: "fast", + ServiceTier: "priority", + Prompt: prompt, + Completion: completion, + Cache: cacheRead, + CacheRead: cacheRead, + CacheCreation: cacheCreation, + PromptConfigured: hasPrompt, + CompletionConfigured: hasCompletion, + CacheConfigured: hasCacheRead, + CacheReadConfigured: hasCacheRead, + CacheCreationConfigured: hasCacheCreation, + }}) + if err != nil { + return nil + } + return tiers +} + type basePriceRule struct { Prompt float64 `json:"prompt"` Completion float64 `json:"completion"` @@ -1406,6 +1620,36 @@ func readFirstFloat(entry map[string]any, keys ...string) (float64, bool) { return 0, false } +func readPositiveInt64(entry map[string]any, key string) (int64, bool) { + value, ok := entry[key] + if !ok || value == nil { + return 0, false + } + var parsed int64 + switch typed := value.(type) { + case float64: + if typed <= 0 || typed > math.MaxInt64 || math.Trunc(typed) != typed { + return 0, false + } + parsed = int64(typed) + case json.Number: + value, err := typed.Int64() + if err != nil || value <= 0 { + return 0, false + } + parsed = value + case string: + value, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + if err != nil || value <= 0 { + return 0, false + } + parsed = value + default: + return 0, false + } + return parsed, true +} + func readString(entry map[string]any, key string) string { value, ok := entry[key] if !ok || value == nil { diff --git a/apps/manager-server/internal/service/modelprice/service_test.go b/apps/manager-server/internal/service/modelprice/service_test.go index 03db2c141..30a73cffd 100644 --- a/apps/manager-server/internal/service/modelprice/service_test.go +++ b/apps/manager-server/internal/service/modelprice/service_test.go @@ -8,18 +8,27 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/testutil" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" ) +type staticSetupResolver struct { + setup store.Setup +} + +func (r staticSetupResolver) ResolveSetup(context.Context) (store.Setup, bool, error) { + return r.setup, true, nil +} + func TestFetchModelsDevModelPrices(t *testing.T) { source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "provider-a": {"models": { - "shared-model": {"name":"Shared A", "cost":{"input":1,"output":2,"cache_read":0.1,"cache_write":0.2,"tiers":[{"input":3,"output":4,"tier":{"type":"context","size":200000}}]}}, + "shared-model": {"name":"Shared A", "cost":{"input":1,"output":2,"cache_read":0.1,"cache_write":0.2,"tiers":[{"input":3,"output":4,"tier":{"type":"context","size":200000}}]},"experimental":{"modes":{"fast":{"cost":{"input":2.5,"output":5,"cache_read":0}}}}}, "unique-model": {"cost":{"input":3,"output":4}} }}, "provider-b": {"models": { @@ -56,6 +65,17 @@ func TestFetchModelsDevModelPrices(t *testing.T) { if !strings.Contains(shared.RawJSON, `"tiers"`) { t.Fatalf("raw model metadata was not retained: %s", shared.RawJSON) } + if len(shared.ContextTiers) != 1 || shared.ContextTiers[0].ThresholdTokens != 200_000 || + shared.ContextTiers[0].Prompt != 3 || shared.ContextTiers[0].Completion != 4 || + !shared.ContextTiers[0].PromptConfigured || !shared.ContextTiers[0].CompletionConfigured { + t.Fatalf("context tiers = %#v", shared.ContextTiers) + } + if len(shared.ServiceTiers) != 1 || shared.ServiceTiers[0].Mode != "fast" || + shared.ServiceTiers[0].ServiceTier != "priority" || shared.ServiceTiers[0].Prompt != 2.5 || + shared.ServiceTiers[0].Completion != 5 || !shared.ServiceTiers[0].CacheReadConfigured || + shared.ServiceTiers[0].CacheRead != 0 { + t.Fatalf("service tiers = %#v", shared.ServiceTiers) + } for _, alias := range []string{"shared-model", "unique-model", "same-rule"} { if _, ok := prices[alias]; ok { @@ -76,6 +96,119 @@ func TestFetchModelsDevModelPrices(t *testing.T) { } } +func TestDecodeModelsDevContextTiersPreservesConfiguredZerosAndIgnoresUnsafeRules(t *testing.T) { + prices, skipped, err := decodeModelsDevModelPrices(strings.NewReader(`{ + "provider-a":{"models":{ + "tiered":{"cost":{"input":1,"output":2,"tiers":[ + {"input":0,"output":8,"cache_read":0,"tier":{"type":"context","size":200000}}, + {"input":3,"output":4,"cache_write":0.5,"tier":{"type":"context","size":32000}}, + {"input":99,"output":99,"tier":{"type":"future-mode","size":1}} + ]}}, + "duplicate":{"cost":{"input":1,"tiers":[ + {"input":2,"tier":{"type":"context","size":32000}}, + {"input":3,"tier":{"type":"context","size":32000}} + ]}}, + "invalid":{"cost":{"input":1,"tiers":[ + {"input":2,"tier":{"type":"context","size":0}} + ]}}, + "unknown-only":{"cost":{"input":1,"tiers":[ + {"input":2,"tier":{"type":"requests","size":10}} + ]}} + }} + }`)) + if err != nil { + t.Fatalf("decode models.dev prices: %v", err) + } + if skipped != 0 { + t.Fatalf("skipped = %d", skipped) + } + + tiers := prices["provider-a/tiered"].ContextTiers + if len(tiers) != 2 || tiers[0].ThresholdTokens != 32_000 || tiers[1].ThresholdTokens != 200_000 { + t.Fatalf("sorted tiers = %#v", tiers) + } + if !tiers[1].PromptConfigured || tiers[1].Prompt != 0 || !tiers[1].CacheReadConfigured || tiers[1].CacheRead != 0 || + tiers[1].CacheCreationConfigured { + t.Fatalf("explicit zero and missing flags = %#v", tiers[1]) + } + if tiers[0].CacheReadConfigured || !tiers[0].CacheCreationConfigured || tiers[0].CacheCreation != 0.5 { + t.Fatalf("optional cache fields = %#v", tiers[0]) + } + for _, modelID := range []string{"provider-a/duplicate", "provider-a/invalid", "provider-a/unknown-only"} { + if len(prices[modelID].ContextTiers) != 0 { + t.Fatalf("unsafe tiers activated for %s: %#v", modelID, prices[modelID].ContextTiers) + } + if !strings.Contains(prices[modelID].RawJSON, `"tiers"`) { + t.Fatalf("raw tiers missing for %s: %s", modelID, prices[modelID].RawJSON) + } + } +} + +func TestDecodeModelsDevRejectsCatalogWithoutUsablePrices(t *testing.T) { + tests := []struct { + name string + payload string + }{ + {name: "empty object", payload: `{}`}, + {name: "null", payload: `null`}, + {name: "empty provider catalog", payload: `{"provider-a":{"models":{}}}`}, + {name: "all models skipped", payload: `{"provider-a":{"models":{"uncosted":{"limit":{"context":1000}}}}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prices, _, err := decodeModelsDevModelPrices(strings.NewReader(tt.payload)) + if err == nil || !strings.Contains(err.Error(), "no usable prices") { + t.Fatalf("decode error = %v, prices = %#v", err, prices) + } + }) + } +} + +func TestPriceMutationsNotifyPricingRollup(t *testing.T) { + ctx := context.Background() + st := testutil.NewStore(t, testutil.NewConfig(t)) + modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"provider-a":{"models":{"synced":{"cost":{"input":3,"output":4}}}}}`)) + })) + t.Cleanup(modelsDev.Close) + + modelsDevURL := modelsDev.URL + service := NewMultiSourceWithModelsDev(st, &modelsDevURL, nil, nil) + var notifications atomic.Int32 + service.SetPricesChangedNotifier(func() { + notifications.Add(1) + }) + + if _, err := service.Replace(ctx, map[string]store.ModelPrice{ + "manual": {Prompt: 1}, + }); err != nil { + t.Fatalf("replace prices: %v", err) + } + if got := notifications.Load(); got != 1 { + t.Fatalf("replace notifications = %d, want 1", got) + } + if _, err := service.Replace(ctx, map[string]store.ModelPrice{ + "": {Prompt: 1}, + }); err == nil { + t.Fatal("invalid replace error = nil") + } + if got := notifications.Load(); got != 1 { + t.Fatalf("failed replace notifications = %d, want 1", got) + } + + result, err := service.Sync(ctx, SyncRequest{Models: []string{"synced"}}) + if err != nil { + t.Fatalf("sync prices: %v", err) + } + if result.Imported != 1 { + t.Fatalf("sync result = %#v", result) + } + if got := notifications.Load(); got != 2 { + t.Fatalf("sync notifications = %d, want 2", got) + } +} + func TestModelsDevPriceCacheReusesETagConcurrently(t *testing.T) { const etag = `"catalog-v1"` var requestCount atomic.Int32 @@ -145,6 +278,49 @@ func TestModelsDevPriceCacheReusesETagConcurrently(t *testing.T) { } } +func TestModelsDevPriceCacheKeepsLastKnownGoodAfterUnusableResponse(t *testing.T) { + const initialETag = `"catalog-v1"` + var requestCount atomic.Int32 + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch requestCount.Add(1) { + case 1: + w.Header().Set("ETag", initialETag) + _, _ = w.Write([]byte(`{"provider-a":{"models":{"cached":{"cost":{"input":1,"output":2}}}}}`)) + case 2: + if received := r.Header.Get("If-None-Match"); received != initialETag { + http.Error(w, "missing initial cache validator", http.StatusPreconditionFailed) + return + } + w.Header().Set("ETag", `"catalog-v2"`) + _, _ = w.Write([]byte(`{"provider-a":{"models":{"uncosted":{"limit":{"context":1000}}}}}`)) + default: + if received := r.Header.Get("If-None-Match"); received != initialETag { + http.Error(w, "unusable response replaced cache validator", http.StatusPreconditionFailed) + return + } + w.Header().Set("ETag", initialETag) + w.WriteHeader(http.StatusNotModified) + } + })) + t.Cleanup(source.Close) + + cache := &modelsDevPriceCache{} + prices, _, err := cache.fetch(context.Background(), source.URL, source.Client()) + if err != nil || prices["provider-a/cached"].Prompt != 1 { + t.Fatalf("prime cache: prices=%#v err=%v", prices, err) + } + if _, skipped, err := cache.fetch(context.Background(), source.URL, source.Client()); err == nil || + !strings.Contains(err.Error(), "no usable prices") { + t.Fatalf("unusable response error = %v", err) + } else if skipped != 1 { + t.Fatalf("unusable response skipped = %d, want 1", skipped) + } + prices, _, err = cache.fetch(context.Background(), source.URL, source.Client()) + if err != nil || prices["provider-a/cached"].Prompt != 1 { + t.Fatalf("reuse last-known-good cache: prices=%#v err=%v", prices, err) + } +} + func TestModelsDevPriceCacheDoesNotServeStaleDataAndInvalidatesURL(t *testing.T) { var invalidResponse atomic.Bool firstSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -245,6 +421,256 @@ func TestModelsDevCacheFailureFallsBackWithoutStalePrices(t *testing.T) { } } +func TestFetchAllModelPricesFallsBackWhenPreferredSourceHangs(t *testing.T) { + modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + t.Cleanup(modelsDev.Close) + liteLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"gpt-test":{"input_cost_per_token":0.000001,"output_cost_per_token":0.000002}}`)) + })) + t.Cleanup(liteLLM.Close) + + modelsDevURL := modelsDev.URL + liteLLMURL := liteLLM.URL + service := NewMultiSourceWithModelsDev(nil, &modelsDevURL, &liteLLMURL, nil) + service.syncSourceTimeout = 25 * time.Millisecond + + startedAt := time.Now() + prices, _, sources, sourceResults, err := service.fetchAllModelPrices( + context.Background(), + modelsDev.Client(), + []string{"gpt-test"}, + ) + if err != nil { + t.Fatalf("fallback after preferred source timeout: %v", err) + } + if elapsed := time.Since(startedAt); elapsed > time.Second { + t.Fatalf("fallback elapsed = %s, want under 1s", elapsed) + } + if len(sources) != 1 || sources[0] != SyncSourceLiteLLM { + t.Fatalf("fallback sources = %#v", sources) + } + if len(sourceResults) != 2 || sourceResults[0].Source != SyncSourceModelsDev || + sourceResults[0].Error == "" || sourceResults[1].Source != SyncSourceLiteLLM { + t.Fatalf("fallback source results = %#v", sourceResults) + } + if price := prices["gpt-test"]; price.Source != SyncSourceLiteLLM || price.Prompt != 1 { + t.Fatalf("fallback price = %#v", price) + } +} + +func TestSyncHTTPClientBoundsProxyResolution(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + t.Cleanup(upstream.Close) + + service := NewMultiSource(nil, nil, nil, staticSetupResolver{setup: store.Setup{ + CPAUpstreamURL: upstream.URL, + ManagementKey: "test-key", + }}) + service.syncProxyTimeout = 25 * time.Millisecond + + startedAt := time.Now() + client, proxyUsed, err := service.syncHTTPClient(context.Background()) + if err != nil { + t.Fatalf("resolve sync client: %v", err) + } + if elapsed := time.Since(startedAt); elapsed > time.Second { + t.Fatalf("proxy resolution elapsed = %s, want under 1s", elapsed) + } + if client == nil || proxyUsed { + t.Fatalf("client = %#v, proxy used = %v", client, proxyUsed) + } +} + +func TestSyncPreservesLastKnownModelsDevPriceDuringPreferredSourceFailure(t *testing.T) { + ctx := context.Background() + st := testutil.NewStore(t, testutil.NewConfig(t)) + if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "gpt-test": { + Prompt: 9, Completion: 18, PromptConfigured: true, CompletionConfigured: true, + Source: SyncSourceModelsDev, SourceModelID: "openai/gpt-test", + ContextTiers: []store.ModelPriceContextTier{{ + ThresholdTokens: 200_000, Prompt: 12, PromptConfigured: true, + }}, + ServiceTiers: []store.ModelPriceServiceTier{{ + Mode: "fast", ServiceTier: "priority", Prompt: 20, PromptConfigured: true, + }}, + }, + }); err != nil { + t.Fatalf("save last-known models.dev price: %v", err) + } + + var modelsDevAvailable atomic.Bool + modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !modelsDevAvailable.Load() { + http.Error(w, "temporary outage", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"openai":{"models":{"gpt-test":{"cost":{"input":7,"output":14},"experimental":{"modes":{"fast":{"cost":{"input":17.5,"output":35}}}}}}}}`)) + })) + t.Cleanup(modelsDev.Close) + liteLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "gpt-test":{"input_cost_per_token":0.000001,"output_cost_per_token":0.000002}, + "fallback-only":{"input_cost_per_token":0.000003,"output_cost_per_token":0.000004} + }`)) + })) + t.Cleanup(liteLLM.Close) + + modelsDevURL := modelsDev.URL + liteLLMURL := liteLLM.URL + service := NewMultiSourceWithModelsDev(st, &modelsDevURL, &liteLLMURL, nil) + result, err := service.Sync(ctx, SyncRequest{Models: []string{"gpt-test", "fallback-only"}}) + if err != nil { + t.Fatalf("sync with preferred source outage: %v", err) + } + if result.Imported != 1 || len(result.Preserved) != 1 || result.Preserved[0] != "gpt-test" { + t.Fatalf("outage sync result = %#v", result) + } + if price := result.Prices["gpt-test"]; price.Source != SyncSourceModelsDev || price.Prompt != 9 || + len(price.ContextTiers) != 1 || len(price.ServiceTiers) != 1 { + t.Fatalf("last-known price was not preserved: %#v", price) + } + if price := result.Prices["fallback-only"]; price.Source != SyncSourceLiteLLM || price.Prompt != 3 { + t.Fatalf("fallback model was not imported: %#v", price) + } + + modelsDevAvailable.Store(true) + result, err = service.Sync(ctx, SyncRequest{Models: []string{"gpt-test"}}) + if err != nil { + t.Fatalf("sync after preferred source recovery: %v", err) + } + if result.Imported != 1 || len(result.Preserved) != 0 { + t.Fatalf("recovery sync result = %#v", result) + } + price := result.Prices["gpt-test"] + if price.Source != SyncSourceModelsDev || price.Prompt != 7 || len(price.ServiceTiers) != 1 || + price.ServiceTiers[0].Prompt != 17.5 { + t.Fatalf("recovered models.dev price = %#v", price) + } +} + +func TestSyncTreatsUnusableModelsDevResponseAsFailureAndReportsAllPreservedPrices(t *testing.T) { + ctx := context.Background() + st := testutil.NewStore(t, testutil.NewConfig(t)) + if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "gpt-test": { + Prompt: 9, Completion: 18, PromptConfigured: true, CompletionConfigured: true, + Source: SyncSourceModelsDev, SourceModelID: "openai/gpt-test", + }, + "rare-model": { + Prompt: 11, Completion: 22, PromptConfigured: true, CompletionConfigured: true, + Source: SyncSourceModelsDev, SourceModelID: "rare/rare-model", + }, + }); err != nil { + t.Fatalf("save last-known models.dev prices: %v", err) + } + + modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(modelsDev.Close) + liteLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"gpt-test":{"input_cost_per_token":0.000001,"output_cost_per_token":0.000002}}`)) + })) + t.Cleanup(liteLLM.Close) + + modelsDevURL := modelsDev.URL + liteLLMURL := liteLLM.URL + service := NewMultiSourceWithModelsDev(st, &modelsDevURL, &liteLLMURL, nil) + result, err := service.Sync(ctx, SyncRequest{Models: []string{"gpt-test", "rare-model"}}) + if err != nil { + t.Fatalf("sync with unusable preferred response: %v", err) + } + if len(result.Preserved) != 2 || result.Preserved[0] != "gpt-test" || result.Preserved[1] != "rare-model" { + t.Fatalf("preserved prices = %#v, want both existing models", result.Preserved) + } + if len(result.SourceResults) < 1 || result.SourceResults[0].Source != SyncSourceModelsDev || + !strings.Contains(result.SourceResults[0].Error, "no usable prices") { + t.Fatalf("models.dev source result = %#v", result.SourceResults) + } + if price := result.Prices["gpt-test"]; price.Source != SyncSourceModelsDev || price.Prompt != 9 { + t.Fatalf("fallback replaced last-known gpt-test price: %#v", price) + } + if price := result.Prices["rare-model"]; price.Source != SyncSourceModelsDev || price.Prompt != 11 { + t.Fatalf("rare model was not retained: %#v", price) + } +} + +func TestSyncAllSourceFailuresLeaveExistingPricesUnchanged(t *testing.T) { + ctx := context.Background() + st := testutil.NewStore(t, testutil.NewConfig(t)) + if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "existing": {Prompt: 4, Source: SyncSourceModelsDev, SourceModelID: "openai/existing"}, + }); err != nil { + t.Fatalf("save existing price: %v", err) + } + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "offline", http.StatusServiceUnavailable) + })) + t.Cleanup(failing.Close) + modelsDevURL := failing.URL + liteLLMURL := failing.URL + service := NewMultiSourceWithModelsDev(st, &modelsDevURL, &liteLLMURL, nil) + if _, err := service.Sync(ctx, SyncRequest{Models: []string{"existing"}}); err == nil || + !strings.Contains(err.Error(), "existing prices were not changed") { + t.Fatalf("all-source failure error = %v", err) + } + prices, err := st.LoadModelPrices(ctx) + if err != nil { + t.Fatalf("load existing prices: %v", err) + } + if len(prices) != 1 || prices["existing"].Prompt != 4 || prices["existing"].Source != SyncSourceModelsDev { + t.Fatalf("existing prices changed after failure: %#v", prices) + } +} + +func TestPreferredSourceSuccessAllowsFallbackReplacementForMissingModel(t *testing.T) { + selection := priceSelectionResult{ + Prices: map[string]store.ModelPrice{ + "gpt-test": {Prompt: 1, Source: SyncSourceLiteLLM}, + }, + Matched: map[string]store.ModelPrice{ + "gpt-test": {Prompt: 1, Source: SyncSourceLiteLLM}, + }, + } + existing := map[string]store.ModelPrice{ + "gpt-test": {Prompt: 9, Source: SyncSourceModelsDev}, + } + filtered, preserved := preserveFailedSourcePrices(selection, existing, []SyncSourceResult{ + {Source: SyncSourceModelsDev, Models: 1}, + {Source: SyncSourceLiteLLM, Models: 1}, + }, []string{"gpt-test"}) + if len(preserved) != 0 || filtered.Prices["gpt-test"].Source != SyncSourceLiteLLM { + t.Fatalf("successful preferred-source omission did not allow fallback: selection=%#v preserved=%#v", filtered, preserved) + } +} + +func TestPreserveFailedSourcePricesReportsOnlyRequestedModels(t *testing.T) { + selection := priceSelectionResult{ + Prices: map[string]store.ModelPrice{}, + Matched: map[string]store.ModelPrice{}, + } + existing := map[string]store.ModelPrice{ + "requested": {Prompt: 1, Source: SyncSourceModelsDev}, + "unrequested": {Prompt: 2, Source: SyncSourceModelsDev}, + } + _, preserved := preserveFailedSourcePrices(selection, existing, []SyncSourceResult{ + {Source: SyncSourceModelsDev, Error: "offline"}, + }, []string{" requested "}) + if len(preserved) != 1 || preserved[0] != "requested" { + t.Fatalf("preserved prices = %#v, want requested model only", preserved) + } +} + func TestSelectModelPricesRequiresConfirmationForScopedIdentityCollision(t *testing.T) { prices := map[string]store.ModelPrice{ "openai/gpt-test": { From bcacdceea5e50677b13db6e5c2be0a8774d72eb8 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:10:51 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20read?= =?UTF-8?q?=20tiered=20pricing=20rollups=20for=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge pricing-band rollups with pending raw usage in the shared hourly reader. Preserve core usage metrics while exposing classified rows to cost-aware consumers. This centralizes the optimized data path used by dashboard and monitoring services. --- .../internal/service/usagehourly/reader.go | 116 ++++++++++++++---- .../service/usagehourly/reader_test.go | 24 +++- 2 files changed, 114 insertions(+), 26 deletions(-) diff --git a/apps/manager-server/internal/service/usagehourly/reader.go b/apps/manager-server/internal/service/usagehourly/reader.go index 6f8fb8a28..80b6e3a27 100644 --- a/apps/manager-server/internal/service/usagehourly/reader.go +++ b/apps/manager-server/internal/service/usagehourly/reader.go @@ -29,8 +29,10 @@ type Reader struct { type Snapshot struct { Aggregate store.Aggregate ModelStats []store.ModelStat + Prices map[string]store.ModelPrice rows []store.UsageHourlyAggregateRow + pricingRows []store.UsagePricingHourlyRow fromMS int64 toMS int64 dashboardTimelineReady bool @@ -38,16 +40,20 @@ type Snapshot struct { } type modelStatKey struct { - model string - billingModel string - serviceTier string + model string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 } type analyticsTimelineKey struct { - bucketMS int64 - model string - billingModel string - serviceTier string + bucketMS int64 + model string + billingModel string + pricingModel string + serviceTier string + contextThresholdTokens int64 } type analyticsTimelineAccumulator struct { @@ -126,28 +132,44 @@ func (r *Reader) loadRows(ctx context.Context, filter store.AnalyticsFilter, das return Snapshot{}, false } - rows, state, available, err := r.store.UsageHourlyAggregateRows(ctx, store.UsageHourlyAggregateFilter{ + aggregateFilter := store.UsageHourlyAggregateFilter{ FromMS: filter.FromMS, ToMS: filter.ToMS, Models: filter.Models, IncludeFailed: filter.IncludeFailed, FailedOnly: filter.FailedOnly, CollapseBuckets: !dashboardTimelineReady && !analyticsTimelineReady, - }) + } + pricingFilter := store.UsagePricingHourlyFilter{ + FromMS: filter.FromMS, + ToMS: filter.ToMS, + Models: filter.Models, + IncludeFailed: filter.IncludeFailed, + FailedOnly: filter.FailedOnly, + CollapseBuckets: !dashboardTimelineReady && !analyticsTimelineReady, + } + dbSnapshot, err := r.store.LoadUsageHourlyPricingSnapshot(ctx, aggregateFilter, pricingFilter) if err != nil { - r.logFallback(fmt.Sprintf("permanent hourly rows query failed: %v", err)) + r.logFallback(fmt.Sprintf("hourly pricing snapshot query failed: %v", err)) return Snapshot{}, false } - if !available { - r.logFallback(fmt.Sprintf("permanent hourly aggregate unavailable: schema_version=%d status=%s", state.SchemaVersion, state.Status)) + if !dbSnapshot.AggregateAvailable { + r.logFallback(fmt.Sprintf("permanent hourly aggregate unavailable: schema_version=%d status=%s", dbSnapshot.AggregateState.SchemaVersion, dbSnapshot.AggregateState.Status)) return Snapshot{}, false } - agg, modelStats := coreFromRows(rows) + if !dbSnapshot.PricingAvailable { + r.logFallback(fmt.Sprintf("pricing hourly aggregate unavailable: schema_version=%d status=%s", dbSnapshot.PricingState.SchemaVersion, dbSnapshot.PricingState.Status)) + return Snapshot{}, false + } + agg, _ := coreFromRows(dbSnapshot.AggregateRows) + modelStats := modelStatsFromPricingRows(dbSnapshot.PricingRows) return Snapshot{ Aggregate: agg, ModelStats: modelStats, - rows: rows, + Prices: dbSnapshot.Prices, + rows: dbSnapshot.AggregateRows, + pricingRows: dbSnapshot.PricingRows, fromMS: filter.FromMS, toMS: filter.ToMS, dashboardTimelineReady: dashboardTimelineReady, @@ -190,7 +212,7 @@ func (r *Reader) AnalyticsTimeline( return nil, false } - return analyticsTimelineFromRows(snapshot.rows, granularity, location), true + return analyticsTimelineFromPricingRows(snapshot.pricingRows, granularity, location), true } // CanRepresentAnalyticsTimeline reports whether complete UTC hourly rows can @@ -280,7 +302,13 @@ func coreFromRows(rows []store.UsageHourlyAggregateRow) (store.Aggregate, []stor } func addModelStat(grouped map[modelStatKey]*store.ModelStat, stat store.ModelStat) { - mapKey := modelStatKey{model: stat.Model, billingModel: stat.BillingModel, serviceTier: stat.ServiceTier} + mapKey := modelStatKey{ + model: stat.Model, + billingModel: stat.BillingModel, + pricingModel: stat.PricingModel, + serviceTier: stat.ServiceTier, + contextThresholdTokens: stat.ContextThresholdTokens, + } entry := grouped[mapKey] if entry == nil { copy := stat @@ -318,11 +346,44 @@ func sortedModelStats(grouped map[modelStatKey]*store.ModelStat) []store.ModelSt if result[i].BillingModel != result[j].BillingModel { return result[i].BillingModel < result[j].BillingModel } - return result[i].ServiceTier < result[j].ServiceTier + if result[i].PricingModel != result[j].PricingModel { + return result[i].PricingModel < result[j].PricingModel + } + if result[i].ServiceTier != result[j].ServiceTier { + return result[i].ServiceTier < result[j].ServiceTier + } + return result[i].ContextThresholdTokens < result[j].ContextThresholdTokens }) return result } +func modelStatsFromPricingRows(rows []store.UsagePricingHourlyRow) []store.ModelStat { + grouped := make(map[modelStatKey]*store.ModelStat) + for _, row := range rows { + successCalls := int64(0) + if !row.Failed { + successCalls = row.Calls + } + addModelStat(grouped, store.ModelStat{ + LongContextTokens: row.LongContextTokens, + PricingBand: row.PricingBand, + Model: row.Model, + BillingModel: row.BillingModel, + ServiceTier: row.ServiceTier, + Calls: row.Calls, + SuccessCalls: successCalls, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + ReasoningTokens: row.ReasoningTokens, + CachedTokens: row.CachedTokens, + CacheReadTokens: row.CacheReadTokens, + CacheCreationTokens: row.CacheCreationTokens, + TotalTokens: row.TotalTokens, + }) + } + return sortedModelStats(grouped) +} + func dashboardTimelineFromRows(rows []store.UsageHourlyAggregateRow) []store.TimelinePoint { grouped := make(map[int64]*store.TimelinePoint) for _, row := range rows { @@ -347,11 +408,12 @@ func dashboardTimelineFromRows(rows []store.UsageHourlyAggregateRow) []store.Tim return result } -func analyticsTimelineFromRows(rows []store.UsageHourlyAggregateRow, granularity string, location *time.Location) []store.TimelinePoint { +func analyticsTimelineFromPricingRows(rows []store.UsagePricingHourlyRow, granularity string, location *time.Location) []store.TimelinePoint { grouped := make(map[analyticsTimelineKey]*analyticsTimelineAccumulator) for _, row := range rows { point := store.TimelinePoint{ LongContextTokens: row.LongContextTokens, + PricingBand: row.PricingBand, BucketMS: usage.AnalyticsBucketMS(row.BucketMS, granularity, location), Model: row.Model, BillingModel: row.BillingModel, @@ -378,10 +440,12 @@ func analyticsTimelineFromRows(rows []store.UsageHourlyAggregateRow, granularity func addAnalyticsTimelinePoint(grouped map[analyticsTimelineKey]*analyticsTimelineAccumulator, point store.TimelinePoint, latencySumMS int64) { mapKey := analyticsTimelineKey{ - bucketMS: point.BucketMS, - model: point.Model, - billingModel: point.BillingModel, - serviceTier: point.ServiceTier, + bucketMS: point.BucketMS, + model: point.Model, + billingModel: point.BillingModel, + pricingModel: point.PricingModel, + serviceTier: point.ServiceTier, + contextThresholdTokens: point.ContextThresholdTokens, } entry := grouped[mapKey] if entry == nil { @@ -429,7 +493,13 @@ func sortedAnalyticsTimeline(grouped map[analyticsTimelineKey]*analyticsTimeline if result[i].BillingModel != result[j].BillingModel { return result[i].BillingModel < result[j].BillingModel } - return result[i].ServiceTier < result[j].ServiceTier + if result[i].PricingModel != result[j].PricingModel { + return result[i].PricingModel < result[j].PricingModel + } + if result[i].ServiceTier != result[j].ServiceTier { + return result[i].ServiceTier < result[j].ServiceTier + } + return result[i].ContextThresholdTokens < result[j].ContextThresholdTokens }) return result } diff --git a/apps/manager-server/internal/service/usagehourly/reader_test.go b/apps/manager-server/internal/service/usagehourly/reader_test.go index d4f3f968b..054eb2b5f 100644 --- a/apps/manager-server/internal/service/usagehourly/reader_test.go +++ b/apps/manager-server/internal/service/usagehourly/reader_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" ) @@ -222,8 +223,9 @@ func TestReaderPreservesEmptyLiteralDashAndWhitespaceModels(t *testing.T) { } func TestAnalyticsTimelineAccumulatesLatencyBeforeAveraging(t *testing.T) { - rows := []store.UsageHourlyAggregateRow{ + rows := []store.UsagePricingHourlyRow{ { + PricingBand: usage.PricingBand{PricingModel: "model-a", ContextThresholdTokens: model.ModelPriceBaseContextThreshold}, BucketMS: 0, Model: "model-a", BillingModel: "model-a", @@ -232,6 +234,7 @@ func TestAnalyticsTimelineAccumulatesLatencyBeforeAveraging(t *testing.T) { LatencySamples: 1, }, { + PricingBand: usage.PricingBand{PricingModel: "model-a", ContextThresholdTokens: model.ModelPriceBaseContextThreshold}, BucketMS: hourMS, Model: "model-a", BillingModel: "model-a", @@ -241,7 +244,7 @@ func TestAnalyticsTimelineAccumulatesLatencyBeforeAveraging(t *testing.T) { }, } - points := analyticsTimelineFromRows(rows, "day", time.UTC) + points := analyticsTimelineFromPricingRows(rows, "day", time.UTC) if len(points) != 1 { t.Fatalf("timeline points = %#v, want one point", points) } @@ -261,7 +264,13 @@ func sortTimelinePoints(points []store.TimelinePoint) { if points[i].BillingModel != points[j].BillingModel { return points[i].BillingModel < points[j].BillingModel } - return points[i].ServiceTier < points[j].ServiceTier + if points[i].PricingModel != points[j].PricingModel { + return points[i].PricingModel < points[j].PricingModel + } + if points[i].ServiceTier != points[j].ServiceTier { + return points[i].ServiceTier < points[j].ServiceTier + } + return points[i].ContextThresholdTokens < points[j].ContextThresholdTokens }) } @@ -304,6 +313,15 @@ func catchUpReaderRollup(t *testing.T, ctx context.Context, db *store.Store) { if err != nil { t.Fatalf("catch up rollup: %v", err) } + if !result.Pending { + break + } + } + for { + result, err := db.CatchUpUsagePricing(ctx, 100, time.Now().UnixMilli()) + if err != nil { + t.Fatalf("catch up pricing rollup: %v", err) + } if !result.Pending { return } From 8d6366cb238db0e8a2944de6c067773ad788cafc Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:10:52 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20run?= =?UTF-8?q?=20usage=20pricing=20rollups=20continuously?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start a pricing rollup worker and wake it after usage, migration, and price changes. Integrate bounded catch-up and failure recording into the collector fanout. This keeps pricing aggregates current without blocking request processing. --- .../cmd/cpa-manager-plus/main.go | 6 + .../internal/worker/usage_pricing_rollup.go | 96 +++++++++++ .../worker/usage_pricing_rollup_test.go | 157 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 apps/manager-server/internal/worker/usage_pricing_rollup.go create mode 100644 apps/manager-server/internal/worker/usage_pricing_rollup_test.go diff --git a/apps/manager-server/cmd/cpa-manager-plus/main.go b/apps/manager-server/cmd/cpa-manager-plus/main.go index 7970790f8..1deaf5b0e 100644 --- a/apps/manager-server/cmd/cpa-manager-plus/main.go +++ b/apps/manager-server/cmd/cpa-manager-plus/main.go @@ -103,6 +103,9 @@ func runServer() { accountActionWorker := worker.NewAccountActionCandidateWorker(db, runtimeSettings.AccountActionsAutoDisable) accountHistoryRollupWorker := worker.NewAccountHistoryRollupWorker(db) accountHistoryRollupWorker.Start(ctx) + usagePricingRollupWorker := worker.NewUsagePricingRollupWorker(db) + usagePricingRollupWorker.Start(ctx) + serverApp.AppContext().ModelPriceService.SetPricesChangedNotifier(usagePricingRollupWorker.Wake) var usageHourlyAggregateWorker *worker.UsageHourlyAggregateWorker if cfg.DashboardHourlyRollupEnabled { usageHourlyAggregateWorker = worker.NewUsageHourlyAggregateWorker(db) @@ -110,6 +113,7 @@ func runServer() { } serverApp.AppContext().UsageService.SetEventsInsertedNotifier(func() { accountHistoryRollupWorker.Wake() + usagePricingRollupWorker.Wake() if usageHourlyAggregateWorker != nil { usageHourlyAggregateWorker.Wake() } @@ -125,6 +129,7 @@ func runServer() { manager.SetUsageEventHandler(worker.NewUsageEventFanout( automationRuntime.UsageEventHandler(), accountHistoryRollupWorker, + usagePricingRollupWorker, usageHourlyAggregateWorker, )) @@ -167,6 +172,7 @@ func runServer() { usageCacheAccountingMigrationWorker := worker.NewUsageCacheAccountingMigrationWorker(db, func() { go runUsageResponseMetadataBackfill(ctx, db) accountHistoryRollupWorker.Wake() + usagePricingRollupWorker.Wake() if usageHourlyAggregateWorker != nil { usageHourlyAggregateWorker.Wake() } diff --git a/apps/manager-server/internal/worker/usage_pricing_rollup.go b/apps/manager-server/internal/worker/usage_pricing_rollup.go new file mode 100644 index 000000000..5ca17a5ad --- /dev/null +++ b/apps/manager-server/internal/worker/usage_pricing_rollup.go @@ -0,0 +1,96 @@ +package worker + +import ( + "context" + "log" + "sync/atomic" + "time" + + collectorpkg "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/collector" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +const ( + defaultUsagePricingBatchLimit = 1000 + defaultUsagePricingMaxBatches = 10 + defaultUsagePricingCheckInterval = 30 * time.Second +) + +type UsagePricingRollupWorker struct { + store *store.Store + wake chan struct{} + running int32 + batchLimit int + maxBatches int + checkInterval time.Duration + continuationDelay time.Duration +} + +func NewUsagePricingRollupWorker(store *store.Store) *UsagePricingRollupWorker { + return &UsagePricingRollupWorker{ + store: store, + wake: make(chan struct{}, 1), + batchLimit: defaultUsagePricingBatchLimit, + maxBatches: defaultUsagePricingMaxBatches, + checkInterval: defaultUsagePricingCheckInterval, + continuationDelay: defaultRollupContinuationDelay, + } +} + +func (w *UsagePricingRollupWorker) Start(ctx context.Context) { + if w == nil || w.store == nil { + return + } + go w.loop(ctx) + w.Wake() +} + +func (w *UsagePricingRollupWorker) HandleUsageEvents(ctx context.Context, _ collectorpkg.RuntimeConfig, events []usage.Event) { + if w == nil || len(events) == 0 || ctx.Err() != nil { + return + } + w.Wake() +} + +func (w *UsagePricingRollupWorker) Wake() { + if w == nil { + return + } + select { + case w.wake <- struct{}{}: + default: + } +} + +func (w *UsagePricingRollupWorker) loop(ctx context.Context) { + runRollupLoop(ctx, w.wake, w.checkInterval, w.continuationDelay, w.catchUp) +} + +func (w *UsagePricingRollupWorker) catchUp(ctx context.Context) bool { + if !atomic.CompareAndSwapInt32(&w.running, 0, 1) { + return false + } + defer atomic.StoreInt32(&w.running, 0) + + pending := false + for batch := 0; batch < w.maxBatches; batch++ { + if ctx.Err() != nil { + return false + } + nowMS := time.Now().UnixMilli() + result, err := w.store.CatchUpUsagePricing(ctx, w.batchLimit, nowMS) + if err != nil { + log.Printf("[usage-pricing] catch-up failed: %v", err) + if recordErr := w.store.RecordUsagePricingFailure(ctx, err, nowMS); recordErr != nil && ctx.Err() == nil { + log.Printf("[usage-pricing] record catch-up failure: %v", recordErr) + } + return false + } + pending = result.Pending + if result.Processed == 0 || !result.Pending { + return false + } + } + return pending +} diff --git a/apps/manager-server/internal/worker/usage_pricing_rollup_test.go b/apps/manager-server/internal/worker/usage_pricing_rollup_test.go new file mode 100644 index 000000000..a2c84f455 --- /dev/null +++ b/apps/manager-server/internal/worker/usage_pricing_rollup_test.go @@ -0,0 +1,157 @@ +package worker + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +func TestUsagePricingRollupWorkerCatchUp(t *testing.T) { + db := newUsagePricingRollupWorkerStore(t) + ctx := context.Background() + timestampMS := int64(1_800_000_001_000) + if err := db.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "gpt-a": { + Prompt: 1, + ContextTiers: []store.ModelPriceContextTier{ + {ThresholdTokens: 100, Prompt: 2, PromptConfigured: true}, + }, + }, + }); err != nil { + t.Fatalf("save model prices: %v", err) + } + if _, err := db.InsertEvents(ctx, []usage.Event{usagePricingRollupWorkerEvent( + "usage-pricing-worker-event", + timestampMS, + 150, + )}); err != nil { + t.Fatalf("insert event: %v", err) + } + + worker := NewUsagePricingRollupWorker(db) + worker.batchLimit = 10 + worker.maxBatches = 2 + if pending := worker.catchUp(ctx); pending { + t.Fatal("completed catch-up reported pending work") + } + + rows, state, available, err := db.UsagePricingHourlyRows(ctx, store.UsagePricingHourlyFilter{ + FromMS: timestampMS - timestampMS%hourWindowMS, + ToMS: timestampMS - timestampMS%hourWindowMS + hourWindowMS, + IncludeFailed: true, + }) + if err != nil { + t.Fatalf("query pricing rollup: %v", err) + } + if !available || state.Status != "ready" || state.CoverageEventID != 1 { + t.Fatalf("pricing state = available:%v state:%#v", available, state) + } + if len(rows) != 1 || rows[0].Calls != 1 || rows[0].InputTokens != 150 || rows[0].ContextThresholdTokens != 100 { + t.Fatalf("pricing rows = %#v", rows) + } +} + +func TestUsagePricingRollupWorkerContinuesPendingBacklog(t *testing.T) { + db := newUsagePricingRollupWorkerStore(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + baseMS := int64(1_800_000_000_000) + events := make([]usage.Event, 0, 5) + for index := 0; index < 5; index++ { + events = append(events, usagePricingRollupWorkerEvent( + fmt.Sprintf("usage-pricing-worker-backlog-%d", index), + baseMS+int64(index)*1000, + int64(index+1), + )) + } + if _, err := db.InsertEvents(ctx, events); err != nil { + t.Fatalf("insert events: %v", err) + } + + worker := NewUsagePricingRollupWorker(db) + worker.batchLimit = 1 + worker.maxBatches = 1 + worker.checkInterval = time.Hour + worker.continuationDelay = time.Millisecond + worker.Start(ctx) + + deadline := time.Now().Add(2 * time.Second) + for { + state, err := db.UsagePricingState(ctx) + if err != nil { + t.Fatalf("pricing state: %v", err) + } + if state.CoverageEventID == 5 && state.Status == "ready" { + break + } + if time.Now().After(deadline) { + t.Fatalf("backlog did not continue: state=%#v", state) + } + time.Sleep(5 * time.Millisecond) + } +} + +func TestUsagePricingRollupWorkerRecordsFailure(t *testing.T) { + sqlDB, err := sqliterepo.Open(filepath.Join(t.TempDir(), "usage.sqlite")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + db := store.New(sqlDB) + ctx := context.Background() + baseMS := int64(1_800_000_000_000) + if _, err := db.InsertEvents(ctx, []usage.Event{ + usagePricingRollupWorkerEvent("usage-pricing-worker-failure", baseMS, 1), + }); err != nil { + t.Fatalf("insert event: %v", err) + } + if _, err := sqlDB.ExecContext(ctx, `drop table usage_pricing_hourly_rollups_v1`); err != nil { + t.Fatalf("drop pricing rollup fixture: %v", err) + } + + worker := NewUsagePricingRollupWorker(db) + worker.catchUp(ctx) + state, err := db.UsagePricingState(ctx) + if err != nil { + t.Fatalf("pricing state: %v", err) + } + if state.Status != "failed" || state.LastError == "" { + t.Fatalf("failure state = %#v", state) + } + if ctx.Err() != nil { + t.Fatalf("worker failure unexpectedly canceled context: %v", ctx.Err()) + } +} + +func newUsagePricingRollupWorkerStore(t *testing.T) *store.Store { + t.Helper() + db, err := store.Open(filepath.Join(t.TempDir(), "usage.sqlite")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func usagePricingRollupWorkerEvent(hash string, timestampMS, inputTokens int64) usage.Event { + return usage.Event{ + EventHash: hash, + TimestampMS: timestampMS, + Timestamp: time.UnixMilli(timestampMS).UTC().Format(time.RFC3339Nano), + Model: "gpt-a", + ResolvedModel: "gpt-a", + Endpoint: "POST /v1/chat/completions", + Method: "POST", + Path: "/v1/chat/completions", + InputTokens: inputTokens, + OutputTokens: 5, + TotalTokens: inputTokens + 5, + CreatedAtMS: timestampMS, + } +} From 6312a0c7cbbbb3dad04b2441ae022ac0b5e855e5 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:11:18 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20apply?= =?UTF-8?q?=20tier-aware=20costs=20to=20dashboard=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use consistent price snapshots and classified rollups when calculating dashboard costs. Handle Priority/Fast, Flex/Batch, and long-context bands without collapsing mixed aggregates. Add correctness and performance coverage for dashboard refresh paths. --- .../internal/service/dashboard/service.go | 49 +++++--- .../dashboard/service_benchmark_test.go | 22 +++- .../service/dashboard/service_test.go | 107 +++++++++++++++++- 3 files changed, 156 insertions(+), 22 deletions(-) diff --git a/apps/manager-server/internal/service/dashboard/service.go b/apps/manager-server/internal/service/dashboard/service.go index 467a0f784..9eeced09d 100644 --- a/apps/manager-server/internal/service/dashboard/service.go +++ b/apps/manager-server/internal/service/dashboard/service.go @@ -215,6 +215,16 @@ type SummaryResponse struct { } func (s *Service) Summary(ctx context.Context, p SummaryParams) (SummaryResponse, error) { + var response SummaryResponse + err := s.store.WithModelPriceSnapshot(func() error { + var summaryErr error + response, summaryErr = s.summary(ctx, p) + return summaryErr + }) + return response, err +} + +func (s *Service) summary(ctx context.Context, p SummaryParams) (SummaryResponse, error) { if p.TodayStartMS <= 0 { return SummaryResponse{}, errors.New("today_start_ms is required") } @@ -278,7 +288,7 @@ func (s *Service) Summary(ctx context.Context, p SummaryParams) (SummaryResponse runQuery(func() error { var err error - todayAgg, modelStats, topStats, timeline, err = s.loadTodayMetrics(queryCtx, p.TodayStartMS, nowMS, topLimit) + todayAgg, modelStats, topStats, timeline, prices, err = s.loadTodayMetrics(queryCtx, p.TodayStartMS, nowMS, topLimit) return err }) runQuery(func() error { @@ -291,11 +301,6 @@ func (s *Service) Summary(ctx context.Context, p SummaryParams) (SummaryResponse recentFailures, err = s.store.RecentFailuresBetween(queryCtx, p.TodayStartMS, nowMS, recentLimit) return err }) - runQuery(func() error { - var err error - prices, err = s.store.LoadModelPrices(queryCtx) - return err - }) runQuery(func() error { var err error healthTimelinePoints, err = s.store.BucketTimelineBetween(queryCtx, p.TodayStartMS, nowMS, healthTimelineBucketMs) @@ -340,40 +345,44 @@ func (s *Service) Summary(ctx context.Context, p SummaryParams) (SummaryResponse }, nil } -func (s *Service) loadTodayMetrics(ctx context.Context, fromMS, toMS int64, topLimit int) (store.Aggregate, []store.ModelStat, []store.ModelStat, []store.TimelinePoint, error) { - if agg, modelStats, timeline, ok := s.loadTodayMetricsFromRollup(ctx, fromMS, toMS); ok { - return agg, modelStats, selectTopModelStats(modelStats, topLimit), timeline, nil +func (s *Service) loadTodayMetrics(ctx context.Context, fromMS, toMS int64, topLimit int) (store.Aggregate, []store.ModelStat, []store.ModelStat, []store.TimelinePoint, map[string]store.ModelPrice, error) { + if agg, modelStats, timeline, prices, ok := s.loadTodayMetricsFromRollup(ctx, fromMS, toMS); ok { + return agg, modelStats, selectTopModelStats(modelStats, topLimit), timeline, prices, nil } agg, err := s.store.AggregateBetween(ctx, fromMS, toMS) if err != nil { - return store.Aggregate{}, nil, nil, nil, err + return store.Aggregate{}, nil, nil, nil, nil, err } modelStats, err := s.store.ModelStatsBetween(ctx, fromMS, toMS) if err != nil { - return store.Aggregate{}, nil, nil, nil, err + return store.Aggregate{}, nil, nil, nil, nil, err } topStats, err := s.store.TopModelsBetween(ctx, fromMS, toMS, topLimit) if err != nil { - return store.Aggregate{}, nil, nil, nil, err + return store.Aggregate{}, nil, nil, nil, nil, err } timeline, err := s.store.HourlyTimelineBetween(ctx, fromMS, toMS) if err != nil { - return store.Aggregate{}, nil, nil, nil, err + return store.Aggregate{}, nil, nil, nil, nil, err + } + prices, err := s.store.LoadModelPrices(ctx) + if err != nil { + return store.Aggregate{}, nil, nil, nil, nil, err } - return agg, modelStats, topStats, timeline, nil + return agg, modelStats, topStats, timeline, prices, nil } -func (s *Service) loadTodayMetricsFromRollup(ctx context.Context, fromMS, toMS int64) (store.Aggregate, []store.ModelStat, []store.TimelinePoint, bool) { +func (s *Service) loadTodayMetricsFromRollup(ctx context.Context, fromMS, toMS int64) (store.Aggregate, []store.ModelStat, []store.TimelinePoint, map[string]store.ModelPrice, bool) { snapshot, ok := s.hourlyReader.Load(ctx, fromMS, toMS) if !ok { - return store.Aggregate{}, nil, nil, false + return store.Aggregate{}, nil, nil, nil, false } timeline, ok := s.hourlyReader.DashboardTimeline(ctx, snapshot, fromMS, toMS) if !ok { - return store.Aggregate{}, nil, nil, false + return store.Aggregate{}, nil, nil, nil, false } - return snapshot.Aggregate, snapshot.ModelStats, timeline, true + return snapshot.Aggregate, snapshot.ModelStats, timeline, snapshot.Prices, true } func selectTopModelStats(stats []store.ModelStat, limit int) []store.ModelStat { @@ -814,6 +823,8 @@ func aggregateModelStats(stats []store.ModelStat, prices map[string]store.ModelP func costForStat(stat store.ModelStat, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ + PricingModel: stat.PricingModel, + ContextThresholdTokens: stat.ContextThresholdTokens, InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -829,6 +840,8 @@ func costForStat(stat store.ModelStat, prices map[string]store.ModelPrice) float func costForChannelStat(stat store.ChannelModelStat, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ + PricingModel: stat.PricingModel, + ContextThresholdTokens: stat.ContextThresholdTokens, InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, diff --git a/apps/manager-server/internal/service/dashboard/service_benchmark_test.go b/apps/manager-server/internal/service/dashboard/service_benchmark_test.go index 4186d007a..226a2764e 100644 --- a/apps/manager-server/internal/service/dashboard/service_benchmark_test.go +++ b/apps/manager-server/internal/service/dashboard/service_benchmark_test.go @@ -27,7 +27,7 @@ func BenchmarkDashboardTodayMetrics(b *testing.B) { b.Run("raw_events_100k", func(b *testing.B) { b.ReportAllocs() for index := 0; index < b.N; index++ { - if _, _, _, _, err := service.loadTodayMetrics(ctx, todayStart, nowMS, 5); err != nil { + if _, _, _, _, _, err := service.loadTodayMetrics(ctx, todayStart, nowMS, 5); err != nil { b.Fatalf("load raw metrics: %v", err) } } @@ -42,11 +42,20 @@ func BenchmarkDashboardTodayMetrics(b *testing.B) { break } } + for { + result, err := db.CatchUpUsagePricing(ctx, 5_000, time.Now().UnixMilli()) + if err != nil { + b.Fatalf("catch up dashboard pricing rollup: %v", err) + } + if !result.Pending { + break + } + } b.Run("hourly_rollup_100k", func(b *testing.B) { b.ReportAllocs() for index := 0; index < b.N; index++ { - if _, _, _, _, err := service.loadTodayMetrics(ctx, todayStart, nowMS, 5); err != nil { + if _, _, _, _, _, err := service.loadTodayMetrics(ctx, todayStart, nowMS, 5); err != nil { b.Fatalf("load rollup metrics: %v", err) } } @@ -112,6 +121,15 @@ func BenchmarkDashboardMonitoringRefreshPaths(b *testing.B) { break } } + for { + result, err := db.CatchUpUsagePricing(ctx, 10_000, nowMS) + if err != nil { + b.Fatalf("catch up dashboard pricing rollup: %v", err) + } + if !result.Pending { + break + } + } dashboardService := New(db) monitoringService := monitoringsvc.New(db, true) diff --git a/apps/manager-server/internal/service/dashboard/service_test.go b/apps/manager-server/internal/service/dashboard/service_test.go index 42901b154..e6a3d8cda 100644 --- a/apps/manager-server/internal/service/dashboard/service_test.go +++ b/apps/manager-server/internal/service/dashboard/service_test.go @@ -397,6 +397,100 @@ func TestSummaryPricesPriorityAndDefaultServiceTiersSeparately(t *testing.T) { } } +func TestSummaryPricesContextTiersAcrossRawAndPricingRollup(t *testing.T) { + db := newDashboardTestStore(t) + ctx := context.Background() + todayStart := int64(1_800_000_000_000) + nowMS := todayStart + 2*hourWindowMs + + if err := db.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "tiered-resolved": { + Prompt: 10, + Completion: 4, + ContextTiers: []store.ModelPriceContextTier{ + { + ThresholdTokens: 100_000, + Prompt: 20, + PromptConfigured: true, + }, + { + ThresholdTokens: 200_000, + Prompt: 0, + Completion: 8, + PromptConfigured: true, + CompletionConfigured: true, + }, + }, + }, + }); err != nil { + t.Fatalf("save prices: %v", err) + } + + events := []usage.Event{ + dashboardEvent("context-tier-exact", todayStart+1_000, "tiered-alias", false, 100_000, 100_000, 0, 0, 0, 200_000, nil), + dashboardEvent("context-tier-first", todayStart+2_000, "tiered-alias", false, 100_001, 100_000, 0, 0, 0, 200_001, nil), + dashboardEvent("context-tier-highest", todayStart+3_000, "tiered-alias", false, 200_001, 100_000, 0, 0, 0, 300_001, nil), + } + for index := range events { + events[index].ResolvedModel = "tiered-resolved" + } + if _, err := db.InsertEvents(ctx, events); err != nil { + t.Fatalf("insert events: %v", err) + } + + const wantCost = 4.60002 + assertCost := func(name string, got float64) { + t.Helper() + if math.Abs(got-wantCost) > 0.000001 { + t.Fatalf("%s cost = %v, want %v", name, got, wantCost) + } + } + assertSummary := func(name string, resp SummaryResponse) { + t.Helper() + if resp.Today.TotalCalls != 3 { + t.Fatalf("%s today = %#v", name, resp.Today) + } + assertCost(name+" today", resp.Today.TotalCost) + if len(resp.TopModelsToday) != 1 || resp.TopModelsToday[0].Calls != 3 { + t.Fatalf("%s top models = %#v", name, resp.TopModelsToday) + } + assertCost(name+" top model", resp.TopModelsToday[0].Cost) + if len(resp.ModelCostRank) != 1 { + t.Fatalf("%s model cost rank = %#v", name, resp.ModelCostRank) + } + assertCost(name+" model rank", resp.ModelCostRank[0].Cost) + if len(resp.ChannelHealth) != 1 { + t.Fatalf("%s channel health = %#v", name, resp.ChannelHealth) + } + assertCost(name+" channel", resp.ChannelHealth[0].Cost) + } + + raw, err := New(db, false).Summary(ctx, SummaryParams{ + TodayStartMS: todayStart, + NowMS: nowMS, + TopModels: 5, + }) + if err != nil { + t.Fatalf("raw summary: %v", err) + } + assertSummary("raw", raw) + + catchUpDashboardHourlyForTest(t, ctx, db) + service := New(db, true) + if _, _, _, _, ok := service.loadTodayMetricsFromRollup(ctx, todayStart, nowMS); !ok { + t.Fatal("pricing-aware dashboard rollup was not available") + } + rolled, err := service.Summary(ctx, SummaryParams{ + TodayStartMS: todayStart, + NowMS: nowMS, + TopModels: 5, + }) + if err != nil { + t.Fatalf("rolled summary: %v", err) + } + assertSummary("rolled", rolled) +} + func TestSummaryDashboardHourlyRollupMatchesRawWithTrailingEdge(t *testing.T) { db := newDashboardTestStore(t) ctx := context.Background() @@ -496,7 +590,7 @@ func TestSummaryDashboardHourlyRollupMergesPendingRawDelta(t *testing.T) { }); err != nil { t.Fatalf("insert pending event: %v", err) } - agg, _, timeline, ok := New(db).loadTodayMetricsFromRollup(ctx, todayStart, nowMS) + agg, _, timeline, _, ok := New(db).loadTodayMetricsFromRollup(ctx, todayStart, nowMS) if !ok || agg.TotalCalls != 2 || agg.TotalTokens != 3 || len(timeline) != 2 { t.Fatalf("pending aggregate did not merge raw delta: ok=%v agg=%#v timeline=%#v", ok, agg, timeline) } @@ -521,7 +615,7 @@ func TestSummaryDashboardHourlyRollupCanBeDisabled(t *testing.T) { } catchUpDashboardHourlyForTest(t, ctx, db) service := New(db, false) - if _, _, _, ok := service.loadTodayMetricsFromRollup(ctx, todayStart, nowMS); ok { + if _, _, _, _, ok := service.loadTodayMetricsFromRollup(ctx, todayStart, nowMS); ok { t.Fatal("disabled service used hourly rollup") } resp, err := service.Summary(ctx, SummaryParams{TodayStartMS: todayStart, NowMS: nowMS}) @@ -540,6 +634,15 @@ func catchUpDashboardHourlyForTest(t *testing.T, ctx context.Context, db *store. if err != nil { t.Fatalf("catch up dashboard hourly: %v", err) } + if !result.Pending { + break + } + } + for { + result, err := db.CatchUpUsagePricing(ctx, 100, time.Now().UnixMilli()) + if err != nil { + t.Fatalf("catch up dashboard pricing: %v", err) + } if !result.Pending { return } From 96f59257088f477512e729888678cfe7e18b649e Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:11:18 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E2=9C=A8=20feat(manager-server):=20apply?= =?UTF-8?q?=20tier-aware=20costs=20to=20monitoring=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Price monitoring timelines, model totals, and account history from classified pricing bands. Use snapshot-safe price reads and durable account rollups for large histories. Add regression and benchmark coverage for mixed service tiers and context lengths. --- .../account_history_benchmark_test.go | 34 ++-- .../internal/service/monitoring/service.go | 111 +++++++++++- .../monitoring/service_benchmark_test.go | 62 +++++-- .../service/monitoring/service_test.go | 170 ++++++++++++++++++ 4 files changed, 340 insertions(+), 37 deletions(-) diff --git a/apps/manager-server/internal/service/monitoring/account_history_benchmark_test.go b/apps/manager-server/internal/service/monitoring/account_history_benchmark_test.go index 061107e3f..158d06722 100644 --- a/apps/manager-server/internal/service/monitoring/account_history_benchmark_test.go +++ b/apps/manager-server/internal/service/monitoring/account_history_benchmark_test.go @@ -24,11 +24,7 @@ func BenchmarkAccountHistoryServiceRead(b *testing.B) { defer closeStore() saveAccountHistoryBenchmarkPrices(b, ctx, st) insertAccountHistoryBenchmarkEvents(b, ctx, st, accountHistoryBenchmarkEvents("read", 0, 20000)) - if result, err := st.CatchUpAccountHistoryRollups(ctx, 20000, accountHistoryBenchmarkNowMS); err != nil { - b.Fatalf("catch-up: %v", err) - } else if result.Processed != 20000 { - b.Fatalf("processed = %d, want 20000", result.Processed) - } + catchUpAccountHistoryBenchmarkRollups(b, ctx, st, 20000) service := New(st) for _, targetCount := range []int{20, 100, 200} { @@ -58,11 +54,7 @@ func BenchmarkAccountHistoryServiceCatchUp(b *testing.B) { defer closeStore() saveAccountHistoryBenchmarkPrices(b, ctx, st) insertAccountHistoryBenchmarkEvents(b, ctx, st, accountHistoryBenchmarkEvents("catchup-baseline", 0, 10000)) - if result, err := st.CatchUpAccountHistoryRollups(ctx, 10000, accountHistoryBenchmarkNowMS); err != nil { - b.Fatalf("baseline catch-up: %v", err) - } else if result.Processed != 10000 { - b.Fatalf("baseline processed = %d, want 10000", result.Processed) - } + catchUpAccountHistoryBenchmarkRollups(b, ctx, st, 10000) service := New(st) req := AccountHistoryRequest{ Accounts: accountHistoryBenchmarkTargets(100), @@ -110,6 +102,10 @@ func saveAccountHistoryBenchmarkPrices(b *testing.B, ctx context.Context, st *st Cache: 0.5, CacheRead: 0.25, CacheCreation: 1.5, + ContextTiers: []store.ModelPriceContextTier{ + {ThresholdTokens: 128, Prompt: 2, PromptConfigured: true}, + {ThresholdTokens: 176, Prompt: 3, Completion: 4, PromptConfigured: true, CompletionConfigured: true}, + }, } } if err := st.SaveModelPrices(ctx, prices); err != nil { @@ -117,6 +113,24 @@ func saveAccountHistoryBenchmarkPrices(b *testing.B, ctx context.Context, st *st } } +func catchUpAccountHistoryBenchmarkRollups(b *testing.B, ctx context.Context, st *store.Store, wantProcessed int) { + b.Helper() + result, err := st.CatchUpAccountHistoryRollups(ctx, wantProcessed, accountHistoryBenchmarkNowMS) + if err != nil { + b.Fatalf("account-history catch-up: %v", err) + } + if result.Processed != wantProcessed { + b.Fatalf("account-history processed = %d, want %d", result.Processed, wantProcessed) + } + pricingResult, err := st.CatchUpUsagePricing(ctx, wantProcessed, accountHistoryBenchmarkNowMS) + if err != nil { + b.Fatalf("pricing catch-up: %v", err) + } + if pricingResult.Processed != wantProcessed { + b.Fatalf("pricing processed = %d, want %d", pricingResult.Processed, wantProcessed) + } +} + func insertAccountHistoryBenchmarkEvents(b *testing.B, ctx context.Context, st *store.Store, events []usage.Event) { b.Helper() if _, err := st.InsertEvents(ctx, events); err != nil { diff --git a/apps/manager-server/internal/service/monitoring/service.go b/apps/manager-server/internal/service/monitoring/service.go index f64192ecf..9780d73c9 100644 --- a/apps/manager-server/internal/service/monitoring/service.go +++ b/apps/manager-server/internal/service/monitoring/service.go @@ -724,6 +724,16 @@ type EventRow struct { } func (s *Service) Analytics(ctx context.Context, req Request) (Response, error) { + var response Response + err := s.store.WithModelPriceSnapshot(func() error { + var analyticsErr error + response, analyticsErr = s.analytics(ctx, req) + return analyticsErr + }) + return response, err +} + +func (s *Service) analytics(ctx context.Context, req Request) (Response, error) { if req.FromMS <= 0 || req.ToMS <= 0 || req.FromMS >= req.ToMS { return Response{}, errors.New("from_ms and to_ms are required and from_ms must be less than to_ms") } @@ -737,10 +747,7 @@ func (s *Service) Analytics(ctx context.Context, req Request) (Response, error) return Response{}, err } filter := buildFilter(req) - prices, err := s.store.LoadModelPrices(ctx) - if err != nil { - return Response{}, err - } + var prices map[string]store.ModelPrice response := Response{ GeneratedAtMS: time.Now().UnixMilli(), @@ -769,6 +776,14 @@ func (s *Service) Analytics(ctx context.Context, req Request) (Response, error) hourlyTimelineRepresentable, ) } + if hourlySnapshotAvailable { + prices = hourlySnapshot.Prices + } else { + prices, err = s.store.LoadModelPrices(ctx) + if err != nil { + return Response{}, err + } + } var modelStats []store.ModelStat var channelStats []store.ChannelModelStat @@ -1182,6 +1197,16 @@ func (s *Service) Analytics(ctx context.Context, req Request) (Response, error) } func (s *Service) AccountHistory(ctx context.Context, req AccountHistoryRequest) (AccountHistoryResponse, error) { + var response AccountHistoryResponse + err := s.store.WithModelPriceSnapshot(func() error { + var historyErr error + response, historyErr = s.accountHistory(ctx, req) + return historyErr + }) + return response, err +} + +func (s *Service) accountHistory(ctx context.Context, req AccountHistoryRequest) (AccountHistoryResponse, error) { if len(req.Accounts) == 0 { return AccountHistoryResponse{}, errors.New("accounts are required") } @@ -1196,6 +1221,9 @@ func (s *Service) AccountHistory(ctx context.Context, req AccountHistoryRequest) return AccountHistoryResponse{}, err } processed = result.Processed + if _, err := s.store.CatchUpUsagePricing(ctx, accountHistoryCatchUpLimit, generatedAtMS); err != nil { + return AccountHistoryResponse{}, err + } } checkpoint, err := s.store.AccountHistoryRollupCheckpoint(ctx) if err != nil { @@ -1217,15 +1245,21 @@ func (s *Service) AccountHistory(ctx context.Context, req AccountHistoryRequest) keys = append(keys, key) } } - rows, err := s.store.AccountHistoryRollupRows(ctx, keys) + pricingSnapshot, err := s.store.LoadUsagePricingAccountSnapshot(ctx, keys) if err != nil { return AccountHistoryResponse{}, err } - prices, err := s.store.LoadModelPrices(ctx) - if err != nil { - return AccountHistoryResponse{}, err + prices := pricingSnapshot.Prices + var totals map[string]*accountHistoryTotal + if pricingSnapshot.Available { + totals = buildPricingAccountHistoryTotals(pricingSnapshot.Rows, prices) + } else { + rows, err := s.store.AccountHistoryRollupRows(ctx, keys) + if err != nil { + return AccountHistoryResponse{}, err + } + totals = buildAccountHistoryTotals(rows, prices) } - totals := buildAccountHistoryTotals(rows, prices) pending := latestID > checkpoint.LastEventID items := make([]AccountHistoryItem, 0, len(req.Accounts)) for index := range req.Accounts { @@ -3027,6 +3061,47 @@ func buildAccountHistoryTotals(rows []store.AccountHistoryRollupRow, prices map[ return totals } +func buildPricingAccountHistoryTotals(rows []store.UsagePricingAccountRow, prices map[string]store.ModelPrice) map[string]*accountHistoryTotal { + totals := map[string]*accountHistoryTotal{} + for _, row := range rows { + total := totals[row.AccountKey] + if total == nil { + total = &accountHistoryTotal{} + totals[row.AccountKey] = total + } + total.requests += row.Calls + total.successCalls += row.SuccessCalls + total.failureCalls += row.FailureCalls + total.totalTokens += row.TotalTokens + total.cost += pricing.CostForModelCandidatesWithServiceTier( + []string{row.BillingModel, row.Model}, + row.ServiceTier, + pricing.ModelTokens{ + PricingModel: row.PricingModel, + ContextThresholdTokens: row.ContextThresholdTokens, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CachedTokens: row.CachedTokens, + CacheReadTokens: row.CacheReadTokens, + CacheCreationTokens: row.CacheCreationTokens, + LongInputTokens: row.LongInputTokens, + LongOutputTokens: row.LongOutputTokens, + LongCachedTokens: row.LongCachedTokens, + LongCacheReadTokens: row.LongCacheReadTokens, + LongCacheCreationTokens: row.LongCacheCreationTokens, + }, + prices, + ) + if total.firstSeenMS == 0 || (row.FirstSeenMS > 0 && row.FirstSeenMS < total.firstSeenMS) { + total.firstSeenMS = row.FirstSeenMS + } + if row.LastSeenMS > total.lastSeenMS { + total.lastSeenMS = row.LastSeenMS + } + } + return totals +} + func accountHistorySyncStatus(matched bool, pending bool) string { if pending { return "pending" @@ -3054,6 +3129,8 @@ func sumCost(stats []store.ModelStat, prices map[string]store.ModelPrice) float6 func costForStat(stat store.ModelStat, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ + PricingModel: stat.PricingModel, + ContextThresholdTokens: stat.ContextThresholdTokens, InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -3069,6 +3146,8 @@ func costForStat(stat store.ModelStat, prices map[string]store.ModelPrice) float func costForTimelinePoint(point store.TimelinePoint, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{point.BillingModel, point.Model}, point.ServiceTier, pricing.ModelTokens{ + PricingModel: point.PricingModel, + ContextThresholdTokens: point.ContextThresholdTokens, InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, CachedTokens: point.CachedTokens, @@ -3084,6 +3163,8 @@ func costForTimelinePoint(point store.TimelinePoint, prices map[string]store.Mod func costForHeatmapPoint(point store.HeatmapPoint, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{point.BillingModel, point.Model}, point.ServiceTier, pricing.ModelTokens{ + PricingModel: point.PricingModel, + ContextThresholdTokens: point.ContextThresholdTokens, InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, CachedTokens: point.CachedTokens, @@ -3099,6 +3180,8 @@ func costForHeatmapPoint(point store.HeatmapPoint, prices map[string]store.Model func costForChannelStat(stat store.ChannelModelStat, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ + PricingModel: stat.PricingModel, + ContextThresholdTokens: stat.ContextThresholdTokens, InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -3114,6 +3197,8 @@ func costForChannelStat(stat store.ChannelModelStat, prices map[string]store.Mod func costForAccountModelStat(stat store.AccountModelStat, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ + PricingModel: stat.PricingModel, + ContextThresholdTokens: stat.ContextThresholdTokens, InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -3129,6 +3214,8 @@ func costForAccountModelStat(stat store.AccountModelStat, prices map[string]stor func costForAPIKeyModelStat(stat store.APIKeyModelStat, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ + PricingModel: stat.PricingModel, + ContextThresholdTokens: stat.ContextThresholdTokens, InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -3144,6 +3231,8 @@ func costForAPIKeyModelStat(stat store.APIKeyModelStat, prices map[string]store. func costForCredentialModelStat(stat store.CredentialModelStat, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ + PricingModel: stat.PricingModel, + ContextThresholdTokens: stat.ContextThresholdTokens, InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -3159,6 +3248,8 @@ func costForCredentialModelStat(stat store.CredentialModelStat, prices map[strin func costForCredentialTimelinePoint(point store.CredentialTimelinePoint, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{point.BillingModel, point.Model}, point.ServiceTier, pricing.ModelTokens{ + PricingModel: point.PricingModel, + ContextThresholdTokens: point.ContextThresholdTokens, InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, CachedTokens: point.CachedTokens, @@ -3174,6 +3265,8 @@ func costForCredentialTimelinePoint(point store.CredentialTimelinePoint, prices func costForAPIKeyTimelinePoint(point store.APIKeyTimelinePoint, prices map[string]store.ModelPrice) float64 { return pricing.CostForModelCandidatesWithServiceTier([]string{point.BillingModel, point.Model}, point.ServiceTier, pricing.ModelTokens{ + PricingModel: point.PricingModel, + ContextThresholdTokens: point.ContextThresholdTokens, InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, CachedTokens: point.CachedTokens, diff --git a/apps/manager-server/internal/service/monitoring/service_benchmark_test.go b/apps/manager-server/internal/service/monitoring/service_benchmark_test.go index 15707bfce..acb04b4ff 100644 --- a/apps/manager-server/internal/service/monitoring/service_benchmark_test.go +++ b/apps/manager-server/internal/service/monitoring/service_benchmark_test.go @@ -22,16 +22,9 @@ func BenchmarkUsageAnalyticsIncludeProfiles(b *testing.B) { ctx := context.Background() fromMS := int64(1_800_000_000_000) toMS := fromMS + 30*24*60*60*1000 + saveMonitoringBenchmarkPrices(b, ctx, db) insertMonitoringBenchmarkEvents(b, ctx, db, fromMS, toMS, 100_000) - for { - result, err := db.CatchUpUsageHourlyAggregate(ctx, 5_000, toMS) - if err != nil { - b.Fatalf("catch up hourly rollup: %v", err) - } - if !result.Pending { - break - } - } + catchUpMonitoringBenchmarkRollups(b, ctx, db, toMS) rawService := New(db, false) rollupService := New(db, true) @@ -361,16 +354,9 @@ func BenchmarkUsageAnalyticsHourlyCorePaths(b *testing.B) { ctx := context.Background() fromMS := int64(1_800_000_000_000) toMS := fromMS + 30*24*60*60*1000 + saveMonitoringBenchmarkPrices(b, ctx, db) insertMonitoringBenchmarkEvents(b, ctx, db, fromMS, toMS, 100_000) - for { - result, err := db.CatchUpUsageHourlyAggregate(ctx, 5_000, toMS) - if err != nil { - b.Fatalf("catch up hourly rollup: %v", err) - } - if !result.Pending { - break - } - } + catchUpMonitoringBenchmarkRollups(b, ctx, db, toMS) filter := store.AnalyticsFilter{FromMS: fromMS, ToMS: toMS, IncludeFailed: true} reader := usagehourly.New(db, true) @@ -424,6 +410,46 @@ func BenchmarkUsageAnalyticsHourlyCorePaths(b *testing.B) { }) } +func saveMonitoringBenchmarkPrices(b *testing.B, ctx context.Context, db *store.Store) { + b.Helper() + prices := make(map[string]store.ModelPrice, 12) + for index := 0; index < 12; index++ { + prices[fmt.Sprintf("gpt-%02d", index)] = store.ModelPrice{ + Prompt: 1, + Completion: 2, + ContextTiers: []store.ModelPriceContextTier{ + {ThresholdTokens: 128, Prompt: 2, PromptConfigured: true}, + {ThresholdTokens: 256, Prompt: 3, Completion: 4, PromptConfigured: true, CompletionConfigured: true}, + }, + } + } + if err := db.SaveModelPrices(ctx, prices); err != nil { + b.Fatalf("save model prices: %v", err) + } +} + +func catchUpMonitoringBenchmarkRollups(b *testing.B, ctx context.Context, db *store.Store, nowMS int64) { + b.Helper() + for { + result, err := db.CatchUpUsageHourlyAggregate(ctx, 5_000, nowMS) + if err != nil { + b.Fatalf("catch up hourly rollup: %v", err) + } + if !result.Pending { + break + } + } + for { + result, err := db.CatchUpUsagePricing(ctx, 5_000, nowMS) + if err != nil { + b.Fatalf("catch up pricing rollup: %v", err) + } + if !result.Pending { + return + } + } +} + func insertMonitoringBenchmarkEvents(b *testing.B, ctx context.Context, db *store.Store, fromMS, toMS int64, count int) { b.Helper() const batchSize = 1000 diff --git a/apps/manager-server/internal/service/monitoring/service_test.go b/apps/manager-server/internal/service/monitoring/service_test.go index c92ce13ed..dadd9a84b 100644 --- a/apps/manager-server/internal/service/monitoring/service_test.go +++ b/apps/manager-server/internal/service/monitoring/service_test.go @@ -981,6 +981,107 @@ func TestAnalyticsPricesPriorityAndDefaultServiceTiersSeparately(t *testing.T) { assertCost("api key model stats", resp.APIKeyStats[0].Models[0].Cost) } +func TestAnalyticsFilteredPricingUsesStrictHighestContextTier(t *testing.T) { + db := newMonitoringTestStore(t) + ctx := context.Background() + fromMS := int64(1_800_010_000_000) + toMS := fromMS + time.Hour.Milliseconds() + + if err := db.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "tiered-resolved": { + Prompt: 10, + Completion: 4, + ContextTiers: []store.ModelPriceContextTier{ + { + ThresholdTokens: 100_000, + Prompt: 20, + PromptConfigured: true, + }, + { + ThresholdTokens: 200_000, + Prompt: 0, + Completion: 8, + PromptConfigured: true, + CompletionConfigured: true, + }, + }, + }, + }); err != nil { + t.Fatalf("save prices: %v", err) + } + + events := []usage.Event{ + monitoringEvent("filtered-context-exact", fromMS+1_000, "tiered-alias", "auth-tiered", "source-tiered", false, 100_000, 100_000, 0, 0, 200_000, nil), + monitoringEvent("filtered-context-first", fromMS+2_000, "tiered-alias", "auth-tiered", "source-tiered", false, 100_001, 100_000, 0, 0, 200_001, nil), + monitoringEvent("filtered-context-highest", fromMS+3_000, "tiered-alias", "auth-tiered", "source-tiered", false, 200_001, 100_000, 0, 0, 300_001, nil), + monitoringEvent("filtered-context-excluded", fromMS+4_000, "tiered-alias", "auth-other", "source-other", false, 1_000_000, 0, 0, 0, 1_000_000, nil), + } + for index := range events { + events[index].ResolvedModel = "tiered-resolved" + events[index].AccountSnapshot = "tier-team@example.com" + events[index].AuthLabelSnapshot = "Tier Team" + events[index].APIKeyHash = "tier-client-key" + } + if _, err := db.InsertEvents(ctx, events); err != nil { + t.Fatalf("insert events: %v", err) + } + + resp, err := New(db, true).Analytics(ctx, Request{ + FromMS: fromMS, + ToMS: toMS, + Filters: Filters{ + AuthIndices: []string{"auth-tiered"}, + }, + Include: Include{ + Summary: true, + Timeline: true, + ModelShare: true, + ModelStats: true, + ChannelShare: true, + AccountStats: true, + APIKeyStats: true, + }, + }) + if err != nil { + t.Fatalf("filtered analytics: %v", err) + } + + const wantCost = 4.60002 + assertCost := func(name string, got float64) { + t.Helper() + if math.Abs(got-wantCost) > 0.000001 { + t.Fatalf("%s cost = %v, want %v", name, got, wantCost) + } + } + if resp.Summary == nil || resp.Summary.TotalCalls != 3 { + t.Fatalf("summary = %#v", resp.Summary) + } + assertCost("summary", resp.Summary.TotalCost) + if len(resp.Timeline) != 1 || resp.Timeline[0].Calls != 3 { + t.Fatalf("timeline = %#v", resp.Timeline) + } + assertCost("timeline", resp.Timeline[0].Cost) + if len(resp.ModelStats) != 1 || resp.ModelStats[0].Calls != 3 || len(resp.ModelShare) != 1 { + t.Fatalf("model rows = %#v / %#v", resp.ModelStats, resp.ModelShare) + } + assertCost("model stats", resp.ModelStats[0].Cost) + assertCost("model share", resp.ModelShare[0].Cost) + if len(resp.ChannelShare) != 1 || resp.ChannelShare[0].AuthIndex != "auth-tiered" { + t.Fatalf("channel share = %#v", resp.ChannelShare) + } + assertCost("channel share", resp.ChannelShare[0].Cost) + if len(resp.AccountStats) != 1 || len(resp.AccountStats[0].Models) != 1 { + t.Fatalf("account stats = %#v", resp.AccountStats) + } + assertCost("account stats", resp.AccountStats[0].Cost) + assertCost("account model stats", resp.AccountStats[0].Models[0].Cost) + if len(resp.APIKeyStats) != 1 || len(resp.APIKeyStats[0].Models) != 1 { + t.Fatalf("api key stats = %#v", resp.APIKeyStats) + } + assertCost("api key stats", resp.APIKeyStats[0].Cost) + assertCost("api key model stats", resp.APIKeyStats[0].Models[0].Cost) +} + func TestAnalyticsPricesGPT56LongContextPerRequest(t *testing.T) { db := newMonitoringTestStore(t) ctx := context.Background() @@ -2028,6 +2129,66 @@ func TestAccountHistoryReturnsRollupTotalsAndCost(t *testing.T) { } } +func TestAccountHistoryPricesContextTierBands(t *testing.T) { + db := newMonitoringTestStore(t) + ctx := context.Background() + baseMS := int64(1_700_010_000_000) + if err := db.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "tiered-resolved": { + Prompt: 10, + Completion: 4, + ContextTiers: []store.ModelPriceContextTier{ + { + ThresholdTokens: 100_000, + Prompt: 20, + PromptConfigured: true, + }, + { + ThresholdTokens: 200_000, + Prompt: 0, + Completion: 8, + PromptConfigured: true, + CompletionConfigured: true, + }, + }, + }, + }); err != nil { + t.Fatalf("save prices: %v", err) + } + + events := []usage.Event{ + monitoringEvent("history-context-exact", baseMS+1_000, "tiered-alias", "auth-tiered", "source-tiered", false, 100_000, 100_000, 0, 0, 200_000, nil), + monitoringEvent("history-context-first", baseMS+2_000, "tiered-alias", "auth-tiered", "source-tiered", false, 100_001, 100_000, 0, 0, 200_001, nil), + monitoringEvent("history-context-highest", baseMS+3_000, "tiered-alias", "auth-tiered", "source-tiered", false, 200_001, 100_000, 0, 0, 300_001, nil), + } + for index := range events { + events[index].ResolvedModel = "tiered-resolved" + events[index].AccountSnapshot = "tier-history@example.com" + events[index].Source = "tier-history@example.com" + } + if _, err := db.InsertEvents(ctx, events); err != nil { + t.Fatalf("insert events: %v", err) + } + + resp, err := New(db).AccountHistory(ctx, AccountHistoryRequest{ + Accounts: []AccountHistoryTarget{{AccountSnapshot: "tier-history@example.com"}}, + CatchUp: true, + }) + if err != nil { + t.Fatalf("account history: %v", err) + } + if resp.Checkpoint.Pending || resp.Checkpoint.LatestID != 3 || resp.Checkpoint.LastEventID != 3 || resp.Checkpoint.Processed != 3 { + t.Fatalf("checkpoint = %#v", resp.Checkpoint) + } + if len(resp.Items) != 1 || !resp.Items[0].Matched || resp.Items[0].TotalRequests != 3 { + t.Fatalf("history item = %#v", resp.Items) + } + const wantCost = 4.60002 + if math.Abs(resp.Items[0].TotalCost-wantCost) > 0.000001 { + t.Fatalf("history cost = %v, want %v", resp.Items[0].TotalCost, wantCost) + } +} + func TestAccountHistoryEmptyTargetDoesNotMatchAnonymousBucket(t *testing.T) { db := newMonitoringTestStore(t) ctx := context.Background() @@ -2369,6 +2530,15 @@ func catchUpMonitoringHourlyRollup(t *testing.T, ctx context.Context, db *store. if err != nil { t.Fatalf("catch up hourly rollup: %v", err) } + if !result.Pending { + break + } + } + for { + result, err := db.CatchUpUsagePricing(ctx, 100, time.Now().UnixMilli()) + if err != nil { + t.Fatalf("catch up pricing rollup: %v", err) + } if !result.Pending { return } From a55097b1e55cc7c4f6e9310cff9ab5964b420894 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:11:18 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E2=9C=A8=20feat(web):=20surface=20tier-a?= =?UTF-8?q?ware=20model=20pricing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display synchronized context and service-tier rules and warn when manual prices replace them. Calculate mixed Priority/Fast and long-context usage consistently with the backend. Update API types, tests, styling, and all supported locales. --- .../monitoring/ModelPricesPage.module.scss | 49 ++- .../features/monitoring/ModelPricesPage.tsx | 67 ++- .../model/modelPricesPageModel.test.ts | 47 +++ .../monitoring/model/modelPricesPageModel.ts | 44 +- apps/web/src/i18n/locales/en.json | 4 +- apps/web/src/i18n/locales/ru.json | 4 +- apps/web/src/i18n/locales/zh-CN.json | 4 +- apps/web/src/i18n/locales/zh-TW.json | 4 +- apps/web/src/services/api/usageService.ts | 3 +- apps/web/src/utils/usage.test.ts | 395 +++++++++++++++++- apps/web/src/utils/usage.ts | 284 +++++++++++-- 11 files changed, 841 insertions(+), 64 deletions(-) diff --git a/apps/web/src/features/monitoring/ModelPricesPage.module.scss b/apps/web/src/features/monitoring/ModelPricesPage.module.scss index 39bd585ca..92a97eca4 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.module.scss +++ b/apps/web/src/features/monitoring/ModelPricesPage.module.scss @@ -47,6 +47,16 @@ min-width: 0; } +.tierClearNotice { + grid-column: 1 / -1; + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--warning-color) 35%, var(--pricing-line)); + border-radius: 10px; + background: color-mix(in srgb, var(--warning-color) 8%, var(--pricing-surface)); + color: var(--pricing-muted); + font-size: 11px; +} + .titleGroup { flex-wrap: nowrap; } @@ -253,7 +263,7 @@ .priceTable { width: 100%; - min-width: 980px; + min-width: 1220px; border-collapse: collapse; } @@ -294,6 +304,43 @@ word-break: break-word; } +.tierCell { + min-width: 220px; +} + +.tierList { + display: grid; + gap: 4px; +} + +.tierBadge { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 6px; + width: fit-content; + max-width: 100%; + padding: 3px 6px; + border: 1px solid color-mix(in srgb, var(--pricing-accent) 22%, var(--pricing-line)); + border-radius: 8px; + background: color-mix(in srgb, var(--pricing-accent) 6%, var(--pricing-surface)); +} + +.tierBadge strong { + color: var(--pricing-accent-strong); + font-family: var(--font-mono); + font-size: 10px; +} + +.tierBadge small { + overflow: hidden; + color: var(--pricing-muted); + font-family: var(--font-mono); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + .modelContent span { color: var(--pricing-muted); font-size: 11px; diff --git a/apps/web/src/features/monitoring/ModelPricesPage.tsx b/apps/web/src/features/monitoring/ModelPricesPage.tsx index 8e4cb960b..aa6d43d6b 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.tsx +++ b/apps/web/src/features/monitoring/ModelPricesPage.tsx @@ -22,7 +22,11 @@ import { createEmptyPriceDraft, createPriceDraft, filterModelPriceRows, + formatContextThreshold, formatPriceUnit, + formatServiceTierRule, + resolveContextTierDisplayPrice, + resolveServiceTierDisplayPrice, type ModelPriceFilter, type PriceDraft, } from '@/features/monitoring/model/modelPricesPageModel'; @@ -135,8 +139,9 @@ export function ModelPricesPage() { imported: result.imported, candidates: result.candidates?.length ?? 0, unmatched: result.unmatched?.length ?? 0, + preserved: result.preserved?.length ?? 0, }), - 'success' + result.preserved?.length ? 'warning' : 'success' ); } catch (error: unknown) { const message = resolveErrorMessage(error, t('common.unknown_error')); @@ -360,6 +365,17 @@ export function ModelPricesPage() { {t('common.save')} + {(modelPrices[draft.model.trim()]?.contextTiers?.length ?? 0) + + (modelPrices[draft.model.trim()]?.serviceTiers?.length ?? 0) > + 0 ? ( +
+ {t('model_prices.manual_clears_pricing_rules', { + count: + (modelPrices[draft.model.trim()]?.contextTiers?.length ?? 0) + + (modelPrices[draft.model.trim()]?.serviceTiers?.length ?? 0), + })} +
+ ) : null} ) : null} @@ -379,6 +395,7 @@ export function ModelPricesPage() { {t('usage_stats.model_price_cache')} {t('usage_stats.model_price_cache_read')} {t('usage_stats.model_price_cache_creation')} + {t('model_prices.pricing_rules')} {t('model_prices.source')} {t('common.action')} @@ -393,6 +410,8 @@ export function ModelPricesPage() { const selectedCandidate = candidates.find((candidate) => candidate.sourceModelId === selectedSource) ?? candidates[0]; + const contextTiers = row.price?.contextTiers ?? []; + const serviceTiers = row.price?.serviceTiers ?? []; return ( @@ -412,6 +431,52 @@ export function ModelPricesPage() { {formatPriceUnit(row.price?.cache)} {formatPriceUnit(row.price?.cacheRead)} {formatPriceUnit(row.price?.cacheCreation)} + + {contextTiers.length > 0 || serviceTiers.length > 0 ? ( +
+ {contextTiers.map((tier) => { + const effectivePrice = resolveContextTierDisplayPrice( + row.price, + tier + ); + return ( + + {`>${formatContextThreshold(tier.thresholdTokens)}`} + + {formatPriceUnit(effectivePrice.prompt)} /{' '} + {formatPriceUnit(effectivePrice.completion)} + + + ); + })} + {serviceTiers.map((tier) => { + const effectivePrice = resolveServiceTierDisplayPrice( + row.price, + tier + ); + return ( + + {formatServiceTierRule(tier)} + + {formatPriceUnit(effectivePrice.prompt)} /{' '} + {formatPriceUnit(effectivePrice.completion)} + + + ); + })} +
+ ) : ( + -- + )} + {row.price ? (
diff --git a/apps/web/src/features/monitoring/model/modelPricesPageModel.test.ts b/apps/web/src/features/monitoring/model/modelPricesPageModel.test.ts index 8b9036ec7..3b2148782 100644 --- a/apps/web/src/features/monitoring/model/modelPricesPageModel.test.ts +++ b/apps/web/src/features/monitoring/model/modelPricesPageModel.test.ts @@ -6,6 +6,10 @@ import { buildModelPriceSummary, buildSyncPriceModelsFromSummary, filterModelPriceRows, + formatContextThreshold, + formatServiceTierRule, + resolveContextTierDisplayPrice, + resolveServiceTierDisplayPrice, } from './modelPricesPageModel'; const usageSummary = { @@ -130,6 +134,8 @@ describe('modelPricesPageModel', () => { cacheReadConfigured: false, cacheCreationConfigured: false, source: 'manual', + contextTiers: [], + serviceTiers: [], }); }); @@ -154,4 +160,45 @@ describe('modelPricesPageModel', () => { cacheCreationConfigured: true, }); }); + + it('formats context tier thresholds compactly', () => { + expect(formatContextThreshold(32_000)).toBe('32K'); + expect(formatContextThreshold(1_000_000)).toBe('1M'); + expect(formatContextThreshold(12_345)).toBe('12,345'); + }); + + it('displays inherited tier rates while preserving explicit zero prices', () => { + expect( + resolveContextTierDisplayPrice( + { prompt: 1, completion: 2, cache: 0.5 }, + { + thresholdTokens: 32_000, + prompt: 0, + completion: 0, + cache: 0, + promptConfigured: true, + completionConfigured: false, + } + ) + ).toEqual({ prompt: 0, completion: 2 }); + }); + + it('displays Fast Mode aliases and inherited service-tier rates', () => { + const tier = { + mode: 'fast', + serviceTier: 'priority', + prompt: 12.5, + completion: 0, + cache: 0, + promptConfigured: true, + completionConfigured: false, + }; + expect(formatServiceTierRule(tier)).toBe('fast/priority'); + expect(resolveServiceTierDisplayPrice({ prompt: 5, completion: 30, cache: 0.5 }, tier)).toEqual( + { + prompt: 12.5, + completion: 30, + } + ); + }); }); diff --git a/apps/web/src/features/monitoring/model/modelPricesPageModel.ts b/apps/web/src/features/monitoring/model/modelPricesPageModel.ts index b8e500bb7..88360c554 100644 --- a/apps/web/src/features/monitoring/model/modelPricesPageModel.ts +++ b/apps/web/src/features/monitoring/model/modelPricesPageModel.ts @@ -1,4 +1,4 @@ -import type { ModelPrice } from '@/utils/usage'; +import type { ModelPrice, ModelPriceContextTier, ModelPriceServiceTier } from '@/utils/usage'; import type { ModelPriceUsageSummaryResponse, ModelPriceSyncCandidate, @@ -48,13 +48,9 @@ const createConfiguredDraftValue = (value: number | undefined, configured?: bool export const createPriceDraft = (model: string, price?: ModelPrice): PriceDraft => ({ model, prompt: price ? createConfiguredDraftValue(price.prompt, price.promptConfigured) : '', - completion: price - ? createConfiguredDraftValue(price.completion, price.completionConfigured) - : '', + completion: price ? createConfiguredDraftValue(price.completion, price.completionConfigured) : '', cache: price ? String(price.cache) : '', - cacheRead: price - ? createConfiguredDraftValue(price.cacheRead, price.cacheReadConfigured) - : '', + cacheRead: price ? createConfiguredDraftValue(price.cacheRead, price.cacheReadConfigured) : '', cacheCreation: price ? createConfiguredDraftValue(price.cacheCreation, price.cacheCreationConfigured) : '', @@ -82,6 +78,8 @@ export const buildPriceFromDraft = (draft: PriceDraft): ModelPrice | null => { cacheReadConfigured: draft.cacheRead.trim() !== '', cacheCreationConfigured: draft.cacheCreation.trim() !== '', source: 'manual', + contextTiers: [], + serviceTiers: [], }; }; @@ -199,3 +197,35 @@ export const formatPriceUnit = (value: number | undefined) => { const num = Number(value); return Number.isFinite(num) ? `$${num.toFixed(4)}/1M` : '--'; }; + +export const resolveContextTierDisplayPrice = ( + price: ModelPrice | undefined, + tier: ModelPriceContextTier +) => ({ + prompt: tier.promptConfigured ? tier.prompt : price?.prompt, + completion: tier.completionConfigured ? tier.completion : price?.completion, +}); + +export const resolveServiceTierDisplayPrice = ( + price: ModelPrice | undefined, + tier: ModelPriceServiceTier +) => ({ + prompt: tier.promptConfigured ? tier.prompt : price?.prompt, + completion: tier.completionConfigured ? tier.completion : price?.completion, +}); + +export const formatServiceTierRule = (tier: ModelPriceServiceTier) => { + const mode = tier.mode.trim(); + const serviceTier = tier.serviceTier.trim(); + if (!mode) return serviceTier || '--'; + if (!serviceTier || mode.toLowerCase() === serviceTier.toLowerCase()) return mode; + return `${mode}/${serviceTier}`; +}; + +export const formatContextThreshold = (value: number) => { + const tokens = Number(value); + if (!Number.isFinite(tokens) || tokens <= 0) return '--'; + if (tokens >= 1_000_000 && tokens % 1_000_000 === 0) return `${tokens / 1_000_000}M`; + if (tokens >= 1_000 && tokens % 1_000 === 0) return `${tokens / 1_000}K`; + return tokens.toLocaleString('en-US', { maximumFractionDigits: 0 }); +}; diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index ff96a1cbd..ea8cbab29 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -1505,7 +1505,7 @@ "sync_title": "Price Sync", "sync_idle": "Sync currently used models from models.dev first, with LiteLLM and OpenRouter fallbacks. Ambiguous matches will be listed for confirmation.", "sync_result": "Sources {{sources}}, imported {{imported}}, skipped {{skipped}}, {{proxy}}.", - "sync_success_detail": "Sync complete: imported {{imported}}, pending {{candidates}}, unmatched {{unmatched}}.", + "sync_success_detail": "Sync complete: imported {{imported}}, preserved {{preserved}} last-known-good prices, pending {{candidates}}, unmatched {{unmatched}}.", "source_result_ok": "{{models}} models, skipped {{skipped}}", "source_result_failed": "fetch failed", "proxy_used": "CPA global proxy used", @@ -1522,6 +1522,8 @@ "filter_saved": "Saved", "calls": "Calls", "source": "Source", + "pricing_rules": "Pricing rules", + "manual_clears_pricing_rules": "Saving a manual price removes {{count}} synchronized context or service-tier rule(s).", "needs_confirmation": "Candidate confirmation needed", "no_price": "No price set", "candidate_select": "Select candidate price", diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index bb23bccc7..babca5a09 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -1505,7 +1505,7 @@ "sync_title": "Синхронизация цен", "sync_idle": "Сначала синхронизируйте используемые модели с models.dev, используя LiteLLM и OpenRouter как резервные источники. Неочевидные совпадения появятся в списке для ручного подтверждения.", "sync_result": "Sources {{sources}}, imported {{imported}}, skipped {{skipped}}, {{proxy}}.", - "sync_success_detail": "Синхронизация завершена: импортировано {{imported}}, ждут подтверждения {{candidates}}, без совпадений {{unmatched}}.", + "sync_success_detail": "Синхронизация завершена: импортировано {{imported}}, сохранено последних корректных цен {{preserved}}, ждут подтверждения {{candidates}}, без совпадений {{unmatched}}.", "source_result_ok": "{{models}} models, skipped {{skipped}}", "source_result_failed": "Не удалось получить данные", "proxy_used": "Использован глобальный прокси CPA", @@ -1522,6 +1522,8 @@ "filter_saved": "Сохранённые", "calls": "Вызовы", "source": "Источник", + "pricing_rules": "Правила тарификации", + "manual_clears_pricing_rules": "Сохранение ручной цены удалит {{count}} синхронизированных контекстных правил или правил уровня обслуживания.", "needs_confirmation": "Нужно подтвердить цену", "no_price": "Цена не задана", "candidate_select": "Выберите цену", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 448f93938..a41c50c95 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -1505,7 +1505,7 @@ "sync_title": "价格同步", "sync_idle": "优先从 models.dev 同步当前使用过的模型,并使用 LiteLLM、OpenRouter 回退。无法确认的相似模型会进入待确认列表。", "sync_result": "来源 {{sources}},已导入 {{imported}},跳过 {{skipped}},{{proxy}}。", - "sync_success_detail": "同步完成:自动导入 {{imported}},待确认 {{candidates}},未匹配 {{unmatched}}。", + "sync_success_detail": "同步完成:自动导入 {{imported}},保留最后有效价格 {{preserved}},待确认 {{candidates}},未匹配 {{unmatched}}。", "source_result_ok": "{{models}} 条,跳过 {{skipped}}", "source_result_failed": "获取失败", "proxy_used": "已使用 CPA 全局代理", @@ -1522,6 +1522,8 @@ "filter_saved": "已保存", "calls": "调用", "source": "来源", + "pricing_rules": "计费规则", + "manual_clears_pricing_rules": "保存手动价格将移除 {{count}} 条已同步的上下文或服务层级规则。", "needs_confirmation": "需要确认价格", "no_price": "未设置价格", "candidate_select": "选择价格", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index 0c1f55a36..32a5df993 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -1505,7 +1505,7 @@ "sync_title": "價格同步", "sync_idle": "優先從 models.dev 同步目前使用過的模型,並使用 LiteLLM、OpenRouter 回退。無法確認的相似模型會進入待確認列表。", "sync_result": "來源 {{sources}},已匯入 {{imported}},跳過 {{skipped}},{{proxy}}。", - "sync_success_detail": "同步完成:自動匯入 {{imported}},待確認 {{candidates}},未匹配 {{unmatched}}。", + "sync_success_detail": "同步完成:自動匯入 {{imported}},保留最後有效價格 {{preserved}},待確認 {{candidates}},未匹配 {{unmatched}}。", "source_result_ok": "{{models}} 條,跳過 {{skipped}}", "source_result_failed": "取得失敗", "proxy_used": "已使用 CPA 全域代理", @@ -1522,6 +1522,8 @@ "filter_saved": "已儲存", "calls": "呼叫", "source": "來源", + "pricing_rules": "計費規則", + "manual_clears_pricing_rules": "儲存手動價格將移除 {{count}} 條已同步的上下文或服務層級規則。", "needs_confirmation": "需要確認價格", "no_price": "未設定價格", "candidate_select": "選擇價格", diff --git a/apps/web/src/services/api/usageService.ts b/apps/web/src/services/api/usageService.ts index 2f9596a04..d4e91c025 100644 --- a/apps/web/src/services/api/usageService.ts +++ b/apps/web/src/services/api/usageService.ts @@ -368,6 +368,7 @@ export interface ModelPriceSyncResponse extends ModelPricesResponse { matched?: Record; candidates?: ModelPriceSyncCandidateSet[]; unmatched?: string[]; + preserved?: string[]; proxyUsed?: boolean; sourceResults?: ModelPriceSyncSourceResult[]; } @@ -2479,7 +2480,7 @@ export const usageServiceApi = { buildUrl(base, '/v0/management/model-prices/sync'), models ? { models } : {}, { - timeout: 30 * 1000, + timeout: 45 * 1000, headers: authHeaders(managementKey), } ); diff --git a/apps/web/src/utils/usage.test.ts b/apps/web/src/utils/usage.test.ts index 786f9f358..98611f0c8 100644 --- a/apps/web/src/utils/usage.test.ts +++ b/apps/web/src/utils/usage.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildCandidateUsageSourceIds, @@ -12,12 +12,17 @@ import { formatCompactNumber, getServiceTierMultiplier, inferCacheInputMode, + loadModelPrices, normalizeCacheAccounting, normalizeUsageSourceId, } from './usage'; import { maskSensitiveText } from './format'; import cacheInputAccountingFixtures from './cacheInputAccounting.fixtures.json'; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe('formatCompactNumber', () => { it('keeps large values compact as data grows beyond millions', () => { expect(formatCompactNumber(999)).toBe('999'); @@ -415,9 +420,7 @@ describe('cache input accounting semantics', () => { totalInputTokens: fixture.expected.totalInput, cacheCreationTokens: fixture.expected.cacheCreation, }); - expect(accounting.legacyRead + accounting.cacheReadTokens).toBe( - fixture.expected.cacheRead - ); + expect(accounting.legacyRead + accounting.cacheReadTokens).toBe(fixture.expected.cacheRead); }); it.each([ @@ -576,12 +579,14 @@ describe('cache input accounting semantics', () => { }); expect(detail.tokens.input_tokens).toBe(100); - expect(calculateCacheHitRate({ - inputTokens: detail.tokens.input_tokens, - cachedTokens: detail.tokens.cached_tokens, - cacheReadTokens: detail.tokens.cache_read_tokens, - cacheCreationTokens: detail.tokens.cache_creation_tokens, - })).toBeCloseTo(0.4); + expect( + calculateCacheHitRate({ + inputTokens: detail.tokens.input_tokens, + cachedTokens: detail.tokens.cached_tokens, + cacheReadTokens: detail.tokens.cache_read_tokens, + cacheCreationTokens: detail.tokens.cache_creation_tokens, + }) + ).toBeCloseTo(0.4); expect(cost).toBeCloseTo(0.000064); }); }); @@ -791,6 +796,49 @@ describe('calculateCost model price preference', () => { expect(cost).toBeCloseTo(0.1); }); + it('keeps flex and batch discounts for legacy long-context pricing', () => { + const modelPrices = { 'gpt-5.5': { prompt: 2, completion: 4, cache: 1 } }; + const tokens = { input_tokens: 1_000_000, output_tokens: 100_000 }; + const standard = calculateCost( + { tokens, __modelName: 'gpt-5.5', service_tier: 'default' }, + modelPrices + ); + for (const serviceTier of ['flex', 'batch']) { + expect( + calculateCost({ tokens, __modelName: 'gpt-5.5', service_tier: serviceTier }, modelPrices) + ).toBeCloseTo(standard * 0.5); + } + }); + + it('uses an explicit batch price with legacy long-context multipliers', () => { + const cost = calculateCost( + { + tokens: { input_tokens: 300_000 }, + __modelName: 'gpt-5.5', + service_tier: 'batch', + }, + { + 'gpt-5.5': { + prompt: 5, + completion: 30, + cache: 0.5, + serviceTiers: [ + { + mode: 'batch', + serviceTier: 'batch', + prompt: 2, + completion: 15, + cache: 0.25, + promptConfigured: true, + completionConfigured: true, + }, + ], + }, + } + ); + expect(cost).toBeCloseTo(1.2); + }); + it('keeps default and missing service tier at standard cost', () => { const modelPrices = { 'gpt-5.4': { prompt: 2.5, completion: 5, cache: 1 }, @@ -979,6 +1027,277 @@ describe('calculateCost model price preference', () => { expect(cost).toBeCloseTo(2.5); }); + + it('uses explicit Fast Mode prices for fast and priority in the base context band', () => { + const modelPrices = { + 'gpt-5.5': { + prompt: 5, + completion: 30, + cache: 0.5, + contextTiers: [ + { + thresholdTokens: 272_000, + prompt: 10, + completion: 45, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: 12.5, + completion: 75, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + }, + }; + + for (const serviceTier of ['fast', 'priority']) { + expect( + calculateCost( + { + tokens: { input_tokens: 100_000, output_tokens: 10_000 }, + __modelName: 'gpt-5.5', + service_tier: serviceTier, + }, + modelPrices + ) + ).toBeCloseTo(2); + } + expect( + calculateCost( + { + tokens: { input_tokens: 100_000, output_tokens: 10_000 }, + __modelName: 'gpt-5.5', + service_tier: 'default', + }, + modelPrices + ) + ).toBeCloseTo(0.8); + }); + + it('does not re-enable legacy long-context pricing inside an explicit base context band', () => { + const cost = calculateCost( + { + tokens: { input_tokens: 300_000, output_tokens: 100_000 }, + __modelName: 'gpt-5.5', + service_tier: 'priority', + }, + { + 'gpt-5.5': { + prompt: 5, + completion: 30, + cache: 0.5, + contextTiers: [ + { + thresholdTokens: 500_000, + prompt: 10, + completion: 45, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: 10, + completion: 20, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + }, + } + ); + + expect(cost).toBeCloseTo(5); + }); + + it('uses long-context pricing instead of an explicit Fast Mode price', () => { + const cost = calculateCost( + { + tokens: { input_tokens: 300_000, output_tokens: 100_000 }, + __modelName: 'gpt-5.5', + service_tier: 'priority', + }, + { + 'gpt-5.5': { + prompt: 5, + completion: 30, + cache: 0.5, + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: 12.5, + completion: 75, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + }, + } + ); + + expect(cost).toBeCloseTo(7.5); + }); + + it('selects the highest context tier with strict threshold semantics', () => { + const modelPrices = { + 'tiered-model': { + prompt: 1, + completion: 2, + cache: 0.1, + contextTiers: [ + { + thresholdTokens: 32_000, + prompt: 3, + completion: 4, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + { + thresholdTokens: 200_000, + prompt: 5, + completion: 8, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + }, + }; + + expect( + calculateCost({ tokens: { input_tokens: 32_000 }, __modelName: 'tiered-model' }, modelPrices) + ).toBeCloseTo(0.032); + expect( + calculateCost({ tokens: { input_tokens: 200_000 }, __modelName: 'tiered-model' }, modelPrices) + ).toBeCloseTo(0.6); + expect( + calculateCost({ tokens: { input_tokens: 200_001 }, __modelName: 'tiered-model' }, modelPrices) + ).toBeCloseTo(1.000005); + }); + + it('does not stack priority pricing with active context-tier pricing', () => { + const modelPrices = { + 'gpt-5.6-sol': { + prompt: 5, + completion: 30, + cache: 0.5, + contextTiers: [ + { + thresholdTokens: 272_000, + prompt: 10, + completion: 40, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: 12.5, + completion: 75, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + }, + }; + const tokens = { input_tokens: 1_000_000 }; + + const standard = calculateCost( + { tokens, __modelName: 'gpt-5.6-sol', service_tier: 'default' }, + modelPrices + ); + const priority = calculateCost( + { tokens, __modelName: 'gpt-5.6-sol', service_tier: 'priority' }, + modelPrices + ); + + expect(priority).toBeCloseTo(standard); + }); + + it('inherits missing tier cache rates and preserves explicit zero overrides', () => { + const cost = calculateCost( + { + tokens: { + input_tokens: 1_000_000, + cache_read_tokens: 200_000, + cache_creation_tokens: 100_000, + }, + __modelName: 'tiered-cache', + }, + { + 'tiered-cache': { + prompt: 2, + completion: 4, + cache: 1, + cacheRead: 0.5, + cacheCreation: 3, + cacheReadConfigured: true, + cacheCreationConfigured: true, + contextTiers: [ + { + thresholdTokens: 100, + prompt: 4, + completion: 8, + cache: 0, + cacheRead: 0, + promptConfigured: true, + completionConfigured: true, + cacheReadConfigured: true, + }, + ], + }, + } + ); + + expect(cost).toBeCloseTo(3.1); + }); + + it('uses generic context tiers instead of the hardcoded GPT long-context rule', () => { + const cost = calculateCost( + { + tokens: { input_tokens: 1_000_000 }, + __modelName: 'gpt-5.6-sol', + }, + { + 'gpt-5.6-sol': { + prompt: 5, + completion: 30, + cache: 0.5, + contextTiers: [ + { + thresholdTokens: 272_000, + prompt: 10, + completion: 40, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + }, + } + ); + + expect(cost).toBeCloseTo(10); + }); }); describe('getServiceTierMultiplier', () => { @@ -994,3 +1313,59 @@ describe('getServiceTierMultiplier', () => { expect(getServiceTierMultiplier('unknown-model', 'priority')).toBe(1); }); }); + +describe('model price storage', () => { + it('normalizes persisted service-tier rules and rejects ambiguous aliases', () => { + const stored = { + 'gpt-valid': { + prompt: 5, + completion: 30, + cache: 0.5, + serviceTiers: [ + { + mode: ' FAST ', + serviceTier: ' PRIORITY ', + prompt: 12.5, + completion: 75, + cache: 0, + promptConfigured: true, + completionConfigured: true, + }, + ], + }, + 'gpt-ambiguous': { + prompt: 5, + completion: 30, + cache: 0.5, + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: 12.5, + completion: 75, + cache: 0, + promptConfigured: true, + }, + { + mode: 'priority', + serviceTier: 'turbo', + prompt: 15, + completion: 80, + cache: 0, + promptConfigured: true, + }, + ], + }, + }; + vi.stubGlobal('localStorage', { + getItem: (key: string) => + key === 'cli-proxy-model-prices-v2' ? JSON.stringify(stored) : null, + }); + + const prices = loadModelPrices(); + expect(prices['gpt-valid'].serviceTiers).toEqual([ + expect.objectContaining({ mode: 'fast', serviceTier: 'priority', prompt: 12.5 }), + ]); + expect(prices['gpt-ambiguous'].serviceTiers).toBeUndefined(); + }); +}); diff --git a/apps/web/src/utils/usage.ts b/apps/web/src/utils/usage.ts index 140f3279e..ea0d3bd67 100644 --- a/apps/web/src/utils/usage.ts +++ b/apps/web/src/utils/usage.ts @@ -5,6 +5,35 @@ import { parseTimestampMs } from './timestamp'; export { normalizeAuthIndex }; +export interface ModelPriceContextTier { + thresholdTokens: number; + prompt: number; + completion: number; + cache: number; + cacheRead?: number; + cacheCreation?: number; + promptConfigured?: boolean; + completionConfigured?: boolean; + cacheConfigured?: boolean; + cacheReadConfigured?: boolean; + cacheCreationConfigured?: boolean; +} + +export interface ModelPriceServiceTier { + mode: string; + serviceTier: string; + prompt: number; + completion: number; + cache: number; + cacheRead?: number; + cacheCreation?: number; + promptConfigured?: boolean; + completionConfigured?: boolean; + cacheConfigured?: boolean; + cacheReadConfigured?: boolean; + cacheCreationConfigured?: boolean; +} + export interface ModelPrice { prompt: number; completion: number; @@ -18,6 +47,8 @@ export interface ModelPrice { source?: string; sourceModelId?: string; rawJson?: string; + contextTiers?: ModelPriceContextTier[]; + serviceTiers?: ModelPriceServiceTier[]; updatedAtMs?: number; syncedAtMs?: number; } @@ -752,8 +783,7 @@ const readTokens = (detail: Record, modelName: string): UsageTo provider: detail.provider, providerSnapshot: detail.auth_provider_snapshot ?? detail.authProviderSnapshot, resolvedModel: detail.resolved_model ?? detail.resolvedModel, - requestedModel: - detail.requested_model ?? detail.requestedModel ?? detail.alias, + requestedModel: detail.requested_model ?? detail.requestedModel ?? detail.alias, displayModel: modelName, }, inputTokens: tokensRaw.input_tokens ?? tokensRaw.inputTokens, @@ -861,9 +891,7 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] { requested_model: readDetailString( detailRaw.requested_model ?? detailRaw.requestedModel ?? detailRaw.alias ), - resolved_model: readDetailString( - detailRaw.resolved_model ?? detailRaw.resolvedModel - ), + resolved_model: readDetailString(detailRaw.resolved_model ?? detailRaw.resolvedModel), latency_ms: latencyMs ?? undefined, ttft_ms: ttftMs ?? undefined, request_service_tier: readDetailString( @@ -990,9 +1018,7 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail requested_model: readDetailString( detailRaw.requested_model ?? detailRaw.requestedModel ?? detailRaw.alias ), - resolved_model: readDetailString( - detailRaw.resolved_model ?? detailRaw.resolvedModel - ), + resolved_model: readDetailString(detailRaw.resolved_model ?? detailRaw.resolvedModel), request_service_tier: readDetailString( detailRaw.request_service_tier ?? detailRaw.requestServiceTier ), @@ -1110,7 +1136,7 @@ export function calculateCost( const officialCandidatePrice = getOfficialGpt56Price(resolvedModel) || getOfficialGpt56Price(requestedModel); const configuredPrice = resolvedPrice || requestedPrice; - const price = configuredPrice + const basePrice = configuredPrice ? { ...configuredPrice, prompt: isConfiguredPriceValue(configuredPrice.prompt, configuredPrice.promptConfigured) @@ -1124,7 +1150,33 @@ export function calculateCost( : (behaviorFallback?.completion ?? 0), } : officialCandidatePrice; - if (!price) return 0; + if (!basePrice) return 0; + + const identity = [ + detail.executor_type, + detail.executorType, + detail.provider, + detail.auth_provider_snapshot, + detail.authProviderSnapshot, + detail.auth_type, + detail.authType, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + const serviceTier = identity.includes('codex') + ? detail.request_service_tier || + detail.requestServiceTier || + detail.service_tier || + detail.serviceTier || + detail.response_service_tier || + detail.responseServiceTier + : detail.response_service_tier || + detail.responseServiceTier || + detail.service_tier || + detail.serviceTier || + detail.request_service_tier || + detail.requestServiceTier; const inputTokens = Math.max(toFiniteNumber(detail.tokens.input_tokens), 0); const completionTokens = Math.max(toFiniteNumber(detail.tokens.output_tokens), 0); @@ -1134,6 +1186,24 @@ export function calculateCost( ); const cacheReadTokens = Math.max(toFiniteNumber(detail.tokens.cache_read_tokens), 0); const cacheCreationTokens = Math.max(toFiniteNumber(detail.tokens.cache_creation_tokens), 0); + const hasContextPricing = Boolean(basePrice.contextTiers?.length); + const contextTier = selectContextTierPrice(basePrice, inputTokens); + const longContext = + !hasContextPricing && supportsLongContextPremium(behaviorModel) && inputTokens > 272_000; + const normalizedServiceTier = String(serviceTier ?? '') + .trim() + .toLowerCase(); + const longContextOverridesServiceTier = + longContext && (normalizedServiceTier === 'priority' || normalizedServiceTier === 'fast'); + const serviceTierPrice = + !contextTier && !longContextOverridesServiceTier + ? selectServiceTierPrice(basePrice, serviceTier) + : undefined; + const price = contextTier + ? applyContextTierPrice(basePrice, contextTier) + : serviceTierPrice + ? applyServiceTierPrice(basePrice, serviceTierPrice) + : basePrice; const promptPrice = Number(price.prompt) || 0; const completionPrice = Number(price.completion) || 0; const configuredCacheReadPrice = Number(price.cacheRead) || 0; @@ -1151,7 +1221,6 @@ export function calculateCost( : promptPrice * (isGpt56Model(behaviorModel) ? 1.25 : 1); const readTokens = cachedTokens + cacheReadTokens; const promptTokens = Math.max(inputTokens - readTokens - cacheCreationTokens, 0); - const longContext = supportsLongContextPremium(behaviorModel) && inputTokens > 272_000; const inputMultiplier = longContext ? 2 : 1; const outputMultiplier = longContext ? 1.5 : 1; const standardCost = @@ -1162,39 +1231,168 @@ export function calculateCost( inputMultiplier + (completionTokens / TOKENS_PER_PRICE_UNIT) * completionPrice * outputMultiplier; - const identity = [ - detail.executor_type, - detail.executorType, - detail.provider, - detail.auth_provider_snapshot, - detail.authProviderSnapshot, - detail.auth_type, - detail.authType, - ] - .filter(Boolean) - .join(' ') - .toLowerCase(); - const serviceTier = identity.includes('codex') - ? detail.request_service_tier || - detail.requestServiceTier || - detail.service_tier || - detail.serviceTier || - detail.response_service_tier || - detail.responseServiceTier - : detail.response_service_tier || - detail.responseServiceTier || - detail.service_tier || - detail.serviceTier || - detail.request_service_tier || - detail.requestServiceTier; - let multiplier = getServiceTierMultiplier(behaviorModel, serviceTier); - if (longContext && ['priority', 'fast'].includes(String(serviceTier ?? '').toLowerCase())) { - multiplier = 1; - } + const multiplier = + longContextOverridesServiceTier || contextTier || serviceTierPrice + ? 1 + : getServiceTierMultiplier(behaviorModel, serviceTier); const total = standardCost * multiplier; return Number.isFinite(total) && total > 0 ? total : 0; } +function selectContextTierPrice( + price: ModelPrice, + inputTokens: number +): ModelPriceContextTier | undefined { + return (price.contextTiers ?? []).reduce( + (selected, candidate) => + inputTokens > candidate.thresholdTokens && + (!selected || candidate.thresholdTokens > selected.thresholdTokens) + ? candidate + : selected, + undefined + ); +} + +function applyContextTierPrice(price: ModelPrice, tier: ModelPriceContextTier): ModelPrice { + return { + ...price, + prompt: tier.promptConfigured ? tier.prompt : price.prompt, + completion: tier.completionConfigured ? tier.completion : price.completion, + cache: tier.cacheConfigured ? tier.cache : price.cache, + cacheRead: tier.cacheReadConfigured ? tier.cacheRead : price.cacheRead, + cacheCreation: tier.cacheCreationConfigured ? tier.cacheCreation : price.cacheCreation, + promptConfigured: tier.promptConfigured ? true : price.promptConfigured, + completionConfigured: tier.completionConfigured ? true : price.completionConfigured, + cacheReadConfigured: tier.cacheReadConfigured ? true : price.cacheReadConfigured, + cacheCreationConfigured: tier.cacheCreationConfigured ? true : price.cacheCreationConfigured, + }; +} + +function selectServiceTierPrice( + price: ModelPrice, + serviceTier: string | undefined +): ModelPriceServiceTier | undefined { + const normalized = String(serviceTier ?? '') + .trim() + .toLowerCase(); + if (!normalized) return undefined; + return (price.serviceTiers ?? []).find( + (tier) => normalized === tier.mode || normalized === tier.serviceTier + ); +} + +function applyServiceTierPrice(price: ModelPrice, tier: ModelPriceServiceTier): ModelPrice { + return { + ...price, + prompt: tier.promptConfigured ? tier.prompt : price.prompt, + completion: tier.completionConfigured ? tier.completion : price.completion, + cache: tier.cacheConfigured ? tier.cache : price.cache, + cacheRead: tier.cacheReadConfigured ? tier.cacheRead : price.cacheRead, + cacheCreation: tier.cacheCreationConfigured ? tier.cacheCreation : price.cacheCreation, + promptConfigured: tier.promptConfigured ? true : price.promptConfigured, + completionConfigured: tier.completionConfigured ? true : price.completionConfigured, + cacheReadConfigured: tier.cacheReadConfigured ? true : price.cacheReadConfigured, + cacheCreationConfigured: tier.cacheCreationConfigured ? true : price.cacheCreationConfigured, + }; +} + +function normalizeContextTiers(value: unknown): ModelPriceContextTier[] | undefined { + if (!Array.isArray(value)) return undefined; + const tiers: ModelPriceContextTier[] = []; + const thresholds = new Set(); + for (const item of value) { + if (!isRecord(item)) return undefined; + const thresholdTokens = Number(item.thresholdTokens); + if ( + !Number.isSafeInteger(thresholdTokens) || + thresholdTokens <= 0 || + thresholds.has(thresholdTokens) + ) { + return undefined; + } + const prompt = toFiniteNumber(item.prompt); + const completion = toFiniteNumber(item.completion); + const cache = toFiniteNumber(item.cache); + const cacheRead = toFiniteNumber(item.cacheRead); + const cacheCreation = toFiniteNumber(item.cacheCreation); + if ([prompt, completion, cache, cacheRead, cacheCreation].some((rate) => rate < 0)) { + return undefined; + } + thresholds.add(thresholdTokens); + tiers.push({ + thresholdTokens, + prompt, + completion, + cache, + cacheRead, + cacheCreation, + promptConfigured: item.promptConfigured === true, + completionConfigured: item.completionConfigured === true, + cacheConfigured: item.cacheConfigured === true, + cacheReadConfigured: item.cacheReadConfigured === true, + cacheCreationConfigured: item.cacheCreationConfigured === true, + }); + } + return tiers.sort((left, right) => left.thresholdTokens - right.thresholdTokens); +} + +function normalizeServiceTiers(value: unknown): ModelPriceServiceTier[] | undefined { + if (!Array.isArray(value)) return undefined; + const tiers: ModelPriceServiceTier[] = []; + const identifiers = new Set(); + for (const item of value) { + if (!isRecord(item)) return undefined; + const mode = typeof item.mode === 'string' ? item.mode.trim().toLowerCase() : ''; + const serviceTier = + typeof item.serviceTier === 'string' ? item.serviceTier.trim().toLowerCase() : ''; + if (!mode || !serviceTier || identifiers.has(mode) || identifiers.has(serviceTier)) { + return undefined; + } + const prompt = toFiniteNumber(item.prompt); + const completion = toFiniteNumber(item.completion); + const cache = toFiniteNumber(item.cache); + const cacheRead = toFiniteNumber(item.cacheRead); + const cacheCreation = toFiniteNumber(item.cacheCreation); + if ([prompt, completion, cache, cacheRead, cacheCreation].some((rate) => rate < 0)) { + return undefined; + } + const promptConfigured = item.promptConfigured === true; + const completionConfigured = item.completionConfigured === true; + const cacheConfigured = item.cacheConfigured === true; + const cacheReadConfigured = item.cacheReadConfigured === true; + const cacheCreationConfigured = item.cacheCreationConfigured === true; + if ( + !promptConfigured && + !completionConfigured && + !cacheConfigured && + !cacheReadConfigured && + !cacheCreationConfigured + ) { + return undefined; + } + identifiers.add(mode); + identifiers.add(serviceTier); + tiers.push({ + mode, + serviceTier, + prompt, + completion, + cache, + cacheRead, + cacheCreation, + promptConfigured, + completionConfigured, + cacheConfigured, + cacheReadConfigured, + cacheCreationConfigured, + }); + } + return tiers.sort( + (left, right) => + left.mode.localeCompare(right.mode) || left.serviceTier.localeCompare(right.serviceTier) + ); +} + export function loadModelPrices(): Record { try { if (typeof localStorage === 'undefined') return {}; @@ -1225,9 +1423,15 @@ export function loadModelPrices(): Record { cache, cacheRead, cacheCreation, + promptConfigured: price.promptConfigured === true, + completionConfigured: price.completionConfigured === true, + cacheReadConfigured: price.cacheReadConfigured === true, + cacheCreationConfigured: price.cacheCreationConfigured === true, source: readDetailString(price.source), sourceModelId: readDetailString(price.sourceModelId), rawJson: readDetailString(price.rawJson), + contextTiers: normalizeContextTiers(price.contextTiers), + serviceTiers: normalizeServiceTiers(price.serviceTiers), updatedAtMs: toPositiveNumber(price.updatedAtMs), syncedAtMs: toPositiveNumber(price.syncedAtMs), }; From 021431186ab947bb77ed5a9a3c62585cba172cbc Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 29 Jul 2026 22:11:18 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E2=9C=A8=20feat(docs):=20document=20tier?= =?UTF-8?q?-aware=20pricing=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document models.dev context and service-tier pricing rules in both manuals. Explain last-known-good fallback behavior and how manual replacement clears synchronized rules. Clarify Priority/Fast, Flex/Batch, and long-context cost semantics. --- apps/docs/en/manual/model-prices.md | 30 +++++++++++++++++++++++++++-- apps/docs/manual/model-prices.md | 30 +++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/docs/en/manual/model-prices.md b/apps/docs/en/manual/model-prices.md index 61503881b..da89030aa 100644 --- a/apps/docs/en/manual/model-prices.md +++ b/apps/docs/en/manual/model-prices.md @@ -19,7 +19,15 @@ Synchronization only occurs when the user triggers it and may use the current Ma The same models.dev model ID may be offered by multiple providers, and a real model ID may itself contain `/`. CPAMP compares source identities separately from original model IDs and only matches automatically when the complete pricing metadata, including tiers and experimental-mode prices, is consistent. Identity or price conflicts remain in the candidate-confirmation flow; a same-named LiteLLM or OpenRouter entry cannot bypass that conflict. -The current sync maps models.dev `cost.input`, `cost.output`, `cost.cache_read`, and `cost.cache_write`. The complete model object remains available in the raw metadata, including `cost.tiers`, Fast Mode, and reasoning fields, but those advanced fields are not yet converted automatically into CPAMP billing rules. +The current sync maps models.dev `cost.input`, `cost.output`, `cost.cache_read`, and `cost.cache_write`, converts valid `cost.tiers` context tiers into CPAMP billing rules, and maps `experimental.modes.fast.cost` to short-context Fast/Priority prices. The complete model object remains available in raw metadata; reasoning prices, unknown experimental modes, unknown tier types, and rules that cannot be validated safely do not activate automatic billing. + +### Sync failures and last-known-good prices + +- When models.dev is temporarily unavailable, CPAMP continues with LiteLLM and OpenRouter. +- A transient models.dev failure cannot automatically replace a stored models.dev price with a lower-priority source; fallback sources may still fill models that have no local price. +- A fallback source may replace a model normally when models.dev responds successfully but does not contain that model. +- If every source fails, synchronization stops before any database write and existing prices remain unchanged. +- A synchronized price remains the last-known-good value until a later successful sync or a manual edit; `syncedAtMs` indicates its freshness. ## Supported Billing Semantics @@ -30,11 +38,29 @@ A price rule may include: - Cache read, cache write, and cache creation. - Fixed per-request cost. - `service_tier` differences. +- models.dev context-price tiers. - Long-context thresholds and multipliers. - Model alias and billing-model mapping. Models such as GPT-5.6 may vary by context length, service tier, and cache type. CPAMP can only apply a rule when both the request event and price entry contain the required fields. +### Context-tier semantics + +- A tier matches only when normalized input tokens are **strictly greater than** `tier.size`; an exact-threshold request stays in the lower band. +- When multiple tiers match, CPAMP selects the highest matching threshold. +- The selected tier's rates apply to the entire request, not only to tokens above the threshold. +- Input, output, or cache rates omitted by a tier inherit the base price; an explicit zero from models.dev remains zero. +- CPAMP currently activates only safely validated tiers with `tier.type = context` and a positive threshold. Other rules remain in raw metadata for inspection. + +### Fast/Priority semantics + +- `experimental.modes.fast.cost` matches both `fast` usage telemetry and API `priority` telemetry. +- Short-context requests prefer explicit Fast/Priority prices. Missing fields inherit base rates, while explicit zeros remain zero. +- A matched context tier or the legacy GPT long-context rule uses its standard context price without stacking Fast/Priority pricing. +- Non-models.dev entries, older data, and models without an explicit mode price retain the existing multiplier as a compatibility fallback. + +Model Prices displays synchronized context tiers and service-tier prices as read-only rules. The current manual editor manages base prices only; saving a manual price explicitly clears existing synchronized advanced rules, with a warning shown before saving. + ## Matching Model Names The client model, CPA alias, provider model, and price-table name may differ. When cost is missing: @@ -52,5 +78,5 @@ The page uses a compact model-usage summary to show which prices are active. It - Provider billing remains authoritative. - Missing token, service tier, long-context, or cache fields reduce estimate accuracy. -- Subscriptions, grants, tiered prices, and multiple currencies may not fit a single price entry. +- Subscriptions, grants, non-context tiers, unsupported dynamic-mode prices, and multiple currencies may not fit a single price entry. - Historical cost may be displayed using current prices after an update; the price table is not an immutable billing snapshot. diff --git a/apps/docs/manual/model-prices.md b/apps/docs/manual/model-prices.md index 1f3d9cb1c..84ed84656 100644 --- a/apps/docs/manual/model-prices.md +++ b/apps/docs/manual/model-prices.md @@ -19,7 +19,15 @@ description: 配置 CPA Manager Plus 模型价格、service tier、长上下文 models.dev 中同一个模型 ID 可能由多个 Provider 提供,而且真实模型 ID 本身也可能包含 `/`。CPAMP 会分别比较来源身份和原始模型 ID;只有完整价格元数据(包括阶梯和实验模式价格)明确一致时才自动匹配。任何身份或价格冲突都会进入候选确认流程,LiteLLM 或 OpenRouter 的同名条目不会绕过该冲突。 -当前同步会映射 models.dev 的 `cost.input`、`cost.output`、`cost.cache_read` 和 `cost.cache_write`。完整模型对象仍保存在原始元数据中,包括 `cost.tiers`、Fast Mode 和 reasoning 等字段,但这些高级字段暂不会自动转换为 CPAMP 计费规则。 +当前同步会映射 models.dev 的 `cost.input`、`cost.output`、`cost.cache_read` 和 `cost.cache_write`,将有效的 `cost.tiers` 上下文阶梯转换为 CPAMP 计费规则,并将 `experimental.modes.fast.cost` 映射为 Fast/Priority 短上下文价格。完整模型对象仍保存在原始元数据中;reasoning、未知实验模式、未知阶梯类型或无法安全验证的规则不会激活自动计费。 + +### 同步失败与最后有效价格 + +- models.dev 暂时不可用时,CPAMP 会继续尝试 LiteLLM 和 OpenRouter。 +- 已保存的 models.dev 价格不会因为本次网络失败而被低优先级来源自动覆盖;回退来源仍可补充本地没有价格的模型。 +- 只有 models.dev 成功响应但明确不包含某个模型时,才允许回退来源正常替换该模型。 +- 如果所有来源都失败,同步在写入数据库前终止,现有价格保持不变。 +- 同步价格会一直作为最后有效数据使用,直到后续成功同步或用户手动修改;`syncedAtMs` 可用于判断数据新鲜度。 ## 当前支持的计费语义 @@ -30,11 +38,29 @@ models.dev 中同一个模型 ID 可能由多个 Provider 提供,而且真实 - Cache read、cache write 和 cache creation。 - 请求级固定费用。 - `service_tier` 差异。 +- models.dev 上下文价格阶梯。 - 长上下文阈值和倍率。 - 模型别名与 billing model 映射。 例如 GPT-5.6 及类似模型可能根据上下文长度、service tier 和缓存类型采用不同价格。只有请求事件带有对应字段且价格规则存在时,CPAMP 才能正确计算。 +### 上下文阶梯语义 + +- 阶梯条件是标准化输入 Token **严格大于** `tier.size`;恰好等于阈值时仍使用较低一档。 +- 如果多个阶梯都满足条件,选择阈值最高的一档。 +- 选中阶梯的费率应用于整个请求,而不是只计算超过阈值的 Token。 +- 阶梯中缺失的输入、输出或缓存费率继承基础价格;models.dev 明确提供的零价格会保留为零。 +- 目前只自动启用 `tier.type = context`、正数阈值且价格结构可安全验证的阶梯。其他规则仍保留在原始元数据中供排查。 + +### Fast/Priority 语义 + +- `experimental.modes.fast.cost` 同时匹配使用数据中的 `fast` 和 API `priority`。 +- 短上下文优先使用显式 Fast/Priority 价格;缺失字段继承基础价格,显式零值保持为零。 +- 命中上下文阶梯或旧版 GPT 长上下文规则时,使用对应的标准上下文价格,不再叠加 Fast/Priority。 +- 非 models.dev、旧数据或没有显式模式价格的模型继续使用现有倍率作为兼容回退。 + +模型价格页只读展示已同步的上下文阶梯和服务层级价格。当前手动编辑器只维护基础价格;保存手动价格会明确清除该模型已有的同步高级规则,界面会在保存前提示。 + ## 模型名称匹配 客户端请求名、CPA 路由别名、Provider 实际模型名和价格表名称可能不同。排查成本为空时: @@ -52,5 +78,5 @@ models.dev 中同一个模型 ID 可能由多个 Provider 提供,而且真实 - Provider 账单是最终依据。 - 缺失 Token、service tier、长上下文或缓存字段会降低估算精度。 -- 包月、赠送额度、阶梯价和多币种不一定能由单一价格条目完整表达。 +- 包月、赠送额度、非上下文阶梯、未支持的动态模式价格和多币种不一定能由单一价格条目完整表达。 - 更新价格后,历史成本可能按当前价格重新展示;价格表不是不可变账单快照。