Skip to content

Commit 0aa0f8e

Browse files
author
JSONbored
committed
fix(observability): count the prompt-cache tiers as input tokens for the CLI providers
Every claude-code call recorded exactly ~2 input tokens. Measured on edge-nl-01 over 48h: 2048 input tokens across 1000 calls -- an average of 2.0 -- against 1051 output tokens per call and $225.06 of real spend. An average of 2.0 over a thousand calls is not a measurement, it is a constant. Anthropic, and therefore Claude Code, splits one prompt across three counters: input_tokens carries only the portion neither read from nor written to the prompt cache, with the rest in cache_read_input_tokens and cache_creation_input_tokens. INPUT_TOKEN_KEYS read only the first, so with caching active -- which it is on every review the CLI runs -- the figure degenerated to the handful of genuinely-new tokens. These are real input tokens: the model processed them and they are billed, cache reads at a reduced rate. Each tier gets its OWN alias group because the three are additive components of one prompt, not names for one value; folding them into INPUT_TOKEN_KEYS would take the maximum of the three and silently under-report again, just less severely. Absence is still absence -- an envelope reporting no input counter at all yields undefined rather than a fabricated 0 (#10207), and a tier present but zero contributes a real zero. Providers that emit no cache keys -- codex and the OpenAI-compatible bindings -- are byte-identical to before, which is what makes this safe at the shared extraction point. loopover_ai_input_tokens_total is corrected by the same change, since it reads the same usage.inputTokens. coerceByokUsage (src/services/ai-review.ts) is deliberately NOT changed. It already documented these two keys and declined them, because 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. Its comment now records that the divergence from the CLI path is intentional rather than an oversight. Closes #10235
1 parent ee1b86e commit 0aa0f8e

3 files changed

Lines changed: 76 additions & 2 deletions

File tree

src/selfhost/ai.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,29 @@ export function providerNameFromBaseUrl(baseUrl: string | undefined): "ollama" |
796796
return "openai-compatible";
797797
}
798798

799+
// ALIASES of one value -- different providers' names for the same number, so `maxNumber` (below) is the right
800+
// combinator. The uncached portion of the prompt only; see the two cache tiers directly below.
799801
const INPUT_TOKEN_KEYS = ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"] as const;
802+
/** #10235: Anthropic (and therefore Claude Code) splits one prompt across THREE counters -- `input_tokens`
803+
* carries only the portion that was neither read from nor written to the prompt cache. With caching active,
804+
* which it is for every review the CLI runs, essentially the whole prompt lands in these two instead.
805+
*
806+
* Measured on edge-nl-01 before this fix: `claude-code` recorded a total of 2048 input tokens across 1000
807+
* calls -- an average of exactly 2.0, against 1051 output tokens per call and $225 of real spend. An average
808+
* of 2.0 over a thousand calls is not a measurement, it is a constant, and every derived figure
809+
* (cost-per-token, input:output ratio, `loopover_ai_input_tokens_total`) was wrong by ~3 orders of magnitude.
810+
*
811+
* These are genuine input tokens: the model processed them, and they are billed (cache reads at a reduced
812+
* rate). Each tier is its OWN alias group because the three are ADDITIVE COMPONENTS of one prompt, not names
813+
* for one value -- they must be summed with each other and max'd only within a group. Folding them into
814+
* INPUT_TOKEN_KEYS would take the maximum of the three and silently under-report again, just less severely.
815+
*
816+
* Deliberately NOT applied to `coerceByokUsage` (src/services/ai-review.ts): that path documents its own
817+
* reason for skipping these keys -- `callAiProvider` never sends `cache_control`, so the provider never
818+
* populates them there -- and it feeds BYOK_MODEL_PRICING_USD_PER_MTOK, where a cache read bills at a
819+
* different rate than fresh input. The divergence between the two paths is intentional. */
820+
const CACHE_READ_INPUT_TOKEN_KEYS = ["cache_read_input_tokens", "cacheReadInputTokens"] as const;
821+
const CACHE_CREATION_INPUT_TOKEN_KEYS = ["cache_creation_input_tokens", "cacheCreationInputTokens"] as const;
800822
const OUTPUT_TOKEN_KEYS = ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"] as const;
801823
const TOTAL_TOKEN_KEYS = ["total_tokens", "totalTokens"] as const;
802824
const COST_KEYS = ["total_cost_usd", "totalCostUsd", "cost_usd", "costUsd"] as const;
@@ -819,6 +841,24 @@ function maxNumber(record: Record<string, unknown>, keys: readonly string[]): nu
819841
return out;
820842
}
821843

844+
/**
845+
* The full prompt size for one usage envelope: the uncached portion plus both cache tiers (#10235).
846+
*
847+
* Returns undefined only when the envelope reports NO input counter at all, so a provider that never emits
848+
* these keys is completely unaffected and an absent count is never turned into a fabricated 0 (#10207's rule).
849+
* A tier that is present but zero contributes a real zero, which is why absence is tested per tier rather than
850+
* falsiness.
851+
*/
852+
function totalInputTokens(entry: Record<string, unknown>): number | undefined {
853+
const tiers = [
854+
maxNumber(entry, INPUT_TOKEN_KEYS),
855+
maxNumber(entry, CACHE_READ_INPUT_TOKEN_KEYS),
856+
maxNumber(entry, CACHE_CREATION_INPUT_TOKEN_KEYS),
857+
];
858+
if (tiers.every((tier) => tier === undefined)) return undefined;
859+
return tiers.reduce<number>((sum, tier) => sum + (tier ?? 0), 0);
860+
}
861+
822862
function mergeUsage(out: CliUsage, record: Record<string, unknown>): void {
823863
const nested = [
824864
record,
@@ -829,7 +869,7 @@ function mergeUsage(out: CliUsage, record: Record<string, unknown>): void {
829869
asRecord(record.usageMetadata),
830870
].filter((entry): entry is Record<string, unknown> => Boolean(entry));
831871
for (const entry of nested) {
832-
const inputTokens = maxNumber(entry, INPUT_TOKEN_KEYS);
872+
const inputTokens = totalInputTokens(entry);
833873
if (inputTokens !== undefined) out.inputTokens = Math.max(out.inputTokens ?? 0, inputTokens);
834874
const outputTokens = maxNumber(entry, OUTPUT_TOKEN_KEYS);
835875
if (outputTokens !== undefined) out.outputTokens = Math.max(out.outputTokens ?? 0, outputTokens);

src/services/ai-review.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2009,7 +2009,11 @@ function priceByokUsageUsd(
20092009
* the already-camelCase envelope `coerceAiUsage` reads from `env.AI.run()`. Anthropic's `usage` can also
20102010
* carry `cache_creation_input_tokens`/`cache_read_input_tokens`, priced differently than `input_tokens` —
20112011
* intentionally not read here, since `callAiProvider` never sends `cache_control`, so Anthropic never
2012-
* populates them on this path. Private to this file, but not private in effect: `ai-slop.ts`'s BYOK branch
2012+
* populates them on this path. #10235: the CLI-subprocess path (`mergeUsage`, src/selfhost/ai.ts) now DOES
2013+
* sum those two tiers, because Claude Code caches internally and was therefore reporting ~2 input tokens per
2014+
* call. That divergence is deliberate, not drift: reading them here would be dead code today, and if
2015+
* `cache_control` is ever introduced it would mis-price, since the sum below feeds
2016+
* 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
20132017
* depends on this normalization too, indirectly, via `callAiProvider`'s returned `usage` field — if this
20142018
* ever moves, update both call sites. */
20152019
function coerceByokUsage(

test/unit/selfhost-ai.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,36 @@ describe("branch coverage — defaults + edge inputs", () => {
16181618
),
16191619
).toEqual({ inputTokens: 12, outputTokens: 6, totalTokens: 18, costUsd: 0.09, model: "gpt-5" });
16201620
});
1621+
1622+
it("REGRESSION (#10235): sums the prompt-cache tiers into inputTokens, instead of reporting the uncached remainder", () => {
1623+
// The real Claude Code result frame. `input_tokens` carries ONLY what was neither read from nor written to
1624+
// the prompt cache, so with caching active -- which it is on every review -- it degenerates to a handful of
1625+
// tokens. Measured on the Orb before this fix: 2048 input tokens across 1000 claude-code calls, an average
1626+
// of exactly 2.0, against 1051 output tokens per call and $225 of real spend.
1627+
expect(
1628+
extractCliUsage(
1629+
JSON.stringify({
1630+
type: "result",
1631+
usage: { input_tokens: 2, cache_read_input_tokens: 41_820, cache_creation_input_tokens: 1_140, output_tokens: 1051 },
1632+
model: "claude-sonnet-5",
1633+
}),
1634+
),
1635+
).toEqual({ inputTokens: 42_962, outputTokens: 1051, model: "claude-sonnet-5" });
1636+
});
1637+
1638+
it("sums the cache tiers only when present, leaving every other provider untouched (#10235)", () => {
1639+
// codex and the OpenAI-compatible providers emit no cache keys at all: their figure must be byte-identical
1640+
// to before, which is what makes this safe to apply at the shared extraction point.
1641+
expect(extractCliUsage(JSON.stringify({ usage: { input_tokens: 20, output_tokens: 7 } }))).toEqual({ inputTokens: 20, outputTokens: 7 });
1642+
// A cache tier alone, with no uncached remainder reported, still yields the real prompt size.
1643+
expect(extractCliUsage(JSON.stringify({ usage: { cache_read_input_tokens: 900 } }))).toEqual({ inputTokens: 900 });
1644+
// camelCase aliases resolve the same way the other key groups already do.
1645+
expect(extractCliUsage(JSON.stringify({ usage: { inputTokens: 5, cacheReadInputTokens: 10, cacheCreationInputTokens: 20 } }))).toEqual({ inputTokens: 35 });
1646+
// A genuinely reported 0 in one tier contributes a real 0 rather than dropping the whole reading.
1647+
expect(extractCliUsage(JSON.stringify({ usage: { input_tokens: 0, cache_read_input_tokens: 700, cache_creation_input_tokens: 0 } }))).toEqual({ inputTokens: 700 });
1648+
// No input counter of any kind stays ABSENT -- never a fabricated 0 (#10207).
1649+
expect(extractCliUsage(JSON.stringify({ usage: { output_tokens: 4 } }))).toEqual({ outputTokens: 4 });
1650+
});
16211651
it("claudeErrorStatus: subtype + unknown fallbacks", () => {
16221652
expect(claudeErrorStatus(JSON.stringify({ is_error: true, subtype: "sub" }))).toBe("sub");
16231653
expect(claudeErrorStatus(JSON.stringify({ is_error: true }))).toBe("unknown");

0 commit comments

Comments
 (0)