@stage-labs/metro 0.1.0-beta.307 → 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 (91) hide show
  1. package/dist/agent.js +23 -28
  2. package/dist/child.js +33 -0
  3. package/dist/claude-env.js +104 -0
  4. package/dist/cli.js +7 -21
  5. package/dist/lock.js +62 -0
  6. package/dist/memory.js +12 -27
  7. package/dist/root-release-io.js +1 -2
  8. package/dist/root-release.js +1 -1
  9. package/dist/route.js +0 -1
  10. package/hooks/guard.mjs +1 -5
  11. package/hooks/session-start.mjs +1 -1
  12. package/package.json +1 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
  14. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -9
  16. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +14 -12
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +39 -118
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
  24. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
  25. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +1 -6
  26. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
  32. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
  33. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +37 -77
  34. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +86 -220
  35. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
  36. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
  37. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
  38. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
  39. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
  40. package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
  41. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
  42. package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
  43. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
  44. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
  45. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  46. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  47. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  48. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  49. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  50. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  51. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -4
  52. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  53. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  54. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  55. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  56. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  57. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  58. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  59. package/runtime/runtime.json +1 -1
  60. package/runtime/sdk-runner/runner.json +1 -1
  61. package/dist/background.js +0 -141
  62. package/dist/claude.js +0 -204
  63. package/dist/mcp-config.js +0 -27
  64. package/dist/native-recovery-scan.js +0 -246
  65. package/dist/native-recovery-state.js +0 -154
  66. package/dist/native-recovery.js +0 -164
  67. package/dist/onboarding.js +0 -44
  68. package/dist/voice.js +0 -56
  69. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  70. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  71. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  72. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  73. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  74. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  75. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +0 -219
  76. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
  77. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
  78. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
  79. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
  80. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
  81. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
  82. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
  83. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
  84. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -83
  85. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
  86. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
  87. package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
  88. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  89. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  90. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  91. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { log } from '@metro-labs/core/log';
2
2
  import { lastServed } from './served.js';
3
3
  import { PER_MINUTE, usageOf, type UsageWindow } from './usage.js';
4
- import { bareModel, connectionOf, isSmallModel, notReady, routedConnection, routeLabel, type Connection, type Fallback, type ModelConfig, type Route } from './model-config.js';
4
+ import { bareModel, connectionOf, isSmallModel, notReady, primaryRoute, routeLabel, type Connection, type Fallback, type ModelSetup, type Route } from './model-config.js';
5
5
 
6
6
  export const USAGE_LIMIT = 0.95;
7
7
  const COOLDOWN_MS = 60_000;
@@ -36,7 +36,6 @@ function scopeOf(label: string): string | null {
36
36
  function applies(window: UsageWindow, route: Route, now: number): boolean {
37
37
  if (window.used === null || window.label === PER_MINUTE) return false;
38
38
  if (window.resetAt !== null && Date.parse(window.resetAt) <= now) return false;
39
- if (route.connection.provider === 'gemini') return window.label === route.model;
40
39
  const scope = scopeOf(window.label);
41
40
  return scope === null || route.model.toLowerCase().split(/[^a-z0-9]+/).includes(scope);
42
41
  }
@@ -59,16 +58,16 @@ function fallbackModel(requested: string, conn: Connection, model: string): stri
59
58
  return conn.provider === 'anthropic' && isSmallModel(bare) ? bare : model;
60
59
  }
61
60
 
62
- function fallbackRoute(cfg: ModelConfig, fallback: Fallback, requested: string): Route | null {
63
- const conn = connectionOf(cfg, fallback.connection);
64
- if (conn === null || notReady(cfg, conn) !== null) return null;
61
+ function fallbackRoute(setup: ModelSetup, fallback: Fallback, requested: string): Route | null {
62
+ const conn = connectionOf(setup, fallback.connection);
63
+ if (conn === null || notReady({ connection: conn, model: fallback.model }) !== null) return null;
65
64
  return { connection: conn, model: fallbackModel(requested, conn, fallback.model) };
66
65
  }
67
66
 
68
67
  const unique = (routes: Route[]): Route[] => routes.filter((route, at) => routes.findIndex((other) => keyOf(other) === keyOf(route)) === at);
69
68
 
70
- export const chainOf = (primary: Route, cfg: ModelConfig, requested: string): Route[] =>
71
- unique([primary, ...(cfg.fallbacks ?? []).flatMap((f) => fallbackRoute(cfg, f, requested) ?? [])]);
69
+ export const chainOf = (primary: Route, setup: ModelSetup, requested: string): Route[] =>
70
+ unique([primary, ...setup.fallbacks.flatMap((f) => fallbackRoute(setup, f, requested) ?? [])]);
72
71
 
73
72
  export function routesToTry(chain: Route[], now = Date.now()): Route[] {
74
73
  const open = chain.filter((route) => holdOf(route, now) === null);
@@ -120,28 +119,28 @@ export interface ChainRow {
120
119
  active: boolean;
121
120
  }
122
121
 
123
- function primaryOf(cfg: ModelConfig): Route | null {
124
- const conn = routedConnection(cfg);
125
- if (conn === null) return null;
122
+ function primaryOf(setup: ModelSetup): Route | null {
123
+ const primary = primaryRoute(setup);
124
+ if (primary === null) return null;
126
125
  const served = lastServed();
127
- const model = conn.model !== '' ? conn.model : served?.connection === conn.id ? served.model : '';
128
- return { connection: conn, model };
126
+ const model = primary.model !== '' ? primary.model : served?.connection === primary.connection.id ? served.model : '';
127
+ return { connection: primary.connection, model };
129
128
  }
130
129
 
131
- function rowOf(cfg: ModelConfig, route: Route, now: number): Omit<ChainRow, 'active'> {
132
- const reason = notReady(cfg, route.connection);
130
+ function rowOf(route: Route, now: number): Omit<ChainRow, 'active'> {
131
+ const reason = notReady(route);
133
132
  const used = windowsFor(route, now).reduce<number | null>((most, w) => Math.max(most ?? 0, w.used ?? 0), null);
134
133
  return { connection: route.connection.id, model: route.model, used, hold: reason === null ? holdOf(route, now) : { reason, until: null } };
135
134
  }
136
135
 
137
- export function chainStatus(cfg: ModelConfig, now = Date.now()): ChainRow[] {
138
- const primary = primaryOf(cfg);
136
+ export function chainStatus(setup: ModelSetup, now = Date.now()): ChainRow[] {
137
+ const primary = primaryOf(setup);
139
138
  if (primary === null) return [];
140
- const listed = (cfg.fallbacks ?? []).flatMap((f) => {
141
- const conn = connectionOf(cfg, f.connection);
139
+ const listed = setup.fallbacks.flatMap((f) => {
140
+ const conn = connectionOf(setup, f.connection);
142
141
  return conn === null ? [] : [{ connection: conn, model: f.model }];
143
142
  });
144
- const rows = unique([primary, ...listed]).map((route) => rowOf(cfg, route, now));
143
+ const rows = unique([primary, ...listed]).map((route) => rowOf(route, now));
145
144
  const active = Math.max(0, rows.findIndex((row) => row.hold === null));
146
145
  return rows.map((row, at) => ({ ...row, active: at === active }));
147
146
  }
@@ -28,7 +28,6 @@ const HOP_BY_HOP = new Set([
28
28
  'upgrade',
29
29
  'accept-encoding',
30
30
  'x-metro-key',
31
- 'x-metro-runner',
32
31
  ]);
33
32
  const RESPONSE_DROP = new Set(['content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive']);
34
33
 
@@ -2,9 +2,7 @@ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
3
  import { cameThroughProxy } from '@metro-labs/http/api-http';
4
4
  import { agentIdForKey } from '../agents/keys.js';
5
- import { sdkAllowed, sdkConnectionAllowed, SDK_NEEDS_KEY } from '../claude/runner.js';
6
5
  import {
7
- assertBedrockReady,
8
6
  bedrockBase,
9
7
  bedrockCount,
10
8
  bedrockMessages,
@@ -38,21 +36,17 @@ import {
38
36
  withEffort,
39
37
  withThinkingFor,
40
38
  } from './effort.js';
41
- import { notReady, PASSTHROUGH_ID, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
42
- import { codexMessages, sharedCodexState } from './codex.js';
43
- import { geminiMessages, sharedGeminiState } from './gemini.js';
39
+ import { notReady, resolveRoute, routeLabel, sessionConnection, type Connection, type ModelSetup, type Route } from './model-config.js';
40
+ import { codexMessages, type CodexDeps } from './codex.js';
44
41
  import { countTokens } from './subscription.js';
45
- import type { GeminiDeps } from './gemini.js';
46
- import type { CodexDeps } from './codex.js';
47
- import type { GeminiTokens } from './gemini-auth.js';
48
42
  import { OPENROUTER_BASE } from './openrouter.js';
49
43
  import { isRecord } from '@metro-labs/core/is-record';
50
44
  import { forgetServed, noteServed } from './served.js';
51
45
  import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
52
46
  import { fitToolSearch } from './tool-search.js';
53
47
  import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
54
- import type { CodexTokens } from './codex-auth.js';
55
48
  import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
49
+ import { orgModels } from './org-models.js';
56
50
 
57
51
  const GATEWAY_PREFIX = '/gateway';
58
52
  const MESSAGES = '/v1/messages';
@@ -61,13 +55,12 @@ const MODELS = '/v1/models';
61
55
  const HELLO = '/api/hello';
62
56
 
63
57
  export interface GatewayDeps {
64
- config: () => ModelConfig;
58
+ setup: () => ModelSetup;
65
59
  identify?: (key: string) => boolean;
66
60
  anthropicBase?: string;
67
61
  bedrockBase?: string;
68
62
  openrouterBase?: string;
69
63
  codex?: Partial<CodexDeps>;
70
- gemini?: Partial<GeminiDeps>;
71
64
  claudeLogins?: ClaudeLoginDeps;
72
65
  }
73
66
 
@@ -80,52 +73,35 @@ export function resetGatewayState(): void {
80
73
  learned.fields.clear();
81
74
  learned.dropBetas = false;
82
75
  forgetLearnedThinking();
83
- sharedCodexState.clear();
84
- sharedGeminiState.clear();
85
76
  forgetClaudeTokens();
86
77
  }
87
78
 
88
- const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
89
- writeModelConfig(setCodexAuth(readModelConfig(), id, tokens));
90
- };
91
-
92
- const saveGeminiTokens = (id: string, tokens: GeminiTokens): void => {
93
- writeModelConfig(setGeminiAuth(readModelConfig(), id, tokens));
94
- };
79
+ const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
95
80
 
96
81
  const keyOf = (req: IncomingMessage): string => {
97
82
  const raw = req.headers['x-metro-key'];
98
83
  return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
99
84
  };
100
85
 
101
- const fromSdkRunner = (req: IncomingMessage): boolean => {
102
- const raw = req.headers['x-metro-runner'];
103
- return (Array.isArray(raw) ? raw[0] : raw)?.trim() === 'sdk';
104
- };
105
-
106
86
  const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
107
87
 
108
- function modelsBody(cfg: ModelConfig): Record<string, unknown> {
109
- const data = cfg.connections
110
- .filter((c) => c.provider !== 'anthropic' && c.model !== '')
111
- .map((c) => ({ id: `${c.provider}:${c.model}`, display_name: `${c.label} · ${c.model}`, description: 'Through metro' }));
112
- return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
88
+ function routesOf(setup: ModelSetup): Route[] {
89
+ return [...(setup.primary === null ? [] : [setup.primary]), ...setup.fallbacks].flatMap((ref) => {
90
+ const conn = setup.connections.find((c) => c.id === ref.connection);
91
+ const model = ref.model === '' ? (conn?.model ?? '') : ref.model;
92
+ return conn === undefined || model === '' ? [] : [{ connection: conn, model }];
93
+ });
113
94
  }
114
95
 
115
- const PASSTHROUGH: Connection = {
116
- id: PASSTHROUGH_ID,
117
- provider: 'anthropic',
118
- label: 'Claude Code login',
119
- model: '',
120
- apiKey: '',
121
- region: '',
122
- zdr: false,
123
- codex: null,
124
- gemini: null,
125
- claude: null,
126
- };
96
+ function modelsBody(setup: ModelSetup): Record<string, unknown> {
97
+ const listed = routesOf(setup)
98
+ .filter((r) => r.connection.provider !== 'anthropic')
99
+ .map((r) => ({ id: routeLabel(r), display_name: `${r.connection.label} · ${r.model}`, description: 'Through metro' }));
100
+ const data = listed.filter((m, at) => listed.findIndex((o) => o.id === m.id) === at);
101
+ return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
102
+ }
127
103
 
128
- const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
104
+ const passthrough = (body: Record<string, unknown>): Route => ({ connection: sessionConnection(), model: requestedModel(body) });
129
105
 
130
106
  function requestedModel(body: Record<string, unknown>): string {
131
107
  const model = body.model;
@@ -181,7 +157,7 @@ function listLoginModels(headers: IncomingHttpHeaders, anthropicBase: string): v
181
157
  function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
182
158
  if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
183
159
  if (conn.claude === null) return attempt(forwardedHeaders(req));
184
- return withClaudeLogin(conn.claude.id, deps.claudeLogins ?? {}, (token) => {
160
+ return withClaudeLogin(conn.claude, deps.claudeLogins ?? {}, (token) => {
185
161
  listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
186
162
  return attempt(loginHeaders(req, token));
187
163
  });
@@ -190,7 +166,7 @@ function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: st
190
166
  async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
191
167
  if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
192
168
  await upstream.body?.cancel();
193
- throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login of ${conn.label}: sign in again on the Model page`);
169
+ throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login ${conn.label}: sign in again on the Model page`);
194
170
  }
195
171
 
196
172
  async function toAnthropic(
@@ -210,7 +186,7 @@ async function toAnthropic(
210
186
  throw new GatewayError(
211
187
  403,
212
188
  'permission_error',
213
- 'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
189
+ "Claude Code on this machine has no Anthropic login of its own; route this agent to a model on the organization's Models page, or sign in a Claude login on the Model page",
214
190
  );
215
191
  const watch = watchUpstream(res);
216
192
  if (passthrough) listLoginModels(req.headers, anthropicBase);
@@ -229,17 +205,8 @@ async function toAnthropic(
229
205
  await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
230
206
  }
231
207
 
232
- async function toOpenRouter(
233
- req: IncomingMessage,
234
- res: ServerResponse,
235
- body: Record<string, unknown>,
236
- route: Route,
237
- cfg: ModelConfig,
238
- deps: GatewayDeps,
239
- ): Promise<void> {
208
+ async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
240
209
  const conn = route.connection;
241
- const reason = notReady(cfg, conn);
242
- if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
243
210
  const headers: Record<string, string> = {
244
211
  'content-type': 'application/json',
245
212
  accept: req.headers.accept ?? 'application/json',
@@ -276,11 +243,9 @@ function noteRefusal(connection: string, model: string, upstream: Response): voi
276
243
  if (!upstream.ok) log.warn({ connection, model, status: upstream.status }, 'gateway: the provider refused the request');
277
244
  }
278
245
 
279
- async function toSubscription(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
280
- const conn = route.connection;
246
+ async function toCodex(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
281
247
  if (path === COUNT) countTokens(res, body);
282
- else if (conn.provider === 'gemini') await geminiMessages(req, res, body, route.model, conn, { save: saveGeminiTokens, ...deps.gemini }, sharedGeminiState, watchUpstream(res));
283
- else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, sharedCodexState, watchUpstream(res));
248
+ else await codexMessages(req, res, body, route.model, route.connection, { refresh: refreshCodex, ...deps.codex }, watchUpstream(res));
284
249
  }
285
250
 
286
251
  function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
@@ -296,22 +261,17 @@ interface Request {
296
261
  parsed: Record<string, unknown>;
297
262
  sent: Record<string, unknown>;
298
263
  body: Record<string, unknown>;
299
- cfg: ModelConfig;
300
- }
301
-
302
- function checkSdkRoute(req: IncomingMessage, route: Route, deps: GatewayDeps): void {
303
- if (fromSdkRunner(req) && (!sdkAllowed(undefined, deps.config()) || !sdkConnectionAllowed(route.connection)))
304
- throw new GatewayError(403, 'permission_error', SDK_NEEDS_KEY);
264
+ setup: ModelSetup;
305
265
  }
306
266
 
307
267
  async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
308
268
  const { path, body } = ask;
309
269
  const conn = route.connection;
310
- checkSdkRoute(req, route, deps);
311
270
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
271
+ const reason = notReady(route);
272
+ if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
312
273
  if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
313
274
  if (conn.provider === 'bedrock') {
314
- assertBedrockReady(conn);
315
275
  const up = { settings: conn, base: deps.bedrockBase ?? bedrockBase(conn.region), learned, watch: watchUpstream(res) };
316
276
  if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
317
277
  else await bedrockMessages(req, res, body, route.model, up);
@@ -319,25 +279,25 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
319
279
  }
320
280
  if (conn.provider === 'openrouter') {
321
281
  if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
322
- await toOpenRouter(req, res, body, route, ask.cfg, deps);
282
+ await toOpenRouter(req, res, body, route, deps);
323
283
  return;
324
284
  }
325
- if (conn.provider === 'codex' || conn.provider === 'gemini') {
326
- await toSubscription(req, res, path, body, route, deps);
285
+ if (conn.provider === 'codex') {
286
+ await toCodex(req, res, path, body, route, deps);
327
287
  return;
328
288
  }
329
289
  await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
330
290
  }
331
291
 
332
292
  async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
333
- const cfg = deps.config();
293
+ const setup = deps.setup();
334
294
  const raw = await readBody(req);
335
295
  const parsed = parseJson(raw);
336
296
  const sent = fitToolSearch(req, parsed);
337
297
  const requested = requestedModel(sent);
338
- const primary = resolveRoute(requested, cfg) ?? passthrough(sent);
339
- const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), cfg };
340
- const routes = routesToTry(chainOf(primary, cfg, requested));
298
+ const primary = resolveRoute(requested, setup) ?? passthrough(sent);
299
+ const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
300
+ const routes = routesToTry(chainOf(primary, setup, requested));
341
301
  for (const [at, route] of routes.entries()) {
342
302
  const next = routes[at + 1];
343
303
  if (path === MESSAGES) noteChoice(primary, route);
@@ -369,7 +329,7 @@ function failed(res: ServerResponse, err: unknown): void {
369
329
  function answer(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): void {
370
330
  if (req.method === 'GET' && path === MODELS) {
371
331
  res.writeHead(200, { 'content-type': 'application/json' });
372
- res.end(JSON.stringify(modelsBody(deps.config())));
332
+ res.end(JSON.stringify(modelsBody(deps.setup())));
373
333
  return;
374
334
  }
375
335
  if (req.method === 'POST' && (path === MESSAGES || path === COUNT)) {
@@ -391,6 +351,6 @@ export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse,
391
351
  }
392
352
  if (cameThroughProxy(req)) sendError(res, 401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address');
393
353
  else if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
394
- else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start Claude Code with metro claude');
354
+ else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start the agent with metro agent');
395
355
  return true;
396
356
  }