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

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.
Files changed (30) hide show
  1. package/package.json +2 -2
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/package.json +1 -1
  4. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -1
  5. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +9 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +52 -18
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +5 -4
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +168 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +147 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +48 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +97 -22
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +34 -21
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +68 -5
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +2 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +8 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +1 -2
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -14
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +28 -3
  20. package/runtime/node_modules/@metro-labs/discord-bot/package.json +1 -1
  21. package/runtime/node_modules/@metro-labs/gmail/package.json +1 -1
  22. package/runtime/node_modules/@metro-labs/http/package.json +1 -1
  23. package/runtime/node_modules/@metro-labs/outlook/package.json +1 -1
  24. package/runtime/node_modules/@metro-labs/telegram/package.json +1 -1
  25. package/runtime/node_modules/@metro-labs/telegram-bot/package.json +1 -1
  26. package/runtime/node_modules/@metro-labs/threema/package.json +1 -1
  27. package/runtime/node_modules/@metro-labs/webhook/package.json +1 -1
  28. package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -1
  29. package/runtime/node_modules/@metro-labs/xmtp/package.json +1 -1
  30. package/runtime/runtime.json +1 -1
@@ -1,4 +1,4 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
1
+ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
3
  import { cameThroughProxy } from '@metro-labs/http/api-http';
4
4
  import { agentIdForKey } from '../agents/keys.js';
@@ -10,7 +10,21 @@ 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 {
14
+ addBeta,
15
+ anthropicHeaders,
16
+ forwardedHeaders,
17
+ GatewayError,
18
+ holdLimitRefusal,
19
+ loginHeaders,
20
+ parseJson,
21
+ pipeResponse,
22
+ readBody,
23
+ sendError,
24
+ upstreamMessage,
25
+ watchUpstream,
26
+ } from './forward.js';
27
+ import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
14
28
  import {
15
29
  BINDING_BETA,
16
30
  cappedEffort,
@@ -37,6 +51,7 @@ import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
37
51
  import { fitToolSearch } from './tool-search.js';
38
52
  import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
39
53
  import type { CodexTokens } from './codex-auth.js';
54
+ import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
40
55
 
41
56
  const GATEWAY_PREFIX = '/gateway';
42
57
  const MESSAGES = '/v1/messages';
@@ -52,18 +67,21 @@ export interface GatewayDeps {
52
67
  openrouterBase?: string;
53
68
  codex?: Partial<CodexDeps>;
54
69
  gemini?: Partial<GeminiDeps>;
70
+ claudeLogins?: ClaudeLoginDeps;
55
71
  }
56
72
 
57
73
  const learned: Adaptations = freshAdaptations();
58
74
 
59
75
  export function resetGatewayState(): void {
60
76
  forgetServed();
77
+ forgetFallbackState();
61
78
  forgetUsage();
62
79
  learned.fields.clear();
63
80
  learned.dropBetas = false;
64
81
  forgetLearnedThinking();
65
82
  sharedCodexState.clear();
66
83
  sharedGeminiState.clear();
84
+ forgetClaudeTokens();
67
85
  }
68
86
 
69
87
  const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
@@ -88,7 +106,18 @@ function modelsBody(cfg: ModelConfig): Record<string, unknown> {
88
106
  return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
89
107
  }
90
108
 
91
- const PASSTHROUGH: Connection = { id: PASSTHROUGH_ID, provider: 'anthropic', label: 'Claude Code login', model: '', apiKey: '', region: '', zdr: false, codex: null, gemini: null };
109
+ const PASSTHROUGH: Connection = {
110
+ id: PASSTHROUGH_ID,
111
+ provider: 'anthropic',
112
+ label: 'Claude Code login',
113
+ model: '',
114
+ apiKey: '',
115
+ region: '',
116
+ zdr: false,
117
+ codex: null,
118
+ gemini: null,
119
+ claude: null,
120
+ };
92
121
 
93
122
  const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
94
123
 
@@ -137,12 +166,27 @@ async function refusedThinkingOff(upstream: Response, shaped: Record<string, unk
137
166
  return true;
138
167
  }
139
168
 
140
- function listLoginModels(req: IncomingMessage, anthropicBase: string): void {
141
- refreshLoginModels(req.headers, anthropicBase).catch((err: unknown) => {
169
+ function listLoginModels(headers: IncomingHttpHeaders, anthropicBase: string): void {
170
+ refreshLoginModels(headers, anthropicBase).catch((err: unknown) => {
142
171
  log.warn({ err: errMsg(err) }, 'gateway: could not list the models of the Claude Code login');
143
172
  });
144
173
  }
145
174
 
175
+ function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
176
+ if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
177
+ if (conn.claude === null) return attempt(forwardedHeaders(req));
178
+ return withClaudeLogin(conn.claude.id, deps.claudeLogins ?? {}, (token) => {
179
+ listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
180
+ return attempt(loginHeaders(req, token));
181
+ });
182
+ }
183
+
184
+ async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
185
+ if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
186
+ await upstream.body?.cancel();
187
+ throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login of ${conn.label}: sign in again on the Model page`);
188
+ }
189
+
146
190
  async function toAnthropic(
147
191
  req: IncomingMessage,
148
192
  res: ServerResponse,
@@ -155,25 +199,28 @@ async function toAnthropic(
155
199
  const conn = route.connection;
156
200
  const anthropicBase = deps.anthropicBase ?? ANTHROPIC_API;
157
201
  const url = `${anthropicBase}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
158
- const key = conn.apiKey;
159
- if (key === '' && standsInFor(req))
202
+ const passthrough = conn.apiKey === '' && conn.claude === null;
203
+ if (passthrough && standsInFor(req))
160
204
  throw new GatewayError(
161
205
  403,
162
206
  'permission_error',
163
207
  'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
164
208
  );
165
209
  const watch = watchUpstream(res);
166
- if (key === '') listLoginModels(req, anthropicBase);
167
- const base = key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key);
210
+ if (passthrough) listLoginModels(req.headers, anthropicBase);
168
211
  const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
169
212
  fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
170
- const attempt = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
171
- const first = await attempt();
172
- const upstream = (await refusedThinkingOff(first, shaped, route.model)) ? await attempt() : first;
213
+ const attempt = async (base: Record<string, string>): Promise<Response> => {
214
+ const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
215
+ const first = await shape();
216
+ return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
217
+ };
218
+ const upstream = await sendAnthropic(req, conn, anthropicBase, attempt, deps);
219
+ await refusedLogin(conn, upstream);
173
220
  noteRefusal(conn.label, route.model, upstream);
174
221
  noteUsageHeaders('anthropic', conn.id, upstream.headers);
175
222
  const scanner = new UsageScanner(conn.id);
176
- await pipeResponse(upstream, res, watch, key === '' ? { scanner } : { ownCredential: true, scanner });
223
+ await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
177
224
  }
178
225
 
179
226
  async function toOpenRouter(
@@ -237,13 +284,17 @@ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<
237
284
 
238
285
  const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
239
286
 
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);
287
+ interface Request {
288
+ path: string;
289
+ raw: Buffer;
290
+ parsed: Record<string, unknown>;
291
+ sent: Record<string, unknown>;
292
+ body: Record<string, unknown>;
293
+ cfg: ModelConfig;
294
+ }
295
+
296
+ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
297
+ const { path, body } = ask;
247
298
  const conn = route.connection;
248
299
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
249
300
  if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
@@ -256,14 +307,38 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
256
307
  }
257
308
  if (conn.provider === 'openrouter') {
258
309
  if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
259
- await toOpenRouter(req, res, body, route, cfg, deps);
310
+ await toOpenRouter(req, res, body, route, ask.cfg, deps);
260
311
  return;
261
312
  }
262
313
  if (conn.provider === 'codex' || conn.provider === 'gemini') {
263
314
  await toSubscription(req, res, path, body, route, deps);
264
315
  return;
265
316
  }
266
- await toAnthropic(req, res, bytesOf(raw, parsed, sent), sent, body, route, deps);
317
+ await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
318
+ }
319
+
320
+ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
321
+ const cfg = deps.config();
322
+ const raw = await readBody(req);
323
+ const parsed = parseJson(raw);
324
+ const sent = fitToolSearch(req, parsed);
325
+ const requested = requestedModel(sent);
326
+ const primary = resolveRoute(requested, cfg) ?? passthrough(sent);
327
+ const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), cfg };
328
+ const routes = routesToTry(chainOf(primary, cfg, requested));
329
+ for (const [at, route] of routes.entries()) {
330
+ const next = routes[at + 1];
331
+ if (path === MESSAGES) noteChoice(primary, route);
332
+ if (next === undefined) {
333
+ await attempt(req, res, ask, route, deps);
334
+ return;
335
+ }
336
+ const held = holdLimitRefusal(res);
337
+ await attempt(req, held.res, ask, route, deps);
338
+ const refusal = held.refusal();
339
+ if (refusal === null) return;
340
+ noteRefused(route, refusal.status, refusal.headers, next);
341
+ }
267
342
  }
268
343
 
269
344
  function failed(res: ServerResponse, err: unknown): void {
@@ -14,17 +14,20 @@ import {
14
14
  parseModelConfig,
15
15
  readModelConfig,
16
16
  removeConnection,
17
+ setFallbacks,
17
18
  setRoute,
18
19
  updateConnection,
19
20
  writeModelConfig,
20
21
  type ModelConfig,
21
22
  } from './model-config.js';
22
- import { asApiError, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
23
+ import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
24
+ import { dropUnusedClaudeLogins } from './claude-logins.js';
23
25
  import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
24
26
  import { refreshUsage } from './usage-refresh.js';
25
27
 
26
28
  const PATH = '/api/model';
27
29
  const CONNECTIONS = '/api/model/connections';
30
+ const FALLBACKS = '/api/model/fallbacks';
28
31
  const CODEX = '/api/model/codex/';
29
32
  const GEMINI = '/api/model/gemini/';
30
33
  const OPENROUTER = '/api/model/openrouter/';
@@ -69,6 +72,11 @@ async function chooseRoute(req: IncomingMessage, deps: ModelApiDeps, store: Stor
69
72
  return kept(store, next, deps, 'model-api: route changed', { route: next.route });
70
73
  }
71
74
 
75
+ async function chooseFallbacks(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
76
+ const next = await withBody(req, (body) => setFallbacks(store.read(), body.fallbacks));
77
+ return kept(store, next, deps, 'model-api: fallback models changed', { fallbacks: (next.fallbacks ?? []).map((f) => `${f.connection}:${f.model}`) });
78
+ }
79
+
72
80
  async function create(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
73
81
  const next = await withBody(req, (body) => addConnection(store.read(), body));
74
82
  return kept(store, next, deps, 'model-api: connection added', { connection: next.connections.at(-1)?.id ?? '' });
@@ -82,14 +90,17 @@ async function change(req: IncomingMessage, deps: ModelApiDeps, store: Store, id
82
90
  }
83
91
 
84
92
  function drop(deps: ModelApiDeps, store: Store, id: string): unknown {
93
+ const before = store.read();
85
94
  let next: ModelConfig;
86
95
  try {
87
- next = removeConnection(store.read(), id);
96
+ next = removeConnection(before, id);
88
97
  } catch (err) {
89
98
  asApiError(err);
90
99
  }
91
100
  forgetOne(id);
92
- return kept(store, next, deps, 'model-api: connection removed', { connection: id });
101
+ const body = kept(store, next, deps, 'model-api: connection removed', { connection: id });
102
+ dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
103
+ return body;
93
104
  }
94
105
 
95
106
  async function settingsWithUsage(deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
@@ -125,7 +136,11 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
125
136
  async function restore(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
126
137
  const body = await readJsonBody(req, RESTORE_MAX);
127
138
  if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
128
- return kept(store, parseModelConfig(body), deps, 'model-api: model setup restored from a file', {});
139
+ const before = store.read();
140
+ const next = parseModelConfig(body);
141
+ const answer = kept(store, next, deps, 'model-api: model setup restored from a file', {});
142
+ dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
143
+ return answer;
129
144
  }
130
145
 
131
146
  function bundleRoute(path: string, method: string | undefined): Route | number {
@@ -133,7 +148,8 @@ function bundleRoute(path: string, method: string | undefined): Route | number {
133
148
  return method === 'POST' ? { method: 'POST', run: (req, deps, store) => restore(req, store, deps) } : 405;
134
149
  }
135
150
 
136
- function settingsRoute(method: string | undefined): Route | number {
151
+ function settingsRoute(path: string, method: string | undefined): Route | number {
152
+ if (path === FALLBACKS) return method === 'PUT' ? { method: 'POST', run: chooseFallbacks } : 405;
137
153
  if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(deps, store) };
138
154
  if (method === 'PUT') return { method: 'POST', run: chooseRoute };
139
155
  return 405;
@@ -155,26 +171,23 @@ function codexRoute(rest: string, method: string | undefined): Route | number {
155
171
  return codexDeviceRoute(id, method);
156
172
  }
157
173
 
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);
174
+ const TABLES: [string, Record<string, Route>][] = [
175
+ [OPENROUTER, OPENROUTER_ROUTES],
176
+ [ANTHROPIC, ANTHROPIC_ROUTES],
177
+ [BEDROCK, BEDROCK_ROUTES],
178
+ [GEMINI, GEMINI_ROUTES],
179
+ ];
180
+ const EXACT = new Set([PATH, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
181
+ const PREFIXES = [`${CONNECTIONS}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
182
+
183
+ const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
169
184
 
170
185
  function routeFor(path: string, method: string | undefined): Route | number {
171
- if (path === PATH) return settingsRoute(method);
186
+ if (path === PATH || path === FALLBACKS) return settingsRoute(path, method);
172
187
  if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
173
188
  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);
189
+ const table = TABLES.find(([prefix]) => path.startsWith(prefix));
190
+ if (table !== undefined) return named(table[1], path.slice(table[0].length), method);
178
191
  return codexRoute(path.slice(CODEX.length), method);
179
192
  }
180
193
 
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { newId } from '@metro-labs/core/ids';
2
+ import { ID_RE, newId } from '@metro-labs/core/ids';
3
3
  import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
4
4
  import { agentsDir } from '../agents/files.js';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
@@ -9,6 +9,13 @@ import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gem
9
9
  export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
10
10
  export type Provider = (typeof PROVIDERS)[number];
11
11
 
12
+ export interface ClaudeLogin {
13
+ id: string;
14
+ email: string | null;
15
+ plan: string | null;
16
+ savedAt: string;
17
+ }
18
+
12
19
  export interface Connection {
13
20
  id: string;
14
21
  provider: Provider;
@@ -19,12 +26,19 @@ export interface Connection {
19
26
  zdr: boolean;
20
27
  codex: CodexTokens | null;
21
28
  gemini: GeminiTokens | null;
29
+ claude: ClaudeLogin | null;
30
+ }
31
+
32
+ export interface Fallback {
33
+ connection: string;
34
+ model: string;
22
35
  }
23
36
 
24
37
  export interface ModelConfig {
25
38
  version: 2;
26
39
  route: string;
27
40
  connections: Connection[];
41
+ fallbacks?: Fallback[];
28
42
  }
29
43
 
30
44
  export interface Route {
@@ -38,6 +52,7 @@ const MODEL_FILE = 'model.json';
38
52
  const MAX_FIELD = 512;
39
53
  const MAX_LABEL = 60;
40
54
  const MAX_CONNECTIONS = 20;
55
+ const MAX_FALLBACKS = 8;
41
56
  const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|gemini):(.+)$/;
42
57
  const SMALL_RE = /haiku/i;
43
58
 
@@ -66,8 +81,14 @@ const newConnection = (provider: Provider, label: string): Connection => ({
66
81
  zdr: false,
67
82
  codex: null,
68
83
  gemini: null,
84
+ claude: null,
69
85
  });
70
86
 
87
+ function claudeFromDisk(raw: unknown): ClaudeLogin | null {
88
+ if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
89
+ return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
90
+ }
91
+
71
92
  function codexFromDisk(raw: unknown): CodexTokens | null {
72
93
  if (!isRecord(raw)) return null;
73
94
  const accessToken = text(raw.accessToken);
@@ -99,14 +120,33 @@ function connectionFromDisk(raw: unknown): Connection | null {
99
120
  zdr: raw.zdr === true,
100
121
  codex: codexFromDisk(raw.codex),
101
122
  gemini: geminiTokensFromDisk(raw.gemini),
123
+ claude: provider === 'anthropic' ? claudeFromDisk(raw.claude) : null,
102
124
  };
103
125
  }
104
126
 
127
+ function fallbacksOf(raw: unknown, connections: Connection[]): Fallback[] {
128
+ if (!Array.isArray(raw)) return [];
129
+ const known = raw.flatMap((f: unknown) => {
130
+ if (!isRecord(f)) return [];
131
+ const entry = { connection: text(f.connection), model: text(f.model).slice(0, MAX_FIELD) };
132
+ return entry.model !== '' && connections.some((c) => c.id === entry.connection) ? [entry] : [];
133
+ });
134
+ return known.filter((f, at) => known.findIndex((o) => o.connection === f.connection && o.model === f.model) === at).slice(0, MAX_FALLBACKS);
135
+ }
136
+
137
+ const withFallbacks = (cfg: ModelConfig, fallbacks: Fallback[]): ModelConfig => ({
138
+ version: 2,
139
+ route: cfg.route,
140
+ connections: cfg.connections,
141
+ ...(fallbacks.length === 0 ? {} : { fallbacks }),
142
+ });
143
+
105
144
  export function parseModelConfig(raw: unknown): ModelConfig {
106
145
  if (!isRecord(raw) || !Array.isArray(raw.connections)) return empty();
107
146
  const connections = raw.connections.flatMap((c: unknown) => connectionFromDisk(c) ?? []).slice(0, MAX_CONNECTIONS);
108
147
  const route = text(raw.route);
109
- return { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
148
+ const cfg: ModelConfig = { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
149
+ return withFallbacks(cfg, fallbacksOf(raw.fallbacks, connections));
110
150
  }
111
151
 
112
152
  const needsIds = (raw: unknown): boolean =>
@@ -160,7 +200,7 @@ export function notReady(cfg: ModelConfig, conn = routedConnection(cfg)): string
160
200
  return CHECKS[conn.provider].find(([missing]) => missing(conn))?.[1] ?? null;
161
201
  }
162
202
 
163
- const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
203
+ export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
164
204
 
165
205
  const DEFAULTS: Record<Provider, (requested: string, c: Connection) => string> = {
166
206
  openrouter: (requested, c) => (requested.includes('/') ? requested : c.model),
@@ -176,6 +216,8 @@ function forProvider(cfg: ModelConfig, provider: Provider): Connection | null {
176
216
  return cfg.connections.find((c) => c.provider === provider) ?? null;
177
217
  }
178
218
 
219
+ export const bareModel = (requested: string): string => PREFIX_RE.exec(requested)?.[2]?.trim() ?? requested;
220
+
179
221
  export function resolveRoute(requested: string, cfg: ModelConfig): Route | null {
180
222
  const explicit = PREFIX_RE.exec(requested);
181
223
  if (explicit !== null && isProvider(explicit[1])) {
@@ -192,6 +234,7 @@ export const routeLabel = (route: Route): string =>
192
234
  export const PASSTHROUGH_ID = 'passthrough';
193
235
 
194
236
  function signedInAs(c: Connection): { account: string | null; plan: string | null } {
237
+ if (c.claude !== null) return { account: c.claude.email, plan: c.claude.plan };
195
238
  if (c.codex === null) return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
196
239
  const claims = claimsOf(c.codex.idToken);
197
240
  return { account: c.codex.email ?? claims.email, plan: c.codex.plan ?? claims.plan };
@@ -206,7 +249,7 @@ function publicConnection(c: Connection): Record<string, unknown> {
206
249
  hasKey: c.apiKey !== '',
207
250
  region: c.region,
208
251
  zdr: c.zdr,
209
- signedIn: c.codex !== null || c.gemini !== null,
252
+ signedIn: c.codex !== null || c.gemini !== null || c.claude !== null,
210
253
  ...signedInAs(c),
211
254
  };
212
255
  }
@@ -227,6 +270,9 @@ export const setCodexAuth = (cfg: ModelConfig, id: string, auth: CodexTokens | n
227
270
  export const setGeminiAuth = (cfg: ModelConfig, id: string, auth: GeminiTokens | null): ModelConfig =>
228
271
  withConnection(cfg, id, (c) => ({ ...c, gemini: auth }));
229
272
 
273
+ export const setClaudeLogin = (cfg: ModelConfig, id: string, login: ClaudeLogin | null): ModelConfig =>
274
+ withConnection(cfg, id, (c) => ({ ...c, claude: login }));
275
+
230
276
  function field(patch: Record<string, unknown>, key: string, current: string, what: string, max = MAX_FIELD): string {
231
277
  if (!(key in patch)) return current;
232
278
  const value = patch[key];
@@ -276,7 +322,24 @@ export function updateConnection(cfg: ModelConfig, id: string, patch: unknown):
276
322
  export function removeConnection(cfg: ModelConfig, id: string): ModelConfig {
277
323
  requireConnection(cfg, id);
278
324
  const connections = cfg.connections.filter((c) => c.id !== id);
279
- return { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
325
+ const next = { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
326
+ return withFallbacks(next, (cfg.fallbacks ?? []).filter((f) => f.connection !== id));
327
+ }
328
+
329
+ function fallbackEntry(cfg: ModelConfig, raw: unknown): Fallback {
330
+ if (!isRecord(raw)) throw new ModelConfigError('each fallback must be an object with a connection and a model');
331
+ const connection = field(raw, 'connection', '', 'connection');
332
+ const model = field(raw, 'model', '', 'model');
333
+ requireConnection(cfg, connection);
334
+ if (model === '') throw new ModelConfigError('each fallback needs a model');
335
+ return { connection, model };
336
+ }
337
+
338
+ export function setFallbacks(cfg: ModelConfig, raw: unknown): ModelConfig {
339
+ if (!Array.isArray(raw)) throw new ModelConfigError('fallbacks must be a list');
340
+ if (raw.length > MAX_FALLBACKS) throw new ModelConfigError(`a box keeps at most ${String(MAX_FALLBACKS)} fallbacks`);
341
+ const entries = raw.map((f: unknown) => fallbackEntry(cfg, f));
342
+ return withFallbacks(cfg, fallbacksOf(entries, cfg.connections));
280
343
  }
281
344
 
282
345
  export function setRoute(cfg: ModelConfig, id: string): ModelConfig {
@@ -42,6 +42,8 @@ const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connectio
42
42
 
43
43
  function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens | null, note: string): unknown {
44
44
  const { cfg, id } = connectionToFill(store, req, 'codex');
45
+ const twin = cfg.connections.find((c) => c.id !== id && tokens !== null && c.codex?.refreshToken === tokens.refreshToken);
46
+ if (twin !== undefined) throw new ApiError(`this ChatGPT login is already connected as ${twin.label}; sign in with ChatGPT to add another one`, 409);
45
47
  const next = setCodexAuth(cfg, id, tokens);
46
48
  return saved(store, next, note, { connection: id, plan: tokens?.plan ?? null });
47
49
  }
@@ -5,8 +5,10 @@ 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';
11
+ import type { ClaudeLoginDeps } from './claude-logins.js';
10
12
 
11
13
  export interface ModelApiDeps {
12
14
  read?: () => ModelConfig;
@@ -24,10 +26,13 @@ export interface ModelApiDeps {
24
26
  bedrockControlBase?: string;
25
27
  setup?: SetupDeps;
26
28
  restartSession?: () => boolean;
27
- claudeUsage?: () => Promise<unknown>;
29
+ claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
30
+ claudeLoginsRoot?: string;
28
31
  usageWaitMs?: number;
29
32
  }
30
33
 
34
+ export const claudeLoginDeps = (deps: ModelApiDeps): ClaudeLoginDeps => ({ root: deps.claudeLoginsRoot, probe: deps.claudeUsage });
35
+
31
36
  export interface Store {
32
37
  read: () => ModelConfig;
33
38
  write: (cfg: ModelConfig) => void;
@@ -45,6 +50,8 @@ export const CREDITS_TTL_MS = 5 * 60_000;
45
50
 
46
51
  export const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({
47
52
  ...publicModelConfig(cfg),
53
+ fallbacks: cfg.fallbacks ?? [],
54
+ chain: chainStatus(cfg),
48
55
  lastServed: lastServed(),
49
56
  usage: usageSeen(),
50
57
  });
@@ -1,6 +1,6 @@
1
1
  import type { IncomingHttpHeaders } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
- import { GatewayError } from './forward.js';
3
+ import { GatewayError, OAUTH_BETA } from './forward.js';
4
4
  import type { Connection } from './model-config.js';
5
5
  import { fingerprint, listCache } from './model-lists.js';
6
6
  import { stringOf } from '@metro-labs/http/api-http';
@@ -9,7 +9,6 @@ export const ANTHROPIC_API = 'https://api.anthropic.com';
9
9
  const ANTHROPIC_VERSION = '2023-06-01';
10
10
  const LIST_MAX = 1000;
11
11
  const LIST_TIMEOUT_MS = 10_000;
12
- const OAUTH_BETA = 'oauth-2025-04-20';
13
12
  const BEARER = 'Bearer ';
14
13
 
15
14
  export interface ProviderModel {
@@ -3,10 +3,11 @@ 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
- import { CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
9
+ import { claudeLoginDeps, CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
10
+ import { probeClaudeLogin } from './claude-logins.js';
10
11
 
11
12
  const WAIT_MS = 4_000;
12
13
  const LOGIN_PROBE = 'claude-login';
@@ -41,14 +42,15 @@ async function refreshCodex(conn: Connection, deps: ModelApiDeps, store: Store,
41
42
  });
42
43
  }
43
44
 
44
- const modelsInUse = (conn: Connection): Set<string> => {
45
+ const modelsInUse = (conn: Connection, cfg: ModelConfig): Set<string> => {
45
46
  const served = lastServed();
46
- return new Set([conn.model, served?.connection === conn.id ? served.model : ''].filter((m) => m !== ''));
47
+ const fallbacks = (cfg.fallbacks ?? []).filter((f) => f.connection === conn.id).map((f) => f.model);
48
+ return new Set([conn.model, served?.connection === conn.id ? served.model : '', ...fallbacks].filter((m) => m !== ''));
47
49
  };
48
50
 
49
51
  async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
50
52
  if (conn.gemini === null) return;
51
- const inUse = modelsInUse(conn);
53
+ const inUse = modelsInUse(conn, store.read());
52
54
  const seen = usageOf(conn.id);
53
55
  const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
54
56
  if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
@@ -61,28 +63,38 @@ async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store,
61
63
 
62
64
  const loginIds = (cfg: ModelConfig): string[] => [
63
65
  ...(routedConnection(cfg) === null ? [PASSTHROUGH_ID] : []),
64
- ...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '').map((c) => c.id),
66
+ ...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '' && c.claude === null).map((c) => c.id),
65
67
  ];
66
68
 
67
- const olderThan = (seen: Reported | undefined, usage: Reported): boolean => seen === undefined || Date.parse(seen.at) < Date.parse(usage.at);
69
+ const hasFallbacks = (cfg: ModelConfig): boolean => (cfg.fallbacks ?? []).length > 0;
70
+
71
+ async function refreshOwnLogin(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
72
+ const login = conn.apiKey === '' ? conn.claude : null;
73
+ if (login === null || (fresh(conn.id, now) && !hasFallbacks(cfg)) || !mayProbe(conn.id, now, CREDITS_TTL_MS)) return;
74
+ await probe('usage of a Claude login', async () => {
75
+ const usage = claudeLoginUsage(await probeClaudeLogin(login.id, claudeLoginDeps(deps)), new Date(now));
76
+ if (usage !== null) mergeUsage(conn.id, usage);
77
+ });
78
+ }
68
79
 
69
80
  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;
81
+ const ids = loginIds(cfg);
82
+ if (!ids.some((id) => !fresh(id, now)) && !(hasFallbacks(cfg) && ids.length > 0)) return;
72
83
  if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
73
84
  await probe('usage of the Claude Code login', async () => {
74
- const usage = claudeLoginUsage(await (deps.claudeUsage ?? readClaudeUsage)(), new Date(now));
85
+ const usage = claudeLoginUsage(await (deps.claudeUsage ?? (() => readClaudeUsage()))(), new Date(now));
75
86
  if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
76
87
  });
77
88
  const usage = probeAnswer(LOGIN_PROBE);
78
89
  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);
90
+ for (const id of ids) mergeUsage(id, usage);
80
91
  }
81
92
 
82
- function refreshOne(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
93
+ function refreshOne(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
83
94
  if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
84
95
  if (conn.provider === 'codex') return refreshCodex(conn, deps, store, now);
85
96
  if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
97
+ if (conn.provider === 'anthropic') return refreshOwnLogin(conn, cfg, deps, now);
86
98
  return Promise.resolve();
87
99
  }
88
100
 
@@ -97,6 +109,17 @@ async function within(ms: number, work: Promise<unknown>): Promise<void> {
97
109
 
98
110
  export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
99
111
  const cfg = store.read();
100
- const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, deps, store, now)), refreshLogin(cfg, deps, now)]);
112
+ const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, cfg, deps, store, now)), refreshLogin(cfg, deps, now)]);
101
113
  await within(deps.usageWaitMs ?? WAIT_MS, work);
102
114
  }
115
+
116
+ export function watchFallbackUsage(deps: ModelApiDeps = {}, every = CREDITS_TTL_MS): void {
117
+ const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
118
+ const run = (): void => {
119
+ if (!hasFallbacks(store.read())) return;
120
+ refreshUsage(deps, store).catch((err: unknown) => {
121
+ log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
122
+ });
123
+ };
124
+ setInterval(run, every).unref();
125
+ }