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