Files

249 lines
7.6 KiB
Go
Raw Permalink Normal View History

2026-08-23 22:52:13 +02:00
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
}