diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 7a27f8064..6010ee256 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -796,7 +796,29 @@ export function providerNameFromBaseUrl(baseUrl: string | undefined): "ollama" | return "openai-compatible"; } +// ALIASES of one value -- different providers' names for the same number, so `maxNumber` (below) is the right +// combinator. The uncached portion of the prompt only; see the two cache tiers directly below. const INPUT_TOKEN_KEYS = ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"] as const; +/** #10235: Anthropic (and therefore Claude Code) splits one prompt across THREE counters -- `input_tokens` + * carries only the portion that was neither read from nor written to the prompt cache. With caching active, + * which it is for every review the CLI runs, essentially the whole prompt lands in these two instead. + * + * Measured on edge-nl-01 before this fix: `claude-code` recorded a total of 2048 input tokens across 1000 + * calls -- an average of exactly 2.0, against 1051 output tokens per call and $225 of real spend. An average + * of 2.0 over a thousand calls is not a measurement, it is a constant, and every derived figure + * (cost-per-token, input:output ratio, `loopover_ai_input_tokens_total`) was wrong by ~3 orders of magnitude. + * + * These are genuine input tokens: the model processed them, and they are billed (cache reads at a reduced + * rate). Each tier is its OWN alias group because the three are ADDITIVE COMPONENTS of one prompt, not names + * for one value -- they must be summed with each other and max'd only within a group. Folding them into + * INPUT_TOKEN_KEYS would take the maximum of the three and silently under-report again, just less severely. + * + * Deliberately NOT applied to `coerceByokUsage` (src/services/ai-review.ts): that path documents its own + * reason for skipping these keys -- `callAiProvider` never sends `cache_control`, so the provider never + * populates them there -- and it feeds BYOK_MODEL_PRICING_USD_PER_MTOK, where a cache read bills at a + * different rate than fresh input. The divergence between the two paths is intentional. */ +const CACHE_READ_INPUT_TOKEN_KEYS = ["cache_read_input_tokens", "cacheReadInputTokens"] as const; +const CACHE_CREATION_INPUT_TOKEN_KEYS = ["cache_creation_input_tokens", "cacheCreationInputTokens"] as const; const OUTPUT_TOKEN_KEYS = ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"] as const; const TOTAL_TOKEN_KEYS = ["total_tokens", "totalTokens"] as const; const COST_KEYS = ["total_cost_usd", "totalCostUsd", "cost_usd", "costUsd"] as const; @@ -819,6 +841,24 @@ function maxNumber(record: Record, keys: readonly string[]): nu return out; } +/** + * The full prompt size for one usage envelope: the uncached portion plus both cache tiers (#10235). + * + * Returns undefined only when the envelope reports NO input counter at all, so a provider that never emits + * these keys is completely unaffected and an absent count is never turned into a fabricated 0 (#10207's rule). + * A tier that is present but zero contributes a real zero, which is why absence is tested per tier rather than + * falsiness. + */ +function totalInputTokens(entry: Record): number | undefined { + const tiers = [ + maxNumber(entry, INPUT_TOKEN_KEYS), + maxNumber(entry, CACHE_READ_INPUT_TOKEN_KEYS), + maxNumber(entry, CACHE_CREATION_INPUT_TOKEN_KEYS), + ]; + if (tiers.every((tier) => tier === undefined)) return undefined; + return tiers.reduce((sum, tier) => sum + (tier ?? 0), 0); +} + function mergeUsage(out: CliUsage, record: Record): void { const nested = [ record, @@ -829,7 +869,7 @@ function mergeUsage(out: CliUsage, record: Record): void { asRecord(record.usageMetadata), ].filter((entry): entry is Record => Boolean(entry)); for (const entry of nested) { - const inputTokens = maxNumber(entry, INPUT_TOKEN_KEYS); + const inputTokens = totalInputTokens(entry); if (inputTokens !== undefined) out.inputTokens = Math.max(out.inputTokens ?? 0, inputTokens); const outputTokens = maxNumber(entry, OUTPUT_TOKEN_KEYS); if (outputTokens !== undefined) out.outputTokens = Math.max(out.outputTokens ?? 0, outputTokens); diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index 7d22d6cf5..144fbb8a9 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -2009,7 +2009,11 @@ function priceByokUsageUsd( * the already-camelCase envelope `coerceAiUsage` reads from `env.AI.run()`. Anthropic's `usage` can also * carry `cache_creation_input_tokens`/`cache_read_input_tokens`, priced differently than `input_tokens` — * intentionally not read here, since `callAiProvider` never sends `cache_control`, so Anthropic never - * populates them on this path. Private to this file, but not private in effect: `ai-slop.ts`'s BYOK branch + * populates them on this path. #10235: the CLI-subprocess path (`mergeUsage`, src/selfhost/ai.ts) now DOES + * sum those two tiers, because Claude Code caches internally and was therefore reporting ~2 input tokens per + * call. That divergence is deliberate, not drift: reading them here would be dead code today, and if + * `cache_control` is ever introduced it would mis-price, since the sum below feeds + * BYOK_MODEL_PRICING_USD_PER_MTOK at the fresh-input rate while a cache read bills at a reduced one. Private to this file, but not private in effect: `ai-slop.ts`'s BYOK branch * depends on this normalization too, indirectly, via `callAiProvider`'s returned `usage` field — if this * ever moves, update both call sites. */ function coerceByokUsage( diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index d80647a76..7b43be2f9 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -1618,6 +1618,36 @@ describe("branch coverage — defaults + edge inputs", () => { ), ).toEqual({ inputTokens: 12, outputTokens: 6, totalTokens: 18, costUsd: 0.09, model: "gpt-5" }); }); + + it("REGRESSION (#10235): sums the prompt-cache tiers into inputTokens, instead of reporting the uncached remainder", () => { + // The real Claude Code result frame. `input_tokens` carries ONLY what was neither read from nor written to + // the prompt cache, so with caching active -- which it is on every review -- it degenerates to a handful of + // tokens. Measured on the Orb before this fix: 2048 input tokens across 1000 claude-code calls, an average + // of exactly 2.0, against 1051 output tokens per call and $225 of real spend. + expect( + extractCliUsage( + JSON.stringify({ + type: "result", + usage: { input_tokens: 2, cache_read_input_tokens: 41_820, cache_creation_input_tokens: 1_140, output_tokens: 1051 }, + model: "claude-sonnet-5", + }), + ), + ).toEqual({ inputTokens: 42_962, outputTokens: 1051, model: "claude-sonnet-5" }); + }); + + it("sums the cache tiers only when present, leaving every other provider untouched (#10235)", () => { + // codex and the OpenAI-compatible providers emit no cache keys at all: their figure must be byte-identical + // to before, which is what makes this safe to apply at the shared extraction point. + expect(extractCliUsage(JSON.stringify({ usage: { input_tokens: 20, output_tokens: 7 } }))).toEqual({ inputTokens: 20, outputTokens: 7 }); + // A cache tier alone, with no uncached remainder reported, still yields the real prompt size. + expect(extractCliUsage(JSON.stringify({ usage: { cache_read_input_tokens: 900 } }))).toEqual({ inputTokens: 900 }); + // camelCase aliases resolve the same way the other key groups already do. + expect(extractCliUsage(JSON.stringify({ usage: { inputTokens: 5, cacheReadInputTokens: 10, cacheCreationInputTokens: 20 } }))).toEqual({ inputTokens: 35 }); + // A genuinely reported 0 in one tier contributes a real 0 rather than dropping the whole reading. + expect(extractCliUsage(JSON.stringify({ usage: { input_tokens: 0, cache_read_input_tokens: 700, cache_creation_input_tokens: 0 } }))).toEqual({ inputTokens: 700 }); + // No input counter of any kind stays ABSENT -- never a fabricated 0 (#10207). + expect(extractCliUsage(JSON.stringify({ usage: { output_tokens: 4 } }))).toEqual({ outputTokens: 4 }); + }); it("claudeErrorStatus: subtype + unknown fallbacks", () => { expect(claudeErrorStatus(JSON.stringify({ is_error: true, subtype: "sub" }))).toBe("sub"); expect(claudeErrorStatus(JSON.stringify({ is_error: true }))).toBe("unknown");