diff --git a/src/core/prices.ts b/src/core/prices.ts index 8123082..4edf4ef 100644 --- a/src/core/prices.ts +++ b/src/core/prices.ts @@ -2812,25 +2812,58 @@ export function sessionCost(record: Pick): { cost: number; return { cost, unpriced }; } -/** The session's context-window footprint: the LAST model call's input+output - * tokens against the model's limit (the next turn re-sends the whole - * conversation — the last call's input IS the running transcript size). - * Null when the record has no usage yet or the model's limit is unknown. */ +/** The session's context-window footprint: the LAST model call's + * token footprint against the model's limit (the next turn re-sends + * the whole conversation — the last call's input IS the running + * transcript size). Null when the record has no usage yet or the + * model's limit is unknown. + * + * NUMERATOR (owner audit, 2026-09-23): cache tokens count. Provider + * `input` EXCLUDES cacheRead/cacheWrite, so folding input+output alone + * reads wildly low on cache-heavy sessions — include all four + * (= pi's own totalTokens semantics; the fields are persisted in every + * CallUsage). + * + * DENOMINATOR: the LIVE catalog wins — `limits`, when given, is the + * client/server-held index of provider:id → contextWindow straight from + * pi's catalog (GET /api/models serves it per model); CONTEXT_LIMITS is + * a generated snapshot kept ONLY as the fallback for models the live + * catalog doesn't carry. + * + * Zeroed-step fallback: an aborted/failed last call reports zeroed + * usage — that is NOT the session footprint; fall back to the previous + * step's usage (the honest last-known footprint, never a bogus tiny + * meter). */ export function contextWindow( record: Pick, + limits?: ReadonlyMap, ): { model: string; usedTokens: number; limit: number } | null { const turns = record.turns ?? []; const last = turns[turns.length - 1]; if (!last) return null; - const limit = CONTEXT_LIMITS[last.model]; + const limit = limits?.get(last.model) ?? CONTEXT_LIMITS[last.model]; if (limit === undefined) return null; - const lastStep = last.steps[last.steps.length - 1]; - if (!lastStep || lastStep.inputTokens === undefined) return null; - return { - model: last.model, - usedTokens: (lastStep.inputTokens ?? 0) + (lastStep.outputTokens ?? 0), - limit, - }; + // The last step with a real (non-zero) footprint — zeroed usage (an + // abort artifact) is not a reading; fall back to the previous step. + for (let i = last.steps.length - 1; i >= 0; i--) { + const step = last.steps[i]!; + const footprint = stepFootprint(step); + if (footprint === 0) continue; // zeroed artifact — no reading + return { model: last.model, usedTokens: footprint, limit }; + } + return null; +} + +/** One call's full context footprint: input + output + both cache + * directions (pi's totalTokens semantics — provider `input` excludes + * the cached portion). */ +function stepFootprint(step: CallUsage): number { + return ( + (step.inputTokens ?? 0) + + (step.outputTokens ?? 0) + + (step.cacheReadTokens ?? 0) + + (step.cacheWriteTokens ?? 0) + ); } /** A job's inferred cost — null until the runner's capture lands / for diff --git a/tests/usage.test.ts b/tests/usage.test.ts index 907f10b..d49d985 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -92,6 +92,79 @@ test("contextWindow: last call's footprint against the model's limit", () => { assert.equal(contextWindow({ turns: [] }), null); }); +test("contextWindow NUMERATOR: cache tokens count (owner audit, 2026-09-23)", () => { + // The audit's live case: a cache-heavy session read 40k on the meter + // while the real footprint was ~190k — provider `input` EXCLUDES the + // cached portion, so input+output alone is wildly low. The fold is + // pi's totalTokens semantics: all four fields. + const cw = contextWindow({ + turns: [ + { + id: "t1", + messageId: "m1", + at: "x", + model: "openrouter:z-ai/glm-5.3-flash", + steps: [call({ inputTokens: 40_000, outputTokens: 1_000, cacheReadTokens: 145_000, cacheWriteTokens: 4_000 })], + }, + ], + }); + assert.ok(cw); + assert.equal(cw.usedTokens, 40_000 + 1_000 + 145_000 + 4_000, "input + output + cacheRead + cacheWrite"); + // Cache fields absent (non-caching providers): the fold is unchanged. + const plain = contextWindow({ + turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] }], + }); + assert.ok(plain); + assert.equal(plain.usedTokens, 10_000); +}); + +test("contextWindow DENOMINATOR: the live-limits index wins; CONTEXT_LIMITS is the fallback", () => { + const turns = [ + { id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 1_000 })] }, + ]; + // The live catalog (GET /api/models) wins even when it disagrees with + // the generated table. + const live = new Map([["openrouter:z-ai/glm-5.3-flash", 65_536]]); + const cw = contextWindow({ turns }, live); + assert.ok(cw); + assert.equal(cw.limit, 65_536, "the live index beats the static table"); + // No live entry: the generated table serves the uncatalogued model. + assert.equal(contextWindow({ turns })!.limit, 1_048_576); + const emptyLive = new Map(); + assert.equal(contextWindow({ turns }, emptyLive)!.limit, 1_048_576, "an empty live index falls back too"); + // Unknown to both: null (no meter, never a guessed limit). + assert.equal(contextWindow({ turns: [{ ...turns[0]!, model: "unknown/model" }] }), null); + assert.equal(contextWindow({ turns: [{ ...turns[0]!, model: "unknown/model" }] }, live), null); +}); + +test("contextWindow ride-along: a zeroed/aborted last step falls back to the previous step", () => { + // An aborted last call reports zeroed usage — that is not the session + // footprint. The honest reading is the previous step's usage. + const cw = contextWindow({ + turns: [ + { + id: "t1", + messageId: "m1", + at: "x", + model: "openrouter:z-ai/glm-5.3-flash", + steps: [ + call({ inputTokens: 80_000, outputTokens: 5_000 }), + call({ inputTokens: 0, outputTokens: 0 }), // aborted — zeroed + ], + }, + ], + }); + assert.ok(cw); + assert.equal(cw.usedTokens, 85_000, "the previous step's footprint, not a bogus tiny meter"); + // ALL steps zeroed (a first-call abort): no reading at all — null. + assert.equal( + contextWindow({ + turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 0, outputTokens: 0 })] }], + }), + null, + ); +}); + test("jobCost: null without usage, computed with it", () => { assert.equal(jobCost({}), null); const job: Pick = {