auto-model-router 0.2.32 → 0.3.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.
Files changed (67) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +208 -29
  3. package/docs/review-2026-09-05.md +267 -0
  4. package/omp-extension/configure-logic.ts +71 -15
  5. package/omp-extension/pi-coding-agent.d.ts +79 -2
  6. package/omp-extension/report-hub.ts +376 -0
  7. package/omp-extension/report-logic.ts +115 -0
  8. package/omp-extension/router-configure.ts +203 -51
  9. package/omp-extension/router-url.ts +52 -0
  10. package/omp-extension/toast-logic.ts +14 -2
  11. package/package.json +1 -1
  12. package/src/catalog/composite.ts +97 -0
  13. package/src/catalog/ollama-catalog.ts +309 -0
  14. package/src/catalog/ollama-prices.ts +85 -0
  15. package/src/catalog/openrouter-catalog.ts +39 -1
  16. package/src/catalog/types.ts +31 -1
  17. package/src/cli/args.ts +1 -0
  18. package/src/cli/config-wizard.ts +189 -28
  19. package/src/cli/explain.ts +2 -4
  20. package/src/cli/models.ts +2 -4
  21. package/src/cli/report.ts +37 -0
  22. package/src/config/defaults.ts +43 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +27 -0
  26. package/src/config/types.ts +114 -2
  27. package/src/cost/ledger.ts +73 -4
  28. package/src/cost/report.ts +340 -0
  29. package/src/cost/types.ts +33 -1
  30. package/src/index.ts +5 -8
  31. package/src/router/candidates.ts +52 -4
  32. package/src/router/classify.ts +33 -6
  33. package/src/router/features.ts +13 -1
  34. package/src/router/select.ts +55 -8
  35. package/src/router/state.ts +6 -2
  36. package/src/router/tier-plan.ts +49 -11
  37. package/src/router/types.ts +10 -0
  38. package/src/server/http.ts +47 -6
  39. package/src/server/providers.ts +54 -0
  40. package/src/server/turn.ts +122 -34
  41. package/src/tokens/estimate.ts +16 -0
  42. package/src/upstream/multi.ts +26 -0
  43. package/src/upstream/ollama-usage.ts +157 -0
  44. package/src/upstream/ollama.ts +275 -0
  45. package/src/upstream/openrouter.ts +19 -1
  46. package/src/upstream/types.ts +2 -0
  47. package/src/util/sqlite.ts +25 -1
  48. package/test/catalog.test.ts +44 -0
  49. package/test/classify.test.ts +41 -5
  50. package/test/compaction.test.ts +1 -0
  51. package/test/config-wizard.test.ts +77 -1
  52. package/test/configure-logic.test.ts +129 -33
  53. package/test/embed-lifecycle.test.ts +1 -0
  54. package/test/failover.test.ts +148 -3
  55. package/test/features.test.ts +35 -0
  56. package/test/http-resilience.test.ts +24 -0
  57. package/test/ollama.test.ts +506 -0
  58. package/test/omp-credentials.test.ts +43 -1
  59. package/test/report-hub.test.ts +341 -0
  60. package/test/report-logic.test.ts +92 -0
  61. package/test/report.test.ts +217 -0
  62. package/test/select.test.ts +151 -1
  63. package/test/tier-plan.test.ts +159 -1
  64. package/test/toast-logic.test.ts +11 -2
  65. package/test/tokens.test.ts +71 -1
  66. package/test/trust-attribution.test.ts +2 -2
  67. package/test/turn.test.ts +124 -7
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test";
3
3
  import { WIZARD_SECTIONS } from "../src/cli/config-wizard.ts";
4
4
  import type { FieldSpec } from "../src/cli/config-wizard.ts";
5
5
 
6
- import { editProfile, walkSection, type ConfigUi } from "../omp-extension/configure-logic.ts";
6
+ import { editProfile, editSectionMenu, walkSection, type ConfigUi, type SelectOption } from "../omp-extension/configure-logic.ts";
7
7
 
8
8
  function makeUi(script: Array<{ type: "select" | "input" | "confirm"; value?: string | boolean | undefined }>): ConfigUi {
9
9
  const calls = script.slice();
@@ -14,7 +14,7 @@ function makeUi(script: Array<{ type: "select" | "input" | "confirm"; value?: st
14
14
  if (call.value === undefined) return undefined;
15
15
  return call.value as string;
16
16
  },
17
- async input(_title, _placeholder, _initial) {
17
+ async input(_title, _placeholder) {
18
18
  const call = calls.shift();
19
19
  if (call?.type !== "input") throw new Error("expected input, got " + JSON.stringify(call));
20
20
  if (call.value === undefined) return undefined;
@@ -28,6 +28,19 @@ function makeUi(script: Array<{ type: "select" | "input" | "confirm"; value?: st
28
28
  notify(_text, _level) {},
29
29
  };
30
30
  }
31
+ /** One "keep" answer per field of a section, with overrides by dotted path. */
32
+ function keepAll(section: { fields: readonly FieldSpec[] }, overrides: Record<string, string> = {}) {
33
+ return section.fields.map((f) => {
34
+ const value = overrides[f.path] ?? "";
35
+ if (f.kind === "boolean" || f.kind === "enum") {
36
+ // A cancelled select aborts the walk, so an optional boolean keeps
37
+ // "unset" and everything else must be given a value by the caller.
38
+ return { type: "select" as const, value: value !== "" ? value : f.kind === "boolean" && f.optional === true ? "unset" : undefined };
39
+ }
40
+ return { type: "input" as const, value };
41
+ });
42
+ }
43
+
31
44
  const baseCfg = {
32
45
  server: { host: "127.0.0.1", port: 8788, apiKey: undefined, harnessId: undefined },
33
46
  openrouter: {
@@ -55,12 +68,7 @@ const serverSection = WIZARD_SECTIONS.find((s) => s.title === "Server")!;
55
68
 
56
69
  describe("promptField via walkSection", () => {
57
70
  test("empty answer keeps the current value (no change)", async () => {
58
- const ui = makeUi([
59
- { type: "input", value: "" }, // keep host
60
- { type: "input", value: "" }, // keep port
61
- { type: "input", value: "" }, // keep apiKey
62
- { type: "input", value: "" }, // keep harnessId
63
- ]);
71
+ const ui = makeUi(keepAll(serverSection));
64
72
  const answers: Record<string, unknown> = {};
65
73
  const changed = await walkSection(ui, serverSection, baseCfg, answers);
66
74
  expect(changed).toBe(false);
@@ -68,12 +76,7 @@ describe("promptField via walkSection", () => {
68
76
  });
69
77
 
70
78
  test("an edit is collected under its dotted path", async () => {
71
- const ui = makeUi([
72
- { type: "input", value: "127.0.0.2" }, // host
73
- { type: "input", value: "" }, // keep port
74
- { type: "input", value: "" }, // keep apiKey
75
- { type: "input", value: "" }, // keep harnessId
76
- ]);
79
+ const ui = makeUi(keepAll(serverSection, { "server.host": "127.0.0.2" }));
77
80
  const answers: Record<string, unknown> = {};
78
81
  const changed = await walkSection(ui, serverSection, baseCfg, answers);
79
82
  expect(changed).toBe(true);
@@ -89,12 +92,7 @@ describe("promptField via walkSection", () => {
89
92
  });
90
93
 
91
94
  test("CLEAR_TOKEN clears an optional field to null", async () => {
92
- const ui = makeUi([
93
- { type: "input", value: "" }, // host
94
- { type: "input", value: "" }, // port
95
- { type: "input", value: "-" }, // clear apiKey
96
- { type: "input", value: "" }, // harnessId
97
- ]);
95
+ const ui = makeUi(keepAll(serverSection, { "server.apiKey": "-" }));
98
96
  const answers: Record<string, unknown> = {};
99
97
  const changed = await walkSection(ui, serverSection, baseCfg, answers);
100
98
  expect(changed).toBe(true);
@@ -102,22 +100,120 @@ describe("promptField via walkSection", () => {
102
100
  });
103
101
 
104
102
  test("boolean fields use the select dialog", async () => {
105
- const adaptive = WIZARD_SECTIONS.find((s) => s.title === "Tiers")!;
106
- const ui = makeUi([
107
- { type: "select", value: "false" }, // adaptiveTierFloors
108
- { type: "input", value: "" }, // trivial minQuality
109
- { type: "input", value: "" }, // trivial maxInputPerMtok
110
- { type: "input", value: "" }, // simple minQuality
111
- { type: "input", value: "" }, // simple maxInputPerMtok
112
- { type: "input", value: "" }, // moderate minQuality
113
- { type: "input", value: "" }, // moderate maxInputPerMtok
114
- { type: "input", value: "" }, // hard minQuality
115
- { type: "input", value: "" }, // hard maxInputPerMtok
116
- ]);
103
+ const tiers = WIZARD_SECTIONS.find((s) => s.title === "Tiers")!;
104
+ // Booleans and enums prompt through select; a cancelled select would
105
+ // abort, so every boolean/enum in the section is answered with its
106
+ // current value except the one under test.
107
+ const script = keepAll(tiers, { adaptiveTierFloors: "false", adaptivePriceCeilings: "false" });
108
+ for (const call of script) if (call.type === "select" && call.value === undefined) call.value = "false";
109
+ const ui = makeUi(script);
117
110
  const answers: Record<string, unknown> = {};
118
- await walkSection(ui, adaptive, baseCfg, answers);
111
+ await walkSection(ui, tiers, { ...(baseCfg as object), adaptivePriceCeilings: false } as never, answers);
119
112
  expect(answers).toEqual({ adaptiveTierFloors: false });
120
113
  });
114
+
115
+ test("an optional boolean can be cleared back to unset", async () => {
116
+ const field: FieldSpec = { path: "tiers.hard.qualityNormalization", label: "norm", kind: "boolean", optional: true };
117
+ const section = { title: "t", fields: [field] };
118
+ const answers: Record<string, unknown> = {};
119
+ await walkSection(makeUi([{ type: "select", value: "unset" }]), section, { tiers: { hard: { qualityNormalization: true } } } as never, answers);
120
+ expect(answers).toEqual({ "tiers.hard.qualityNormalization": null });
121
+ const none: Record<string, unknown> = {};
122
+ await walkSection(makeUi([{ type: "select", value: "unset" }]), section, { tiers: { hard: {} } } as never, none);
123
+ expect(none).toEqual({});
124
+ });
125
+
126
+ test("re-entering an unchanged array is not an edit", async () => {
127
+ const field: FieldSpec = { path: "filters.deny", label: "deny", kind: "stringArray" };
128
+ const section = { title: "t", fields: [field] };
129
+ const answers: Record<string, unknown> = {};
130
+ const changed = await walkSection(makeUi([{ type: "input", value: "a, b" }]), section, { filters: { deny: ["a", "b"] } } as never, answers);
131
+ expect(changed).toBe(false);
132
+ expect(answers).toEqual({});
133
+ });
134
+
135
+ test("a secret field is prompted with set/unset, never its value", async () => {
136
+ const field: FieldSpec = { path: "openrouter.apiKey", label: "key", kind: "string", optional: true, secret: true };
137
+ const placeholders: string[] = [];
138
+ const ui: ConfigUi = {
139
+ async select() { return undefined; },
140
+ async input(_t, placeholder) { placeholders.push(placeholder ?? ""); return ""; },
141
+ async confirm() { return false; },
142
+ notify() {},
143
+ };
144
+ await walkSection(ui, { title: "t", fields: [field] }, { openrouter: { apiKey: "sk-secret" } } as never, {});
145
+ expect(placeholders[0]).toStartWith("set (");
146
+ expect(placeholders[0]).not.toContain("sk-secret");
147
+ });
148
+ });
149
+
150
+ describe("current value is visible in every dialog", () => {
151
+ /** A UI that records what each dialog showed and answers from a script. */
152
+ function recordingUi(script: Array<string | undefined>) {
153
+ const shown: { title: string; options?: SelectOption[]; placeholder?: string }[] = [];
154
+ const ui: ConfigUi = {
155
+ async select(title, options) { shown.push({ title, options }); return script.shift(); },
156
+ async input(title, placeholder) { shown.push({ title, ...(placeholder === undefined ? {} : { placeholder }) }); return script.shift(); },
157
+ async confirm() { return false; },
158
+ notify() {},
159
+ };
160
+ return { ui, shown };
161
+ }
162
+ const cfg = { server: { host: "127.0.0.1", port: 8788 }, budget: { onExceeded: "downgrade" }, cache: { injectBreakpoints: true } } as never;
163
+
164
+ test("text prompts carry the current value in the title and placeholder", async () => {
165
+ const { ui, shown } = recordingUi([""]);
166
+ const field: FieldSpec = { path: "server.port", label: "Listen port", kind: "number", min: 1, max: 65535 };
167
+ await walkSection(ui, { title: "Server", fields: [field] }, cfg, {});
168
+ expect(shown[0]?.title).toBe("Listen port · current: 8788");
169
+ expect(shown[0]?.placeholder).toContain("8788");
170
+ expect(shown[0]?.placeholder).toContain("Enter keeps");
171
+ });
172
+
173
+ test("enum and boolean pickers mark the current option instead of relying on a preselect index", async () => {
174
+ const { ui, shown } = recordingUi(["reject", "false"]);
175
+ const en: FieldSpec = { path: "budget.onExceeded", label: "On exceeded", kind: "enum", options: ["downgrade", "reject"] };
176
+ const bo: FieldSpec = { path: "cache.injectBreakpoints", label: "Inject cache breakpoints", kind: "boolean" };
177
+ const answers: Record<string, unknown> = {};
178
+ await walkSection(ui, { title: "t", fields: [en, bo] }, cfg, answers);
179
+ expect(shown[0]?.title).toBe("On exceeded · current: downgrade");
180
+ expect(shown[0]?.options).toEqual([{ label: "downgrade", description: "current" }, "reject"]);
181
+ expect(shown[1]?.options).toEqual([{ label: "true", description: "current" }, "false"]);
182
+ expect(answers).toEqual({ "budget.onExceeded": "reject", "cache.injectBreakpoints": false });
183
+ });
184
+
185
+ test("editSectionMenu lists every field with its current value, edits one, and marks it pending", async () => {
186
+ const section = {
187
+ title: "Server",
188
+ fields: [
189
+ { path: "server.host", label: "Listen host", kind: "string" },
190
+ { path: "server.port", label: "Listen port", kind: "number", min: 1 },
191
+ ] as FieldSpec[],
192
+ };
193
+ // pick port → type 9000 → picker again → Back
194
+ const { ui, shown } = recordingUi(["Listen port", "9000", "Back"]);
195
+ const answers: Record<string, unknown> = {};
196
+ const changed = await editSectionMenu(ui, section, cfg, answers);
197
+ expect(changed).toBe(true);
198
+ expect(answers).toEqual({ "server.port": 9000 });
199
+ expect(shown[0]?.options).toEqual([
200
+ { label: "Listen host", description: "127.0.0.1" },
201
+ { label: "Listen port", description: "8788" },
202
+ "Back",
203
+ ]);
204
+ expect(shown[1]?.title).toBe("Listen port · current: 8788");
205
+ // Second picker shows the pending edit, not the on-disk value.
206
+ expect(shown[2]?.title).toBe("Server (edited)");
207
+ expect(shown[2]?.options?.[1]).toEqual({ label: "Listen port", description: "9000 (pending)" });
208
+ });
209
+
210
+ test("cancelling a field dialog returns to the picker; cancelling the picker returns", async () => {
211
+ const section = { title: "Server", fields: [{ path: "server.host", label: "Listen host", kind: "string" }] as FieldSpec[] };
212
+ const { ui, shown } = recordingUi(["Listen host", undefined, undefined]);
213
+ const changed = await editSectionMenu(ui, section, cfg, {});
214
+ expect(changed).toBe(false);
215
+ expect(shown).toHaveLength(3);
216
+ });
121
217
  });
122
218
 
123
219
  describe("editProfile", () => {
@@ -33,6 +33,7 @@ const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => voi
33
33
 
34
34
  const pi: ExtensionAPI = {
35
35
  setLabel: () => {},
36
+ sendMessage: () => {},
36
37
  on: (event, handler) => {
37
38
  const list = handlers.get(event) ?? [];
38
39
  list.push(handler);
@@ -13,6 +13,7 @@ import type {
13
13
  Tier,
14
14
  } from "../src/router/types.ts";
15
15
  import { runTurn } from "../src/server/turn.ts";
16
+ import { classifyUpstreamStatus } from "../src/upstream/openrouter.ts";
16
17
  import { UpstreamError, type DispatchOptions, type UpstreamClient } from "../src/upstream/types.ts";
17
18
  import type {
18
19
  FinishReason,
@@ -30,6 +31,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
30
31
  return {
31
32
  server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24 },
32
33
  openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
34
+ ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0 },
33
35
  benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
34
36
  tiers: {
35
37
  trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
@@ -44,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
44
46
  data: { axis: "intelligence", minQuality: 0 },
45
47
  chat: { axis: "intelligence", minQuality: 0 },
46
48
  },
47
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, latencyMinSamples: 20 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, latencyMinSamples: 20, escalationCostWeight: 0 },
48
50
  classifier: {
49
51
  ambiguityThreshold: 0,
50
52
  model: "test/adjudicator",
@@ -55,6 +57,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
55
57
  toolAxis: "coding",
56
58
  chatAxis: "intelligence",
57
59
  agenticLoopDepth: 3,
60
+ mechanicalRetryFactor: 0.2,
58
61
  reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
59
62
  },
60
63
  escalation: {
@@ -67,11 +70,11 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
67
70
  escalateOnLengthStop: false,
68
71
  ...escalation,
69
72
  },
70
- hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
73
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1 },
71
74
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
75
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
76
  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
- compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
77
+ compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1 },
75
78
  budget: { onExceeded: "downgrade" },
76
79
  profiles: [],
77
80
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
@@ -146,6 +149,8 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
146
149
  cacheBreakpointMessageIndices: [],
147
150
  compactionPlan: [],
148
151
  promptTokensSaved: 0,
152
+ compactionSavedBytes: 0,
153
+ compactionPlanTokens: 0,
149
154
  reasoning: undefined,
150
155
  maxTokens: undefined,
151
156
  stripAssistantReasoning: false,
@@ -545,4 +550,144 @@ describe("same-tier failover", () => {
545
550
  expect(finishes).toHaveLength(1);
546
551
  expect(finishes[0]!.servedSlug).toBe("b/model");
547
552
  });
553
+
554
+ test("an empty completion tries a different model in the SAME tier before escalating", async () => {
555
+ // An empty stream indicts the provider, not the tier. Measured: 49 of 71
556
+ // escalations in a week re-dispatched the very slug that had just
557
+ // failed, one tier up, paying the tier premium for a provider hiccup.
558
+ const { router, calls } = mkRouter([
559
+ mkDecision("trivial", "a/model", { escalateTo: "simple" }),
560
+ mkDecision("trivial", "b/model", { escalateTo: "simple" }),
561
+ ]);
562
+ const { upstream, calls: dispatches } = mkUpstream([
563
+ { kind: "chunks", chunks: [startChunk("a/model"), finishChunk("stop")] },
564
+ { kind: "chunks", chunks: okChunks("b/model") },
565
+ ]);
566
+ const { ledger, entries } = mkLedger();
567
+ const { store } = mkConversations();
568
+ const { sink, chunks, errors, finishes } = mkSink();
569
+
570
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
571
+
572
+ expect(errors).toHaveLength(0);
573
+ expect(calls).toHaveLength(2);
574
+ expect(calls[0]).toEqual({ attempt: 0 });
575
+ // Same tier, the failed slug excluded, no escalateFrom.
576
+ expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["a/model"] });
577
+ expect(dispatches.map((d) => d.body.model)).toEqual(["a/model", "b/model"]);
578
+
579
+ expect(entries).toHaveLength(2);
580
+ expect(entries[0]!.wasted).toBe(true);
581
+ expect(entries[0]!.escalationSignal).toBe("empty_completion"); // still counts against a/model's trust
582
+ expect(entries[0]!.error).toBeNull();
583
+ expect(entries[1]!.slug).toBe("b/model");
584
+ expect(entries[1]!.tier).toBe("trivial");
585
+ expect(entries[1]!.reasons.some((r) => r.startsWith("failover: a/model empty_completion"))).toBe(true);
586
+
587
+ expect(textOut(chunks)).toBe("done");
588
+ expect(finishes).toHaveLength(1);
589
+ expect(finishes[0]!.escalated).toBe(false);
590
+ expect(finishes[0]!.servedSlug).toBe("b/model");
591
+ });
592
+
593
+ test("a provider signal with no sibling at this tier escalates, still excluding the failed slug", async () => {
594
+ const { router, calls } = mkRouter([
595
+ mkDecision("trivial", "a/model", { escalateTo: "simple" }),
596
+ mkDecision("simple", "b/model", { escalateTo: "moderate" }), // failover probe: router widened (wrong tier)
597
+ mkDecision("simple", "b/model", { escalateTo: "moderate" }), // real escalation
598
+ ]);
599
+ const { upstream, calls: dispatches } = mkUpstream([
600
+ { kind: "chunks", chunks: [startChunk("a/model"), finishChunk("stop")] },
601
+ { kind: "chunks", chunks: okChunks("b/model") },
602
+ ]);
603
+ const { ledger, entries } = mkLedger();
604
+ const { store } = mkConversations();
605
+ const { sink, errors, finishes } = mkSink();
606
+
607
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
608
+
609
+ expect(errors).toHaveLength(0);
610
+ expect(calls).toHaveLength(3);
611
+ expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["a/model"] });
612
+ expect(calls[2]).toEqual({ attempt: 1, escalateFrom: "trivial", excludeSlugs: ["a/model"] });
613
+ expect(dispatches.map((d) => d.body.model)).toEqual(["a/model", "b/model"]);
614
+ expect(entries).toHaveLength(2);
615
+ expect(entries[0]!.escalationSignal).toBe("empty_completion");
616
+ expect(entries[1]!.tier).toBe("simple");
617
+ expect(finishes[0]!.escalated).toBe(true);
618
+ });
619
+
620
+ test("a structural signal escalates a tier directly, with the failed slug excluded", async () => {
621
+ // Malformed tool arguments are the model's own doing; a stronger model
622
+ // is the remedy, so no same-tier probe — but the failing slug must not
623
+ // be the one that serves the escalated attempt.
624
+ const { router, calls } = mkRouter([
625
+ mkDecision("trivial", "a/model", { escalateTo: "simple" }),
626
+ mkDecision("simple", "b/model", { escalateTo: "moderate" }),
627
+ ]);
628
+ const { upstream, calls: dispatches } = mkUpstream([
629
+ {
630
+ kind: "chunks",
631
+ chunks: [
632
+ startChunk("a/model"),
633
+ chunk([{ type: "tool_call", index: 0, id: "c1", name: "read", argsDelta: "{\"path\": " }]),
634
+ finishChunk("tool_calls"),
635
+ ],
636
+ },
637
+ { kind: "chunks", chunks: okChunks("b/model") },
638
+ ]);
639
+ const { ledger, entries } = mkLedger();
640
+ const { store } = mkConversations();
641
+ const { sink, errors, finishes } = mkSink();
642
+
643
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
644
+
645
+ expect(errors).toHaveLength(0);
646
+ expect(calls).toHaveLength(2);
647
+ expect(calls[1]).toEqual({ attempt: 1, escalateFrom: "trivial", excludeSlugs: ["a/model"] });
648
+ expect(dispatches.map((d) => d.body.model)).toEqual(["a/model", "b/model"]);
649
+ expect(entries[0]!.escalationSignal).toBe("malformed_tool_args");
650
+ expect(finishes[0]!.escalated).toBe(true);
651
+ });
652
+
653
+ });
654
+
655
+ describe("400 classification (review 2026-09-05 follow-up)", () => {
656
+ test("a 400 naming a model capability limit is retryable, so failover picks a sibling", () => {
657
+ const e = classifyUpstreamStatus(400, { error: { message: "This model only supports single tool-calls at once!" } });
658
+ expect(e.kind).toBe("invalid_request");
659
+ expect(e.retryable).toBe(true);
660
+ });
661
+
662
+ test("a 400 for a malformed request stays non-retryable", () => {
663
+ const e = classifyUpstreamStatus(400, { error: { message: "messages[3].content: invalid type" } });
664
+ expect(e.kind).toBe("invalid_request");
665
+ expect(e.retryable).toBe(false);
666
+ });
667
+
668
+ test("a 400 for context overflow is still context_length", () => {
669
+ const e = classifyUpstreamStatus(400, { error: { message: "This endpoint's maximum context length is 131072 tokens" } });
670
+ expect(e.kind).toBe("context_length");
671
+ expect(e.retryable).toBe(false);
672
+ });
673
+
674
+ test("a capability 400 before commit fails over in the same tier, excluding the model", async () => {
675
+ const { router, calls } = mkRouter([mkDecision("trivial", "a/model"), mkDecision("trivial", "b/model")]);
676
+ const { upstream, calls: dispatches } = mkUpstream([
677
+ { kind: "fail", error: classifyUpstreamStatus(400, { error: { message: "This model only supports single tool-calls at once!" } }) },
678
+ { kind: "chunks", chunks: okChunks("b/model") },
679
+ ]);
680
+ const { ledger, entries } = mkLedger();
681
+ const { store } = mkConversations();
682
+ const { sink, errors, finishes } = mkSink();
683
+
684
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
685
+
686
+ expect(errors).toHaveLength(0);
687
+ expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["a/model"] });
688
+ expect(dispatches.map((d) => d.body.model)).toEqual(["a/model", "b/model"]);
689
+ expect(entries[0]!.error).toContain("invalid_request"); // attributable: trust learns the limitation
690
+ expect(finishes[0]!.servedSlug).toBe("b/model");
691
+ });
548
692
  });
693
+
@@ -271,3 +271,38 @@ describe("newest-content scoping", () => {
271
271
  expect(f.isToolResultContinuation).toBe(true);
272
272
  });
273
273
  });
274
+
275
+ describe("user-visible tool failure (review 2026-09-05)", () => {
276
+ test("a failed tool run the user is now responding to still counts as failed", () => {
277
+ // The classifier keeps the FULL failed-tool weight when the failure is
278
+ // user-visible (not a mechanical continuation); that branch was dead
279
+ // while the scan ran only on tool-result tails.
280
+ const f = extractFeatures(
281
+ req([
282
+ SYSTEM,
283
+ { role: "user", content: "build it" },
284
+ toolCall("c1", "bash", '{"command":"make"}'),
285
+ { role: "tool", tool_call_id: "c1", content: "make: *** [all] Error 2" },
286
+ { role: "user", content: "that failed, try a different approach" },
287
+ ]),
288
+ 100,
289
+ );
290
+ expect(f.isToolResultContinuation).toBe(false);
291
+ expect(f.lastToolFailed).toBe(true);
292
+ });
293
+
294
+ test("an assistant reply between the failed run and the user resets the failure signal", () => {
295
+ const f = extractFeatures(
296
+ req([
297
+ SYSTEM,
298
+ { role: "user", content: "build it" },
299
+ toolCall("c1", "bash", '{"command":"make"}'),
300
+ { role: "tool", tool_call_id: "c1", content: "make: *** [all] Error 2" },
301
+ { role: "assistant", content: "The build failed because cc is missing." },
302
+ { role: "user", content: "ok, install it" },
303
+ ]),
304
+ 100,
305
+ );
306
+ expect(f.lastToolFailed).toBe(false);
307
+ });
308
+ });
@@ -55,4 +55,28 @@ describe("HTTP server resilience against dead streams", () => {
55
55
  const json = (await modelsRes.json()) as { data: unknown[] };
56
56
  expect(Array.isArray(json.data)).toBe(true);
57
57
  });
58
+
59
+ test("a malformed request body does not consume a concurrency slot", async () => {
60
+ // Regression: the slot was acquired before the body was parsed and only
61
+ // released in runTurn's finally, so every rejected body leaked one slot
62
+ // and 24 of them turned the router into a permanent 429.
63
+ for (let i = 0; i < 30; i++) {
64
+ const res = await fetch(`${baseUrl}/v1/chat/completions`, {
65
+ method: "POST",
66
+ headers: { "content-type": "application/json" },
67
+ body: "this is not json",
68
+ });
69
+ expect(res.status).toBe(400);
70
+ }
71
+ const res = await fetch(`${baseUrl}/v1/chat/completions`, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body: JSON.stringify({ model: "auto", stream: false, messages: [{ role: "user", content: "hello" }] }),
75
+ });
76
+ // Anything but "too many concurrent turns"; with no API key the turn
77
+ // itself fails at dispatch, which is fine here.
78
+ expect(res.status).not.toBe(429);
79
+ await res.text();
80
+ });
81
+
58
82
  });