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.

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 turns5s, 30s, 120s. All well inside every provider's cache lifetime (Claude 5m, OpenAI 30m, Gemini unknown).
Threads per gap4
Turns per thread20 (1 prime + 19 scored)
ConcurrencyAll 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.
Total3 providers × 12 threads × 20 requests = 720 requests. 684 scored turns. 0 errors.
RunsClaude 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 configOther paramsUsage 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.
OpenAI
openai 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}
expectedThe prompt value of the same thread's previous request. This is the most a perfect cache can read on this turn.
promptTotal input tokens for this request, cached or not.
readInput tokens billed at the cached rate.
writeInput tokens billed at the cache-write rate (Claude and OpenAI only).
msWall 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

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 tokensInputCache writeCache readOutput
Gemini 4 Argon (through 2026-11-30)2.00none0.1010.00
Claude Sonnet 5.5 (5m TTL)2.002.500.1010.00
GPT-6.1 Sol (short context)2.002.500.1010.00

The three providers have the same input, read and output prices. So the cost multiple compares cache behavior only, not price.

6. Results

ModelGapFull hitPartialMissCost multiple
Claude Sonnet 5.55s / 30s / 120s100%0%0%1.00x
GPT-6.1 Sol5s / 30s / 120s100%0%0%1.00x
Gemini 4 Argon5s76.3%23.7%0%1.20x
Gemini 4 Argon30s69.7%28.9%1.3%1.34x
Gemini 4 Argon120s73.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.
ThreadSecond request atResult
no refresh2mmiss
no refresh6.5mmiss
no refresh15mhit (25.1k of 27.2k read)
no refresh35mmiss
no refresh65mmiss
refresh every 4m4m, 8m, 12mhit, 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: false

9. Things that could affect the result