@stage-labs/metro 0.1.0-beta.107 → 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 +2 -1
- package/dist/route.js +26 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +21 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +47 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +29 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +65 -0
- package/runtime/runtime.json +1 -1
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.
|
|
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.
|
|
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",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
@@ -7,6 +8,7 @@ import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
|
7
8
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
8
9
|
import { agentsDir, listAgentFiles } from '../agents/files.js';
|
|
9
10
|
import { notReady, readModelConfig } from '../gateway/model-config.js';
|
|
11
|
+
import { claudeDir, listClaudeProjects } from './files.js';
|
|
10
12
|
import { claudeAccount, claudeInstalled } from './login.js';
|
|
11
13
|
import { trustFolder } from './onboarding.js';
|
|
12
14
|
|
|
@@ -28,6 +30,7 @@ export interface SessionDeps {
|
|
|
28
30
|
signedIn?: () => boolean;
|
|
29
31
|
now?: () => number;
|
|
30
32
|
version?: string;
|
|
33
|
+
continues?: (home: string) => boolean;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export interface SessionStatus {
|
|
@@ -77,10 +80,24 @@ function tmuxOk(tmux: string, args: string[]): boolean {
|
|
|
77
80
|
|
|
78
81
|
export const sessionRunning = (tmux = 'tmux'): boolean => tmuxOk(tmux, ['has-session', '-t', SESSION_NAME]);
|
|
79
82
|
|
|
80
|
-
function
|
|
81
|
-
|
|
83
|
+
function realDir(dir: string): string {
|
|
84
|
+
try {
|
|
85
|
+
return realpathSync(dir);
|
|
86
|
+
} catch {
|
|
87
|
+
return dir;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function hasConversation(home: string, dir = claudeDir()): boolean {
|
|
92
|
+
const cwd = realDir(home);
|
|
93
|
+
return listClaudeProjects(dir).some((project) => project.sessions > 0 && project.cwd !== null && realDir(project.cwd) === cwd);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function metroCommand(deps: SessionDeps, home: string): string[] {
|
|
82
97
|
const bin = process.env.METRO_CLI_BIN?.trim() ?? '';
|
|
83
|
-
|
|
98
|
+
const base = deps.metro ?? (bin === '' ? ['metro', 'claude'] : [process.execPath, bin, 'claude']);
|
|
99
|
+
const continues = (deps.continues ?? hasConversation)(home);
|
|
100
|
+
return continues ? [...base, '-c'] : base;
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
function credentialReady(deps: SessionDeps): string | null {
|
|
@@ -138,7 +155,7 @@ export function startSession(deps: SessionDeps = {}): SessionStatus {
|
|
|
138
155
|
const home = deps.home ?? homedir();
|
|
139
156
|
const now = (deps.now ?? Date.now)();
|
|
140
157
|
const trusted = trustFolder(home);
|
|
141
|
-
const [command = 'metro', ...args] = metroCommand(deps);
|
|
158
|
+
const [command = 'metro', ...args] = metroCommand(deps, home);
|
|
142
159
|
const run = spawnSync(tmux, ['new-session', '-d', '-s', SESSION_NAME, '-c', home, '-x', '200', '-y', '50', command, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: home });
|
|
143
160
|
recordStart(deps, tmux, now, run);
|
|
144
161
|
if (memory.lastError === null) log.info({ home, trusted, command: [command, ...args].join(' ') }, 'claude-session: started Claude Code in tmux');
|
|
@@ -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,
|
|
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 =>
|
|
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
|
+
}
|
package/runtime/runtime.json
CHANGED