@stage-labs/metro 0.1.0-beta.164 → 0.1.0-beta.166

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 (22) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/orchestrator.md +6 -0
  4. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +2 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +8 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -2
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +28 -13
  8. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +3 -3
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +19 -19
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +70 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +9 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +83 -51
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +3 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +35 -18
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +66 -238
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +202 -141
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +195 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +70 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
  20. package/runtime/node_modules/@metro-labs/daemon/src/gateway/served.ts +1 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +25 -23
  22. package/runtime/runtime.json +1 -1
@@ -2,33 +2,26 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { ApiError } from '@metro-labs/http/api-error';
3
3
  import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
- import { errMsg, log } from '@metro-labs/core/log';
6
- import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from './codex-auth.js';
7
- import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
8
- import { codexModels, currentTokens, freshCodexState } from './codex.js';
9
- import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, GeminiAuthError, userEmail } from './gemini-auth.js';
10
- import { onboard, parseGeminiProject } from './gemini-setup.js';
11
- import { currentGeminiTokens, freshGeminiState, listGeminiModels, type GeminiDeps } from './gemini.js';
12
- import { openrouterCredits, openrouterModels, openrouterZdrModels } from './openrouter.js';
5
+ import { log } from '@metro-labs/core/log';
6
+ import { openrouterModels, openrouterZdrModels } from './openrouter.js';
13
7
  import { anthropicModels, bedrockModels } from './provider-models.js';
14
- import { syncAvailableModelsQuietly, type SetupDeps } from '../claude/setup.js';
15
- import { lastServed } from './served.js';
16
- import { geminiUsage, noteUsage, openrouterUsage, usageOf, usageSeen } from './usage.js';
17
- import type { CodexTokens } from './codex-auth.js';
18
- import { GatewayError } from './forward.js';
8
+ import { syncAvailableModelsQuietly } from '../claude/setup.js';
9
+ import { forgetOne } from './usage.js';
19
10
  import {
20
- applyModelUpdate,
21
- ModelConfigError,
11
+ addConnection,
22
12
  parseModelConfig,
23
- publicModelConfig,
24
13
  readModelConfig,
25
- setCodexAuth,
26
- setGeminiAuth,
14
+ removeConnection,
15
+ setRoute,
16
+ updateConnection,
27
17
  writeModelConfig,
28
18
  type ModelConfig,
29
19
  } from './model-config.js';
20
+ import { asApiError, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
21
+ import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES, refreshUsage } from './model-signin.js';
30
22
 
31
23
  const PATH = '/api/model';
24
+ const CONNECTIONS = '/api/model/connections';
32
25
  const CODEX = '/api/model/codex/';
33
26
  const GEMINI = '/api/model/gemini/';
34
27
  const OPENROUTER = '/api/model/openrouter/';
@@ -37,255 +30,75 @@ const BEDROCK = '/api/model/bedrock/';
37
30
  const BUNDLE = '/api/model/bundle';
38
31
  const RESTORE = '/api/model/restore';
39
32
  const RESTORE_MAX = 64 * 1024;
40
- const BODY_MAX = 16 * 1024;
41
33
  const DEVICE_PREFIX = 'device/';
42
34
  const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
43
35
 
44
- export interface ModelApiDeps {
45
- authorize: (subject: string) => void;
46
- read?: () => ModelConfig;
47
- write?: (cfg: ModelConfig) => void;
48
- issuer?: string;
49
- fetchImpl?: typeof fetch;
50
- codexHome?: string;
51
- codexBase?: string;
52
- geminiAuthBase?: string;
53
- geminiTokenBase?: string;
54
- geminiUserBase?: string;
55
- geminiBase?: string;
56
- openrouterBase?: string;
57
- anthropicBase?: string;
58
- bedrockControlBase?: string;
59
- setup?: SetupDeps;
60
- }
61
-
62
- interface Store {
63
- read: () => ModelConfig;
64
- write: (cfg: ModelConfig) => void;
65
- }
36
+ export type { ModelApiDeps } from './model-store.js';
66
37
 
67
- type Handler = (req: IncomingMessage, deps: ModelApiDeps, store: Store) => Promise<unknown>;
68
-
69
- interface Route {
70
- method: 'GET' | 'POST';
71
- run: Handler;
38
+ function kept(store: Store, next: ModelConfig, deps: ModelApiDeps, note: string, fields: Record<string, unknown>): unknown {
39
+ store.write(next);
40
+ syncAvailableModelsQuietly(deps.setup ?? {}, next);
41
+ log.info(fields, note);
42
+ return settingsBody(next);
72
43
  }
73
44
 
74
- const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({
75
- ...publicModelConfig(cfg),
76
- lastServed: lastServed(),
77
- usage: usageSeen(),
78
- });
79
-
80
- const CREDITS_TTL_MS = 5 * 60_000;
81
-
82
- async function refreshCredits(cfg: ModelConfig, deps: ModelApiDeps, now = Date.now()): Promise<void> {
83
- const key = cfg.openrouter.apiKey;
84
- if (key === '') return;
85
- const seen = usageOf('openrouter');
86
- if (seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
45
+ async function withBody(req: IncomingMessage, run: (body: Record<string, unknown>) => ModelConfig): Promise<ModelConfig> {
46
+ const body = await readJsonBody(req, BODY_MAX);
47
+ if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
87
48
  try {
88
- const credits = await openrouterCredits(key, deps.openrouterBase, deps.fetchImpl);
89
- noteUsage('openrouter', openrouterUsage(credits.total, credits.spent, new Date(now)));
49
+ return run(body);
90
50
  } catch (err) {
91
- log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter credits');
51
+ return asApiError(err);
92
52
  }
93
53
  }
94
54
 
95
- const geminiApiState = freshGeminiState();
96
-
97
- const geminiDeps = (deps: ModelApiDeps, store: Store): GeminiDeps => ({
98
- base: deps.geminiBase,
99
- tokenBase: deps.geminiTokenBase,
100
- fetchImpl: deps.fetchImpl,
101
- save: (t) => {
102
- store.write(setGeminiAuth(store.read(), t));
103
- },
104
- });
105
-
106
- async function geminiModels(deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
107
- const cfg = store.read();
108
- if (cfg.gemini.auth === null) throw new ApiError('Gemini is not connected: sign in with Google first', 400);
109
- const tokens = await currentGeminiTokens(cfg, geminiDeps(deps, store), geminiApiState);
110
- return listGeminiModels(tokens, geminiDeps(deps, store));
55
+ async function chooseRoute(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
56
+ const next = await withBody(req, (body) => setRoute(store.read(), typeof body.route === 'string' ? body.route : ''));
57
+ return kept(store, next, deps, 'model-api: route changed', { route: next.route });
111
58
  }
112
59
 
113
- function geminiModelsInUse(cfg: ModelConfig): Set<string> {
114
- const served = lastServed();
115
- return new Set([cfg.gemini.model, served?.provider === 'gemini' ? served.model : ''].filter((m) => m !== ''));
60
+ async function create(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
61
+ const next = await withBody(req, (body) => addConnection(store.read(), body));
62
+ return kept(store, next, deps, 'model-api: connection added', { connection: next.connections.at(-1)?.id ?? '' });
116
63
  }
117
64
 
118
- async function refreshGeminiQuota(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
119
- const cfg = store.read();
120
- if (cfg.gemini.auth === null) return;
121
- const inUse = geminiModelsInUse(cfg);
122
- const seen = usageOf('gemini');
123
- const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
124
- if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
125
- try {
126
- const rows = (await geminiModels(deps, store)).filter((m) => inUse.has(m.id));
127
- const usage = geminiUsage(rows, new Date(now));
128
- if (usage !== null) noteUsage('gemini', usage);
129
- } catch (err) {
130
- log.warn({ err: errMsg(err) }, 'model-api: could not read the Gemini quota');
131
- }
132
- }
133
-
134
- async function settingsWithUsage(cfg: ModelConfig, deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
135
- await refreshCredits(cfg, deps);
136
- await refreshGeminiQuota(deps, store);
137
- return settingsBody(cfg);
138
- }
139
-
140
- function asApiError(err: unknown): never {
141
- if (err instanceof ModelConfigError || err instanceof CodexAuthError || err instanceof GeminiAuthError) throw new ApiError(err.message, 400);
142
- if (err instanceof GatewayError) throw new ApiError(err.message, err.status >= 400 && err.status < 500 ? 400 : 502);
143
- throw err;
65
+ async function change(req: IncomingMessage, deps: ModelApiDeps, store: Store, id: string): Promise<unknown> {
66
+ const next = await withBody(req, (body) => updateConnection(store.read(), id, body));
67
+ return kept(store, next, deps, 'model-api: connection changed', { connection: id });
144
68
  }
145
69
 
146
- async function update(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
147
- const patch = await readJsonBody(req, BODY_MAX);
70
+ function drop(deps: ModelApiDeps, store: Store, id: string): unknown {
148
71
  let next: ModelConfig;
149
72
  try {
150
- next = applyModelUpdate(store.read(), patch);
73
+ next = removeConnection(store.read(), id);
151
74
  } catch (err) {
152
75
  asApiError(err);
153
76
  }
154
- store.write(next);
155
- syncAvailableModelsQuietly(deps.setup ?? {}, next);
156
- log.info({ provider: next.provider }, 'model-api: route updated');
157
- return settingsBody(next);
77
+ forgetOne(id);
78
+ return kept(store, next, deps, 'model-api: connection removed', { connection: id });
158
79
  }
159
80
 
160
- function saveCodex(store: Store, cfg: ModelConfig, note: string): unknown {
161
- store.write(cfg);
162
- log.info({ signedIn: cfg.codex.auth !== null, plan: cfg.codex.auth?.plan ?? null }, note);
163
- return settingsBody(cfg);
81
+ async function settingsWithUsage(deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
82
+ await refreshUsage(deps, store);
83
+ return settingsBody(store.read());
164
84
  }
165
85
 
166
- const modelApiState = freshCodexState();
167
-
168
- async function pollDevice(id: string, deps: ModelApiDeps, store: Store): Promise<unknown> {
169
- const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
170
- if (result.status !== 'done') return result;
171
- return { status: 'done', settings: saveCodex(store, setCodexAuth(store.read(), result.tokens), 'model-api: Codex connected by device code') };
172
- }
173
-
174
- const CODEX_ROUTES: Record<string, Route> = {
175
- device: {
176
- method: 'POST',
177
- run: async (_req, deps) => {
178
- const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
179
- return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
180
- },
181
- },
182
- login: {
183
- method: 'POST',
184
- run: (_req, deps) => Promise.resolve({ url: beginLogin(deps.issuer).url }),
185
- },
186
- callback: {
187
- method: 'POST',
188
- run: async (req, deps, store) => {
189
- const body = await readJsonBody(req, BODY_MAX);
190
- const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
191
- const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
192
- return saveCodex(store, setCodexAuth(store.read(), tokens), 'model-api: Codex connected');
193
- },
194
- },
195
- logout: {
196
- method: 'POST',
197
- run: (_req, _deps, store) => Promise.resolve(saveCodex(store, setCodexAuth(store.read(), null), 'model-api: Codex disconnected')),
198
- },
199
- import: {
200
- method: 'POST',
201
- run: (_req, deps, store) => {
202
- let tokens;
203
- try {
204
- tokens = readCodexCliAuth(deps.codexHome);
205
- } catch (err) {
206
- asApiError(err);
207
- }
208
- return Promise.resolve(saveCodex(store, setCodexAuth(store.read(), tokens), 'model-api: Codex CLI login imported'));
209
- },
210
- },
211
- models: {
212
- method: 'GET',
213
- run: async (_req, deps, store) => {
214
- const cfg = store.read();
215
- if (cfg.codex.auth === null) throw new ApiError('Codex is not connected: sign in with ChatGPT first', 400);
216
- const codexDeps = { issuer: deps.issuer, fetchImpl: deps.fetchImpl, base: deps.codexBase, save: (t: CodexTokens) => { store.write(setCodexAuth(store.read(), t)); } };
217
- const auth = await currentTokens(cfg, codexDeps, modelApiState).catch(asApiError);
218
- const models = await codexModels(auth, codexDeps).catch(asApiError);
219
- return { models };
220
- },
221
- },
222
- };
223
-
224
- function projectOf(body: unknown): string | null {
225
- try {
226
- return parseGeminiProject(isRecord(body) ? body.project : null);
227
- } catch (err) {
228
- return asApiError(err);
229
- }
230
- }
231
-
232
- async function connectGemini(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
233
- const body = await readJsonBody(req, BODY_MAX);
234
- const code = isRecord(body) && typeof body.code === 'string' ? body.code : '';
235
- const state = isRecord(body) && typeof body.state === 'string' ? body.state : '';
236
- const project = projectOf(body);
237
- const tokens = await exchangeGeminiCode(code, state, deps.geminiTokenBase, deps.fetchImpl).catch(asApiError);
238
- const email = await userEmail(tokens, deps.geminiUserBase, deps.fetchImpl);
239
- const onboarded = await onboard(tokens, project, deps.geminiBase, deps.fetchImpl).catch(asApiError);
240
- const cfg = setGeminiAuth(store.read(), { ...tokens, email, project: onboarded.project, tier: onboarded.tier });
241
- store.write(cfg);
242
- log.info({ tier: onboarded.tier }, 'model-api: Gemini connected');
243
- return settingsBody(cfg);
244
- }
245
-
246
- const GEMINI_ROUTES: Record<string, Route> = {
247
- login: {
248
- method: 'POST',
249
- run: (_req, deps) => Promise.resolve(beginGeminiLogin(deps.geminiAuthBase)),
250
- },
251
- code: { method: 'POST', run: connectGemini },
252
- logout: {
253
- method: 'POST',
254
- run: (_req, _deps, store) => {
255
- const cfg = setGeminiAuth(store.read(), null);
256
- store.write(cfg);
257
- log.info('model-api: Gemini disconnected');
258
- return Promise.resolve(settingsBody(cfg));
259
- },
260
- },
261
- models: {
262
- method: 'GET',
263
- run: async (_req, deps, store) => ({ models: (await geminiModels(deps, store).catch(asApiError)).map((m) => m.id) }),
264
- },
265
- };
266
-
267
86
  const OPENROUTER_ROUTES: Record<string, Route> = {
268
- models: {
269
- method: 'GET',
270
- run: async (_req, deps) => ({ models: await openrouterModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }),
271
- },
272
- zdr: {
273
- method: 'GET',
274
- run: async (_req, deps) => ({ models: await openrouterZdrModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }),
275
- },
87
+ models: { method: 'GET', run: async (_req, deps) => ({ models: await openrouterModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }) },
88
+ zdr: { method: 'GET', run: async (_req, deps) => ({ models: await openrouterZdrModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }) },
276
89
  };
277
90
 
278
91
  const ANTHROPIC_ROUTES: Record<string, Route> = {
279
92
  models: {
280
93
  method: 'GET',
281
- run: async (_req, deps, store) => ({ models: await anthropicModels(store.read().anthropic, deps.anthropicBase, deps.fetchImpl).catch(asApiError) }),
94
+ run: async (req, deps, store) => ({ models: await anthropicModels(connectionFor(store.read(), req, 'anthropic'), deps.anthropicBase, deps.fetchImpl).catch(asApiError) }),
282
95
  },
283
96
  };
284
97
 
285
98
  const BEDROCK_ROUTES: Record<string, Route> = {
286
99
  models: {
287
100
  method: 'GET',
288
- run: async (_req, deps, store) => ({ models: await bedrockModels(store.read().bedrock, deps.bedrockControlBase, deps.fetchImpl).catch(asApiError) }),
101
+ run: async (req, deps, store) => ({ models: await bedrockModels(connectionFor(store.read(), req, 'bedrock'), deps.bedrockControlBase, deps.fetchImpl).catch(asApiError) }),
289
102
  },
290
103
  };
291
104
 
@@ -298,11 +111,7 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
298
111
  async function restore(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
299
112
  const body = await readJsonBody(req, RESTORE_MAX);
300
113
  if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
301
- const next = parseModelConfig(body);
302
- store.write(next);
303
- syncAvailableModelsQuietly(deps.setup ?? {}, next);
304
- log.info({ provider: next.provider }, 'model-api: model setup restored from a file');
305
- return settingsBody(next);
114
+ return kept(store, parseModelConfig(body), deps, 'model-api: model setup restored from a file', {});
306
115
  }
307
116
 
308
117
  function bundleRoute(path: string, method: string | undefined): Route | number {
@@ -311,8 +120,17 @@ function bundleRoute(path: string, method: string | undefined): Route | number {
311
120
  }
312
121
 
313
122
  function settingsRoute(method: string | undefined): Route | number {
314
- if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(store.read(), deps, store) };
315
- if (method === 'PUT') return { method: 'POST', run: (req, deps, store) => update(req, store, deps) };
123
+ if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(deps, store) };
124
+ if (method === 'PUT') return { method: 'POST', run: chooseRoute };
125
+ return 405;
126
+ }
127
+
128
+ function connectionsRoute(path: string, method: string | undefined): Route | number {
129
+ if (path === CONNECTIONS) return method === 'POST' ? { method: 'POST', run: create } : 405;
130
+ const id = path.slice(CONNECTIONS.length + 1);
131
+ if (id === '' || id.includes('/')) return 404;
132
+ if (method === 'PUT') return { method: 'POST', run: (req, deps, store) => change(req, deps, store, id) };
133
+ if (method === 'DELETE') return { method: 'GET', run: (_req, deps, store) => Promise.resolve(drop(deps, store, id)) };
316
134
  return 405;
317
135
  }
318
136
 
@@ -320,15 +138,25 @@ function codexRoute(rest: string, method: string | undefined): Route | number {
320
138
  if (rest in CODEX_ROUTES) return named(CODEX_ROUTES, rest, method);
321
139
  const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
322
140
  if (!DEVICE_ID_RE.test(id)) return 404;
323
- return method === 'GET' ? { method: 'GET', run: (_req, deps, store) => pollDevice(id, deps, store) } : 405;
141
+ return codexDeviceRoute(id, method);
324
142
  }
325
143
 
326
144
  const mine = (path: string): boolean =>
327
- path === PATH || path === BUNDLE || path === RESTORE || path.startsWith(CODEX) || path.startsWith(GEMINI) || path.startsWith(OPENROUTER) || path.startsWith(ANTHROPIC) || path.startsWith(BEDROCK);
145
+ path === PATH ||
146
+ path === BUNDLE ||
147
+ path === RESTORE ||
148
+ path === CONNECTIONS ||
149
+ path.startsWith(`${CONNECTIONS}/`) ||
150
+ path.startsWith(CODEX) ||
151
+ path.startsWith(GEMINI) ||
152
+ path.startsWith(OPENROUTER) ||
153
+ path.startsWith(ANTHROPIC) ||
154
+ path.startsWith(BEDROCK);
328
155
 
329
156
  function routeFor(path: string, method: string | undefined): Route | number {
330
157
  if (path === PATH) return settingsRoute(method);
331
158
  if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
159
+ if (path === CONNECTIONS || path.startsWith(`${CONNECTIONS}/`)) return connectionsRoute(path, method);
332
160
  if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
333
161
  if (path.startsWith(ANTHROPIC)) return named(ANTHROPIC_ROUTES, path.slice(ANTHROPIC.length), method);
334
162
  if (path.startsWith(BEDROCK)) return named(BEDROCK_ROUTES, path.slice(BEDROCK.length), method);