Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -819,6 +841,24 @@ function maxNumber(record: Record<string, unknown>, 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<string, unknown>): 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<number>((sum, tier) => sum + (tier ?? 0), 0);
}

function mergeUsage(out: CliUsage, record: Record<string, unknown>): void {
const nested = [
record,
Expand All @@ -829,7 +869,7 @@ function mergeUsage(out: CliUsage, record: Record<string, unknown>): void {
asRecord(record.usageMetadata),
].filter((entry): entry is Record<string, unknown> => 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);
Expand Down
6 changes: 5 additions & 1 deletion src/services/ai-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 30 additions & 0 deletions test/unit/selfhost-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading