@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.308",
3
+ "version": "0.1.0-beta.309",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -4,6 +4,7 @@ import { errMsg, log } from '@metro-labs/core/log';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { agentsDir } from '../agents/files.js';
6
6
  import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
7
+ import { effectiveSetup, modelViewText } from '../gateway/effective.js';
7
8
  import { removeHome, writeHomeText } from './home-fs.js';
8
9
  import { closeMetroHome } from './metro-home.js';
9
10
  import { agentUser, agentViewDir, type AgentUser } from './user.js';
@@ -39,17 +40,10 @@ function agentFile(dir: string): string | null {
39
40
  return json({ version: 1, id: file.id, key: file.key });
40
41
  }
41
42
 
42
- function modelFile(dir: string): string | null {
43
- const cfg = readRecord(join(dir, 'model.json'));
44
- if (cfg === null || !Array.isArray(cfg.connections)) return null;
45
- const connections = cfg.connections.filter(isRecord).map((c) => ({ id: c.id, provider: c.provider, model: c.model }));
46
- return json({ version: 2, route: cfg.route, connections });
47
- }
48
-
49
- export function viewFiles(dir = agentsDir()): Map<string, string | null> {
43
+ export function viewFiles(dir = agentsDir(), model: () => string | null = () => modelViewText(effectiveSetup(dir))): Map<string, string | null> {
50
44
  const files = new Map<string, string | null>([
51
45
  ['agent.json', agentFile(dir)],
52
- ['model.json', modelFile(dir)],
46
+ ['model.json', model()],
53
47
  ]);
54
48
  for (const name of COPIED) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
55
49
  for (const name of NEVER_COPIED) files.set(name, null);
@@ -60,6 +60,11 @@ import { startUploadReaper } from '../files/upload-store.js';
60
60
  import { startAttachReaper } from '../files/attach-reaper.js';
61
61
  import { startCloudWatchPublisher } from '../server/cloudwatch.js';
62
62
  import { watchFallbackUsage } from '../gateway/usage-refresh.js';
63
+ import { startOrgModels, stopOrgModels } from '../gateway/org-models.js';
64
+ import { effectiveSetup, watchModelChanges } from '../gateway/effective.js';
65
+ import { readClaudeLogins } from '../gateway/claude-store.js';
66
+ import { readAgentKey } from '../connectors/agent-key.js';
67
+ import { METRO_API } from '../connectors/metro-api.js';
63
68
  import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } from '../voice/calls.js';
64
69
 
65
70
  installCrashGuard();
@@ -114,7 +119,7 @@ async function syncStations(station: StationName): Promise<void> {
114
119
  }
115
120
  }
116
121
 
117
- function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model: string | null) => boolean): SessionApis {
122
+ function sessionApis(setLiveEvents: (on: boolean) => void): SessionApis {
118
123
  return localSessionApis({
119
124
  syncStations,
120
125
  reloadAgents: async () => {
@@ -133,7 +138,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
133
138
  toolGroups: stationToolGroups,
134
139
  prepareAccount,
135
140
  liveEvents: setLiveEvents,
136
- switchModel,
137
141
  });
138
142
  }
139
143
 
@@ -150,10 +154,12 @@ installBearerSessions(agentsDir(), localOwner);
150
154
  syncGmailHost();
151
155
  supervisor.start();
152
156
  sweepLeftoverCalls();
157
+ const models = startOrgModels({ owner: localOwner, key: () => readAgentKey(), api: METRO_API, dir: agentsDir });
153
158
  const metroMcp = await createMetroMcp({ liveEvents: liveEvents() });
159
+ watchModelChanges(models, metroMcp.switchModel);
154
160
  webhookServer = await startWebhookServer(
155
161
  emit,
156
- sessionApis(metroMcp.setLiveEvents, metroMcp.switchModel),
162
+ sessionApis(metroMcp.setLiveEvents),
157
163
  metroMcp.httpHandler,
158
164
  true,
159
165
  );
@@ -174,7 +180,7 @@ installBearerSessions(agentsDir(), localOwner);
174
180
  });
175
181
  watchConnectorTools();
176
182
  startCloudWatchPublisher();
177
- watchFallbackUsage();
183
+ watchFallbackUsage(() => effectiveSetup(), () => readClaudeLogins());
178
184
  announceLocalEndpoint();
179
185
  tunnel?.start();
180
186
  log.info(
@@ -221,6 +227,7 @@ async function shutdown(): Promise<void> {
221
227
  log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
222
228
  });
223
229
  const leaving = leaveCallsForShutdown();
230
+ stopOrgModels();
224
231
  unwatchSession();
225
232
  tunnel?.stop();
226
233
  if (webhookServer) {
@@ -11,7 +11,8 @@ import { isRecord } from '@metro-labs/core/is-record';
11
11
  import { readJson, writeJson } from '@metro-labs/core/secure-fs';
12
12
  import { METRO_VERSION } from '@metro-labs/core/version';
13
13
  import { agentsDir, listAgentFiles } from '../agents/files.js';
14
- import { notReady, readModelConfig, routedConnection } from '../gateway/model-config.js';
14
+ import { notReady, primaryRoute, type ModelSetup } from '../gateway/model-config.js';
15
+ import { effectiveSetup } from '../gateway/effective.js';
15
16
  import { claudeAccount, claudeInstalled, forgetClaudeChecks } from './login.js';
16
17
  import { inSessionScope } from './memory.js';
17
18
  import { runningAsRoot } from '../metro-user/privilege.js';
@@ -38,6 +39,7 @@ export interface SessionDeps {
38
39
  home?: string;
39
40
  agents?: string;
40
41
  signedIn?: () => boolean;
42
+ model?: () => ModelSetup;
41
43
  now?: () => number;
42
44
  version?: string;
43
45
  }
@@ -138,13 +140,12 @@ function credentialReady(deps: SessionDeps): string | null {
138
140
  const signedIn = deps.signedIn ?? loginNotRuledOut;
139
141
  if (signedIn()) return null;
140
142
  try {
141
- const cfg = readModelConfig(deps.agents ?? agentsDir());
142
- const conn = routedConnection(cfg);
143
- if (conn !== null && notReady(cfg) === null && (conn.provider !== 'anthropic' || conn.apiKey !== '')) return null;
143
+ const primary = primaryRoute((deps.model ?? (() => effectiveSetup(deps.agents ?? agentsDir())))());
144
+ if (primary !== null && notReady(primary) === null && (primary.connection.provider !== 'anthropic' || primary.connection.apiKey !== '')) return null;
144
145
  } catch (err) {
145
- return `the Model page is not readable (${errMsg(err)})`;
146
+ return `the model setup is not readable (${errMsg(err)})`;
146
147
  }
147
- return 'Claude Code is not signed in and the Model page routes nowhere yet';
148
+ return 'Claude Code is not signed in and the organization routes this agent to no model yet';
148
149
  }
149
150
 
150
151
  const agentUserMissing = (deps: SessionDeps): boolean =>
@@ -1,7 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { log } from '@metro-labs/core/log';
3
3
  import { EventStreamDecoder, type EventStreamMessage } from './eventstream.js';
4
- import { errorFrame, GatewayError, providerStatus, relayFrames, sendError, upstreamMessage, type Watch } from './forward.js';
4
+ import { errorFrame, providerStatus, relayFrames, sendError, upstreamMessage, type Watch } from './forward.js';
5
5
  import type { Connection } from './model-config.js';
6
6
  import { estimateTokens } from './subscription.js';
7
7
  import { UsageScanner } from './usage.js';
@@ -226,8 +226,3 @@ export async function bedrockCount(
226
226
  res.writeHead(200, { 'content-type': 'application/json' });
227
227
  res.end(JSON.stringify({ input_tokens: counted ?? estimateTokens(rewritten.body) }));
228
228
  }
229
-
230
- export function assertBedrockReady(settings: Connection): void {
231
- if (settings.apiKey === '' || settings.region === '')
232
- throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key and a region: add them on the Model page.');
233
- }
@@ -5,13 +5,14 @@ import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { errMsg, log } from '@metro-labs/core/log';
6
6
  import { ApiError } from '@metro-labs/http/api-error';
7
7
  import { readFileSync } from '../agent-user/agent-fs.js';
8
- import { removeHome } from '../agent-user/home-fs.js';
8
+ import { moveHome, removeHome } from '../agent-user/home-fs.js';
9
9
  import { claudeHome } from '../agent-user/user.js';
10
10
  import { claudeAccountIn, type ClaudeAccount, type LoginTarget } from '../claude/login.js';
11
11
  import { readClaudeUsage } from '../claude/usage-probe.js';
12
12
  import { currentOf, reach, refreshed, type TokenSource, type TokenState } from './subscription.js';
13
- import { forgetReported } from './usage.js';
14
- import { addConnection, connectionOf, readModelConfig, setClaudeLogin, writeModelConfig, type ModelConfig } from './model-config.js';
13
+ import { forgetOne, forgetReported } from './usage.js';
14
+ import { readClaudeLogins, writeClaudeLogins, type ClaudeLogin } from './claude-store.js';
15
+ import { loginRef } from './model-config.js';
15
16
 
16
17
  const CREDENTIALS = '.credentials.json';
17
18
  const RENEW_MARGIN_MS = 60_000;
@@ -22,8 +23,7 @@ export interface ClaudeLoginDeps {
22
23
  root?: string;
23
24
  probe?: (env: Record<string, string>) => Promise<unknown>;
24
25
  account?: (env: Record<string, string>) => ClaudeAccount;
25
- read?: () => ModelConfig;
26
- write?: (cfg: ModelConfig) => void;
26
+ dir?: string;
27
27
  }
28
28
 
29
29
  interface ClaudeTokens {
@@ -110,56 +110,80 @@ export function withClaudeLogin(login: string, deps: ClaudeLoginDeps, send: (tok
110
110
  );
111
111
  }
112
112
 
113
- function dropClaudeLogin(login: string, deps: ClaudeLoginDeps): void {
113
+ const folderOf = (login: string, deps: ClaudeLoginDeps): string => claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '';
114
+
115
+ function forgetTokens(login: string): void {
114
116
  seen.delete(login);
115
117
  state.delete(login);
118
+ }
119
+
120
+ function dropFolder(login: string, deps: ClaudeLoginDeps): void {
121
+ forgetTokens(login);
116
122
  try {
117
- removeHome(claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '', true);
123
+ removeHome(folderOf(login, deps), true);
118
124
  } catch (err) {
119
- log.warn({ err: errMsg(err) }, 'claude-logins: could not remove a Claude login that is no longer used');
125
+ log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of a Claude login that is no longer used');
120
126
  }
121
127
  }
122
128
 
123
- export function dropUnusedClaudeLogins(before: ModelConfig, after: ModelConfig, deps: ClaudeLoginDeps = {}): void {
124
- const kept = new Set(after.connections.flatMap((c) => (c.claude === null ? [] : [c.claude.id])));
125
- for (const c of before.connections) if (c.claude !== null && !kept.has(c.claude.id)) dropClaudeLogin(c.claude.id, deps);
129
+ export function removeClaudeLogin(login: string, deps: ClaudeLoginDeps = {}): void {
130
+ const logins = readClaudeLogins(deps.dir);
131
+ if (!logins.some((l) => l.id === login)) throw new ApiError('no such Claude login on this box', 404);
132
+ writeClaudeLogins(logins.filter((l) => l.id !== login), deps.dir);
133
+ dropFolder(login, deps);
134
+ forgetOne(loginRef(login));
135
+ log.info({ login }, 'claude-logins: a Claude login was removed from this box');
136
+ }
137
+
138
+ function takeOver(fresh: string, replaced: string, deps: ClaudeLoginDeps): void {
139
+ const aside = `${folderOf(replaced, deps)}.old-${newId()}`;
140
+ moveHome(folderOf(replaced, deps), aside);
141
+ try {
142
+ moveHome(folderOf(fresh, deps), folderOf(replaced, deps));
143
+ } catch (err) {
144
+ moveHome(aside, folderOf(replaced, deps));
145
+ throw err;
146
+ }
147
+ forgetTokens(replaced);
148
+ try {
149
+ removeHome(aside, true);
150
+ } catch (err) {
151
+ log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of the login that was signed in again');
152
+ }
126
153
  }
127
154
 
128
- function keep(login: string, connection: string | null, account: ClaudeAccount, deps: ClaudeLoginDeps): string | null {
129
- const before = (deps.read ?? readModelConfig)();
130
- const cfg = connection === null ? addConnection(before, { provider: 'anthropic' }) : before;
131
- const id = connection ?? cfg.connections.at(-1)?.id ?? '';
132
- if (connectionOf(cfg, id)?.provider !== 'anthropic') return 'that connection was removed during the sign-in';
133
- const next = setClaudeLogin(cfg, id, { id: login, email: account.account, plan: account.plan, savedAt: new Date().toISOString() });
134
- (deps.write ?? writeModelConfig)(next);
135
- dropUnusedClaudeLogins(before, next, deps);
136
- forgetReported(id);
137
- log.info({ connection: id, plan: account.plan, added: connection === null }, 'claude-logins: the Claude login was kept for its own connection');
155
+ function keep(login: string, replacing: string | null, account: ClaudeAccount, deps: ClaudeLoginDeps): string | null {
156
+ const logins = readClaudeLogins(deps.dir);
157
+ if (replacing !== null && !logins.some((l) => l.id === replacing)) return 'that Claude login was removed during the sign-in';
158
+ if (replacing !== null) takeOver(login, replacing, deps);
159
+ const entry: ClaudeLogin = { id: replacing ?? login, email: account.account, plan: account.plan, savedAt: new Date().toISOString() };
160
+ writeClaudeLogins(replacing === null ? [...logins, entry] : logins.map((l) => (l.id === replacing ? entry : l)), deps.dir);
161
+ forgetReported(loginRef(entry.id));
162
+ log.info({ login: entry.id, plan: account.plan, added: replacing === null }, 'claude-logins: the Claude login was kept on this box');
138
163
  return null;
139
164
  }
140
165
 
141
- function settle(ok: boolean, login: string, connection: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
166
+ function settle(ok: boolean, login: string, replacing: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
142
167
  if (!ok) {
143
- dropClaudeLogin(login, deps);
168
+ dropFolder(login, deps);
144
169
  return Promise.resolve(null);
145
170
  }
146
171
  let problem: string | null;
147
172
  try {
148
173
  const account = (deps.account ?? claudeAccountIn)(claudeLoginEnv(login, deps));
149
- problem = account.signedIn ? keep(login, connection, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
174
+ problem = account.signedIn ? keep(login, replacing, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
150
175
  } catch (err) {
151
176
  problem = errMsg(err);
152
177
  }
153
- if (problem !== null) dropClaudeLogin(login, deps);
178
+ if (problem !== null) dropFolder(login, deps);
154
179
  return Promise.resolve(problem);
155
180
  }
156
181
 
157
182
  export function claudeLoginTarget(asked: string, deps: ClaudeLoginDeps = {}): LoginTarget {
158
- const connection = asked === NEW ? null : asked;
159
- if (connection !== null && connectionOf((deps.read ?? readModelConfig)(), connection)?.provider !== 'anthropic')
160
- throw new ApiError('no such Anthropic connection', 404);
183
+ const replacing = asked === NEW ? null : asked;
184
+ if (replacing !== null && !readClaudeLogins(deps.dir).some((l) => l.id === replacing)) throw new ApiError('no such Claude login on this box', 404);
161
185
  const login = newId();
162
- return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, connection, deps) };
186
+ return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, replacing, deps) };
163
187
  }
164
188
 
165
189
  export function forgetClaudeTokens(): void {
@@ -0,0 +1,55 @@
1
+ import { join } from 'node:path';
2
+ import { ID_RE, newId } from '@metro-labs/core/ids';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
5
+ import { agentsDir } from '../agents/files.js';
6
+
7
+ export interface ClaudeLogin {
8
+ id: string;
9
+ email: string | null;
10
+ plan: string | null;
11
+ savedAt: string;
12
+ }
13
+
14
+ const MODEL_FILE = 'model.json';
15
+
16
+ const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
17
+ const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
18
+
19
+ export function claudeLoginOf(raw: unknown): ClaudeLogin | null {
20
+ if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
21
+ return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
22
+ }
23
+
24
+ export const modelPath = (dir: string): string => join(dir, MODEL_FILE);
25
+
26
+ export const readModelFile = (dir = agentsDir()): unknown =>
27
+ readJson<unknown>(modelPath(dir), null, { warn: 'model.json is unreadable, so the Claude logins of this box are not seen until it is fixed' });
28
+
29
+ export const isV2 = (raw: unknown): raw is Record<string, unknown> & { connections: unknown[] } =>
30
+ isRecord(raw) && raw.version !== 3 && Array.isArray(raw.connections);
31
+
32
+ const unique = (logins: ClaudeLogin[]): ClaudeLogin[] => logins.filter((l, at) => logins.findIndex((o) => o.id === l.id) === at);
33
+
34
+ export function claudeLoginsIn(raw: unknown): ClaudeLogin[] {
35
+ if (isV2(raw)) return unique(raw.connections.flatMap((c: unknown) => (isRecord(c) && c.provider === 'anthropic' ? (claudeLoginOf(c.claude) ?? []) : [])));
36
+ if (!isRecord(raw) || !Array.isArray(raw.claude)) return [];
37
+ return unique(raw.claude.flatMap((c: unknown) => claudeLoginOf(c) ?? []));
38
+ }
39
+
40
+ export const readClaudeLogins = (dir = agentsDir()): ClaudeLogin[] => claudeLoginsIn(readModelFile(dir));
41
+
42
+ export const v3Text = (logins: ClaudeLogin[]): string => JSON.stringify({ version: 3, claude: logins }, null, 2);
43
+
44
+ function v2With(raw: Record<string, unknown> & { connections: unknown[] }, logins: ClaudeLogin[]): Record<string, unknown> {
45
+ const holds = (c: unknown): c is Record<string, unknown> => isRecord(c) && c.provider === 'anthropic' && claudeLoginOf(c.claude) !== null;
46
+ const held = new Map(raw.connections.filter(holds).map((c) => [claudeLoginOf(c.claude)?.id ?? '', c]));
47
+ const others = raw.connections.filter((c) => !holds(c));
48
+ const logged = logins.map((login) => ({ ...(held.get(login.id) ?? { id: newId(), provider: 'anthropic', label: 'Anthropic', model: '' }), claude: login }));
49
+ return { ...raw, connections: [...others, ...logged] };
50
+ }
51
+
52
+ export function writeClaudeLogins(logins: ClaudeLogin[], dir = agentsDir()): void {
53
+ const raw = readModelFile(dir);
54
+ writeSecure(modelPath(dir), isV2(raw) ? JSON.stringify(v2With(raw, logins), null, 2) : v3Text(logins));
55
+ }
@@ -2,54 +2,40 @@ import { randomUUID } from 'node:crypto';
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import { arch, platform, release } from 'node:os';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
+ import { errMsg, log } from '@metro-labs/core/log';
5
6
  import { stringOf } from '@metro-labs/http/api-http';
6
- import { OPENAI_API, CODEX_SIGN_IN, refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
7
- import { refreshCodeTokens } from './codex-device.js';
8
7
  import { codexVersion, learnCodexVersion } from './codex-version.js';
9
8
  import { CodexEventTranslator, withUsageLink } from './codex-stream.js';
10
9
  import { ToolNames, toResponsesRequest } from './codex-translate.js';
11
10
  import { GatewayError, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
12
11
  import { parseEvent, SseParser, type SseEvent } from './frames.js';
13
- import type { Connection } from './model-config.js';
14
- import { listCache } from './model-lists.js';
15
- import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
12
+ import { CODEX_SIGN_IN, type CodexAccess, type Connection } from './model-config.js';
13
+ import { answerWhole, errorKind, relayTranslated, sessionHeader } from './subscription.js';
16
14
  import { noteUsageHeaders } from './usage.js';
17
15
 
18
16
  const CODEX_BACKEND = 'https://chatgpt.com/backend-api/codex';
17
+ const OPENAI_API = 'https://api.openai.com/v1';
19
18
  const INVALID = [400, 404, 422];
19
+ const RENEW_FAILED = "Codex refused its sign-in and api.metro.box could not renew it: sign in again on the organization's Models page.";
20
20
 
21
21
  export interface CodexDeps {
22
22
  base?: string;
23
- issuer?: string;
24
23
  fetchImpl?: typeof fetch;
25
- save: (id: string, tokens: CodexTokens) => void;
24
+ refresh: (conn: Connection) => Promise<Connection | null>;
26
25
  }
27
26
 
28
- export type CodexState = TokenState<CodexTokens>;
29
-
30
- export const sharedCodexState: CodexState = new Map();
31
-
32
- const codexLists = listCache<string>('codex');
33
-
34
- const sourceOf = (deps: CodexDeps): TokenSource<CodexTokens> => ({
35
- label: 'Codex',
36
- stale: (tokens) => tokensStale(tokens),
37
- refresh: (tokens) => (tokens.method === 'code' ? refreshCodeTokens(tokens, deps.issuer, deps.fetchImpl) : refreshTokens(tokens, deps.issuer, deps.fetchImpl)),
38
- save: deps.save,
39
- });
40
-
41
27
  const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
42
28
 
43
29
  export const userAgent = (): string => `codex_cli_rs/${codexVersion()} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
44
30
 
45
- const baseFor = (tokens: CodexTokens, deps: Pick<CodexDeps, 'base'>): string => deps.base ?? (tokens.method === 'code' ? CODEX_BACKEND : OPENAI_API);
31
+ const baseFor = (access: CodexAccess, deps: Pick<CodexDeps, 'base'>): string => deps.base ?? (access.method === 'code' ? CODEX_BACKEND : OPENAI_API);
46
32
 
47
- function headersFor(tokens: CodexTokens, accept: string, sessionId: string): Record<string, string> {
48
- const plain = { authorization: `Bearer ${tokens.accessToken}`, 'content-type': 'application/json', accept };
49
- if (tokens.method === 'chatgpt') return plain;
33
+ function headersFor(access: CodexAccess, sessionId: string): Record<string, string> {
34
+ const plain = { authorization: `Bearer ${access.accessToken}`, 'content-type': 'application/json', accept: 'text/event-stream' };
35
+ if (access.method === 'chatgpt') return plain;
50
36
  return {
51
37
  ...plain,
52
- 'chatgpt-account-id': tokens.accountId,
38
+ 'chatgpt-account-id': access.accountId ?? '',
53
39
  originator: 'codex_cli_rs',
54
40
  'user-agent': userAgent(),
55
41
  'openai-beta': 'responses=experimental',
@@ -57,9 +43,6 @@ function headersFor(tokens: CodexTokens, accept: string, sessionId: string): Rec
57
43
  };
58
44
  }
59
45
 
60
- export const currentTokens = (conn: Connection, deps: CodexDeps, state: CodexState): Promise<CodexTokens> =>
61
- currentOf(state, conn.id, conn.codex, sourceOf(deps), CODEX_SIGN_IN);
62
-
63
46
  interface Call {
64
47
  body: Record<string, unknown>;
65
48
  model: string;
@@ -69,11 +52,11 @@ interface Call {
69
52
  names: ToolNames;
70
53
  }
71
54
 
72
- function send(call: Call, tokens: CodexTokens): Promise<Response> {
55
+ function send(call: Call, access: CodexAccess): Promise<Response> {
73
56
  const request = toResponsesRequest(call.body, call.model, { promptCacheKey: call.sessionId, names: call.names });
74
- return (call.deps.fetchImpl ?? fetch)(`${baseFor(tokens, call.deps)}/responses`, {
57
+ return (call.deps.fetchImpl ?? fetch)(`${baseFor(access, call.deps)}/responses`, {
75
58
  method: 'POST',
76
- headers: headersFor(tokens, 'text/event-stream', call.sessionId),
59
+ headers: headersFor(access, call.sessionId),
77
60
  body: JSON.stringify(request),
78
61
  signal: call.watch.signal,
79
62
  redirect: 'manual',
@@ -91,22 +74,33 @@ function refusalOf(text: string, status: number): string {
91
74
  return withUsageLink(code, upstreamMessage(text, `OpenAI answered ${String(status)}`));
92
75
  }
93
76
 
94
- export async function codexMessages(
95
- req: IncomingMessage,
96
- res: ServerResponse,
97
- body: Record<string, unknown>,
98
- model: string,
99
- conn: Connection,
100
- deps: CodexDeps,
101
- state: CodexState,
102
- watch: Watch,
103
- ): Promise<void> {
77
+ let learning: Promise<unknown> | null = null;
78
+
79
+ function learnVersion(access: CodexAccess): void {
80
+ if (access.method !== 'code' || learning !== null) return;
81
+ learning = learnCodexVersion()
82
+ .catch((err: unknown) => {
83
+ log.warn({ err: errMsg(err) }, 'gateway: the latest Codex version could not be learned');
84
+ })
85
+ .finally(() => {
86
+ learning = null;
87
+ });
88
+ }
89
+
90
+ async function reach(call: Call, conn: Connection): Promise<Response> {
91
+ if (conn.codex === null) throw new GatewayError(400, 'invalid_request_error', CODEX_SIGN_IN);
92
+ learnVersion(conn.codex);
93
+ const upstream = await send(call, conn.codex);
94
+ if (upstream.status !== 401) return upstream;
95
+ await upstream.body?.cancel();
96
+ const fresh = await call.deps.refresh(conn);
97
+ if (fresh?.codex === null || fresh?.codex === undefined) throw new GatewayError(403, 'permission_error', RENEW_FAILED);
98
+ return send(call, fresh.codex);
99
+ }
100
+
101
+ export async function codexMessages(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, model: string, conn: Connection, deps: CodexDeps, watch: Watch): Promise<void> {
104
102
  const call: Call = { body, model, sessionId: sessionHeader(req) || randomUUID(), watch, deps, names: new ToolNames() };
105
- const upstream = await reach(
106
- () => currentTokens(conn, deps, state),
107
- (tokens) => refreshed(state, conn.id, tokens, sourceOf(deps)),
108
- (tokens) => send(call, tokens),
109
- );
103
+ const upstream = await reach(call, conn);
110
104
  noteUsageHeaders('codex', conn.id, upstream.headers);
111
105
  if (!upstream.ok) {
112
106
  const text = await upstream.text();
@@ -125,19 +119,3 @@ export async function codexMessages(
125
119
  const frames = new SseParser().push(await upstream.text()).map(onEvent).join('');
126
120
  answerWhole(res, frames + translator.close(), conn.id);
127
121
  }
128
-
129
- export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
130
- const base = baseFor(tokens, deps);
131
- const fetchImpl = deps.fetchImpl ?? fetch;
132
- if (tokens.method === 'chatgpt') return codexLists.get(`${base}:${tokens.clientId}`, () => listCodex(tokens, `${base}/models`, fetchImpl));
133
- const version = await learnCodexVersion();
134
- return codexLists.get(`${base}:${tokens.accountId}:${version}`, () => listCodex(tokens, `${base}/models?client_version=${version}`, fetchImpl));
135
- }
136
-
137
- async function listCodex(tokens: CodexTokens, url: string, fetchImpl: typeof fetch): Promise<string[]> {
138
- const res = await fetchImpl(url, { headers: headersFor(tokens, 'application/json', randomUUID()), redirect: 'manual' });
139
- if (!res.ok) throw new GatewayError(res.status, errorKind(res.status, INVALID), `OpenAI would not list the models of this ChatGPT plan (${String(res.status)})`);
140
- const body: unknown = await res.json();
141
- const models: unknown[] = isRecord(body) && Array.isArray(body.models) ? body.models : [];
142
- return models.filter(isRecord).flatMap((m) => ((m.visibility ?? 'list') === 'list' && stringOf(m.slug) !== '' ? [stringOf(m.slug)] : []));
143
- }
@@ -0,0 +1,82 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { isProvider, LOCAL_PREFIX, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
3
+
4
+ export interface DeliveredRoute {
5
+ primary: Fallback | null;
6
+ fallbacks: Fallback[];
7
+ }
8
+
9
+ export interface Delivered {
10
+ route: DeliveredRoute | null;
11
+ source: RouteSource | null;
12
+ connections: Connection[];
13
+ }
14
+
15
+ const MAX_FALLBACKS = 8;
16
+ const MAX_CONNECTIONS = 20;
17
+
18
+ const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
19
+ const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
20
+
21
+ function refOf(raw: unknown): Fallback | null {
22
+ if (!isRecord(raw)) return null;
23
+ const ref = { connection: text(raw.connection), model: text(raw.model) };
24
+ return ref.connection === '' ? null : ref;
25
+ }
26
+
27
+ function routeOf(raw: unknown): DeliveredRoute | null {
28
+ if (!isRecord(raw)) return null;
29
+ const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
30
+ return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS) };
31
+ }
32
+
33
+ function accountOf(raw: unknown): Account {
34
+ const account = isRecord(raw) ? raw : {};
35
+ const method = account.method === 'chatgpt' || account.method === 'code' ? account.method : null;
36
+ return { email: maybe(account.email), plan: maybe(account.plan), method };
37
+ }
38
+
39
+ const numberOf = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
40
+
41
+ function codexOf(raw: Record<string, unknown>): CodexAccess | null {
42
+ const accessToken = text(raw.accessToken);
43
+ if (accessToken === '' || (raw.method !== 'chatgpt' && raw.method !== 'code')) return null;
44
+ return { method: raw.method, accessToken, expiresAt: numberOf(raw.expiresAt), accountId: maybe(raw.accountId) };
45
+ }
46
+
47
+ function credentialOf(provider: Provider, raw: unknown): { apiKey: string; codex: CodexAccess | null } {
48
+ if (!isRecord(raw)) return { apiKey: '', codex: null };
49
+ return provider === 'codex' ? { apiKey: '', codex: codexOf(raw) } : { apiKey: text(raw.apiKey), codex: null };
50
+ }
51
+
52
+ export function deliveredConnection(raw: unknown): Connection | null {
53
+ if (!isRecord(raw) || !isProvider(raw.provider)) return null;
54
+ const id = text(raw.id);
55
+ if (id === '' || id.startsWith(LOCAL_PREFIX)) return null;
56
+ const { apiKey, codex } = credentialOf(raw.provider, raw.credential);
57
+ return {
58
+ id,
59
+ provider: raw.provider,
60
+ label: text(raw.label) === '' ? raw.provider : text(raw.label),
61
+ model: text(raw.model),
62
+ apiKey,
63
+ region: text(raw.region),
64
+ zdr: raw.zdr === true,
65
+ revision: numberOf(raw.revision),
66
+ codex,
67
+ claude: null,
68
+ account: accountOf(raw.account),
69
+ signedOut: codex === null && apiKey === '',
70
+ };
71
+ }
72
+
73
+ export function deliveredOf(raw: Record<string, unknown>): Delivered | null {
74
+ if (!Array.isArray(raw.connections) || (raw.route !== null && !isRecord(raw.route))) return null;
75
+ const source = raw.source === 'agent' || raw.source === 'organization' ? raw.source : null;
76
+ const connections = raw.connections.flatMap((c: unknown) => deliveredConnection(c) ?? []).slice(0, MAX_CONNECTIONS);
77
+ return { route: routeOf(raw.route), source, connections };
78
+ }
79
+
80
+ export function refreshedConnection(raw: Record<string, unknown>): Connection | null {
81
+ return deliveredConnection(isRecord(raw.connection) ? raw.connection : raw);
82
+ }
@@ -0,0 +1,47 @@
1
+ import { log } from '@metro-labs/core/log';
2
+ import { agentsDir } from '../agents/files.js';
3
+ import { claudeLoginsIn, isV2, readModelFile, type ClaudeLogin } from './claude-store.js';
4
+ import type { Delivered } from './delivered.js';
5
+ import { emptySetup, LOCAL_PREFIX, loginConnection, primaryRoute, runnerModel, SESSION_LOGIN, sessionConnection, type Connection, type ModelSetup } from './model-config.js';
6
+ import { v2Setup } from './model-v2.js';
7
+ import { deliveredSetup, type OrgModels } from './org-models.js';
8
+
9
+ function localOf(id: string, logins: ClaudeLogin[]): Connection | null {
10
+ if (id === SESSION_LOGIN) return sessionConnection();
11
+ if (!id.startsWith(LOCAL_PREFIX)) return null;
12
+ const login = id.slice(LOCAL_PREFIX.length);
13
+ return loginConnection(login, logins.find((l) => l.id === login));
14
+ }
15
+
16
+ export function setupOf(delivered: Delivered | null, logins: ClaudeLogin[]): ModelSetup {
17
+ const route = delivered?.route ?? null;
18
+ if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
19
+ const named = [...(route.primary === null ? [] : [route.primary]), ...route.fallbacks].map((r) => r.connection);
20
+ const locals = [...new Set(named)].flatMap((id) => localOf(id, logins) ?? []);
21
+ return { primary: route.primary, fallbacks: route.fallbacks, connections: [...delivered.connections, ...locals], source: delivered.source };
22
+ }
23
+
24
+ export function effectiveSetup(dir = agentsDir(), delivered: Delivered | null = deliveredSetup()): ModelSetup {
25
+ const raw = readModelFile(dir);
26
+ return isV2(raw) ? v2Setup(raw) : setupOf(delivered, claudeLoginsIn(raw));
27
+ }
28
+
29
+ export function modelViewText(setup: ModelSetup): string | null {
30
+ const primary = primaryRoute(setup);
31
+ if (primary === null) return null;
32
+ const connections = [{ id: primary.connection.id, provider: primary.connection.provider, model: primary.model }];
33
+ return `${JSON.stringify({ version: 2, route: primary.connection.id, connections }, null, 2)}\n`;
34
+ }
35
+
36
+ export function watchModelChanges(models: Pick<OrgModels, 'onChange'>, switchModel: (model: string | null) => boolean, setup: () => ModelSetup = effectiveSetup): () => void {
37
+ let last = runnerModel(setup());
38
+ const changed = (): void => {
39
+ const now = runnerModel(setup());
40
+ if (now === last) return;
41
+ const was = last;
42
+ last = now;
43
+ if (switchModel(now)) log.info({ was, now }, 'org-models: the model changed, so the Agent SDK session switches to it live, with no restart');
44
+ else log.info({ was, now }, 'org-models: the model changed while no Agent SDK session listens; its next start loads it');
45
+ };
46
+ return models.onChange(changed);
47
+ }