fix
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user