auto-model-router 0.2.7 → 0.2.9

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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.2.7",
10
+ "version": "0.2.9",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.2.7",
17
+ "version": "0.2.9",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
@@ -55,10 +55,12 @@ These are hard; the design is shaped by them.
55
55
  result without its call, nor an assistant tool_call without its result, nor
56
56
  reorder the pair.
57
57
  - **Never touch:** system/developer messages (they carry the system prompt and the
58
- agentdox-injected block), the **volatile tail** (newest user-authored run, or the
59
- trailing tool-result run of the current loop — the same window
60
- `features.ts`/`cache-control.ts` treat as fresh), and image parts (they are
61
- capability-relevant; see Edge cases).
58
+ agentdox-injected block), the **protected tail** (newest user-authored run, or the
59
+ trailing tool-result run of the current loop — the same window `features.ts`
60
+ treats as fresh), and image parts (they are capability-relevant; see Edge cases).
61
+ Note that `cache-control.ts` deliberately does **not** treat that tail as fresh:
62
+ a conversation is append-only, so the tail is exactly what the *next* turn will
63
+ read back out of the cache, and it gets a breakpoint (see Prompt cache below).
62
64
  - **Determinism.** `auto-model-router explain` replays a past decision offline.
63
65
  Phase 1 is a pure function of `(messages, target budget, model window)` →
64
66
  replayable. Phase 2 breaks this unless the summary is **pinned and persisted**
@@ -67,10 +69,19 @@ These are hard; the design is shaped by them.
67
69
  The governing law is already stated for the agentdox bridge
68
70
  (`src/context/bridge.ts`): *the same bytes are re-injected verbatim and the cache
69
71
  survives; a refresh rides on a cache miss that was happening anyway.* Compaction
70
- obeys it Phase 1 rules are **stable** (identical input → identical elision each
71
- turn), so a message elided last turn is elided byte-identically this turn and the
72
- prefix does not churn; Phase 2 summaries are pinned per conversation and refreshed
73
- only when the cache is already cold.
72
+ obeys it in two ways: rules are **stable** (identical input → identical elision
73
+ each turn) *and* the truncation set is **monotone** (rule 3 below: oldest-first,
74
+ so it only extends forward and never rewrites an already-cached prefix). Phase 2
75
+ summaries are pinned per conversation and refreshed only when the cache is
76
+ already cold.
77
+ - **Breakpoints must be reproducible.** A `cache_control` breakpoint only pays off
78
+ when a later turn asks to read the exact same byte prefix, so
79
+ `planCacheBreakpoints` places them where the next turn will place them again:
80
+ the system prefix, byte **milestones** at fixed multiples of
81
+ `cache.milestoneTokens` (default 20k) measured over *post-compaction* sizes, and
82
+ the **tail**. A boundary at "roughly 75% of history" — the pre-v0.2.9 behaviour —
83
+ drifts by one index per appended message, so every turn wrote a fresh cache entry
84
+ and read none of them.
74
85
 
75
86
  ## Precedent to reuse
76
87
 
@@ -184,8 +195,19 @@ A pure function `compact(messages, target, protectFrom) → { messages, saved, n
184
195
  fetch is authoritative.
185
196
  3. **Truncate large stale results** (`maxToolResultBytes`): remaining tool results
186
197
  outside the protected window whose content exceeds `maxToolResultBytes` are
187
- reduced to `keepHeadBytes` + breadcrumb + `keepTailBytes`. Applied to the
188
- **largest/oldest first** until under `target` or exhausted.
198
+ reduced to `keepHeadBytes` + breadcrumb + `keepTailBytes`. Applied **oldest
199
+ first** until under `target` or exhausted.
200
+
201
+ Oldest-first is a **prompt-cache requirement**, not an aesthetic. The selected
202
+ set is then always an index-ordered prefix of the eligible results, so across
203
+ turns it only ever extends forward: an existing edit keeps its index and keep
204
+ bytes, and new edits land after every previous one — leaving the cached prefix
205
+ byte-identical. Selecting largest-first instead inserts new edits at arbitrary
206
+ early indices on later turns, rewriting history the upstream had already
207
+ cached. Measured under largest-first: 61% cache read (bimodal 44%/90%, with
208
+ `cachedTokens` pinned at the system prefix on half the requests) against
209
+ 76–82% on comparable pre-compaction sessions. `test/compaction.test.ts`
210
+ ("the edit set only ever extends forward") pins the property.
189
211
  4. **Age-drop assistant reasoning:** reasoning fields on assistant messages outside
190
212
  the protected window are dropped. (Largely redundant with the model-driven
191
213
  `stripAssistantReasoning`; matters only for reasoning-replay authors.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -144,6 +144,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
144
144
  // Anthropic allows 4 breakpoints; OpenRouter translates for other vendors.
145
145
  maxBreakpoints: 4,
146
146
  minPromptTokens: 2_048,
147
+ // One slot for the system prefix, one for the tail, leaving two milestones
148
+ // live at 4 breakpoints. 20k spacing keeps them coarse enough that a
149
+ // milestone survives many turns of appended tool output.
150
+ milestoneTokens: 20_000,
147
151
  },
148
152
  context: {
149
153
  // Off until an agentdox URL + token are configured. Enabling this changes
@@ -127,6 +127,7 @@ const cache = z.strictObject({
127
127
  injectBreakpoints: z.boolean().optional(),
128
128
  maxBreakpoints: z.number().int().positive().optional(),
129
129
  minPromptTokens: z.number().int().nonnegative().optional(),
130
+ milestoneTokens: z.number().int().positive().optional(),
130
131
  });
131
132
 
132
133
  const context = z.strictObject({
@@ -286,6 +286,13 @@ export interface CacheConfig {
286
286
  maxBreakpoints: number;
287
287
  /** Skip injection below this prompt-token estimate; small prompts cannot cache. */
288
288
  minPromptTokens: number;
289
+ /**
290
+ * Spacing of the stable mid-history breakpoints, in prompt tokens. Boundaries
291
+ * land at fixed multiples of this size, so the same prefix recurs turn after
292
+ * turn and each turn reads what the last one wrote. Smaller = finer recovery
293
+ * after a history rewrite, at the cost of more breakpoint slots.
294
+ */
295
+ milestoneTokens: number;
289
296
  }
290
297
 
291
298
  export interface BudgetConfig {
@@ -117,6 +117,13 @@ const TRUST_SELECT = `COUNT(*) AS attempts,
117
117
  * body streams once it starts. Errored/aborted and non-streaming rows (null
118
118
  * ttft) are excluded; throughput additionally requires a positive completion
119
119
  * count and elapsed time.
120
+ *
121
+ * Aggregated over a RECENT WINDOW (LATENCY_WINDOW_ROWS newest rows per slug),
122
+ * NOT all history: a model that degrades — e.g. deepseek-v4-flash collapsing
123
+ * from ~18 tok/s to ~7 — must move its score fast, or the penalty is drowned by
124
+ * hundreds of historical good rows and never demotes it (observed live: it kept
125
+ * 100% of coding at ~53s/turn despite latencyWeight=0.75). Trust (reliability)
126
+ * stays all-time; latency (volatile) is recency-weighted.
120
127
  */
121
128
  const LATENCY_SELECT = `COUNT(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL THEN 1 END) AS samples,
122
129
  AVG(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL THEN ttft_ms END) AS ttft_ms,
@@ -127,6 +134,14 @@ const LATENCY_SELECT = `COUNT(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND
127
134
  AND json_extract(usage, '$.completionTokens') > 0
128
135
  THEN latency_ms - ttft_ms END) AS elapsed_ms_sum`;
129
136
 
137
+ /**
138
+ * Recent-rows window for latency stats: recent enough to react to a degrading
139
+ * model, wide enough to stay stable for a busy one. Rows are taken newest-first
140
+ * and then filtered by LATENCY_SELECT, so recent aborts naturally shrink the
141
+ * qualifying sample count (and can drop a model below latencyMinSamples).
142
+ */
143
+ export const LATENCY_WINDOW_ROWS = 100;
144
+
130
145
  /**
131
146
  * Recovers the `UpstreamErrorKind` from the text turn.ts stored.
132
147
  *
@@ -231,8 +246,12 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
231
246
  const trustStmt = db.query(`SELECT ${TRUST_SELECT} FROM ledger WHERE slug = ?`);
232
247
  const trustHarnessStmt = db.query(`SELECT ${TRUST_SELECT} FROM ledger WHERE slug = ? AND harness_id = ?`);
233
248
  const allTrustStmt = db.query(`SELECT slug, ${TRUST_SELECT} FROM ledger GROUP BY slug`);
234
- const latencyStmt = db.query(`SELECT ${LATENCY_SELECT} FROM ledger WHERE slug = ?`);
235
- const latencyHarnessStmt = db.query(`SELECT ${LATENCY_SELECT} FROM ledger WHERE slug = ? AND harness_id = ?`);
249
+ const latencyStmt = db.query(
250
+ `SELECT ${LATENCY_SELECT} FROM (SELECT * FROM ledger WHERE slug = ? ORDER BY created_at_ms DESC LIMIT ${LATENCY_WINDOW_ROWS})`,
251
+ );
252
+ const latencyHarnessStmt = db.query(
253
+ `SELECT ${LATENCY_SELECT} FROM (SELECT * FROM ledger WHERE slug = ? AND harness_id = ? ORDER BY created_at_ms DESC LIMIT ${LATENCY_WINDOW_ROWS})`,
254
+ );
236
255
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
237
256
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
238
257
  const cacheMetaStmt = db.query("SELECT fetched_at_ms FROM catalog_cache WHERE id = 1");
@@ -2,15 +2,39 @@
2
2
  * Cache-breakpoint placement (Anthropic-style `cache_control: ephemeral`;
3
3
  * OpenRouter translates these to OpenAI/Google cache primitives, so one
4
4
  * mechanism covers every target). Returns message indices to mark.
5
+ *
6
+ * Placement is chosen for REUSE ACROSS TURNS, not for a single request. A
7
+ * breakpoint only pays off when a LATER turn asks to read the exact same byte
8
+ * prefix, so every boundary here must be one the next turn will reproduce:
9
+ *
10
+ * - the system prefix, which never moves;
11
+ * - byte MILESTONES at fixed multiples of `cache.milestoneTokens`, which land
12
+ * on the same message every turn for as long as the prefix is unchanged
13
+ * (a boundary at "roughly 75% of history" drifts with every appended
14
+ * message, so it writes a fresh entry each turn and never reads one);
15
+ * - the tail, so the whole of this turn's prompt becomes the entry the NEXT
16
+ * turn reads. A conversation is append-only: nothing already in the array
17
+ * can change later, so there is no "volatile tail" to keep out of the
18
+ * cache. Walking back over an agent loop's trailing tool run instead left
19
+ * everything the loop had accumulated permanently uncached.
20
+ *
21
+ * Milestones are measured over POST-compaction sizes, so the boundaries match
22
+ * the bytes actually dispatched.
5
23
  */
6
24
 
7
25
  import type { CatalogModel } from "../catalog/types.ts";
8
26
  import type { RouterConfig } from "../config/types.ts";
9
27
  import { priceAt } from "../cost/forecast.ts";
10
28
  import { estimateTokens } from "../tokens/estimate.ts";
11
- import type { NormMessage, NormRequest } from "../wire/types.ts";
29
+ import type { CompactionEdit, NormRequest } from "../wire/types.ts";
30
+ import { compactedBytes } from "./compaction.ts";
12
31
 
13
- export function planCacheBreakpoints(req: NormRequest, model: CatalogModel, cfg: RouterConfig): number[] {
32
+ export function planCacheBreakpoints(
33
+ req: NormRequest,
34
+ model: CatalogModel,
35
+ cfg: RouterConfig,
36
+ compactionPlan: readonly CompactionEdit[] = [],
37
+ ): number[] {
14
38
  if (!cfg.cache.injectBreakpoints) return [];
15
39
  const promptTokens = estimateTokens(req.promptBytes, model.tokenizer, null);
16
40
  // Small prompts cannot amortize cache-write cost.
@@ -19,6 +43,8 @@ export function planCacheBreakpoints(req: NormRequest, model: CatalogModel, cfg:
19
43
  if (priceAt(model, Math.max(1, promptTokens)).cacheRead === undefined) return [];
20
44
 
21
45
  const messages = req.messages;
46
+ if (messages.length === 0) return [];
47
+
22
48
  const picks: number[] = [];
23
49
 
24
50
  // 1. End of the last system message: the most stable, usually largest prefix.
@@ -30,28 +56,33 @@ export function planCacheBreakpoints(req: NormRequest, model: CatalogModel, cfg:
30
56
  }
31
57
  }
32
58
 
33
- // 2. End of the last message before the volatile tail the newest
34
- // user-authored content, or the trailing tool-result run of an agent
35
- // loop. Caches everything the model has already seen, leaving only the
36
- // fresh tail uncached.
37
- const tail = messages[messages.length - 1];
38
- if (tail !== undefined) {
39
- let pred: (m: NormMessage) => boolean;
40
- if (tail.role === "user") pred = (m) => m.role === "user";
41
- else if (tail.role === "tool") pred = (m) => m.role === "tool" || (m.role === "assistant" && m.toolCalls.length > 0);
42
- // An assistant tail has no fresh human content; the whole history is prefix.
43
- else pred = () => false;
44
- let i = messages.length - 1;
45
- while (i >= 0) {
46
- const m = messages[i];
47
- if (m === undefined || !pred(m)) break;
48
- i--;
59
+ // 2. The tail: everything this turn sent, cached for the next turn to read.
60
+ picks.push(messages.length - 1);
61
+
62
+ // 3. Stable byte milestones through the history, newest first so the slots
63
+ // left over by 1 and 2 cover the largest readable prefixes.
64
+ const editByIndex = new Map<number, CompactionEdit>();
65
+ for (const e of compactionPlan) editByIndex.set(e.index, e);
66
+ const bytesPerToken = req.promptBytes / Math.max(1, promptTokens);
67
+ const milestoneBytes = Math.max(1, Math.floor(cfg.cache.milestoneTokens * bytesPerToken));
68
+ const milestones: number[] = [];
69
+ let cumulative = 0;
70
+ let nextMilestone = milestoneBytes;
71
+ for (let i = 0; i < messages.length - 1; i++) {
72
+ const m = messages[i];
73
+ if (m === undefined) continue;
74
+ cumulative += compactedBytes(m.textBytes, editByIndex.get(i));
75
+ if (cumulative >= nextMilestone) {
76
+ milestones.push(i);
77
+ // Skip past every milestone this message already crossed, so one huge
78
+ // message cannot claim a run of adjacent boundaries.
79
+ while (cumulative >= nextMilestone) nextMilestone += milestoneBytes;
49
80
  }
50
- if (i >= 0) picks.push(i);
51
81
  }
52
-
53
- // 3. Stable prefix boundary at roughly 75% of history.
54
- if (messages.length > 1) picks.push(Math.floor((messages.length - 1) * 0.75));
82
+ for (let i = milestones.length - 1; i >= 0; i--) {
83
+ const idx = milestones[i];
84
+ if (idx !== undefined) picks.push(idx);
85
+ }
55
86
 
56
87
  // Dedupe preserving priority order, cap, return ascending indices.
57
88
  const seen = new Set<number>();
@@ -26,6 +26,17 @@ const EMPTY: CompactionResult = { edits: [], savedBytes: 0 };
26
26
  /** Approximate byte cost of an elision breadcrumb; savings are net of it. */
27
27
  const BREADCRUMB_BYTES = 120;
28
28
 
29
+ /**
30
+ * Byte size a message ends up with once `edit` is applied — the size that
31
+ * actually reaches the upstream. Cache-breakpoint placement walks these rather
32
+ * than the raw `textBytes`, so its boundaries match the dispatched bytes.
33
+ */
34
+ export function compactedBytes(originalBytes: number, edit: CompactionEdit | undefined): number {
35
+ if (edit === undefined) return originalBytes;
36
+ const kept = edit.mode === "stub" ? BREADCRUMB_BYTES : edit.keepHead + edit.keepTail + BREADCRUMB_BYTES;
37
+ return Math.min(originalBytes, kept);
38
+ }
39
+
29
40
  /**
30
41
  * First string value in a tool call's argument JSON — a schema-agnostic proxy
31
42
  * for the resource a call operates on (a `path`, `id`, `query`, ...). Used to
@@ -73,9 +84,18 @@ interface ToolResult {
73
84
  /**
74
85
  * Plans compaction for a turn's messages toward `targetBytes` of total prompt.
75
86
  * Duplicate and superseded elisions (pure stale-data wins) are always applied;
76
- * large-result truncation (more lossy) runs largest-first only until the target
87
+ * large-result truncation (more lossy) runs OLDEST-first only until the target
77
88
  * is met. `promptBytes` is the whole prompt (messages + system + tool schemas),
78
89
  * so the target is compared against the real dispatched size.
90
+ *
91
+ * Oldest-first is a prompt-cache requirement, not a preference. The truncated
92
+ * set is then always an index-ordered PREFIX of the eligible results, so as a
93
+ * conversation grows and the target tightens the set only ever EXTENDS FORWARD:
94
+ * an edit already made keeps the same index and the same keep bytes, and a new
95
+ * edit lands after every previous one. Selecting largest-first instead inserts
96
+ * fresh edits at arbitrarily early indices on later turns, rewriting history
97
+ * the upstream had already cached and collapsing cache reads to the system
98
+ * prefix (measured: 61% cache read, bimodal, vs 76-82% before compaction).
79
99
  */
80
100
  export function planCompaction(
81
101
  messages: readonly NormMessage[],
@@ -145,11 +165,11 @@ export function planCompaction(
145
165
  }
146
166
  }
147
167
 
148
- // Rule 3: truncate large stale results, largest first, until under target.
168
+ // Rule 3: truncate large stale results, OLDEST first, until under target.
169
+ // `tools` is already in message order, so the filter alone yields that order
170
+ // and the selected set stays an extend-forward prefix across turns.
149
171
  const keepBudget = cfg.keepHeadBytes + cfg.keepTailBytes + BREADCRUMB_BYTES;
150
- const truncatable = tools
151
- .filter((t) => !done.has(t.index) && t.bytes > cfg.maxToolResultBytes && t.bytes > keepBudget)
152
- .sort((a, b) => b.bytes - a.bytes || a.index - b.index);
172
+ const truncatable = tools.filter((t) => !done.has(t.index) && t.bytes > cfg.maxToolResultBytes && t.bytes > keepBudget);
153
173
  for (const t of truncatable) {
154
174
  if (promptBytes - saved <= targetBytes) break;
155
175
  edits.push({ index: t.index, mode: "truncate", keepHead: cfg.keepHeadBytes, keepTail: cfg.keepTailBytes, note: `large ${t.name || "tool"} result` });
@@ -390,8 +390,9 @@ export function select(args: SelectArgs): Decision {
390
390
  if (fallbacks.length >= 2) break;
391
391
  }
392
392
 
393
- // 7. Cache breakpoints.
394
- const cacheBreakpointMessageIndices = planCacheBreakpoints(req, chosen.model, cfg);
393
+ // 7. Cache breakpoints, measured over post-compaction sizes so the
394
+ // boundaries match the bytes that actually get dispatched.
395
+ const cacheBreakpointMessageIndices = planCacheBreakpoints(req, chosen.model, cfg, compactionPlan);
395
396
 
396
397
  // 8. Guarded probe: only tiers configured for probing, and only when a
397
398
  // strictly higher tier exists inside the profile envelope to escalate into.
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
+ import type { CatalogModel } from "../src/catalog/types.ts";
5
+ import { loadConfig } from "../src/config/load.ts";
6
+ import type { RouterConfig } from "../src/config/types.ts";
7
+ import { priceAt } from "../src/cost/forecast.ts";
8
+ import { planCacheBreakpoints } from "../src/router/cache-control.ts";
9
+ import { planCompaction } from "../src/router/compaction.ts";
10
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
11
+ import type { NormRequest } from "../src/wire/types.ts";
12
+
13
+ const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json()) as { data: unknown[] };
14
+ const MODELS: CatalogModel[] = FIXTURE.data.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
15
+ // Breakpoints are only planned for models that publish a cache-read price.
16
+ const CACHING = MODELS.find((m) => priceAt(m, 100_000).cacheRead !== undefined);
17
+ if (CACHING === undefined) throw new Error("fixture has no model with a published cache-read price");
18
+ const MODEL: CatalogModel = CACHING;
19
+
20
+ const BASE = loadConfig({});
21
+ function cfg(over: Partial<RouterConfig["cache"]> = {}): RouterConfig {
22
+ return { ...BASE, cache: { ...BASE.cache, ...over } };
23
+ }
24
+
25
+ const RESULT_BYTES = 8_000;
26
+ const result = (turn: number): string => `result ${turn}:${"x".repeat(RESULT_BYTES)}`;
27
+
28
+ /** A tool-loop conversation: system, one user ask, then `turns` call/result pairs. */
29
+ function loop(turns: number): NormRequest {
30
+ const messages: Record<string, unknown>[] = [
31
+ { role: "system", content: `You are a coding agent.${"!".repeat(4_000)}` },
32
+ { role: "user", content: "find the bug" },
33
+ ];
34
+ for (let t = 0; t < turns; t++) {
35
+ messages.push({
36
+ role: "assistant",
37
+ content: null,
38
+ tool_calls: [{ id: `c${t}`, type: "function", function: { name: "read", arguments: `{"path":"f${t}.ts"}` } }],
39
+ });
40
+ messages.push({ role: "tool", tool_call_id: `c${t}`, content: result(t) });
41
+ }
42
+ return parseChatRequest({ model: "auto", messages }, new Headers());
43
+ }
44
+
45
+ describe("planCacheBreakpoints", () => {
46
+ test("marks the tail so the next turn can read this turn's whole prompt", () => {
47
+ const req = loop(12);
48
+ const picks = planCacheBreakpoints(req, MODEL, cfg());
49
+ expect(picks).toContain(req.messages.length - 1);
50
+ });
51
+
52
+ test("marks the system prefix", () => {
53
+ const req = loop(12);
54
+ const picks = planCacheBreakpoints(req, MODEL, cfg());
55
+ expect(picks).toContain(0);
56
+ });
57
+
58
+ test("mid-history boundaries are stable as the conversation grows", () => {
59
+ // Uncapped so the comparison is about placement, not slot eviction.
60
+ const uncapped = cfg({ maxBreakpoints: 64, milestoneTokens: 4_000 });
61
+ const mid = (turns: number): number[] => {
62
+ const req = loop(turns);
63
+ const tail = req.messages.length - 1;
64
+ return planCacheBreakpoints(req, MODEL, uncapped).filter((i) => i !== 0 && i !== tail);
65
+ };
66
+ const early = mid(10);
67
+ expect(early.length).toBeGreaterThan(1);
68
+ for (const turns of [11, 12, 13, 20]) {
69
+ // Every boundary the earlier turn wrote is still a boundary later, so
70
+ // the later turn reads what the earlier one paid to write.
71
+ expect(mid(turns)).toEqual(expect.arrayContaining(early));
72
+ }
73
+ });
74
+
75
+ test("boundaries are spaced by the milestone size, not by message position", () => {
76
+ const req = loop(30);
77
+ const tail = req.messages.length - 1;
78
+ const coarse = planCacheBreakpoints(req, MODEL, cfg({ maxBreakpoints: 64, milestoneTokens: 20_000 })).filter(
79
+ (i) => i !== 0 && i !== tail,
80
+ );
81
+ const fine = planCacheBreakpoints(req, MODEL, cfg({ maxBreakpoints: 64, milestoneTokens: 4_000 })).filter(
82
+ (i) => i !== 0 && i !== tail,
83
+ );
84
+ expect(fine.length).toBeGreaterThan(coarse.length);
85
+ });
86
+
87
+ test("keeps the system prefix and the tail when slots are scarce", () => {
88
+ const req = loop(30);
89
+ const picks = planCacheBreakpoints(req, MODEL, cfg({ maxBreakpoints: 2, milestoneTokens: 4_000 }));
90
+ expect(picks).toEqual([0, req.messages.length - 1]);
91
+ });
92
+
93
+ test("milestones follow post-compaction sizes", () => {
94
+ const req = loop(30);
95
+ const tail = req.messages.length - 1;
96
+ const plan = planCompaction(req.messages, BASE.compaction, req.promptBytes * 0.3, req.promptBytes);
97
+ expect(plan.edits.length).toBeGreaterThan(0);
98
+ const options = cfg({ maxBreakpoints: 64, milestoneTokens: 4_000 });
99
+ const raw = planCacheBreakpoints(req, MODEL, options).filter((i) => i !== 0 && i !== tail);
100
+ const compacted = planCacheBreakpoints(req, MODEL, options, plan.edits).filter((i) => i !== 0 && i !== tail);
101
+ // Shrinking early results pushes each byte milestone later in the history.
102
+ expect(compacted.length).toBeLessThan(raw.length);
103
+ expect(Math.min(...compacted)).toBeGreaterThan(Math.min(...raw));
104
+ });
105
+
106
+ test("injects nothing below the minimum prompt size, or when disabled", () => {
107
+ const small = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers());
108
+ expect(planCacheBreakpoints(small, MODEL, cfg())).toEqual([]);
109
+ expect(planCacheBreakpoints(loop(12), MODEL, cfg({ injectBreakpoints: false }))).toEqual([]);
110
+ });
111
+ });
@@ -99,6 +99,43 @@ describe("planCompaction", () => {
99
99
  const b = planCompaction(msgs, CFG, 1, 10_000);
100
100
  expect(a).toEqual(b);
101
101
  });
102
+
103
+ // The prompt-cache contract: an edit, once made, keeps its index and its keep
104
+ // bytes for the rest of the conversation, and every later edit lands AFTER
105
+ // it. Anything else rewrites already-cached history and forces a full
106
+ // re-read of the prefix on the next turn.
107
+ test("the edit set only ever extends forward as the conversation grows", () => {
108
+ // Sizes GROW with age-descending order (newest results are the biggest), so
109
+ // a size-ordered planner selects newest-first and its later additions move
110
+ // BACKWARD into already-cached history. Equal-sized results would make
111
+ // every ordering identical and the assertions vacuous.
112
+ const loop = (pairs: number): NormMessage[] => {
113
+ const msgs: NormMessage[] = [user("go")];
114
+ for (let i = 0; i < pairs; i++) {
115
+ const content = `R${i}:${"x".repeat(200 + i * 40)}`;
116
+ msgs.push(asst(`c${i}`, "read", `{"path":"f${i}.ts"}`), toolMsg(`c${i}`, "read", content));
117
+ }
118
+ return msgs;
119
+ };
120
+ let previous: number[] = [];
121
+ for (let pairs = 4; pairs <= 24; pairs++) {
122
+ const msgs = loop(pairs);
123
+ const promptBytes = msgs.reduce((n, m) => n + m.textBytes, 0);
124
+ // A target the plan can hit with a handful of edits: this is where the
125
+ // selection ORDER decides which results get truncated. A saturating
126
+ // target would truncate everything and hide the difference.
127
+ const indices = planCompaction(msgs, CFG, Math.floor(promptBytes * 0.8), promptBytes).edits.map((e) => e.index);
128
+ // Nothing already compacted may be dropped...
129
+ expect(indices).toEqual(expect.arrayContaining(previous));
130
+ // ...and anything new lands after every existing edit.
131
+ const added = indices.filter((i) => !previous.includes(i));
132
+ if (previous.length > 0 && added.length > 0) {
133
+ expect(Math.min(...added)).toBeGreaterThan(Math.max(...previous));
134
+ }
135
+ previous = indices;
136
+ }
137
+ expect(previous.length).toBeGreaterThan(4);
138
+ });
102
139
  });
103
140
 
104
141
  describe("renderUpstreamBody applies compaction", () => {
@@ -68,7 +68,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
68
68
  },
69
69
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
70
70
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
71
- cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024 },
71
+ cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
72
72
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, recordTurns: false, maxQueue: 64 },
73
73
  compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
74
74
  budget: { onExceeded: "downgrade" },
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import { loadConfig } from "../src/config/load.ts";
4
- import { createLedger } from "../src/cost/ledger.ts";
4
+ import { createLedger, LATENCY_WINDOW_ROWS } from "../src/cost/ledger.ts";
5
5
  import { EMPTY_USAGE, type LedgerEntry } from "../src/cost/types.ts";
6
6
  import { openDb } from "../src/util/sqlite.ts";
7
7
 
@@ -198,6 +198,22 @@ describe("latency signal", () => {
198
198
  expect(l?.tokensPerSec).toBeCloseTo(100, 5);
199
199
  expect(l?.samples).toBe(2);
200
200
  });
201
+
202
+ test("throughput and ttft track a recent window, not the lifetime average", () => {
203
+ // Old rows are fast; the recent window is slow. Latency must reflect the
204
+ // recent (slow) behaviour so a degraded model is penalised, not masked by
205
+ // its history. Lifetime blend here would be ~57 tok/s; the window is 10.
206
+ const rows: Array<Partial<LedgerEntry>> = [];
207
+ let t = 1;
208
+ for (let i = 0; i < 50; i++)
209
+ rows.push({ createdAtMs: t++, ttftMs: 200, latencyMs: 1200, usage: { ...EMPTY_USAGE, completionTokens: 1000 } });
210
+ for (let i = 0; i < LATENCY_WINDOW_ROWS; i++)
211
+ rows.push({ createdAtMs: t++, ttftMs: 4000, latencyMs: 14000, usage: { ...EMPTY_USAGE, completionTokens: 100 } });
212
+ const l = latencyOf(rows);
213
+ expect(l?.samples).toBe(LATENCY_WINDOW_ROWS);
214
+ expect(l?.tokensPerSec).toBeCloseTo(10, 0);
215
+ expect(l?.ttftMs).toBeCloseTo(4000, 5);
216
+ });
201
217
  });
202
218
 
203
219
  describe("v4 migration", () => {
package/test/turn.test.ts CHANGED
@@ -68,7 +68,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
68
68
  },
69
69
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
70
70
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
71
- cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024 },
71
+ cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
72
72
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, recordTurns: false, maxQueue: 64 },
73
73
  compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
74
74
  budget: { onExceeded: "downgrade" },