auto-model-router 0.34.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.
@@ -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.35.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.35.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.35.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.
@@ -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
  /**
@@ -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
@@ -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 },
@@ -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
+ });
@@ -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 () => {