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.
@@ -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 },
@@ -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 () => {