@stage-labs/metro 0.1.0-beta.108 → 0.1.0-beta.109

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.
package/dist/claude.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { markOnboardingDone, seedChannels } from './onboarding.js';
3
3
  import { writeMcpConfig } from './mcp-config.js';
4
+ import { currentRoute, routeModelEnv } from './route.js';
4
5
  import { settingsConflicts, settingsFiles } from './claude-settings.js';
5
6
  import { localAgents, pickLocalAgent } from './local.js';
6
7
  import { PROVIDER_FLAGS } from './provider-flags.js';
@@ -135,7 +136,7 @@ function mcpConfigFor(key, port) {
135
136
  }
136
137
  function gatewayLaunchEnv(key, port) {
137
138
  process.stderr.write(`metro claude: inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)\n`);
138
- const routed = gatewayEnv(process.env, key, port);
139
+ const routed = routeModelEnv(gatewayEnv(process.env, key, port), currentRoute());
139
140
  const env = credentialEnv(routed, key, claudeSignedIn());
140
141
  if (env !== routed)
141
142
  process.stderr.write("metro claude: Claude Code has no login of its own here, so metro's key stands in as its credential; the Model page must route to Bedrock, OpenRouter or Codex\n");
package/dist/route.js ADDED
@@ -0,0 +1,26 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { agentsDir } from './local.js';
4
+ const PROVIDERS = ['bedrock', 'openrouter', 'codex'];
5
+ export function currentRoute(dir = agentsDir()) {
6
+ const path = join(dir, 'model.json');
7
+ if (!existsSync(path))
8
+ return null;
9
+ try {
10
+ const cfg = JSON.parse(readFileSync(path, 'utf8'));
11
+ const provider = PROVIDERS.find((p) => p === cfg.provider);
12
+ if (provider === undefined)
13
+ return null;
14
+ const block = cfg[provider];
15
+ const model = typeof block === 'object' && block !== null ? block.model : undefined;
16
+ return typeof model === 'string' && model !== '' ? `${provider}:${model}` : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export function routeModelEnv(env, route) {
23
+ if (route === null || (env.ANTHROPIC_MODEL ?? '').trim() !== '')
24
+ return env;
25
+ return { ...env, ANTHROPIC_MODEL: route };
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.108",
3
+ "version": "0.1.0-beta.109",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.108",
3
+ "version": "0.1.0-beta.109",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -7,6 +7,7 @@ import { readJson, writeJson } from '@metro-labs/core/secure-fs';
7
7
  import { agentsDir } from '../agents/files.js';
8
8
  import { claudeDir } from './files.js';
9
9
  import { stagedMarketplaceDir } from './plugin-install.js';
10
+ import { readModelConfig, type ModelConfig } from '../gateway/model-config.js';
10
11
 
11
12
  export const PRIVACY_ENV: Record<string, string> = {
12
13
  DISABLE_TELEMETRY: '1',
@@ -112,8 +113,8 @@ export function applyPrivacy(dir: string, enabled: boolean): SettingsOutcome {
112
113
  const current = readSettings(path);
113
114
  if (current === null) return 'unreadable';
114
115
  const next = withPrivacy(current, enabled);
116
+ if (existsSync(path) && JSON.stringify(next) === JSON.stringify(current)) return 'unchanged';
115
117
  const text = `${JSON.stringify(next, null, 2)}\n`;
116
- if (existsSync(path) && readFileSync(path, 'utf8') === text) return 'unchanged';
117
118
  mkdirSync(dir, { recursive: true });
118
119
  const tmp = `${path}.metro-${String(process.pid)}.tmp`;
119
120
  writeFileSync(tmp, text, { mode: 0o644 });
@@ -140,6 +141,7 @@ export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
140
141
  skill: placeSkill(dir, deps.guidance ?? guidancePath(deps.env)),
141
142
  settings: applyPrivacy(dir, privacy),
142
143
  };
144
+ syncAvailableModelsQuietly({ ...deps, dir, agents });
143
145
  if (report.worker === 'written' || report.skill === 'written' || report.settings === 'written')
144
146
  log.info(report, 'claude-setup: applied the Claude Code setup for a metro box');
145
147
  if (report.settings === 'unreadable') log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: settings.json is not valid JSON, so the privacy settings were not written');
@@ -168,3 +170,47 @@ export function tryClaudeSetup(deps: SetupDeps = {}): void {
168
170
  log.warn({ err: errMsg(err) }, 'claude-setup: could not apply the Claude Code setup');
169
171
  }
170
172
  }
173
+
174
+ const MODELS_KEY = 'availableModels';
175
+ const ENFORCE_KEY = 'enforceAvailableModels';
176
+
177
+ export function routeOf(cfg: ModelConfig): string | null {
178
+ if (cfg.provider === 'anthropic') return null;
179
+ const model = cfg[cfg.provider].model;
180
+ return model === '' ? null : `${cfg.provider}:${model}`;
181
+ }
182
+
183
+ function withAvailableModels(settings: Record<string, unknown>, route: string | null, metroWrote: boolean): Record<string, unknown> {
184
+ if (route !== null) return { ...settings, [MODELS_KEY]: [route], [ENFORCE_KEY]: true };
185
+ if (!metroWrote) return settings;
186
+ return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== MODELS_KEY && key !== ENFORCE_KEY));
187
+ }
188
+
189
+ export function syncAvailableModels(cfg: ModelConfig, deps: SetupDeps = {}): SettingsOutcome {
190
+ const dir = deps.dir ?? claudeDir();
191
+ const agents = deps.agents ?? agentsDir();
192
+ const route = routeOf(cfg);
193
+ const state = readJson<unknown>(statePath(agents), null);
194
+ const metroWrote = isRecord(state) && state.modelsByMetro === true;
195
+ const path = join(dir, 'settings.json');
196
+ const current = readSettings(path);
197
+ if (current === null) return 'unreadable';
198
+ const next = withAvailableModels(current, route, metroWrote);
199
+ mkdirSync(agents, { recursive: true });
200
+ writeJson(statePath(agents), { ...(isRecord(state) ? state : {}), modelsByMetro: route !== null });
201
+ if (existsSync(path) && JSON.stringify(next) === JSON.stringify(current)) return 'unchanged';
202
+ const text = `${JSON.stringify(next, null, 2)}\n`;
203
+ mkdirSync(dir, { recursive: true });
204
+ const tmp = `${path}.metro-${String(process.pid)}.tmp`;
205
+ writeFileSync(tmp, text, { mode: 0o644 });
206
+ renameSync(tmp, path);
207
+ return 'written';
208
+ }
209
+
210
+ export function syncAvailableModelsQuietly(deps: SetupDeps = {}, cfg?: ModelConfig): void {
211
+ try {
212
+ syncAvailableModels(cfg ?? readModelConfig(deps.agents ?? agentsDir()), deps);
213
+ } catch (err) {
214
+ log.warn({ err: errMsg(err) }, 'claude-setup: could not sync the model allowlist');
215
+ }
216
+ }
@@ -7,6 +7,8 @@ import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from './cod
7
7
  import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
8
8
  import { codexModels, currentTokens, freshCodexState } from './codex.js';
9
9
  import { openrouterModels, openrouterZdrModels } from './openrouter.js';
10
+ import { anthropicModels, bedrockModels } from './provider-models.js';
11
+ import { syncAvailableModelsQuietly, type SetupDeps } from '../claude/setup.js';
10
12
  import { lastServed } from './served.js';
11
13
  import type { CodexTokens } from './codex-auth.js';
12
14
  import { GatewayError } from './forward.js';
@@ -23,6 +25,8 @@ import {
23
25
  const PATH = '/api/model';
24
26
  const CODEX = '/api/model/codex/';
25
27
  const OPENROUTER = '/api/model/openrouter/';
28
+ const ANTHROPIC = '/api/model/anthropic/';
29
+ const BEDROCK = '/api/model/bedrock/';
26
30
  const BODY_MAX = 16 * 1024;
27
31
  const DEVICE_PREFIX = 'device/';
28
32
  const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
@@ -36,6 +40,9 @@ export interface ModelApiDeps {
36
40
  codexHome?: string;
37
41
  codexBase?: string;
38
42
  openrouterBase?: string;
43
+ anthropicBase?: string;
44
+ bedrockControlBase?: string;
45
+ setup?: SetupDeps;
39
46
  }
40
47
 
41
48
  interface Store {
@@ -54,11 +61,11 @@ const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({ ...public
54
61
 
55
62
  function asApiError(err: unknown): never {
56
63
  if (err instanceof ModelConfigError || err instanceof CodexAuthError) throw new ApiError(err.message, 400);
57
- if (err instanceof GatewayError) throw new ApiError(err.message, 502);
64
+ if (err instanceof GatewayError) throw new ApiError(err.message, err.status >= 400 && err.status < 500 ? 400 : 502);
58
65
  throw err;
59
66
  }
60
67
 
61
- async function update(req: IncomingMessage, store: Store): Promise<unknown> {
68
+ async function update(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
62
69
  const patch = await readJsonBody(req, BODY_MAX);
63
70
  let next: ModelConfig;
64
71
  try {
@@ -67,6 +74,7 @@ async function update(req: IncomingMessage, store: Store): Promise<unknown> {
67
74
  asApiError(err);
68
75
  }
69
76
  store.write(next);
77
+ syncAvailableModelsQuietly(deps.setup ?? {}, next);
70
78
  log.info({ provider: next.provider }, 'model-api: route updated');
71
79
  return settingsBody(next);
72
80
  }
@@ -146,6 +154,20 @@ const OPENROUTER_ROUTES: Record<string, Route> = {
146
154
  },
147
155
  };
148
156
 
157
+ const ANTHROPIC_ROUTES: Record<string, Route> = {
158
+ models: {
159
+ method: 'GET',
160
+ run: async (_req, deps, store) => ({ models: await anthropicModels(store.read().anthropic, deps.anthropicBase, deps.fetchImpl).catch(asApiError) }),
161
+ },
162
+ };
163
+
164
+ const BEDROCK_ROUTES: Record<string, Route> = {
165
+ models: {
166
+ method: 'GET',
167
+ run: async (_req, deps, store) => ({ models: await bedrockModels(store.read().bedrock, deps.bedrockControlBase, deps.fetchImpl).catch(asApiError) }),
168
+ },
169
+ };
170
+
149
171
  const named = (table: Record<string, Route>, name: string, method: string | undefined): Route | number => {
150
172
  const route = table[name];
151
173
  if (route === undefined) return 404;
@@ -154,7 +176,7 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
154
176
 
155
177
  function settingsRoute(method: string | undefined): Route | number {
156
178
  if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(settingsBody(store.read())) };
157
- if (method === 'PUT') return { method: 'POST', run: (req, _deps, store) => update(req, store) };
179
+ if (method === 'PUT') return { method: 'POST', run: (req, deps, store) => update(req, store, deps) };
158
180
  return 405;
159
181
  }
160
182
 
@@ -165,11 +187,14 @@ function codexRoute(rest: string, method: string | undefined): Route | number {
165
187
  return method === 'GET' ? { method: 'GET', run: (_req, deps, store) => pollDevice(id, deps, store) } : 405;
166
188
  }
167
189
 
168
- const mine = (path: string): boolean => path === PATH || path.startsWith(CODEX) || path.startsWith(OPENROUTER);
190
+ const mine = (path: string): boolean =>
191
+ path === PATH || path.startsWith(CODEX) || path.startsWith(OPENROUTER) || path.startsWith(ANTHROPIC) || path.startsWith(BEDROCK);
169
192
 
170
193
  function routeFor(path: string, method: string | undefined): Route | number {
171
194
  if (path === PATH) return settingsRoute(method);
172
195
  if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
196
+ if (path.startsWith(ANTHROPIC)) return named(ANTHROPIC_ROUTES, path.slice(ANTHROPIC.length), method);
197
+ if (path.startsWith(BEDROCK)) return named(BEDROCK_ROUTES, path.slice(BEDROCK.length), method);
173
198
  return codexRoute(path.slice(CODEX.length), method);
174
199
  }
175
200
 
@@ -0,0 +1,65 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { GatewayError } from './forward.js';
3
+ import type { AnthropicSettings, BedrockSettings } from './model-config.js';
4
+
5
+ export const ANTHROPIC_API = 'https://api.anthropic.com';
6
+ const ANTHROPIC_VERSION = '2023-06-01';
7
+ const LIST_MAX = 1000;
8
+
9
+ export interface ProviderModel {
10
+ id: string;
11
+ name: string;
12
+ }
13
+
14
+ export const KNOWN_CLAUDE: ProviderModel[] = [
15
+ { id: 'claude-fable-5-1', name: 'Claude Fable 5.1' },
16
+ { id: 'claude-opus-5', name: 'Claude Opus 5' },
17
+ { id: 'claude-sonnet-5', name: 'Claude Sonnet 5' },
18
+ { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
19
+ ];
20
+
21
+ const str = (value: unknown): string => (typeof value === 'string' ? value : '');
22
+
23
+ export async function anthropicModels(settings: AnthropicSettings, base = ANTHROPIC_API, fetchImpl: typeof fetch = fetch): Promise<ProviderModel[]> {
24
+ if (settings.apiKey === '') return KNOWN_CLAUDE;
25
+ const res = await fetchImpl(`${base}/v1/models?limit=${String(LIST_MAX)}`, {
26
+ headers: { 'x-api-key': settings.apiKey, 'anthropic-version': ANTHROPIC_VERSION, accept: 'application/json' },
27
+ redirect: 'manual',
28
+ });
29
+ if (!res.ok) throw new GatewayError(res.status, 'api_error', `Anthropic would not list its models with the stored key (${String(res.status)})`);
30
+ const body: unknown = await res.json();
31
+ const data = isRecord(body) && Array.isArray(body.data) ? body.data : null;
32
+ if (data === null) throw new GatewayError(502, 'api_error', 'Anthropic answered with no model list');
33
+ return data
34
+ .flatMap((entry) => (isRecord(entry) && str(entry.id) !== '' ? [{ id: str(entry.id), name: str(entry.display_name) || str(entry.id) }] : []))
35
+ .sort((a, b) => a.id.localeCompare(b.id));
36
+ }
37
+
38
+ export const bedrockControlBase = (region: string): string => `https://bedrock.${region}.amazonaws.com`;
39
+
40
+ export async function bedrockModels(settings: BedrockSettings, base?: string, fetchImpl: typeof fetch = fetch): Promise<ProviderModel[]> {
41
+ if (settings.region === '') throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs a region before its models can be listed');
42
+ if (settings.apiKey === '') throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key before its models can be listed');
43
+ const res = await fetchImpl(`${base ?? bedrockControlBase(settings.region)}/inference-profiles?maxResults=${String(LIST_MAX)}`, {
44
+ headers: { authorization: `Bearer ${settings.apiKey}`, accept: 'application/json' },
45
+ redirect: 'manual',
46
+ });
47
+ if (!res.ok)
48
+ throw new GatewayError(
49
+ res.status,
50
+ 'api_error',
51
+ `Bedrock would not list its inference profiles (${String(res.status)}); the stored key may cover the runtime only, so type the model id instead`,
52
+ );
53
+ const body: unknown = await res.json();
54
+ const list = isRecord(body) && Array.isArray(body.inferenceProfileSummaries) ? body.inferenceProfileSummaries : null;
55
+ if (list === null) throw new GatewayError(502, 'api_error', 'Bedrock answered with no inference profile list');
56
+ return list
57
+ .flatMap((entry) => {
58
+ if (!isRecord(entry)) return [];
59
+ const id = str(entry.inferenceProfileId);
60
+ const status = str(entry.status);
61
+ if (id === '' || !id.includes('anthropic') || (status !== '' && status !== 'ACTIVE')) return [];
62
+ return [{ id, name: str(entry.inferenceProfileName) || id }];
63
+ })
64
+ .sort((a, b) => a.id.localeCompare(b.id));
65
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.108"
2
+ "version": "0.1.0-beta.109"
3
3
  }