auto-model-router 0.34.0 → 0.36.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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.34.0",
10
+ "version": "0.36.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.34.0",
17
+ "version": "0.36.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
@@ -27,7 +27,8 @@
27
27
 
28
28
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
29
29
 
30
- import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
30
+ import { routerAuthHeaders, routerBaseUrl, routerHome } from "./router-url.ts";
31
+ import { readRemoteRouter } from "./remote-logic.ts";
31
32
  import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
32
33
 
33
34
  /** Raw router config.yml, or null when there is none to read. */
@@ -35,8 +36,12 @@ import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
35
36
  /** Absolute path of the shared embed port file (main session writes it). */
36
37
 
37
38
  // This harness's id, matching the X-Omp-Harness header the router records.
38
- // Empty toast every harness (single-harness default).
39
- const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
39
+ // In remote (team) mode the team rewrites that header to the member id from
40
+ // the key, so remote.json's userId is the same fact — and the only way to
41
+ // keep OTHER members' task toasts out, because task turns carry their own
42
+ // session id and cannot be scoped by session. Empty ⇒ toast every harness
43
+ // (single-harness default).
44
+ const HARNESS_ID = process.env.OMP_HARNESS_ID ?? readRemoteRouter(routerHome())?.userId ?? "";
40
45
  const POLL_MS = 2_000;
41
46
  // The toast explains the choice by default: model, tier, cost, why it was picked
42
47
  // and what it was handed. `AUTO_MODEL_ROUTER_TOAST=compact` restores the one-liner.
@@ -47,6 +52,11 @@ export default function (pi: ExtensionAPI): void {
47
52
 
48
53
  // The newest ledger entry already toasted. Ledger is `created_at_ms DESC`.
49
54
  let lastSeenId: string | null = null;
55
+ // Task session → the model the task last toasted. Task turns belong to
56
+ // their own session ids, so this — not the session filter — is what keeps
57
+ // a task's forty same-model dispatches down to one toast, and surfaces the
58
+ // mid-task model change when an escalation switches the model.
59
+ const taskModels = new Map<string, string | null>();
50
60
 
51
61
  pi.on("session_start", (_event, ctx) => {
52
62
  // Headless/print/subagent sessions have no UI to toast into; skip the
@@ -94,7 +104,7 @@ export default function (pi: ExtensionAPI): void {
94
104
  const entries = body.entries;
95
105
  if (!Array.isArray(entries) || entries.length === 0) return;
96
106
 
97
- for (const t of selectToasts(entries, lastSeenId, HARNESS_ID, sessionId, VERBOSE)) {
107
+ for (const t of selectToasts(entries, lastSeenId, HARNESS_ID, sessionId, VERBOSE, taskModels)) {
98
108
  ctx.ui.notify(t.text, "info");
99
109
  }
100
110
  lastSeenId = newestId(entries) ?? lastSeenId;
@@ -91,7 +91,7 @@ export interface ToastDecision {
91
91
  /** The router's own decision trail, already written for people. */
92
92
  reasons?: string[];
93
93
  /** Classifier inputs; only a few are worth surfacing. */
94
- features?: { promptTokens?: number; toolCount?: number; turnDepth?: number; isToolResultContinuation?: boolean } | null;
94
+ features?: { promptTokens?: number; toolCount?: number; turnDepth?: number; isToolResultContinuation?: boolean; isSubagent?: boolean } | null;
95
95
  /** Attempt index within the turn; >0 means this served after an escalation. */
96
96
  attempt?: number;
97
97
  /** Prompt tokens compaction removed before dispatch. */
@@ -172,6 +172,7 @@ const tokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 100) / 10}k
172
172
  export function factsOf(d: ToastDecision): string[] {
173
173
  const out: string[] = [];
174
174
  const f = d.features ?? undefined;
175
+ if (f?.isSubagent === true) out.push("task");
175
176
  if (f?.promptTokens !== undefined && f.promptTokens > 0) out.push(`${tokens(f.promptTokens)} prompt`);
176
177
  if (d.promptTokensSaved !== undefined && d.promptTokensSaved > 0) out.push(`${tokens(d.promptTokensSaved)} compacted`);
177
178
  if (f?.toolCount !== undefined && f.toolCount > 0) out.push(`${f.toolCount} tools`);
@@ -215,6 +216,14 @@ export function toToastText(d: ToastDecision, verbose = true): string {
215
216
  *
216
217
  * When `harnessId` is non-empty, only entries from that harness are toasted,
217
218
  * so multiple harnesses sharing one router don't spam each other's toasts.
219
+ *
220
+ * `ompSessionId` scopes the toast to this interactive session's own turns.
221
+ * Subagent (task) turns carry their OWN session id — the subagent process's —
222
+ * so they never match it and would otherwise be invisible. They are admitted
223
+ * whenever the harness matches, deduplicated per task session on the served
224
+ * model through `subModels` (the caller holds the map across ticks): the
225
+ * first dispatch of a task toasts, a mid-task model change (an escalation)
226
+ * toasts again, and the forty same-model dispatches after it toast nothing.
218
227
  */
219
228
  export function selectToasts(
220
229
  entries: ToastDecision[],
@@ -222,6 +231,7 @@ export function selectToasts(
222
231
  harnessId = "",
223
232
  ompSessionId = "",
224
233
  verbose = true,
234
+ subModels?: Map<string, string | null>,
225
235
  ): ToastMessage[] {
226
236
  if (lastSeenId === null) return [];
227
237
  // `entries` is newest-first. Entries strictly newer than lastSeenId are the
@@ -235,7 +245,15 @@ export function selectToasts(
235
245
  if (d === undefined) continue;
236
246
  if (d.wasted) continue;
237
247
  if (harnessId !== "" && d.harnessId !== harnessId) continue;
238
- if (ompSessionId !== "" && d.ompSessionId !== ompSessionId) continue;
248
+ const isTask = d.features?.isSubagent === true;
249
+ if (!isTask && ompSessionId !== "" && d.ompSessionId !== ompSessionId) continue;
250
+ if (isTask && subModels !== undefined) {
251
+ const session = d.ompSessionId ?? "";
252
+ const model = d.servedSlug ?? d.slug;
253
+ // Same task, same model as last toasted: not a change, not news.
254
+ if (subModels.get(session) === model) continue;
255
+ subModels.set(session, model);
256
+ }
239
257
  out.push({ model: d.servedSlug ?? d.slug, tier: d.tier, costUsd: d.reportedUsd, text: toToastText(d, verbose) });
240
258
  }
241
259
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
 
22
22
  import type { OllamaAvailability } from "../upstream/ollama.ts";
23
23
  import { effectiveOllamaBias, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
24
+ import { openRouterServing, type OpenRouterCredits } from "../upstream/openrouter-usage.ts";
24
25
  import { mergeSnapshots, type OllamaCatalogSource } from "./ollama-catalog.ts";
25
26
  import type { CatalogModel, CatalogShrink, CatalogSnapshot, CatalogSource } from "./types.ts";
26
27
 
@@ -34,6 +35,10 @@ export interface CompositeBias {
34
35
  live?: () => { costBias: number; biasUntilUsage: number };
35
36
  /** False when OpenRouter cannot dispatch (no key): its models are listed for metadata only, never served. Default true. */
36
37
  serveOpenRouter?: () => boolean;
38
+ /** Last known OpenRouter credit balance (USD), or null when never fetched. Read every combine, no network. */
39
+ openRouterCredits?: () => OpenRouterCredits | null;
40
+ /** Balance at or below which OpenRouter stops serving. 0 disables the gate. */
41
+ minCreditsUsd?: number;
37
42
  /** Named upstreams' models, built from the OpenRouter models (twins) and filtered by each upstream's breaker. */
38
43
  named?: {
39
44
  models(openrouter: readonly CatalogModel[]): readonly CatalogModel[];
@@ -69,7 +74,7 @@ export function createCompositeCatalog(
69
74
 
70
75
  function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
71
76
  const available = availability.available();
72
- const serveBase = bias.serveOpenRouter?.() ?? true;
77
+ const serveBase = (bias.serveOpenRouter?.() ?? true) && openRouterServing(bias.openRouterCredits?.() ?? null, bias.minCreditsUsd ?? 0);
73
78
  const providerBias = currentBias();
74
79
  // Named upstreams: every enabled entry's models, minus those of an upstream in cooldown.
75
80
  const namedAll = bias.named?.models(base.models) ?? NO_NAMED;
@@ -128,6 +128,8 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
128
128
  { path: "openrouter.timeoutMs", label: "Request timeout", kind: "number", min: 1, hint: "ms" },
129
129
  { path: "openrouter.catalogTtlMs", label: "Catalog TTL", kind: "number", min: 1, hint: "ms" },
130
130
  { path: "openrouter.catalogRefreshMs", label: "Catalog refresh", kind: "number", min: 0, hint: "ms, 0=off" },
131
+ { path: "openrouter.minCreditsUsd", label: "Credit floor USD", kind: "number", min: 0, hint: "0=serve regardless of balance" },
132
+ { path: "openrouter.usagePollMs", label: "Credits poll", kind: "number", min: 0, hint: "ms, 0=off" },
131
133
  ],
132
134
  },
133
135
  {
@@ -32,6 +32,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
32
32
  // Refetch the key-scoped catalog every 5 minutes in the background so
33
33
  // guardrail changes are picked up without waiting for traffic + TTL.
34
34
  catalogRefreshMs: 5 * 60 * 1000,
35
+ // Stop serving OpenRouter at/below this balance (USD); top up to rejoin.
36
+ // 0 disables the gate. Mirrors ollama.blockBelowUsage's intent.
37
+ minCreditsUsd: 5,
38
+ // Balance moves slowly; 10 minutes matches the Ollama usage poll.
39
+ usagePollMs: 10 * 60 * 1000,
35
40
  },
36
41
  ollama: {
37
42
  // Off: a second upstream changes what every turn can route to.
@@ -100,6 +105,7 @@ export const DEFAULT_CONFIG: RouterConfig = {
100
105
  filters: {
101
106
  allow: [],
102
107
  deny: [],
108
+ providerLocks: {},
103
109
  // Free models are rate-limited hard enough that retries cost more than they save.
104
110
  includeFree: false,
105
111
  requireToolSupport: true,
@@ -33,6 +33,8 @@ const openrouter = z.strictObject({
33
33
  timeoutMs: z.number().positive().optional(),
34
34
  catalogTtlMs: z.number().positive().optional(),
35
35
  catalogRefreshMs: z.number().nonnegative().optional(),
36
+ minCreditsUsd: z.number().nonnegative().optional(),
37
+ usagePollMs: z.number().nonnegative().optional(),
36
38
  });
37
39
 
38
40
  const ollamaRate = z.strictObject({
@@ -70,6 +70,15 @@ export interface OpenRouterConfig {
70
70
  catalogTtlMs: number;
71
71
  /** Background catalog refresh cadence, ms. 0 disables the periodic refresh. */
72
72
  catalogRefreshMs: number;
73
+ /**
74
+ * Balance (USD, `total_credits − total_usage`) at or below which OpenRouter
75
+ * stops serving: its models drop from the catalog until the account is
76
+ * topped up. 0 disables the gate — the 402 breaker still catches the real
77
+ * thing. Default 5.
78
+ */
79
+ minCreditsUsd: number;
80
+ /** Credits poll interval, ms. 0 disables the poll (and the gate never fires). */
81
+ usagePollMs: number;
73
82
  }
74
83
 
75
84
  /**
@@ -217,6 +226,15 @@ export interface FilterConfig {
217
226
  allow: string[];
218
227
  /** Glob patterns; matching models are dropped. Applied after `allow`. */
219
228
  deny: string[];
229
+ /**
230
+ * Model-glob → provider-glob. A model matching a key may only dispatch
231
+ * through a provider whose id matches the value, so a team can keep a
232
+ * subscription's models on that subscription instead of its OpenRouter
233
+ * twins (`{"anthropic/*": "anthropic-subscription"}`). Applied before
234
+ * ranking; a model matching several locks must satisfy each. An empty
235
+ * object locks nothing.
236
+ */
237
+ providerLocks: Record<string, string>;
220
238
  /** Consider zero-price models. Off by default: rate limits make them expensive in retries. */
221
239
  includeFree: boolean;
222
240
  /** Require `supported_parameters` to include `tools` whenever the request offers tools. */
@@ -164,6 +164,12 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
164
164
  const relaxTrust = relaxLevel >= 3;
165
165
  const allowRes = filters.allow.map(globToRe);
166
166
  const denyRes = filters.deny.map(globToRe);
167
+ // Compiled once per turn: pairs of [model glob, provider glob] regexes.
168
+ // Null when there are no locks, so the common path allocates nothing.
169
+ const lockRes =
170
+ filters.providerLocks && Object.keys(filters.providerLocks).length > 0
171
+ ? Object.entries(filters.providerLocks).map(([m, p]) => [globToRe(m), globToRe(p)] as const)
172
+ : null;
167
173
  const needTools = req.tools.length > 0 && filters.requireToolSupport;
168
174
  const minContext = Math.ceil(features.promptTokens * filters.contextHeadroom) + expectedCompletionTokens;
169
175
  // Task selects the quality axis and capability filters; the tier still
@@ -237,6 +243,17 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
237
243
  rejected.push({ slug, reason: "denylisted", detail: "filters.deny" });
238
244
  continue;
239
245
  }
246
+ // Provider locks: where a model may be served from. Checked like a
247
+ // filter (a lock the model cannot satisfy drops it before ranking)
248
+ // rather than at dispatch, so the catalog view, the rejection list and
249
+ // the turn all agree on what was available.
250
+ if (lockRes !== null) {
251
+ const violated = lockRes.find(([modelRe, providerRe]) => modelRe.test(slug) && !providerRe.test(model.provider));
252
+ if (violated !== undefined) {
253
+ rejected.push({ slug, reason: "provider_locked", detail: `${slug} is locked to providers matching "${violated[1].source}"` });
254
+ continue;
255
+ }
256
+ }
240
257
  if (model.isFree && !filters.includeFree) {
241
258
  rejected.push({ slug, reason: "free_tier_excluded" });
242
259
  continue;
@@ -65,6 +65,10 @@ export function applyRequestPolicy(
65
65
  const outProfile = narrowed ? { ...profile, id: `${profile.id}+policy`, minTier, maxTier } : profile;
66
66
  if (narrowed) reasons.push(`policy: tiers narrowed to [${minTier}..${maxTier}]`);
67
67
  let outCfg = cfg;
68
+ if (policy.providerLocks !== undefined) {
69
+ outCfg = { ...cfg, filters: { ...cfg.filters, providerLocks: { ...cfg.filters.providerLocks, ...policy.providerLocks } } };
70
+ reasons.push(`policy: provider locks ${Object.entries(policy.providerLocks).map(([m, p]) => `${m}→${p}`).join(" ")}`);
71
+ }
68
72
  if (policy.allow !== undefined || policy.deny !== undefined) {
69
73
  outCfg = { ...cfg, filters: { ...cfg.filters, ...(policy.allow === undefined ? {} : { allow: policy.allow }), ...(policy.deny === undefined ? {} : { deny: [...cfg.filters.deny, ...policy.deny] }) } };
70
74
  reasons.push(`policy: ${policy.allow === undefined ? "" : `allow ${policy.allow.join("|")} `}${policy.deny === undefined ? "" : `deny ${policy.deny.join("|")}`}`.trim());
@@ -162,6 +162,8 @@ export type RejectionReason =
162
162
  | "denylisted"
163
163
  | "not_allowlisted"
164
164
  | "free_tier_excluded"
165
+ /** filters.providerLocks: the model matches a lock whose provider glob excludes its upstream. */
166
+ | "provider_locked"
165
167
  | "reasoning_mandatory"
166
168
  | "untrusted"
167
169
  /** The turn's `X-Omp-Upstream-Keys` names this model's upstream with an empty credential: it cannot be dispatched to. */
@@ -49,7 +49,7 @@ export interface CatalogView {
49
49
  }
50
50
 
51
51
  /** The filters a turn routes under: the configured ones, or those `applyRequestPolicy` merged a policy into. */
52
- export type AdmissionFilters = Pick<FilterConfig, "allow" | "deny" | "includeFree" | "requireToolSupport">;
52
+ export type AdmissionFilters = Pick<FilterConfig, "allow" | "deny" | "providerLocks" | "includeFree" | "requireToolSupport">;
53
53
 
54
54
  export interface CatalogViewArgs {
55
55
  models: readonly CatalogModel[];
@@ -84,11 +84,17 @@ function filterReason(model: CatalogModel, filters: AdmissionFilters, allowRes:
84
84
  if (allowRes.length > 0 && !allowRes.some((re) => re.test(model.slug))) return "not in the allow list";
85
85
  const denied = denyRes.findIndex((re) => re.test(model.slug));
86
86
  if (denied !== -1) return `denied by ${filters.deny[denied]}`;
87
+ if (filters.providerLocks) {
88
+ for (const [modelGlob, providerGlob] of Object.entries(filters.providerLocks)) {
89
+ if (globToRe(modelGlob).test(model.slug) && !globToRe(providerGlob).test(model.provider)) {
90
+ return `locked to providers matching ${providerGlob} (filters.providerLocks)`;
91
+ }
92
+ }
93
+ }
87
94
  if (model.isFree && !filters.includeFree) return "free models excluded (filters.includeFree)";
88
95
  if (filters.requireToolSupport && !model.supportsTools) return "no tool support (filters.requireToolSupport)";
89
96
  return null;
90
97
  }
91
-
92
98
  export function catalogView(args: CatalogViewArgs): CatalogView {
93
99
  const sorted = [...args.models].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
94
100
  const verdict = args.verdict;
@@ -19,6 +19,7 @@ import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
19
19
  import { setKnownUpstreamIds } from "../cost/report.ts";
20
20
  import { createOllamaUsageSource, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
21
21
  import { createOpenRouterClient } from "../upstream/openrouter.ts";
22
+ import { createOpenRouterUsageSource } from "../upstream/openrouter-usage.ts";
22
23
  import type { UpstreamClient } from "../upstream/types.ts";
23
24
  import { createLogger, type Logger } from "../util/log.ts";
24
25
 
@@ -75,6 +76,15 @@ export function createProviders(
75
76
  // estimate can be scaled to what ollama.com actually bills.
76
77
  calibration: { db: sqlDb, ledgerUsd: ollamaLedgerUsd, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
77
78
  });
79
+ // OpenRouter's credit balance on the same slow cadence: at/below
80
+ // `openrouter.minCreditsUsd` its models drop from the catalog until the
81
+ // account is topped up (a 402 trip only blocks for the rate-limit minute).
82
+ const openrouterUsage = createOpenRouterUsageSource({
83
+ apiKey: () => cfg.openrouter.apiKey,
84
+ pollMs: cfg.openrouter.usagePollMs,
85
+ timeoutMs: 15_000,
86
+ log,
87
+ });
78
88
  // Named upstreams (OpenAI, Azure, Anthropic, vLLM…): a client per id, built when
79
89
  // first needed and kept — its breaker state must survive config reloads — while
80
90
  // the entry it reads is looked up live, so a changed key or URL applies at once.
@@ -105,6 +115,8 @@ export function createProviders(
105
115
  usage: ollamaUsage,
106
116
  live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
107
117
  serveOpenRouter: () => cfg.openrouter.apiKey !== "",
118
+ openRouterCredits: () => openrouterUsage.peek(),
119
+ minCreditsUsd: cfg.openrouter.minCreditsUsd,
108
120
  named: { models: (base) => staticCatalog.get(base), serving: namedServingOne, bias: (id) => cfg.upstreams.find((u) => u.id === id)?.costBias ?? 1 },
109
121
  }),
110
122
  ollama,
@@ -340,6 +340,7 @@ export function classifyAnthropicStatus(id: string, status: number, body: unknow
340
340
  const message = typeof msg === "string" && msg !== "" ? msg : `${id} HTTP ${status}`;
341
341
  const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError => new UpstreamError(kind, status, message, retryable, body);
342
342
  if (status === 401 || status === 403) return fail("auth", false);
343
+ if (status === 402) return fail("quota", true);
343
344
  if (status === 404) return fail("model_unavailable", true);
344
345
  if (status === 413) return fail("context_length", false);
345
346
  if (status === 429) return fail("rate_limit", true);
@@ -431,7 +432,7 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
431
432
  /* status alone */
432
433
  }
433
434
  const err = classifyAnthropicStatus(id, res.status, body);
434
- if (err.kind === "rate_limit" || res.status === 529) breaker.trip(err);
435
+ if (err.kind === "rate_limit" || err.kind === "quota" || res.status === 529) breaker.trip(err);
435
436
  return err;
436
437
  }
437
438
 
@@ -94,8 +94,10 @@ export function classifyCompatStatus(id: string, status: number, body: unknown):
94
94
  if (status === 401) return fail("auth", false);
95
95
  if (status === 402) return fail("quota", true);
96
96
  if (status === 403) return /credit|quota|plan|limit|billing/i.test(message) ? fail("quota", true) : fail("moderation", true);
97
- // OpenAI reports an exhausted balance as a 429 with insufficient_quota: the account, not the moment.
98
- if (status === 429) return /insufficient_quota|exceeded your current quota/i.test(`${code} ${message}`) ? fail("quota", true) : fail("rate_limit", true);
97
+ // OpenAI reports an exhausted balance as a 429 with insufficient_quota; Kimi
98
+ // ("exceed your available credits given your current in-flight requests") says
99
+ // credits. Both are the account, not the moment.
100
+ if (status === 429) return /insufficient_quota|exceeded your current quota|available credits|in-flight requests/i.test(`${code} ${message}`) ? fail("quota", true) : fail("rate_limit", true);
99
101
  if (status === 404) return fail("model_unavailable", true);
100
102
  if (status === 400 || status === 413 || status === 422) {
101
103
  if (/context|too many tokens|token limit|maximum context|too long/i.test(message)) return fail("context_length", false);
@@ -0,0 +1,120 @@
1
+ /**
2
+ * OpenRouter credit balance, polled on a slow interval and read without
3
+ * network on every routing decision (`GET /api/v1/credits`: management-key
4
+ * scope, `data.total_credits - data.total_usage` = the balance that gates a
5
+ * dispatch — OpenRouter reserves credits per in-flight request, so the
6
+ * UNRESERVED balance is what a 402 means).
7
+ *
8
+ * Mirrors the Ollama usage source: a key that is empty now may be set from
9
+ * the dashboard later, so the reader stays live and idles until it is not.
10
+ * A failed or unparseable poll keeps the last reading — the balance moves
11
+ * slowly, and hiding a provider on a poll glitch would route around it for
12
+ * nothing.
13
+ */
14
+
15
+ import type { Logger } from "../util/log.ts";
16
+
17
+ /** Minimal fetch surface, injectable for tests. */
18
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
19
+
20
+ export interface OpenRouterCredits {
21
+ /** Balance in USD: total_credits − total_usage. Null when the payload lacks it. */
22
+ remainingUsd: number | null;
23
+ /** Lifetime credits bought and used, for the dashboard. */
24
+ totalCreditsUsd: number | null;
25
+ totalUsageUsd: number | null;
26
+ fetchedAtMs: number;
27
+ }
28
+
29
+ export interface OpenRouterUsageSource {
30
+ /** Latest reading, refreshed when older than the poll interval; last good value on failure. */
31
+ get(): Promise<OpenRouterCredits | null>;
32
+ /** Last fetched value without touching the network. */
33
+ peek(): OpenRouterCredits | null;
34
+ }
35
+
36
+ export const NO_OPENROUTER_USAGE: OpenRouterUsageSource = { get: async () => null, peek: () => null };
37
+
38
+ function parseCredits(json: unknown): OpenRouterCredits | null {
39
+ if (typeof json !== "object" || json === null || !("data" in json)) return null;
40
+ const data: unknown = json.data;
41
+ if (typeof data !== "object" || data === null) return null;
42
+ const d = data as Record<string, unknown>; // narrowed above; cast names the wire shape once
43
+ const total = typeof d.total_credits === "number" ? d.total_credits : null;
44
+ const used = typeof d.total_usage === "number" ? d.total_usage : null;
45
+ if (total === null || used === null) return null;
46
+ return { remainingUsd: total - used, totalCreditsUsd: total, totalUsageUsd: used, fetchedAtMs: Date.now() };
47
+ }
48
+
49
+ export function createOpenRouterUsageSource(opts: {
50
+ apiKey: () => string;
51
+ pollMs: number;
52
+ timeoutMs: number;
53
+ log: Logger;
54
+ fetchImpl?: FetchLike;
55
+ root?: string;
56
+ }): OpenRouterUsageSource {
57
+ if (opts.pollMs <= 0) return NO_OPENROUTER_USAGE;
58
+ const fetchImpl = opts.fetchImpl ?? fetch;
59
+ const root = (opts.root ?? "https://openrouter.ai/api/v1").replace(/\/+$/, "");
60
+ let current: OpenRouterCredits | null = null;
61
+ let checkedAtMs = 0;
62
+ let inflight: Promise<OpenRouterCredits | null> | null = null;
63
+ let warned = false;
64
+
65
+ async function refresh(): Promise<OpenRouterCredits | null> {
66
+ try {
67
+ const res = await fetchImpl(`${root}/credits`, {
68
+ headers: { authorization: `Bearer ${opts.apiKey()}` },
69
+ signal: AbortSignal.timeout(opts.timeoutMs),
70
+ });
71
+ if (res.ok) {
72
+ const parsed = parseCredits(await res.json());
73
+ if (parsed !== null) {
74
+ current = parsed;
75
+ warned = false;
76
+ } else if (!warned) {
77
+ warned = true;
78
+ opts.log.warn("openrouter credits payload had no recognisable fields; credit gate keeps its last reading");
79
+ }
80
+ } else if (!warned) {
81
+ warned = true;
82
+ opts.log.warn("openrouter credits endpoint unavailable; credit gate keeps its last reading", { status: res.status });
83
+ }
84
+ } catch (err) {
85
+ if (!warned) {
86
+ warned = true;
87
+ opts.log.warn("openrouter credits fetch failed; credit gate keeps its last reading", {
88
+ error: err instanceof Error ? err.message : String(err),
89
+ });
90
+ }
91
+ }
92
+ checkedAtMs = Date.now();
93
+ return current;
94
+ }
95
+
96
+ return {
97
+ async get() {
98
+ if (opts.apiKey() === "") return null;
99
+ if (Date.now() - checkedAtMs < opts.pollMs) return current;
100
+ inflight ??= refresh().finally(() => {
101
+ inflight = null;
102
+ });
103
+ return inflight;
104
+ },
105
+ peek: () => current,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * True while OpenRouter may serve: the balance is strictly above the floor, or
111
+ * unknown (a poll that has not landed yet, no key for the endpoint, a payload
112
+ * change). Hiding on unknown would take a provider down for a dashboard's
113
+ * missing field — fail open instead; the 402 breaker still catches the real
114
+ * thing.
115
+ */
116
+ export function openRouterServing(credits: OpenRouterCredits | null, minCreditsUsd: number): boolean {
117
+ if (minCreditsUsd <= 0) return true;
118
+ if (credits === null || credits.remainingUsd === null) return true;
119
+ return credits.remainingUsd > minCreditsUsd;
120
+ }
@@ -55,7 +55,12 @@ function classifyStatus(status: number, body: unknown): UpstreamError {
55
55
  // retrying is pointless.
56
56
  if (status === 401) return fail("auth", false);
57
57
  // 402 = out of credits; retrying changes nothing, only topping up does.
58
- if (status === 402) return fail("auth", false);
58
+ // EXCEPT the concurrency shape: OpenRouter reserves credits for in-flight
59
+ // requests, so a burst can exhaust the UNRESERVED balance on an account
60
+ // with plenty left ("would exceed your available credits given your
61
+ // current in-flight requests"). That clears when the streams settle —
62
+ // a moment, not an account state — so fail over like a rate limit.
63
+ if (status === 402) return /in-flight requests/i.test(message) ? fail("rate_limit", true) : fail("auth", false);
59
64
  // 403 = provider content-moderation or per-model policy gate (prompt-injection
60
65
  // block, age/data-policy confirmation). This indicts the model/provider, NOT
61
66
  // the key: siblings routinely serve the same content. Retryable so the turn
package/src/wire/types.ts CHANGED
@@ -17,6 +17,11 @@ export type WireProtocol = "openai-chat" | "openai-responses" | "anthropic-messa
17
17
  * like `filters.allow`/`filters.deny` (a request allow list replaces the
18
18
  * configured one; a deny list adds to it); `minTier`/`maxTier` narrow the
19
19
  * profile's tier envelope; `pin` forces one slug, like `/router pin`.
20
+ * `providerLocks` restricts WHERE a model may be served from: a model whose
21
+ * slug matches the key may only dispatch through a provider whose id matches
22
+ * the value (`{"anthropic/*": "anthropic-subscription"}` keeps Claude on the
23
+ * subscription upstream and away from OpenRouter's billed twins). Keys and
24
+ * values are slug globs; a model matching several locks must satisfy each.
20
25
  */
21
26
  export interface RequestPolicy {
22
27
  allow?: string[];
@@ -24,6 +29,7 @@ export interface RequestPolicy {
24
29
  minTier?: "trivial" | "simple" | "moderate" | "hard";
25
30
  maxTier?: "trivial" | "simple" | "moderate" | "hard";
26
31
  pin?: string;
32
+ providerLocks?: Record<string, string>;
27
33
  }
28
34
 
29
35
  export type Role = "system" | "developer" | "user" | "assistant" | "tool";
@@ -137,8 +137,19 @@ describe("catalogView", () => {
137
137
  // So is a pin naming no model.
138
138
  expect(bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters, pin: "nobody/here" }, unserved: served })).get("anthropic/claude-sonnet-5")!.admitted).toBe(true);
139
139
  });
140
+
141
+ test("a provider lock drops a model whose upstream cannot serve it", async () => {
142
+ // MODELS are all openrouter-served except vllm/ and azure-eu/; a lock
143
+ // confining anthropic to a named subscription upstream admits only the
144
+ // models that upstream actually carries.
145
+ const filters = { ...DEFAULT_CONFIG.filters, providerLocks: { "anthropic/*": "claude-sub" } };
146
+ const view = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters }, unserved: served }));
147
+ expect(view.get("anthropic/claude-sonnet-5")).toMatchObject({ admitted: false, reason: "locked to providers matching claude-sub (filters.providerLocks)" });
148
+ expect(view.get("openai/gpt-5")).toMatchObject({ admitted: true }); // no key matches
149
+ });
140
150
  });
141
151
 
152
+
142
153
  describe("GET /v1/router/catalog", () => {
143
154
  let handle: StartedServer;
144
155
  let empty: StartedServer;
@@ -31,7 +31,7 @@ import type {
31
31
  function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
32
32
  return {
33
33
  server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
34
- openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
34
+ openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0, minCreditsUsd: 0, usagePollMs: 0 },
35
35
  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 },
36
36
  upstreams: [],
37
37
  benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
@@ -48,7 +48,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
48
48
  data: { axis: "intelligence", minQuality: 0 },
49
49
  chat: { axis: "intelligence", minQuality: 0 },
50
50
  },
51
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, agenticAxisForToolTurns: true, minAgenticForToolTurns: 0, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
51
+ filters: { allow: [], deny: [], providerLocks: {}, includeFree: false, requireToolSupport: true, agenticAxisForToolTurns: true, minAgenticForToolTurns: 0, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
52
52
  classifier: {
53
53
  ambiguityThreshold: 0,
54
54
  model: "test/adjudicator", learnedModelPath: "",
@@ -380,6 +380,33 @@ describe("same-tier failover", () => {
380
380
  expect(finishes[0]!.attempts).toBe(2);
381
381
  });
382
382
 
383
+ test("a Kimi credit 429 fails over to another provider's model in the SAME turn", async () => {
384
+ // Moonshot's balance wording misclassified as rate_limit used to let the
385
+ // raw 429 (Retry-After 30 min) reach the client. Quota is retryable
386
+ // elsewhere: the retry re-routes with the failed slug excluded.
387
+ const { router, calls } = mkRouter([mkDecision("moderate", "kimi/kimi-k3"), mkDecision("moderate", "openrouter/grok-4")]);
388
+ const { upstream, calls: dispatches } = mkUpstream([
389
+ { kind: "fail", error: new UpstreamError("quota", 429, "This request would exceed your available credits given your current in-flight requests", true) },
390
+ { kind: "chunks", chunks: okChunks("openrouter/grok-4") },
391
+ ]);
392
+ const { ledger, entries } = mkLedger();
393
+ const { store } = mkConversations();
394
+ const { sink, errors, finishes } = mkSink();
395
+
396
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
397
+
398
+ expect(errors).toHaveLength(0);
399
+ expect(finishes).toHaveLength(1);
400
+ expect(calls).toHaveLength(2);
401
+ expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["kimi/kimi-k3"] });
402
+ expect(dispatches.map((d) => d.body.model)).toEqual(["kimi/kimi-k3", "openrouter/grok-4"]);
403
+ expect(entries[0]!.error).toContain("available credits");
404
+ expect(entries[0]!.wasted).toBe(true);
405
+ expect(entries[1]!.wasted).toBe(false);
406
+ expect(finishes[0]!.servedSlug).toBe("openrouter/grok-4");
407
+ expect(finishes[0]!.escalated).toBe(false);
408
+ });
409
+
383
410
  test("a 403 moderation block fails over to a different model in the same tier", async () => {
384
411
  const { router, calls } = mkRouter([mkDecision("trivial", "a/model"), mkDecision("trivial", "b/model")]);
385
412
  const { upstream, calls: dispatches } = mkUpstream([
@@ -664,6 +691,7 @@ describe("same-tier failover", () => {
664
691
 
665
692
  });
666
693
 
694
+
667
695
  describe("400 classification (review 2026-09-05 follow-up)", () => {
668
696
  test("a 400 naming a model capability limit is retryable, so failover picks a sibling", async () => {
669
697
  const e = classifyUpstreamStatus(400, { error: { message: "This model only supports single tool-calls at once!" } });
@@ -677,6 +705,19 @@ describe("400 classification (review 2026-09-05 follow-up)", () => {
677
705
  expect(e.retryable).toBe(false);
678
706
  });
679
707
 
708
+ test("an OpenRouter 402 naming in-flight requests is a concurrency throttle, retryable like a rate limit", async () => {
709
+ // OpenRouter reserves credits per in-flight request: a burst can exhaust
710
+ // the UNRESERVED balance on an account with plenty left. It clears when
711
+ // the streams settle — fail over, never surface to the client.
712
+ const e = classifyUpstreamStatus(402, { error: { message: "This request would exceed your available credits given your current in-flight requests. Retry after in-flight requests settle, or add credits." } });
713
+ expect(e.kind).toBe("rate_limit");
714
+ expect(e.retryable).toBe(true);
715
+ // A plain 402 (balance actually gone) stays final.
716
+ const broke = classifyUpstreamStatus(402, { error: { message: "Insufficient credits" } });
717
+ expect(broke.kind).toBe("auth");
718
+ expect(broke.retryable).toBe(false);
719
+ });
720
+
680
721
  test("a 400 for context overflow is still context_length", async () => {
681
722
  const e = classifyUpstreamStatus(400, { error: { message: "This endpoint's maximum context length is 131072 tokens" } });
682
723
  expect(e.kind).toBe("context_length");
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createOpenRouterUsageSource, NO_OPENROUTER_USAGE, openRouterServing, type OpenRouterCredits } from "../src/upstream/openrouter-usage.ts";
3
+ import { createLogger } from "../src/util/log.ts";
4
+
5
+ const log = createLogger("error");
6
+
7
+ function credits(remaining: number | null): OpenRouterCredits | null {
8
+ return remaining === null ? null : { remainingUsd: remaining, totalCreditsUsd: 100, totalUsageUsd: 100 - remaining, fetchedAtMs: 1 };
9
+ }
10
+
11
+ describe("openRouterServing", () => {
12
+ test("serves above the floor and stops at/below it", () => {
13
+ expect(openRouterServing(credits(5.01), 5)).toBe(true);
14
+ expect(openRouterServing(credits(5), 5)).toBe(false);
15
+ expect(openRouterServing(credits(0), 5)).toBe(false);
16
+ });
17
+
18
+ test("fails open on unknown balance and when the gate is off", () => {
19
+ expect(openRouterServing(null, 5)).toBe(true);
20
+ expect(openRouterServing(credits(0), 0)).toBe(true);
21
+ });
22
+ });
23
+
24
+ describe("createOpenRouterUsageSource", () => {
25
+ test("returns null without a key and parses the credits payload", async () => {
26
+ const unkeyed = createOpenRouterUsageSource({ apiKey: () => "", pollMs: 1000, timeoutMs: 100, log, root: "https://x/v1" });
27
+ expect(await unkeyed.get()).toBe(null);
28
+ expect(NO_OPENROUTER_USAGE.peek()).toBe(null);
29
+
30
+ let calls = 0;
31
+ const src = createOpenRouterUsageSource({
32
+ apiKey: () => "k",
33
+ pollMs: 1000,
34
+ timeoutMs: 100,
35
+ log,
36
+ root: "https://x/v1",
37
+ fetchImpl: async () => {
38
+ calls++;
39
+ return new Response(JSON.stringify({ data: { total_credits: 20, total_usage: 13.5 } }), { status: 200 });
40
+ },
41
+ });
42
+ const v = await src.get();
43
+ expect(calls).toBe(1);
44
+ expect(v?.remainingUsd).toBe(6.5);
45
+ expect(src.peek()?.totalUsageUsd).toBe(13.5);
46
+
47
+ // A failed poll keeps the last good reading instead of hiding the provider.
48
+ const failing = createOpenRouterUsageSource({
49
+ apiKey: () => "k",
50
+ pollMs: 0,
51
+ timeoutMs: 100,
52
+ log,
53
+ root: "https://x/v1",
54
+ fetchImpl: async () => new Response("nope", { status: 500 }),
55
+ });
56
+ await failing.get();
57
+ expect(failing.peek()).toBe(null); // never had a reading; gate stays open on unknown
58
+ });
59
+ });
@@ -404,6 +404,57 @@ describe("adaptive price ceilings", () => {
404
404
  });
405
405
  });
406
406
 
407
+ describe("provider locks", () => {
408
+ const req = parseChatRequest(
409
+ {
410
+ model: "auto",
411
+ messages: [{ role: "user", content: "summarize the release notes" }],
412
+ },
413
+ new Headers(),
414
+ );
415
+ const features = extractFeatures(req, 100);
416
+
417
+ // claude/opus from the subscription upstream (cheap for a subscriber) and
418
+ // its expensive OpenRouter twin, priced as OpenRouter really bills.
419
+ const mk = (slug: string, provider: string, inPerMtok: number) => ({
420
+ slug, canonicalSlug: slug, name: slug, provider, vendor: provider,
421
+ contextLength: 200_000, maxCompletionTokens: 32_000, supportsTools: true, supportsReasoning: false,
422
+ reasoningMandatory: false, supportsToolChoice: true, inputModalities: ["text" as const],
423
+ price: { prompt: inPerMtok / 1e6, completion: (inPerMtok * 3) / 1e6 }, priceTiers: [],
424
+ quality: { intelligence: 80, coding: 80, agentic: 60 }, tokenizer: "Other",
425
+ isFree: false, createdAtMs: 0, author: "vendor",
426
+ });
427
+ const snap = { models: [mk("anthropic/claude-opus", "anthropic-subscription", 0.5), mk("anthropic/claude-opus-openrouter", "openrouter", 15)], fetchedAtMs: Date.now(), keyScoped: false };
428
+
429
+ const run = (locks: Record<string, string>) =>
430
+ buildCandidates({
431
+ req, features, tier: "moderate", task: "chat", snapshot: snap,
432
+ cfg: { ...BASE, filters: { ...BASE.filters, providerLocks: locks } },
433
+ expectedCompletionTokens: 512, warmSlug: null,
434
+ });
435
+
436
+ test("a model matching a lock key may only come from a provider matching the value", () => {
437
+ const { candidates, rejected } = run({ "anthropic/*": "anthropic-subscription" });
438
+ expect(candidates).toHaveLength(1);
439
+ expect(candidates[0]!.model.slug).toBe("anthropic/claude-opus");
440
+ expect(candidates[0]!.model.provider).toBe("anthropic-subscription");
441
+ expect(rejected.some((r) => r.slug === "anthropic/claude-opus-openrouter" && r.reason === "provider_locked")).toBe(true);
442
+ });
443
+
444
+ test("a model no key matches is untouched, and a provider glob keeps other providers serving", () => {
445
+ const only = { models: [mk("xai/grok", "openrouter", 2), mk("anthropic/claude-opus", "openrouter", 15)], fetchedAtMs: Date.now(), keyScoped: false };
446
+ const out = buildCandidates({
447
+ req, features, tier: "moderate", task: "chat", snapshot: only,
448
+ cfg: { ...BASE, filters: { ...BASE.filters, providerLocks: { "anthropic/*": "anthropic-subscription" } } },
449
+ expectedCompletionTokens: 512, warmSlug: null,
450
+ });
451
+ // grok matches no key: served from openrouter as usual. The claude slug
452
+ // matches the lock and openrouter does not match the value: dropped.
453
+ expect(out.candidates.map((c) => c.model.slug)).toEqual(["xai/grok"]);
454
+ expect(out.rejected.some((r) => r.slug === "anthropic/claude-opus" && r.reason === "provider_locked")).toBe(true);
455
+ });
456
+ });
457
+
407
458
  describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
408
459
  const req = parseChatRequest(
409
460
  {
@@ -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 },
@@ -51,7 +51,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
51
51
  data: { axis: "intelligence", minQuality: 0 },
52
52
  chat: { axis: "intelligence", minQuality: 0 },
53
53
  },
54
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, agenticAxisForToolTurns: true, minAgenticForToolTurns: 0, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
54
+ filters: { allow: [], deny: [], providerLocks: {}, includeFree: false, requireToolSupport: true, agenticAxisForToolTurns: true, minAgenticForToolTurns: 0, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
55
55
  classifier: {
56
56
  ambiguityThreshold: 0,
57
57
  model: "test/adjudicator", learnedModelPath: "",
@@ -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 () => {