pi-mono/packages/coding-agent/test/cache-stats.test.ts
Armin Ronacher 3f9aa5d10b
Some checks are pending
CI / build-check-test (push) Waiting to run
feat(coding-agent): add prompt cache miss tracking (#6427)
Detect prompt cache misses per turn by comparing each assistant message's
cache reads against the previous request's prompt tokens (core/cache-stats.ts).
Significant misses emit a warning-colored transcript notice at the turn they
occur, noting idle gaps past the cache TTL and model switches when relevant.

/session gained cache statistics: a compact token/cache breakdown with hit
rate, a $-prefixed cost section with per-model cost breakdown, and the
cumulative cost re-billed due to cache misses.
2026-07-09 14:12:57 +02:00

143 lines
5 KiB
TypeScript

import type { AssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import {
collectCacheMisses,
computeCacheWaste,
detectCacheMiss,
type ModelPriceSource,
} from "../src/core/cache-stats.ts";
import type { SessionEntry } from "../src/core/session-manager.ts";
const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
const models: ModelPriceSource = {
// $/million tokens; used as cache-read price fallback on full-miss turns
find: () => ({ cost: { cacheRead: 0.3 } }),
};
function assistant(options: {
input?: number;
cacheRead?: number;
cacheWrite?: number;
cost?: Partial<typeof zeroCost>;
model?: string;
timestamp?: number;
}): AssistantMessage {
return {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "test",
model: options.model ?? "test-model",
usage: {
input: options.input ?? 0,
output: 10,
cacheRead: options.cacheRead ?? 0,
cacheWrite: options.cacheWrite ?? 0,
totalTokens: 0,
cost: { ...zeroCost, ...options.cost },
},
stopReason: "stop",
timestamp: options.timestamp ?? 0,
} as AssistantMessage;
}
function entry(message: AssistantMessage): SessionEntry {
return { type: "message", id: "x", parentId: null, timestamp: "", message } as SessionEntry;
}
// Turn 1: fresh 100k cache write at $3.75/M
const turn1 = assistant({ cacheWrite: 100_000, cost: { cacheWrite: 0.375 }, timestamp: 0 });
// Turn 2: healthy, everything read back at $0.30/M
const turn2 = assistant({
cacheRead: 100_000,
cacheWrite: 5_000,
cost: { cacheRead: 0.03, cacheWrite: 0.019 },
timestamp: 60_000,
});
describe("computeCacheWaste", () => {
it("accumulates missed tokens and cost across turns", () => {
// Turn 3: full miss, previous 105k prompt re-billed at $3.75/M write
const turn3 = assistant({ cacheWrite: 110_000, cost: { cacheWrite: 0.4125 }, timestamp: 120_000 });
const totals = computeCacheWaste([entry(turn1), entry(turn2), entry(turn3)], models);
expect(totals.missedTokens).toBe(105_000);
// 105k at ($3.75 - $0.30)/M
expect(totals.missedCost).toBeCloseTo(0.36225, 5);
});
it("counts nothing for healthy sessions", () => {
const totals = computeCacheWaste([entry(turn1), entry(turn2)], models);
expect(totals.missedTokens).toBe(0);
expect(totals.missedCost).toBe(0);
});
it("skips the turn after a compaction reset", () => {
const reset = { type: "compaction", id: "c", parentId: null, timestamp: "" } as SessionEntry;
const afterReset = assistant({ cacheWrite: 20_000, cost: { cacheWrite: 0.075 } });
const totals = computeCacheWaste([entry(turn1), reset, entry(afterReset)], models);
expect(totals.missedTokens).toBe(0);
});
it("counts misses caused by model switches", () => {
const otherModel = assistant({ cacheWrite: 100_000, cost: { cacheWrite: 0.375 }, model: "other-model" });
const totals = computeCacheWaste([entry(turn1), entry(otherModel)], models);
expect(totals.missedTokens).toBe(100_000);
expect(totals.missCount).toBe(1);
});
it("skips providers that report no cache activity", () => {
const a = assistant({ input: 100_000 });
const b = assistant({ input: 110_000 });
const totals = computeCacheWaste([entry(a), entry(b)], models);
expect(totals.missedTokens).toBe(0);
});
});
describe("collectCacheMisses", () => {
it("maps counted misses to their assistant messages by reference", () => {
const missTurn = assistant({ cacheWrite: 110_000, cost: { cacheWrite: 0.4125 }, timestamp: 120_000 });
const misses = collectCacheMisses([entry(turn1), entry(turn2), entry(missTurn)], models);
expect(misses.size).toBe(1);
expect(misses.get(missTurn)?.missedTokens).toBe(105_000);
});
});
describe("detectCacheMiss", () => {
it("detects a miss on a just-completed message with idle time", () => {
const missMessage = assistant({ cacheWrite: 110_000, cost: { cacheWrite: 0.4125 }, timestamp: 600_000 });
const miss = detectCacheMiss([entry(turn1), entry(turn2)], missMessage, models);
expect(miss).toBeDefined();
expect(miss?.missedTokens).toBe(105_000);
expect(miss?.missedCost).toBeCloseTo(0.36225, 5);
// 600s - 60s since the previous request
expect(miss?.idleMs).toBe(540_000);
expect(miss?.modelChanged).toBe(false);
});
it("flags model switches on detected misses", () => {
const otherModel = assistant({
cacheWrite: 110_000,
cost: { cacheWrite: 0.4125 },
model: "other-model",
timestamp: 120_000,
});
const miss = detectCacheMiss([entry(turn1), entry(turn2)], otherModel, models);
expect(miss?.missedTokens).toBe(105_000);
expect(miss?.modelChanged).toBe(true);
});
it("returns undefined for healthy turns", () => {
const healthy = assistant({
cacheRead: 105_000,
cacheWrite: 2_000,
cost: { cacheRead: 0.0315, cacheWrite: 0.0075 },
timestamp: 120_000,
});
expect(detectCacheMiss([entry(turn1), entry(turn2)], healthy, models)).toBeUndefined();
});
it("returns undefined for the first turn of a session", () => {
expect(detectCacheMiss([], turn1, models)).toBeUndefined();
});
});