auto-model-router 0.2.15 → 0.2.20

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.
@@ -18,7 +18,10 @@ export interface AgentDoxClientOptions {
18
18
  /** Bounds on what agentdox may select for one block. */
19
19
  export interface AssembleLimits {
20
20
  memoryLimit: number;
21
+ docsLimit: number;
21
22
  sessionLimit: number;
23
+ /** Character budget for the project brief; 0 omits it (pre-brief servers ignore it). */
24
+ briefChars: number;
22
25
  }
23
26
 
24
27
  export interface AgentDoxClient {
@@ -83,12 +86,16 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
83
86
  return {
84
87
  async assemble(scope, query, limits) {
85
88
  // camelCase: the REST endpoint ignores snake_case limit keys entirely,
86
- // which silently reads as "unbounded".
89
+ // which silently reads as "unbounded". briefChars is sent even when 0:
90
+ // an older server ignores the unknown key, and 0 is the documented
91
+ // "no brief" value there.
87
92
  const res = await request("POST", "/context/assemble", {
88
93
  scope,
89
94
  query,
90
95
  memoryLimit: limits.memoryLimit,
96
+ docsLimit: limits.docsLimit,
91
97
  sessionLimit: limits.sessionLimit,
98
+ briefChars: limits.briefChars,
92
99
  });
93
100
  if (res !== null && res.status === 200) {
94
101
  const prompt = promptOf(res.json);
@@ -36,7 +36,10 @@ export interface BridgeOptions {
36
36
  * useful entry instead of severing whatever straddles the cap.
37
37
  */
38
38
  memoryLimit: number;
39
+ docsLimit: number;
39
40
  sessionLimit: number;
41
+ /** Character budget for the project brief rendered first in the block; 0 omits it. */
42
+ briefChars: number;
40
43
  /** Record settled turns back into agentdox sessions. */
41
44
  recordTurns: boolean;
42
45
  /** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
@@ -80,7 +83,7 @@ function appendFragment(prior: string, next: string): string {
80
83
  }
81
84
 
82
85
  export function createContextBridge(opts: BridgeOptions): ContextBridge {
83
- const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, sessionLimit, recordTurns, maxQueue } = opts;
86
+ const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, briefChars, recordTurns, maxQueue } = opts;
84
87
 
85
88
  // Serialized write-back queue. Session appends for one conversation must
86
89
  // stay ordered, and agentdox is a local service — one worker is plenty.
@@ -118,7 +121,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
118
121
  return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
119
122
  }
120
123
 
121
- const raw = await client.assemble(input.scope, input.query, { memoryLimit, sessionLimit });
124
+ const raw = await client.assemble(input.scope, input.query, { memoryLimit, docsLimit, sessionLimit, briefChars });
122
125
  if (raw === null) {
123
126
  // agentdox unreachable or empty. Keep serving the pinned block if we
124
127
  // have one: stale shared context beats none, and re-using it also
@@ -28,7 +28,9 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Context
28
28
  maxStalenessMs: c.maxStalenessMs,
29
29
  maxBlockChars: c.maxBlockChars,
30
30
  memoryLimit: c.memoryLimit,
31
+ docsLimit: c.docsLimit,
31
32
  sessionLimit: c.sessionLimit,
33
+ briefChars: c.briefChars,
32
34
  recordTurns: c.recordTurns,
33
35
  maxQueue: c.maxQueue,
34
36
  });
package/src/cost/types.ts CHANGED
@@ -124,7 +124,12 @@ export interface LedgerEntry {
124
124
  /** Time to first content token, ms. */
125
125
  ttftMs: number | null;
126
126
  finishReason: string | null;
127
- /** Tokens billed but discarded because the attempt was aborted and retried. */
127
+ /**
128
+ * Attempt superseded by a retry or escalation. NOT a cost figure: by design
129
+ * these rows never carry reported_usd, so "wasted spend" sums to $0.00.
130
+ * The meaningful waste measure is retry spend — rows with attempt > 0 that
131
+ * DID bill. Kept for compatibility; do not read it as money.
132
+ */
128
133
  wasted: boolean;
129
134
  upstreamGenerationId: string | null;
130
135
  error: string | null;
@@ -134,6 +134,8 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
134
134
 
135
135
  const candidates: Candidate[] = [];
136
136
  const rejected: Rejection[] = [];
137
+ // Carries the trust/latency-adjusted cost into the second scoring pass.
138
+ const effectiveUsdBySlug = new Map<string, number>();
137
139
 
138
140
  for (const model of snapshot.models) {
139
141
  const slug = model.slug;
@@ -231,6 +233,20 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
231
233
  continue;
232
234
  }
233
235
 
236
+ // Every candidate is priced COLD, deliberately, and this has been measured
237
+ // rather than assumed. Two reasons:
238
+ // 1. `coldUsd` feeds the budget guard in select.ts, and a budget must
239
+ // survive a cache miss.
240
+ // 2. Discounting the warm slug here only ever LOWERS its effective cost,
241
+ // so it can only make the warm model win more often — and the warm
242
+ // model is already either the cheapest candidate or kept by the
243
+ // dedicated stay-vs-switch comparison in select.ts step 4, which does
244
+ // price staying at `cacheRead` against switching at cold+`cacheWrite`.
245
+ // So the ranking change has no headroom to alter an outcome.
246
+ // Verified with tools/replay.ts: scoring the warm candidate at hit rates
247
+ // 0.5 / 0.8 / 0.95 changed 0 of 897 decisions, and 0 of 702 on the subset
248
+ // whose conversations ran the expensive model. Cache economics belong in
249
+ // the switch decision, not in candidate scoring — do not "fix" this.
234
250
  const fc = forecast(model, {
235
251
  promptTokens: features.promptTokens,
236
252
  completionTokens: expectedCompletionTokens,
@@ -249,7 +265,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
249
265
  : null;
250
266
  const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
251
267
  const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5)) * latencyMult;
252
- const score = Math.pow(qualityScore / 100, tierCfg.qualityExponent) / Math.max(effectiveUsd, 1e-9);
268
+ // Score is assigned in a SECOND PASS below: both qualityNormalization and
269
+ // capabilityFloorUsd are properties of the candidate SET, not of one
270
+ // model, so no per-model value can be computed here. Placeholder only.
271
+ const score = 0;
253
272
 
254
273
  const reasons: string[] = [
255
274
  quality === null
@@ -267,6 +286,30 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
267
286
  }
268
287
  if (pinned) reasons.push("pinned into tier");
269
288
  candidates.push({ model, forecast: fc, qualityScore, trustScore, score, reasons });
289
+ effectiveUsdBySlug.set(slug, effectiveUsd);
290
+ }
291
+
292
+ // Second pass: both new tier modes need the whole set.
293
+ // - qualityNormalization rescales quality to the set's own [worst, best]
294
+ // range, so the exponent operates on a full 0-1 spread instead of the
295
+ // raw index's compressed 69-78 band.
296
+ // - capabilityFloorUsd ignores the ratio entirely and takes the highest
297
+ // quality candidate affordable within the cap.
298
+ const qualities = candidates.map((c) => c.qualityScore);
299
+ const qMin = qualities.length > 0 ? Math.min(...qualities) : 0;
300
+ const qMax = qualities.length > 0 ? Math.max(...qualities) : 0;
301
+ const qSpread = qMax - qMin;
302
+ const normalize = tierCfg.qualityNormalization === true && qSpread > 0;
303
+ for (const c of candidates) {
304
+ const effectiveUsd = effectiveUsdBySlug.get(c.model.slug) ?? c.forecast.expectedUsd;
305
+ // Normalised quality is unitless in [0,1]: the set's cheapest-quality
306
+ // model scores 0, its best scores 1. A single-model set has no spread,
307
+ // so it keeps the raw path (guarded by qSpread > 0).
308
+ const q = normalize ? (c.qualityScore - qMin) / qSpread : c.qualityScore / 100;
309
+ c.score = Math.pow(q, tierCfg.qualityExponent) / Math.max(effectiveUsd, 1e-9);
310
+ if (normalize) {
311
+ c.reasons.push(`quality normalised ${q.toFixed(3)} within set [${qMin}, ${qMax}]`);
312
+ }
270
313
  }
271
314
 
272
315
  candidates.sort((a, b) => {
@@ -285,5 +328,28 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
285
328
  if (b.model.slug === warmSlug) return 1;
286
329
  return a.model.slug < b.model.slug ? -1 : 1;
287
330
  });
331
+
332
+ // Capability-floor mode: the top tier's job is "best model the work needs",
333
+ // which quality-per-dollar cannot express — a bargain model always wins the
334
+ // ratio however weak it is. Promote the highest-quality candidate whose
335
+ // forecast turn cost fits the cap to the front. Strictly an upgrade: when
336
+ // nothing is affordable, or the ranked winner is already the best quality,
337
+ // the order is untouched.
338
+ const floorUsd = tierCfg.capabilityFloorUsd;
339
+ if (floorUsd !== undefined && candidates.length > 1) {
340
+ let best: Candidate | undefined;
341
+ for (const c of candidates) {
342
+ if (c.forecast.coldUsd > floorUsd) continue;
343
+ if (best === undefined || c.qualityScore > best.qualityScore) best = c;
344
+ }
345
+ if (best !== undefined && best !== candidates[0]) {
346
+ const idx = candidates.indexOf(best);
347
+ candidates.splice(idx, 1);
348
+ candidates.unshift(best);
349
+ best.reasons.push(
350
+ `capability floor: highest quality ${best.qualityScore} within $${floorUsd}/turn (cold $${best.forecast.coldUsd.toFixed(4)})`,
351
+ );
352
+ }
353
+ }
288
354
  return { candidates, rejected };
289
355
  }
@@ -167,7 +167,7 @@ function isLoopbackHostHeader(hostHeader: string | null): boolean {
167
167
  export function startServer(cfg: RouterConfig): StartedServer {
168
168
  const log = createLogger(cfg.logLevel);
169
169
 
170
- mkdirSync(dirname(cfg.ledger.path), { recursive: true });
170
+ if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
171
171
  const db = openDb(cfg.ledger.path);
172
172
  const ledger = createLedger(db, cfg);
173
173
  const upstream = createOpenRouterClient(cfg);
@@ -258,7 +258,14 @@ export function startServer(cfg: RouterConfig): StartedServer {
258
258
  runTurn(normReq, sink, turnDeps, req.signal)
259
259
  .catch((err: unknown) => {
260
260
  log.error("turn failed", { error: err instanceof Error ? err.message : String(err) });
261
- return Promise.resolve(sink.error(toWireError(err))).catch(() => {});
261
+ // sink.error() is SYNCHRONOUS: it runs before Promise.resolve wraps
262
+ // anything, so a throw out of it escapes this .catch() handler and
263
+ // becomes an unhandled rejection that kills the process. Wrap it.
264
+ try {
265
+ Promise.resolve(sink.error(toWireError(err))).catch(() => {});
266
+ } catch {
267
+ // Stream already gone; the response cannot carry the error.
268
+ }
262
269
  })
263
270
  .finally(() => {
264
271
  // Release the concurrency slot when the turn settles, not when the
@@ -26,12 +26,25 @@ export function createStreamingSink(virtualModel: string): { sink: ResponseSink;
26
26
  },
27
27
  });
28
28
  const send = (bytes: Uint8Array): void => {
29
- if (!closed) controller?.enqueue(bytes);
29
+ if (closed) return;
30
+ try {
31
+ controller?.enqueue(bytes);
32
+ } catch {
33
+ // The runtime can close the controller under us — a client cancelling
34
+ // the stream (reader.cancel()) is not exceptional and nothing above us
35
+ // observes it. Mark closed and drop the write; measured to throw
36
+ // ERR_INVALID_STATE synchronously on Bun 1.x otherwise.
37
+ closed = true;
38
+ }
30
39
  };
31
40
  const close = (): void => {
32
41
  if (!closed) {
33
42
  closed = true;
34
- controller?.close();
43
+ try {
44
+ controller?.close();
45
+ } catch {
46
+ // Already closed by the runtime; nothing left to do.
47
+ }
35
48
  }
36
49
  };
37
50
 
@@ -53,7 +53,9 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
53
53
  maxStalenessMs: 900_000,
54
54
  maxBlockChars: 24_000,
55
55
  memoryLimit: 8,
56
+ docsLimit: 2,
56
57
  sessionLimit: 6,
58
+ briefChars: 0,
57
59
  recordTurns: true,
58
60
  maxQueue: 64,
59
61
  ...over,
@@ -95,16 +97,18 @@ describe("context bridge refresh policy", () => {
95
97
  });
96
98
 
97
99
  test("assembly is bounded, so the block cannot grow until bytes get severed", async () => {
98
- // The block reached 23.5k chars (~5.9k tokens, 15 memory entries) against a
99
- // 24k maxBlockChars cap, at which point renderBlock slices mid-entry. Byte
100
- // truncation is blind to relevance, so the server must be told to rank and
101
- // select instead. The REST endpoint ignores snake_case limit keys, which
102
- // silently reads as unboundedhence pinning that the limits are passed.
100
+ // The block reached 23.5k chars against a 24k maxBlockChars cap, at which
101
+ // point renderBlock slices mid-entry. Byte truncation is blind to relevance,
102
+ // so the server must be told to rank and select instead. `docsLimit`
103
+ // especially: docs are WHOLE documents and were left unbounded, and a single
104
+ // ashlands note-doc measured 41,921 chars over the whole cap by itself.
105
+ // The REST endpoint also ignores snake_case limit keys, which silently reads
106
+ // as unbounded, so pin that all four limits actually reach the client.
103
107
  const client = mkClient();
104
- const { bridge, db } = mkBridge(client, { memoryLimit: 5, sessionLimit: 2 });
108
+ const { bridge, db } = mkBridge(client, { memoryLimit: 5, docsLimit: 1, sessionLimit: 2, briefChars: 9000 });
105
109
  try {
106
110
  await bridge.resolve(input());
107
- expect(client.lastLimits).toEqual({ memoryLimit: 5, sessionLimit: 2 });
111
+ expect(client.lastLimits).toEqual({ memoryLimit: 5, docsLimit: 1, sessionLimit: 2, briefChars: 9000 });
108
112
  } finally {
109
113
  db.close();
110
114
  }
@@ -239,7 +243,9 @@ describe("context bridge refresh policy", () => {
239
243
  maxStalenessMs: 900_000,
240
244
  maxBlockChars: 24_000,
241
245
  memoryLimit: 8,
246
+ docsLimit: 2,
242
247
  sessionLimit: 6,
248
+ briefChars: 0,
243
249
  recordTurns: true,
244
250
  maxQueue: 64,
245
251
  };
@@ -69,7 +69,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
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
71
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
72
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
72
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, 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" },
75
75
  profiles: [],
@@ -0,0 +1,58 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
3
+ import type { RouterConfig } from "../src/config/types.ts";
4
+ import { startServer, type StartedServer } from "../src/server/http.ts";
5
+
6
+ describe("HTTP server resilience against dead streams", () => {
7
+ let handle: StartedServer;
8
+ let baseUrl: string;
9
+
10
+ beforeAll(() => {
11
+ const cfg: RouterConfig = {
12
+ ...DEFAULT_CONFIG,
13
+ server: { host: "127.0.0.1", port: 0 },
14
+ ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
15
+ context: { ...DEFAULT_CONFIG.context, enabled: false },
16
+ logLevel: "silent",
17
+ };
18
+ handle = startServer(cfg);
19
+ baseUrl = `http://127.0.0.1:${handle.server.port}`;
20
+ });
21
+ afterAll(async () => {
22
+ await handle.stop();
23
+ });
24
+
25
+ test("server answers /v1/models cleanly initially", async () => {
26
+ const res = await fetch(`${baseUrl}/v1/models`);
27
+ expect(res.status).toBe(200);
28
+ const json = (await res.json()) as { data: unknown[] };
29
+ expect(Array.isArray(json.data)).toBe(true);
30
+ });
31
+
32
+ test("cancelling a streaming /v1/chat/completions client does not crash the server", async () => {
33
+ const res = await fetch(`${baseUrl}/v1/chat/completions`, {
34
+ method: "POST",
35
+ headers: { "content-type": "application/json" },
36
+ body: JSON.stringify({
37
+ model: "auto",
38
+ stream: true,
39
+ messages: [{ role: "user", content: "hello" }],
40
+ }),
41
+ });
42
+
43
+ // Immediately cancel the reader mid-stream (simulates client disconnect)
44
+ const reader = res.body?.getReader();
45
+ expect(reader).toBeDefined();
46
+ await reader?.cancel("client abruptly dropped");
47
+
48
+ // Allow microtasks and I/O ticks to settle without real wall-clock delays
49
+ await new Promise<void>((resolve) => {
50
+ setImmediate(() => resolve());
51
+ });
52
+ // and answers subsequent requests cleanly.
53
+ const modelsRes = await fetch(`${baseUrl}/v1/models`);
54
+ expect(modelsRes.status).toBe(200);
55
+ const json = (await modelsRes.json()) as { data: unknown[] };
56
+ expect(Array.isArray(json.data)).toBe(true);
57
+ });
58
+ });
@@ -354,3 +354,94 @@ describe("adaptive price ceilings", () => {
354
354
  expect(on.rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
355
355
  });
356
356
  });
357
+
358
+ describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
359
+ const req = parseChatRequest(
360
+ {
361
+ model: "auto",
362
+ tools: [{ type: "function", function: { name: "read", description: "Read", parameters: { type: "object", properties: {} } } }],
363
+ messages: [{ role: "user", content: "implement nested transaction savepoints" }],
364
+ },
365
+ new Headers(),
366
+ );
367
+ const features = extractFeatures(req, 100);
368
+
369
+ // The real catalog's shape: quality in a narrow band, price spanning ~100x.
370
+ // `hard` has a quality floor of 72, so cheap/1 is deliberately below it —
371
+ // it must be rejected, and mid/2 is the cheapest ELIGIBLE model, the one
372
+ // raw quality-per-dollar ranking picks at any sane exponent.
373
+ const spread = snapshot(
374
+ models([
375
+ ["cheap/1", 70, 0.05],
376
+ ["mid/2", 74, 1.0],
377
+ ["good/3", 76, 3.0],
378
+ ["best/4", 78, 5.0],
379
+ ]),
380
+ );
381
+
382
+ const run = (tierOverride: Partial<(typeof BASE)["tiers"]["hard"]>) =>
383
+ buildCandidates({
384
+ req,
385
+ features,
386
+ tier: "hard",
387
+ task: "coding",
388
+ snapshot: spread,
389
+ ledger: null,
390
+ cfg: { ...BASE, tiers: { ...BASE.tiers, hard: { ...BASE.tiers.hard, ...tierOverride } } },
391
+ expectedCompletionTokens: 512,
392
+ warmSlug: null,
393
+ });
394
+
395
+ test("raw scoring at the shipped exponent picks the cheapest ELIGIBLE model", () => {
396
+ const { candidates, rejected } = run({ qualityExponent: 3 });
397
+ expect(candidates[0]?.model.slug).toBe("mid/2");
398
+ // cheap/1 is under the hard floor of 72 and never competes.
399
+ expect(rejected.some((r) => r.slug === "cheap/1" && r.reason === "below_quality_floor")).toBe(true);
400
+ });
401
+
402
+ test("normalization lets a single-digit exponent buy the best model, which raw cannot", () => {
403
+ // Raw at the same exponent still cannot reach it: that is the defect.
404
+ expect(run({ qualityExponent: 12 }).candidates[0]?.model.slug).toBe("mid/2");
405
+ // Normalised, the same 12 selects the top-quality model.
406
+ const normalised = run({ qualityExponent: 12, qualityNormalization: true });
407
+ expect(normalised.candidates[0]?.model.slug).toBe("best/4");
408
+ expect(normalised.candidates[0]?.reasons.some((r) => r.includes("quality normalised"))).toBe(true);
409
+ });
410
+
411
+ test("normalization is monotone in the exponent: higher never picks a weaker model", () => {
412
+ let lastQuality = 0;
413
+ for (const qualityExponent of [1, 4, 8, 12, 20]) {
414
+ const top = run({ qualityExponent, qualityNormalization: true }).candidates[0];
415
+ expect(top).toBeDefined();
416
+ expect(top?.qualityScore ?? 0).toBeGreaterThanOrEqual(lastQuality);
417
+ lastQuality = top?.qualityScore ?? 0;
418
+ }
419
+ });
420
+
421
+ test("capability floor takes the best model inside the cap, ignoring the ratio", () => {
422
+ // mid/2 costs ~$0.0016 and good/3 ~$0.0049, so this cap admits both but
423
+ // excludes best/4 (~$0.0082). The ranked winner is mid/2 (cheapest).
424
+ const cap = 0.005;
425
+ const capped = run({ capabilityFloorUsd: cap });
426
+ const top = capped.candidates[0];
427
+ expect(top).toBeDefined();
428
+ expect(top?.forecast.coldUsd ?? 1).toBeLessThanOrEqual(cap);
429
+ // It must be the highest-quality affordable one, not the cheapest: good/3.
430
+ expect(top?.model.slug).toBe("good/3");
431
+ expect(top?.reasons.some((r) => r.includes("capability floor"))).toBe(true);
432
+ });
433
+
434
+ test("capability floor is strictly an upgrade: an unaffordable cap changes nothing", () => {
435
+ const base = run({}).candidates.map((c) => c.model.slug);
436
+ // A cap below every candidate's cost promotes nobody.
437
+ const tiny = run({ capabilityFloorUsd: 1e-9 }).candidates.map((c) => c.model.slug);
438
+ expect(tiny).toEqual(base);
439
+ });
440
+
441
+ test("both modes stay inert by default, so shipped behaviour is unchanged", () => {
442
+ const shipped = run({});
443
+ expect(shipped.candidates[0]?.model.slug).toBe("mid/2");
444
+ expect(shipped.candidates.every((c) => !c.reasons.some((r) => r.includes("normalised")))).toBe(true);
445
+ expect(shipped.candidates.every((c) => !c.reasons.some((r) => r.includes("capability floor")))).toBe(true);
446
+ });
447
+ });
package/test/turn.test.ts CHANGED
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
70
70
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
71
71
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
72
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
73
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
74
74
  compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
75
75
  budget: { onExceeded: "downgrade" },
76
76
  profiles: [],
@@ -70,6 +70,37 @@ describe("createStreamingSink", () => {
70
70
  { error: { message: "boom", type: "server_error", code: "upstream_error" } },
71
71
  ]);
72
72
  });
73
+
74
+ test("chunk, error, and finish survive a client-cancelled body without throwing", async () => {
75
+ const { sink, response } = createStreamingSink("auto");
76
+ const reader = response.body?.getReader();
77
+ expect(reader).toBeDefined();
78
+ // Client disconnects mid-stream: reader cancels, controller closes.
79
+ await reader?.cancel("client closed connection");
80
+
81
+ // None of these may throw ERR_INVALID_STATE:
82
+ expect(() => {
83
+ sink.chunk(
84
+ chunk({
85
+ id: "gen-cancelled",
86
+ model: "openai/gpt-5.5",
87
+ choices: [{ index: 0, delta: { content: "trailing" }, finish_reason: null }],
88
+ }),
89
+ );
90
+ }).not.toThrow();
91
+
92
+ expect(() => {
93
+ sink.finish(SUMMARY);
94
+ }).not.toThrow();
95
+
96
+ // Repeated calls (e.g. error after chunk on dead stream) must also stay safe:
97
+ const { sink: sink2, response: response2 } = createStreamingSink("auto");
98
+ const reader2 = response2.body?.getReader();
99
+ await reader2?.cancel();
100
+ expect(() => {
101
+ sink2.error({ status: 500, code: "server_error", message: "late failure" });
102
+ }).not.toThrow();
103
+ });
73
104
  });
74
105
 
75
106
  describe("createBufferedSink", () => {