@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
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path';
2
+ import { newId } from '@metro-labs/core/ids';
2
3
  import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
3
4
  import { agentsDir } from '../agents/files.js';
4
5
  import { isRecord } from '@metro-labs/core/is-record';
@@ -8,45 +9,26 @@ import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gem
8
9
  export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
9
10
  export type Provider = (typeof PROVIDERS)[number];
10
11
 
11
- export interface AnthropicSettings {
12
- apiKey: string;
13
- model: string;
14
- }
15
-
16
- export interface BedrockSettings {
17
- region: string;
18
- apiKey: string;
12
+ export interface Connection {
13
+ id: string;
14
+ provider: Provider;
15
+ label: string;
19
16
  model: string;
20
- }
21
-
22
- export interface OpenRouterSettings {
23
17
  apiKey: string;
24
- model: string;
18
+ region: string;
25
19
  zdr: boolean;
26
- }
27
-
28
- export interface CodexSettings {
29
- model: string;
30
- auth: CodexTokens | null;
31
- }
32
-
33
- export interface GeminiSettings {
34
- model: string;
35
- auth: GeminiTokens | null;
20
+ codex: CodexTokens | null;
21
+ gemini: GeminiTokens | null;
36
22
  }
37
23
 
38
24
  export interface ModelConfig {
39
- version: 1;
40
- provider: Provider;
41
- anthropic: AnthropicSettings;
42
- bedrock: BedrockSettings;
43
- openrouter: OpenRouterSettings;
44
- codex: CodexSettings;
45
- gemini: GeminiSettings;
25
+ version: 2;
26
+ route: string;
27
+ connections: Connection[];
46
28
  }
47
29
 
48
30
  export interface Route {
49
- provider: Provider;
31
+ connection: Connection;
50
32
  model: string;
51
33
  }
52
34
 
@@ -54,26 +36,39 @@ export class ModelConfigError extends Error {}
54
36
 
55
37
  export const MODEL_FILE = 'model.json';
56
38
  const MAX_FIELD = 512;
39
+ const MAX_LABEL = 60;
40
+ const MAX_CONNECTIONS = 20;
57
41
  const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|gemini):(.+)$/;
58
42
  const SMALL_RE = /haiku/i;
59
43
 
60
- const empty = (): ModelConfig => ({
61
- version: 1,
62
- provider: 'anthropic',
63
- anthropic: { apiKey: '', model: '' },
64
- bedrock: { region: '', apiKey: '', model: '' },
65
- openrouter: { apiKey: '', model: '', zdr: false },
66
- codex: { model: '', auth: null },
67
- gemini: { model: '', auth: null },
68
- });
44
+ export const LABELS: Record<Provider, string> = {
45
+ anthropic: 'Anthropic',
46
+ bedrock: 'Amazon Bedrock',
47
+ openrouter: 'OpenRouter',
48
+ codex: 'Codex (ChatGPT)',
49
+ gemini: 'Gemini (Google)',
50
+ };
69
51
 
70
- const isProvider = (value: unknown): value is Provider =>
71
- typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
52
+ const isProvider = (value: unknown): value is Provider => typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
72
53
 
73
54
  const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
74
55
  const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
75
56
 
76
- function tokensFromDisk(raw: unknown): CodexTokens | null {
57
+ export const empty = (): ModelConfig => ({ version: 2, route: '', connections: [] });
58
+
59
+ export const newConnection = (provider: Provider, label: string): Connection => ({
60
+ id: newId(),
61
+ provider,
62
+ label,
63
+ model: '',
64
+ apiKey: '',
65
+ region: '',
66
+ zdr: false,
67
+ codex: null,
68
+ gemini: null,
69
+ });
70
+
71
+ function codexFromDisk(raw: unknown): CodexTokens | null {
77
72
  if (!isRecord(raw)) return null;
78
73
  const accessToken = text(raw.accessToken);
79
74
  const refreshToken = text(raw.refreshToken);
@@ -90,23 +85,49 @@ function tokensFromDisk(raw: unknown): CodexTokens | null {
90
85
  };
91
86
  }
92
87
 
93
- export function parseModelConfig(raw: unknown): ModelConfig {
94
- const base = empty();
95
- if (!isRecord(raw)) return base;
96
- const anthropic = isRecord(raw.anthropic) ? raw.anthropic : {};
97
- const bedrock = isRecord(raw.bedrock) ? raw.bedrock : {};
98
- const openrouter = isRecord(raw.openrouter) ? raw.openrouter : {};
99
- const codex = isRecord(raw.codex) ? raw.codex : {};
100
- const gemini = isRecord(raw.gemini) ? raw.gemini : {};
88
+ function connectionFromDisk(raw: unknown): Connection | null {
89
+ if (!isRecord(raw) || !isProvider(raw.provider)) return null;
90
+ const id = text(raw.id);
91
+ const provider = raw.provider;
101
92
  return {
102
- version: 1,
103
- provider: isProvider(raw.provider) ? raw.provider : 'anthropic',
104
- anthropic: { apiKey: text(anthropic.apiKey), model: text(anthropic.model) },
105
- bedrock: { region: text(bedrock.region), apiKey: text(bedrock.apiKey), model: text(bedrock.model) },
106
- openrouter: { apiKey: text(openrouter.apiKey), model: text(openrouter.model), zdr: openrouter.zdr === true },
107
- codex: { model: text(codex.model), auth: tokensFromDisk(codex.auth) },
108
- gemini: { model: text(gemini.model), auth: geminiTokensFromDisk(gemini.auth) },
93
+ id: id === '' ? newId() : id,
94
+ provider,
95
+ label: text(raw.label) === '' ? LABELS[provider] : text(raw.label).slice(0, MAX_LABEL),
96
+ model: text(raw.model),
97
+ apiKey: text(raw.apiKey),
98
+ region: text(raw.region),
99
+ zdr: raw.zdr === true,
100
+ codex: codexFromDisk(raw.codex),
101
+ gemini: geminiTokensFromDisk(raw.gemini),
102
+ };
103
+ }
104
+
105
+ function fromVersionOne(raw: Record<string, unknown>): ModelConfig {
106
+ const block = (name: Provider): Record<string, unknown> => (isRecord(raw[name]) ? raw[name] : {});
107
+ const made: Connection[] = [];
108
+ const keep = (provider: Provider, fill: (c: Connection) => Connection, has: boolean): void => {
109
+ if (has) made.push(fill(newConnection(provider, LABELS[provider])));
109
110
  };
111
+ const anthropic = block('anthropic');
112
+ const bedrock = block('bedrock');
113
+ const openrouter = block('openrouter');
114
+ const codex = block('codex');
115
+ const gemini = block('gemini');
116
+ keep('anthropic', (c) => ({ ...c, apiKey: text(anthropic.apiKey), model: text(anthropic.model) }), text(anthropic.apiKey) !== '' || text(anthropic.model) !== '');
117
+ keep('bedrock', (c) => ({ ...c, apiKey: text(bedrock.apiKey), region: text(bedrock.region), model: text(bedrock.model) }), text(bedrock.apiKey) !== '');
118
+ keep('openrouter', (c) => ({ ...c, apiKey: text(openrouter.apiKey), model: text(openrouter.model), zdr: openrouter.zdr === true }), text(openrouter.apiKey) !== '');
119
+ keep('codex', (c) => ({ ...c, model: text(codex.model), codex: codexFromDisk(codex.auth) }), codexFromDisk(codex.auth) !== null);
120
+ keep('gemini', (c) => ({ ...c, model: text(gemini.model), gemini: geminiTokensFromDisk(gemini.auth) }), geminiTokensFromDisk(gemini.auth) !== null);
121
+ const wanted = isProvider(raw.provider) ? raw.provider : 'anthropic';
122
+ return { version: 2, route: made.find((c) => c.provider === wanted)?.id ?? '', connections: made };
123
+ }
124
+
125
+ export function parseModelConfig(raw: unknown): ModelConfig {
126
+ if (!isRecord(raw)) return empty();
127
+ if (!Array.isArray(raw.connections)) return fromVersionOne(raw);
128
+ const connections = raw.connections.flatMap((c: unknown) => connectionFromDisk(c) ?? []).slice(0, MAX_CONNECTIONS);
129
+ const route = text(raw.route);
130
+ return { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
110
131
  }
111
132
 
112
133
  export function readModelConfig(dir = agentsDir()): ModelConfig {
@@ -117,116 +138,156 @@ export function writeModelConfig(cfg: ModelConfig, dir = agentsDir()): void {
117
138
  writeSecure(join(dir, MODEL_FILE), JSON.stringify(cfg, null, 2));
118
139
  }
119
140
 
120
- function flag(patch: Record<string, unknown>, key: string, current: boolean, what: string): boolean {
121
- if (!(key in patch)) return current;
122
- const value = patch[key];
123
- if (typeof value !== 'boolean') throw new ModelConfigError(`${what} must be true or false`);
124
- return value;
125
- }
141
+ export const connectionOf = (cfg: ModelConfig, id: string): Connection | null => cfg.connections.find((c) => c.id === id) ?? null;
126
142
 
127
- function field(patch: Record<string, unknown>, key: string, current: string, what: string): string {
128
- if (!(key in patch)) return current;
129
- const value = patch[key];
130
- if (typeof value !== 'string') throw new ModelConfigError(`${what} must be a string`);
131
- if (value.length > MAX_FIELD) throw new ModelConfigError(`${what} is too long`);
132
- return value.trim();
133
- }
143
+ export const routedConnection = (cfg: ModelConfig): Connection | null => connectionOf(cfg, cfg.route);
134
144
 
135
- export function applyModelUpdate(cfg: ModelConfig, patch: unknown): ModelConfig {
136
- if (!isRecord(patch)) throw new ModelConfigError('body must be a JSON object');
137
- const provider = 'provider' in patch ? patch.provider : cfg.provider;
138
- if (!isProvider(provider)) throw new ModelConfigError(`provider must be one of ${PROVIDERS.join(', ')}`);
139
- const anthropic = isRecord(patch.anthropic) ? patch.anthropic : {};
140
- const bedrock = isRecord(patch.bedrock) ? patch.bedrock : {};
141
- const openrouter = isRecord(patch.openrouter) ? patch.openrouter : {};
142
- const codex = isRecord(patch.codex) ? patch.codex : {};
143
- const gemini = isRecord(patch.gemini) ? patch.gemini : {};
144
- return {
145
- version: 1,
146
- provider,
147
- anthropic: {
148
- apiKey: field(anthropic, 'apiKey', cfg.anthropic.apiKey, 'Anthropic API key'),
149
- model: field(anthropic, 'model', cfg.anthropic.model, 'Anthropic model'),
150
- },
151
- bedrock: {
152
- region: field(bedrock, 'region', cfg.bedrock.region, 'Bedrock region'),
153
- apiKey: field(bedrock, 'apiKey', cfg.bedrock.apiKey, 'Bedrock API key'),
154
- model: field(bedrock, 'model', cfg.bedrock.model, 'Bedrock model'),
155
- },
156
- openrouter: {
157
- apiKey: field(openrouter, 'apiKey', cfg.openrouter.apiKey, 'OpenRouter API key'),
158
- model: field(openrouter, 'model', cfg.openrouter.model, 'OpenRouter model'),
159
- zdr: flag(openrouter, 'zdr', cfg.openrouter.zdr, 'OpenRouter zero data retention'),
160
- },
161
- codex: { model: field(codex, 'model', cfg.codex.model, 'Codex model'), auth: cfg.codex.auth },
162
- gemini: { model: field(gemini, 'model', cfg.gemini.model, 'Gemini model'), auth: cfg.gemini.auth },
163
- };
145
+ export function requireConnection(cfg: ModelConfig, id: string): Connection {
146
+ const found = connectionOf(cfg, id);
147
+ if (found === null) throw new ModelConfigError('no such connection');
148
+ return found;
164
149
  }
165
150
 
166
- export const setCodexAuth = (cfg: ModelConfig, auth: CodexTokens | null): ModelConfig => ({ ...cfg, codex: { ...cfg.codex, auth } });
167
-
168
- export const setGeminiAuth = (cfg: ModelConfig, auth: GeminiTokens | null): ModelConfig => ({ ...cfg, gemini: { ...cfg.gemini, auth } });
151
+ const PASSTHROUGH = 'Nothing is connected: the request carries the Claude Code login of the session that sent it.';
169
152
 
170
- const CHECKS: Record<Provider, [(cfg: ModelConfig) => boolean, string][]> = {
153
+ const CHECKS: Record<Provider, [(c: Connection) => boolean, string][]> = {
171
154
  anthropic: [],
172
155
  bedrock: [
173
- [(cfg) => cfg.bedrock.apiKey === '', 'Bedrock needs an API key: add it on the Model page.'],
174
- [(cfg) => cfg.bedrock.region === '', 'Bedrock needs a region: add it on the Model page.'],
156
+ [(c) => c.apiKey === '', 'Bedrock needs an API key: add it on the Model page.'],
157
+ [(c) => c.region === '', 'Bedrock needs a region: add it on the Model page.'],
175
158
  ],
176
159
  openrouter: [
177
- [(cfg) => cfg.openrouter.apiKey === '', 'OpenRouter needs an API key: add it on the Model page.'],
178
- [(cfg) => cfg.openrouter.model === '', 'OpenRouter needs a model id: choose one on the Model page.'],
160
+ [(c) => c.apiKey === '', 'OpenRouter needs an API key: add it on the Model page.'],
161
+ [(c) => c.model === '', 'OpenRouter needs a model id: choose one on the Model page.'],
179
162
  ],
180
163
  codex: [
181
- [(cfg) => cfg.codex.auth === null, 'Codex is not connected: sign in with ChatGPT on the Model page.'],
182
- [(cfg) => cfg.codex.model === '', 'Codex needs a model id: choose one on the Model page.'],
164
+ [(c) => c.codex === null, 'Codex is not connected: sign in with ChatGPT on the Model page.'],
165
+ [(c) => c.model === '', 'Codex needs a model id: choose one on the Model page.'],
183
166
  ],
184
167
  gemini: [
185
- [(cfg) => cfg.gemini.auth === null, 'Gemini is not connected: sign in with Google on the Model page.'],
186
- [(cfg) => cfg.gemini.model === '', 'Gemini needs a model id: choose one on the Model page.'],
168
+ [(c) => c.gemini === null, 'Gemini is not connected: sign in with Google on the Model page.'],
169
+ [(c) => c.model === '', 'Gemini needs a model id: choose one on the Model page.'],
187
170
  ],
188
171
  };
189
172
 
190
- export function notReady(cfg: ModelConfig, provider: Provider = cfg.provider): string | null {
191
- return CHECKS[provider].find(([missing]) => missing(cfg))?.[1] ?? null;
173
+ export function notReady(cfg: ModelConfig, conn = routedConnection(cfg)): string | null {
174
+ if (conn === null) return cfg.connections.length === 0 ? null : PASSTHROUGH;
175
+ return CHECKS[conn.provider].find(([missing]) => missing(conn))?.[1] ?? null;
192
176
  }
193
177
 
194
- export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
195
- const auth = cfg.codex.auth;
196
- const gemini = cfg.gemini.auth;
178
+ export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
179
+
180
+ const DEFAULTS: Record<Provider, (requested: string, c: Connection) => string> = {
181
+ openrouter: (requested, c) => (requested.includes('/') ? requested : c.model),
182
+ bedrock: (requested, c) => (c.model === '' ? requested : c.model),
183
+ codex: (requested, c) => (requested.startsWith('gpt-') ? requested : c.model),
184
+ gemini: (requested, c) => (requested.startsWith('gemini-') ? requested : c.model),
185
+ anthropic: (requested, c) => (c.model === '' || isSmallModel(requested) ? requested : c.model),
186
+ };
187
+
188
+ function forProvider(cfg: ModelConfig, provider: Provider): Connection | null {
189
+ const routed = routedConnection(cfg);
190
+ if (routed?.provider === provider) return routed;
191
+ return cfg.connections.find((c) => c.provider === provider) ?? null;
192
+ }
193
+
194
+ export function resolveRoute(requested: string, cfg: ModelConfig): Route | null {
195
+ const explicit = PREFIX_RE.exec(requested);
196
+ if (explicit !== null && isProvider(explicit[1])) {
197
+ const conn = forProvider(cfg, explicit[1]);
198
+ return conn === null ? null : { connection: conn, model: (explicit[2] ?? '').trim() };
199
+ }
200
+ const routed = routedConnection(cfg);
201
+ return routed === null ? null : { connection: routed, model: DEFAULTS[routed.provider](requested, routed) };
202
+ }
203
+
204
+ export const routeLabel = (route: Route): string =>
205
+ route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
206
+
207
+ export function publicConnection(c: Connection): Record<string, unknown> {
197
208
  return {
198
- provider: cfg.provider,
199
- ready: notReady(cfg) === null,
200
- reason: notReady(cfg),
201
- anthropic: { model: cfg.anthropic.model, hasKey: cfg.anthropic.apiKey !== '' },
202
- bedrock: { region: cfg.bedrock.region, model: cfg.bedrock.model, hasKey: cfg.bedrock.apiKey !== '' },
203
- openrouter: { model: cfg.openrouter.model, hasKey: cfg.openrouter.apiKey !== '', zdr: cfg.openrouter.zdr },
204
- codex: { model: cfg.codex.model, signedIn: auth !== null, account: auth?.email ?? null, plan: auth?.plan ?? null },
205
- gemini: { model: cfg.gemini.model, signedIn: gemini !== null, account: gemini?.email ?? null, plan: gemini?.tier ?? null },
209
+ id: c.id,
210
+ provider: c.provider,
211
+ label: c.label,
212
+ model: c.model,
213
+ hasKey: c.apiKey !== '',
214
+ region: c.region,
215
+ zdr: c.zdr,
216
+ signedIn: c.codex !== null || c.gemini !== null,
217
+ account: c.codex?.email ?? c.gemini?.email ?? null,
218
+ plan: c.codex?.plan ?? c.gemini?.tier ?? null,
206
219
  };
207
220
  }
208
221
 
209
- export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
222
+ export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
223
+ const reason = notReady(cfg);
224
+ return { route: cfg.route, ready: reason === null, reason, connections: cfg.connections.map(publicConnection) };
225
+ }
210
226
 
211
- const DEFAULTS: Record<Provider, (requested: string, cfg: ModelConfig) => string> = {
212
- openrouter: (requested, cfg) => (requested.includes('/') ? requested : cfg.openrouter.model),
213
- bedrock: (requested, cfg) => (cfg.bedrock.model === '' ? requested : cfg.bedrock.model),
214
- codex: (requested, cfg) => (requested.startsWith('gpt-') ? requested : cfg.codex.model),
215
- gemini: (requested, cfg) => (requested.startsWith('gemini-') ? requested : cfg.gemini.model),
216
- anthropic: (requested, cfg) => (cfg.anthropic.model === '' || isSmallModel(requested) ? requested : cfg.anthropic.model),
217
- };
227
+ const withConnection = (cfg: ModelConfig, id: string, change: (c: Connection) => Connection): ModelConfig => ({
228
+ ...cfg,
229
+ connections: cfg.connections.map((c) => (c.id === id ? change(c) : c)),
230
+ });
231
+
232
+ export const setCodexAuth = (cfg: ModelConfig, id: string, auth: CodexTokens | null): ModelConfig =>
233
+ withConnection(cfg, id, (c) => ({ ...c, codex: auth }));
234
+
235
+ export const setGeminiAuth = (cfg: ModelConfig, id: string, auth: GeminiTokens | null): ModelConfig =>
236
+ withConnection(cfg, id, (c) => ({ ...c, gemini: auth }));
237
+
238
+ function field(patch: Record<string, unknown>, key: string, current: string, what: string, max = MAX_FIELD): string {
239
+ if (!(key in patch)) return current;
240
+ const value = patch[key];
241
+ if (typeof value !== 'string') throw new ModelConfigError(`${what} must be a string`);
242
+ if (value.length > max) throw new ModelConfigError(`${what} is too long`);
243
+ return value.trim();
244
+ }
218
245
 
219
- function defaultModelFor(provider: Provider, requested: string, cfg: ModelConfig): string {
220
- return DEFAULTS[provider](requested, cfg);
246
+ function flag(patch: Record<string, unknown>, key: string, current: boolean, what: string): boolean {
247
+ if (!(key in patch)) return current;
248
+ const value = patch[key];
249
+ if (typeof value !== 'boolean') throw new ModelConfigError(`${what} must be true or false`);
250
+ return value;
221
251
  }
222
252
 
223
- export function resolveRoute(requested: string, cfg: ModelConfig): Route {
224
- const explicit = PREFIX_RE.exec(requested);
225
- if (explicit !== null && isProvider(explicit[1]))
226
- return { provider: explicit[1], model: (explicit[2] ?? '').trim() };
227
- return { provider: cfg.provider, model: defaultModelFor(cfg.provider, requested, cfg) };
253
+ export function labelFor(cfg: ModelConfig, provider: Provider, given: string): string {
254
+ if (given !== '') return given.slice(0, MAX_LABEL);
255
+ const taken = cfg.connections.filter((c) => c.provider === provider).length;
256
+ return taken === 0 ? LABELS[provider] : `${LABELS[provider]} ${String(taken + 1)}`;
257
+ }
258
+
259
+ export function addConnection(cfg: ModelConfig, patch: unknown): ModelConfig {
260
+ if (!isRecord(patch) || !isProvider(patch.provider)) throw new ModelConfigError(`provider must be one of ${PROVIDERS.join(', ')}`);
261
+ if (cfg.connections.length >= MAX_CONNECTIONS) throw new ModelConfigError(`a box keeps at most ${String(MAX_CONNECTIONS)} connections`);
262
+ const made = newConnection(patch.provider, labelFor(cfg, patch.provider, field(patch, 'label', '', 'label', MAX_LABEL)));
263
+ const filled = applyToConnection(made, patch);
264
+ return { ...cfg, route: cfg.route === '' ? filled.id : cfg.route, connections: [...cfg.connections, filled] };
265
+ }
266
+
267
+ export function applyToConnection(c: Connection, patch: Record<string, unknown>): Connection {
268
+ return {
269
+ ...c,
270
+ label: field(patch, 'label', c.label, 'label', MAX_LABEL),
271
+ model: field(patch, 'model', c.model, 'model'),
272
+ apiKey: field(patch, 'apiKey', c.apiKey, 'API key'),
273
+ region: field(patch, 'region', c.region, 'region'),
274
+ zdr: flag(patch, 'zdr', c.zdr, 'zero data retention'),
275
+ };
276
+ }
277
+
278
+ export function updateConnection(cfg: ModelConfig, id: string, patch: unknown): ModelConfig {
279
+ if (!isRecord(patch)) throw new ModelConfigError('body must be a JSON object');
280
+ requireConnection(cfg, id);
281
+ return withConnection(cfg, id, (c) => applyToConnection(c, patch));
282
+ }
283
+
284
+ export function removeConnection(cfg: ModelConfig, id: string): ModelConfig {
285
+ requireConnection(cfg, id);
286
+ const connections = cfg.connections.filter((c) => c.id !== id);
287
+ return { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
228
288
  }
229
289
 
230
- export function routeLabel(route: Route): string {
231
- return route.provider === 'anthropic' ? route.model : `${route.provider}:${route.model}`;
290
+ export function setRoute(cfg: ModelConfig, id: string): ModelConfig {
291
+ requireConnection(cfg, id);
292
+ return { ...cfg, route: id };
232
293
  }
@@ -0,0 +1,195 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { ApiError } from '@metro-labs/http/api-error';
3
+ import { readJsonBody } from '@metro-labs/http/api-http';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { errMsg, log } from '@metro-labs/core/log';
6
+ import { beginLogin, finishLogin, readCodexCliAuth, type CodexTokens } 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, userEmail, type GeminiTokens } 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 } from './openrouter.js';
13
+ import { lastServed } from './served.js';
14
+ import { geminiUsage, noteUsage, openrouterUsage, usageOf } from './usage.js';
15
+ import {
16
+ addConnection,
17
+ connectionOf,
18
+ setCodexAuth,
19
+ setGeminiAuth,
20
+ type Connection,
21
+ type ModelConfig,
22
+ type Provider,
23
+ } from './model-config.js';
24
+ import { asApiError, askedConnection, BODY_MAX, connectionFor, CREDITS_TTL_MS, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
25
+
26
+ const codexState = freshCodexState();
27
+ const geminiState = freshGeminiState();
28
+
29
+ function connectionToFill(store: Store, req: IncomingMessage, provider: Provider): { cfg: ModelConfig; id: string } {
30
+ const asked = askedConnection(req);
31
+ const current = store.read();
32
+ if (asked !== '') {
33
+ if (connectionOf(current, asked)?.provider !== provider) throw new ApiError('no such connection', 404);
34
+ return { cfg: current, id: asked };
35
+ }
36
+ const cfg = addConnection(current, { provider });
37
+ return { cfg, id: cfg.connections.at(-1)?.id ?? '' };
38
+ }
39
+
40
+ const saved = (store: Store, cfg: ModelConfig, note: string, fields: Record<string, unknown>): unknown => {
41
+ store.write(cfg);
42
+ log.info(fields, note);
43
+ return settingsBody(cfg);
44
+ };
45
+
46
+ function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens | null, note: string): unknown {
47
+ const { cfg, id } = connectionToFill(store, req, 'codex');
48
+ const next = setCodexAuth(cfg, id, tokens);
49
+ return saved(store, next, note, { connection: id, plan: tokens?.plan ?? null });
50
+ }
51
+
52
+ async function pollDevice(id: string, req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
53
+ const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
54
+ if (result.status !== 'done') return result;
55
+ return { status: 'done', settings: keepCodex(store, req, result.tokens, 'model-api: Codex connected by device code') };
56
+ }
57
+
58
+ const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { issuer?: string; fetchImpl?: typeof fetch; base?: string; save: (connId: string, t: CodexTokens) => void } => ({
59
+ issuer: deps.issuer,
60
+ fetchImpl: deps.fetchImpl,
61
+ base: deps.codexBase,
62
+ save: (connId, t) => {
63
+ store.write(setCodexAuth(store.read(), connId === '' ? id : connId, t));
64
+ },
65
+ });
66
+
67
+ export const CODEX_ROUTES: Record<string, Route> = {
68
+ device: {
69
+ method: 'POST',
70
+ run: async (_req, deps) => {
71
+ const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
72
+ return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
73
+ },
74
+ },
75
+ login: { method: 'POST', run: (_req, deps) => Promise.resolve({ url: beginLogin(deps.issuer).url }) },
76
+ callback: {
77
+ method: 'POST',
78
+ run: async (req, deps, store) => {
79
+ const body = await readJsonBody(req, BODY_MAX);
80
+ const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
81
+ const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
82
+ return keepCodex(store, req, tokens, 'model-api: Codex connected');
83
+ },
84
+ },
85
+ import: {
86
+ method: 'POST',
87
+ run: (_req, deps, store) => {
88
+ let tokens;
89
+ try {
90
+ tokens = readCodexCliAuth(deps.codexHome);
91
+ } catch (err) {
92
+ asApiError(err);
93
+ }
94
+ return Promise.resolve(keepCodex(store, _req, tokens, 'model-api: Codex CLI login imported'));
95
+ },
96
+ },
97
+ models: {
98
+ method: 'GET',
99
+ run: async (req, deps, store) => {
100
+ const conn = connectionFor(store.read(), req, 'codex');
101
+ if (conn.codex === null) throw new ApiError('this connection is not signed in yet', 400);
102
+ const codexDeps = codexDepsFor(deps, store, conn.id);
103
+ const auth = await currentTokens(conn, codexDeps, codexState).catch(asApiError);
104
+ return { models: await codexModels(auth, codexDeps).catch(asApiError) };
105
+ },
106
+ },
107
+ };
108
+
109
+ export const geminiDeps = (deps: ModelApiDeps, store: Store): GeminiDeps => ({
110
+ base: deps.geminiBase,
111
+ tokenBase: deps.geminiTokenBase,
112
+ fetchImpl: deps.fetchImpl,
113
+ save: (id, t) => {
114
+ store.write(setGeminiAuth(store.read(), id, t));
115
+ },
116
+ });
117
+
118
+ async function geminiModelsOf(conn: Connection, deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
119
+ if (conn.gemini === null) throw new ApiError('this connection is not signed in yet', 400);
120
+ const tokens = await currentGeminiTokens(conn, geminiDeps(deps, store), geminiState);
121
+ return listGeminiModels(tokens, geminiDeps(deps, store));
122
+ }
123
+
124
+ function projectOf(body: unknown): string | null {
125
+ try {
126
+ return parseGeminiProject(isRecord(body) ? body.project : null);
127
+ } catch (err) {
128
+ return asApiError(err);
129
+ }
130
+ }
131
+
132
+ async function connectGemini(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
133
+ const body = await readJsonBody(req, BODY_MAX);
134
+ const code = isRecord(body) && typeof body.code === 'string' ? body.code : '';
135
+ const state = isRecord(body) && typeof body.state === 'string' ? body.state : '';
136
+ const project = projectOf(body);
137
+ const tokens = await exchangeGeminiCode(code, state, deps.geminiTokenBase, deps.fetchImpl).catch(asApiError);
138
+ const email = await userEmail(tokens, deps.geminiUserBase, deps.fetchImpl);
139
+ const onboarded = await onboard(tokens, project, deps.geminiBase, deps.fetchImpl).catch(asApiError);
140
+ const { cfg, id } = connectionToFill(store, req, 'gemini');
141
+ const full: GeminiTokens = { ...tokens, email, project: onboarded.project, tier: onboarded.tier };
142
+ return saved(store, setGeminiAuth(cfg, id, full), 'model-api: Gemini connected', { connection: id, tier: onboarded.tier });
143
+ }
144
+
145
+ export const GEMINI_ROUTES: Record<string, Route> = {
146
+ login: { method: 'POST', run: (_req, deps) => Promise.resolve(beginGeminiLogin(deps.geminiAuthBase)) },
147
+ code: { method: 'POST', run: connectGemini },
148
+ models: {
149
+ method: 'GET',
150
+ run: async (req, deps, store) => ({ models: (await geminiModelsOf(connectionFor(store.read(), req, 'gemini'), deps, store).catch(asApiError)).map((m) => m.id) }),
151
+ },
152
+ };
153
+
154
+ export function codexDeviceRoute(id: string, method: string | undefined): Route | number {
155
+ return method === 'GET' ? { method: 'GET', run: (req, deps, store) => pollDevice(id, req, deps, store) } : 405;
156
+ }
157
+
158
+ async function refreshOne(conn: Connection, deps: ModelApiDeps, now: number): Promise<void> {
159
+ if (conn.apiKey === '') return;
160
+ const seen = usageOf(conn.id);
161
+ if (seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
162
+ try {
163
+ const credits = await openrouterCredits(conn.apiKey, deps.openrouterBase, deps.fetchImpl);
164
+ noteUsage(conn.id, openrouterUsage(credits.total, credits.spent, new Date(now)));
165
+ } catch (err) {
166
+ log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter credits');
167
+ }
168
+ }
169
+
170
+ const modelsInUse = (conn: Connection): Set<string> => {
171
+ const served = lastServed();
172
+ return new Set([conn.model, served?.connection === conn.id ? served.model : ''].filter((m) => m !== ''));
173
+ };
174
+
175
+ async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
176
+ if (conn.gemini === null) return;
177
+ const inUse = modelsInUse(conn);
178
+ const seen = usageOf(conn.id);
179
+ const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
180
+ if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
181
+ try {
182
+ const rows = (await geminiModelsOf(conn, deps, store)).filter((m) => inUse.has(m.id));
183
+ const usage = geminiUsage(rows, new Date(now));
184
+ if (usage !== null) noteUsage(conn.id, usage);
185
+ } catch (err) {
186
+ log.warn({ err: errMsg(err) }, 'model-api: could not read the Gemini quota');
187
+ }
188
+ }
189
+
190
+ export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
191
+ for (const conn of store.read().connections) {
192
+ if (conn.provider === 'openrouter') await refreshOne(conn, deps, now);
193
+ if (conn.provider === 'gemini') await refreshQuota(conn, deps, store, now);
194
+ }
195
+ }