Context-meter fold: cache-inclusive numerator, live-limits denominator, zeroed-step fallback
The owner's context-meter audit (2026-09-23), three fixes in the pure contextWindow() fold (core/prices.ts): - NUMERATOR: cache tokens count. Provider "input" excludes cacheRead/cacheWrite, so input+output read wildly low on cache-heavy sessions (40k shown vs ~190k real). The fold is now pi's own totalTokens semantics: input + output + cacheRead + cacheWrite. The fields were already persisted in every CallUsage (chat-handler's capture seam); non-caching providers are unchanged. - DENOMINATOR: the live catalog wins. contextWindow() accepts an optional live-limits index (provider:id -> contextWindow — the shape the client already holds from GET /api/models, where toModelDto projects pi's Model.contextWindow); CONTEXT_LIMITS, the generated duplicate, is demoted to fallback for models the live catalog doesn't carry. The UI's stats-bar join passes its catalog index; the call is unchanged when omitted. - RIDE-ALONG (honesty guard): a zeroed/aborted last step falls back to the previous step's footprint — an abort artifact is not a reading. All-zero runs return null (no meter, never a bogus tiny number). Tests pin all three: the cache-inclusive fold (and the plain no-cache fold unchanged), live-index-beats-table + table fallback + unknown-to- both null, and the zeroed-step fallback (previous step wins; all-zero is null). 885/885 tests, typecheck/lint/build green.
This commit is contained in:
+45
-12
@@ -2812,25 +2812,58 @@ export function sessionCost(record: Pick<ChatRecord, "turns">): { 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<ChatRecord, "turns">,
|
||||
limits?: ReadonlyMap<string, number>,
|
||||
): { 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
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<JobRecord, "usage"> = {
|
||||
|
||||
Reference in New Issue
Block a user