From 67ff6ffffa999d202105581bc20f36029cf05ff6 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:20:37 +0200 Subject: [PATCH] feat: cost ledger (per-profile EUR pricing on token usage) The token metering is real now (input, output, cache read/write per profile), but it answers "how many tokens" while the question an operator actually asks is "what did this run cost". Converting requires per-profile prices, which nowhere exist. Changes: - llm_profiles gains four optional price columns (EUR per 1M tokens: input, output, cache read, cache write; 0/NULL = unset), migrated idempotently and round-tripped through SaveProfile / the profile API in one small auditable write - the usage aggregation computes cost_eur per profile from the stored prices, in Go after the archived-task merge so cold-storage tokens are priced identically; profiles without prices keep cost_eur nil instead of showing a fake zero - web types carry cost_eur and the price fields; the dashboard card and profile-dialog inputs consume them in a follow-up (the API serves the data already) - integration test: priced profile + usage rows -> expected EUR to the cent (2M in @1.5 + 0.5M out @4 + 1M cache-read @0.15 = 5.15), plus a prices round-trip check Note: the Go side activates on the next build; the schema migration is idempotent at startup. --- db/config.go | 29 ++++++++++++++++-- db/llm_usage.go | 46 ++++++++++++++++++++++++++++ db/schema.sql | 7 +++++ server/cost_ledger_test.go | 61 ++++++++++++++++++++++++++++++++++++++ web/src/lib/types.ts | 7 +++++ 5 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 server/cost_ledger_test.go diff --git a/db/config.go b/db/config.go index ad29fc9..cf7e705 100644 --- a/db/config.go +++ b/db/config.go @@ -64,6 +64,12 @@ type LLMProfile struct { // or sticky routing off a session-id header. "" = not sent. Maps to // agent.Config.SessionHeaderKey. SessionHeaderKey string `json:"session_header_key"` + // Cost ledger: EUR per 1M tokens (0 = unset; cost is computed only when a + // price is set). Set through the same profile API/SaveProfile path. + PriceInputPerM float64 `json:"price_input_per_m,omitempty"` + PriceOutputPerM float64 `json:"price_output_per_m,omitempty"` + PriceCacheReadPerM float64 `json:"price_cache_read_per_m,omitempty"` + PriceCacheWritePerM float64 `json:"price_cache_write_per_m,omitempty"` // Retry overrides this profile's share of the retry ladder. Zero value = // inherit the global policy (LLMRetryPolicy), so an untouched profile behaves // exactly as before. See RetryOverride. @@ -83,8 +89,8 @@ type RetryOverride struct { // profileCols is the read column list (hint variant, no api key) shared by the // list query; profileColsKey is the same with api_key for the single-row loads. const profileRetryCols = `COALESCE(retry_connect_attempts,0),COALESCE(retry_connect_interval_ms,0),COALESCE(retry_empty_attempts,0),COALESCE(retry_empty_interval_ms,0),COALESCE(retry_stream_attempts,0),COALESCE(retry_stream_interval_ms,0)` -const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols -const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols +const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0)` +const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0)` // scanProfile reads one row in the profileCols / profileColsKey column order. The // 7th column lands in APIKeyHint or APIKey depending on which list the caller used. @@ -94,7 +100,8 @@ func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile &p.MaxTokens, &p.MaxTokensField, &p.SessionHeaderKey, &p.Retry.Connect.Attempts, &p.Retry.Connect.IntervalMS, &p.Retry.Empty.Attempts, &p.Retry.Empty.IntervalMS, - &p.Retry.Stream.Attempts, &p.Retry.Stream.IntervalMS) + &p.Retry.Stream.Attempts, &p.Retry.Stream.IntervalMS, + &p.PriceInputPerM, &p.PriceOutputPerM, &p.PriceCacheReadPerM, &p.PriceCacheWritePerM) } func (d *DB) ListProfiles() ([]*LLMProfile, error) { @@ -176,20 +183,36 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) { VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) RETURNING id`, p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS).Scan(&id) + if err == nil { + _, err = d.saveProfilePrices(id, p) + } return id, err } if p.APIKey == "" { _, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22 WHERE id=$23`, p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID) + if err == nil { + _, err = d.saveProfilePrices(p.ID, p) + } return p.ID, err } _, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24 WHERE id=$25`, p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID) + if err == nil { + _, err = d.saveProfilePrices(p.ID, p) + } return p.ID, err } +// saveProfilePrices writes the cost-ledger columns (kept out of the three main +// statements so the pricing surface stays one small, auditable write). +func (d *DB) saveProfilePrices(id int64, p *LLMProfile) (sql.Result, error) { + return d.Exec(`UPDATE llm_profiles SET price_input_per_m=($1),price_output_per_m=($2),price_cache_read_per_m=($3),price_cache_write_per_m=($4) WHERE id=$5`, + p.PriceInputPerM, p.PriceOutputPerM, p.PriceCacheReadPerM, p.PriceCacheWritePerM, id) +} + var ( ErrActiveLLMProfileDelete = errors.New("cannot delete the active LLM profile; activate another profile first") ErrLLMProfileReferencesChanged = errors.New("LLM profile references changed while deleting; retry the request") diff --git a/db/llm_usage.go b/db/llm_usage.go index df14788..92009e5 100644 --- a/db/llm_usage.go +++ b/db/llm_usage.go @@ -117,6 +117,8 @@ type ProfileUsage struct { OutputTokens int `json:"output_tokens"` CacheReadTokens int `json:"cache_read_tokens"` CacheWriteTokens int `json:"cache_write_tokens"` + // CostEur is set only when the profile carries at least one price; nil = not priced. + CostEur *float64 `json:"cost_eur,omitempty"` } // UsageByProfile returns global token spend grouped by profile name, most-used @@ -171,6 +173,9 @@ ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC`) for _, current := range byName { out = append(out, current) } + if err := applyCostEur(d, out); err != nil { + return nil, err + } sort.Slice(out, func(i, j int) bool { left := out[i].InputTokens + out[i].OutputTokens right := out[j].InputTokens + out[j].OutputTokens @@ -332,3 +337,44 @@ func ParseExpID(s string) int64 { } return n } + +// applyCostEur fills CostEur on each profile bucket from the stored per-profile +// prices (EUR per 1M tokens). Profiles without any price keep CostEur nil. The +// join is by profile name (llm_usage records the name, not the id); the first +// matching profile wins, and duplicate names are a configuration error the +// operator sees as a weird number, not a crash. +func applyCostEur(d *DB, out []ProfileUsage) error { + rows, err := d.Query(`SELECT name, COALESCE(price_input_per_m,0), COALESCE(price_output_per_m,0), COALESCE(price_cache_read_per_m,0), COALESCE(price_cache_write_per_m,0) FROM llm_profiles`) + if err != nil { + return err + } + defer rows.Close() + type prices struct{ in, outp, cr, cw float64 } + byName := map[string]prices{} + seen := map[string]bool{} + for rows.Next() { + var n string + var pr prices + if err := rows.Scan(&n, &pr.in, &pr.outp, &pr.cr, &pr.cw); err != nil { + return err + } + if !seen[n] { + byName[n] = pr + seen[n] = true + } + } + if err := rows.Err(); err != nil { + return err + } + for i := range out { + pr, ok := byName[out[i].ProfileName] + if !ok || (pr.in == 0 && pr.outp == 0 && pr.cr == 0 && pr.cw == 0) { + continue + } + cost := float64(out[i].InputTokens)/1e6*pr.in + float64(out[i].OutputTokens)/1e6*pr.outp + + float64(out[i].CacheReadTokens)/1e6*pr.cr + float64(out[i].CacheWriteTokens)/1e6*pr.cw + c := cost + out[i].CostEur = &c + } + return nil +} diff --git a/db/schema.sql b/db/schema.sql index 6938bcb..71f948c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1425,3 +1425,10 @@ CREATE INDEX IF NOT EXISTS idx_notification_deliveries_batch ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel ON notification_deliveries(channel_id, id DESC); + +-- Cost ledger (#70): optional per-profile pricing, EUR per 1M tokens. NULL/0 = unset; +-- usage aggregations compute cost only when at least one price is set. +ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS price_input_per_m NUMERIC(12,6); +ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS price_output_per_m NUMERIC(12,6); +ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS price_cache_read_per_m NUMERIC(12,6); +ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS price_cache_write_per_m NUMERIC(12,6); diff --git a/server/cost_ledger_test.go b/server/cost_ledger_test.go new file mode 100644 index 0000000..a7b4add --- /dev/null +++ b/server/cost_ledger_test.go @@ -0,0 +1,61 @@ +package server + +import ( + "testing" + + "github.com/Autumn-27/artex/db" +) + +// The cost ledger: profile prices (EUR per 1M tokens) turn token buckets into +// EUR on the usage aggregation; unpriced profiles stay nil. +func TestCostLedgerEurOnUsage(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { m.Close() }) + if m.pg == nil { + t.Skip("no postgres for this run") + } + + pid, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: "cost-test", Format: "openai", Model: "m", APIKey: "k", + PriceInputPerM: 1.5, PriceOutputPerM: 4.0, PriceCacheReadPerM: 0.15, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) }) + + stored, err := m.pg.ProfileByID(pid) + if err != nil || stored == nil { + t.Fatalf("ProfileByID: %v %v", stored, err) + } + if stored.PriceInputPerM != 1.5 || stored.PriceOutputPerM != 4.0 || stored.PriceCacheReadPerM != 0.15 || stored.PriceCacheWritePerM != 0 { + t.Fatalf("prices did not round-trip: %+v", *stored) + } + + if _, err := m.pg.Exec(`INSERT INTO llm_usage(task_id, profile_name, model, input_tokens, output_tokens, cache_read, cache_write, ts) + VALUES ('', 'cost-test', 'm', 2000000, 500000, 1000000, 0, now())`); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = m.pg.Exec(`DELETE FROM llm_usage WHERE profile_name='cost-test'`) }) + + profiles, err := m.pg.UsageByProfile() + if err != nil { + t.Fatal(err) + } + var got *float64 + for _, p := range profiles { + if p.ProfileName == "cost-test" { + got = p.CostEur + } + } + if got == nil { + t.Fatal("priced profile has no cost_eur") + } + want := 2.0*1.5 + 0.5*4.0 + 1.0*0.15 // 3.0 + 2.0 + 0.15 = 5.15 + if d := *got - want; d > 0.001 || d < -0.001 { + t.Fatalf("cost_eur = %v, want %v", *got, want) + } +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c05d897..c4a7cb8 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -818,6 +818,8 @@ export interface ProfileUsage { output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + // EUR cost from the profile's stored prices; absent when the profile has no price set. + cost_eur?: number | null; } // One (profile, UTC day) token bucket for the dashboard's daily chart (new source). @@ -1045,6 +1047,11 @@ export interface LLMProfile { pool_exclude?: boolean; // true (default) = streaming (SSE) | false = genuinely non-streaming (stream:false, one-shot response). streaming?: boolean; + // Cost ledger: EUR per 1M tokens (0/absent = unset). + price_input_per_m?: number; + price_output_per_m?: number; + price_cache_read_per_m?: number; + price_cache_write_per_m?: number; // Output cap per reply (tokens). 0 = do not send the field; the server default decides. // Note the distinction from context_window_k: that is the model's total capacity, used locally only for the compression threshold. max_tokens?: number;