diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 65ab02fe4b..e5423a9e20 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -308,7 +308,7 @@ export function createEnDict(allowSignup: boolean): LandingDict { "Grouping and filters now behave the same across List, Board, and Swimlane views.", "The chat box now grows with the window so you can see more of a long draft.", "Getting started now takes fewer steps.", - "Grok usage now shows accurate cost, and your saved custom prices stay editable.", + "Grok cost now comes from what xAI actually charged for each turn, including long-context requests, and your saved custom prices stay editable.", "Animations across the app are now smoother.", ], fixes: [ diff --git a/apps/web/features/landing/i18n/ja.ts b/apps/web/features/landing/i18n/ja.ts index 4430200248..cbd033fb27 100644 --- a/apps/web/features/landing/i18n/ja.ts +++ b/apps/web/features/landing/i18n/ja.ts @@ -284,7 +284,7 @@ export function createJaDict(allowSignup: boolean): LandingDict { "リスト・ボード・スイムレーンの各ビューでグループ化と絞り込みの挙動がそろいました。", "チャット入力欄がウィンドウに合わせて広がり、長い下書きも見やすくなりました。", "初回セットアップの手順が少なくなりました。", - "Grok の使用量が正確な費用で表示され、保存したカスタム価格も編集し続けられます。", + "Grok の費用が xAI の実際の課金額に基づくようになり、長いコンテキストのリクエストも正確になりました。保存したカスタム価格も編集し続けられます。", "アプリ全体のアニメーションがなめらかになりました。", ], fixes: [ diff --git a/apps/web/features/landing/i18n/ko.ts b/apps/web/features/landing/i18n/ko.ts index c9ae61d1f4..dc768edb74 100644 --- a/apps/web/features/landing/i18n/ko.ts +++ b/apps/web/features/landing/i18n/ko.ts @@ -283,7 +283,7 @@ export function createKoDict(allowSignup: boolean): LandingDict { "리스트, 보드, 스윔레인 뷰에서 그룹화와 필터가 동일하게 동작합니다.", "채팅 입력창이 창 크기에 맞춰 커져 긴 초안도 더 잘 보입니다.", "시작하기 설정 단계가 더 줄었습니다.", - "Grok 사용량이 정확한 비용으로 표시되고, 저장한 맞춤 가격도 계속 편집할 수 있습니다.", + "Grok 비용이 xAI가 실제로 청구한 금액을 그대로 반영하며, 긴 컨텍스트 요청도 정확합니다. 저장한 맞춤 가격도 계속 편집할 수 있습니다.", "앱 전반의 애니메이션이 더 부드러워졌습니다.", ], fixes: [ diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 9dc709d3be..c972fa4dfc 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -308,7 +308,7 @@ export function createZhDict(allowSignup: boolean): LandingDict { "列表、看板和泳道视图的分组与筛选现在表现一致了。", "聊天输入框现在会随窗口变大,长草稿一眼看得更全。", "初次上手现在步骤更少了。", - "Grok 用量现在会显示准确费用,保存后的自定义价格也能再改。", + "Grok 费用现在直接采用 xAI 每轮实际收取的金额,长上下文请求也算得准,保存后的自定义价格也能再改。", "全站的界面动画更顺滑了。", ], fixes: [ diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 9953a52cf2..f1a84b8b69 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -742,6 +742,26 @@ export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = { // only that row instead of dropping the whole array to the `[]` fallback. // --------------------------------------------------------------------------- +// Cost split carried by every usage row. `cost_usd_ticks` is what the provider +// itself charged for the rows behind this aggregate (1e-10 USD); the +// `uncosted_*` counts are the tokens from rows the provider did NOT price, and +// so are the only ones the client should run through its rate table. +// +// The `uncosted_*` fields are deliberately `.optional()` rather than +// `.default(0)`: a backend that predates them sends nothing, and defaulting +// those rows to "0 tokens left to estimate" would silently zero their cost. +// `undefined` means "this backend doesn't split", and the consumer falls back +// to the full token counts — i.e. exactly the old behaviour. A real 0 from a +// current backend means "everything here is already priced", which is a +// different thing and must stay distinguishable. +const CostSplitShape = { + cost_usd_ticks: z.number().optional(), + uncosted_input_tokens: z.number().optional(), + uncosted_output_tokens: z.number().optional(), + uncosted_cache_read_tokens: z.number().optional(), + uncosted_cache_write_tokens: z.number().optional(), +}; + const DashboardUsageDailySchema = z.object({ date: z.string().default(""), provider: z.string().default(""), @@ -750,6 +770,7 @@ const DashboardUsageDailySchema = z.object({ output_tokens: z.number().default(0), cache_read_tokens: z.number().default(0), cache_write_tokens: z.number().default(0), + ...CostSplitShape, task_count: z.number().default(0), }).loose(); @@ -763,6 +784,7 @@ const DashboardUsageByAgentSchema = z.object({ output_tokens: z.number().default(0), cache_read_tokens: z.number().default(0), cache_write_tokens: z.number().default(0), + ...CostSplitShape, task_count: z.number().default(0), }).loose(); @@ -802,6 +824,7 @@ const RuntimeUsageSchema = z.object({ output_tokens: z.number().default(0), cache_read_tokens: z.number().default(0), cache_write_tokens: z.number().default(0), + ...CostSplitShape, }).loose(); export const RuntimeUsageListSchema = z.array(RuntimeUsageSchema); @@ -821,6 +844,7 @@ const RuntimeUsageByAgentSchema = z.object({ output_tokens: z.number().default(0), cache_read_tokens: z.number().default(0), cache_write_tokens: z.number().default(0), + ...CostSplitShape, task_count: z.number().default(0), }).loose(); @@ -833,6 +857,7 @@ const RuntimeUsageByHourSchema = z.object({ output_tokens: z.number().default(0), cache_read_tokens: z.number().default(0), cache_write_tokens: z.number().default(0), + ...CostSplitShape, task_count: z.number().default(0), }).loose(); diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index f201dcf463..41200b7a16 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -791,9 +791,27 @@ export interface IssueUsageSummary { total_output_tokens: number; total_cache_read_tokens: number; total_cache_write_tokens: number; + // Optional unlike the usage-row types: `getIssueUsage` returns this shape + // unvalidated (no zod schema), so nothing guarantees the field is present + // when the backend is older than the cost split. + cost_usd_ticks?: number; + uncosted_input_tokens?: number; + uncosted_output_tokens?: number; + uncosted_cache_read_tokens?: number; + uncosted_cache_write_tokens?: number; task_count: number; } +// `cost_usd_ticks` + `uncosted_*`: the cost split every usage row carries. +// All five are optional: a backend older than the split sends none of them, +// and `undefined` has to stay distinguishable from a real 0 (see below). +// The provider priced the rows behind `cost_usd_ticks` itself (1e-10 USD); +// `uncosted_*` are the tokens it did not price, and are the only ones that +// should go through the client's rate table. The `uncosted_*` fields are +// optional because a backend older than the split omits them — `undefined` +// there means "estimate from the full token counts", which is not the same as +// a real 0 ("nothing left to estimate"). See estimateCost in +// packages/views/runtimes/utils.ts. export interface RuntimeUsage { runtime_id: string; date: string; @@ -803,6 +821,11 @@ export interface RuntimeUsage { output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + cost_usd_ticks?: number; + uncosted_input_tokens?: number; + uncosted_output_tokens?: number; + uncosted_cache_read_tokens?: number; + uncosted_cache_write_tokens?: number; } export interface RuntimeHourlyActivity { @@ -823,6 +846,11 @@ export interface RuntimeUsageByAgent { output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + cost_usd_ticks?: number; + uncosted_input_tokens?: number; + uncosted_output_tokens?: number; + uncosted_cache_read_tokens?: number; + uncosted_cache_write_tokens?: number; task_count: number; } @@ -836,6 +864,11 @@ export interface RuntimeUsageByHour { output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + cost_usd_ticks?: number; + uncosted_input_tokens?: number; + uncosted_output_tokens?: number; + uncosted_cache_read_tokens?: number; + uncosted_cache_write_tokens?: number; task_count: number; } @@ -853,6 +886,11 @@ export interface DashboardUsageDaily { output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + cost_usd_ticks?: number; + uncosted_input_tokens?: number; + uncosted_output_tokens?: number; + uncosted_cache_read_tokens?: number; + uncosted_cache_write_tokens?: number; task_count: number; } @@ -867,6 +905,11 @@ export interface DashboardUsageByAgent { output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + cost_usd_ticks?: number; + uncosted_input_tokens?: number; + uncosted_output_tokens?: number; + uncosted_cache_read_tokens?: number; + uncosted_cache_write_tokens?: number; task_count: number; } diff --git a/packages/views/runtimes/utils.test.ts b/packages/views/runtimes/utils.test.ts index 1bf16d1d5f..f1305dddfa 100644 --- a/packages/views/runtimes/utils.test.ts +++ b/packages/views/runtimes/utils.test.ts @@ -507,6 +507,220 @@ describe("estimateCost", () => { ).toBeCloseTo(3, 5); }); + // ------------------------------------------------------------------------- + // Provider-reported cost. `cost_usd_ticks` is what the provider actually + // charged (1e-10 USD) for the rows behind an aggregate; `uncosted_*` are the + // tokens it did not price and which therefore still need the rate table. + // ------------------------------------------------------------------------- + + it("uses the provider's own cost instead of the rate table when it reports one", () => { + // Real grok 0.2.106 turn: 2049 uncached input + 10880 cache read + 29 + // output, costUsdTicks 75360000 = $0.007536. Here the rate table would + // agree, which is what makes it a clean check that the authoritative + // number is the one being used rather than added to an estimate. + expect( + estimateCost({ + ...zeroUsage, + provider: "grok", + model: "grok-4.5", + input_tokens: 2049, + cache_read_tokens: 10880, + output_tokens: 29, + cost_usd_ticks: 75_360_000, + uncosted_input_tokens: 0, + uncosted_output_tokens: 0, + uncosted_cache_read_tokens: 0, + uncosted_cache_write_tokens: 0, + }), + ).toBeCloseTo(0.007536, 10); + }); + + it("keeps the long-context surcharge the rate table cannot express", () => { + // xAI bills a request at 2x once its prompt reaches 200K tokens. The same + // tokens priced from the table give the short-context figure; the + // provider's own number carries the surcharge, and must not be quietly + // replaced by the cheaper local estimate. + const tokens = { + ...zeroUsage, + provider: "grok", + model: "grok-4.5", + input_tokens: 1_000_000, + output_tokens: 1_000_000, + }; + const shortContext = estimateCost(tokens); + expect(shortContext).toBeCloseTo(8, 5); + + const longContext = estimateCost({ + ...tokens, + cost_usd_ticks: 16 * 10_000_000_000, // $16 — the 2x tier + uncosted_input_tokens: 0, + uncosted_output_tokens: 0, + uncosted_cache_read_tokens: 0, + uncosted_cache_write_tokens: 0, + }); + expect(longContext).toBeCloseTo(16, 5); + }); + + it("adds an estimate for the tokens the provider did not price", () => { + // A bucket can mix rows that carry a provider cost with rows that don't — + // two providers in one aggregate, or Grok either side of a CLI upgrade. + // Reporting only the authoritative half would under-report the bucket. + expect( + estimateCost({ + ...zeroUsage, + provider: "grok", + model: "grok-4.5", + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cost_usd_ticks: 4 * 10_000_000_000, // $4 for the priced half + uncosted_input_tokens: 1_000_000, // $2 at the table rate + uncosted_output_tokens: 1_000_000, // $6 at the table rate + uncosted_cache_read_tokens: 0, + uncosted_cache_write_tokens: 0, + }), + ).toBeCloseTo(12, 5); + }); + + it("falls back to estimating the full row when the backend omits the split", () => { + // A backend older than the cost split sends no `uncosted_*` fields. + // Treating that as "nothing left to estimate" would report $0 for every + // row, so an absent split must estimate the full token counts. + expect( + estimateCost({ + ...zeroUsage, + provider: "grok", + model: "grok-4.5", + input_tokens: 1_000_000, + output_tokens: 1_000_000, + }), + ).toBeCloseTo(8, 5); + }); + + it("does not double-charge a cost that arrives without its token split", () => { + // Defensive: an authoritative cost with no `uncosted_*` must not also get + // a full-row estimate stacked on top. + expect( + estimateCost({ + ...zeroUsage, + provider: "grok", + model: "grok-4.5", + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cost_usd_ticks: 16 * 10_000_000_000, + }), + ).toBeCloseTo(16, 5); + }); + + it("reports provider cost even for a model with no rate-table row", () => { + // `grok-composer-*` has no published rate, but a turn the provider priced + // itself needs no rate — the money is known exactly. + expect( + estimateCost({ + ...zeroUsage, + provider: "grok", + model: "grok-composer-2.5-fast", + input_tokens: 500, + output_tokens: 100, + cost_usd_ticks: 12_345_678_900, + uncosted_input_tokens: 0, + uncosted_output_tokens: 0, + uncosted_cache_read_tokens: 0, + uncosted_cache_write_tokens: 0, + }), + ).toBeCloseTo(1.23456789, 8); + }); + + it("keeps the breakdown and the headline agreeing on an unpriced model", () => { + // `grok-composer-*` has no rate row, so there is nothing to split by — but + // the provider priced the turn. If the breakdown returned zeros here the + // stacked chart would read $0 while the headline read the real cost, and + // the unmapped banner (correctly) would not be shown to explain it. + const usage = { + ...zeroUsage, + provider: "grok", + model: "grok-composer-2.5-fast", + input_tokens: 500, + output_tokens: 100, + cost_usd_ticks: 12_345_678_900, + uncosted_input_tokens: 0, + uncosted_output_tokens: 0, + uncosted_cache_read_tokens: 0, + uncosted_cache_write_tokens: 0, + }; + const b = estimateCostBreakdown(usage); + expect(b.input + b.output + b.cacheRead + b.cacheWrite).toBeCloseTo( + estimateCost(usage), + 8, + ); + expect(b.input).toBeCloseTo(1.23456789, 8); + }); + + it("reports no cost for an unpriced model the provider did not price either", () => { + // The control for the case above: no rates and no provider cost must stay + // at zero rather than inventing a figure. + const usage = { + ...zeroUsage, + provider: "grok", + model: "grok-composer-2.5-fast", + input_tokens: 500, + output_tokens: 100, + }; + expect(estimateCost(usage)).toBe(0); + const b = estimateCostBreakdown(usage); + expect(b.input + b.output + b.cacheRead + b.cacheWrite).toBe(0); + // ...and it still asks the user for a rate, because one would help here. + expect(collectUnmappedModels([usage])).toEqual(["grok/grok-composer-2.5-fast"]); + }); + + it("keeps the cost breakdown summing to the total on provider-priced rows", () => { + // The stacked chart is drawn from the breakdown while the headline uses + // estimateCost; if the authoritative charge were dropped from the split + // the two would silently disagree on every Grok row. + const usage = { + ...zeroUsage, + provider: "grok", + model: "grok-4.5", + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cost_usd_ticks: 16 * 10_000_000_000, + uncosted_input_tokens: 0, + uncosted_output_tokens: 0, + uncosted_cache_read_tokens: 0, + uncosted_cache_write_tokens: 0, + }; + const b = estimateCostBreakdown(usage); + expect(b.input + b.output + b.cacheRead + b.cacheWrite).toBeCloseTo( + estimateCost(usage), + 5, + ); + // Split follows the rate table's own proportions ($2 input : $6 output). + expect(b.input).toBeCloseTo(4, 5); + expect(b.output).toBeCloseTo(12, 5); + }); + + it("drops a fully provider-priced model from the unmapped diagnostic", () => { + // The banner asks the user to supply a missing rate. A row the provider + // priced in full needs no rate, so prompting for one would invite + // overriding a real bill with a guess. + const row = { + ...zeroUsage, + provider: "grok", + model: "grok-composer-2.5-fast", + input_tokens: 500, + cost_usd_ticks: 12_345_678_900, + uncosted_input_tokens: 0, + uncosted_output_tokens: 0, + uncosted_cache_read_tokens: 0, + uncosted_cache_write_tokens: 0, + }; + expect(collectUnmappedModels([row])).toEqual([]); + // ...but the same model still surfaces while any of its tokens are + // unpriced, because those genuinely need a rate. + expect( + collectUnmappedModels([{ ...row, uncosted_input_tokens: 500 }]), + ).toEqual(["grok/grok-composer-2.5-fast"]); + }); + it("leaves Grok SKUs that xAI does not publish a price for unmapped", () => { // No startsWith fallback: `grok-composer-2.5-fast` is in the Grok Build // catalog but absent from docs.x.ai/developers/pricing, so it must NOT diff --git a/packages/views/runtimes/utils.ts b/packages/views/runtimes/utils.ts index 954fe03193..50b614473d 100644 --- a/packages/views/runtimes/utils.ts +++ b/packages/views/runtimes/utils.ts @@ -256,15 +256,20 @@ const MODEL_PRICING: Record< "glm-4.5-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, // -- xAI Grok (docs.x.ai/developers/pricing). Rates below are the - // short-context tier. xAI bills a request at 2x ("long context") once - // its prompt reaches 200K tokens, but aggregated usage rows carry no - // per-request prompt sizes, so we price at the standard tier — the same - // trade-off the Anthropic `[1m]` context tag takes (see `resolvePricing`). + // short-context tier, and are now only a FALLBACK for Grok: xAI reports + // its own price per turn and `estimateCost` prefers it. That matters + // because xAI bills a request at 2x once its prompt reaches 200K tokens, + // and a usage row aggregates every model call in a turn — so these rates + // cannot tell which tier a request hit, while xAI's own figure already + // has it priced in. These rows still apply to Grok usage recorded by a + // daemon too old to report cost (the same trade-off the Anthropic `[1m]` + // context tag takes, see `resolvePricing`). // `cacheRead` is xAI's published "Cached" input rate; there is no // separate cache-write rate on the page (writes bill as normal input), // so cacheWrite mirrors input per the header note. Grok ids are - // vendor-prefixed, so these keys stay unqualified even though the - // daemon tags the rows with provider `xai`. + // vendor-prefixed, so these keys stay unqualified — which is what makes + // them resolve at all, since the daemon tags the rows with the runtime + // provider `grok`, not `xai`. // `grok-composer-*` ships in the Grok Build catalog // (server/pkg/agent/models.go) but is absent from the price sheet; it // deliberately stays unmapped rather than inheriting a guessed rate. -- @@ -468,12 +473,22 @@ export function isModelPriced(model: string, provider?: string): boolean { // the row carries a provider, so the same bare model id reported by two // providers surfaces as two distinct entries the user can price separately. // Empty when everything's priced or there are no rows. +// A row the provider priced in full needs no rate-table entry, so it must not +// raise the "we can't price this model" warning — its cost is already exact, +// and asking the user to supply a rate for it would be asking them to override +// a real bill with a guess. export function collectUnmappedModels(rows: readonly Priceable[]): string[] { const set = new Set(); for (const r of rows) { - if (r.model && !isModelPriced(r.model, r.provider)) { - set.add(pricingKey(r.model, r.provider)); - } + if (!r.model || isModelPriced(r.model, r.provider)) continue; + const uncosted = uncostedTokens(r); + const needsEstimate = + uncosted.input > 0 || + uncosted.output > 0 || + uncosted.cacheRead > 0 || + uncosted.cacheWrite > 0; + if (!needsEstimate && (r.cost_usd_ticks ?? 0) > 0) continue; + set.add(pricingKey(r.model, r.provider)); } return Array.from(set).toSorted(); } @@ -488,18 +503,85 @@ export function collectUnmappedModels(rows: readonly Priceable[]): string[] { // DashboardUsageDaily / DashboardUsageByAgent all carry it on the wire. type Priceable = Pick< RuntimeUsage, - "model" | "input_tokens" | "output_tokens" | "cache_read_tokens" | "cache_write_tokens" -> & { provider?: string }; + | "model" + | "input_tokens" + | "output_tokens" + | "cache_read_tokens" + | "cache_write_tokens" +> & { + provider?: string; + cost_usd_ticks?: number; + uncosted_input_tokens?: number; + uncosted_output_tokens?: number; + uncosted_cache_read_tokens?: number; + uncosted_cache_write_tokens?: number; +}; +// Providers report cost in ticks of 1e-10 USD (xAI's unit), which keeps +// sub-cent turn costs exact as integers all the way from the agent to here. +const COST_USD_TICKS_PER_USD = 10_000_000_000; + +// The tokens in a row that still need pricing from the table above, i.e. the +// ones no provider priced for us. +// +// A backend older than the cost split sends no `uncosted_*` at all. Treating +// that as "0 tokens left to estimate" would report $0 for every row, so +// `undefined` falls back to the full token counts — exactly the pre-split +// behaviour. The one exception is a row that DOES carry an authoritative cost +// without the split: adding a full-token estimate on top would double-charge +// it, so the authoritative figure stands alone. A current backend always sends +// both, so this only guards against version drift. +function uncostedTokens(usage: Priceable): { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} { + if (usage.uncosted_input_tokens === undefined) { + if ((usage.cost_usd_ticks ?? 0) > 0) { + return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + } + return { + input: usage.input_tokens, + output: usage.output_tokens, + cacheRead: usage.cache_read_tokens, + cacheWrite: usage.cache_write_tokens, + }; + } + return { + input: usage.uncosted_input_tokens, + output: usage.uncosted_output_tokens ?? 0, + cacheRead: usage.uncosted_cache_read_tokens ?? 0, + cacheWrite: usage.uncosted_cache_write_tokens ?? 0, + }; +} + +// Cost of a usage row: what the provider actually charged, plus a rate-table +// estimate for whatever it didn't charge for. +// +// The rate table cannot express request-level pricing rules — xAI bills a Grok +// request at 2x once its prompt reaches 200K tokens, and these rows aggregate +// every model call in a turn, so the token counts genuinely cannot say which +// tier a given request hit. Where the provider tells us its own price, that +// number is the bill and no estimate can improve on it. +// +// Both halves are summed rather than one winning outright because a single row +// can aggregate both kinds of source row (two providers in a bucket, or Grok +// either side of a CLI upgrade). Custom pricing overrides still apply — but +// only to the estimated half, since they are a user's guess at a rate and the +// authoritative half is not a guess. export function estimateCost(usage: Priceable): number { + const authoritative = (usage.cost_usd_ticks ?? 0) / COST_USD_TICKS_PER_USD; const pricing = resolvePricing(usage.model, usage.provider); - if (!pricing) return 0; + if (!pricing) return authoritative; + const uncosted = uncostedTokens(usage); return ( - (usage.input_tokens * pricing.input + - usage.output_tokens * pricing.output + - usage.cache_read_tokens * pricing.cacheRead + - usage.cache_write_tokens * pricing.cacheWrite) / - 1_000_000 + authoritative + + (uncosted.input * pricing.input + + uncosted.output * pricing.output + + uncosted.cacheRead * pricing.cacheRead + + uncosted.cacheWrite * pricing.cacheWrite) / + 1_000_000 ); } @@ -510,16 +592,59 @@ export interface CostBreakdown { cacheWrite: number; } +// Per-token-type split of `estimateCost`. The estimated half splits naturally; +// the authoritative half arrives as one number per row, so it is distributed +// across the buckets in the same proportions the rate table would have charged. +// Only the total is authoritative — the split is presentation, and doing it +// this way keeps the stacked chart summing to the headline figure instead of +// silently under-drawing every Grok row. export function estimateCostBreakdown(usage: Priceable): CostBreakdown { const pricing = resolvePricing(usage.model, usage.provider); if (!pricing) { - return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + // No rates to split by, but the provider may still have priced the turn + // itself. Returning zeros here would make the stacked chart disagree with + // the headline `estimateCost` on exactly the rows whose cost is EXACT, so + // the charge lands whole in one bucket instead. + return { + input: (usage.cost_usd_ticks ?? 0) / COST_USD_TICKS_PER_USD, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }; } + const uncosted = uncostedTokens(usage); + const breakdown: CostBreakdown = { + input: (uncosted.input * pricing.input) / 1_000_000, + output: (uncosted.output * pricing.output) / 1_000_000, + cacheRead: (uncosted.cacheRead * pricing.cacheRead) / 1_000_000, + cacheWrite: (uncosted.cacheWrite * pricing.cacheWrite) / 1_000_000, + }; + + const authoritative = (usage.cost_usd_ticks ?? 0) / COST_USD_TICKS_PER_USD; + if (authoritative <= 0) return breakdown; + + // Shape the authoritative charge like the rate table would have priced the + // tokens it covers — the row's full tokens minus the estimated ones. + const shape = { + input: ((usage.input_tokens - uncosted.input) * pricing.input) / 1_000_000, + output: ((usage.output_tokens - uncosted.output) * pricing.output) / 1_000_000, + cacheRead: + ((usage.cache_read_tokens - uncosted.cacheRead) * pricing.cacheRead) / 1_000_000, + cacheWrite: + ((usage.cache_write_tokens - uncosted.cacheWrite) * pricing.cacheWrite) / 1_000_000, + }; + const shapeTotal = shape.input + shape.output + shape.cacheRead + shape.cacheWrite; + if (shapeTotal <= 0) { + // Nothing to shape it with (unpriced tokens, or a row carrying cost but no + // tokens). Keep the money in the total rather than dropping it. + return { ...breakdown, input: breakdown.input + authoritative }; + } + const scale = authoritative / shapeTotal; return { - input: (usage.input_tokens * pricing.input) / 1_000_000, - output: (usage.output_tokens * pricing.output) / 1_000_000, - cacheRead: (usage.cache_read_tokens * pricing.cacheRead) / 1_000_000, - cacheWrite: (usage.cache_write_tokens * pricing.cacheWrite) / 1_000_000, + input: breakdown.input + shape.input * scale, + output: breakdown.output + shape.output * scale, + cacheRead: breakdown.cacheRead + shape.cacheRead * scale, + cacheWrite: breakdown.cacheWrite + shape.cacheWrite * scale, }; } diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index beea6baa3c..4eaa8fc331 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -4779,6 +4779,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i OutputTokens: u.OutputTokens, CacheReadTokens: u.CacheReadTokens, CacheWriteTokens: u.CacheWriteTokens, + CostUSDTicks: u.CostUSDTicks, }) } @@ -5454,6 +5455,7 @@ func mergeUsage(a, b map[string]agent.TokenUsage) map[string]agent.TokenUsage { existing.OutputTokens += u.OutputTokens existing.CacheReadTokens += u.CacheReadTokens existing.CacheWriteTokens += u.CacheWriteTokens + existing.CostUSDTicks += u.CostUSDTicks merged[model] = existing } return merged diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index a4611e5471..0b4da92ad1 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -241,6 +241,11 @@ type TaskUsageEntry struct { OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` CacheWriteTokens int64 `json:"cache_write_tokens"` + // CostUSDTicks is the provider's own price for this usage, in 1e-10 USD. + // Omitted when the agent reports no cost, which is the common case — the + // server then leaves the column NULL and the client estimates from the + // pricing table instead. See agent.TokenUsage.CostUSDTicks. + CostUSDTicks int64 `json:"cost_usd_ticks,omitempty"` } // TaskResult is the outcome of executing a task. diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 93a28cf9cd..da83f2c43f 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -3344,6 +3344,22 @@ type TaskUsagePayload struct { OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` CacheWriteTokens int64 `json:"cache_write_tokens"` + // CostUSDTicks is the provider's own price for this usage in 1e-10 USD. + // Absent or 0 from every daemon that doesn't have one (older builds, and + // every provider except Grok today) — stored as NULL so the reader knows + // to fall back to rate-table estimation rather than reading a real $0. + CostUSDTicks int64 `json:"cost_usd_ticks"` +} + +// authoritativeCostTicks converts a reported cost into the nullable column. +// Only a positive figure is authoritative: 0 is what a daemon that knows +// nothing about cost sends, and storing it would claim a genuine $0 spend and +// suppress the estimate. Negative is nonsense from a malformed report. +func authoritativeCostTicks(ticks int64) pgtype.Int8 { + if ticks <= 0 { + return pgtype.Int8{} + } + return pgtype.Int8{Int64: ticks, Valid: true} } func (h *Handler) ReportTaskUsage(w http.ResponseWriter, r *http.Request) { @@ -3391,11 +3407,12 @@ func (h *Handler) ReportTaskUsage(w http.ResponseWriter, r *http.Request) { OutputTokens: u.OutputTokens, CacheReadTokens: u.CacheReadTokens, CacheWriteTokens: u.CacheWriteTokens, + CostUsdTicks: authoritativeCostTicks(u.CostUSDTicks), }); err != nil { slog.Warn("upsert task usage failed", "task_id", taskID, "model", u.Model, "error", err) continue } - h.TaskService.CaptureTaskUsage(r.Context(), task, provider, u.Model, u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens) + h.TaskService.CaptureTaskUsage(r.Context(), task, provider, u.Model, u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens, u.CostUSDTicks) // Surface prompt-cache effectiveness per run so cache hit rates are // observable in logs, not just queryable from runtime_usage. The ratio @@ -3796,7 +3813,13 @@ func (h *Handler) GetIssueUsage(w http.ResponseWriter, r *http.Request) { "total_output_tokens": row.TotalOutputTokens, "total_cache_read_tokens": row.TotalCacheReadTokens, "total_cache_write_tokens": row.TotalCacheWriteTokens, - "task_count": row.TaskCount, + // Cost split — see the note on DashboardUsageDailyResponse. + "cost_usd_ticks": row.TotalCostUsdTicks, + "uncosted_input_tokens": row.UncostedInputTokens, + "uncosted_output_tokens": row.UncostedOutputTokens, + "uncosted_cache_read_tokens": row.UncostedCacheReadTokens, + "uncosted_cache_write_tokens": row.UncostedCacheWriteTokens, + "task_count": row.TaskCount, }) } diff --git a/server/internal/handler/dashboard.go b/server/internal/handler/dashboard.go index d3f44af875..d25f474849 100644 --- a/server/internal/handler/dashboard.go +++ b/server/internal/handler/dashboard.go @@ -63,7 +63,17 @@ type DashboardUsageDailyResponse struct { OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` CacheWriteTokens int64 `json:"cache_write_tokens"` - TaskCount int32 `json:"task_count"` + // Cost split: `CostUSDTicks` is what the provider itself charged for the + // rows behind this aggregate (1e-10 USD), and the `Uncosted*` token + // counts are the tokens from rows the provider did NOT price. The client + // reports authoritative + estimate(uncosted), so a window mixing both + // kinds of row stays whole. See migration 213. + CostUSDTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } // GetDashboardUsageDaily returns per-(date, model) token rows for the @@ -108,14 +118,19 @@ func (h *Handler) listDashboardUsageDaily( resp := make([]DashboardUsageDailyResponse, len(rows)) for i, row := range rows { resp[i] = DashboardUsageDailyResponse{ - Date: row.Date.Time.Format("2006-01-02"), - Provider: row.Provider, - Model: row.Model, - InputTokens: row.InputTokens, - OutputTokens: row.OutputTokens, - CacheReadTokens: row.CacheReadTokens, - CacheWriteTokens: row.CacheWriteTokens, - TaskCount: row.TaskCount, + Date: row.Date.Time.Format("2006-01-02"), + Provider: row.Provider, + Model: row.Model, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CacheReadTokens: row.CacheReadTokens, + CacheWriteTokens: row.CacheWriteTokens, + CostUSDTicks: row.CostUsdTicks, + UncostedInputTokens: row.UncostedInputTokens, + UncostedOutputTokens: row.UncostedOutputTokens, + UncostedCacheReadTokens: row.UncostedCacheReadTokens, + UncostedCacheWriteTokens: row.UncostedCacheWriteTokens, + TaskCount: row.TaskCount, } } return resp, nil @@ -132,7 +147,17 @@ type DashboardUsageByAgentResponse struct { OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` CacheWriteTokens int64 `json:"cache_write_tokens"` - TaskCount int32 `json:"task_count"` + // Cost split: `CostUSDTicks` is what the provider itself charged for the + // rows behind this aggregate (1e-10 USD), and the `Uncosted*` token + // counts are the tokens from rows the provider did NOT price. The client + // reports authoritative + estimate(uncosted), so a window mixing both + // kinds of row stays whole. See migration 213. + CostUSDTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } // GetDashboardUsageByAgent returns per-(agent, model) token aggregates @@ -177,14 +202,19 @@ func (h *Handler) listDashboardUsageByAgent( resp := make([]DashboardUsageByAgentResponse, len(rows)) for i, row := range rows { resp[i] = DashboardUsageByAgentResponse{ - AgentID: uuidToString(row.AgentID), - Provider: row.Provider, - Model: row.Model, - InputTokens: row.InputTokens, - OutputTokens: row.OutputTokens, - CacheReadTokens: row.CacheReadTokens, - CacheWriteTokens: row.CacheWriteTokens, - TaskCount: row.TaskCount, + AgentID: uuidToString(row.AgentID), + Provider: row.Provider, + Model: row.Model, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CacheReadTokens: row.CacheReadTokens, + CacheWriteTokens: row.CacheWriteTokens, + CostUSDTicks: row.CostUsdTicks, + UncostedInputTokens: row.UncostedInputTokens, + UncostedOutputTokens: row.UncostedOutputTokens, + UncostedCacheReadTokens: row.UncostedCacheReadTokens, + UncostedCacheWriteTokens: row.UncostedCacheWriteTokens, + TaskCount: row.TaskCount, } } return resp, nil diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index 8f850c71f8..709932714f 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -90,6 +90,16 @@ type RuntimeUsageResponse struct { OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` CacheWriteTokens int64 `json:"cache_write_tokens"` + // Cost split: `CostUSDTicks` is what the provider itself charged for the + // rows behind this aggregate (1e-10 USD), and the `Uncosted*` token + // counts are the tokens from rows the provider did NOT price. The client + // reports authoritative + estimate(uncosted), so a window mixing both + // kinds of row stays whole. See migration 213. + CostUSDTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` } // GetRuntimeUsage returns daily token usage for a runtime, aggregated from @@ -141,14 +151,19 @@ func (h *Handler) listRuntimeUsage(ctx context.Context, runtimeID pgtype.UUID, t resp := make([]RuntimeUsageResponse, len(rows)) for i, row := range rows { resp[i] = RuntimeUsageResponse{ - RuntimeID: resolvedRuntimeID, - Date: row.Date.Time.Format("2006-01-02"), - Provider: row.Provider, - Model: row.Model, - InputTokens: row.InputTokens, - OutputTokens: row.OutputTokens, - CacheReadTokens: row.CacheReadTokens, - CacheWriteTokens: row.CacheWriteTokens, + RuntimeID: resolvedRuntimeID, + Date: row.Date.Time.Format("2006-01-02"), + Provider: row.Provider, + Model: row.Model, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CacheReadTokens: row.CacheReadTokens, + CacheWriteTokens: row.CacheWriteTokens, + CostUSDTicks: row.CostUsdTicks, + UncostedInputTokens: row.UncostedInputTokens, + UncostedOutputTokens: row.UncostedOutputTokens, + UncostedCacheReadTokens: row.UncostedCacheReadTokens, + UncostedCacheWriteTokens: row.UncostedCacheWriteTokens, } } return resp, nil @@ -208,7 +223,17 @@ type RuntimeUsageByAgentResponse struct { OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` CacheWriteTokens int64 `json:"cache_write_tokens"` - TaskCount int32 `json:"task_count"` + // Cost split: `CostUSDTicks` is what the provider itself charged for the + // rows behind this aggregate (1e-10 USD), and the `Uncosted*` token + // counts are the tokens from rows the provider did NOT price. The client + // reports authoritative + estimate(uncosted), so a window mixing both + // kinds of row stays whole. See migration 213. + CostUSDTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } // GetRuntimeUsageByAgent returns per-agent token aggregates for a runtime @@ -247,14 +272,19 @@ func (h *Handler) GetRuntimeUsageByAgent(w http.ResponseWriter, r *http.Request) resp := make([]RuntimeUsageByAgentResponse, len(rows)) for i, row := range rows { resp[i] = RuntimeUsageByAgentResponse{ - AgentID: uuidToString(row.AgentID), - Provider: row.Provider, - Model: row.Model, - InputTokens: row.InputTokens, - OutputTokens: row.OutputTokens, - CacheReadTokens: row.CacheReadTokens, - CacheWriteTokens: row.CacheWriteTokens, - TaskCount: row.TaskCount, + AgentID: uuidToString(row.AgentID), + Provider: row.Provider, + Model: row.Model, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CacheReadTokens: row.CacheReadTokens, + CacheWriteTokens: row.CacheWriteTokens, + CostUSDTicks: row.CostUsdTicks, + UncostedInputTokens: row.UncostedInputTokens, + UncostedOutputTokens: row.UncostedOutputTokens, + UncostedCacheReadTokens: row.UncostedCacheReadTokens, + UncostedCacheWriteTokens: row.UncostedCacheWriteTokens, + TaskCount: row.TaskCount, } } @@ -271,7 +301,17 @@ type RuntimeUsageByHourResponse struct { OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` CacheWriteTokens int64 `json:"cache_write_tokens"` - TaskCount int32 `json:"task_count"` + // Cost split: `CostUSDTicks` is what the provider itself charged for the + // rows behind this aggregate (1e-10 USD), and the `Uncosted*` token + // counts are the tokens from rows the provider did NOT price. The client + // reports authoritative + estimate(uncosted), so a window mixing both + // kinds of row stays whole. See migration 213. + CostUSDTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } // GetRuntimeUsageByHour returns hourly (0..23) token aggregates for a @@ -313,13 +353,18 @@ func (h *Handler) GetRuntimeUsageByHour(w http.ResponseWriter, r *http.Request) resp := make([]RuntimeUsageByHourResponse, len(rows)) for i, row := range rows { resp[i] = RuntimeUsageByHourResponse{ - Hour: int(row.Hour), - Model: row.Model, - InputTokens: row.InputTokens, - OutputTokens: row.OutputTokens, - CacheReadTokens: row.CacheReadTokens, - CacheWriteTokens: row.CacheWriteTokens, - TaskCount: row.TaskCount, + Hour: int(row.Hour), + Model: row.Model, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CacheReadTokens: row.CacheReadTokens, + CacheWriteTokens: row.CacheWriteTokens, + CostUSDTicks: row.CostUsdTicks, + UncostedInputTokens: row.UncostedInputTokens, + UncostedOutputTokens: row.UncostedOutputTokens, + UncostedCacheReadTokens: row.UncostedCacheReadTokens, + UncostedCacheWriteTokens: row.UncostedCacheWriteTokens, + TaskCount: row.TaskCount, } } diff --git a/server/internal/metrics/business.go b/server/internal/metrics/business.go index 548bfae45f..f25008ff8c 100644 --- a/server/internal/metrics/business.go +++ b/server/internal/metrics/business.go @@ -250,7 +250,11 @@ func (m *BusinessMetrics) RecordTaskLeaseExpired(source string) { m.taskLeaseExpired.WithLabelValues(NormalizeTaskSource(source)).Inc() } -func (m *BusinessMetrics) RecordLLMUsage(source, runtimeMode, rawProvider, modelAlias string, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int64) { +// costUSDTicks is the provider's own price for this usage in 1e-10 USD, or 0 +// when it reported none. When present it wins over the rate table: the table +// cannot express request-level rules such as xAI's 2x surcharge above a 200K +// prompt, so for those providers the local estimate is structurally low. +func (m *BusinessMetrics) RecordLLMUsage(source, runtimeMode, rawProvider, modelAlias string, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUSDTicks int64) { if m == nil { return } @@ -264,17 +268,62 @@ func (m *BusinessMetrics) RecordLLMUsage(source, runtimeMode, rawProvider, model m.recordUnpricedTokens(provider, alias, "output", outputTokens) m.recordUnpricedTokens(provider, alias, "cache_read", cacheReadTokens) m.recordUnpricedTokens(provider, alias, "cache_write", cacheWriteTokens) + // Having no rate row does not mean having no cost: the provider may + // have priced the turn itself (`grok-composer-*` is in the Grok Build + // catalog but absent from xAI's price sheet). Dropping the charge here + // would under-report real spend purely for lack of a rate we no longer + // need. Without rates there is nothing to split the total by, so it + // lands whole in the `input` bucket — the same fallback + // distributeAuthoritativeCost uses when it has no shape to scale. + if costUSDTicks > 0 { + m.llmCostUSD. + WithLabelValues(provider, alias, NormalizeTokenType("input"), runtimeMode, source). + Add(float64(costUSDTicks) / CostUSDTicksPerUSD) + } m.llmRequests.WithLabelValues(provider, "unknown", runtimeMode).Inc() return } - m.recordPricedTokens(price.Provider, price.Model, "input", runtimeMode, source, inputTokens, tokenCostUSD(inputTokens, price.InputPerM)) - m.recordPricedTokens(price.Provider, price.Model, "output", runtimeMode, source, outputTokens, tokenCostUSD(outputTokens, price.OutputPerM)) - m.recordPricedTokens(price.Provider, price.Model, "cache_read", runtimeMode, source, cacheReadTokens, tokenCostUSD(cacheReadTokens, price.CacheReadPerM)) - m.recordPricedTokens(price.Provider, price.Model, "cache_write", runtimeMode, source, cacheWriteTokens, tokenCostUSD(cacheWriteTokens, price.CacheWritePerM)) + costs := [4]float64{ + tokenCostUSD(inputTokens, price.InputPerM), + tokenCostUSD(outputTokens, price.OutputPerM), + tokenCostUSD(cacheReadTokens, price.CacheReadPerM), + tokenCostUSD(cacheWriteTokens, price.CacheWritePerM), + } + if costUSDTicks > 0 { + costs = distributeAuthoritativeCost(float64(costUSDTicks)/CostUSDTicksPerUSD, costs) + } + + m.recordPricedTokens(price.Provider, price.Model, "input", runtimeMode, source, inputTokens, costs[0]) + m.recordPricedTokens(price.Provider, price.Model, "output", runtimeMode, source, outputTokens, costs[1]) + m.recordPricedTokens(price.Provider, price.Model, "cache_read", runtimeMode, source, cacheReadTokens, costs[2]) + m.recordPricedTokens(price.Provider, price.Model, "cache_write", runtimeMode, source, cacheWriteTokens, costs[3]) m.llmRequests.WithLabelValues(price.Provider, price.Model, runtimeMode).Inc() } +// distributeAuthoritativeCost rescales the per-token-type estimates so they +// sum to the provider's actual charge. `llm_cost_usd` is broken down by +// token_type and the provider reports one number for the whole turn, so the +// split has to come from somewhere; the rate table's own proportions are the +// best available guess and keep the total exact. Only the total is +// authoritative — the per-type split remains an estimate, which is why this +// scales rather than inventing a new label value. +// +// A zero estimate (unknown rates, or a turn recorded with no tokens) has no +// proportions to scale, so the charge lands on `input` to avoid dropping real +// spend from the total. +func distributeAuthoritativeCost(actual float64, estimated [4]float64) [4]float64 { + total := estimated[0] + estimated[1] + estimated[2] + estimated[3] + if total <= 0 { + return [4]float64{actual, 0, 0, 0} + } + scale := actual / total + for i := range estimated { + estimated[i] *= scale + } + return estimated +} + func (m *BusinessMetrics) recordPricedTokens(provider, model, tokenType, runtimeMode, source string, tokens int64, cost float64) { if tokens <= 0 { return diff --git a/server/internal/metrics/business_test.go b/server/internal/metrics/business_test.go index 691643d985..ebcabb51af 100644 --- a/server/internal/metrics/business_test.go +++ b/server/internal/metrics/business_test.go @@ -1,6 +1,7 @@ package metrics import ( + "math" "strconv" "testing" @@ -68,7 +69,7 @@ func TestBusinessMetricsFailureReasonUsesCanonicalClassifier(t *testing.T) { func TestBusinessMetricsLLMPricingAndUnpricedTokens(t *testing.T) { m := NewBusinessMetrics() - m.RecordLLMUsage("chat", "cloud", "codex", "gpt-5.4", 1_000_000, 2_000_000, 3_000_000, 4_000_000) + m.RecordLLMUsage("chat", "cloud", "codex", "gpt-5.4", 1_000_000, 2_000_000, 3_000_000, 4_000_000, 0) if got := testutil.ToFloat64(m.llmTokens.WithLabelValues("openai", "gpt-5.4", "input", "cloud", "chat")); got != 1_000_000 { t.Fatalf("priced input tokens = %v, want 1000000", got) @@ -86,7 +87,7 @@ func TestBusinessMetricsLLMPricingAndUnpricedTokens(t *testing.T) { t.Fatalf("priced request counter = %v, want 1", got) } - m.RecordLLMUsage("issue", "local", "custom-provider", "Free Model!!", 7, 0, 0, 0) + m.RecordLLMUsage("issue", "local", "custom-provider", "Free Model!!", 7, 0, 0, 0, 0) if got := testutil.ToFloat64(m.llmUnpricedTokens.WithLabelValues("other", "free_model_", "input")); got != 7 { t.Fatalf("unpriced input tokens = %v, want 7", got) } @@ -107,8 +108,8 @@ func TestBusinessMetricsRegistryExposesAllFamilies(t *testing.T) { m.RecordTaskFailed("issue", "local", taskfailure.ReasonTimeout.String()) m.RecordTaskQueuedExpired("issue", "local") m.RecordTaskLeaseExpired("issue") - m.RecordLLMUsage("issue", "local", "codex", "gpt-5.4", 1, 1, 1, 1) - m.RecordLLMUsage("issue", "local", "custom-provider", "custom-model", 1, 0, 0, 0) + m.RecordLLMUsage("issue", "local", "codex", "gpt-5.4", 1, 1, 1, 1, 0) + m.RecordLLMUsage("issue", "local", "custom-provider", "custom-model", 1, 0, 0, 0, 0) // PR3 funnel / community / commercial events. Drive every counter // with one synthetic value so the gather loop below sees the family. @@ -169,3 +170,136 @@ func exerciseEvent(m *BusinessMetrics, name string, props map[string]any) { } m.IncForEvent(analytics.Event{Name: name, Properties: props}) } + +// TestBusinessMetricsPrefersProviderReportedCost pins the rule that a +// provider's own charge wins over the rate table. The table cannot express +// request-level pricing (xAI bills a Grok request at 2x above a 200K prompt), +// so for those turns the local estimate is structurally low and recording it +// would under-report real spend. +func TestBusinessMetricsPrefersProviderReportedCost(t *testing.T) { + m := NewBusinessMetrics() + + // 1M input + 1M output on grok-4.5 estimates to $2 + $6 = $8 from the + // table. The provider says the turn cost $16 — the long-context tier. + const actualUSD = 16.0 + m.RecordLLMUsage("issue", "local", "grok", "grok-4.5", + 1_000_000, 1_000_000, 0, 0, int64(actualUSD*CostUSDTicksPerUSD)) + + input := testutil.ToFloat64(m.llmCostUSD.WithLabelValues("xai", "grok-4.5", "input", "local", "issue")) + output := testutil.ToFloat64(m.llmCostUSD.WithLabelValues("xai", "grok-4.5", "output", "local", "issue")) + if total := input + output; math.Abs(total-actualUSD) > 1e-9 { + t.Fatalf("recorded cost = %v, want %v (the provider's own charge)", total, actualUSD) + } + // The per-type split is presentation only, but it must follow the table's + // own proportions ($2 input : $6 output) so the labels stay meaningful. + if math.Abs(input-4) > 1e-9 { + t.Errorf("input cost = %v, want 4 (a quarter of the charge)", input) + } + if math.Abs(output-12) > 1e-9 { + t.Errorf("output cost = %v, want 12 (three quarters of the charge)", output) + } + // Token counters are unaffected by the cost source. + if got := testutil.ToFloat64(m.llmTokens.WithLabelValues("xai", "grok-4.5", "input", "local", "issue")); got != 1_000_000 { + t.Errorf("input tokens = %v, want 1000000", got) + } +} + +// TestBusinessMetricsFallsBackToRateTableWithoutProviderCost is the other half +// of the rule: every provider that reports no cost must keep being estimated. +func TestBusinessMetricsFallsBackToRateTableWithoutProviderCost(t *testing.T) { + m := NewBusinessMetrics() + + m.RecordLLMUsage("issue", "local", "grok", "grok-4.5", 1_000_000, 1_000_000, 0, 0, 0) + + input := testutil.ToFloat64(m.llmCostUSD.WithLabelValues("xai", "grok-4.5", "input", "local", "issue")) + output := testutil.ToFloat64(m.llmCostUSD.WithLabelValues("xai", "grok-4.5", "output", "local", "issue")) + if math.Abs(input-2) > 1e-9 || math.Abs(output-6) > 1e-9 { + t.Fatalf("estimated cost = (%v, %v), want (2, 6) from the rate table", input, output) + } +} + +func TestDistributeAuthoritativeCost(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + actual float64 + estimated [4]float64 + want [4]float64 + }{ + { + name: "scales the estimate to the real charge", + actual: 16, + estimated: [4]float64{2, 6, 0, 0}, + want: [4]float64{4, 12, 0, 0}, + }, + { + name: "scales down when the estimate was too high", + actual: 4, + estimated: [4]float64{2, 6, 0, 0}, + want: [4]float64{1, 3, 0, 0}, + }, + { + // No proportions to shape it with — the charge still has to land + // somewhere, or real spend silently vanishes from the total. + name: "keeps the charge when there is nothing to scale", + actual: 7, + estimated: [4]float64{0, 0, 0, 0}, + want: [4]float64{7, 0, 0, 0}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := distributeAuthoritativeCost(tc.actual, tc.estimated) + var sum float64 + for i := range got { + if math.Abs(got[i]-tc.want[i]) > 1e-9 { + t.Errorf("bucket %d = %v, want %v", i, got[i], tc.want[i]) + } + sum += got[i] + } + if math.Abs(sum-tc.actual) > 1e-9 { + t.Errorf("buckets sum to %v, want the actual charge %v", sum, tc.actual) + } + }) + } +} + +// TestBusinessMetricsRecordsProviderCostForUnpricedModel covers the gap where +// a model has no rate row but the provider priced the turn itself +// (`grok-composer-*` is in the Grok Build catalog and absent from xAI's price +// sheet). Returning early on "no rates" would drop real spend from +// llm_cost_usd for want of a rate the charge does not need. +func TestBusinessMetricsRecordsProviderCostForUnpricedModel(t *testing.T) { + m := NewBusinessMetrics() + + const actualUSD = 1.23456789 + m.RecordLLMUsage("issue", "local", "grok", "grok-composer-2.5-fast", + 500, 100, 0, 0, int64(actualUSD*CostUSDTicksPerUSD)) + + // No rates means no way to split by token type, so the whole charge lands + // in one bucket — but it must be the whole charge. + got := testutil.ToFloat64(m.llmCostUSD.WithLabelValues( + "grok", "grok-composer-2.5-fast", "input", "local", "issue")) + if math.Abs(got-actualUSD) > 1e-9 { + t.Fatalf("recorded cost = %v, want %v", got, actualUSD) + } + // The tokens still have no rate, so they stay in the unpriced counter — + // "unpriced" describes the rate table, not the money. + if got := testutil.ToFloat64(m.llmUnpricedTokens.WithLabelValues("grok", "grok-composer-2.5-fast", "input")); got != 500 { + t.Errorf("unpriced input tokens = %v, want 500", got) + } +} + +// TestBusinessMetricsUnpricedModelWithoutCostStaysAtZero is the control: no +// rates and no provider cost must record no spend, not a fabricated one. +func TestBusinessMetricsUnpricedModelWithoutCostStaysAtZero(t *testing.T) { + m := NewBusinessMetrics() + + m.RecordLLMUsage("issue", "local", "grok", "grok-composer-2.5-fast", 500, 100, 0, 0, 0) + + if got := testutil.ToFloat64(m.llmCostUSD.WithLabelValues( + "grok", "grok-composer-2.5-fast", "input", "local", "issue")); got != 0 { + t.Fatalf("recorded cost = %v, want 0", got) + } +} diff --git a/server/internal/metrics/pricing.go b/server/internal/metrics/pricing.go index 9358b004b9..8f312393f2 100644 --- a/server/internal/metrics/pricing.go +++ b/server/internal/metrics/pricing.go @@ -5,6 +5,12 @@ import ( "strings" ) +// CostUSDTicksPerUSD is the scale of provider-reported costs: xAI reports +// whole ticks of 1e-10 USD. Declared here rather than imported from pkg/agent +// (which owns the wire-format parsing) so this package keeps no dependency on +// the agent runtime for a physical unit; the two must stay equal. +const CostUSDTicksPerUSD = 10_000_000_000 + type ModelPrice struct { Provider string Model string @@ -51,6 +57,22 @@ var modelPrices = map[string]ModelPrice{ "google:gemini-3.1-pro": {Provider: "google", Model: "gemini-3.1-pro", InputPerM: 2.00, CacheReadPerM: 0.20, CacheWritePerM: 2.00, OutputPerM: 12.00}, "google:gemini-2.5-pro": {Provider: "google", Model: "gemini-2.5-pro", InputPerM: 1.25, CacheReadPerM: 0.31, CacheWritePerM: 1.25, OutputPerM: 10.00}, "google:gemini-2.5-flash": {Provider: "google", Model: "gemini-2.5-flash", InputPerM: 0.30, CacheReadPerM: 0.03, CacheWritePerM: 0.30, OutputPerM: 2.50}, + // xAI Grok (docs.x.ai/developers/pricing). Short-context tier: xAI bills + // a request at 2x once its prompt reaches 200K tokens, but a usage record + // aggregates every model call in a turn, so it cannot say which tier any + // individual request hit — pricing the standard tier under-estimates a + // long-context turn by at most 50% instead of over-estimating a short one + // by 100%. xAI publishes no separate cache-write rate (writes bill as + // normal input), so CacheWrite mirrors Input. These rows mirror + // packages/views/runtimes/utils.ts; keep the two tables in sync. + // `grok-composer-*` ships in the Grok Build catalog but is absent from the + // price sheet, so it stays unmapped rather than inheriting a guessed rate. + "xai:grok-4.5": {Provider: "xai", Model: "grok-4.5", InputPerM: 2.00, CacheReadPerM: 0.30, CacheWritePerM: 2.00, OutputPerM: 6.00}, + "xai:grok-4.3": {Provider: "xai", Model: "grok-4.3", InputPerM: 1.25, CacheReadPerM: 0.20, CacheWritePerM: 1.25, OutputPerM: 2.50}, + "xai:grok-build-0.1": {Provider: "xai", Model: "grok-build-0.1", InputPerM: 1.00, CacheReadPerM: 0.20, CacheWritePerM: 1.00, OutputPerM: 2.00}, + "xai:grok-4.20-multi-agent-0309": {Provider: "xai", Model: "grok-4.20-multi-agent-0309", InputPerM: 1.25, CacheReadPerM: 0.20, CacheWritePerM: 1.25, OutputPerM: 2.50}, + "xai:grok-4.20-0309-reasoning": {Provider: "xai", Model: "grok-4.20-0309-reasoning", InputPerM: 1.25, CacheReadPerM: 0.20, CacheWritePerM: 1.25, OutputPerM: 2.50}, + "xai:grok-4.20-0309-non-reasoning": {Provider: "xai", Model: "grok-4.20-0309-non-reasoning", InputPerM: 1.25, CacheReadPerM: 0.20, CacheWritePerM: 1.25, OutputPerM: 2.50}, } var modelAliasRules = []struct { @@ -89,6 +111,16 @@ var modelAliasRules = []struct { {regexp.MustCompile(`gemini-3[.]1-pro`), "google:gemini-3.1-pro"}, {regexp.MustCompile(`gemini-2[.]5-pro`), "google:gemini-2.5-pro"}, {regexp.MustCompile(`gemini-2[.]5-flash`), "google:gemini-2.5-flash"}, + // Anchored exact-match, dotted spelling only — same rule as the gpt-5.6 + // rows above. The frontend resolver does not dash-normalize non-Anthropic + // ids, so a dashed `grok-4-5` must surface as unmapped on both sides + // rather than silently borrowing a tier here. + {regexp.MustCompile(`(^|/|:)grok-4\.5$`), "xai:grok-4.5"}, + {regexp.MustCompile(`(^|/|:)grok-4\.3$`), "xai:grok-4.3"}, + {regexp.MustCompile(`(^|/|:)grok-build-0\.1$`), "xai:grok-build-0.1"}, + {regexp.MustCompile(`(^|/|:)grok-4\.20-multi-agent-0309$`), "xai:grok-4.20-multi-agent-0309"}, + {regexp.MustCompile(`(^|/|:)grok-4\.20-0309-reasoning$`), "xai:grok-4.20-0309-reasoning"}, + {regexp.MustCompile(`(^|/|:)grok-4\.20-0309-non-reasoning$`), "xai:grok-4.20-0309-non-reasoning"}, } func PriceForModelAlias(model string) (ModelPrice, bool) { diff --git a/server/internal/metrics/pricing_test.go b/server/internal/metrics/pricing_test.go index d2325daed4..2fcc47d725 100644 --- a/server/internal/metrics/pricing_test.go +++ b/server/internal/metrics/pricing_test.go @@ -94,3 +94,105 @@ func TestPriceForModelAliasCodexGPT56(t *testing.T) { } } } + +// TestPriceForModelAliasGrok pins the xAI catalog to the published rates +// (docs.x.ai/developers/pricing). Before these rows existed every Grok token +// took the unpriced branch in RecordLLMUsage, so llm_cost_usd reported zero +// Grok spend while the tokens piled up in llm_unpriced_tokens. +func TestPriceForModelAliasGrok(t *testing.T) { + cases := []struct { + model string + want ModelPrice + }{ + { + model: "grok-4.5", + want: ModelPrice{Provider: "xai", Model: "grok-4.5", InputPerM: 2, CacheReadPerM: 0.3, CacheWritePerM: 2, OutputPerM: 6}, + }, + { + model: "xai:grok-4.5", + want: ModelPrice{Provider: "xai", Model: "grok-4.5", InputPerM: 2, CacheReadPerM: 0.3, CacheWritePerM: 2, OutputPerM: 6}, + }, + { + model: "xai/grok-4.5", + want: ModelPrice{Provider: "xai", Model: "grok-4.5", InputPerM: 2, CacheReadPerM: 0.3, CacheWritePerM: 2, OutputPerM: 6}, + }, + { + model: "grok-4.3", + want: ModelPrice{Provider: "xai", Model: "grok-4.3", InputPerM: 1.25, CacheReadPerM: 0.2, CacheWritePerM: 1.25, OutputPerM: 2.5}, + }, + { + model: "grok-build-0.1", + want: ModelPrice{Provider: "xai", Model: "grok-build-0.1", InputPerM: 1, CacheReadPerM: 0.2, CacheWritePerM: 1, OutputPerM: 2}, + }, + { + model: "grok-4.20-multi-agent-0309", + want: ModelPrice{Provider: "xai", Model: "grok-4.20-multi-agent-0309", InputPerM: 1.25, CacheReadPerM: 0.2, CacheWritePerM: 1.25, OutputPerM: 2.5}, + }, + { + model: "grok-4.20-0309-reasoning", + want: ModelPrice{Provider: "xai", Model: "grok-4.20-0309-reasoning", InputPerM: 1.25, CacheReadPerM: 0.2, CacheWritePerM: 1.25, OutputPerM: 2.5}, + }, + { + model: "grok-4.20-0309-non-reasoning", + want: ModelPrice{Provider: "xai", Model: "grok-4.20-0309-non-reasoning", InputPerM: 1.25, CacheReadPerM: 0.2, CacheWritePerM: 1.25, OutputPerM: 2.5}, + }, + } + + for _, tc := range cases { + got, ok := PriceForModelAlias(tc.model) + if !ok { + t.Fatalf("PriceForModelAlias(%q) did not resolve", tc.model) + } + if got != tc.want { + t.Fatalf("PriceForModelAlias(%q) = %+v, want %+v", tc.model, got, tc.want) + } + } + + // `grok-composer-*` ships in the Grok Build catalog but xAI publishes no + // rate for it, so it must stay unmapped rather than inherit grok-4.5's. + // Suffixed and dash-spelled variants must miss for the same reason the + // gpt-5.6 rows do: the frontend resolver is an exact match that does not + // dash-normalize non-Anthropic ids, so both sides agree on "unmapped". + for _, model := range []string{ + "grok-composer-2.5-fast", + "grok-composer-2.5", + "grok-4.5-fast", + "grok-4-5", + "grok-4.20-0309", + "grok", + "unknown", + } { + if got, ok := PriceForModelAlias(model); ok { + t.Fatalf("PriceForModelAlias(%q) unexpectedly resolved to %+v; want unmapped", model, got) + } + } +} + +// TestGrokPricingMatchesRecordedTurn re-derives the cost of a real +// grok 0.2.106 turn from the table and checks it against the costUsdTicks xAI +// returned for that same turn (1 tick = 1e-10 USD). This is the end-to-end +// proof that both the rates and the cached-input bucketing are right. +func TestGrokPricingMatchesRecordedTurn(t *testing.T) { + // Captured payload: inputTokens 12929, cachedReadTokens 10880, + // outputTokens 29, totalTokens 12958, costUsdTicks 75360000. Grok counts + // the cached prefix inside inputTokens, so the uncached remainder is + // 12929 - 10880 = 2049 (see excludeACPCachedInput in pkg/agent/hermes.go). + const ( + uncachedInput = int64(2049) + cacheRead = int64(10880) + output = int64(29) + wantUSD = 75360000 / 1e10 + ) + + price, ok := PriceForModelAlias("grok-4.5") + if !ok { + t.Fatal("grok-4.5 did not resolve") + } + got := tokenCostUSD(uncachedInput, price.InputPerM) + + tokenCostUSD(cacheRead, price.CacheReadPerM) + + tokenCostUSD(output, price.OutputPerM) + + if diff := got - wantUSD; diff > 1e-12 || diff < -1e-12 { + t.Fatalf("recomputed cost = %.10f, want %.10f (xAI costUsdTicks)", got, wantUSD) + } +} diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 741d28d4b2..1e28bc3449 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -668,12 +668,14 @@ func (s *TaskService) captureTaskCancelled(ctx context.Context, task db.AgentTas } } -func (s *TaskService) CaptureTaskUsage(ctx context.Context, task db.AgentTaskQueue, provider, model string, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int64) { +// costUSDTicks is the provider's own price for this usage in 1e-10 USD, or 0 +// when it reported none — the metrics layer prefers it over its rate table. +func (s *TaskService) CaptureTaskUsage(ctx context.Context, task db.AgentTaskQueue, provider, model string, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUSDTicks int64) { if s.Metrics == nil { return } source, runtimeMode, _ := s.taskMetricsContext(ctx, task) - s.Metrics.RecordLLMUsage(source, runtimeMode, provider, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens) + s.Metrics.RecordLLMUsage(source, runtimeMode, provider, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUSDTicks) } func (s *TaskService) CaptureQueuedExpiredTasks(ctx context.Context, tasks []db.AgentTaskQueue) { diff --git a/server/migrations/213_task_usage_authoritative_cost.down.sql b/server/migrations/213_task_usage_authoritative_cost.down.sql new file mode 100644 index 0000000000..2c1a160d13 --- /dev/null +++ b/server/migrations/213_task_usage_authoritative_cost.down.sql @@ -0,0 +1,141 @@ +-- Restore migration 102's rollup window function verbatim, then drop the +-- columns. Order matters: the function must stop referencing the columns +-- before they go away. +CREATE OR REPLACE FUNCTION rollup_task_usage_hourly_window( + p_from TIMESTAMPTZ, + p_to TIMESTAMPTZ +) +RETURNS BIGINT +LANGUAGE plpgsql +AS $$ +DECLARE + v_rows BIGINT; +BEGIN + IF p_from >= p_to THEN + RETURN 0; + END IF; + + WITH + dirty_from_updates AS ( + SELECT DISTINCT + task_usage_hour_bucket(tu.created_at) AS bucket_hour, + a.workspace_id AS workspace_id, + atq.runtime_id AS runtime_id, + atq.agent_id AS agent_id, + i.project_id AS project_id, + tu.provider AS provider, + tu.model AS model + FROM task_usage tu + JOIN agent_task_queue atq ON atq.id = tu.task_id + JOIN agent a ON a.id = atq.agent_id + LEFT JOIN issue i ON i.id = atq.issue_id + WHERE atq.runtime_id IS NOT NULL + AND ( + (tu.updated_at >= p_from AND tu.updated_at < p_to) + OR (tu.updated_at IS NULL + AND tu.created_at >= p_from + AND tu.created_at < p_to) + ) + ), + dirty_from_queue AS ( + SELECT bucket_hour, workspace_id, runtime_id, agent_id, + project_id, provider, model + FROM task_usage_hourly_dirty + WHERE enqueued_at < p_to + ), + dirty_keys AS ( + SELECT * FROM dirty_from_updates + UNION + SELECT * FROM dirty_from_queue + ), + recomputed AS ( + SELECT + dk.bucket_hour, + dk.workspace_id, + dk.runtime_id, + dk.agent_id, + dk.project_id, + dk.provider, + dk.model, + SUM(tu.input_tokens)::bigint AS input_tokens, + SUM(tu.output_tokens)::bigint AS output_tokens, + SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens, + SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens, + COUNT(DISTINCT tu.task_id)::bigint AS task_count, + COUNT(*)::bigint AS event_count + FROM dirty_keys dk + JOIN agent_task_queue atq ON atq.runtime_id = dk.runtime_id + AND atq.agent_id = dk.agent_id + JOIN agent a ON a.id = atq.agent_id + AND a.workspace_id = dk.workspace_id + LEFT JOIN issue i ON i.id = atq.issue_id + JOIN task_usage tu ON tu.task_id = atq.id + AND tu.provider = dk.provider + AND tu.model = dk.model + AND task_usage_hour_bucket(tu.created_at) = dk.bucket_hour + WHERE (i.project_id IS NOT DISTINCT FROM dk.project_id) + GROUP BY 1, 2, 3, 4, 5, 6, 7 + ), + upserted AS ( + INSERT INTO task_usage_hourly AS d ( + bucket_hour, workspace_id, runtime_id, agent_id, + project_id, provider, model, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + task_count, event_count + ) + SELECT + bucket_hour, workspace_id, runtime_id, agent_id, + project_id, provider, model, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + task_count, event_count + FROM recomputed + ON CONFLICT ON CONSTRAINT uq_task_usage_hourly_key DO UPDATE + SET input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + cache_write_tokens = EXCLUDED.cache_write_tokens, + task_count = EXCLUDED.task_count, + event_count = EXCLUDED.event_count, + updated_at = now() + RETURNING 1 + ), + deleted_empty AS ( + DELETE FROM task_usage_hourly d + USING dirty_keys dk + WHERE d.bucket_hour = dk.bucket_hour + AND d.workspace_id = dk.workspace_id + AND d.runtime_id = dk.runtime_id + AND d.agent_id = dk.agent_id + AND d.project_id IS NOT DISTINCT FROM dk.project_id + AND d.provider = dk.provider + AND d.model = dk.model + AND NOT EXISTS ( + SELECT 1 FROM recomputed r + WHERE r.bucket_hour = dk.bucket_hour + AND r.workspace_id = dk.workspace_id + AND r.runtime_id = dk.runtime_id + AND r.agent_id = dk.agent_id + AND r.project_id IS NOT DISTINCT FROM dk.project_id + AND r.provider = dk.provider + AND r.model = dk.model + ) + RETURNING 1 + ) + SELECT (SELECT COUNT(*) FROM upserted) + (SELECT COUNT(*) FROM deleted_empty) + INTO v_rows; + + DELETE FROM task_usage_hourly_dirty WHERE enqueued_at < p_to; + + RETURN v_rows; +END; +$$; + +ALTER TABLE task_usage_hourly + DROP COLUMN IF EXISTS uncosted_cache_write_tokens, + DROP COLUMN IF EXISTS uncosted_cache_read_tokens, + DROP COLUMN IF EXISTS uncosted_output_tokens, + DROP COLUMN IF EXISTS uncosted_input_tokens, + DROP COLUMN IF EXISTS cost_usd_ticks; + +ALTER TABLE task_usage + DROP COLUMN IF EXISTS cost_usd_ticks; diff --git a/server/migrations/213_task_usage_authoritative_cost.up.sql b/server/migrations/213_task_usage_authoritative_cost.up.sql new file mode 100644 index 0000000000..2235c49bc2 --- /dev/null +++ b/server/migrations/213_task_usage_authoritative_cost.up.sql @@ -0,0 +1,223 @@ +-- Provider-reported cost for a usage record, and the rollup columns needed to +-- mix it with rows that have none. +-- +-- WHY: cost has always been derived client-side as tokens x a static rate. +-- That cannot express request-level pricing rules. xAI bills a Grok request at +-- 2x once its prompt reaches 200K tokens, and a `task_usage` row aggregates +-- every model call in a turn — so the stored token counts genuinely cannot say +-- which tier any individual request hit. Thresholding on the aggregate would +-- turn a bounded 50% under-estimate into an unbounded over-estimate. Grok +-- already returns its own price per turn (`_meta.usage.costUsdTicks`); store +-- it and the arithmetic stops being a guess. +-- +-- UNIT: ticks of 1e-10 USD, as xAI reports them. An integer keeps sub-cent +-- turn costs exact end-to-end instead of drifting through float64. BIGINT +-- holds ~9.2e8 USD, which is not a reachable spend for one usage record. +-- +-- NULL means "the provider reported no cost" — every pre-existing row, and +-- every provider that doesn't return one. Those rows keep being estimated from +-- the pricing table. No backfill: historical rows have no authoritative figure +-- to recover, and inventing one would be the same guess we're removing. +ALTER TABLE task_usage + ADD COLUMN IF NOT EXISTS cost_usd_ticks BIGINT; + +COMMENT ON COLUMN task_usage.cost_usd_ticks IS + 'Provider-reported cost in 1e-10 USD. NULL when the provider reports none; those rows are priced client-side from the static rate table.'; + +-- Rollup side. A single hourly bucket can mix rows that carry an authoritative +-- cost with rows that don't (two providers, or Grok before and after a CLI +-- upgrade). Summing only the authoritative side would silently under-report +-- the bucket, so the rollup carries both halves: +-- +-- cost_usd_ticks - sum over rows that HAVE a provider cost +-- uncosted_*_tokens - tokens from rows that do NOT, i.e. exactly the +-- tokens still needing a rate-table estimate +-- +-- The consumer then reports `authoritative + estimate(uncosted tokens)`, which +-- degrades to today's behaviour when nothing in the bucket is authoritative. +-- The existing token columns keep their meaning (every row in the bucket) so +-- token displays are untouched. +-- +-- The `uncosted_*` columns are NULLABLE WITH NO DEFAULT, and that is load- +-- bearing: NULL means "this bucket has never been recomputed since the split +-- existed", which is every pre-existing row. Readers COALESCE it to the +-- bucket's own token total, i.e. "estimate all of it" — exactly what those +-- rows did before. A `NOT NULL DEFAULT 0` would instead assert "nothing here +-- needs estimating" and collapse every historical bucket's cost to $0 until +-- the rollup happened to touch it again, which is why this migration does NOT +-- rewrite history to seed them: with NULL there is nothing to seed. A bare +-- ADD COLUMN is metadata-only, so this stays a fast DDL with no table rewrite, +-- no WAL churn, and no lock held proportional to table size. +-- +-- The rollup writes concrete values for every bucket it recomputes, so rows +-- heal into the split naturally as they are touched. +-- +-- `cost_usd_ticks` is NOT NULL DEFAULT 0 because 0 is the honest identity for +-- a sum of "no rows here were priced by their provider", which is true of +-- every historical bucket. +-- +-- All additive, so the unique key, the dirty-queue key shape, and every +-- trigger in migration 102 are unaffected. +ALTER TABLE task_usage_hourly + ADD COLUMN IF NOT EXISTS cost_usd_ticks BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS uncosted_input_tokens BIGINT, + ADD COLUMN IF NOT EXISTS uncosted_output_tokens BIGINT, + ADD COLUMN IF NOT EXISTS uncosted_cache_read_tokens BIGINT, + ADD COLUMN IF NOT EXISTS uncosted_cache_write_tokens BIGINT; + +COMMENT ON COLUMN task_usage_hourly.cost_usd_ticks IS + 'Sum of provider-reported cost (1e-10 USD) over the rows in this bucket that had one; 0 when none did.'; +COMMENT ON COLUMN task_usage_hourly.uncosted_input_tokens IS + 'Input tokens from rows with no provider-reported cost — the portion still priced from the static rate table. NULL on buckets not yet recomputed since this column existed; readers COALESCE to input_tokens.'; + +-- Teach the rollup to populate them. Body is migration 102's, with the two +-- new expression groups in `recomputed` and the matching SET list on +-- conflict; everything else — dirty discovery, the empty-bucket delete, the +-- queue drain, the idempotency contract — is unchanged. +CREATE OR REPLACE FUNCTION rollup_task_usage_hourly_window( + p_from TIMESTAMPTZ, + p_to TIMESTAMPTZ +) +RETURNS BIGINT +LANGUAGE plpgsql +AS $$ +DECLARE + v_rows BIGINT; +BEGIN + IF p_from >= p_to THEN + RETURN 0; + END IF; + + WITH + dirty_from_updates AS ( + SELECT DISTINCT + task_usage_hour_bucket(tu.created_at) AS bucket_hour, + a.workspace_id AS workspace_id, + atq.runtime_id AS runtime_id, + atq.agent_id AS agent_id, + i.project_id AS project_id, + tu.provider AS provider, + tu.model AS model + FROM task_usage tu + JOIN agent_task_queue atq ON atq.id = tu.task_id + JOIN agent a ON a.id = atq.agent_id + LEFT JOIN issue i ON i.id = atq.issue_id + WHERE atq.runtime_id IS NOT NULL + AND ( + (tu.updated_at >= p_from AND tu.updated_at < p_to) + -- Legacy updated_at-NULL rows; partial index from 078. + OR (tu.updated_at IS NULL + AND tu.created_at >= p_from + AND tu.created_at < p_to) + ) + ), + dirty_from_queue AS ( + SELECT bucket_hour, workspace_id, runtime_id, agent_id, + project_id, provider, model + FROM task_usage_hourly_dirty + WHERE enqueued_at < p_to + ), + dirty_keys AS ( + SELECT * FROM dirty_from_updates + UNION + SELECT * FROM dirty_from_queue + ), + recomputed AS ( + SELECT + dk.bucket_hour, + dk.workspace_id, + dk.runtime_id, + dk.agent_id, + dk.project_id, + dk.provider, + dk.model, + SUM(tu.input_tokens)::bigint AS input_tokens, + SUM(tu.output_tokens)::bigint AS output_tokens, + SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens, + SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens, + -- Authoritative half: only rows the provider priced. + COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS cost_usd_ticks, + -- Estimated half: tokens from rows the provider did not price. + COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens, + COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens, + COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens, + COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens, + COUNT(DISTINCT tu.task_id)::bigint AS task_count, + COUNT(*)::bigint AS event_count + FROM dirty_keys dk + JOIN agent_task_queue atq ON atq.runtime_id = dk.runtime_id + AND atq.agent_id = dk.agent_id + JOIN agent a ON a.id = atq.agent_id + AND a.workspace_id = dk.workspace_id + LEFT JOIN issue i ON i.id = atq.issue_id + JOIN task_usage tu ON tu.task_id = atq.id + AND tu.provider = dk.provider + AND tu.model = dk.model + AND task_usage_hour_bucket(tu.created_at) = dk.bucket_hour + WHERE (i.project_id IS NOT DISTINCT FROM dk.project_id) + GROUP BY 1, 2, 3, 4, 5, 6, 7 + ), + upserted AS ( + INSERT INTO task_usage_hourly AS d ( + bucket_hour, workspace_id, runtime_id, agent_id, + project_id, provider, model, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + cost_usd_ticks, + uncosted_input_tokens, uncosted_output_tokens, + uncosted_cache_read_tokens, uncosted_cache_write_tokens, + task_count, event_count + ) + SELECT + bucket_hour, workspace_id, runtime_id, agent_id, + project_id, provider, model, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + cost_usd_ticks, + uncosted_input_tokens, uncosted_output_tokens, + uncosted_cache_read_tokens, uncosted_cache_write_tokens, + task_count, event_count + FROM recomputed + ON CONFLICT ON CONSTRAINT uq_task_usage_hourly_key DO UPDATE + SET input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + cache_write_tokens = EXCLUDED.cache_write_tokens, + cost_usd_ticks = EXCLUDED.cost_usd_ticks, + uncosted_input_tokens = EXCLUDED.uncosted_input_tokens, + uncosted_output_tokens = EXCLUDED.uncosted_output_tokens, + uncosted_cache_read_tokens = EXCLUDED.uncosted_cache_read_tokens, + uncosted_cache_write_tokens = EXCLUDED.uncosted_cache_write_tokens, + task_count = EXCLUDED.task_count, + event_count = EXCLUDED.event_count, + updated_at = now() + RETURNING 1 + ), + deleted_empty AS ( + DELETE FROM task_usage_hourly d + USING dirty_keys dk + WHERE d.bucket_hour = dk.bucket_hour + AND d.workspace_id = dk.workspace_id + AND d.runtime_id = dk.runtime_id + AND d.agent_id = dk.agent_id + AND d.project_id IS NOT DISTINCT FROM dk.project_id + AND d.provider = dk.provider + AND d.model = dk.model + AND NOT EXISTS ( + SELECT 1 FROM recomputed r + WHERE r.bucket_hour = dk.bucket_hour + AND r.workspace_id = dk.workspace_id + AND r.runtime_id = dk.runtime_id + AND r.agent_id = dk.agent_id + AND r.project_id IS NOT DISTINCT FROM dk.project_id + AND r.provider = dk.provider + AND r.model = dk.model + ) + RETURNING 1 + ) + SELECT (SELECT COUNT(*) FROM upserted) + (SELECT COUNT(*) FROM deleted_empty) + INTO v_rows; + + DELETE FROM task_usage_hourly_dirty WHERE enqueued_at < p_to; + + RETURN v_rows; +END; +$$; diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 178a15f90f..66ae73937d 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -140,8 +140,23 @@ type TokenUsage struct { OutputTokens int64 CacheReadTokens int64 CacheWriteTokens int64 + // CostUSDTicks is the provider's own statement of what this usage cost, + // in ticks of 1e-10 USD. Zero means "not reported" — only a few agents + // return it (xAI Grok Build does, via `_meta.usage.costUsdTicks`). + // + // It matters because a token-times-rate estimate cannot reproduce + // request-level pricing rules. xAI bills a request at 2x once its prompt + // reaches 200K tokens, and a usage record aggregates every model call in + // a turn — so the stored token counts cannot say which tier any single + // request hit. The provider's own figure already has that priced in. + CostUSDTicks int64 } +// CostUSDTicksPerUSD is the scale of the provider-reported cost unit: xAI +// reports whole ticks of 1e-10 USD, which keeps sub-cent turn costs exact in +// int64 all the way to the database instead of drifting through float64. +const CostUSDTicksPerUSD = 10_000_000_000 + // Result is the final outcome after an agent session completes. type Result struct { Status string // "completed", "failed", "aborted", "timeout", "cancelled" diff --git a/server/pkg/agent/grok.go b/server/pkg/agent/grok.go index 43fe06b6e1..39f7bff829 100644 --- a/server/pkg/agent/grok.go +++ b/server/pkg/agent/grok.go @@ -443,10 +443,23 @@ func (b *grokBackend) Execute(ctx context.Context, prompt string, opts ExecOptio finalStatus = "aborted" finalError = "grok cancelled the prompt" } + // `session/load` carries no model id (only `session/new` + // does), so a resumed session with no configured model would + // otherwise bucket its whole spend under "unknown" — which + // prices at $0 because no pricing row matches. The turn's + // own `_meta.modelId` is authoritative; use it. + if effectiveModel == "" { + effectiveModel = pr.modelID + } c.usageMu.Lock() c.usage.InputTokens += pr.usage.InputTokens c.usage.OutputTokens += pr.usage.OutputTokens c.usage.CacheReadTokens += pr.usage.CacheReadTokens + // xAI prices the turn itself and reports the result here. + // Carrying it through is the only way the ≥200K long-context + // surcharge reaches the bill — token counts alone cannot + // reconstruct which tier a request hit. + c.usage.CostUSDTicks += pr.usage.CostUSDTicks c.usageMu.Unlock() default: } diff --git a/server/pkg/agent/grok_test.go b/server/pkg/agent/grok_test.go index f650cd4f07..2acbe68834 100644 --- a/server/pkg/agent/grok_test.go +++ b/server/pkg/agent/grok_test.go @@ -109,7 +109,7 @@ while IFS= read -r line; do if [ -n "$GROK_USAGE" ]; then # Match live Grok Build ACP (0.2.x): metering lives under result._meta, # not a top-level usage field or sessionUpdate=usage_update. - printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","_meta":{"sessionId":"ses_new","modelId":"grok-4.5","inputTokens":120,"outputTokens":30,"cachedReadTokens":20,"usage":{"inputTokens":120,"outputTokens":30,"totalTokens":150,"cachedReadTokens":20,"modelCalls":1}}}}\n' "$id" + printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","_meta":{"sessionId":"ses_new","modelId":"grok-4.5","inputTokens":120,"outputTokens":30,"cachedReadTokens":20,"usage":{"inputTokens":120,"outputTokens":30,"totalTokens":150,"cachedReadTokens":20,"modelCalls":1,"costUsdTicks":98765}}}}\n' "$id" else printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" fi @@ -616,6 +616,60 @@ func TestGrokPropagatesMCPAndUsage(t *testing.T) { if usage.InputTokens != 100 || usage.OutputTokens != 30 || usage.CacheReadTokens != 20 { t.Fatalf("unexpected usage: %+v", usage) } + // xAI's own price for the turn has to survive the whole backend, not just + // the parser: it is the only figure carrying the ≥200K prompt surcharge, + // and everything downstream falls back to a rate-table guess without it. + if usage.CostUSDTicks != 98765 { + t.Fatalf("cost ticks = %d, want 98765", usage.CostUSDTicks) + } +} + +// TestGrokAttributesUsageOnResumeWithoutConfiguredModel pins the model +// attribution on the resume path. `session/load` reports no model id (only +// `session/new` does), so when neither the agent nor the runtime pins a model +// the turn's own `_meta.modelId` is the only source left. Without it the whole +// run buckets under "unknown", which matches no pricing row and reports $0 +// spend for the task. +func TestGrokAttributesUsageOnResumeWithoutConfiguredModel(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + fakePath := filepath.Join(tempDir, "grok") + writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) + backend, err := New("grok", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"GROK_USAGE": "1"}, + }) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + // No Model: the daemon leaves it empty whenever neither the agent nor + // MULTICA_GROK_MODEL pins one (see daemon.go resolveModel). + session, err := backend.Execute(context.Background(), "continue", ExecOptions{ + ResumeSessionID: "ses_existing", + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + result := <-session.Result + if result.Status != "completed" { + t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error) + } + if _, unknown := result.Usage["unknown"]; unknown { + t.Fatalf("resumed usage fell back to the unpriced \"unknown\" bucket: %+v", result.Usage) + } + usage, ok := result.Usage["grok-4.5"] + if !ok { + t.Fatalf("usage missing grok-4.5 key: %+v", result.Usage) + } + if usage.InputTokens != 100 || usage.OutputTokens != 30 || usage.CacheReadTokens != 20 { + t.Fatalf("unexpected usage: %+v", usage) + } } func TestGrokTimeoutAndCancellation(t *testing.T) { diff --git a/server/pkg/agent/hermes.go b/server/pkg/agent/hermes.go index c3ce4a8551..0367dc2d9b 100644 --- a/server/pkg/agent/hermes.go +++ b/server/pkg/agent/hermes.go @@ -682,6 +682,11 @@ func waitForHermesPipeDrain(readerDone, stderrDone <-chan struct{}, timeout time type hermesPromptResult struct { stopReason string usage TokenUsage + // modelID is the model the agent actually billed this turn against, as + // reported on `result._meta.modelId`. Empty for agents that don't report + // it. Backends use it to attribute usage when the session handshake + // didn't surface a model id (see grok.go). + modelID string } type hermesClient struct { @@ -1115,6 +1120,7 @@ func (c *hermesClient) extractPromptResult(data json.RawMessage) { pr := hermesPromptResult{ stopReason: resp.StopReason, + modelID: parseACPModelIDFromMeta(resp.Meta), } if len(resp.Usage) > 0 && string(resp.Usage) != "null" { pr.usage = parseACPTokenUsage(resp.Usage) @@ -1164,6 +1170,28 @@ func parseACPTokenUsageFromMeta(meta json.RawMessage) TokenUsage { return parseACPTokenUsage(meta) } +// parseACPModelIDFromMeta pulls the model id off an ACP result `_meta` +// object. Grok Build stamps every turn with `_meta.modelId`, which is the +// only authoritative statement of what the turn was billed against — the +// session handshake reports a model id on `session/new` but NOT on +// `session/load`, so a resumed session has no other source. +func parseACPModelIDFromMeta(meta json.RawMessage) string { + if len(meta) == 0 || string(meta) == "null" { + return "" + } + var r struct { + ModelID string `json:"modelId"` + ModelIDSnake string `json:"model_id"` + } + if err := json.Unmarshal(meta, &r); err != nil { + return "" + } + if id := strings.TrimSpace(r.ModelID); id != "" { + return id + } + return strings.TrimSpace(r.ModelIDSnake) +} + func (c *hermesClient) handleNotification(raw map[string]json.RawMessage) { var method string _ = json.Unmarshal(raw["method"], &method) @@ -1638,6 +1666,9 @@ func (c *hermesClient) handleUsageUpdate(data json.RawMessage) { if usage.CacheWriteTokens > c.usage.CacheWriteTokens { c.usage.CacheWriteTokens = usage.CacheWriteTokens } + if usage.CostUSDTicks > c.usage.CostUSDTicks { + c.usage.CostUSDTicks = usage.CostUSDTicks + } c.usageMu.Unlock() } @@ -1665,6 +1696,10 @@ func parseACPTokenUsage(data json.RawMessage) TokenUsage { "cache_write_tokens", "cache_creation_input_tokens", ), + // The provider's own price for this turn, already inclusive of + // request-level pricing rules we cannot reconstruct from token + // counts (see TokenUsage.CostUSDTicks). + CostUSDTicks: acpUsageInt64(fields, "costUsdTicks", "cost_usd_ticks"), } return excludeACPCachedInput(usage, acpUsageInt64(fields, "totalTokens", "total_tokens")) } diff --git a/server/pkg/agent/hermes_test.go b/server/pkg/agent/hermes_test.go index fee47b925b..c92b380dfe 100644 --- a/server/pkg/agent/hermes_test.go +++ b/server/pkg/agent/hermes_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "math" "os" "path/filepath" "runtime" @@ -1578,6 +1579,77 @@ func TestHermesClientExtractPromptResultMetaUsage(t *testing.T) { if got.usage.CacheReadTokens != 10880 { t.Errorf("cacheReadTokens: got %d, want 10880", got.usage.CacheReadTokens) } + // The turn stamps the model it billed against. A resumed session has no + // other source for this (session/load reports no model id), so losing it + // buckets the whole run under "unknown", which prices at $0. + if got.modelID != "grok-4.5" { + t.Errorf("modelID: got %q, want %q", got.modelID, "grok-4.5") + } + // xAI's own price for the turn, in ticks of 1e-10 USD. This is the only + // figure that carries request-level pricing rules (the ≥200K prompt + // surcharge); dropping it forces a rate-table guess downstream. + if got.usage.CostUSDTicks != 75360000 { + t.Errorf("costUsdTicks: got %d, want 75360000", got.usage.CostUSDTicks) + } + if usd := float64(got.usage.CostUSDTicks) / CostUSDTicksPerUSD; math.Abs(usd-0.007536) > 1e-12 { + t.Errorf("cost in USD: got %v, want 0.007536", usd) + } +} + +// TestParseACPTokenUsageCostIsOptional guards the common case: almost no agent +// reports a cost, and a zero must stay zero so the reader knows to estimate +// rather than recording a real $0 spend. +func TestParseACPTokenUsageCostIsOptional(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + raw string + want int64 + }{ + {name: "camelCase", raw: `{"inputTokens":10,"costUsdTicks":4200}`, want: 4200}, + {name: "snake_case", raw: `{"input_tokens":10,"cost_usd_ticks":4200}`, want: 4200}, + {name: "absent", raw: `{"inputTokens":10,"outputTokens":2}`, want: 0}, + {name: "null", raw: `{"inputTokens":10,"costUsdTicks":null}`, want: 0}, + {name: "string number", raw: `{"inputTokens":10,"costUsdTicks":"4200"}`, want: 4200}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := parseACPTokenUsage(json.RawMessage(tc.raw)).CostUSDTicks; got != tc.want { + t.Errorf("CostUSDTicks: got %d, want %d", got, tc.want) + } + }) + } +} + +// TestParseACPModelIDFromMeta covers the shapes the `_meta` model id can +// arrive in, and confirms a missing one degrades to empty rather than +// inventing an attribution. +func TestParseACPModelIDFromMeta(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + raw string + want string + }{ + {name: "camelCase", raw: `{"modelId":"grok-4.5"}`, want: "grok-4.5"}, + {name: "snake_case", raw: `{"model_id":"grok-4.3"}`, want: "grok-4.3"}, + {name: "camelCase wins over snake_case", raw: `{"modelId":"grok-4.5","model_id":"grok-4.3"}`, want: "grok-4.5"}, + {name: "whitespace trimmed", raw: `{"modelId":" grok-4.5 "}`, want: "grok-4.5"}, + {name: "absent", raw: `{"sessionId":"ses_1"}`, want: ""}, + {name: "empty string", raw: `{"modelId":""}`, want: ""}, + {name: "null meta", raw: `null`, want: ""}, + {name: "malformed", raw: `{"modelId":`, want: ""}, + {name: "wrong type degrades to empty", raw: `{"modelId":42}`, want: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := parseACPModelIDFromMeta(json.RawMessage(tc.raw)); got != tc.want { + t.Errorf("parseACPModelIDFromMeta(%s): got %q, want %q", tc.raw, got, tc.want) + } + }) + } } // TestHermesClientExtractPromptResultMetaFlatOnly covers agents that put diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 996a2babab..4223f57eb6 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -938,6 +938,8 @@ type TaskUsage struct { CacheWriteTokens int64 `json:"cache_write_tokens"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + // Provider-reported cost in 1e-10 USD. NULL when the provider reports none; those rows are priced client-side from the static rate table. + CostUsdTicks pgtype.Int8 `json:"cost_usd_ticks"` } type TaskUsageHourly struct { @@ -955,6 +957,13 @@ type TaskUsageHourly struct { TaskCount int64 `json:"task_count"` EventCount int64 `json:"event_count"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + // Sum of provider-reported cost (1e-10 USD) over the rows in this bucket that had one; 0 when none did. + CostUsdTicks int64 `json:"cost_usd_ticks"` + // Input tokens from rows with no provider-reported cost — the portion still priced from the static rate table. NULL on buckets not yet recomputed since this column existed; readers COALESCE to input_tokens. + UncostedInputTokens pgtype.Int8 `json:"uncosted_input_tokens"` + UncostedOutputTokens pgtype.Int8 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens pgtype.Int8 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens pgtype.Int8 `json:"uncosted_cache_write_tokens"` } type TaskUsageHourlyDirty struct { diff --git a/server/pkg/db/generated/runtime_usage.sql.go b/server/pkg/db/generated/runtime_usage.sql.go index 43bd185c58..71b3f5b29e 100644 --- a/server/pkg/db/generated/runtime_usage.sql.go +++ b/server/pkg/db/generated/runtime_usage.sql.go @@ -61,6 +61,11 @@ SELECT SUM(tu.output_tokens)::bigint AS output_tokens, SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens, SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens, + COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS cost_usd_ticks, + COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens, + COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens, + COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens, + COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens, COUNT(DISTINCT tu.task_id)::int AS task_count FROM task_usage tu JOIN agent_task_queue atq ON atq.id = tu.task_id @@ -77,13 +82,18 @@ type GetRuntimeUsageByHourParams struct { } type GetRuntimeUsageByHourRow struct { - Hour int32 `json:"hour"` - Model string `json:"model"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWriteTokens int64 `json:"cache_write_tokens"` - TaskCount int32 `json:"task_count"` + Hour int32 `json:"hour"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` + CostUsdTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } // Per-(hour, model) token aggregates (hour ∈ 0..23) for a runtime since a @@ -110,6 +120,11 @@ func (q *Queries) GetRuntimeUsageByHour(ctx context.Context, arg GetRuntimeUsage &i.OutputTokens, &i.CacheReadTokens, &i.CacheWriteTokens, + &i.CostUsdTicks, + &i.UncostedInputTokens, + &i.UncostedOutputTokens, + &i.UncostedCacheReadTokens, + &i.UncostedCacheWriteTokens, &i.TaskCount, ); err != nil { return nil, err @@ -130,7 +145,12 @@ SELECT SUM(input_tokens)::bigint AS input_tokens, SUM(output_tokens)::bigint AS output_tokens, SUM(cache_read_tokens)::bigint AS cache_read_tokens, - SUM(cache_write_tokens)::bigint AS cache_write_tokens + SUM(cache_write_tokens)::bigint AS cache_write_tokens, + SUM(cost_usd_ticks)::bigint AS cost_usd_ticks, + SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens, + SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens, + SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens, + SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens FROM task_usage_hourly WHERE runtime_id = $1 AND bucket_hour >= $3::timestamptz @@ -145,13 +165,18 @@ type ListRuntimeUsageParams struct { } type ListRuntimeUsageRow struct { - Date pgtype.Date `json:"date"` - Provider string `json:"provider"` - Model string `json:"model"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWriteTokens int64 `json:"cache_write_tokens"` + Date pgtype.Date `json:"date"` + Provider string `json:"provider"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` + CostUsdTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` } // Reads from the UTC-bucketed `task_usage_hourly` rollup table, @@ -182,6 +207,11 @@ func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsagePara &i.OutputTokens, &i.CacheReadTokens, &i.CacheWriteTokens, + &i.CostUsdTicks, + &i.UncostedInputTokens, + &i.UncostedOutputTokens, + &i.UncostedCacheReadTokens, + &i.UncostedCacheWriteTokens, ); err != nil { return nil, err } @@ -202,6 +232,11 @@ SELECT SUM(tu.output_tokens)::bigint AS output_tokens, SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens, SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens, + COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS cost_usd_ticks, + COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens, + COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens, + COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens, + COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens, COUNT(DISTINCT tu.task_id)::int AS task_count FROM task_usage tu JOIN agent_task_queue atq ON atq.id = tu.task_id @@ -217,14 +252,19 @@ type ListRuntimeUsageByAgentParams struct { } type ListRuntimeUsageByAgentRow struct { - AgentID pgtype.UUID `json:"agent_id"` - Provider string `json:"provider"` - Model string `json:"model"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWriteTokens int64 `json:"cache_write_tokens"` - TaskCount int32 `json:"task_count"` + AgentID pgtype.UUID `json:"agent_id"` + Provider string `json:"provider"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` + CostUsdTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } // Per-(agent, provider, model) token aggregates for a runtime since a cutoff. Powers @@ -255,6 +295,11 @@ func (q *Queries) ListRuntimeUsageByAgent(ctx context.Context, arg ListRuntimeUs &i.OutputTokens, &i.CacheReadTokens, &i.CacheWriteTokens, + &i.CostUsdTicks, + &i.UncostedInputTokens, + &i.UncostedOutputTokens, + &i.UncostedCacheReadTokens, + &i.UncostedCacheWriteTokens, &i.TaskCount, ); err != nil { return nil, err diff --git a/server/pkg/db/generated/task_usage.sql.go b/server/pkg/db/generated/task_usage.sql.go index 7072831914..fe08dce770 100644 --- a/server/pkg/db/generated/task_usage.sql.go +++ b/server/pkg/db/generated/task_usage.sql.go @@ -17,6 +17,11 @@ SELECT COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens, COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens, COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens, + COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS total_cost_usd_ticks, + COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens, + COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens, + COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens, + COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens, COUNT(DISTINCT tu.task_id)::int AS task_count FROM task_usage tu JOIN agent_task_queue atq ON atq.id = tu.task_id @@ -24,11 +29,16 @@ WHERE atq.issue_id = $1 ` type GetIssueUsageSummaryRow struct { - TotalInputTokens int64 `json:"total_input_tokens"` - TotalOutputTokens int64 `json:"total_output_tokens"` - TotalCacheReadTokens int64 `json:"total_cache_read_tokens"` - TotalCacheWriteTokens int64 `json:"total_cache_write_tokens"` - TaskCount int32 `json:"task_count"` + TotalInputTokens int64 `json:"total_input_tokens"` + TotalOutputTokens int64 `json:"total_output_tokens"` + TotalCacheReadTokens int64 `json:"total_cache_read_tokens"` + TotalCacheWriteTokens int64 `json:"total_cache_write_tokens"` + TotalCostUsdTicks int64 `json:"total_cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } func (q *Queries) GetIssueUsageSummary(ctx context.Context, issueID pgtype.UUID) (GetIssueUsageSummaryRow, error) { @@ -39,13 +49,18 @@ func (q *Queries) GetIssueUsageSummary(ctx context.Context, issueID pgtype.UUID) &i.TotalOutputTokens, &i.TotalCacheReadTokens, &i.TotalCacheWriteTokens, + &i.TotalCostUsdTicks, + &i.UncostedInputTokens, + &i.UncostedOutputTokens, + &i.UncostedCacheReadTokens, + &i.UncostedCacheWriteTokens, &i.TaskCount, ) return i, err } const getTaskUsage = `-- name: GetTaskUsage :many -SELECT id, task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at FROM task_usage +SELECT id, task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at, cost_usd_ticks FROM task_usage WHERE task_id = $1 ORDER BY model ` @@ -70,6 +85,7 @@ func (q *Queries) GetTaskUsage(ctx context.Context, taskID pgtype.UUID) ([]TaskU &i.CacheWriteTokens, &i.CreatedAt, &i.UpdatedAt, + &i.CostUsdTicks, ); err != nil { return nil, err } @@ -237,6 +253,11 @@ SELECT SUM(output_tokens)::bigint AS output_tokens, SUM(cache_read_tokens)::bigint AS cache_read_tokens, SUM(cache_write_tokens)::bigint AS cache_write_tokens, + SUM(cost_usd_ticks)::bigint AS cost_usd_ticks, + SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens, + SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens, + SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens, + SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens, SUM(task_count)::int AS task_count FROM task_usage_hourly WHERE workspace_id = $1 @@ -253,14 +274,19 @@ type ListDashboardUsageByAgentParams struct { } type ListDashboardUsageByAgentRow struct { - AgentID pgtype.UUID `json:"agent_id"` - Provider string `json:"provider"` - Model string `json:"model"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWriteTokens int64 `json:"cache_write_tokens"` - TaskCount int32 `json:"task_count"` + AgentID pgtype.UUID `json:"agent_id"` + Provider string `json:"provider"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` + CostUsdTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } // Per-(agent, provider, model) token aggregates from `task_usage_hourly`. No @@ -294,6 +320,11 @@ func (q *Queries) ListDashboardUsageByAgent(ctx context.Context, arg ListDashboa &i.OutputTokens, &i.CacheReadTokens, &i.CacheWriteTokens, + &i.CostUsdTicks, + &i.UncostedInputTokens, + &i.UncostedOutputTokens, + &i.UncostedCacheReadTokens, + &i.UncostedCacheWriteTokens, &i.TaskCount, ); err != nil { return nil, err @@ -315,6 +346,11 @@ SELECT SUM(output_tokens)::bigint AS output_tokens, SUM(cache_read_tokens)::bigint AS cache_read_tokens, SUM(cache_write_tokens)::bigint AS cache_write_tokens, + SUM(cost_usd_ticks)::bigint AS cost_usd_ticks, + SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens, + SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens, + SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens, + SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens, SUM(task_count)::int AS task_count FROM task_usage_hourly WHERE workspace_id = $1 @@ -332,14 +368,19 @@ type ListDashboardUsageDailyParams struct { } type ListDashboardUsageDailyRow struct { - Date pgtype.Date `json:"date"` - Provider string `json:"provider"` - Model string `json:"model"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWriteTokens int64 `json:"cache_write_tokens"` - TaskCount int32 `json:"task_count"` + Date pgtype.Date `json:"date"` + Provider string `json:"provider"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` + CostUsdTicks int64 `json:"cost_usd_ticks"` + UncostedInputTokens int64 `json:"uncosted_input_tokens"` + UncostedOutputTokens int64 `json:"uncosted_output_tokens"` + UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"` + UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"` + TaskCount int32 `json:"task_count"` } // Daily per-(date, provider, model) token aggregates for the workspace, served @@ -381,6 +422,11 @@ func (q *Queries) ListDashboardUsageDaily(ctx context.Context, arg ListDashboard &i.OutputTokens, &i.CacheReadTokens, &i.CacheWriteTokens, + &i.CostUsdTicks, + &i.UncostedInputTokens, + &i.UncostedOutputTokens, + &i.UncostedCacheReadTokens, + &i.UncostedCacheWriteTokens, &i.TaskCount, ); err != nil { return nil, err @@ -394,14 +440,15 @@ func (q *Queries) ListDashboardUsageDaily(ctx context.Context, arg ListDashboard } const upsertTaskUsage = `-- name: UpsertTaskUsage :exec -INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, now()) +INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd_ticks, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) ON CONFLICT (task_id, provider, model) DO UPDATE SET input_tokens = EXCLUDED.input_tokens, output_tokens = EXCLUDED.output_tokens, cache_read_tokens = EXCLUDED.cache_read_tokens, cache_write_tokens = EXCLUDED.cache_write_tokens, + cost_usd_ticks = EXCLUDED.cost_usd_ticks, updated_at = now() ` @@ -413,12 +460,16 @@ type UpsertTaskUsageParams struct { OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` CacheWriteTokens int64 `json:"cache_write_tokens"` + CostUsdTicks pgtype.Int8 `json:"cost_usd_ticks"` } // Bumps `updated_at` on INSERT and on conflict so the hourly-rollup worker // detects the row as dirty and re-aggregates its bucket. // Without the conflict-side bump, a correction to historical token counts // would never propagate to the rollup. +// cost_usd_ticks is the provider's own price for this usage (1e-10 USD), NULL +// when it reports none. It is overwritten like the token counters so a +// corrected report replaces the previous figure rather than accumulating. func (q *Queries) UpsertTaskUsage(ctx context.Context, arg UpsertTaskUsageParams) error { _, err := q.db.Exec(ctx, upsertTaskUsage, arg.TaskID, @@ -428,6 +479,7 @@ func (q *Queries) UpsertTaskUsage(ctx context.Context, arg UpsertTaskUsageParams arg.OutputTokens, arg.CacheReadTokens, arg.CacheWriteTokens, + arg.CostUsdTicks, ) return err } diff --git a/server/pkg/db/queries/runtime_usage.sql b/server/pkg/db/queries/runtime_usage.sql index ea5550eb3d..315f0a8be5 100644 --- a/server/pkg/db/queries/runtime_usage.sql +++ b/server/pkg/db/queries/runtime_usage.sql @@ -17,7 +17,12 @@ SELECT SUM(input_tokens)::bigint AS input_tokens, SUM(output_tokens)::bigint AS output_tokens, SUM(cache_read_tokens)::bigint AS cache_read_tokens, - SUM(cache_write_tokens)::bigint AS cache_write_tokens + SUM(cache_write_tokens)::bigint AS cache_write_tokens, + SUM(cost_usd_ticks)::bigint AS cost_usd_ticks, + SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens, + SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens, + SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens, + SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens FROM task_usage_hourly WHERE runtime_id = $1 AND bucket_hour >= sqlc.arg('since')::timestamptz @@ -55,6 +60,11 @@ SELECT SUM(tu.output_tokens)::bigint AS output_tokens, SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens, SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens, + COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS cost_usd_ticks, + COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens, + COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens, + COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens, + COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens, COUNT(DISTINCT tu.task_id)::int AS task_count FROM task_usage tu JOIN agent_task_queue atq ON atq.id = tu.task_id @@ -79,6 +89,11 @@ SELECT SUM(tu.output_tokens)::bigint AS output_tokens, SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens, SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens, + COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS cost_usd_ticks, + COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens, + COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens, + COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens, + COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens, COUNT(DISTINCT tu.task_id)::int AS task_count FROM task_usage tu JOIN agent_task_queue atq ON atq.id = tu.task_id diff --git a/server/pkg/db/queries/task_usage.sql b/server/pkg/db/queries/task_usage.sql index f697deff79..8e86a02627 100644 --- a/server/pkg/db/queries/task_usage.sql +++ b/server/pkg/db/queries/task_usage.sql @@ -3,14 +3,18 @@ -- detects the row as dirty and re-aggregates its bucket. -- Without the conflict-side bump, a correction to historical token counts -- would never propagate to the rollup. -INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, now()) +-- cost_usd_ticks is the provider's own price for this usage (1e-10 USD), NULL +-- when it reports none. It is overwritten like the token counters so a +-- corrected report replaces the previous figure rather than accumulating. +INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd_ticks, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, sqlc.narg('cost_usd_ticks'), now()) ON CONFLICT (task_id, provider, model) DO UPDATE SET input_tokens = EXCLUDED.input_tokens, output_tokens = EXCLUDED.output_tokens, cache_read_tokens = EXCLUDED.cache_read_tokens, cache_write_tokens = EXCLUDED.cache_write_tokens, + cost_usd_ticks = EXCLUDED.cost_usd_ticks, updated_at = now(); -- name: GetTaskUsage :many @@ -24,6 +28,11 @@ SELECT COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens, COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens, COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens, + COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS total_cost_usd_ticks, + COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens, + COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens, + COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens, + COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens, COUNT(DISTINCT tu.task_id)::int AS task_count FROM task_usage tu JOIN agent_task_queue atq ON atq.id = tu.task_id @@ -55,6 +64,11 @@ SELECT SUM(output_tokens)::bigint AS output_tokens, SUM(cache_read_tokens)::bigint AS cache_read_tokens, SUM(cache_write_tokens)::bigint AS cache_write_tokens, + SUM(cost_usd_ticks)::bigint AS cost_usd_ticks, + SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens, + SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens, + SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens, + SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens, SUM(task_count)::int AS task_count FROM task_usage_hourly WHERE workspace_id = $1 @@ -86,6 +100,11 @@ SELECT SUM(output_tokens)::bigint AS output_tokens, SUM(cache_read_tokens)::bigint AS cache_read_tokens, SUM(cache_write_tokens)::bigint AS cache_write_tokens, + SUM(cost_usd_ticks)::bigint AS cost_usd_ticks, + SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens, + SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens, + SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens, + SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens, SUM(task_count)::int AS task_count FROM task_usage_hourly WHERE workspace_id = $1