package cost import ( "context" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // ModelPrice holds per-million-token rates in micro-EUR. type ModelPrice struct { ModelID string ProviderType string InputMicroEURPerMTok int64 CachedInputMicroEURPerMTok int64 OutputMicroEURPerMTok int64 ReasoningMicroEURPerMTok int64 } // Default fallback when model is unknown (~gpt-4o-mini input rate). const fallbackInputMicroEURPerMTok int64 = 140000 const fallbackOutputMicroEURPerMTok int64 = 560000 type PricingStore struct { db *pgxpool.Pool } func NewPricingStore(db *pgxpool.Pool) *PricingStore { return &PricingStore{db: db} } func (s *PricingStore) LookupPrice(ctx context.Context, modelID string) (ModelPrice, bool) { modelID = strings.TrimSpace(modelID) if modelID == "" || s.db == nil { return ModelPrice{}, false } var p ModelPrice var cached, reasoning *int64 err := s.db.QueryRow(ctx, ` SELECT model_id, provider_type, input_micro_eur_per_mtok, cached_input_micro_eur_per_mtok, output_micro_eur_per_mtok, reasoning_micro_eur_per_mtok FROM ai_model_pricing WHERE model_id = $1 AND effective_from <= CURRENT_DATE ORDER BY effective_from DESC LIMIT 1 `, modelID).Scan( &p.ModelID, &p.ProviderType, &p.InputMicroEURPerMTok, &cached, &p.OutputMicroEURPerMTok, &reasoning, ) if err != nil { if err != pgx.ErrNoRows { // prefix match for bedrock/azure model ids err = s.db.QueryRow(ctx, ` SELECT model_id, provider_type, input_micro_eur_per_mtok, cached_input_micro_eur_per_mtok, output_micro_eur_per_mtok, reasoning_micro_eur_per_mtok FROM ai_model_pricing WHERE $1 LIKE model_id || '%' AND effective_from <= CURRENT_DATE ORDER BY LENGTH(model_id) DESC, effective_from DESC LIMIT 1 `, modelID).Scan( &p.ModelID, &p.ProviderType, &p.InputMicroEURPerMTok, &cached, &p.OutputMicroEURPerMTok, &reasoning, ) } if err != nil { return ModelPrice{}, false } } if cached != nil { p.CachedInputMicroEURPerMTok = *cached } if reasoning != nil { p.ReasoningMicroEURPerMTok = *reasoning } return p, true } // ComputeCostMicroEUR calculates estimated cost from usage and model pricing. func ComputeCostMicroEUR(usage UsageDetail, price ModelPrice, found bool) (microEUR int64, estimated bool) { if !found { price = ModelPrice{ InputMicroEURPerMTok: fallbackInputMicroEURPerMTok, OutputMicroEURPerMTok: fallbackOutputMicroEURPerMTok, } estimated = true if usage.PromptTokens == 0 && usage.CompletionTokens == 0 { microEUR = int64(usage.TotalTokens) * fallbackInputMicroEURPerMTok / 1_000_000 if microEUR == 0 && usage.TotalTokens > 0 { microEUR = 1 } return microEUR, true } } cachedRate := price.CachedInputMicroEURPerMTok if cachedRate == 0 { cachedRate = price.InputMicroEURPerMTok / 2 } reasoningRate := price.ReasoningMicroEURPerMTok if reasoningRate == 0 { reasoningRate = price.OutputMicroEURPerMTok } uncached := usage.UncachedInputTokens() microEUR += int64(uncached) * price.InputMicroEURPerMTok / 1_000_000 microEUR += int64(usage.CachedInputTokens) * cachedRate / 1_000_000 completion := usage.CompletionTokens - usage.ReasoningTokens if completion < 0 { completion = usage.CompletionTokens } microEUR += int64(completion) * price.OutputMicroEURPerMTok / 1_000_000 microEUR += int64(usage.ReasoningTokens) * reasoningRate / 1_000_000 if microEUR == 0 && usage.TotalTokens > 0 { microEUR = 1 } return microEUR, estimated } // UpsertModelPrice stores or updates pricing for a model (effective today). func (s *PricingStore) UpsertModelPrice(ctx context.Context, p ModelPrice) error { if s.db == nil { return nil } today := time.Now().UTC().Truncate(24 * time.Hour) var cached, reasoning *int64 if p.CachedInputMicroEURPerMTok > 0 { v := p.CachedInputMicroEURPerMTok cached = &v } if p.ReasoningMicroEURPerMTok > 0 { v := p.ReasoningMicroEURPerMTok reasoning = &v } _, err := s.db.Exec(ctx, ` INSERT INTO ai_model_pricing ( model_id, provider_type, input_micro_eur_per_mtok, cached_input_micro_eur_per_mtok, output_micro_eur_per_mtok, reasoning_micro_eur_per_mtok, effective_from, source ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual') ON CONFLICT (model_id, effective_from) DO UPDATE SET provider_type = EXCLUDED.provider_type, input_micro_eur_per_mtok = EXCLUDED.input_micro_eur_per_mtok, cached_input_micro_eur_per_mtok = EXCLUDED.cached_input_micro_eur_per_mtok, output_micro_eur_per_mtok = EXCLUDED.output_micro_eur_per_mtok, reasoning_micro_eur_per_mtok = EXCLUDED.reasoning_micro_eur_per_mtok, source = EXCLUDED.source `, p.ModelID, p.ProviderType, p.InputMicroEURPerMTok, cached, p.OutputMicroEURPerMTok, reasoning, today) return err } func (s *PricingStore) ListPrices(ctx context.Context) ([]ModelPrice, error) { if s.db == nil { return nil, nil } rows, err := s.db.Query(ctx, ` SELECT DISTINCT ON (model_id) model_id, provider_type, input_micro_eur_per_mtok, cached_input_micro_eur_per_mtok, output_micro_eur_per_mtok, reasoning_micro_eur_per_mtok FROM ai_model_pricing WHERE effective_from <= CURRENT_DATE ORDER BY model_id, effective_from DESC `) if err != nil { return nil, err } defer rows.Close() var out []ModelPrice for rows.Next() { var p ModelPrice var cached, reasoning *int64 if err := rows.Scan(&p.ModelID, &p.ProviderType, &p.InputMicroEURPerMTok, &cached, &p.OutputMicroEURPerMTok, &reasoning); err != nil { return nil, err } if cached != nil { p.CachedInputMicroEURPerMTok = *cached } if reasoning != nil { p.ReasoningMicroEURPerMTok = *reasoning } out = append(out, p) } return out, rows.Err() } // MicroEURToEUR converts micro-EUR to float EUR for API responses. func MicroEURToEUR(micro int64) float64 { return float64(micro) / 1_000_000 }