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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +208 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +115 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +189 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +43 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +27 -0
- package/src/config/types.ts +114 -2
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +340 -0
- package/src/cost/types.ts +33 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +52 -4
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +10 -0
- package/src/server/http.ts +47 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +122 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +157 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +506 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +341 -0
- package/test/report-logic.test.ts +92 -0
- package/test/report.test.ts +217 -0
- package/test/select.test.ts +151 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- 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
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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,
|
|
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", () => {
|
package/test/failover.test.ts
CHANGED
|
@@ -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
|
+
|
package/test/features.test.ts
CHANGED
|
@@ -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
|
});
|