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.
@@ -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.33.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.33.0",
17
+ "version": "0.35.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1192,6 +1192,16 @@ it lands on the other (`cache: keeping warm …`), and a replica that has served
1192
1192
  nothing refuses with `402 budget_exceeded` once the shared spend is past its
1193
1193
  cap — where the same cap against an empty store serves.
1194
1194
 
1195
+ On Postgres the `ledger` table is partitioned by UTC day
1196
+ (`PARTITION BY RANGE (created_at_ms)`), so `ledger.retentionDays` drops whole
1197
+ days as partitions instead of deleting rows — at the write rates a shared store
1198
+ is for, a bulk `DELETE` competes for I/O with the inserts it is making room
1199
+ for. Partitions are provisioned a few days ahead on every boot and created on
1200
+ demand if a turn arrives for a day nobody provisioned, so a write never fails
1201
+ for a missing partition. A Postgres ledger created before this is left exactly
1202
+ as it is (Postgres cannot convert a populated table in place) and logs how to
1203
+ convert; see [Data governance](docs/data-governance.md).
1204
+
1195
1205
  A SQLite deployment is unchanged: the file is still migrated in place through
1196
1206
  the nineteen shipped versions, and both halves live in the one file.
1197
1207
 
@@ -166,7 +166,12 @@ one a library default should make for them. (Before v0.21.0 the default was
166
166
  because the subquery needs the rows that are about to be deleted.
167
167
  2. `ollama_meter_samples` past the cutoff. They only calibrate the ledger's own
168
168
  Ollama estimate, so they age out with the rows they calibrate.
169
- 3. `ledger` rows past the cutoff.
169
+ 3. `ledger` rows past the cutoff. On Postgres the ledger is partitioned by UTC
170
+ day, so every day the cutoff covers *completely* is dropped as a partition —
171
+ a metadata operation — and only the boundary day the cutoff falls inside is
172
+ deleted row by row. `deleted` still counts ROWS: each partition is counted
173
+ before it is dropped, so the number means turns, not tables. SQLite has no
174
+ declarative partitioning and deletes rows as it always has.
170
175
 
171
176
  Then, when anything was deleted, `PRAGMA incremental_vacuum` hands freed pages
172
177
  back to the filesystem and `PRAGMA wal_checkpoint(TRUNCATE)` folds the WAL so
@@ -182,6 +187,42 @@ behaviour and is fine.
182
187
  honest answer to "how far back does this ledger go now", which is what the
183
188
  question was actually about, and it is reported even when nothing was deleted.
184
189
 
190
+ ### Partitions, on Postgres
191
+
192
+ A ledger row is ~2.6 kB, and a thousand-tenant deployment writes 33-165 GB a
193
+ day at 440-2200 writes a second. Deleting that competes for I/O with the
194
+ inserts it is trying to make room for, and leaves bloat autovacuum has to
195
+ chase. So on Postgres `ledger` is `PARTITION BY RANGE (created_at_ms)`, one
196
+ partition per UTC day, and retention becomes a `DROP TABLE` per day.
197
+
198
+ The bounds are the ms-epoch integers the column already holds, not a derived
199
+ timestamp, so every existing query (`created_at_ms >= ?`) prunes partitions on
200
+ its own: no read — export, spend, the caps, the decision trail — knows the
201
+ table is partitioned. The one visible consequence is the primary key, which a
202
+ partitioned table requires to include the partition key: it is
203
+ `(id, created_at_ms)` there. Ids are per-row UUIDs and a re-recorded entry
204
+ carries the same instant, so the `ON CONFLICT DO NOTHING` guard still collapses
205
+ a duplicate write.
206
+
207
+ `migrateStore` provisions yesterday through three days ahead on every boot. A
208
+ row for a day nobody provisioned is not an error either: the insert is retried
209
+ once after `ensureLedgerPartitions` creates that day (and the next few), so a
210
+ turn is never lost because a partition was late.
211
+
212
+ A Postgres ledger created before this shipped **stays exactly as it is**.
213
+ Postgres cannot convert a populated table to a partitioned one in place, and
214
+ copying a billing table at boot is not a failure mode a ledger can have — there
215
+ is no second copy of it. The router logs one line saying retention will keep
216
+ deleting rows there, with the conversion an operator can run deliberately, with
217
+ the router stopped:
218
+
219
+ ```sql
220
+ ALTER TABLE ledger RENAME TO ledger_legacy; -- then start the router:
221
+ -- boot recreates it partitioned
222
+ INSERT INTO ledger SELECT * FROM ledger_legacy; -- verify the counts, then
223
+ DROP TABLE ledger_legacy;
224
+ ```
225
+
185
226
  ### The schedule, and the route
186
227
 
187
228
  `createRetentionRunner` (`src/cost/retention.ts`) owns the once-an-hour floor.
@@ -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.33.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
  /**
@@ -22,6 +22,7 @@
22
22
  import type { CatalogModel } from "../catalog/types.ts";
23
23
  import type { RouterConfig } from "../config/types.ts";
24
24
  import { consumePendingEstimate } from "../tokens/estimate.ts";
25
+ import { droppableLedgerPartitions, ensureLedgerPartitions } from "../util/schema.ts";
25
26
  import { jsonParam, jsonValue, num, numOrNull, type SqlDb } from "../util/sql.ts";
26
27
  import { foldBlendSamples, type BlendSample } from "./blended.ts";
27
28
  import { computeCost } from "./forecast.ts";
@@ -290,7 +291,12 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
290
291
  deps.findModel(entry.slug) ??
291
292
  null;
292
293
  const breakdown = model !== null ? computeCost(model, entry.usage) : null;
293
- await sql`
294
+ // No conflict target: on Postgres the ledger is partitioned by day, so
295
+ // its unique index has to include `created_at_ms` and `(id)` alone is
296
+ // not an arbiter. A re-recorded entry carries the same instant, which
297
+ // is the case this guard exists for.
298
+ const insert = async (): Promise<void> => {
299
+ await sql`
294
300
  INSERT INTO ledger (
295
301
  id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id,
296
302
  slug, served_slug, tier, classification_source, reasons, predicted_usd, reported_usd, usage,
@@ -309,7 +315,22 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
309
315
  ${entry.promptTokensSaved},
310
316
  ${entry.scope === undefined || entry.scope === "" ? null : entry.scope}, ${entry.redactions ?? null}
311
317
  )
312
- ON CONFLICT (id) DO NOTHING`;
318
+ ON CONFLICT DO NOTHING`;
319
+ };
320
+ try {
321
+ await insert();
322
+ } catch (err) {
323
+ // A day nobody provisioned: Postgres refuses the row with 23514
324
+ // ("no partition of relation ledger found for row"). The ledger has
325
+ // no CHECK constraints of its own, so on this statement that code
326
+ // can only mean partition routing. Create the day — and the next
327
+ // few, so this happens once rather than daily — and write again. A
328
+ // turn is never lost because a partition was late.
329
+ const code = err !== null && typeof err === "object" && "errno" in err ? String(err.errno) : "";
330
+ if (code !== "23514") throw err;
331
+ await ensureLedgerPartitions(db, entry.createdAtMs);
332
+ await insert();
333
+ }
313
334
  // Always consume the pending estimate, even when the turn failed, so a
314
335
  // dead turn's bytes can never pair with a later turn's tokens.
315
336
  const pending = consumePendingEstimate(entry.conversationKey);
@@ -520,8 +541,25 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
520
541
  // swept up too.
521
542
  await sql`DELETE FROM feedback WHERE created_at_ms < ${cutoff} OR ledger_id IN (SELECT id FROM ledger WHERE created_at_ms < ${cutoff})`;
522
543
  await sql`DELETE FROM ollama_meter_samples WHERE at_ms < ${cutoff}`;
544
+ // Whole days the cutoff covers go as metadata: a DROP of a day's
545
+ // partition is O(1) where deleting its rows is hours of I/O competing
546
+ // with the inserts it is making room for. Counted before the drop, so
547
+ // `deleted` still means rows, not partitions.
548
+ //
549
+ // ponytail: an exact COUNT(*) scans each doomed day once — a fraction of
550
+ // what deleting it costs, but not free. If that scan ever matters, read
551
+ // pg_class.reltuples instead and report the count as an estimate.
552
+ let dropped = 0;
553
+ for (const partition of await droppableLedgerPartitions(db, cutoff)) {
554
+ const rows = (await sql.unsafe(`SELECT COUNT(*) AS n FROM ${partition}`)) as { n: unknown }[];
555
+ dropped += num(rows[0]?.n);
556
+ await sql.unsafe(`DROP TABLE ${partition}`);
557
+ }
558
+ // What the drops could not cover: the boundary day the cutoff falls
559
+ // inside (a partial day, so row-wise), anything in a partition whose
560
+ // bounds could not be read, and every row on SQLite.
523
561
  const deleted = (await sql`DELETE FROM ledger WHERE created_at_ms < ${cutoff} RETURNING id`) as { id: string }[];
524
- return { deleted: deleted.length, oldestKeptMs: await oldest() };
562
+ return { deleted: dropped + deleted.length, oldestKeptMs: await oldest() };
525
563
  },
526
564
 
527
565
  async markWasted(id: string): Promise<void> {
@@ -282,7 +282,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
282
282
  // A Postgres store has no bootstrap of its own to run synchronously, so the
283
283
  // shape is created on the way up and every entry point waits for it once.
284
284
  // Resolved already on SQLite, where `openDb` just did it.
285
- const storeReady = postgres ? migrateStore(sqlDb) : Promise.resolve();
285
+ const storeReady = postgres ? migrateStore(sqlDb, log) : Promise.resolve();
286
286
  // The ledger reads and writes through the engine-agnostic handle. `findModel`
287
287
  // closes over the catalog built just below: a shared store has no catalog
288
288
  // cache of its own to price a row from.
@@ -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