Methodology: prompt cache hit rate in agent loops
We measure how often each API reads its prompt cache when a client uses it the way a coding agent does. The thread is append-only, the prefix never changes, and the next request comes well inside the cache lifetime. A perfect cache reads the whole previous prompt on every turn. We count how often each provider falls short of that, and what it costs.
Run on 2026-10-17. Models: claude-sonnet-5-5, gpt-6.1-sol, gemini-4-argon. One TypeScript file, about 200 lines, full source below.
1. Thread shape
Each thread simulates one agent session. Every request is a strict append of the request before it, so the previous prompt is always a byte-exact prefix of the next one.
- System prompt. 301 lines: one header line and 300 reference lines. Every line contains an 8-char random thread ID (the nonce). The nonce makes sure that no thread can read another thread's cache, or a cache from an earlier run.
- Turn 0. One user message:
Start the task.This request primes the cache and is not scored. - Turns 1 to 19. Append a fixed assistant message
OK., then a new user message that looks like tool output. It has 41 lines, and each line holds a freshrandomUUID(), so the new text cannot match any cache. - No model output is fed back. The assistant turns are always the literal
OK., not the model's real reply. This keeps the prefix fully under our control. - Output is capped at 16 tokens. Only input caching is under test.
Prompt sizes (Gemini tokenizer): about 10.2k tokens at turn 0 and about 49k at turn 19. Each turn adds about 2k tokens. The Claude and OpenAI tokenizers give somewhat different counts for the same text.
request n = [system] [user: Start the task.] [OK.] [tool 1] [OK.] [tool 2] ... [OK.] [tool n]
request n+1 = [system] [user: Start the task.] [OK.] [tool 1] [OK.] [tool 2] ... [OK.] [tool n] [OK.] [tool n+1]
|<------------------------- should be read from cache ------------------------->| new tail
2. Grid
| Gap between turns | 5s, 30s, 120s. All well inside every provider's cache lifetime (Claude 5m, OpenAI 30m, Gemini unknown). |
|---|---|
| Threads per gap | 4 |
| Turns per thread | 20 (1 prime + 19 scored) |
| Concurrency | All 12 threads for a provider run at the same time, in one process. Each thread is sequential: it waits for the response, then sleeps for the gap, then sends the next turn. |
| Total | 3 providers × 12 threads × 20 requests = 720 requests. 684 scored turns. 0 errors. |
| Runs | Claude and OpenAI ran together. Gemini ran as a separate process from a different network, because the first network got HTTP 403 from the Gemini API. The client code is the same for both runs. |
3. Requests per provider
Every request uses the provider's default caching for its newest model. There are no tools and no streaming.
| Caching config | Other params | Usage fields read | |
|---|---|---|---|
Gemini@google/genai 2.30 | None. Implicit caching is the only mode on gemini-4-argon. We do not create cachedContents. | systemInstruction, maxOutputTokens: 16. Roles alternate user/model. Thinking is left at the default. | promptTokenCount (prompt), cachedContentTokenCount (read). Write = 0. |
Claude@anthropic-ai/sdk 0.135 | Top-level cache_control: {type:"ephemeral", ttl:"5m"} (automatic caching: the breakpoint moves to the last block). | max_tokens: 16, thinking: {type:"between_tools"} (this turns thinking off on Sonnet 5.5). | prompt = input_tokens + cache_read_input_tokens + cache_creation_input_tokens. Read and write come from the same fields. |
OpenAIopenai 7.34 | None. Implicit caching (the default). The system prompt goes in as a developer message. | Responses API, max_output_tokens: 16, store: false. | input_tokens (prompt), input_tokens_details.cached_tokens (read), input_tokens_details.cache_write_tokens (write). |
The Gemini request, exactly
await ai.models.generateContent({
model: "gemini-4-argon",
contents: [
{ role: "user", parts: [{ text: "Start the task." }] },
{ role: "model", parts: [{ text: "OK." }] },
{ role: "user", parts: [{ text: "Tool result for step 1:\nsrc/module_1/file_0.ts:3 <uuid> ok\n..." }] },
// ... one model "OK." + one user tool result per turn
],
config: { systemInstruction: system, maxOutputTokens: 16 },
});
4. What we log
One JSON line per request:
{"provider":"gemini","gapSec":5,"thread":"52233187","turn":3,"expected":14254,"prompt":16278,"read":12205,"write":0,"output":2,"ms":4204}
| expected | The prompt value of the same thread's previous request. This is the most a perfect cache can read on this turn. |
|---|---|
| prompt | Total input tokens for this request, cached or not. |
| read | Input tokens billed at the cached rate. |
| write | Input tokens billed at the cache-write rate (Claude and OpenAI only). |
| ms | Wall time of the request. |
In the example row, the previous prompt was 14,254 tokens, but only 12,205 were read (86%). That is about 2k tokens billed again at the full input rate.
5. Scoring
- Full hit:
read / expected ≥ 0.9 - Miss:
read == 0 - Partial: anything between.
The 90% threshold is only for the hit/partial/miss labels. The main metric is the cost multiple, which has no threshold:
cost(read, write, prompt, output) =
read * readRate + write * writeRate + (prompt - read - write) * inputRate + output * outputRate
actual = sum over scored turns of cost(read, write, prompt, output)
perfect = sum over scored turns of cost(min(expected, prompt), prompt - min(expected, prompt), prompt, output)
cost multiple = actual / perfect
The perfect case still pays for the new tail on each turn. Claude and OpenAI pay the write rate for it, and Gemini pays the plain input rate because it has no write fee. So a provider is only penalized for prefix tokens that it already processed but did not read from cache.
| $ per 1M tokens | Input | Cache write | Cache read | Output |
|---|---|---|---|---|
| Gemini 4 Argon (through 2026-11-30) | 2.00 | none | 0.10 | 10.00 |
| Claude Sonnet 5.5 (5m TTL) | 2.00 | 2.50 | 0.10 | 10.00 |
| GPT-6.1 Sol (short context) | 2.00 | 2.50 | 0.10 | 10.00 |
The three providers have the same input, read and output prices. So the cost multiple compares cache behavior only, not price.
6. Results
| Model | Gap | Full hit | Partial | Miss | Cost multiple |
|---|---|---|---|---|---|
| Claude Sonnet 5.5 | 5s / 30s / 120s | 100% | 0% | 0% | 1.00x |
| GPT-6.1 Sol | 5s / 30s / 120s | 100% | 0% | 0% | 1.00x |
| Gemini 4 Argon | 5s | 76.3% | 23.7% | 0% | 1.20x |
| Gemini 4 Argon | 30s | 69.7% | 28.9% | 1.3% | 1.34x |
| Gemini 4 Argon | 120s | 73.7% | 22.4% | 3.9% | 1.48x |
On Gemini, partial hits happen mostly in turns 1 to 6 of each thread. Partial turns read on average 85% of the previous prompt, so each one re-bills about 2.35k tokens. On full hits, Gemini reads all but about 340 tokens of the previous prompt. Claude and OpenAI read all but 3 or 4 tokens.
7. Separate test: Gemini cache lifetime
Google does not publish a lifetime for implicit caching, so we measured it. Six threads started at the same time, each with its own nonce prefix of about 25k tokens.
// Same request shape and prefix style as missrate.ts (400 nonce lines, about 25k tokens).
// Each scenario gets its own nonce, so no scenario can read another one's cache.
const gapScenario = (gapMin) => async () => {
const system = prefix(randomUUID());
await gem(system, ["u1"]); // t = 0: prime
await sleepUntil(t0 + gapMin * 60_000);
await gem(system, ["u1", "branch"]); // t = gap: does it read u1's prefix?
};
// No-refresh gaps, all started at the same time:
[2, 6.5, 15, 35, 65].map(gapScenario);
// Refresh thread: prime at 0, then read the same prefix at 4m, 8m, 12m.
| Thread | Second request at | Result |
|---|---|---|
| no refresh | 2m | miss |
| no refresh | 6.5m | miss |
| no refresh | 15m | hit (25.1k of 27.2k read) |
| no refresh | 35m | miss |
| no refresh | 65m | miss |
| refresh every 4m | 4m, 8m, 12m | hit, hit, hit |
This is one sample for each gap, so it shows that the lifetime is not predictable. It does not measure the lifetime exactly.
8. Rebuild it
mkdir cache-test && cd cache-test
pnpm init
pnpm add @google/genai@2.30 @anthropic-ai/sdk@0.135 openai@7.34
pnpm add -D tsx typescript @types/node
# set "type": "module" in package.json
# pnpm 10+: allow the esbuild build that tsx needs (pnpm-workspace.yaml below)
# save missrate.ts (below) in this folder
cat > .env <<'EOF'
GOOGLE_GENERATIVE_AI_API_KEY=
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
EOF
pnpm tsx missrate.ts run gemini # about 45 min, writes missrate-gemini.jsonl
pnpm tsx missrate.ts run claude,gpt # optional baseline
pnpm tsx missrate.ts report missrate-*.jsonl
Needs Node 22 or later (the script uses process.loadEnvFile and Map.groupBy). To test Gemini only, you do not need the other two keys. To test a different model, change the model string in the gemini function. The run takes about 20 × (gap + latency) for the slowest gap, so about 45 minutes. For a quicker run, remove 120 from GAPS_SEC.
missrate.ts (full source, as run)
// Cache hit rate under "correct" agent-loop usage: append-only threads, gaps well inside every TTL.
// Usage: pnpm tsx missrate.ts run claude,gpt (about 40 min, writes missrate-<providers>.jsonl)
// pnpm tsx missrate.ts report <files...> (prints hit rates and cost vs a perfect cache)
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import { GoogleGenAI } from "@google/genai";
import { appendFileSync, readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
const GAPS_SEC = [5, 30, 120];
const THREADS_PER_GAP = 4;
const TURNS = 20;
const PROVIDERS = ["claude", "gpt", "gemini"] as const;
type Provider = (typeof PROVIDERS)[number];
type Usage = { prompt: number; read: number; write: number; output: number };
// $ per 1M tokens. Gemini has no write fee, so its "write" rate is the plain input rate.
const PRICE: Record<Provider, { input: number; write: number; read: number; output: number }> = {
claude: { input: 2, write: 2.5, read: 0.1, output: 10 },
gpt: { input: 2, write: 2.5, read: 0.1, output: 10 },
gemini: { input: 2, write: 2, read: 0.1, output: 10 },
};
type Row = {
provider: Provider;
gapSec: number;
thread: string;
turn: number;
expected: number; // previous request's full prompt: what a perfect cache would read
ms: number;
error?: string;
} & Partial<Usage>;
// One API call per provider. `users` alternate with a fixed "OK." assistant turn, so each request
// is a pure append of the one before.
const call: Record<Provider, (system: string, users: string[]) => Promise<Usage>> = {
claude: async (system, users) => {
const client = (clients.claude ??= new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
authToken: null,
baseURL: "https://api.anthropic.com",
}));
const res = await client.messages.create({
model: "claude-sonnet-5-5",
max_tokens: 16,
thinking: { type: "between_tools" },
system,
messages: users.flatMap((text, i) =>
i === 0
? [{ role: "user" as const, content: text }]
: [{ role: "assistant" as const, content: "OK." }, { role: "user" as const, content: text }],
),
cache_control: { type: "ephemeral", ttl: "5m" },
});
const u = res.usage;
const read = u.cache_read_input_tokens ?? 0;
const write = u.cache_creation_input_tokens ?? 0;
return { prompt: u.input_tokens + read + write, read, write, output: u.output_tokens };
},
gpt: async (system, users) => {
const client = (clients.gpt ??= new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: "https://api.openai.com/v1",
}));
const res = await client.responses.create({
model: "gpt-6.1-sol",
max_output_tokens: 16,
store: false,
input: [
{ role: "developer", content: system },
...users.flatMap((text, i) =>
i === 0
? [{ role: "user" as const, content: text }]
: [{ role: "assistant" as const, content: "OK." }, { role: "user" as const, content: text }],
),
],
});
const u = res.usage;
if (!u) throw new Error(`no usage, status=${res.status}`);
return {
prompt: u.input_tokens,
read: u.input_tokens_details.cached_tokens,
write: u.input_tokens_details.cache_write_tokens,
output: u.output_tokens,
};
},
gemini: async (system, users) => {
const client = (clients.gemini ??= new GoogleGenAI({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY }));
const res = await client.models.generateContent({
model: "gemini-4-argon",
contents: users.flatMap((text, i) => {
const user = { role: "user", parts: [{ text }] };
return i === 0 ? [user] : [{ role: "model", parts: [{ text: "OK." }] }, user];
}),
config: { systemInstruction: system, maxOutputTokens: 16 },
});
const u = res.usageMetadata;
if (!u?.promptTokenCount) throw new Error("no usage");
return {
prompt: u.promptTokenCount,
read: u.cachedContentTokenCount ?? 0,
write: 0,
output: (u.candidatesTokenCount ?? 0) + (u.thoughtsTokenCount ?? 0),
};
},
};
const clients: { claude?: Anthropic; gpt?: OpenAI; gemini?: GoogleGenAI } = {};
// About 20-25k tokens of instructions and reference text.
const systemPrompt = (nonce: string) =>
[
`Session ${nonce}. You are a coding agent. Reply with only OK.`,
...Array.from(
{ length: 300 },
(_, i) => `Reference line ${i} [${nonce}]: alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima.`,
),
].join("\n");
// About 1.5k tokens of fake tool output with real entropy, like file reads or command logs.
const toolResult = (turn: number) =>
[
`Tool result for step ${turn}:`,
...Array.from({ length: 40 }, (_, i) => `src/module_${turn}/file_${i}.ts:${i * 7 + 3} ${randomUUID()} ok`),
].join("\n");
async function thread(provider: Provider, gapSec: number, out: string) {
const id = randomUUID().slice(0, 8);
const system = systemPrompt(id);
const users = ["Start the task."];
let expected = 0;
for (let turn = 0; turn < TURNS; turn++) {
if (turn > 0) users.push(toolResult(turn));
const started = Date.now();
const base = { provider, gapSec, thread: id, turn, expected };
try {
const usage = await call[provider](system, users);
expected = usage.prompt;
appendFileSync(out, JSON.stringify({ ...base, ...usage, ms: Date.now() - started } satisfies Row) + "\n");
} catch (e) {
const row: Row = { ...base, ms: Date.now() - started, error: String(e).slice(0, 300) };
appendFileSync(out, JSON.stringify(row) + "\n");
}
await sleep(gapSec * 1000);
}
}
async function run(providers: Provider[]) {
process.loadEnvFile(".env");
const out = `missrate-${providers.join("-")}.jsonl`;
await Promise.all(
providers.flatMap((p) =>
GAPS_SEC.flatMap((gap) => Array.from({ length: THREADS_PER_GAP }, () => thread(p, gap, out))),
),
);
console.log(`done, wrote ${out}`);
}
// A turn is a "full hit" when it reads at least 90% of the previous prompt, a "miss" when it reads nothing.
// Cost inflation compares the real bill to one where every turn reads the whole previous prompt.
function report(files: string[]) {
const rows = files.flatMap((f) =>
readFileSync(f, "utf8")
.trim()
.split("\n")
.map((l) => JSON.parse(l) as Row),
);
const cost = (p: Provider, prompt: number, read: number, write: number, output: number) => {
const r = PRICE[p];
return (read * r.read + write * r.write + (prompt - read - write) * r.input + output * r.output) / 1e6;
};
const groups = Map.groupBy(rows, (r) => `${r.provider}|${r.gapSec}`);
const table = [...groups].map(([key, group]) => {
const [provider, gap] = key.split("|");
const p = provider as Provider;
const scored = group.filter((r) => r.turn > 0 && !r.error && r.prompt !== undefined);
const ratio = (r: Row) => (r.read ?? 0) / r.expected;
const full = scored.filter((r) => ratio(r) >= 0.9).length;
const miss = scored.filter((r) => (r.read ?? 0) === 0).length;
const actual = scored.reduce((s, r) => s + cost(p, r.prompt!, r.read!, r.write!, r.output!), 0);
const ideal = scored.reduce((s, r) => {
const read = Math.min(r.expected, r.prompt!);
// Ideal write: Claude/GPT write the new tail; Gemini bills it as input (its write rate equals input).
return s + cost(p, r.prompt!, read, r.prompt! - read, r.output!);
}, 0);
return {
provider,
gapSec: Number(gap),
turns: scored.length,
errors: group.filter((r) => r.error).length,
"full hit %": +((100 * full) / scored.length).toFixed(1),
"partial %": +((100 * (scored.length - full - miss)) / scored.length).toFixed(1),
"miss %": +((100 * miss) / scored.length).toFixed(1),
"actual $": +actual.toFixed(3),
"perfect-cache $": +ideal.toFixed(3),
"cost x": +(actual / ideal).toFixed(2),
};
});
console.table(table.sort((a, b) => a.provider.localeCompare(b.provider) || a.gapSec - b.gapSec));
}
const [cmd, ...rest] = process.argv.slice(2);
if (cmd === "run") {
const providers = (rest[0] ?? "").split(",").filter((p): p is Provider => PROVIDERS.includes(p as Provider));
if (!providers.length) throw new Error(`usage: run ${PROVIDERS.join(",")}`);
await run(providers);
} else if (cmd === "report" && rest.length) {
report(rest);
} else {
throw new Error("usage: pnpm tsx missrate.ts run <providers> | report <files...>");
}package.json
{
"name": "cache-probe",
"private": true,
"type": "module",
"dependencies": {
"@anthropic-ai/sdk": "^0.135.0",
"@google/genai": "^2.30.0",
"openai": "^7.34.0"
},
"devDependencies": {
"@types/node": "^26.6.5",
"tsx": "^4.23.15",
"typescript": "^7.0.2"
}
}tsconfig.json
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["*.ts"]
}pnpm-workspace.yaml
allowBuilds:
'@google/genai': false
esbuild: true
protobufjs: false9. Things that could affect the result
- Region and network. Gemini ran from one network, and Claude and OpenAI from another. Cache routing can depend on where the request lands.
- Concurrency. 12 threads ran in parallel for each provider. Each thread has a unique prefix, so they do not share cache entries, but they can land on different backends.
- Sample size. 76 scored turns per provider and gap. 228 per provider.
- Synthetic text. The system prompt is repeated, low-entropy text. Real agent prompts vary more. The tool results use high-entropy UUIDs on purpose.
- No retries. Each turn is sent once. A failed request would show up as an error row and not be scored. There were none.