@stage-labs/metro 0.1.0-beta.308 → 0.1.0-beta.309

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 (39) hide show
  1. package/package.json +1 -1
  2. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -9
  3. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +11 -4
  4. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
  5. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +1 -6
  6. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
  7. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +36 -64
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +99 -213
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
  21. package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
  22. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
  23. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
  24. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -4
  25. package/runtime/runtime.json +1 -1
  26. package/runtime/sdk-runner/runner.json +1 -1
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +0 -219
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
  31. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
  32. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
  33. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
  34. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
  35. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
  36. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -82
  37. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
  38. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
  39. package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
@@ -1,7 +1,7 @@
1
1
  import { log } from '@metro-labs/core/log';
2
2
  import { lastServed } from './served.js';
3
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';
4
+ import { bareModel, connectionOf, isSmallModel, notReady, primaryRoute, routeLabel, type Connection, type Fallback, type ModelSetup, type Route } from './model-config.js';
5
5
 
6
6
  export const USAGE_LIMIT = 0.95;
7
7
  const COOLDOWN_MS = 60_000;
@@ -36,7 +36,6 @@ function scopeOf(label: string): string | null {
36
36
  function applies(window: UsageWindow, route: Route, now: number): boolean {
37
37
  if (window.used === null || window.label === PER_MINUTE) return false;
38
38
  if (window.resetAt !== null && Date.parse(window.resetAt) <= now) return false;
39
- if (route.connection.provider === 'gemini') return window.label === route.model;
40
39
  const scope = scopeOf(window.label);
41
40
  return scope === null || route.model.toLowerCase().split(/[^a-z0-9]+/).includes(scope);
42
41
  }
@@ -59,16 +58,16 @@ function fallbackModel(requested: string, conn: Connection, model: string): stri
59
58
  return conn.provider === 'anthropic' && isSmallModel(bare) ? bare : model;
60
59
  }
61
60
 
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;
61
+ function fallbackRoute(setup: ModelSetup, fallback: Fallback, requested: string): Route | null {
62
+ const conn = connectionOf(setup, fallback.connection);
63
+ if (conn === null || notReady({ connection: conn, model: fallback.model }) !== null) return null;
65
64
  return { connection: conn, model: fallbackModel(requested, conn, fallback.model) };
66
65
  }
67
66
 
68
67
  const unique = (routes: Route[]): Route[] => routes.filter((route, at) => routes.findIndex((other) => keyOf(other) === keyOf(route)) === at);
69
68
 
70
- export const chainOf = (primary: Route, cfg: ModelConfig, requested: string): Route[] =>
71
- unique([primary, ...(cfg.fallbacks ?? []).flatMap((f) => fallbackRoute(cfg, f, requested) ?? [])]);
69
+ export const chainOf = (primary: Route, setup: ModelSetup, requested: string): Route[] =>
70
+ unique([primary, ...setup.fallbacks.flatMap((f) => fallbackRoute(setup, f, requested) ?? [])]);
72
71
 
73
72
  export function routesToTry(chain: Route[], now = Date.now()): Route[] {
74
73
  const open = chain.filter((route) => holdOf(route, now) === null);
@@ -120,28 +119,28 @@ export interface ChainRow {
120
119
  active: boolean;
121
120
  }
122
121
 
123
- function primaryOf(cfg: ModelConfig): Route | null {
124
- const conn = routedConnection(cfg);
125
- if (conn === null) return null;
122
+ function primaryOf(setup: ModelSetup): Route | null {
123
+ const primary = primaryRoute(setup);
124
+ if (primary === null) return null;
126
125
  const served = lastServed();
127
- const model = conn.model !== '' ? conn.model : served?.connection === conn.id ? served.model : '';
128
- return { connection: conn, model };
126
+ const model = primary.model !== '' ? primary.model : served?.connection === primary.connection.id ? served.model : '';
127
+ return { connection: primary.connection, model };
129
128
  }
130
129
 
131
- function rowOf(cfg: ModelConfig, route: Route, now: number): Omit<ChainRow, 'active'> {
132
- const reason = notReady(cfg, route.connection);
130
+ function rowOf(route: Route, now: number): Omit<ChainRow, 'active'> {
131
+ const reason = notReady(route);
133
132
  const used = windowsFor(route, now).reduce<number | null>((most, w) => Math.max(most ?? 0, w.used ?? 0), null);
134
133
  return { connection: route.connection.id, model: route.model, used, hold: reason === null ? holdOf(route, now) : { reason, until: null } };
135
134
  }
136
135
 
137
- export function chainStatus(cfg: ModelConfig, now = Date.now()): ChainRow[] {
138
- const primary = primaryOf(cfg);
136
+ export function chainStatus(setup: ModelSetup, now = Date.now()): ChainRow[] {
137
+ const primary = primaryOf(setup);
139
138
  if (primary === null) return [];
140
- const listed = (cfg.fallbacks ?? []).flatMap((f) => {
141
- const conn = connectionOf(cfg, f.connection);
139
+ const listed = setup.fallbacks.flatMap((f) => {
140
+ const conn = connectionOf(setup, f.connection);
142
141
  return conn === null ? [] : [{ connection: conn, model: f.model }];
143
142
  });
144
- const rows = unique([primary, ...listed]).map((route) => rowOf(cfg, route, now));
143
+ const rows = unique([primary, ...listed]).map((route) => rowOf(route, now));
145
144
  const active = Math.max(0, rows.findIndex((row) => row.hold === null));
146
145
  return rows.map((row, at) => ({ ...row, active: at === active }));
147
146
  }
@@ -3,7 +3,6 @@ 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';
5
5
  import {
6
- assertBedrockReady,
7
6
  bedrockBase,
8
7
  bedrockCount,
9
8
  bedrockMessages,
@@ -37,21 +36,17 @@ import {
37
36
  withEffort,
38
37
  withThinkingFor,
39
38
  } from './effort.js';
40
- import { notReady, PASSTHROUGH_ID, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
41
- import { codexMessages, sharedCodexState } from './codex.js';
42
- import { geminiMessages, sharedGeminiState } from './gemini.js';
39
+ import { notReady, resolveRoute, routeLabel, sessionConnection, type Connection, type ModelSetup, type Route } from './model-config.js';
40
+ import { codexMessages, type CodexDeps } from './codex.js';
43
41
  import { countTokens } from './subscription.js';
44
- import type { GeminiDeps } from './gemini.js';
45
- import type { CodexDeps } from './codex.js';
46
- import type { GeminiTokens } from './gemini-auth.js';
47
42
  import { OPENROUTER_BASE } from './openrouter.js';
48
43
  import { isRecord } from '@metro-labs/core/is-record';
49
44
  import { forgetServed, noteServed } from './served.js';
50
45
  import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
51
46
  import { fitToolSearch } from './tool-search.js';
52
47
  import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
53
- import type { CodexTokens } from './codex-auth.js';
54
48
  import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
49
+ import { orgModels } from './org-models.js';
55
50
 
56
51
  const GATEWAY_PREFIX = '/gateway';
57
52
  const MESSAGES = '/v1/messages';
@@ -60,13 +55,12 @@ const MODELS = '/v1/models';
60
55
  const HELLO = '/api/hello';
61
56
 
62
57
  export interface GatewayDeps {
63
- config: () => ModelConfig;
58
+ setup: () => ModelSetup;
64
59
  identify?: (key: string) => boolean;
65
60
  anthropicBase?: string;
66
61
  bedrockBase?: string;
67
62
  openrouterBase?: string;
68
63
  codex?: Partial<CodexDeps>;
69
- gemini?: Partial<GeminiDeps>;
70
64
  claudeLogins?: ClaudeLoginDeps;
71
65
  }
72
66
 
@@ -79,18 +73,10 @@ export function resetGatewayState(): void {
79
73
  learned.fields.clear();
80
74
  learned.dropBetas = false;
81
75
  forgetLearnedThinking();
82
- sharedCodexState.clear();
83
- sharedGeminiState.clear();
84
76
  forgetClaudeTokens();
85
77
  }
86
78
 
87
- const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
88
- writeModelConfig(setCodexAuth(readModelConfig(), id, tokens));
89
- };
90
-
91
- const saveGeminiTokens = (id: string, tokens: GeminiTokens): void => {
92
- writeModelConfig(setGeminiAuth(readModelConfig(), id, tokens));
93
- };
79
+ const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
94
80
 
95
81
  const keyOf = (req: IncomingMessage): string => {
96
82
  const raw = req.headers['x-metro-key'];
@@ -99,27 +85,23 @@ const keyOf = (req: IncomingMessage): string => {
99
85
 
100
86
  const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
101
87
 
102
- function modelsBody(cfg: ModelConfig): Record<string, unknown> {
103
- const data = cfg.connections
104
- .filter((c) => c.provider !== 'anthropic' && c.model !== '')
105
- .map((c) => ({ id: `${c.provider}:${c.model}`, display_name: `${c.label} · ${c.model}`, description: 'Through metro' }));
106
- return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
88
+ function routesOf(setup: ModelSetup): Route[] {
89
+ return [...(setup.primary === null ? [] : [setup.primary]), ...setup.fallbacks].flatMap((ref) => {
90
+ const conn = setup.connections.find((c) => c.id === ref.connection);
91
+ const model = ref.model === '' ? (conn?.model ?? '') : ref.model;
92
+ return conn === undefined || model === '' ? [] : [{ connection: conn, model }];
93
+ });
107
94
  }
108
95
 
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
- };
96
+ function modelsBody(setup: ModelSetup): Record<string, unknown> {
97
+ const listed = routesOf(setup)
98
+ .filter((r) => r.connection.provider !== 'anthropic')
99
+ .map((r) => ({ id: routeLabel(r), display_name: `${r.connection.label} · ${r.model}`, description: 'Through metro' }));
100
+ const data = listed.filter((m, at) => listed.findIndex((o) => o.id === m.id) === at);
101
+ return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
102
+ }
121
103
 
122
- const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
104
+ const passthrough = (body: Record<string, unknown>): Route => ({ connection: sessionConnection(), model: requestedModel(body) });
123
105
 
124
106
  function requestedModel(body: Record<string, unknown>): string {
125
107
  const model = body.model;
@@ -175,7 +157,7 @@ function listLoginModels(headers: IncomingHttpHeaders, anthropicBase: string): v
175
157
  function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
176
158
  if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
177
159
  if (conn.claude === null) return attempt(forwardedHeaders(req));
178
- return withClaudeLogin(conn.claude.id, deps.claudeLogins ?? {}, (token) => {
160
+ return withClaudeLogin(conn.claude, deps.claudeLogins ?? {}, (token) => {
179
161
  listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
180
162
  return attempt(loginHeaders(req, token));
181
163
  });
@@ -184,7 +166,7 @@ function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: st
184
166
  async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
185
167
  if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
186
168
  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`);
169
+ throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login ${conn.label}: sign in again on the Model page`);
188
170
  }
189
171
 
190
172
  async function toAnthropic(
@@ -204,7 +186,7 @@ async function toAnthropic(
204
186
  throw new GatewayError(
205
187
  403,
206
188
  'permission_error',
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',
189
+ "Claude Code on this machine has no Anthropic login of its own; route this agent to a model on the organization's Models page, or sign in a Claude login on the Model page",
208
190
  );
209
191
  const watch = watchUpstream(res);
210
192
  if (passthrough) listLoginModels(req.headers, anthropicBase);
@@ -223,17 +205,8 @@ async function toAnthropic(
223
205
  await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
224
206
  }
225
207
 
226
- async function toOpenRouter(
227
- req: IncomingMessage,
228
- res: ServerResponse,
229
- body: Record<string, unknown>,
230
- route: Route,
231
- cfg: ModelConfig,
232
- deps: GatewayDeps,
233
- ): Promise<void> {
208
+ async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
234
209
  const conn = route.connection;
235
- const reason = notReady(cfg, conn);
236
- if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
237
210
  const headers: Record<string, string> = {
238
211
  'content-type': 'application/json',
239
212
  accept: req.headers.accept ?? 'application/json',
@@ -270,11 +243,9 @@ function noteRefusal(connection: string, model: string, upstream: Response): voi
270
243
  if (!upstream.ok) log.warn({ connection, model, status: upstream.status }, 'gateway: the provider refused the request');
271
244
  }
272
245
 
273
- async function toSubscription(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
274
- const conn = route.connection;
246
+ async function toCodex(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
275
247
  if (path === COUNT) countTokens(res, body);
276
- else if (conn.provider === 'gemini') await geminiMessages(req, res, body, route.model, conn, { save: saveGeminiTokens, ...deps.gemini }, sharedGeminiState, watchUpstream(res));
277
- else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, sharedCodexState, watchUpstream(res));
248
+ else await codexMessages(req, res, body, route.model, route.connection, { refresh: refreshCodex, ...deps.codex }, watchUpstream(res));
278
249
  }
279
250
 
280
251
  function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
@@ -290,16 +261,17 @@ interface Request {
290
261
  parsed: Record<string, unknown>;
291
262
  sent: Record<string, unknown>;
292
263
  body: Record<string, unknown>;
293
- cfg: ModelConfig;
264
+ setup: ModelSetup;
294
265
  }
295
266
 
296
267
  async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
297
268
  const { path, body } = ask;
298
269
  const conn = route.connection;
299
270
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
271
+ const reason = notReady(route);
272
+ if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
300
273
  if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
301
274
  if (conn.provider === 'bedrock') {
302
- assertBedrockReady(conn);
303
275
  const up = { settings: conn, base: deps.bedrockBase ?? bedrockBase(conn.region), learned, watch: watchUpstream(res) };
304
276
  if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
305
277
  else await bedrockMessages(req, res, body, route.model, up);
@@ -307,25 +279,25 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
307
279
  }
308
280
  if (conn.provider === 'openrouter') {
309
281
  if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
310
- await toOpenRouter(req, res, body, route, ask.cfg, deps);
282
+ await toOpenRouter(req, res, body, route, deps);
311
283
  return;
312
284
  }
313
- if (conn.provider === 'codex' || conn.provider === 'gemini') {
314
- await toSubscription(req, res, path, body, route, deps);
285
+ if (conn.provider === 'codex') {
286
+ await toCodex(req, res, path, body, route, deps);
315
287
  return;
316
288
  }
317
289
  await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
318
290
  }
319
291
 
320
292
  async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
321
- const cfg = deps.config();
293
+ const setup = deps.setup();
322
294
  const raw = await readBody(req);
323
295
  const parsed = parseJson(raw);
324
296
  const sent = fitToolSearch(req, parsed);
325
297
  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));
298
+ const primary = resolveRoute(requested, setup) ?? passthrough(sent);
299
+ const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
300
+ const routes = routesToTry(chainOf(primary, setup, requested));
329
301
  for (const [at, route] of routes.entries()) {
330
302
  const next = routes[at + 1];
331
303
  if (path === MESSAGES) noteChoice(primary, route);
@@ -357,7 +329,7 @@ function failed(res: ServerResponse, err: unknown): void {
357
329
  function answer(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): void {
358
330
  if (req.method === 'GET' && path === MODELS) {
359
331
  res.writeHead(200, { 'content-type': 'application/json' });
360
- res.end(JSON.stringify(modelsBody(deps.config())));
332
+ res.end(JSON.stringify(modelsBody(deps.setup())));
361
333
  return;
362
334
  }
363
335
  if (req.method === 'POST' && (path === MESSAGES || path === COUNT)) {
@@ -1,217 +1,105 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { ApiError } from '@metro-labs/http/api-error';
3
- import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
4
- import { isRecord } from '@metro-labs/core/is-record';
5
- import { log } from '@metro-labs/core/log';
6
- import { openrouterModels, openrouterZdrModels } from './openrouter.js';
7
- import { anthropicModels, bedrockModels } from './provider-models.js';
8
- import { runnerAlive } from '../claude/session.js';
9
- import { forgetOne, forgetReported } from './usage.js';
10
- import {
11
- addConnection,
12
- connectionOf,
13
- parseModelConfig,
14
- readModelConfig,
15
- readModelSnapshot,
16
- removeConnection,
17
- routedConnection,
18
- setFallbacks,
19
- setRoute,
20
- updateConnection,
21
- writeModelConfig,
22
- type ModelConfig,
23
- } from './model-config.js';
24
- import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
25
- import { dropUnusedClaudeLogins } from './claude-logins.js';
26
- import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
27
- import { refreshUsage } from './usage-refresh.js';
28
- import { handleOpenRouterSignIn } from './openrouter-signin.js';
3
+ import { apiFailure, apiSession, cors, requireAdmin, sendJson, type ApiSession } from '@metro-labs/http/api-http';
4
+ import { agentsDir } from '../agents/files.js';
5
+ import { removeClaudeLogin } from './claude-logins.js';
6
+ import { readClaudeLogins, type ClaudeLogin } from './claude-store.js';
7
+ import { effectiveSetup } from './effective.js';
8
+ import { chainStatus } from './fallback.js';
9
+ import { notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
10
+ import { orgModels, type SyncStatus } from './org-models.js';
11
+ import { claudeModels } from './provider-models.js';
12
+ import { lastServed } from './served.js';
13
+ import { refreshUsage, type UsageDeps } from './usage-refresh.js';
14
+ import { usageSeen } from './usage.js';
29
15
 
30
16
  const PATH = '/api/model';
31
17
  const SNAPSHOT = '/api/model/snapshot';
32
- const CONNECTIONS = '/api/model/connections';
33
- const FALLBACKS = '/api/model/fallbacks';
34
- const CODEX = '/api/model/codex/';
35
- const GEMINI = '/api/model/gemini/';
36
- const OPENROUTER = '/api/model/openrouter/';
37
- const ANTHROPIC = '/api/model/anthropic/';
38
- const BEDROCK = '/api/model/bedrock/';
39
- const BUNDLE = '/api/model/bundle';
40
- const RESTORE = '/api/model/restore';
41
- const RESTORE_MAX = 64 * 1024;
42
- const DEVICE_PREFIX = 'device/';
43
- const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
44
-
45
- export type { ModelApiDeps } from './model-store.js';
46
-
47
- function runnerModel(cfg: ModelConfig): string | null {
48
- const conn = routedConnection(cfg);
49
- if (conn === null || conn.model === '') return null;
50
- return conn.provider === 'anthropic' ? conn.model : `${conn.provider}:${conn.model}`;
51
- }
52
-
53
- function switchLive(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
54
- const [was, now] = [runnerModel(before), runnerModel(next)];
55
- if (was === now) return;
56
- if (deps.switchModel?.(now) === true) log.info({ was, now }, 'model-api: the model changed, so the Agent SDK session switches to it live, with no restart');
57
- else if (runnerIsUp(deps)) throw new ApiError('The running Agent SDK could not be told about this model change. Nothing was saved. Let its work finish, stop it, then save and start it again.', 409);
58
- else log.info({ was, now }, 'model-api: the model changed while no Agent SDK runner is up; its next start loads it');
59
- }
60
-
61
- function kept(store: Store, next: ModelConfig, deps: ModelApiDeps, note: string, fields: Record<string, unknown>): unknown {
62
- const before = store.read();
63
- store.write(next);
64
- try {
65
- switchLive(before, next, deps);
66
- } catch (err) {
67
- store.write(before);
68
- throw err;
69
- }
70
- log.info(fields, note);
71
- return settingsBody(next);
72
- }
73
-
74
- async function withBody(req: IncomingMessage, run: (body: Record<string, unknown>) => ModelConfig): Promise<ModelConfig> {
75
- const body = await readJsonBody(req, BODY_MAX);
76
- if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
77
- try {
78
- return run(body);
79
- } catch (err) {
80
- return asApiError(err);
81
- }
82
- }
83
-
84
- async function chooseRoute(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
85
- const next = await withBody(req, (body) => setRoute(store.read(), typeof body.route === 'string' ? body.route : ''));
86
- return kept(store, next, deps, 'model-api: route changed', { route: next.route });
87
- }
88
-
89
- async function chooseFallbacks(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
90
- const next = await withBody(req, (body) => setFallbacks(store.read(), body.fallbacks));
91
- return kept(store, next, deps, 'model-api: fallback models changed', { fallbacks: (next.fallbacks ?? []).map((f) => `${f.connection}:${f.model}`) });
92
- }
93
-
94
- async function create(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
95
- const next = await withBody(req, (body) => addConnection(store.read(), body));
96
- return kept(store, next, deps, 'model-api: connection added', { connection: next.connections.at(-1)?.id ?? '' });
97
- }
98
-
99
- async function change(req: IncomingMessage, deps: ModelApiDeps, store: Store, id: string): Promise<unknown> {
100
- const before = connectionOf(store.read(), id)?.apiKey;
101
- const next = await withBody(req, (body) => updateConnection(store.read(), id, body));
102
- if (connectionOf(next, id)?.apiKey !== before) forgetReported(id);
103
- return kept(store, next, deps, 'model-api: connection changed', { connection: id });
104
- }
105
-
106
- function drop(deps: ModelApiDeps, store: Store, id: string): unknown {
107
- const before = store.read();
108
- let next: ModelConfig;
109
- try {
110
- next = removeConnection(before, id);
111
- } catch (err) {
112
- asApiError(err);
113
- }
114
- forgetOne(id);
115
- const body = kept(store, next, deps, 'model-api: connection removed', { connection: id });
116
- dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
117
- return body;
118
- }
119
-
120
- async function settingsWithUsage(deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
121
- await refreshUsage(deps, store);
122
- return settingsBody(store.read());
123
- }
124
-
125
- const OPENROUTER_ROUTES: Record<string, Route> = {
126
- models: { method: 'GET', run: async (_req, deps) => ({ models: await openrouterModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }) },
127
- zdr: { method: 'GET', run: async (_req, deps) => ({ models: await openrouterZdrModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }) },
128
- };
129
-
130
- const ANTHROPIC_ROUTES: Record<string, Route> = {
131
- models: {
132
- method: 'GET',
133
- run: async (req, deps, store) => ({ models: await anthropicModels(connectionFor(store.read(), req, 'anthropic'), deps.anthropicBase, deps.fetchImpl).catch(asApiError) }),
134
- },
135
- };
136
-
137
- const BEDROCK_ROUTES: Record<string, Route> = {
138
- models: {
139
- method: 'GET',
140
- run: async (req, deps, store) => ({ models: await bedrockModels(connectionFor(store.read(), req, 'bedrock'), deps.bedrockControlBase, deps.fetchImpl).catch(asApiError) }),
141
- },
142
- };
143
-
144
- const named = (table: Record<string, Route>, name: string, method: string | undefined): Route | number => {
145
- const route = table[name];
146
- if (route === undefined) return 404;
147
- return route.method === method ? route : 405;
148
- };
149
-
150
- async function restore(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
151
- const body = await readJsonBody(req, RESTORE_MAX);
152
- if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
153
- const before = store.read();
154
- const next = parseModelConfig(body);
155
- const answer = kept(store, next, deps, 'model-api: model setup restored from a file', {});
156
- dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
157
- return answer;
158
- }
159
-
160
- function bundleRoute(path: string, method: string | undefined): Route | number {
161
- if (path === BUNDLE) return method === 'GET' ? { method: 'GET', run: (_req, _deps, store) => Promise.resolve(store.read()) } : 405;
162
- return method === 'POST' ? { method: 'POST', run: (req, deps, store) => restore(req, store, deps) } : 405;
163
- }
164
-
165
- function settingsRoute(path: string, method: string | undefined): Route | number {
166
- if (path === FALLBACKS) return method === 'PUT' ? { method: 'POST', run: chooseFallbacks } : 405;
167
- if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(deps, store) };
168
- if (method === 'PUT') return { method: 'POST', run: chooseRoute };
169
- return 405;
170
- }
171
-
172
- function connectionsRoute(path: string, method: string | undefined): Route | number {
173
- if (path === CONNECTIONS) return method === 'POST' ? { method: 'POST', run: create } : 405;
174
- const id = path.slice(CONNECTIONS.length + 1);
175
- if (id === '' || id.includes('/')) return 404;
176
- if (method === 'PUT') return { method: 'POST', run: (req, deps, store) => change(req, deps, store, id) };
177
- if (method === 'DELETE') return { method: 'GET', run: (_req, deps, store) => Promise.resolve(drop(deps, store, id)) };
178
- return 405;
179
- }
180
-
181
- function codexRoute(rest: string, method: string | undefined): Route | number {
182
- if (rest in CODEX_ROUTES) return named(CODEX_ROUTES, rest, method);
183
- const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
184
- if (!DEVICE_ID_RE.test(id)) return 404;
185
- return codexDeviceRoute(id, method);
186
- }
187
-
188
- const TABLES: [string, Record<string, Route>][] = [
189
- [OPENROUTER, OPENROUTER_ROUTES],
190
- [ANTHROPIC, ANTHROPIC_ROUTES],
191
- [BEDROCK, BEDROCK_ROUTES],
192
- [GEMINI, GEMINI_ROUTES],
193
- ];
194
- const EXACT = new Set([PATH, SNAPSHOT, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
195
- const PREFIXES = [`${CONNECTIONS}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
196
-
197
- const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
198
-
199
- function routeFor(path: string, method: string | undefined): Route | number {
200
- if (path === SNAPSHOT) return method === 'GET' ? { method: 'GET', run: (_req, _deps, store) => Promise.resolve().then(() => settingsBody(store.read())).catch(asApiError) } : 405;
201
- if (path === PATH || path === FALLBACKS) return settingsRoute(path, method);
202
- if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
203
- if (path === CONNECTIONS || path.startsWith(`${CONNECTIONS}/`)) return connectionsRoute(path, method);
204
- const table = TABLES.find(([prefix]) => path.startsWith(prefix));
205
- if (table !== undefined) return named(table[1], path.slice(table[0].length), method);
206
- return codexRoute(path.slice(CODEX.length), method);
207
- }
208
-
209
- const runnerIsUp = (deps: ModelApiDeps): boolean => (deps.runnerAlive ?? (() => runnerAlive()))();
210
-
211
- const storeFor = (path: string, deps: ModelApiDeps): Store => ({
212
- read: deps.read ?? (path === SNAPSHOT ? readModelSnapshot : readModelConfig),
213
- write: deps.write ?? writeModelConfig,
214
- });
18
+ const SYNC = '/api/model/sync';
19
+ const CLAUDE_MODELS = '/api/model/claude-models';
20
+ const CLAUDE_LOGIN = '/api/model/claude/';
21
+ const LOGIN_RE = /^[A-Za-z0-9_-]{11}$/;
22
+
23
+ export interface ModelApiDeps extends UsageDeps {
24
+ dir?: () => string;
25
+ setup?: () => ModelSetup;
26
+ sync?: () => Promise<void>;
27
+ syncStatus?: () => SyncStatus;
28
+ anthropicBase?: string;
29
+ }
30
+
31
+ const NO_SYNC: SyncStatus = { enrolled: false, at: null, error: null };
32
+
33
+ function publicConnection(c: Connection): Record<string, unknown> {
34
+ return {
35
+ id: c.id,
36
+ provider: c.provider,
37
+ label: c.label,
38
+ model: c.model,
39
+ hasKey: c.apiKey !== '',
40
+ region: c.region,
41
+ zdr: c.zdr,
42
+ signedIn: c.codex !== null || (c.claude !== null && !c.signedOut),
43
+ signedOut: c.signedOut,
44
+ account: c.account.email,
45
+ plan: c.account.plan,
46
+ method: c.account.method,
47
+ };
48
+ }
49
+
50
+ const loginsOf = (deps: ModelApiDeps): ClaudeLogin[] => readClaudeLogins(deps.dir?.() ?? agentsDir());
51
+ const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup(deps.dir?.())))();
52
+
53
+ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
54
+ const setup = setupOf(deps);
55
+ const primary = primaryRoute(setup);
56
+ const reason = primary === null ? null : notReady(primary);
57
+ return {
58
+ source: setup.source,
59
+ sync: (deps.syncStatus ?? (() => orgModels()?.syncStatus() ?? NO_SYNC))(),
60
+ route: primary?.connection.id ?? '',
61
+ model: primary?.model ?? '',
62
+ ready: reason === null,
63
+ reason,
64
+ connections: setup.connections.map(publicConnection),
65
+ fallbacks: setup.fallbacks,
66
+ chain: chainStatus(setup),
67
+ claude: loginsOf(deps),
68
+ lastServed: lastServed(),
69
+ usage: usageSeen(),
70
+ };
71
+ }
72
+
73
+ async function withUsage(deps: ModelApiDeps): Promise<Record<string, unknown>> {
74
+ await refreshUsage(setupOf(deps), loginsOf(deps), deps);
75
+ return settingsBody(deps);
76
+ }
77
+
78
+ async function synced(deps: ModelApiDeps): Promise<Record<string, unknown>> {
79
+ await (deps.sync ?? (() => orgModels()?.requested() ?? Promise.resolve()))();
80
+ return settingsBody(deps);
81
+ }
82
+
83
+ function dropLogin(session: ApiSession, login: string, deps: ModelApiDeps): Record<string, unknown> {
84
+ requireAdmin(session);
85
+ removeClaudeLogin(login, { ...deps.claudeLogins, dir: deps.dir?.() ?? deps.claudeLogins?.dir });
86
+ return settingsBody(deps);
87
+ }
88
+
89
+ type Run = (session: ApiSession) => Promise<unknown>;
90
+
91
+ function routeFor(path: string, method: string | undefined, deps: ModelApiDeps): Run | number {
92
+ const only = (wanted: string, run: Run): Run | number => (method === wanted ? run : 405);
93
+ if (path === PATH) return only('GET', () => withUsage(deps));
94
+ if (path === SNAPSHOT) return only('GET', () => Promise.resolve(settingsBody(deps)));
95
+ if (path === SYNC) return only('POST', () => synced(deps));
96
+ if (path === CLAUDE_MODELS) return only('GET', () => Promise.resolve({ models: claudeModels(deps.anthropicBase) }));
97
+ const login = path.slice(CLAUDE_LOGIN.length);
98
+ if (!LOGIN_RE.test(login)) return 404;
99
+ return only('DELETE', (session) => Promise.resolve(dropLogin(session, login, deps)));
100
+ }
101
+
102
+ const mine = (path: string): boolean => path === PATH || path.startsWith(`${PATH}/`);
215
103
 
216
104
  export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
217
105
  const path = (req.url ?? '').split('?')[0] ?? '';
@@ -220,9 +108,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
220
108
  res.writeHead(204, cors(req)).end();
221
109
  return true;
222
110
  }
223
- const store = storeFor(path, deps);
224
- if (handleOpenRouterSignIn(req, res, deps, store)) return true;
225
- const route = routeFor(path, req.method);
111
+ const route = path === PATH || path.startsWith(CLAUDE_LOGIN) || [SNAPSHOT, SYNC, CLAUDE_MODELS].includes(path) ? routeFor(path, req.method, deps) : 404;
226
112
  if (typeof route === 'number') {
227
113
  sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
228
114
  return true;
@@ -230,7 +116,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
230
116
  apiSession(req)
231
117
  .then(async (session) => {
232
118
  if (!session) throw new ApiError('unauthorized', 401);
233
- sendJson(req, res, 200, await route.run(req, deps, store));
119
+ sendJson(req, res, 200, await route(session));
234
120
  })
235
121
  .catch((err: unknown) => {
236
122
  apiFailure(req, res, err, 'model-api');