Skip to content

Commit ba9f06e

Browse files
committed
fix(observability): read Anthropic cache tokens instead of under-reporting input by orders of magnitude
`input_tokens` on an Anthropic-style usage envelope is the UNCACHED remainder, not the whole prompt. This engine's prompts cache well -- a stable system prompt plus repo context -- so on a cache hit the real prompt sits in cache_read_input_tokens while input_tokens holds a handful of leftovers. Nothing read those fields, on either the ORB or the miner. Live evidence minutes after deploying orb-v3.7.0-beta.9, which is the image that first carried #10212's fix for the scrubber that had been nulling tokens entirely: a review that produced 787 output tokens and cost $0.21 reported TWO input tokens, while the local ollama model on the same box reported 2,706. The gap was invisible until tokens started arriving at all. Parse cache_read_input_tokens and cache_creation_input_tokens and report them in their own right, rather than folding them into inputTokens. That is what the provider means by each field, and cache reads price at a fraction of fresh input, so summing them in would trade one wrong number for another. PostHog models the distinction directly and prices the two separately. Also set $ai_cache_reporting_exclusive when cache fields are present. PostHog otherwise auto-detects the convention from $ai_provider, and this deployment reports "claude-code" / "claude-cli" / "agent-sdk" rather than "anthropic", so the auto-detection would read the envelope as inclusive and price the cached prompt as fresh input. The key names themselves are the exclusive-counting convention, so their presence is the signal -- no provider-name sniffing. Fixed in all four parsers that had the gap: the ORB's extractCliUsage, the miner's deliberately-duplicated copy in cli-subprocess-driver, the Agent-SDK driver's result reader, and through it runChatGrounding, which shares it. Closes #10246
1 parent ee1b86e commit ba9f06e

13 files changed

Lines changed: 352 additions & 3 deletions

packages/loopover-engine/src/miner/agent-sdk-driver.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,20 @@ function tokensFromResultMessage(resultMessage: Record<string, unknown> | null):
103103
const usage = asRecord(resultMessage?.usage);
104104
const inputTokens = finiteNonNegativeNumber(usage?.input_tokens);
105105
const outputTokens = finiteNonNegativeNumber(usage?.output_tokens);
106-
if (inputTokens === undefined && outputTokens === undefined) return {};
106+
// #10246: on a cache hit `input_tokens` is only the UNCACHED remainder -- the real prompt sits here. Kept
107+
// separate rather than folded into `inputTokens`, because that is what the provider means by each and
108+
// cache reads are priced at a fraction of fresh input.
109+
const cacheReadInputTokens = finiteNonNegativeNumber(usage?.cache_read_input_tokens);
110+
const cacheCreationInputTokens = finiteNonNegativeNumber(usage?.cache_creation_input_tokens);
111+
if (inputTokens === undefined && outputTokens === undefined && cacheReadInputTokens === undefined && cacheCreationInputTokens === undefined) {
112+
return {};
113+
}
107114
return {
108115
tokensUsed: (inputTokens ?? 0) + (outputTokens ?? 0),
109116
...(inputTokens === undefined ? {} : { inputTokens }),
110117
...(outputTokens === undefined ? {} : { outputTokens }),
118+
...(cacheReadInputTokens === undefined ? {} : { cacheReadInputTokens }),
119+
...(cacheCreationInputTokens === undefined ? {} : { cacheCreationInputTokens }),
111120
};
112121
}
113122

packages/loopover-engine/src/miner/ai-generation-sink.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export type MinerAiGenerationRecord = {
3737
totalTokens?: number | undefined;
3838
inputTokens?: number | undefined;
3939
outputTokens?: number | undefined;
40+
/** #10246: Anthropic-style cache accounting. On a cache hit `inputTokens` is only the UNCACHED remainder,
41+
* so these carry the rest -- separately, because the provider prices them separately and PostHog does too. */
42+
cacheReadInputTokens?: number | undefined;
43+
cacheCreationInputTokens?: number | undefined;
4044
totalCostUsd?: number | undefined;
4145
error?: unknown;
4246
};
@@ -101,6 +105,8 @@ export function withCodingAgentGenerationCapture(provider: string, model: string
101105
totalTokens: result.tokensUsed,
102106
inputTokens: result.inputTokens,
103107
outputTokens: result.outputTokens,
108+
cacheReadInputTokens: result.cacheReadInputTokens,
109+
cacheCreationInputTokens: result.cacheCreationInputTokens,
104110
totalCostUsd: result.costUsd,
105111
error: result.ok ? undefined : result.error,
106112
});

packages/loopover-engine/src/miner/chat-grounding.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,8 @@ export async function* runChatGrounding(
341341
totalTokens: tokens.tokensUsed,
342342
inputTokens: tokens.inputTokens,
343343
outputTokens: tokens.outputTokens,
344+
cacheReadInputTokens: tokens.cacheReadInputTokens,
345+
cacheCreationInputTokens: tokens.cacheCreationInputTokens,
344346
totalCostUsd: costUsd,
345347
...(failure === undefined ? {} : { error: new Error(failure) }),
346348
});

packages/loopover-engine/src/miner/cli-subprocess-driver.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,21 @@ const COST_KEYS = ["total_cost_usd", "totalCostUsd", "cost_usd", "costUsd"] as c
143143
const INPUT_TOKEN_KEYS = ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"] as const;
144144
const OUTPUT_TOKEN_KEYS = ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"] as const;
145145
const TOTAL_TOKEN_KEYS = ["total_tokens", "totalTokens"] as const;
146+
/** #10246: Anthropic-style cache accounting. `input_tokens` on such an envelope is the UNCACHED remainder,
147+
* not the whole prompt, so reading it alone under-reports input by orders of magnitude on a cache hit --
148+
* the same gap this file's src/selfhost/ai.ts counterpart had. These key names ARE the exclusive-counting
149+
* convention, so their presence is what identifies the envelope; no provider-name sniffing required. */
150+
const CACHE_READ_TOKEN_KEYS = ["cache_read_input_tokens", "cacheReadInputTokens"] as const;
151+
const CACHE_CREATION_TOKEN_KEYS = ["cache_creation_input_tokens", "cacheCreationInputTokens"] as const;
146152

147-
type CliUsage = { costUsd?: number; inputTokens?: number; outputTokens?: number; totalTokens?: number };
153+
type CliUsage = {
154+
costUsd?: number;
155+
inputTokens?: number;
156+
outputTokens?: number;
157+
totalTokens?: number;
158+
cacheReadInputTokens?: number;
159+
cacheCreationInputTokens?: number;
160+
};
148161

149162
function finiteNonNegativeNumber(value: unknown): number | undefined {
150163
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
@@ -182,6 +195,10 @@ function mergeCliUsage(out: CliUsage, record: Record<string, unknown>): void {
182195
if (outputTokens !== undefined) out.outputTokens = Math.max(out.outputTokens ?? 0, outputTokens);
183196
const totalTokens = maxNumber(entry, TOTAL_TOKEN_KEYS);
184197
if (totalTokens !== undefined) out.totalTokens = Math.max(out.totalTokens ?? 0, totalTokens);
198+
const cacheRead = maxNumber(entry, CACHE_READ_TOKEN_KEYS);
199+
if (cacheRead !== undefined) out.cacheReadInputTokens = Math.max(out.cacheReadInputTokens ?? 0, cacheRead);
200+
const cacheCreation = maxNumber(entry, CACHE_CREATION_TOKEN_KEYS);
201+
if (cacheCreation !== undefined) out.cacheCreationInputTokens = Math.max(out.cacheCreationInputTokens ?? 0, cacheCreation);
185202
}
186203
}
187204

@@ -215,6 +232,8 @@ function totalTokensFromUsage(usage: CliUsage): CodingAgentTokenUsage {
215232
const split = {
216233
...(usage.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }),
217234
...(usage.outputTokens === undefined ? {} : { outputTokens: usage.outputTokens }),
235+
...(usage.cacheReadInputTokens === undefined ? {} : { cacheReadInputTokens: usage.cacheReadInputTokens }),
236+
...(usage.cacheCreationInputTokens === undefined ? {} : { cacheCreationInputTokens: usage.cacheCreationInputTokens }),
218237
};
219238
if (usage.totalTokens !== undefined) return { tokensUsed: usage.totalTokens, ...split };
220239
if (usage.inputTokens === undefined && usage.outputTokens === undefined) return {};

packages/loopover-engine/src/miner/coding-agent-driver.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,20 @@ export type CodingAgentDriverResult = {
3434
* that reports only a blended total leaves these absent, and the blended `tokensUsed` still stands alone. */
3535
inputTokens?: number | undefined;
3636
outputTokens?: number | undefined;
37+
/** #10246: Anthropic-style cache accounting, reported SEPARATELY from `inputTokens` because that is what
38+
* the provider means by each -- on a cache hit `inputTokens` is only the uncached remainder. Same
39+
* never-fabricated convention: a provider that reports no cache fields leaves these absent. */
40+
cacheReadInputTokens?: number | undefined;
41+
cacheCreationInputTokens?: number | undefined;
3742
error?: string | undefined;
3843
};
3944

4045
/** The token fields a driver contributes to its result (#10198) -- spread into the result at each return site
4146
* so a driver can never report a split that disagrees with its own blended total. */
42-
export type CodingAgentTokenUsage = Pick<CodingAgentDriverResult, "tokensUsed" | "inputTokens" | "outputTokens">;
47+
export type CodingAgentTokenUsage = Pick<
48+
CodingAgentDriverResult,
49+
"tokensUsed" | "inputTokens" | "outputTokens" | "cacheReadInputTokens" | "cacheCreationInputTokens"
50+
>;
4351

4452
export interface CodingAgentDriver {
4553
run(task: CodingAgentDriverTask): Promise<CodingAgentDriverResult>;

packages/loopover-engine/test/agent-sdk-driver.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,3 +473,54 @@ test("a side the provider did not report stays ABSENT rather than being zeroed (
473473
assert.equal(noUsageResult.inputTokens, undefined);
474474
assert.equal(noUsageResult.outputTokens, undefined);
475475
});
476+
477+
// #10246: `input_tokens` on an Anthropic-style envelope is the UNCACHED remainder. Reading it alone
478+
// under-reported a real review's prompt by roughly four orders of magnitude.
479+
test("reports Anthropic-style cache tokens alongside the input/output split (#10246)", async () => {
480+
const driver = driverWith({
481+
query: queryYielding([
482+
{
483+
type: "result",
484+
subtype: "success",
485+
is_error: false,
486+
num_turns: 2,
487+
result: "done",
488+
usage: { input_tokens: 2, output_tokens: 787, cache_read_input_tokens: 48210, cache_creation_input_tokens: 1536 },
489+
},
490+
]),
491+
});
492+
493+
const result = await driver.run(task);
494+
495+
assert.equal(result.inputTokens, 2);
496+
assert.equal(result.outputTokens, 787);
497+
assert.equal(result.cacheReadInputTokens, 48210);
498+
assert.equal(result.cacheCreationInputTokens, 1536);
499+
// tokensUsed stays input+output: the cache figures are reported in their own right, not silently summed
500+
// into a blended total that would price cache reads as fresh input.
501+
assert.equal(result.tokensUsed, 789);
502+
});
503+
504+
test("a cache-only usage envelope still produces a usage record (#10246)", async () => {
505+
// A fully-cached turn can report zero fresh input; that must not read as "no usage at all".
506+
const driver = driverWith({
507+
query: queryYielding([
508+
{ type: "result", subtype: "success", is_error: false, num_turns: 1, result: "done", usage: { cache_read_input_tokens: 900 } },
509+
]),
510+
});
511+
const result = await driver.run(task);
512+
assert.equal(result.cacheReadInputTokens, 900);
513+
assert.equal(result.inputTokens, undefined);
514+
assert.equal(result.tokensUsed, 0);
515+
});
516+
517+
test("leaves the cache fields ABSENT when the provider reports none (#10246)", async () => {
518+
const driver = driverWith({
519+
query: queryYielding([
520+
{ type: "result", subtype: "success", is_error: false, num_turns: 1, result: "done", usage: { input_tokens: 100, output_tokens: 50 } },
521+
]),
522+
});
523+
const result = await driver.run(task);
524+
assert.equal(result.cacheReadInputTokens, undefined);
525+
assert.equal(result.cacheCreationInputTokens, undefined);
526+
});

packages/loopover-miner/lib/posthog.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ export type MinerAiGenerationEvent = {
138138
totalTokens?: number | undefined;
139139
inputTokens?: number | undefined;
140140
outputTokens?: number | undefined;
141+
/** #10246: Anthropic-style cache accounting, reported separately -- see the ORB-side counterpart in
142+
* src/selfhost/posthog.ts for why folding them into the input count is a different wrong answer. */
143+
cacheReadInputTokens?: number | undefined;
144+
cacheCreationInputTokens?: number | undefined;
141145
totalCostUsd?: number | undefined;
142146
error?: unknown;
143147
};
@@ -162,6 +166,14 @@ export function captureMinerPostHogAiGeneration(event: MinerAiGenerationEvent):
162166
// now contributes nothing to the token properties rather than a run of false zeros.
163167
if (Number.isFinite(event.inputTokens)) properties.$ai_input_tokens = event.inputTokens;
164168
if (Number.isFinite(event.outputTokens)) properties.$ai_output_tokens = event.outputTokens;
169+
// #10246: their presence also marks the envelope EXCLUSIVE. PostHog otherwise auto-detects that from
170+
// `$ai_provider`, which here reads "claude-cli"/"agent-sdk" rather than "anthropic", so it would guess
171+
// inclusive and price the cached prompt as fresh input.
172+
if (Number.isFinite(event.cacheReadInputTokens)) properties.$ai_cache_read_input_tokens = event.cacheReadInputTokens;
173+
if (Number.isFinite(event.cacheCreationInputTokens)) properties.$ai_cache_creation_input_tokens = event.cacheCreationInputTokens;
174+
if (Number.isFinite(event.cacheReadInputTokens) || Number.isFinite(event.cacheCreationInputTokens)) {
175+
properties.$ai_cache_reporting_exclusive = true;
176+
}
165177
if (Number.isFinite(event.totalTokens)) properties.tokens_used = event.totalTokens;
166178
if (Number.isFinite(event.totalCostUsd)) properties.$ai_total_cost_usd = event.totalCostUsd;
167179
if (event.isError) {

src/selfhost/ai.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,10 @@ export type CliUsage = {
721721
inputTokens?: number;
722722
outputTokens?: number;
723723
totalTokens?: number;
724+
/** #10246: Anthropic-style cache accounting, reported SEPARATELY from `inputTokens` -- see
725+
* CACHE_READ_TOKEN_KEYS for why folding them in would be a different wrong answer. */
726+
cacheReadInputTokens?: number;
727+
cacheCreationInputTokens?: number;
724728
costUsd?: number;
725729
model?: string;
726730
};
@@ -739,6 +743,8 @@ function buildAiUsage(fields: {
739743
inputTokens?: number | undefined;
740744
outputTokens?: number | undefined;
741745
totalTokens?: number | undefined;
746+
cacheReadInputTokens?: number | undefined;
747+
cacheCreationInputTokens?: number | undefined;
742748
costUsd?: number | undefined;
743749
effort?: string | undefined;
744750
}): AiUsage {
@@ -748,6 +754,8 @@ function buildAiUsage(fields: {
748754
if (fields.inputTokens !== undefined) usage.inputTokens = fields.inputTokens;
749755
if (fields.outputTokens !== undefined) usage.outputTokens = fields.outputTokens;
750756
if (fields.totalTokens !== undefined) usage.totalTokens = fields.totalTokens;
757+
if (fields.cacheReadInputTokens !== undefined) usage.cacheReadInputTokens = fields.cacheReadInputTokens;
758+
if (fields.cacheCreationInputTokens !== undefined) usage.cacheCreationInputTokens = fields.cacheCreationInputTokens;
751759
if (fields.costUsd !== undefined) usage.costUsd = fields.costUsd;
752760
if (fields.effort !== undefined) usage.effort = fields.effort;
753761
return usage;
@@ -796,9 +804,24 @@ export function providerNameFromBaseUrl(baseUrl: string | undefined): "ollama" |
796804
return "openai-compatible";
797805
}
798806

807+
// #10246: `input_tokens` is the UNCACHED remainder on an Anthropic-style envelope, NOT the whole prompt.
808+
// Anthropic (and therefore the claude-code CLI) reports cached prompt tokens in their own fields, and this
809+
// engine's prompts cache well -- a stable system prompt plus repo context -- so on a cache hit `input_tokens`
810+
// is a handful of tokens while the real prompt sits in `cache_read_input_tokens`. Live evidence: a review that
811+
// produced 787 output tokens and cost $0.21 reported TWO input tokens.
812+
//
813+
// The fix keeps `inputTokens` faithful to what the provider means by it, and reports the cache fields
814+
// alongside, because PostHog models exactly this distinction: `$ai_cache_reporting_exclusive` tells it
815+
// whether cache tokens sit outside (Anthropic) or inside (OpenAI and most others) the input count, and it
816+
// prices them separately. Folding cache reads into `inputTokens` would price them at full input rate, which
817+
// is a different wrong answer -- cache reads are roughly a tenth the cost.
799818
const INPUT_TOKEN_KEYS = ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"] as const;
800819
const OUTPUT_TOKEN_KEYS = ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"] as const;
801820
const TOTAL_TOKEN_KEYS = ["total_tokens", "totalTokens"] as const;
821+
/** Anthropic-style cache accounting (#10246). These key names ARE the exclusive-counting convention, so their
822+
* presence is what marks a usage envelope as exclusive -- no provider-name sniffing required. */
823+
const CACHE_READ_TOKEN_KEYS = ["cache_read_input_tokens", "cacheReadInputTokens"] as const;
824+
const CACHE_CREATION_TOKEN_KEYS = ["cache_creation_input_tokens", "cacheCreationInputTokens"] as const;
802825
const COST_KEYS = ["total_cost_usd", "totalCostUsd", "cost_usd", "costUsd"] as const;
803826

804827
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -835,6 +858,10 @@ function mergeUsage(out: CliUsage, record: Record<string, unknown>): void {
835858
if (outputTokens !== undefined) out.outputTokens = Math.max(out.outputTokens ?? 0, outputTokens);
836859
const totalTokens = maxNumber(entry, TOTAL_TOKEN_KEYS);
837860
if (totalTokens !== undefined) out.totalTokens = Math.max(out.totalTokens ?? 0, totalTokens);
861+
const cacheReadInputTokens = maxNumber(entry, CACHE_READ_TOKEN_KEYS);
862+
if (cacheReadInputTokens !== undefined) out.cacheReadInputTokens = Math.max(out.cacheReadInputTokens ?? 0, cacheReadInputTokens);
863+
const cacheCreationInputTokens = maxNumber(entry, CACHE_CREATION_TOKEN_KEYS);
864+
if (cacheCreationInputTokens !== undefined) out.cacheCreationInputTokens = Math.max(out.cacheCreationInputTokens ?? 0, cacheCreationInputTokens);
838865
const costUsd = maxNumber(entry, COST_KEYS);
839866
if (costUsd !== undefined) out.costUsd = Math.max(out.costUsd ?? 0, costUsd);
840867
if (typeof entry.model === "string" && entry.model.trim()) out.model = entry.model.trim();
@@ -1629,6 +1656,8 @@ async function runProviderWithOtel(
16291656
isError: false,
16301657
inputTokens: usage?.inputTokens,
16311658
outputTokens: usage?.outputTokens,
1659+
cacheReadInputTokens: usage?.cacheReadInputTokens,
1660+
cacheCreationInputTokens: usage?.cacheCreationInputTokens,
16321661
totalCostUsd: usage?.costUsd,
16331662
effort: usage?.effort,
16341663
context: { repo: options.repoFullName, pullNumber: options.pullNumber },
@@ -1825,6 +1854,8 @@ export function withAiGenerationCapture(providerName: string, ai: SelfHostAi): S
18251854
isError: false,
18261855
inputTokens: usage?.inputTokens,
18271856
outputTokens: usage?.outputTokens,
1857+
cacheReadInputTokens: usage?.cacheReadInputTokens,
1858+
cacheCreationInputTokens: usage?.cacheCreationInputTokens,
18281859
totalCostUsd: usage?.costUsd,
18291860
effort: usage?.effort,
18301861
input: aiContentInput(options),

src/selfhost/posthog.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,11 @@ export type PostHogAiGenerationEvent = {
433433
* never fabricates a split its source data doesn't have. Absent for the claude-code/codex subscription
434434
* CLIs whenever their own stdout reports none (a flat subscription has no real per-call dollar cost). */
435435
totalCostUsd?: number | undefined;
436+
/** #10246: Anthropic-style cache accounting, reported SEPARATELY from `inputTokens` because that is what
437+
* the provider means by each. PostHog prices cache reads at their own (much lower) rate and uses
438+
* `$ai_cache_reporting_exclusive` to know which side of the input count they fall on. */
439+
cacheReadInputTokens?: number | undefined;
440+
cacheCreationInputTokens?: number | undefined;
436441
/** Reasoning-effort dial ("low"/"medium"/"high"/"max") -- generation CONFIG, never prompt content. */
437442
effort?: string | undefined;
438443
/** Correlation context (repo/PR), the same optional fields AiRunOptions already threads through for
@@ -633,6 +638,16 @@ export function capturePostHogAiGeneration(event: PostHogAiGenerationEvent): voi
633638
// outliers. A genuinely reported 0 still lands, because absence is tested, not falsiness.
634639
if (Number.isFinite(event.inputTokens)) properties.$ai_input_tokens = event.inputTokens;
635640
if (Number.isFinite(event.outputTokens)) properties.$ai_output_tokens = event.outputTokens;
641+
// #10246: only emitted when the provider actually reported them. Their presence is also what marks the
642+
// envelope as EXCLUSIVE -- these key names are the Anthropic convention, so no provider-name sniffing is
643+
// needed. Setting the flag explicitly matters because PostHog otherwise auto-detects it from
644+
// `$ai_provider`, and this deployment reports "claude-code", not "anthropic", so the auto-detection would
645+
// read it as inclusive and price the same prompt twice over.
646+
if (Number.isFinite(event.cacheReadInputTokens)) properties.$ai_cache_read_input_tokens = event.cacheReadInputTokens;
647+
if (Number.isFinite(event.cacheCreationInputTokens)) properties.$ai_cache_creation_input_tokens = event.cacheCreationInputTokens;
648+
if (Number.isFinite(event.cacheReadInputTokens) || Number.isFinite(event.cacheCreationInputTokens)) {
649+
properties.$ai_cache_reporting_exclusive = true;
650+
}
636651
if (Number.isFinite(event.totalCostUsd)) properties.$ai_total_cost_usd = event.totalCostUsd;
637652
if (event.effort) properties.$ai_model_parameters = { effort: event.effort };
638653
if (event.isError) {

0 commit comments

Comments
 (0)