@stage-labs/metro 0.1.0-beta.251 → 0.1.0-beta.252

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.251",
3
+ "version": "0.1.0-beta.252",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.251",
3
+ "version": "0.1.0-beta.252",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -54,6 +54,7 @@ import type { StationName } from '@metro-labs/core/station-names';
54
54
  import { startUploadReaper } from '../files/upload-store.js';
55
55
  import { startAttachReaper } from '../files/attach-reaper.js';
56
56
  import { startCloudWatchPublisher } from '../server/cloudwatch.js';
57
+ import { watchFallbackUsage } from '../gateway/usage-refresh.js';
57
58
  import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } from '../voice/calls.js';
58
59
 
59
60
  installCrashGuard();
@@ -153,6 +154,7 @@ installBearerSessions(agentsDir(), localOwner);
153
154
  startUploadReaper();
154
155
  startAttachReaper();
155
156
  startCloudWatchPublisher();
157
+ watchFallbackUsage();
156
158
  announceLocalEndpoint();
157
159
  tunnel?.start();
158
160
  log.info(
@@ -0,0 +1,147 @@
1
+ import { log } from '@metro-labs/core/log';
2
+ import { lastServed } from './served.js';
3
+ import { PER_MINUTE, usageOf, type UsageWindow } from './usage.js';
4
+ import { bareModel, connectionOf, isSmallModel, notReady, routedConnection, routeLabel, type Connection, type Fallback, type ModelConfig, type Route } from './model-config.js';
5
+
6
+ export const USAGE_LIMIT = 0.95;
7
+ const COOLDOWN_MS = 60_000;
8
+ const SCOPED = 'Weekly, ';
9
+ const MS_PER_SECOND = 1000;
10
+
11
+ export interface Hold {
12
+ reason: string;
13
+ until: string | null;
14
+ }
15
+
16
+ interface Cooling {
17
+ until: number;
18
+ reason: string;
19
+ }
20
+
21
+ const cooling = new Map<string, Cooling>();
22
+ let lastChoice = '';
23
+
24
+ const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
25
+
26
+ export function forgetFallbackState(): void {
27
+ cooling.clear();
28
+ lastChoice = '';
29
+ }
30
+
31
+ function scopeOf(label: string): string | null {
32
+ if (!label.startsWith(SCOPED)) return null;
33
+ return (label.slice(SCOPED.length).trim().split(/\s+/)[0] ?? '').toLowerCase();
34
+ }
35
+
36
+ function applies(window: UsageWindow, route: Route, now: number): boolean {
37
+ if (window.used === null || window.label === PER_MINUTE) return false;
38
+ if (window.resetAt !== null && Date.parse(window.resetAt) <= now) return false;
39
+ if (route.connection.provider === 'gemini') return window.label === route.model;
40
+ const scope = scopeOf(window.label);
41
+ return scope === null || route.model.toLowerCase().split(/[^a-z0-9]+/).includes(scope);
42
+ }
43
+
44
+ export function windowsFor(route: Route, now = Date.now()): UsageWindow[] {
45
+ return (usageOf(route.connection.id)?.windows ?? []).filter((w) => applies(w, route, now));
46
+ }
47
+
48
+ const percent = (used: number): string => `${String(Math.round(used * 100))}%`;
49
+
50
+ export function holdOf(route: Route, now = Date.now()): Hold | null {
51
+ const cool = cooling.get(keyOf(route));
52
+ if (cool !== undefined && cool.until > now) return { reason: cool.reason, until: new Date(cool.until).toISOString() };
53
+ const over = windowsFor(route, now).find((w) => (w.used ?? 0) > USAGE_LIMIT);
54
+ return over === undefined ? null : { reason: `${over.label} ${percent(over.used ?? 0)}`, until: over.resetAt };
55
+ }
56
+
57
+ function fallbackModel(requested: string, conn: Connection, model: string): string {
58
+ const bare = bareModel(requested);
59
+ return conn.provider === 'anthropic' && isSmallModel(bare) ? bare : model;
60
+ }
61
+
62
+ function fallbackRoute(cfg: ModelConfig, fallback: Fallback, requested: string): Route | null {
63
+ const conn = connectionOf(cfg, fallback.connection);
64
+ if (conn === null || notReady(cfg, conn) !== null) return null;
65
+ return { connection: conn, model: fallbackModel(requested, conn, fallback.model) };
66
+ }
67
+
68
+ const unique = (routes: Route[]): Route[] => routes.filter((route, at) => routes.findIndex((other) => keyOf(other) === keyOf(route)) === at);
69
+
70
+ export const chainOf = (primary: Route, cfg: ModelConfig, requested: string): Route[] =>
71
+ unique([primary, ...(cfg.fallbacks ?? []).flatMap((f) => fallbackRoute(cfg, f, requested) ?? [])]);
72
+
73
+ export function routesToTry(chain: Route[], now = Date.now()): Route[] {
74
+ const open = chain.filter((route) => holdOf(route, now) === null);
75
+ return open.length > 0 ? open : chain.slice(0, 1);
76
+ }
77
+
78
+ function secondsHeader(headers: Headers, name: string): number | null {
79
+ const raw = headers.get(name);
80
+ if (raw === null || raw.trim() === '') return null;
81
+ const value = Number(raw);
82
+ return Number.isFinite(value) && value > 0 ? value : null;
83
+ }
84
+
85
+ function coolUntil(headers: Headers, now: number): number {
86
+ const after = secondsHeader(headers, 'retry-after');
87
+ if (after !== null) return now + after * MS_PER_SECOND;
88
+ const reset = secondsHeader(headers, 'anthropic-ratelimit-unified-reset');
89
+ if (reset !== null && reset * MS_PER_SECOND > now) return reset * MS_PER_SECOND;
90
+ return now + COOLDOWN_MS;
91
+ }
92
+
93
+ export function noteRefused(route: Route, status: number, headers: Headers, next: Route | undefined, now = Date.now()): void {
94
+ const reason = `answered ${String(status)}`;
95
+ cooling.set(keyOf(route), { until: coolUntil(headers, now), reason });
96
+ log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, 'gateway: the model hit its limit, so the request goes to the next fallback model');
97
+ }
98
+
99
+ export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
100
+ const key = keyOf(chosen);
101
+ if (key === lastChoice) return;
102
+ const first = lastChoice === '';
103
+ lastChoice = key;
104
+ if (key === keyOf(primary)) {
105
+ if (!first) log.info({ model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
106
+ return;
107
+ }
108
+ const hold = holdOf(primary, now);
109
+ log.info(
110
+ { from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
111
+ 'gateway: the first model is over its limit, so requests go to a fallback model',
112
+ );
113
+ }
114
+
115
+ export interface ChainRow {
116
+ connection: string;
117
+ model: string;
118
+ used: number | null;
119
+ hold: Hold | null;
120
+ active: boolean;
121
+ }
122
+
123
+ function primaryOf(cfg: ModelConfig): Route | null {
124
+ const conn = routedConnection(cfg);
125
+ if (conn === null) return null;
126
+ const served = lastServed();
127
+ const model = conn.model !== '' ? conn.model : served?.connection === conn.id ? served.model : '';
128
+ return { connection: conn, model };
129
+ }
130
+
131
+ function rowOf(cfg: ModelConfig, route: Route, now: number): Omit<ChainRow, 'active'> {
132
+ const reason = notReady(cfg, route.connection);
133
+ const used = windowsFor(route, now).reduce<number | null>((most, w) => Math.max(most ?? 0, w.used ?? 0), null);
134
+ return { connection: route.connection.id, model: route.model, used, hold: reason === null ? holdOf(route, now) : { reason, until: null } };
135
+ }
136
+
137
+ export function chainStatus(cfg: ModelConfig, now = Date.now()): ChainRow[] {
138
+ const primary = primaryOf(cfg);
139
+ if (primary === null) return [];
140
+ const listed = (cfg.fallbacks ?? []).flatMap((f) => {
141
+ const conn = connectionOf(cfg, f.connection);
142
+ return conn === null ? [] : [{ connection: conn, model: f.model }];
143
+ });
144
+ const rows = unique([primary, ...listed]).map((route) => rowOf(cfg, route, now));
145
+ const active = Math.max(0, rows.findIndex((row) => row.hold === null));
146
+ return rows.map((row, at) => ({ ...row, active: at === active }));
147
+ }
@@ -1,4 +1,4 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
1
+ import type { IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from 'node:http';
2
2
  import { UsageScanner } from './usage.js';
3
3
 
4
4
  export class GatewayError extends Error {
@@ -235,3 +235,41 @@ export function upstreamMessage(text: string, fallback: string): string {
235
235
  return text;
236
236
  }
237
237
  }
238
+
239
+ const LIMIT_STATUSES = new Set([402, 429]);
240
+
241
+ export interface Refusal {
242
+ status: number;
243
+ headers: Headers;
244
+ }
245
+
246
+ export interface Held {
247
+ res: ServerResponse;
248
+ refusal: () => Refusal | null;
249
+ }
250
+
251
+ function headersOf(raw: OutgoingHttpHeaders | OutgoingHttpHeader[] | undefined): Headers {
252
+ const out = new Headers();
253
+ if (raw === undefined || Array.isArray(raw)) return out;
254
+ for (const [name, value] of Object.entries(raw)) if (value !== undefined) out.set(name, Array.isArray(value) ? value.join(', ') : String(value));
255
+ return out;
256
+ }
257
+
258
+ export function holdLimitRefusal(res: ServerResponse): Held {
259
+ let refusal: Refusal | null = null;
260
+ const swallow = (): boolean => true;
261
+ const held: ServerResponse = new Proxy(res, {
262
+ get(target, prop) {
263
+ if (prop === 'writeHead')
264
+ return (status: number, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]): ServerResponse => {
265
+ if (!LIMIT_STATUSES.has(status)) return target.writeHead(status, headers);
266
+ refusal = { status, headers: headersOf(headers) };
267
+ return held;
268
+ };
269
+ if (refusal !== null && (prop === 'write' || prop === 'end')) return swallow;
270
+ const value: unknown = Reflect.get(target, prop, target);
271
+ return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
272
+ },
273
+ });
274
+ return { res: held, refusal: () => refusal };
275
+ }
@@ -10,7 +10,8 @@ import {
10
10
  freshAdaptations,
11
11
  type Adaptations,
12
12
  } from './bedrock.js';
13
- import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, upstreamMessage, watchUpstream } from './forward.js';
13
+ import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, holdLimitRefusal, parseJson, pipeResponse, readBody, sendError, upstreamMessage, watchUpstream } from './forward.js';
14
+ import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
14
15
  import {
15
16
  BINDING_BETA,
16
17
  cappedEffort,
@@ -58,6 +59,7 @@ const learned: Adaptations = freshAdaptations();
58
59
 
59
60
  export function resetGatewayState(): void {
60
61
  forgetServed();
62
+ forgetFallbackState();
61
63
  forgetUsage();
62
64
  learned.fields.clear();
63
65
  learned.dropBetas = false;
@@ -237,13 +239,17 @@ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<
237
239
 
238
240
  const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
239
241
 
240
- async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
241
- const cfg = deps.config();
242
- const raw = await readBody(req);
243
- const parsed = parseJson(raw);
244
- const sent = fitToolSearch(req, parsed);
245
- const route = resolveRoute(requestedModel(sent), cfg) ?? passthrough(sent);
246
- const body = shapedFor(req, sent);
242
+ interface Request {
243
+ path: string;
244
+ raw: Buffer;
245
+ parsed: Record<string, unknown>;
246
+ sent: Record<string, unknown>;
247
+ body: Record<string, unknown>;
248
+ cfg: ModelConfig;
249
+ }
250
+
251
+ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
252
+ const { path, body } = ask;
247
253
  const conn = route.connection;
248
254
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
249
255
  if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
@@ -256,14 +262,38 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
256
262
  }
257
263
  if (conn.provider === 'openrouter') {
258
264
  if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
259
- await toOpenRouter(req, res, body, route, cfg, deps);
265
+ await toOpenRouter(req, res, body, route, ask.cfg, deps);
260
266
  return;
261
267
  }
262
268
  if (conn.provider === 'codex' || conn.provider === 'gemini') {
263
269
  await toSubscription(req, res, path, body, route, deps);
264
270
  return;
265
271
  }
266
- await toAnthropic(req, res, bytesOf(raw, parsed, sent), sent, body, route, deps);
272
+ await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
273
+ }
274
+
275
+ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
276
+ const cfg = deps.config();
277
+ const raw = await readBody(req);
278
+ const parsed = parseJson(raw);
279
+ const sent = fitToolSearch(req, parsed);
280
+ const requested = requestedModel(sent);
281
+ const primary = resolveRoute(requested, cfg) ?? passthrough(sent);
282
+ const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), cfg };
283
+ const routes = routesToTry(chainOf(primary, cfg, requested));
284
+ for (const [at, route] of routes.entries()) {
285
+ const next = routes[at + 1];
286
+ if (path === MESSAGES) noteChoice(primary, route);
287
+ if (next === undefined) {
288
+ await attempt(req, res, ask, route, deps);
289
+ return;
290
+ }
291
+ const held = holdLimitRefusal(res);
292
+ await attempt(req, held.res, ask, route, deps);
293
+ const refusal = held.refusal();
294
+ if (refusal === null) return;
295
+ noteRefused(route, refusal.status, refusal.headers, next);
296
+ }
267
297
  }
268
298
 
269
299
  function failed(res: ServerResponse, err: unknown): void {
@@ -14,6 +14,7 @@ import {
14
14
  parseModelConfig,
15
15
  readModelConfig,
16
16
  removeConnection,
17
+ setFallbacks,
17
18
  setRoute,
18
19
  updateConnection,
19
20
  writeModelConfig,
@@ -25,6 +26,7 @@ import { refreshUsage } from './usage-refresh.js';
25
26
 
26
27
  const PATH = '/api/model';
27
28
  const CONNECTIONS = '/api/model/connections';
29
+ const FALLBACKS = '/api/model/fallbacks';
28
30
  const CODEX = '/api/model/codex/';
29
31
  const GEMINI = '/api/model/gemini/';
30
32
  const OPENROUTER = '/api/model/openrouter/';
@@ -69,6 +71,11 @@ async function chooseRoute(req: IncomingMessage, deps: ModelApiDeps, store: Stor
69
71
  return kept(store, next, deps, 'model-api: route changed', { route: next.route });
70
72
  }
71
73
 
74
+ async function chooseFallbacks(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
75
+ const next = await withBody(req, (body) => setFallbacks(store.read(), body.fallbacks));
76
+ return kept(store, next, deps, 'model-api: fallback models changed', { fallbacks: (next.fallbacks ?? []).map((f) => `${f.connection}:${f.model}`) });
77
+ }
78
+
72
79
  async function create(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
73
80
  const next = await withBody(req, (body) => addConnection(store.read(), body));
74
81
  return kept(store, next, deps, 'model-api: connection added', { connection: next.connections.at(-1)?.id ?? '' });
@@ -133,7 +140,8 @@ function bundleRoute(path: string, method: string | undefined): Route | number {
133
140
  return method === 'POST' ? { method: 'POST', run: (req, deps, store) => restore(req, store, deps) } : 405;
134
141
  }
135
142
 
136
- function settingsRoute(method: string | undefined): Route | number {
143
+ function settingsRoute(path: string, method: string | undefined): Route | number {
144
+ if (path === FALLBACKS) return method === 'PUT' ? { method: 'POST', run: chooseFallbacks } : 405;
137
145
  if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(deps, store) };
138
146
  if (method === 'PUT') return { method: 'POST', run: chooseRoute };
139
147
  return 405;
@@ -155,26 +163,23 @@ function codexRoute(rest: string, method: string | undefined): Route | number {
155
163
  return codexDeviceRoute(id, method);
156
164
  }
157
165
 
158
- const mine = (path: string): boolean =>
159
- path === PATH ||
160
- path === BUNDLE ||
161
- path === RESTORE ||
162
- path === CONNECTIONS ||
163
- path.startsWith(`${CONNECTIONS}/`) ||
164
- path.startsWith(CODEX) ||
165
- path.startsWith(GEMINI) ||
166
- path.startsWith(OPENROUTER) ||
167
- path.startsWith(ANTHROPIC) ||
168
- path.startsWith(BEDROCK);
166
+ const TABLES: [string, Record<string, Route>][] = [
167
+ [OPENROUTER, OPENROUTER_ROUTES],
168
+ [ANTHROPIC, ANTHROPIC_ROUTES],
169
+ [BEDROCK, BEDROCK_ROUTES],
170
+ [GEMINI, GEMINI_ROUTES],
171
+ ];
172
+ const EXACT = new Set([PATH, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
173
+ const PREFIXES = [`${CONNECTIONS}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
174
+
175
+ const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
169
176
 
170
177
  function routeFor(path: string, method: string | undefined): Route | number {
171
- if (path === PATH) return settingsRoute(method);
178
+ if (path === PATH || path === FALLBACKS) return settingsRoute(path, method);
172
179
  if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
173
180
  if (path === CONNECTIONS || path.startsWith(`${CONNECTIONS}/`)) return connectionsRoute(path, method);
174
- if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
175
- if (path.startsWith(ANTHROPIC)) return named(ANTHROPIC_ROUTES, path.slice(ANTHROPIC.length), method);
176
- if (path.startsWith(BEDROCK)) return named(BEDROCK_ROUTES, path.slice(BEDROCK.length), method);
177
- if (path.startsWith(GEMINI)) return named(GEMINI_ROUTES, path.slice(GEMINI.length), method);
181
+ const table = TABLES.find(([prefix]) => path.startsWith(prefix));
182
+ if (table !== undefined) return named(table[1], path.slice(table[0].length), method);
178
183
  return codexRoute(path.slice(CODEX.length), method);
179
184
  }
180
185
 
@@ -21,10 +21,16 @@ export interface Connection {
21
21
  gemini: GeminiTokens | null;
22
22
  }
23
23
 
24
+ export interface Fallback {
25
+ connection: string;
26
+ model: string;
27
+ }
28
+
24
29
  export interface ModelConfig {
25
30
  version: 2;
26
31
  route: string;
27
32
  connections: Connection[];
33
+ fallbacks?: Fallback[];
28
34
  }
29
35
 
30
36
  export interface Route {
@@ -38,6 +44,7 @@ const MODEL_FILE = 'model.json';
38
44
  const MAX_FIELD = 512;
39
45
  const MAX_LABEL = 60;
40
46
  const MAX_CONNECTIONS = 20;
47
+ const MAX_FALLBACKS = 8;
41
48
  const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|gemini):(.+)$/;
42
49
  const SMALL_RE = /haiku/i;
43
50
 
@@ -102,11 +109,29 @@ function connectionFromDisk(raw: unknown): Connection | null {
102
109
  };
103
110
  }
104
111
 
112
+ function fallbacksOf(raw: unknown, connections: Connection[]): Fallback[] {
113
+ if (!Array.isArray(raw)) return [];
114
+ const known = raw.flatMap((f: unknown) => {
115
+ if (!isRecord(f)) return [];
116
+ const entry = { connection: text(f.connection), model: text(f.model).slice(0, MAX_FIELD) };
117
+ return entry.model !== '' && connections.some((c) => c.id === entry.connection) ? [entry] : [];
118
+ });
119
+ return known.filter((f, at) => known.findIndex((o) => o.connection === f.connection && o.model === f.model) === at).slice(0, MAX_FALLBACKS);
120
+ }
121
+
122
+ const withFallbacks = (cfg: ModelConfig, fallbacks: Fallback[]): ModelConfig => ({
123
+ version: 2,
124
+ route: cfg.route,
125
+ connections: cfg.connections,
126
+ ...(fallbacks.length === 0 ? {} : { fallbacks }),
127
+ });
128
+
105
129
  export function parseModelConfig(raw: unknown): ModelConfig {
106
130
  if (!isRecord(raw) || !Array.isArray(raw.connections)) return empty();
107
131
  const connections = raw.connections.flatMap((c: unknown) => connectionFromDisk(c) ?? []).slice(0, MAX_CONNECTIONS);
108
132
  const route = text(raw.route);
109
- return { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
133
+ const cfg: ModelConfig = { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
134
+ return withFallbacks(cfg, fallbacksOf(raw.fallbacks, connections));
110
135
  }
111
136
 
112
137
  const needsIds = (raw: unknown): boolean =>
@@ -160,7 +185,7 @@ export function notReady(cfg: ModelConfig, conn = routedConnection(cfg)): string
160
185
  return CHECKS[conn.provider].find(([missing]) => missing(conn))?.[1] ?? null;
161
186
  }
162
187
 
163
- const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
188
+ export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
164
189
 
165
190
  const DEFAULTS: Record<Provider, (requested: string, c: Connection) => string> = {
166
191
  openrouter: (requested, c) => (requested.includes('/') ? requested : c.model),
@@ -176,6 +201,8 @@ function forProvider(cfg: ModelConfig, provider: Provider): Connection | null {
176
201
  return cfg.connections.find((c) => c.provider === provider) ?? null;
177
202
  }
178
203
 
204
+ export const bareModel = (requested: string): string => PREFIX_RE.exec(requested)?.[2]?.trim() ?? requested;
205
+
179
206
  export function resolveRoute(requested: string, cfg: ModelConfig): Route | null {
180
207
  const explicit = PREFIX_RE.exec(requested);
181
208
  if (explicit !== null && isProvider(explicit[1])) {
@@ -276,7 +303,24 @@ export function updateConnection(cfg: ModelConfig, id: string, patch: unknown):
276
303
  export function removeConnection(cfg: ModelConfig, id: string): ModelConfig {
277
304
  requireConnection(cfg, id);
278
305
  const connections = cfg.connections.filter((c) => c.id !== id);
279
- return { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
306
+ const next = { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
307
+ return withFallbacks(next, (cfg.fallbacks ?? []).filter((f) => f.connection !== id));
308
+ }
309
+
310
+ function fallbackEntry(cfg: ModelConfig, raw: unknown): Fallback {
311
+ if (!isRecord(raw)) throw new ModelConfigError('each fallback must be an object with a connection and a model');
312
+ const connection = field(raw, 'connection', '', 'connection');
313
+ const model = field(raw, 'model', '', 'model');
314
+ requireConnection(cfg, connection);
315
+ if (model === '') throw new ModelConfigError('each fallback needs a model');
316
+ return { connection, model };
317
+ }
318
+
319
+ export function setFallbacks(cfg: ModelConfig, raw: unknown): ModelConfig {
320
+ if (!Array.isArray(raw)) throw new ModelConfigError('fallbacks must be a list');
321
+ if (raw.length > MAX_FALLBACKS) throw new ModelConfigError(`a box keeps at most ${String(MAX_FALLBACKS)} fallbacks`);
322
+ const entries = raw.map((f: unknown) => fallbackEntry(cfg, f));
323
+ return withFallbacks(cfg, fallbacksOf(entries, cfg.connections));
280
324
  }
281
325
 
282
326
  export function setRoute(cfg: ModelConfig, id: string): ModelConfig {
@@ -5,6 +5,7 @@ import { GeminiAuthError } from './gemini-auth.js';
5
5
  import { GatewayError } from './forward.js';
6
6
  import { lastServed } from './served.js';
7
7
  import { usageSeen } from './usage.js';
8
+ import { chainStatus } from './fallback.js';
8
9
  import { ModelConfigError, connectionOf, publicModelConfig, routedConnection, type Connection, type ModelConfig, type Provider } from './model-config.js';
9
10
  import type { SetupDeps } from '../claude/setup.js';
10
11
 
@@ -45,6 +46,8 @@ export const CREDITS_TTL_MS = 5 * 60_000;
45
46
 
46
47
  export const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({
47
48
  ...publicModelConfig(cfg),
49
+ fallbacks: cfg.fallbacks ?? [],
50
+ chain: chainStatus(cfg),
48
51
  lastServed: lastServed(),
49
52
  usage: usageSeen(),
50
53
  });
@@ -3,8 +3,8 @@ import { readClaudeUsage } from '../claude/usage-probe.js';
3
3
  import { codexUsageNow, sharedCodexState } from './codex.js';
4
4
  import { openrouterKey } from './openrouter.js';
5
5
  import { lastServed } from './served.js';
6
- import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, noteUsage, openrouterUsage, probeAnswer, usageOf, type Reported } from './usage.js';
7
- import { PASSTHROUGH_ID, routedConnection, type Connection, type ModelConfig } from './model-config.js';
6
+ import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
7
+ import { PASSTHROUGH_ID, readModelConfig, routedConnection, writeModelConfig, type Connection, type ModelConfig } from './model-config.js';
8
8
  import { codexDepsFor, geminiModelsOf } from './model-signin.js';
9
9
  import { CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
10
10
 
@@ -41,14 +41,15 @@ async function refreshCodex(conn: Connection, deps: ModelApiDeps, store: Store,
41
41
  });
42
42
  }
43
43
 
44
- const modelsInUse = (conn: Connection): Set<string> => {
44
+ const modelsInUse = (conn: Connection, cfg: ModelConfig): Set<string> => {
45
45
  const served = lastServed();
46
- return new Set([conn.model, served?.connection === conn.id ? served.model : ''].filter((m) => m !== ''));
46
+ const fallbacks = (cfg.fallbacks ?? []).filter((f) => f.connection === conn.id).map((f) => f.model);
47
+ return new Set([conn.model, served?.connection === conn.id ? served.model : '', ...fallbacks].filter((m) => m !== ''));
47
48
  };
48
49
 
49
50
  async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
50
51
  if (conn.gemini === null) return;
51
- const inUse = modelsInUse(conn);
52
+ const inUse = modelsInUse(conn, store.read());
52
53
  const seen = usageOf(conn.id);
53
54
  const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
54
55
  if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
@@ -64,11 +65,11 @@ const loginIds = (cfg: ModelConfig): string[] => [
64
65
  ...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '').map((c) => c.id),
65
66
  ];
66
67
 
67
- const olderThan = (seen: Reported | undefined, usage: Reported): boolean => seen === undefined || Date.parse(seen.at) < Date.parse(usage.at);
68
+ const hasFallbacks = (cfg: ModelConfig): boolean => (cfg.fallbacks ?? []).length > 0;
68
69
 
69
70
  async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
70
- const stale = loginIds(cfg).filter((id) => !fresh(id, now));
71
- if (stale.length === 0) return;
71
+ const ids = loginIds(cfg);
72
+ if (!ids.some((id) => !fresh(id, now)) && !(hasFallbacks(cfg) && ids.length > 0)) return;
72
73
  if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
73
74
  await probe('usage of the Claude Code login', async () => {
74
75
  const usage = claudeLoginUsage(await (deps.claudeUsage ?? readClaudeUsage)(), new Date(now));
@@ -76,7 +77,7 @@ async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number):
76
77
  });
77
78
  const usage = probeAnswer(LOGIN_PROBE);
78
79
  if (usage === undefined || now - Date.parse(usage.at) >= CREDITS_TTL_MS) return;
79
- for (const id of stale) if (olderThan(usageOf(id), usage)) noteUsage(id, usage);
80
+ for (const id of ids) mergeUsage(id, usage);
80
81
  }
81
82
 
82
83
  function refreshOne(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
@@ -100,3 +101,14 @@ export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.
100
101
  const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, deps, store, now)), refreshLogin(cfg, deps, now)]);
101
102
  await within(deps.usageWaitMs ?? WAIT_MS, work);
102
103
  }
104
+
105
+ export function watchFallbackUsage(deps: ModelApiDeps = {}, every = CREDITS_TTL_MS): void {
106
+ const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
107
+ const run = (): void => {
108
+ if (!hasFallbacks(store.read())) return;
109
+ refreshUsage(deps, store).catch((err: unknown) => {
110
+ log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
111
+ });
112
+ };
113
+ setInterval(run, every).unref();
114
+ }
@@ -3,7 +3,7 @@ import { stringOf } from '@metro-labs/http/api-http';
3
3
 
4
4
  type Key = string;
5
5
 
6
- interface UsageWindow {
6
+ export interface UsageWindow {
7
7
  label: string;
8
8
  used: number | null;
9
9
  resetAt: string | null;
@@ -28,14 +28,32 @@ export interface ProviderUsage {
28
28
  export type Reported = Omit<ProviderUsage, 'tally'>;
29
29
 
30
30
  const latest = new Map<Key, Reported>();
31
+ const stamps = new Map<Key, Map<string, number>>();
31
32
  const tallies = new Map<Key, Tally>();
32
33
  const probed = new Map<Key, number>();
33
34
  const answers = new Map<Key, Reported>();
34
35
 
35
36
  export const noteUsage = (key: Key, usage: Reported): void => {
36
37
  latest.set(key, usage);
38
+ stamps.set(key, new Map(usage.windows.map((w) => [w.label, Date.parse(usage.at)])));
37
39
  };
38
40
 
41
+ export function mergeUsage(key: Key, usage: Reported): void {
42
+ const seen = latest.get(key);
43
+ const marks = stamps.get(key);
44
+ if (seen === undefined || marks === undefined) {
45
+ noteUsage(key, usage);
46
+ return;
47
+ }
48
+ const at = Date.parse(usage.at);
49
+ const newer = new Map(usage.windows.filter((w) => (marks.get(w.label) ?? Number.NEGATIVE_INFINITY) <= at).map((w) => [w.label, w]));
50
+ for (const label of newer.keys()) marks.set(label, at);
51
+ const kept = seen.windows.map((w) => newer.get(w.label) ?? w);
52
+ const added = [...newer.values()].filter((w) => !seen.windows.some((old) => old.label === w.label));
53
+ const later = Date.parse(seen.at) <= at;
54
+ latest.set(key, { windows: [...kept, ...added], note: later ? usage.note : seen.note, at: later ? usage.at : seen.at });
55
+ }
56
+
39
57
  export function usageSeen(): Record<Key, ProviderUsage> {
40
58
  const out: Record<Key, ProviderUsage> = {};
41
59
  for (const key of new Set<Key>([...latest.keys(), ...tallies.keys()])) {
@@ -50,17 +68,20 @@ export const usageOf = (key: Key): Reported | undefined => latest.get(key);
50
68
 
51
69
  export const forgetOne = (key: Key): void => {
52
70
  latest.delete(key);
71
+ stamps.delete(key);
53
72
  tallies.delete(key);
54
73
  probed.delete(key);
55
74
  };
56
75
 
57
76
  export const forgetReported = (key: Key): void => {
58
77
  latest.delete(key);
78
+ stamps.delete(key);
59
79
  probed.delete(key);
60
80
  };
61
81
 
62
82
  export const forgetUsage = (): void => {
63
83
  latest.clear();
84
+ stamps.clear();
64
85
  tallies.clear();
65
86
  probed.clear();
66
87
  answers.clear();
@@ -178,13 +199,15 @@ function anthropicUnified(headers: Headers, now: Date): { windows: UsageWindow[]
178
199
  return { windows, note };
179
200
  }
180
201
 
202
+ export const PER_MINUTE = 'Tokens per minute';
203
+
181
204
  function anthropicKeyed(headers: Headers, now: Date): UsageWindow[] {
182
205
  const limit = Number(headers.get('anthropic-ratelimit-tokens-limit'));
183
206
  const remaining = Number(headers.get('anthropic-ratelimit-tokens-remaining'));
184
207
  if (!Number.isFinite(limit) || !Number.isFinite(remaining) || limit <= 0) return [];
185
208
  return [
186
209
  {
187
- label: 'Tokens per minute',
210
+ label: PER_MINUTE,
188
211
  used: clamp(1 - remaining / limit),
189
212
  resetAt: whenFrom(headers.get('anthropic-ratelimit-tokens-reset'), now),
190
213
  detail: `${remaining.toLocaleString('en-US')} of ${limit.toLocaleString('en-US')} left`,
@@ -315,7 +338,9 @@ export function claudeLoginUsage(limits: unknown, now = new Date()): Reported |
315
338
 
316
339
  export function noteUsageHeaders(kind: 'anthropic' | 'codex', key: Key, headers: Headers, now = new Date()): void {
317
340
  const usage = kind === 'anthropic' ? anthropicUsage(headers, now) : codexUsage(headers, now);
318
- if (usage !== null) noteUsage(key, usage);
341
+ if (usage === null) return;
342
+ if (kind === 'anthropic') mergeUsage(key, usage);
343
+ else noteUsage(key, usage);
319
344
  }
320
345
 
321
346
  const dollars = (value: number): string => `$${value.toFixed(2)}`;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.251"
2
+ "version": "0.1.0-beta.252"
3
3
  }