pi-context-guard-jev 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/CACHE.md ADDED
@@ -0,0 +1,281 @@
1
+ # Prompt-cache impact
2
+
3
+ **Summary.** An edit changes the context from the first distilled tool result onwards. The next prompt
4
+ therefore has a one-time partial cache miss: everything before the edited result can still be read from the
5
+ cache, and everything after it is written again. After that, each request carries the smaller, distilled
6
+ history. On Anthropic models, a long run can push the reusable cache entry out of reach. The extension adds
7
+ one cache breakpoint to prevent that (measured below). On OpenAI, caching is automatic. Pi's Codex transport
8
+ also loses its delta continuation once after an edit.
9
+
10
+ Pi facts were checked in the Pi 1.0.3 source (`@earendil-works/pi-ai/dist/api/anthropic-messages.js`,
11
+ `openai-codex-responses.js`). Provider facts come from the primary docs, read on 2026-10-06 (see
12
+ [Sources](#sources)). Numbers marked *measured* come from the live tests in [TESTING.md](TESTING.md). Single
13
+ runs, not averages.
14
+
15
+ ## Anthropic
16
+
17
+ ### What Anthropic does (documented)
18
+
19
+ - At most **4** cache breakpoints per request.
20
+ - For each breakpoint, the lookup goes back **at most 20 positions** to find an earlier cache entry.
21
+ Consecutive `tool_use` blocks count as one position, and so do consecutive `tool_result` blocks.
22
+ - Entries with a longer TTL must come before entries with a shorter TTL.
23
+ - Reading an entry refreshes its TTL. The docs do **not** say that reading a longer prefix refreshes a
24
+ shorter entry that was written separately; measured below, it does
25
+ ([Mid-run checkpoints](#mid-run-checkpoints)).
26
+ - Pricing relative to base input: 5-minute write 1.25×, 1-hour write 2×, read 0.1× (0.05× on Opus 5.5).
27
+ - The minimum cacheable prompt is 512 tokens on Sonnet/Opus 5.5.
28
+
29
+ ### Where Pi puts breakpoints
30
+
31
+ | Breakpoint | Where |
32
+ |---|---|
33
+ | tools | the last tool definition |
34
+ | system | the system prompt block |
35
+ | messages | rolling: the last user or system message only |
36
+
37
+ With an API key that makes 3 breakpoints. With Anthropic OAuth, Pi also adds a `"You are Claude Code…"`
38
+ identity block as `system[0]`, with its own breakpoint, which makes 4. With `PI_CACHE_RETENTION=long`, all
39
+ breakpoints get `ttl: "1h"` where the model supports it.
40
+
41
+ The `pi-claude-auth` extension, used for the Claude subscription tests, rewrites the payload differently.
42
+ It moves the system prompt into the first user message and adds a billing block that is not cached. The
43
+ measured requests never had more than 4 breakpoints.
44
+
45
+ ### The problem
46
+
47
+ The first request of a run has the question as its last message, so it writes a cache entry ending at the
48
+ question. After the run is distilled, the next prompt's prefix matches up to the first edited result, which
49
+ includes the question. But that entry was written by the run's first request, and the rolling breakpoint is
50
+ now at the end of the conversation. If the run had more than about 20 positions between the question and the
51
+ end (a dozen sequential tool calls is enough), the lookback never reaches the question's entry. Only tools and
52
+ system are read, and the whole history is written again.
53
+
54
+ ### The fix, as implemented (`src/cache-pin.ts`)
55
+
56
+ In `before_provider_request`, for models with `api === "anthropic-messages"` (when `enabled` and
57
+ `pinAnthropicCache` are on):
58
+
59
+ 1. Find Pi's rolling `cache_control` (the last one in `messages`). Without one, caching is off: do nothing.
60
+ 2. Skip trailing mid-conversation `system` messages (effort or tool changes). Then find the most recent user
61
+ message with text that is **not** the last message. Tool-result-only user messages do not count. This is
62
+ the run's question during a run, and the previous question on the first request of a new prompt.
63
+ 3. Put a `cache_control` on that message's last text block (string content is converted to one text block
64
+ first). If it already has one, leave it. The TTL must keep Anthropic's order (every breakpoint before it
65
+ at least as long, every one after it no longer): the rolling breakpoint's TTL is used when that holds,
66
+ otherwise the other TTL; if neither fits, nothing is pinned (`ttl-conflict`). Pi itself always uses one
67
+ TTL for every breakpoint, so in practice the pin copies it.
68
+ 4. If that makes more than 4 breakpoints, remove the breakpoint from `system[0]` (the OAuth identity block).
69
+ The `system[1]` breakpoint covers that prefix too.
70
+ 5. If it is still more than 4, undo everything and leave the payload as Pi built it.
71
+
72
+ During a run, every request reads the question's entry directly, so the entry stays refreshed and the lookback
73
+ limit does not matter. Moving a breakpoint does not change the cached content, so existing entries stay
74
+ valid. The pin is added on every Anthropic request, whether or not anything was distilled.
75
+
76
+ ### Measured
77
+
78
+ Claude Sonnet 5.5 via `pi-claude-auth`, Pi 1.0.3:
79
+
80
+ | Case | First request after the distilled run |
81
+ |---|---|
82
+ | Short run (badge scenario), with pin | cacheRead 5,777, cacheWrite 2,036 |
83
+ | Long run (11 sequential tool calls, 36,113 → ~3.3k chars), **with pin** | cacheRead **5,810**, cacheWrite **8,913** |
84
+ | Same long run, **without pin** (`pinAnthropicCache: false`) | cacheRead **1,693** (tools + system only), cacheWrite **13,068** |
85
+
86
+ Without the pin, the 20-position lookback missed the question's entry. With the pin, it was hit. No request
87
+ had more than 4 breakpoints.
88
+
89
+ ## OpenAI
90
+
91
+ ### What OpenAI does (documented)
92
+
93
+ - Caching is automatic, with no breakpoints. The whole rendered prefix must match. A change prevents reuse
94
+ from that point on.
95
+ - GPT-5.6 and later: 1,024-token minimum, about 30 minutes retention, reads cost about 0.1×.
96
+
97
+ ### Pi's Codex transport
98
+
99
+ Over WebSocket, Pi's `openai-codex` transport sends only the new input items with `previous_response_id`, as
100
+ long as the input only grows. After an edit, the input is no longer an extension of the previous one, so
101
+ Pi sends the full input. The server-side continuation is lost for that request, and in every measurement the
102
+ cache read was at most the 2,560-token stable prefix, **wherever the first edited item was** (also when it
103
+ was 18 turns into a run; see [Mid-run checkpoints](#mid-run-checkpoints)). This happens once, on the first
104
+ request after the edit.
105
+
106
+ ### Measured
107
+
108
+ GPT-6 Luna (openai-codex), badge scenario, turn 2 first request:
109
+
110
+ | Case | cacheRead |
111
+ |---|---|
112
+ | Without the guard (control) | 13,824 |
113
+ | With the guard | 2,560 (only the stable prefix before the first edited item) |
114
+ | With the guard, one earlier run | 0 |
115
+
116
+ The pin does not apply to OpenAI.
117
+
118
+ ## Mid-run checkpoints
119
+
120
+ A checkpoint edits outputs from earlier in the *current* run, so the next request cannot read the run from
121
+ cache beyond the first edited output. Two questions decide the design: does a cache entry at the
122
+ unchanged prefix still exist when the checkpoint comes, possibly many minutes after it was written; and
123
+ how much does the one-time rewrite cost against the per-request saving.
124
+
125
+ ### Do cache entries on the read path stay alive? (measured)
126
+
127
+ `node test/e2e/ttl-probe.mjs` (Claude Sonnet 5.5, `pi-claude-auth`). The model `cat`s a ~31k-token file
128
+ (entry A ends at that result, written once as Pi's rolling breakpoint), then makes further requests; at the
129
+ end, the request after A is changed so that only A can be read.
130
+
131
+ | Arm | Between writing A and the test request | Test request |
132
+ |---|---|---|
133
+ | refresh | 8 requests in 7.6 min, each marking A (5m) and reading the longer prefix | cacheRead **31,001** (A alive) |
134
+ | control | the same 8 requests, A **not** marked again | cacheRead **30,999** (A alive) |
135
+ | ttl1h | as refresh, A at `ttl: "1h"` | cacheRead 31,001; but switching the earlier breakpoints to 1h was itself a full miss (cacheRead 0 → 31,001 written at the 1h price) |
136
+ | idle (5m) | **no request for 362 s** (Pi's cache warming stopped) | cacheRead **1,431**, cacheWrite 29,633 (A expired) |
137
+ | idle1h | no request for 365 s, A at 1h | cacheRead 30,977 (A alive) |
138
+
139
+ So the 5-minute TTL is real (idle), but **every request that reads a longer prefix keeps the shorter entries
140
+ on its path alive** (control). During an active run (requests seconds apart, and Pi's own cache warming,
141
+ setting `cacheWarming: "streaming"` by default, during long tool calls), every turn boundary that was once
142
+ the rolling breakpoint stays readable. A 1-hour TTL is not needed and would cost a full rewrite at 2× once.
143
+ The extension therefore keeps Pi's TTL (5m by default). Pi's warming requests go through
144
+ `before_provider_request` (seen in the capture), so they carry the extension's breakpoints too.
145
+
146
+ What the lookback limit still breaks: the request after an edit has its rolling breakpoint at the end; the
147
+ first edited output is usually many more than 20 positions back. So every Anthropic request keeps the
148
+ previous-question pin (the floor: system prompt and question; with `pi-claude-auth`, the relocated system
149
+ prompt sits in that same user message), and the first request after any edits (checkpoint, run end, or
150
+ another extension; the `context_edit` entries after the last answered assistant message) gets two more
151
+ breakpoints (`cacheAnchors` in `src/checkpoint.ts`):
152
+
153
+ - a **read point**, only at a cache entry this process saw itself write: a `tool_result` block that carried
154
+ a breakpoint in a request it sent, to the same model, still on the branch, and with no `context_edit` at
155
+ or before it and no compaction or branch summary since, written or read through within the last 5
156
+ minutes. A response refreshes an entry when its `cacheRead` shows the request read clearly past it: at
157
+ least the run's baseline (the full input of the run's first request) plus the characters from the
158
+ question to the entry divided by 1.5, which overestimates the tokens (code ≈ 2.3 characters per token).
159
+ This follows the control arm above. The latest trusted entry before the first edited entry is used. The
160
+ log lives in memory and is empty after a reload, so a read point is never inferred;
161
+ - a **write anchor** at the end of the first edited tool-result batch, so the edited prefix gets an entry
162
+ that the next checkpoint can read. It enters the log only if it was really in the request.
163
+
164
+ Budget: Pi uses 3 (API key: tools, system, rolling) or 4 breakpoints (OAuth: plus the identity
165
+ `system[0]`; `pi-claude-auth`: tools, `system[1]`, question, rolling). To make room, the identity breakpoint
166
+ (covered by `system[1]`) and then the tools breakpoint (covered by the system breakpoint) are dropped. If
167
+ that is not enough, the write anchor goes first, then the read point; the question stays. Marks use the
168
+ rolling breakpoint's TTL and are skipped if they would break the TTL order. Only breakpoints actually left in
169
+ the payload are logged, so an anchor dropped for the budget is never treated as written.
170
+
171
+ ### The first request after a checkpoint (measured)
172
+
173
+ Claude Sonnet 5.5, long scenario with settings that force checkpoints into a short run
174
+ (`midRunBreakEven: false`, `midRunMinAgeTurns: 2`, `midRunBatchChars: 20000`), with `pi-claude-auth` and
175
+ `test/e2e/plain-test-runs.ts`:
176
+
177
+ | Configuration | Checkpoint | First edited output | Next request |
178
+ |---|---|---|---|
179
+ | default anchors | 1st (after request 3) | the run's first output | cacheRead **5,832** (system prompt + question, via the question pin), cacheWrite 10,867 |
180
+ | default anchors | 2nd (after request 8) | 3 turns into the run | cacheRead **16,699** (read at the entry written after the 1st checkpoint), cacheWrite 22,778 |
181
+ | default anchors | 3rd (after request 10) | 5 turns into the run | cacheRead **16,699**, cacheWrite 20,008 |
182
+ | `pinAnthropicCache: false` | 1st | the run's first output | cacheRead 5,832, cacheWrite 29,570 |
183
+ | `pinAnthropicCache: false` | 2nd, 3rd, 4th | 2–6 turns into the run | cacheRead **5,832** each time, cacheWrite 41,162 / 32,183 / 33,872 |
184
+
185
+ Without the guard's breakpoints, every request after a checkpoint read only the system-and-question entry
186
+ and rewrote the whole run. With them, the later checkpoints read up to the entry written by the first
187
+ request after the first checkpoint. The first checkpoint of a run usually edits the run's first output, so
188
+ only the system prompt and question can be read then. When a read point is placed, the write anchor often
189
+ does not fit the budget (question, read point, `system[1]` and rolling take all four slots).
190
+
191
+ GPT-6 Luna (openai-codex), long scenario, 8 checkpoints in 5 runs (default batch and age settings), at
192
+ requests 13–30: the first request after every checkpoint read **2,560 or 0** tokens from cache (17k–29k
193
+ uncached), against 35k–72k read on the request before it. That is the transport, not the position of the
194
+ edit.
195
+
196
+ ### Economics and the break-even rule
197
+
198
+ At the prices in these runs (Sonnet 5.5: input $2/M, 5m write $2.5/M, read $0.2/M; GPT-6 Luna: input
199
+ $0.10/M, cached $0.01/M), a checkpoint costs once, and saves on every later request:
200
+
201
+ - OpenAI Codex: the whole context is sent uncached once: extra ≈ 0.9 × context × input price.
202
+ - Anthropic: everything after the read point is written again: extra ≈ (1.25 − 0.1) × rewritten tokens.
203
+ - Saving per later request: the removed tokens × the cache-read price. Jev removed 60–90% of checkpoint
204
+ outputs in the runs.
205
+
206
+ Measured (GPT, a run with two checkpoints): checkpoint 1 removed 57k characters; the next request was 17,144 tokens uncached
207
+ (≈ $0.0015 extra) and every later request carried about 19k tokens less (≈ $0.0002 saved each): break-even
208
+ after about 8 requests, and the run went on for 12 more. Checkpoint 2 needed about 9 requests and the run
209
+ ended 2 requests later. On Anthropic the ratio of write to read price is larger (12.5:1 vs 10:1).
210
+
211
+ So checkpoints pay for themselves only when the run goes on for roughly 8–30 more requests. The
212
+ **break-even rule** (`midRunBreakEven`, on) skips a checkpoint unless
213
+ `pending chars × turns so far ≥ factor × rewrite chars` (OpenAI: factor 13, rewrite = whole context;
214
+ Anthropic: factor 16, rewrite = context after the read point the next request would get, or after the
215
+ question when no logged entry is trusted, or the whole context with pinning off; other providers: factor
216
+ 16, rewrite = context from the first eligible output), assuming the run goes on
217
+ about as long as it has run so far. In the measured Claude runs (8–14 requests) it never allowed a
218
+ checkpoint; in the GPT runs (26–37 requests) it allowed one or two. Turn it off to checkpoint purely for
219
+ context room.
220
+
221
+ ## Old exchanges
222
+
223
+ An omitted exchange comes before the current question, so the first request after it cannot read the
224
+ question's cache entry: that prefix contains the edit. `cacheAnchors` handles this like any other edit: the
225
+ read point is the latest trusted logged entry before the earliest edited entry, and the question pin is
226
+ still placed (it writes a new entry for the requests that follow). Without such an entry, only the tools and
227
+ system prompt are read. The break-even estimate (`anthropicRewriteChars`) follows the same rule: with an edit
228
+ at or before the question and no trusted read point, the rewrite is the whole context.
229
+
230
+ Measured (`topics` scenario, Claude Sonnet 5.5, first request of prompt 5 after the first exchange was
231
+ omitted at the end of prompt 4 with `exchangeBreakEven: false`): cacheRead 1,760 (tools and system),
232
+ cacheWrite 6,280, against cacheRead 12,376 and cacheWrite 438 with the guard off. With the default gate the
233
+ omission was deferred and prompt 5 read 11,049 tokens from cache. On OpenAI Codex any edit makes the next
234
+ request read at most the 2,560-token stable prefix (see above).
235
+
236
+ So each omission costs one rewrite of everything after the omitted exchange on the next request (Pi's
237
+ default `cacheWarming: "streaming"` keeps the cache warm between prompts that come within the TTL) and saves
238
+ the exchange on every request after it. At run end, omissions therefore go through a break-even gate
239
+ (`exchangeBreakEven`): their marginal rewrite (beyond what the pass's other edits rewrite anyway) must pay
240
+ off over as many later requests as the session has had so far, and they must save at least
241
+ `exchangeMinSavingChars` (8,000 characters); otherwise they wait and accumulate. On OpenAI Codex a pass that
242
+ edits anything else already costs the whole context, so exchange omissions in that pass are free. Mid-run, exchanges join the checkpoint batch only when the batch with them pays off; their early
243
+ position makes the rewrite most of the context, so with factor 16 the batch must reach about
244
+ 1/16 of the context per turn so far (for example, old exchanges that are half of the context pay off from
245
+ about turn 32).
246
+
247
+ ## Cost model
248
+
249
+ All units are input-token equivalents, with a 5-minute TTL (write 1.25×, read 0.1×).
250
+
251
+ **Measured, long Claude run, first request after the run:**
252
+
253
+ | | Read | Write | Cost |
254
+ |---|---|---|---|
255
+ | With pin | 5,810 × 0.1 | 8,913 × 1.25 | **≈ 11.7k** |
256
+ | Without pin | 1,693 × 0.1 | 13,068 × 1.25 | **≈ 16.5k** |
257
+
258
+ The prompt was the same size in both cases, about 14.7k tokens. The pin moved about 4.1k tokens from write to
259
+ read and made that request about 29% cheaper.
260
+
261
+ **Assumed sizes, to show the trade-off.** System prompt and tools 15k tokens, earlier history 30k, the run's
262
+ tool output 60k distilled to 5k:
263
+
264
+ | Next prompt | Cost |
265
+ |---|---|
266
+ | Not distilled (full cache hit) | read 105k × 0.1 → **≈ 10.5k** |
267
+ | Distilled, question entry hit (pin) | read 45k × 0.1 + write 5k × 1.25 → **≈ 10.8k**; every later request then saves about 5.5k |
268
+ | Distilled, only tools and system hit (no pin, long run) | read 15k × 0.1 + write 35k × 1.25 → **≈ 45k** one time; pays back after about 6 requests |
269
+
270
+ Notes:
271
+
272
+ - If the user waits longer than the cache TTL before the next prompt, everything is cold anyway, and the
273
+ edit costs nothing extra.
274
+ - Jev requests do not touch the main model's cache. They cost about $0.0008 per qualifying run (measured).
275
+ - On OpenAI, the one-time cost is the difference between 13,824 and 2,560 cached tokens on one request
276
+ (measured above), against a smaller context on every later request.
277
+
278
+ ## Sources
279
+
280
+ - Anthropic, prompt caching: <https://docs.claude.com/en/docs/build-with-claude/prompt-caching>
281
+ - OpenAI, prompt caching: <https://developers.openai.com/api/docs/guides/prompt-caching>