diff --git a/apps/api/cmd/backfill-ai-usage/main.go b/apps/api/cmd/backfill-ai-usage/main.go new file mode 100644 index 0000000..8e36538 --- /dev/null +++ b/apps/api/cmd/backfill-ai-usage/main.go @@ -0,0 +1,100 @@ +// Command backfill-ai-usage recovers AI cost history for products that were +// processed before prompt/usage capture existed. +// +// It reads the provider usage blocks still stored in processed_products.gpt_response +// and rolls them into ai_usage_daily with source='backfill', so /admin/ai-costs +// shows spend from before the feature shipped. See internal/aiaudit/backfill.go for +// what is and is not recoverable. +// +// go run ./cmd/backfill-ai-usage # dry run, reports what it would write +// go run ./cmd/backfill-ai-usage -apply +// go run ./cmd/backfill-ai-usage -apply -before 2026-08-23 +// +// Safe to re-run: each pass replaces its own rows and never touches live capture. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit" + "github.com/descrybe/descrybe-v2/apps/api/internal/config" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + apply := flag.Bool("apply", false, "write rows (default is a dry run)") + before := flag.String("before", "", "only products last updated before this date (YYYY-MM-DD); default: the day live capture started") + dsn := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL (defaults to DATABASE_URL / repo .env)") + flag.Parse() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + url := strings.TrimSpace(*dsn) + if url == "" { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + url = cfg.DatabaseURL + } + pool, err := pgxpool.New(ctx, url) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + cutoff, err := resolveCutoff(ctx, pool, *before) + if err != nil { + log.Fatalf("cutoff: %v", err) + } + + res, err := aiaudit.BackfillFromProcessedProducts(ctx, pool, cutoff, !*apply) + if err != nil { + log.Fatalf("backfill: %v", err) + } + + b, _ := json.MarshalIndent(res, "", " ") + fmt.Println(string(b)) + fmt.Printf("\nrecovered %s across %d product(s); %d product(s) had no stored usage block\n", + usd(res.CostUSD()), res.ProductsUsed, res.ProductsNoUsage) + if !*apply { + fmt.Println("dry run — re-run with -apply to write these rows") + } +} + +// resolveCutoff keeps the backfill from double-counting calls the live recorder +// already logged: by default it stops at the first day live capture wrote a row. +func resolveCutoff(ctx context.Context, pool *pgxpool.Pool, before string) (time.Time, error) { + if s := strings.TrimSpace(before); s != "" { + t, err := time.Parse("2006-01-02", s) + if err != nil { + return time.Time{}, fmt.Errorf("invalid -before date (YYYY-MM-DD): %w", err) + } + return t.UTC(), nil + } + start, err := aiaudit.LiveCaptureStart(ctx, pool) + if err != nil { + return time.Time{}, err + } + if start.IsZero() { + log.Println("no live capture recorded yet — scanning all history") + return time.Time{}, nil + } + log.Printf("live capture starts %s — backfilling products updated before that", start.Format("2006-01-02")) + return start, nil +} + +func usd(v float64) string { + if v != 0 && v < 0.01 { + return fmt.Sprintf("$%.6f", v) + } + return fmt.Sprintf("$%.2f", v) +} diff --git a/apps/api/cmd/worker/main.go b/apps/api/cmd/worker/main.go index b51bc22..310182d 100644 --- a/apps/api/cmd/worker/main.go +++ b/apps/api/cmd/worker/main.go @@ -380,6 +380,12 @@ func main() { } else if n > 0 { log.Printf("worker: ai call log cleanup removed=%d older_than_days=%d", n, aiaudit.RetentionDays) } + // Cost rollup is kept for years, unlike the prompt bodies above. + if n, err := aiaudit.CleanupExpiredUsage(ctx, pool); err != nil { + log.Printf("worker: ai usage cleanup: %v", err) + } else if n > 0 { + log.Printf("worker: ai usage cleanup removed=%d older_than_days=%d", n, aiaudit.UsageRetentionDays) + } if res, err := processing.CleanupExpiredSyncJobs(ctx, pool); err != nil { log.Printf("expired sync cleanup: %v", err) } else if res.SyncJobsDeleted > 0 { diff --git a/apps/api/internal/aiaudit/aiaudit.go b/apps/api/internal/aiaudit/aiaudit.go index 90e3447..6e6d5b3 100644 --- a/apps/api/internal/aiaudit/aiaudit.go +++ b/apps/api/internal/aiaudit/aiaudit.go @@ -54,9 +54,12 @@ type Call struct { Error string FinishReason string PromptTokens int - OutputTokens int - TotalTokens int - Duration time.Duration + // CachedPromptTokens is the cached subset of PromptTokens — billed far cheaper, + // so cost accounting must know about it. + CachedPromptTokens int + OutputTokens int + TotalTokens int + Duration time.Duration } // CallContext is the per-call metadata processing/campaigns attach to ctx before @@ -110,6 +113,9 @@ type Recorder struct { mu sync.Mutex failed bool // stop log-spamming once writes are known to fail + + // prices caches ai_model_prices for cost rollup (see cost.go). + prices priceCache } // NewRecorder returns a Recorder writing to pool. nil pool yields a no-op recorder. @@ -170,6 +176,9 @@ func (r *Recorder) Record(ctx context.Context, c Call) { if err != nil { r.noteFailure(err) } + // Roll the same call into the durable daily cost aggregate. ai_call_logs is + // pruned after a week; spend has to survive for years (see cost.go). + r.recordUsage(ctx, c) } func (r *Recorder) noteFailure(err error) { diff --git a/apps/api/internal/aiaudit/backfill.go b/apps/api/internal/aiaudit/backfill.go new file mode 100644 index 0000000..8f7e554 --- /dev/null +++ b/apps/api/internal/aiaudit/backfill.go @@ -0,0 +1,305 @@ +package aiaudit + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Recovering cost for products processed before capture existed. +// +// processed_products.gpt_response keeps the provider's usage block for some rows +// (model + prompt/completion tokens). That is enough to reconstruct spend per +// company and day, so history is not simply lost. It is explicitly best effort: +// +// - Only rows that still carry a usage block are recoverable. gpt_response is +// overwritten on every reprocess and absent on older/failed rows. +// - There is no per-user attribution: processed_products.user_id is null on these +// rows, so backfilled spend lands under "Unattributed" in the by-user view. +// - The day is processed_products.updated_at, which is the LAST write to the row, +// not necessarily when the call was made. +// - Cached-token discounts cannot be recovered (the field did not exist), so a +// backfilled call is priced as if none of its input was cached — an +// over-estimate, never an under-estimate. +// +// Rows are written with source='backfill' so they never collide with live capture +// and a re-run replaces rather than doubles them. + +// BackfillResult reports what a backfill pass recovered. +type BackfillResult struct { + ProductsScanned int `json:"products_scanned"` + ProductsUsed int `json:"products_used"` + ProductsNoUsage int `json:"products_without_usage"` + CallsRecovered int `json:"calls_recovered"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + CostMicros int64 `json:"cost_micros"` + RowsWritten int `json:"rows_written"` + RowsDeleted int64 `json:"rows_deleted"` + Cutoff string `json:"cutoff"` + DryRun bool `json:"dry_run"` +} + +// CostUSD renders the recovered spend for CLI output. +func (r BackfillResult) CostUSD() float64 { + return float64(r.CostMicros) / float64(MicrosPerUSD) +} + +type backfillKey struct { + day time.Time + companyID uuid.UUID + model string + role string +} + +type backfillAgg struct { + calls int + promptTokens int64 + completionTokens int64 + totalTokens int64 + costMicros int64 +} + +// LiveCaptureStart returns the earliest day live capture recorded, or zero when +// nothing has been captured yet. The backfill uses it as a default cutoff so a +// product processed after capture began is never counted twice. +func LiveCaptureStart(ctx context.Context, pool *pgxpool.Pool) (time.Time, error) { + if pool == nil { + return time.Time{}, nil + } + var day *time.Time + err := pool.QueryRow(ctx, ` + SELECT min(day) FROM ai_usage_daily WHERE source = $1`, SourceLive).Scan(&day) + if err != nil { + return time.Time{}, err + } + if day == nil { + return time.Time{}, nil + } + return *day, nil +} + +// BackfillFromProcessedProducts reconstructs cost history from stored provider +// responses for products last written before cutoff. Passing a zero cutoff scans +// everything, which is only safe when live capture has recorded nothing yet. +func BackfillFromProcessedProducts(ctx context.Context, pool *pgxpool.Pool, cutoff time.Time, dryRun bool) (BackfillResult, error) { + out := BackfillResult{DryRun: dryRun} + if pool == nil { + return out, fmt.Errorf("nil pool") + } + if cutoff.IsZero() { + // Far future = no bound. + cutoff = time.Now().UTC().AddDate(100, 0, 0) + } + out.Cutoff = cutoff.Format("2006-01-02") + + prices, err := loadPrices(ctx, pool) + if err != nil { + return out, err + } + + rows, err := pool.Query(ctx, ` + SELECT company_id, updated_at, COALESCE(gpt_response, '{}'::jsonb) + FROM processed_products + WHERE gpt_response IS NOT NULL + AND updated_at < $1 + ORDER BY updated_at`, cutoff) + if err != nil { + return out, err + } + defer rows.Close() + + agg := map[backfillKey]*backfillAgg{} + for rows.Next() { + var companyID uuid.UUID + var updatedAt time.Time + var raw []byte + if err := rows.Scan(&companyID, &updatedAt, &raw); err != nil { + return out, err + } + out.ProductsScanned++ + calls := extractUsageCalls(raw) + if len(calls) == 0 { + out.ProductsNoUsage++ + continue + } + out.ProductsUsed++ + day := updatedAt.UTC().Truncate(24 * time.Hour) + for _, c := range calls { + price := priceFromMap(prices, c.Model) + // Cached tokens are unknown for history — price all input as fresh. + cost := CostMicros(price, c.PromptTokens, 0, c.CompletionTokens) + k := backfillKey{day: day, companyID: companyID, model: NormalizeModel(c.Model), role: c.Role} + a := agg[k] + if a == nil { + a = &backfillAgg{} + agg[k] = a + } + a.calls++ + a.promptTokens += int64(c.PromptTokens) + a.completionTokens += int64(c.CompletionTokens) + a.totalTokens += int64(c.TotalTokens) + a.costMicros += cost + + out.CallsRecovered++ + out.PromptTokens += int64(c.PromptTokens) + out.CompletionTokens += int64(c.CompletionTokens) + out.CostMicros += cost + } + } + if err := rows.Err(); err != nil { + return out, err + } + out.RowsWritten = len(agg) + if dryRun { + return out, nil + } + + tx, err := pool.Begin(ctx) + if err != nil { + return out, err + } + defer func() { _ = tx.Rollback(ctx) }() + + // Replace rather than add: a re-run must not double the recovered history. + tag, err := tx.Exec(ctx, `DELETE FROM ai_usage_daily WHERE source = $1`, SourceBackfill) + if err != nil { + return out, err + } + out.RowsDeleted = tag.RowsAffected() + + for k, a := range agg { + if _, err := tx.Exec(ctx, ` + INSERT INTO ai_usage_daily ( + day, company_id, user_id, model, role, source, + calls, failed_calls, prompt_tokens, cached_prompt_tokens, + completion_tokens, total_tokens, cost_micros, updated_at) + VALUES ($1, $2, '00000000-0000-0000-0000-000000000000', $3, $4, $5, + $6, 0, $7, 0, $8, $9, $10, now())`, + k.day, k.companyID, k.model, k.role, SourceBackfill, + a.calls, a.promptTokens, a.completionTokens, a.totalTokens, a.costMicros); err != nil { + return out, err + } + } + if err := tx.Commit(ctx); err != nil { + return out, err + } + return out, nil +} + +func loadPrices(ctx context.Context, pool *pgxpool.Pool) (map[string]ModelPrice, error) { + out := map[string]ModelPrice{} + rows, err := pool.Query(ctx, ` + SELECT model, input_micros_per_mtok, cached_input_micros_per_mtok, output_micros_per_mtok + FROM ai_model_prices`) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var model string + var in, cached, o int64 + if err := rows.Scan(&model, &in, &cached, &o); err != nil { + return out, err + } + out[NormalizeModel(model)] = ModelPrice{InputPerMTok: in, CachedInputPerMTok: cached, OutputPerMTok: o} + } + return out, rows.Err() +} + +func priceFromMap(prices map[string]ModelPrice, model string) ModelPrice { + key := NormalizeModel(model) + if p, ok := prices[key]; ok { + return p + } + if p, ok := DefaultModelPrice(key); ok { + return p + } + return ModelPrice{} +} + +// recoveredCall is one provider response found inside gpt_response. +type recoveredCall struct { + Model string + Role string + PromptTokens int + CompletionTokens int + TotalTokens int +} + +// extractUsageCalls walks a stored gpt_response and returns every provider usage +// block it holds. The shape varies by pipeline version, so this looks for any +// object with a "usage" child rather than assuming one fixed path — older rows +// nest it differently and a rigid path would silently recover nothing. +func extractUsageCalls(raw []byte) []recoveredCall { + var doc any + if err := json.Unmarshal(raw, &doc); err != nil { + return nil + } + var out []recoveredCall + walkUsage(doc, "", &out) + return out +} + +func walkUsage(node any, stepHint string, out *[]recoveredCall) { + switch v := node.(type) { + case map[string]any: + if s, ok := v["step"].(string); ok && s != "" { + stepHint = s + } + if usage, ok := v["usage"].(map[string]any); ok { + call := recoveredCall{ + Model: stringField(v, "model"), + Role: roleFromStep(stepHint), + PromptTokens: intField(usage, "prompt_tokens"), + CompletionTokens: intField(usage, "completion_tokens"), + TotalTokens: intField(usage, "total_tokens"), + } + if call.PromptTokens > 0 || call.CompletionTokens > 0 { + *out = append(*out, call) + } + } + for _, child := range v { + walkUsage(child, stepHint, out) + } + case []any: + for _, child := range v { + walkUsage(child, stepHint, out) + } + } +} + +func roleFromStep(step string) string { + switch step { + case "categorize": + return RoleCategorize + case "ai_enhance", "": + return RoleProcessing + default: + return RoleProcessing + } +} + +func stringField(m map[string]any, key string) string { + if s, ok := m[key].(string); ok { + return s + } + return "" +} + +func intField(m map[string]any, key string) int { + switch v := m[key].(type) { + case float64: + return int(v) + case int: + return v + case json.Number: + n, _ := v.Int64() + return int(n) + } + return 0 +} diff --git a/apps/api/internal/aiaudit/backfill_test.go b/apps/api/internal/aiaudit/backfill_test.go new file mode 100644 index 0000000..2c0eb41 --- /dev/null +++ b/apps/api/internal/aiaudit/backfill_test.go @@ -0,0 +1,81 @@ +package aiaudit + +import ( + "testing" +) + +// gpt_response nests the usage block differently across pipeline versions, so the +// walker looks for any object carrying "usage" rather than one fixed path. A rigid +// path would silently recover nothing from older rows. +func TestExtractUsageCalls_findsNestedUsage(t *testing.T) { + t.Parallel() + raw := []byte(`{ + "steps": [ + {"step": "normalize", "raw": {"keys": 21}}, + {"step": "categorize", "raw": {"model": "gpt-5.6-luna", + "usage": {"prompt_tokens": 700, "completion_tokens": 5, "total_tokens": 705}}}, + {"step": "ai_enhance", "raw": {"languages": [ + {"language": "sl", "raw": {"raw": {"model": "gpt-5.6-luna", "status": 200, + "usage": {"prompt_tokens": 4000, "completion_tokens": 800, "total_tokens": 4800}}}} + ]}} + ] + }`) + calls := extractUsageCalls(raw) + if len(calls) != 2 { + t.Fatalf("expected 2 recovered calls, got %d: %+v", len(calls), calls) + } + byRole := map[string]recoveredCall{} + for _, c := range calls { + byRole[c.Role] = c + } + cat, ok := byRole[RoleCategorize] + if !ok { + t.Fatalf("categorize call not recovered: %+v", calls) + } + if cat.PromptTokens != 700 || cat.Model != "gpt-5.6-luna" { + t.Fatalf("categorize call wrong: %+v", cat) + } + enh, ok := byRole[RoleProcessing] + if !ok { + t.Fatalf("enhance call not recovered: %+v", calls) + } + if enh.PromptTokens != 4000 || enh.CompletionTokens != 800 { + t.Fatalf("enhance call wrong: %+v", enh) + } +} + +func TestExtractUsageCalls_ignoresRowsWithoutUsage(t *testing.T) { + t.Parallel() + for name, raw := range map[string]string{ + "empty": `{}`, + "steps only": `{"steps":[{"step":"normalize","raw":{"keys":3}}]}`, + "zero tokens": `{"raw":{"usage":{"prompt_tokens":0,"completion_tokens":0}}}`, + "not json": `not json at all`, + "unchanged skip": `{"steps":[{"step":"ai_enhance","raw":{"languages":[{"raw":{"status":"unchanged"}}]}}]}`, + } { + t.Run(name, func(t *testing.T) { + if got := extractUsageCalls([]byte(raw)); len(got) != 0 { + t.Fatalf("expected no recovered calls, got %+v", got) + } + }) + } +} + +// Backfilled history has no cached-token detail, so input is priced as fully +// fresh. That must over-estimate, never under-estimate. +func TestBackfillPricing_treatsInputAsUncached(t *testing.T) { + t.Parallel() + luna, _ := DefaultModelPrice("gpt-5.6-luna") + backfilled := CostMicros(luna, 10_000, 0, 1_000) + ifHalfCached := CostMicros(luna, 10_000, 5_000, 1_000) + if backfilled <= ifHalfCached { + t.Fatalf("backfill cost %d should exceed the cached-aware cost %d", backfilled, ifHalfCached) + } +} + +func TestBackfillSourcesAreDistinct(t *testing.T) { + t.Parallel() + if SourceLive == SourceBackfill { + t.Fatal("live and backfill rows must be distinguishable") + } +} diff --git a/apps/api/internal/aiaudit/cost.go b/apps/api/internal/aiaudit/cost.go new file mode 100644 index 0000000..dba5353 --- /dev/null +++ b/apps/api/internal/aiaudit/cost.go @@ -0,0 +1,248 @@ +package aiaudit + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Cost accounting. Kept apart from the prompt capture above on purpose: +// ai_call_logs is a 7-day debugging buffer, while spend has to answer "what did +// this tenant cost me last quarter" for years. Usage is therefore rolled up per +// day / company / user / model / role as each call completes. +// +// Money is micro-USD integers end to end. Summing millions of fractional-cent +// calls in float drifts; integers add up exactly. + +// UsageRetentionDays bounds the rollup. Three years plus a leap day. +const UsageRetentionDays = 3*365 + 1 + +// MicrosPerUSD converts the stored integer unit to dollars. +const MicrosPerUSD = 1_000_000 + +// ModelPrice is micro-USD per 1,000,000 tokens. +type ModelPrice struct { + InputPerMTok int64 + CachedInputPerMTok int64 + OutputPerMTok int64 +} + +// defaultModelPrices is the fallback when ai_model_prices has no row for a model +// (a provider the admin pointed at without adding a price). Keeping the shipped +// GPT-5.6 rates here means a fresh database still costs correctly before the +// migration seed is edited. Rates: OpenAI list price, 2026-07-30. +var defaultModelPrices = map[string]ModelPrice{ + "gpt-5.6-luna": {InputPerMTok: 200_000, CachedInputPerMTok: 20_000, OutputPerMTok: 1_200_000}, + "gpt-5.6-terra": {InputPerMTok: 2_000_000, CachedInputPerMTok: 200_000, OutputPerMTok: 12_000_000}, + "gpt-5.6-sol": {InputPerMTok: 5_000_000, CachedInputPerMTok: 500_000, OutputPerMTok: 30_000_000}, +} + +// DefaultModelPrice returns the shipped price for a model, ok=false when unknown. +func DefaultModelPrice(model string) (ModelPrice, bool) { + p, ok := defaultModelPrices[NormalizeModel(model)] + return p, ok +} + +// NormalizeModel folds provider prefixes and dated suffixes onto the price key, so +// "openai/gpt-5.6-luna" and "gpt-5.6-luna-2026-07-30" both price as gpt-5.6-luna. +func NormalizeModel(model string) string { + m := strings.ToLower(strings.TrimSpace(model)) + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + // Trim a trailing -YYYY-MM-DD snapshot tag. + if len(m) > 11 { + tail := m[len(m)-11:] + if tail[0] == '-' && isDigits(tail[1:5]) && tail[5] == '-' && isDigits(tail[6:8]) && tail[8] == '-' && isDigits(tail[9:11]) { + m = m[:len(m)-11] + } + } + return m +} + +func isDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return len(s) > 0 +} + +// CostMicros prices one call. Cached prompt tokens are billed at the cached rate +// and subtracted from fresh input — they are a subset of prompt tokens, not extra. +func CostMicros(p ModelPrice, promptTokens, cachedPromptTokens, completionTokens int) int64 { + if cachedPromptTokens < 0 { + cachedPromptTokens = 0 + } + if cachedPromptTokens > promptTokens { + cachedPromptTokens = promptTokens + } + fresh := int64(promptTokens - cachedPromptTokens) + cached := int64(cachedPromptTokens) + out := int64(completionTokens) + if out < 0 { + out = 0 + } + if fresh < 0 { + fresh = 0 + } + // Rounded division keeps small calls from always costing zero. + return divRound(fresh*p.InputPerMTok, 1_000_000) + + divRound(cached*p.CachedInputPerMTok, 1_000_000) + + divRound(out*p.OutputPerMTok, 1_000_000) +} + +func divRound(n, d int64) int64 { + if d == 0 { + return 0 + } + return (n + d/2) / d +} + +// priceCache avoids a DB read per call; prices change at most a few times a year. +type priceCache struct { + mu sync.RWMutex + prices map[string]ModelPrice + loadedAt time.Time +} + +const priceCacheTTL = 5 * time.Minute + +func (r *Recorder) priceFor(ctx context.Context, model string) ModelPrice { + key := NormalizeModel(model) + if r != nil { + r.prices.mu.RLock() + fresh := time.Since(r.prices.loadedAt) < priceCacheTTL + p, ok := r.prices.prices[key] + r.prices.mu.RUnlock() + if fresh && ok { + return p + } + if !fresh { + r.reloadPrices(ctx) + r.prices.mu.RLock() + p, ok = r.prices.prices[key] + r.prices.mu.RUnlock() + if ok { + return p + } + } + } + if p, ok := DefaultModelPrice(key); ok { + return p + } + // Unknown model: record usage with zero cost rather than guessing a rate. + return ModelPrice{} +} + +func (r *Recorder) reloadPrices(ctx context.Context) { + if r == nil || r.pool == nil { + return + } + rows, err := r.pool.Query(ctx, ` + SELECT model, input_micros_per_mtok, cached_input_micros_per_mtok, output_micros_per_mtok + FROM ai_model_prices`) + if err != nil { + // Keep whatever is cached; defaults still apply for misses. + r.prices.mu.Lock() + r.prices.loadedAt = time.Now() + r.prices.mu.Unlock() + return + } + defer rows.Close() + loaded := map[string]ModelPrice{} + for rows.Next() { + var model string + var in, cached, out int64 + if err := rows.Scan(&model, &in, &cached, &out); err != nil { + continue + } + loaded[NormalizeModel(model)] = ModelPrice{ + InputPerMTok: in, CachedInputPerMTok: cached, OutputPerMTok: out, + } + } + r.prices.mu.Lock() + r.prices.prices = loaded + r.prices.loadedAt = time.Now() + r.prices.mu.Unlock() +} + +// SourceLive marks rows written by the recorder from a provider usage block. +// SourceBackfill marks history reconstructed from processed_products (see +// BackfillFromProcessedProducts) — no user attribution, best effort. +const ( + SourceLive = "live" + SourceBackfill = "backfill" +) + +const upsertUsageSQL = ` + INSERT INTO ai_usage_daily ( + day, company_id, user_id, model, role, source, + calls, failed_calls, prompt_tokens, cached_prompt_tokens, completion_tokens, + total_tokens, cost_micros, updated_at) + VALUES ( + (now() AT TIME ZONE 'UTC')::date, $1, $2, $3, $4, 'live', + 1, $5, $6, $7, $8, + $9, $10, now()) + ON CONFLICT (day, company_id, user_id, model, role, source) DO UPDATE SET + calls = ai_usage_daily.calls + 1, + failed_calls = ai_usage_daily.failed_calls + EXCLUDED.failed_calls, + prompt_tokens = ai_usage_daily.prompt_tokens + EXCLUDED.prompt_tokens, + cached_prompt_tokens = ai_usage_daily.cached_prompt_tokens + EXCLUDED.cached_prompt_tokens, + completion_tokens = ai_usage_daily.completion_tokens + EXCLUDED.completion_tokens, + total_tokens = ai_usage_daily.total_tokens + EXCLUDED.total_tokens, + cost_micros = ai_usage_daily.cost_micros + EXCLUDED.cost_micros, + updated_at = now()` + +// recordUsage rolls one call into the daily aggregate. Like Record it must never +// break the call it observes. +func (r *Recorder) recordUsage(ctx context.Context, c Call) { + if r == nil || r.pool == nil { + return + } + price := r.priceFor(ctx, c.Model) + cost := CostMicros(price, c.PromptTokens, c.CachedPromptTokens, c.OutputTokens) + failed := int64(0) + if strings.TrimSpace(c.Error) != "" { + failed = 1 + } + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + _, err := r.pool.Exec(writeCtx, upsertUsageSQL, + orNilUUID(c.CompanyID), orNilUUID(c.UserID), NormalizeModel(c.Model), roleOrOther(c.Role), + failed, c.PromptTokens, c.CachedPromptTokens, c.OutputTokens, + c.TotalTokens, cost, + ) + if err != nil { + r.noteFailure(err) + } +} + +// orNilUUID keeps the primary key non-NULL (see the migration comment). +func orNilUUID(id uuid.UUID) uuid.UUID { return id } + +func roleOrOther(role string) string { + if r := strings.TrimSpace(role); r != "" { + return r + } + return RoleOther +} + +// CleanupExpiredUsage drops rollup rows past UsageRetentionDays. +func CleanupExpiredUsage(ctx context.Context, pool *pgxpool.Pool) (int64, error) { + if pool == nil { + return 0, nil + } + tag, err := pool.Exec(ctx, ` + DELETE FROM ai_usage_daily + WHERE day < ((now() AT TIME ZONE 'UTC')::date - ($1::int))`, UsageRetentionDays) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/apps/api/internal/aiaudit/cost_test.go b/apps/api/internal/aiaudit/cost_test.go new file mode 100644 index 0000000..1c3a33b --- /dev/null +++ b/apps/api/internal/aiaudit/cost_test.go @@ -0,0 +1,104 @@ +package aiaudit + +import "testing" + +// Luna list price, 2026-07-30: $0.20 / $0.02 cached / $1.20 per 1M tokens. +func TestCostMicros_lunaListPrice(t *testing.T) { + t.Parallel() + luna, ok := DefaultModelPrice("gpt-5.6-luna") + if !ok { + t.Fatal("gpt-5.6-luna must have a shipped price") + } + if luna.InputPerMTok != 200_000 || luna.CachedInputPerMTok != 20_000 || luna.OutputPerMTok != 1_200_000 { + t.Fatalf("unexpected Luna price: %+v", luna) + } + + // 1M input + 1M output = $0.20 + $1.20 = $1.40 = 1_400_000 micros. + if got := CostMicros(luna, 1_000_000, 0, 1_000_000); got != 1_400_000 { + t.Fatalf("got %d micros want 1400000", got) + } + // A realistic enhance call: 4k prompt, 800 completion. + // 4000*0.2 + 800*1.2 per 1M = 800 + 960 = 1760 micros ($0.00176). + if got := CostMicros(luna, 4000, 0, 800); got != 1760 { + t.Fatalf("got %d micros want 1760", got) + } +} + +// Cached tokens are a SUBSET of prompt tokens, not extra ones. Billing them on top +// of full input would overstate every repeated system prompt. +func TestCostMicros_cachedTokensAreDiscountedNotAdded(t *testing.T) { + t.Parallel() + luna, _ := DefaultModelPrice("gpt-5.6-luna") + + full := CostMicros(luna, 1_000_000, 0, 0) // 1M fresh input = 200000 + allCached := CostMicros(luna, 1_000_000, 1_000_000, 0) // 1M cached = 20000 + if full != 200_000 { + t.Fatalf("fresh input = %d want 200000", full) + } + if allCached != 20_000 { + t.Fatalf("fully cached input = %d want 20000", allCached) + } + if allCached >= full { + t.Fatal("cached input must be cheaper than fresh input") + } + // Half cached: 500k*0.2 + 500k*0.02 per 1M = 100000 + 10000. + if got := CostMicros(luna, 1_000_000, 500_000, 0); got != 110_000 { + t.Fatalf("half cached = %d want 110000", got) + } + // A provider reporting more cached than prompt tokens must not go negative. + if got := CostMicros(luna, 100, 900, 0); got < 0 { + t.Fatalf("cached > prompt produced negative cost %d", got) + } +} + +// Small calls must not round to zero, or per-call spend vanishes at scale. +func TestCostMicros_smallCallsStillCost(t *testing.T) { + t.Parallel() + luna, _ := DefaultModelPrice("gpt-5.6-luna") + if got := CostMicros(luna, 500, 0, 100); got <= 0 { + t.Fatalf("small call cost %d, expected > 0", got) + } +} + +func TestCostMicros_unknownModelIsFree(t *testing.T) { + t.Parallel() + if _, ok := DefaultModelPrice("some-local-model"); ok { + t.Fatal("unknown model should not have a shipped price") + } + // Zero price rather than a guessed rate: better to under-report than invent spend. + if got := CostMicros(ModelPrice{}, 1_000_000, 0, 1_000_000); got != 0 { + t.Fatalf("unpriced model cost %d want 0", got) + } +} + +func TestNormalizeModel(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "gpt-5.6-luna": "gpt-5.6-luna", + "GPT-5.6-Luna": "gpt-5.6-luna", + "openai/gpt-5.6-luna": "gpt-5.6-luna", + "gpt-5.6-luna-2026-07-30": "gpt-5.6-luna", + " openai/GPT-5.6-Terra ": "gpt-5.6-terra", + "mock-llm": "mock-llm", + } + for in, want := range cases { + if got := NormalizeModel(in); got != want { + t.Fatalf("NormalizeModel(%q) = %q want %q", in, got, want) + } + } + // A dated snapshot must still price like its base model. + if _, ok := DefaultModelPrice("gpt-5.6-luna-2026-07-30"); !ok { + t.Fatal("dated snapshot should resolve to the base model price") + } +} + +// Three years, so quarter- and year-scale cost questions still have data. +func TestUsageRetentionCoversThreeYears(t *testing.T) { + t.Parallel() + if UsageRetentionDays < 3*365 { + t.Fatalf("usage retention %d days is under 3 years", UsageRetentionDays) + } + if RetentionDays >= UsageRetentionDays { + t.Fatal("prompt bodies must expire long before the cost rollup") + } +} diff --git a/apps/api/internal/aiprovider/audit.go b/apps/api/internal/aiprovider/audit.go index 6a453ae..1753699 100644 --- a/apps/api/internal/aiprovider/audit.go +++ b/apps/api/internal/aiprovider/audit.go @@ -84,18 +84,19 @@ func (a *auditingCompleter) record(ctx context.Context, system, user string, cal role = cc.Role } rec := aiaudit.Call{ - CompanyID: a.companyID, - Role: role, - ProviderMode: a.providerMode, - Model: comp.Model, - System: system, - User: user, - Response: comp.Text, - FinishReason: finishReason(comp), - PromptTokens: comp.PromptTokens, - OutputTokens: comp.OutputTokens, - TotalTokens: comp.TotalTokens, - Duration: time.Since(started), + CompanyID: a.companyID, + Role: role, + ProviderMode: a.providerMode, + Model: comp.Model, + System: system, + User: user, + Response: comp.Text, + FinishReason: finishReason(comp), + PromptTokens: comp.PromptTokens, + CachedPromptTokens: comp.CachedPromptTokens, + OutputTokens: comp.OutputTokens, + TotalTokens: comp.TotalTokens, + Duration: time.Since(started), } if err != nil { // The provider error text is already redacted by processing's OpenAI client. diff --git a/apps/api/internal/catalog/service.go b/apps/api/internal/catalog/service.go index 53439c6..13d4534 100644 --- a/apps/api/internal/catalog/service.go +++ b/apps/api/internal/catalog/service.go @@ -679,6 +679,14 @@ const ( // Prefer processed_* so dashboard product fields match V1 process items for the same EAN. processedPreferredNameSQL = `COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), NULLIF(r.mapped_data->>'name', ''), NULLIF(r.mapped_data->>'title', ''), '')` processedPreferredDescriptionSQL = `COALESCE(NULLIF(p.processed_description, ''), NULLIF(p.description, ''), NULLIF(r.mapped_data->>'description', ''), '')` + // Pre-enrichment values, with NO processed_* fallback. The preferred SQL above + // resolves to the enriched copy whenever it exists, so a review screen that read + // "name"/"description" showed the AI output on BOTH sides and every product + // looked unchanged. Review must compare against these instead. + processedOriginalNameSQL = `COALESCE(NULLIF(p.name, ''), NULLIF(r.mapped_data->>'name', ''), NULLIF(r.mapped_data->>'title', ''), '')` + processedOriginalDescriptionSQL = `COALESCE(NULLIF(p.description, ''), NULLIF(r.mapped_data->>'description', ''), '')` + // Category the feed supplied (before categorize assigned one). + processedFeedCategorySQL = `COALESCE(NULLIF(BTRIM(r.mapped_data->>'category'), ''), NULLIF(BTRIM(r.mapped_data->>'category_unique_id'), ''), '')` processedHasNameSQL = `(` + processedPreferredNameSQL + ` <> '')` processedHasDescriptionSQL = `(` + processedPreferredDescriptionSQL + ` <> '')` processedHasCategorySQL = `(COALESCE(NULLIF(p.category, ''), '') <> '' AND lower(p.category) <> 'none')` @@ -1133,6 +1141,7 @@ func (s *Service) ListProcessedProducts(ctx context.Context, companyID uuid.UUID rows, err := s.Pool.Query(ctx, fmt.Sprintf(` SELECT p.id, p.product_id, `+processedPreferredNameSQL+` AS name, + `+processedOriginalNameSQL+` AS original_name, COALESCE(NULLIF(p.processed_name, ''), '') AS processed_name, p.category, cat.name AS category_name, @@ -1161,7 +1170,7 @@ func (s *Service) ListProcessedProducts(ctx context.Context, companyID uuid.UUID } defer rows.Close() items, err := scanMaps(rows, []string{ - "id", "product_id", "name", "processed_name", "category", "category_name", "category_unique_id", + "id", "product_id", "name", "original_name", "processed_name", "category", "category_name", "category_unique_id", "status", "raw_product_id", "feed_id", "gtin", "feed_name", "feed_last_synced_at", "raw_updated_at", "has_name", "has_processed_name", "has_description", "has_processed_description", "has_category", "has_attributes", "has_processed_attributes", @@ -1223,11 +1232,14 @@ func (s *Service) ListProcessedProductsDetailed(ctx context.Context, companyID u rows, err := s.Pool.Query(ctx, fmt.Sprintf(` SELECT p.id, p.product_id, `+processedPreferredNameSQL+` AS name, + `+processedOriginalNameSQL+` AS original_name, p.category, cat.name AS category_name, COALESCE(cat.unique_id, NULLIF(BTRIM(p.category), '')) AS category_unique_id, + `+processedFeedCategorySQL+` AS feed_category, p.status, p.raw_product_id, p.feed_id, r.gtin, `+processedPreferredDescriptionSQL+` AS description, + `+processedOriginalDescriptionSQL+` AS original_description, p.processed_name, p.processed_description, COALESCE(p.meta_title, ''), COALESCE(p.meta_description, ''), p.attributes, p.processed_attributes, r.mapped_data, @@ -1241,9 +1253,10 @@ func (s *Service) ListProcessedProductsDetailed(ctx context.Context, companyID u } defer rows.Close() items, err := scanMaps(rows, []string{ - "id", "product_id", "name", "category", "category_name", "category_unique_id", + "id", "product_id", "name", "original_name", "category", "category_name", "category_unique_id", + "feed_category", "status", "raw_product_id", "feed_id", "gtin", - "description", "processed_name", "processed_description", + "description", "original_description", "processed_name", "processed_description", "meta_title", "meta_description", "attributes", "processed_attributes", "mapped_data", "created_at", "updated_at", @@ -1263,10 +1276,13 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU row := s.Pool.QueryRow(ctx, ` SELECT p.id, p.product_id, `+processedPreferredNameSQL+` AS name, + `+processedOriginalNameSQL+` AS original_name, p.category, cat.name AS category_name, COALESCE(cat.unique_id, NULLIF(BTRIM(p.category), '')) AS category_unique_id, + `+processedFeedCategorySQL+` AS feed_category, `+processedPreferredDescriptionSQL+` AS description, + `+processedOriginalDescriptionSQL+` AS original_description, p.processed_name, p.processed_description, p.status, p.attributes, p.processed_attributes, r.gtin, r.mapped_data, COALESCE(p.feed_id, r.feed_id) AS feed_id, @@ -1285,8 +1301,9 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU LEFT JOIN input_feeds f ON f.id = COALESCE(p.feed_id, r.feed_id)`+processedCategoryResolveJoin+` WHERE p.id = $1 AND p.company_id = $2`, id, companyID) item, err := scanMap(row, []string{ - "id", "product_id", "name", "category", "category_name", "category_unique_id", - "description", "processed_name", "processed_description", + "id", "product_id", "name", "original_name", "category", "category_name", "category_unique_id", + "feed_category", + "description", "original_description", "processed_name", "processed_description", "status", "attributes", "processed_attributes", "gtin", "mapped_data", "feed_id", "feed_name", "feed_last_synced_at", "raw_updated_at", "has_processed_name", "has_processed_description", "has_attributes", "has_processed_attributes", diff --git a/apps/api/internal/httpapi/admin_ai_costs_handlers.go b/apps/api/internal/httpapi/admin_ai_costs_handlers.go new file mode 100644 index 0000000..194195c --- /dev/null +++ b/apps/api/internal/httpapi/admin_ai_costs_handlers.go @@ -0,0 +1,243 @@ +package httpapi + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit" + "github.com/google/uuid" +) + +// Admin AI cost report: what each tenant and each user costs, plus the total. +// +// Reads ai_usage_daily, the durable rollup written alongside every captured call. +// ai_call_logs (full prompts) is pruned after a week; this survives for +// aiaudit.UsageRetentionDays (3 years), so quarter- and year-scale questions work. + +const ( + adminAICostsTimeout = 30 * time.Second + adminAICostsMaxDays = 1200 // slightly over the 3-year retention + adminAICostsMaxRows = 500 + adminAICostsDefDays = 30 +) + +// handleAdminAICosts returns spend grouped by company and by user, with totals. +// GET /api/admin/ai-costs?days=30&from=&to=&company_id= +func (s *Server) handleAdminAICosts(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), adminAICostsTimeout) + defer cancel() + if s.Pool == nil { + Error(w, http.StatusServiceUnavailable, "database unavailable") + return + } + + q := r.URL.Query() + from, to, err := adminCostRange(q.Get("from"), q.Get("to"), q.Get("days")) + if err != nil { + Error(w, http.StatusBadRequest, err.Error()) + return + } + + where := []string{"day >= $1", "day <= $2"} + args := []any{from, to} + if raw := strings.TrimSpace(q.Get("company_id")); raw != "" { + id, perr := uuid.Parse(raw) + if perr != nil { + Error(w, http.StatusBadRequest, "invalid company_id") + return + } + args = append(args, id) + where = append(where, "company_id = $"+strconv.Itoa(len(args))) + } + whereSQL := strings.Join(where, " AND ") + + totals, err := s.scanCostTotals(ctx, whereSQL, args) + if err != nil { + Error(w, http.StatusInternalServerError, "load ai costs failed") + return + } + byCompany, err := s.scanCostGroup(ctx, costGroupCompany, whereSQL, args) + if err != nil { + Error(w, http.StatusInternalServerError, "load ai costs by company failed") + return + } + byUser, err := s.scanCostGroup(ctx, costGroupUser, whereSQL, args) + if err != nil { + Error(w, http.StatusInternalServerError, "load ai costs by user failed") + return + } + byModel, err := s.scanCostGroup(ctx, costGroupModel, whereSQL, args) + if err != nil { + Error(w, http.StatusInternalServerError, "load ai costs by model failed") + return + } + + JSON(w, http.StatusOK, map[string]any{ + "from": from.Format("2006-01-02"), + "to": to.Format("2006-01-02"), + "currency": "USD", + "totals": totals, + "by_company": byCompany, + "by_user": byUser, + "by_model": byModel, + "usage_retention_days": aiaudit.UsageRetentionDays, + "prompt_retention_days": aiaudit.RetentionDays, + }) +} + +func adminCostRange(fromRaw, toRaw, daysRaw string) (time.Time, time.Time, error) { + today := time.Now().UTC().Truncate(24 * time.Hour) + to := today + if s := strings.TrimSpace(toRaw); s != "" { + t, err := time.Parse("2006-01-02", s) + if err != nil { + return time.Time{}, time.Time{}, errBadRequest("invalid to date (YYYY-MM-DD)") + } + to = t.UTC() + } + if s := strings.TrimSpace(fromRaw); s != "" { + f, err := time.Parse("2006-01-02", s) + if err != nil { + return time.Time{}, time.Time{}, errBadRequest("invalid from date (YYYY-MM-DD)") + } + from := f.UTC() + if from.After(to) { + return time.Time{}, time.Time{}, errBadRequest("from must not be after to") + } + if to.Sub(from) > time.Duration(adminAICostsMaxDays)*24*time.Hour { + return time.Time{}, time.Time{}, errBadRequest("range too large") + } + return from, to, nil + } + days := adminAICostsDefDays + if s := strings.TrimSpace(daysRaw); s != "" { + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return time.Time{}, time.Time{}, errBadRequest("invalid days") + } + days = n + } + if days > adminAICostsMaxDays { + days = adminAICostsMaxDays + } + return to.AddDate(0, 0, -(days - 1)), to, nil +} + +type badRequestErr string + +func (e badRequestErr) Error() string { return string(e) } + +func errBadRequest(msg string) error { return badRequestErr(msg) } + +func (s *Server) scanCostTotals(ctx context.Context, whereSQL string, args []any) (map[string]any, error) { + var calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros int64 + var companies, users int64 + err := s.Pool.QueryRow(ctx, ` + SELECT COALESCE(sum(calls),0), COALESCE(sum(failed_calls),0), + COALESCE(sum(prompt_tokens),0), COALESCE(sum(cached_prompt_tokens),0), + COALESCE(sum(completion_tokens),0), COALESCE(sum(total_tokens),0), + COALESCE(sum(cost_micros),0), + count(DISTINCT company_id), count(DISTINCT user_id) + FROM ai_usage_daily WHERE `+whereSQL, args...). + Scan(&calls, &failed, &promptTok, &cachedTok, &outTok, &totalTok, &costMicros, &companies, &users) + if err != nil { + return nil, err + } + return costRow(map[string]any{ + "companies": companies, + "users": users, + }, calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros), nil +} + +type costGroup string + +const ( + costGroupCompany costGroup = "company" + costGroupUser costGroup = "user" + costGroupModel costGroup = "model" +) + +func (s *Server) scanCostGroup(ctx context.Context, group costGroup, whereSQL string, args []any) ([]map[string]any, error) { + var sql string + switch group { + case costGroupCompany: + sql = ` + SELECT u.company_id::text, COALESCE(c.name, ''), '', '', + COALESCE(sum(u.calls),0), COALESCE(sum(u.failed_calls),0), + COALESCE(sum(u.prompt_tokens),0), COALESCE(sum(u.cached_prompt_tokens),0), + COALESCE(sum(u.completion_tokens),0), COALESCE(sum(u.total_tokens),0), + COALESCE(sum(u.cost_micros),0) + FROM ai_usage_daily u + LEFT JOIN companies c ON c.id = u.company_id + WHERE ` + whereSQL + ` + GROUP BY u.company_id, c.name + ORDER BY COALESCE(sum(u.cost_micros),0) DESC + LIMIT ` + strconv.Itoa(adminAICostsMaxRows) + case costGroupUser: + sql = ` + SELECT u.user_id::text, COALESCE(usr.email, ''), u.company_id::text, COALESCE(c.name, ''), + COALESCE(sum(u.calls),0), COALESCE(sum(u.failed_calls),0), + COALESCE(sum(u.prompt_tokens),0), COALESCE(sum(u.cached_prompt_tokens),0), + COALESCE(sum(u.completion_tokens),0), COALESCE(sum(u.total_tokens),0), + COALESCE(sum(u.cost_micros),0) + FROM ai_usage_daily u + LEFT JOIN users usr ON usr.id = u.user_id + LEFT JOIN companies c ON c.id = u.company_id + WHERE ` + whereSQL + ` + GROUP BY u.user_id, usr.email, u.company_id, c.name + ORDER BY COALESCE(sum(u.cost_micros),0) DESC + LIMIT ` + strconv.Itoa(adminAICostsMaxRows) + default: + sql = ` + SELECT u.model, u.model, '', '', + COALESCE(sum(u.calls),0), COALESCE(sum(u.failed_calls),0), + COALESCE(sum(u.prompt_tokens),0), COALESCE(sum(u.cached_prompt_tokens),0), + COALESCE(sum(u.completion_tokens),0), COALESCE(sum(u.total_tokens),0), + COALESCE(sum(u.cost_micros),0) + FROM ai_usage_daily u + WHERE ` + whereSQL + ` + GROUP BY u.model + ORDER BY COALESCE(sum(u.cost_micros),0) DESC + LIMIT ` + strconv.Itoa(adminAICostsMaxRows) + } + + rows, err := s.Pool.Query(ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var id, label, companyID, companyName string + var calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros int64 + if err := rows.Scan(&id, &label, &companyID, &companyName, + &calls, &failed, &promptTok, &cachedTok, &outTok, &totalTok, &costMicros); err != nil { + return nil, err + } + base := map[string]any{"id": id, "label": label} + if group == costGroupUser { + base["company_id"] = companyID + base["company_name"] = companyName + } + out = append(out, costRow(base, calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros)) + } + return out, rows.Err() +} + +// costRow returns cost as integer micros AND a display-ready USD float. Callers +// must sum micros, never the float — that is the whole reason cost is stored as an +// integer. +func costRow(base map[string]any, calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros int64) map[string]any { + base["calls"] = calls + base["failed_calls"] = failed + base["prompt_tokens"] = promptTok + base["cached_prompt_tokens"] = cachedTok + base["completion_tokens"] = outTok + base["total_tokens"] = totalTok + base["cost_micros"] = costMicros + base["cost_usd"] = float64(costMicros) / float64(aiaudit.MicrosPerUSD) + return base +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 929d873..a8c9c9d 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -405,6 +405,7 @@ func (s *Server) Router() http.Handler { r.Get("/readiness", s.handleAdminReadiness) r.Get("/diagnostics", s.handleAdminDiagnostics) r.Get("/ai-calls", s.handleAdminListAICalls) + r.Get("/ai-costs", s.handleAdminAICosts) r.Get("/ai-calls/{id}", s.handleAdminGetAICall) r.Get("/analytics", s.handleAdminAnalytics) r.Get("/jobs", s.handleAdminListJobs) diff --git a/apps/api/internal/processing/ai.go b/apps/api/internal/processing/ai.go index 721785c..b373329 100644 --- a/apps/api/internal/processing/ai.go +++ b/apps/api/internal/processing/ai.go @@ -43,10 +43,14 @@ type EnableChecker interface { type Completion struct { Text string PromptTokens int - OutputTokens int - TotalTokens int - Model string - Raw any + // CachedPromptTokens is the cached subset of PromptTokens (OpenAI + // usage.prompt_tokens_details.cached_tokens). Billed far cheaper than fresh + // input, so cost accounting must subtract it. 0 when the provider omits it. + CachedPromptTokens int + OutputTokens int + TotalTokens int + Model string + Raw any } // Embedder turns text into vectors (OpenAI-compatible /v1/embeddings). diff --git a/apps/api/internal/processing/openai.go b/apps/api/internal/processing/openai.go index b931da9..199f72b 100644 --- a/apps/api/internal/processing/openai.go +++ b/apps/api/internal/processing/openai.go @@ -242,6 +242,11 @@ type chatResponse struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` + // Cached prompt tokens bill at roughly a tenth of fresh input, and enhance + // re-sends a large shared system prompt, so cost is wrong without them. + PromptTokensDetails struct { + CachedTokens int `json:"cached_tokens"` + } `json:"prompt_tokens_details"` } `json:"usage"` Error *openAIErrorBody `json:"error"` } @@ -557,11 +562,12 @@ func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temp log.Printf("openai: chat content ok model=%s finish_reason=%s content_runes=%d prompt_tokens=%d completion_tokens=%d total_tokens=%d", c.Model, finishReason, len([]rune(text)), usagePrompt, usageOut, usageTotal) return Completion{ - Text: text, - PromptTokens: usagePrompt, - OutputTokens: usageOut, - TotalTokens: usageTotal, - Model: parsed.Model, + Text: text, + PromptTokens: usagePrompt, + CachedPromptTokens: parsed.Usage.PromptTokensDetails.CachedTokens, + OutputTokens: usageOut, + TotalTokens: usageTotal, + Model: parsed.Model, Raw: map[string]any{ "model": parsed.Model, "usage": parsed.Usage, diff --git a/apps/api/sql/schema/046_ai_usage_cost.sql b/apps/api/sql/schema/046_ai_usage_cost.sql new file mode 100644 index 0000000..8bd49cd --- /dev/null +++ b/apps/api/sql/schema/046_ai_usage_cost.sql @@ -0,0 +1,65 @@ +-- +goose Up +-- Durable AI cost accounting, deliberately separate from ai_call_logs. +-- +-- ai_call_logs holds full prompts and is pruned after 7 days. Cost has to outlive +-- that by years, so usage is rolled up per day / company / user / model / role at +-- capture time. One row per combination per day keeps three years small: a busy +-- tenant with 5 users and 3 roles adds ~45 rows a day, not one per product. +-- +-- Cost is stored in micro-USD integers, never floats: summing millions of +-- fractional-cent calls in float drifts, and money must add up exactly. + +CREATE TABLE IF NOT EXISTS ai_model_prices ( + model TEXT PRIMARY KEY, + -- Micro-USD per 1,000,000 tokens. $0.20/1M = 200000 micros. + input_micros_per_mtok BIGINT NOT NULL DEFAULT 0, + cached_input_micros_per_mtok BIGINT NOT NULL DEFAULT 0, + output_micros_per_mtok BIGINT NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- OpenAI GPT-5.6 family, rates effective 2026-07-30. +-- Admin can UPDATE these when list prices change; already-recorded cost is never +-- recalculated, so history keeps the price that applied at the time. +INSERT INTO ai_model_prices (model, input_micros_per_mtok, cached_input_micros_per_mtok, output_micros_per_mtok, source) +VALUES + ('gpt-5.6-luna', 200000, 20000, 1200000, 'openai list price 2026-07-30'), + ('gpt-5.6-terra', 2000000, 200000, 12000000, 'openai list price 2026-07-30'), + ('gpt-5.6-sol', 5000000, 500000, 30000000, 'openai list price 2026-07-30') +ON CONFLICT (model) DO NOTHING; + +CREATE TABLE IF NOT EXISTS ai_usage_daily ( + day DATE NOT NULL, + -- Nil UUID rather than NULL: these are primary-key columns and NULL would let + -- duplicate rows accumulate instead of upserting. + company_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + user_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + model TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'other', + calls BIGINT NOT NULL DEFAULT 0, + failed_calls BIGINT NOT NULL DEFAULT 0, + prompt_tokens BIGINT NOT NULL DEFAULT 0, + cached_prompt_tokens BIGINT NOT NULL DEFAULT 0, + completion_tokens BIGINT NOT NULL DEFAULT 0, + total_tokens BIGINT NOT NULL DEFAULT 0, + cost_micros BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (day, company_id, user_id, model, role) +); + +-- No FK to companies/users: cost history must survive a tenant or user being +-- deleted, otherwise the yearly total silently drops. + +CREATE INDEX IF NOT EXISTS ai_usage_daily_company_day_idx + ON ai_usage_daily (company_id, day DESC); + +CREATE INDEX IF NOT EXISTS ai_usage_daily_user_day_idx + ON ai_usage_daily (user_id, day DESC); + +CREATE INDEX IF NOT EXISTS ai_usage_daily_day_idx + ON ai_usage_daily (day DESC); + +-- +goose Down +DROP TABLE IF EXISTS ai_usage_daily; +DROP TABLE IF EXISTS ai_model_prices; diff --git a/apps/api/sql/schema/047_ai_usage_source.sql b/apps/api/sql/schema/047_ai_usage_source.sql new file mode 100644 index 0000000..8a3372a --- /dev/null +++ b/apps/api/sql/schema/047_ai_usage_source.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Distinguish live capture from history reconstructed after the fact. +-- +-- ai_usage_daily rows written by the recorder are exact: they come from the +-- provider's usage block at call time. Rows recovered from +-- processed_products.gpt_response are best-effort — that JSON has no user +-- attribution and only survives for products whose response metadata was kept. +-- +-- Keeping the two apart (and putting source in the primary key) makes the +-- backfill idempotent: it deletes and rewrites only its own rows, and can never +-- double-count a call the recorder already logged. + +ALTER TABLE ai_usage_daily + ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'live'; + +ALTER TABLE ai_usage_daily DROP CONSTRAINT IF EXISTS ai_usage_daily_pkey; +ALTER TABLE ai_usage_daily + ADD CONSTRAINT ai_usage_daily_pkey PRIMARY KEY (day, company_id, user_id, model, role, source); + +-- +goose Down +ALTER TABLE ai_usage_daily DROP CONSTRAINT IF EXISTS ai_usage_daily_pkey; +DELETE FROM ai_usage_daily WHERE source <> 'live'; +ALTER TABLE ai_usage_daily + ADD CONSTRAINT ai_usage_daily_pkey PRIMARY KEY (day, company_id, user_id, model, role); +ALTER TABLE ai_usage_daily DROP COLUMN IF EXISTS source; diff --git a/apps/web/src/lib/admin-nav.ts b/apps/web/src/lib/admin-nav.ts index f46f20e..348eaec 100644 --- a/apps/web/src/lib/admin-nav.ts +++ b/apps/web/src/lib/admin-nav.ts @@ -38,6 +38,12 @@ export const ADMIN_NAV_ROUTES: readonly AdminNavRoute[] = [ group: "ops", fullAdminOnly: true }, + { + titleKey: "admin.nav.aiCosts", + href: "/admin/ai-costs", + group: "ops", + fullAdminOnly: true + }, { titleKey: "admin.nav.stuckProducts", href: "/admin/stuck-products", diff --git a/apps/web/src/lib/components/AdminNav.svelte b/apps/web/src/lib/components/AdminNav.svelte index 6c22fa1..890a976 100644 --- a/apps/web/src/lib/components/AdminNav.svelte +++ b/apps/web/src/lib/components/AdminNav.svelte @@ -30,6 +30,7 @@ ArrowLeft, FileWarning, MessagesSquare, + Wallet, LogOut, X } from "@lucide/svelte"; @@ -42,6 +43,7 @@ "/admin/support/knowledge": BookOpen, "/admin/diagnostics": Activity, "/admin/ai-calls": MessagesSquare, + "/admin/ai-costs": Wallet, "/admin/stuck-products": ClipboardList, "/admin/orphan-processed": FileWarning, "/admin/billing": CreditCard, diff --git a/apps/web/src/lib/components/products/ProductEditPanel.svelte b/apps/web/src/lib/components/products/ProductEditPanel.svelte index 85e54dc..515bb69 100644 --- a/apps/web/src/lib/components/products/ProductEditPanel.svelte +++ b/apps/web/src/lib/components/products/ProductEditPanel.svelte @@ -411,6 +411,47 @@ return unique.length === rows.length ? rows : unique; }); + // --- Review diffs ------------------------------------------------------- + // Category the feed supplied vs the one processing assigned. Most catalogs ship + // no category at all, so "(none) -> Stolcki" is the change worth showing. + const feedCategoryRaw = $derived(String(product?.feed_category ?? "").trim()); + const feedCategoryLabel = $derived.by(() => { + if (!feedCategoryRaw) return ""; + const match = findCategoryOption(categories, feedCategoryRaw); + return match?.name || feedCategoryRaw; + }); + const assignedCategoryLabel = $derived.by(() => { + if (!category || category === "none") return ""; + const match = findCategoryOption(categories, category); + return match?.name || categoryFieldDisplayValue(categories, product, category) || category; + }); + const categoryChanged = $derived( + kind === "processed" && + assignedCategoryLabel.trim().toLowerCase() !== feedCategoryLabel.trim().toLowerCase() + ); + + /** Per-key attribute diff: what enrichment added or rewrote versus the feed. */ + const attrDiff = $derived.by(() => { + const before = new Map( + productAttrEntries(product?.attributes).map((a) => [a.key.toLowerCase(), a]) + ); + const after = new Map( + productAttrEntries(product?.processed_attributes).map((a) => [a.key.toLowerCase(), a]) + ); + const keys = [...new Set([...before.keys(), ...after.keys()])].sort(); + const rows = keys.map((k) => { + const b = before.get(k); + const a = after.get(k); + const state = !b ? "added" : !a ? "removed" : b.value === a.value ? "same" : "changed"; + return { key: (a ?? b)?.key ?? k, before: b?.value ?? "", after: a?.value ?? "", state }; + }); + return { + rows, + changed: rows.filter((r) => r.state !== "same") + }; + }); + const attrsChanged = $derived(kind === "processed" && attrDiff.changed.length > 0); + const feedAttrs = $derived.by(() => { const rows = productFeedAttributeEntries(product); const covered = new Set( @@ -748,26 +789,97 @@ {/if} -
+ {i18n.t("admin.aiCosts.grandTotal")} +
+{usd(totals)}
++ {i18n.t("admin.aiCosts.calls")} +
+{num(totals.calls)}
+ {#if Number(totals.failed_calls ?? 0) > 0} ++ {i18n.t("admin.aiCosts.failedCalls", { count: num(totals.failed_calls) })} +
+ {/if} ++ {i18n.t("admin.aiCosts.tokens")} +
+{num(totals.total_tokens)}
++ {i18n.t("admin.aiCosts.cachedTokens", { count: num(totals.cached_prompt_tokens) })} +
++ {i18n.t("admin.aiCosts.tenants")} +
+{num(totals.companies)}
++ {i18n.t("admin.aiCosts.usersCount", { count: num(totals.users) })} +
+