auto-model-router 0.33.0 → 0.35.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 +10 -0
- package/docs/data-governance.md +42 -1
- package/omp-extension/router-toast.ts +14 -4
- package/omp-extension/toast-logic.ts +20 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +6 -1
- package/src/cli/config-wizard.ts +2 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/schema.ts +2 -0
- package/src/config/types.ts +9 -0
- package/src/cost/ledger-sql.ts +41 -3
- package/src/server/http.ts +1 -1
- package/src/server/providers.ts +12 -0
- package/src/upstream/anthropic.ts +2 -1
- package/src/upstream/compat.ts +4 -2
- package/src/upstream/openrouter-usage.ts +120 -0
- package/src/upstream/openrouter.ts +6 -1
- package/src/util/schema.ts +148 -19
- package/test/failover.test.ts +42 -1
- package/test/ledger-partitions.test.ts +321 -0
- package/test/openrouter-usage.test.ts +59 -0
- package/test/toast-logic.test.ts +55 -0
- package/test/turn.test.ts +1 -1
- package/test/upstreams.test.ts +26 -0
package/test/toast-logic.test.ts
CHANGED
|
@@ -176,6 +176,61 @@ describe("selectToasts", () => {
|
|
|
176
176
|
expect(toasts).toHaveLength(1);
|
|
177
177
|
expect(toasts[0]?.model).toBe("keep");
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
test("task (subagent) turns toast even though they carry their own session id", async () => {
|
|
181
|
+
// A task's dispatches carry the subagent's own session id and the
|
|
182
|
+
// isSubagent flag, so the session filter must not drop them.
|
|
183
|
+
const entries = [
|
|
184
|
+
dec({ id: "d2", slug: "x/task-model", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
185
|
+
dec({ id: "d1", slug: "prior", ompSessionId: "sess-a" }),
|
|
186
|
+
];
|
|
187
|
+
const subModels = new Map<string, string | null>();
|
|
188
|
+
const toasts = selectToasts(entries, "d1", "", "sess-a", true, subModels);
|
|
189
|
+
expect(toasts).toHaveLength(1);
|
|
190
|
+
expect(toasts[0]?.model).toBe("x/task-model");
|
|
191
|
+
expect(toasts[0]!.text).toContain("task");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a task toasts once per model: the first dispatch and every change after", async () => {
|
|
195
|
+
const entries = [
|
|
196
|
+
dec({ id: "d5", slug: "x/m2", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
197
|
+
dec({ id: "d4", slug: "x/m1", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
198
|
+
dec({ id: "d3", slug: "x/m1", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
199
|
+
dec({ id: "d2", slug: "x/m1", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
200
|
+
];
|
|
201
|
+
const subModels = new Map<string, string | null>();
|
|
202
|
+
const first = selectToasts(entries, "d1", "", "sess-a", true, subModels);
|
|
203
|
+
// oldest→newest: m1 (first dispatch toasts), m1 (skip), m1 (skip), m2 (change, toasts)
|
|
204
|
+
expect(first.map((t) => t.model)).toEqual(["x/m1", "x/m2"]);
|
|
205
|
+
// The next tick remembers the task's last model: the same model again is not news.
|
|
206
|
+
const again = selectToasts([dec({ id: "d6", slug: "x/m2", ompSessionId: "task-1", features: { isSubagent: true } })], "d5", "", "sess-a", true, subModels);
|
|
207
|
+
expect(again).toHaveLength(0);
|
|
208
|
+
// A second task with its own session id toasts its own first dispatch.
|
|
209
|
+
const other = selectToasts([dec({ id: "d7", slug: "x/m3", ompSessionId: "task-2", features: { isSubagent: true } })], "d6", "", "sess-a", true, subModels);
|
|
210
|
+
expect(other).toHaveLength(1);
|
|
211
|
+
expect(other[0]?.model).toBe("x/m3");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("task toasts respect the harness filter: another member's tasks stay out", async () => {
|
|
215
|
+
const entries = [
|
|
216
|
+
dec({ id: "d2", slug: "theirs", ompSessionId: "task-9", harnessId: "member-b", features: { isSubagent: true } }),
|
|
217
|
+
dec({ id: "d1", slug: "mine", ompSessionId: "task-8", harnessId: "member-a", features: { isSubagent: true } }),
|
|
218
|
+
dec({ id: "d0", slug: "prior", ompSessionId: "sess-a", harnessId: "member-a" }),
|
|
219
|
+
];
|
|
220
|
+
const subModels = new Map<string, string | null>();
|
|
221
|
+
const toasts = selectToasts(entries, "d0", "member-a", "sess-a", true, subModels);
|
|
222
|
+
expect(toasts).toHaveLength(1);
|
|
223
|
+
expect(toasts[0]?.model).toBe("mine");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a main-session turn still toasts every dispatch, unchanged", async () => {
|
|
227
|
+
const entries = [
|
|
228
|
+
dec({ id: "d3", slug: "x/m1", ompSessionId: "sess-a" }),
|
|
229
|
+
dec({ id: "d2", slug: "x/m1", ompSessionId: "sess-a" }),
|
|
230
|
+
dec({ id: "d1", slug: "prior", ompSessionId: "sess-a" }),
|
|
231
|
+
];
|
|
232
|
+
expect(selectToasts(entries, "d1", "", "sess-a", true)).toHaveLength(2);
|
|
233
|
+
});
|
|
179
234
|
});
|
|
180
235
|
|
|
181
236
|
describe("toToastText", () => {
|
package/test/turn.test.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type {
|
|
|
34
34
|
function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
35
35
|
return {
|
|
36
36
|
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
|
|
37
|
-
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
37
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0, minCreditsUsd: 0, usagePollMs: 0 },
|
|
38
38
|
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, planCreditsUsd: 0 },
|
|
39
39
|
upstreams: [],
|
|
40
40
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
package/test/upstreams.test.ts
CHANGED
|
@@ -193,6 +193,8 @@ describe("the OpenAI-compatible client", () => {
|
|
|
193
193
|
test("statuses: OpenAI's insufficient_quota 429 is the account, a plain 429 the moment; 400 context is final", async () => {
|
|
194
194
|
expect(classifyCompatStatus("x", 429, { error: { code: "insufficient_quota", message: "You exceeded your current quota" } })).toMatchObject({ kind: "quota", retryable: true });
|
|
195
195
|
expect(classifyCompatStatus("x", 429, { error: { message: "Rate limit reached" } })).toMatchObject({ kind: "rate_limit", retryable: true });
|
|
196
|
+
// Kimi's balance wording: 429 but the account, so the 15-minute quota cooldown hides the whole upstream.
|
|
197
|
+
expect(classifyCompatStatus("kimi", 429, { error: { message: "This request would exceed your available credits given your current in-flight requests" } })).toMatchObject({ kind: "quota", retryable: true });
|
|
196
198
|
expect(classifyCompatStatus("x", 400, { error: { message: "This model's maximum context length is 8192 tokens" } })).toMatchObject({ kind: "context_length", retryable: false });
|
|
197
199
|
expect(classifyCompatStatus("x", 401, {})).toMatchObject({ kind: "auth", retryable: false });
|
|
198
200
|
expect(classifyCompatStatus("x", 503, {})).toMatchObject({ kind: "upstream_error", retryable: true });
|
|
@@ -238,6 +240,18 @@ describe("the OpenAI-compatible client", () => {
|
|
|
238
240
|
expect(caught).toMatchObject({ kind: "quota", retryable: true });
|
|
239
241
|
expect(broke.available()).toBe(false);
|
|
240
242
|
expect(broke.lastTrip()?.kind).toBe("quota");
|
|
243
|
+
const kimiBroke = createCompatClient(cfg, "openai-direct", async () =>
|
|
244
|
+
Response.json({ error: { message: "This request would exceed your available credits given your current in-flight requests" } }, { status: 429 }),
|
|
245
|
+
);
|
|
246
|
+
caught = null;
|
|
247
|
+
try {
|
|
248
|
+
await kimiBroke.dispatch({ body: { model: "openai-direct/gpt-4o", messages: [] }, sessionId: "s", signal: new AbortController().signal });
|
|
249
|
+
} catch (err) {
|
|
250
|
+
caught = err;
|
|
251
|
+
}
|
|
252
|
+
expect(caught).toMatchObject({ kind: "quota", retryable: true });
|
|
253
|
+
expect(kimiBroke.available()).toBe(false);
|
|
254
|
+
expect(kimiBroke.lastTrip()?.kind).toBe("quota");
|
|
241
255
|
// The live key applies to the next call without a new client.
|
|
242
256
|
applyConfigPatch(cfg, { upstreams: [{ id: "openai-direct", kind: "openai", baseUrl: "https://api.openai.com/v1", apiKey: "sk-new", models: [{ id: "gpt-4o", input: 2.5, output: 10 }] }] } as never);
|
|
243
257
|
await client.dispatch({ body: { model: "openai-direct/gpt-4o", messages: [], stream: true }, sessionId: "s", signal: new AbortController().signal });
|
|
@@ -363,8 +377,20 @@ describe("the Anthropic client", () => {
|
|
|
363
377
|
}
|
|
364
378
|
expect(caught).toMatchObject({ kind: "upstream_error", retryable: true });
|
|
365
379
|
expect(overloaded.available()).toBe(false);
|
|
380
|
+
const billedOut = createAnthropicClient(cfg, "anthropic-direct", async () => Response.json({ error: { message: "Your credit balance is too low" } }, { status: 402 }));
|
|
381
|
+
caught = null;
|
|
382
|
+
try {
|
|
383
|
+
await billedOut.dispatch({ body: { model: "anthropic-direct/claude-sonnet-4", messages: [] }, sessionId: "s", signal: new AbortController().signal });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
caught = err;
|
|
386
|
+
}
|
|
387
|
+
expect(caught).toMatchObject({ kind: "quota", retryable: true });
|
|
388
|
+
expect(billedOut.available()).toBe(false);
|
|
389
|
+
expect(billedOut.lastTrip()?.kind).toBe("quota");
|
|
366
390
|
expect(classifyAnthropicStatus("a", 400, { error: { message: "prompt is too long: 250000 tokens" } })).toMatchObject({ kind: "context_length", retryable: false });
|
|
367
391
|
expect(classifyAnthropicStatus("a", 401, {})).toMatchObject({ kind: "auth", retryable: false });
|
|
392
|
+
// The credit card said no (402): the account, not the moment — quota, and the breaker hides the upstream.
|
|
393
|
+
expect(classifyAnthropicStatus("a", 402, { error: { message: "Your credit balance is too low" } })).toMatchObject({ kind: "quota", retryable: true });
|
|
368
394
|
});
|
|
369
395
|
|
|
370
396
|
test("a subscription upstream: OAuth bearer instead of x-api-key, and the Claude Code identity leads the system blocks", async () => {
|