@stage-labs/metro 0.1.0-beta.70 → 0.1.0-beta.72

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 (25) hide show
  1. package/dist/bedrock.js +4 -73
  2. package/dist/claude-settings.js +40 -0
  3. package/dist/claude.js +101 -9
  4. package/dist/hold.js +20 -6
  5. package/dist/provider-flags.js +9 -0
  6. package/package.json +1 -1
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +22 -4
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-settings.ts +117 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +9 -3
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +3 -0
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/model-api.ts +192 -0
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +5 -0
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/terminal-ws.ts +18 -9
  14. package/runtime/node_modules/@metro-labs/mcp/src/gateway/bedrock.ts +252 -0
  15. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-auth.ts +231 -0
  16. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-device.ts +91 -0
  17. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-stream.ts +288 -0
  18. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-translate.ts +203 -0
  19. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex.ts +211 -0
  20. package/runtime/node_modules/@metro-labs/mcp/src/gateway/eventstream.ts +123 -0
  21. package/runtime/node_modules/@metro-labs/mcp/src/gateway/forward.ts +169 -0
  22. package/runtime/node_modules/@metro-labs/mcp/src/gateway/gateway.ts +193 -0
  23. package/runtime/node_modules/@metro-labs/mcp/src/gateway/model-config.ts +180 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/gateway/openrouter.ts +31 -0
  25. package/runtime/runtime.json +1 -1
@@ -0,0 +1,169 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+
3
+ export class GatewayError extends Error {
4
+ constructor(
5
+ readonly status: number,
6
+ readonly kind: string,
7
+ message: string,
8
+ ) {
9
+ super(message);
10
+ }
11
+ }
12
+
13
+ export const BODY_MAX = 64 * 1024 * 1024;
14
+ const DRAIN_FACTOR = 2;
15
+ const PING_MS = 25_000;
16
+ const IDLE_MS = 300_000;
17
+ const HOP_BY_HOP = new Set([
18
+ 'host',
19
+ 'connection',
20
+ 'content-length',
21
+ 'transfer-encoding',
22
+ 'keep-alive',
23
+ 'proxy-authorization',
24
+ 'te',
25
+ 'trailer',
26
+ 'upgrade',
27
+ 'accept-encoding',
28
+ 'x-metro-key',
29
+ ]);
30
+ const RESPONSE_DROP = new Set(['content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive']);
31
+
32
+ export function sendError(res: ServerResponse, status: number, kind: string, message: string): void {
33
+ res.writeHead(status, { 'content-type': 'application/json' });
34
+ res.end(JSON.stringify({ type: 'error', error: { type: kind, message } }));
35
+ }
36
+
37
+ export const errorFrame = (kind: string, message: string): string => `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: kind, message } })}\n\n`;
38
+
39
+ export const providerStatus = (status: number): number => (status === 401 ? 403 : status);
40
+
41
+ export const idleMs = (): number => Number(process.env.METRO_GATEWAY_IDLE_MS) || IDLE_MS;
42
+
43
+ export const idleMessage = (ms: number): string => `metro gateway: the provider sent nothing for ${String(Math.round(ms / 1000))}s; giving up on this request`;
44
+
45
+ export interface Watch {
46
+ signal: AbortSignal;
47
+ ms: number;
48
+ touch: () => void;
49
+ idle: () => boolean;
50
+ stop: () => void;
51
+ }
52
+
53
+ export function watchUpstream(res: ServerResponse, ms = idleMs()): Watch {
54
+ const control = new AbortController();
55
+ let expired = false;
56
+ let timer: NodeJS.Timeout | null = null;
57
+ const stop = (): void => {
58
+ if (timer !== null) clearTimeout(timer);
59
+ timer = null;
60
+ };
61
+ const arm = (): void => {
62
+ stop();
63
+ timer = setTimeout(() => {
64
+ expired = true;
65
+ control.abort();
66
+ }, ms);
67
+ timer.unref();
68
+ };
69
+ res.on('close', () => {
70
+ stop();
71
+ if (!res.writableEnded) control.abort();
72
+ });
73
+ arm();
74
+ return { signal: control.signal, ms, touch: arm, idle: () => expired, stop };
75
+ }
76
+
77
+ export async function readBody(req: IncomingMessage): Promise<Buffer> {
78
+ const chunks: Buffer[] = [];
79
+ let total = 0;
80
+ let over = false;
81
+ for await (const chunk of req) {
82
+ const buf = chunk as Buffer;
83
+ total += buf.length;
84
+ if (total > BODY_MAX) {
85
+ over = true;
86
+ if (total > BODY_MAX * DRAIN_FACTOR) break;
87
+ continue;
88
+ }
89
+ chunks.push(buf);
90
+ }
91
+ if (over) throw new GatewayError(413, 'invalid_request_error', 'request too large');
92
+ return Buffer.concat(chunks);
93
+ }
94
+
95
+ export function parseJson(raw: Buffer): Record<string, unknown> {
96
+ let parsed: unknown;
97
+ try {
98
+ parsed = JSON.parse(raw.toString('utf8'));
99
+ } catch {
100
+ throw new GatewayError(400, 'invalid_request_error', 'body must be JSON');
101
+ }
102
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
103
+ throw new GatewayError(400, 'invalid_request_error', 'body must be a JSON object');
104
+ return parsed as Record<string, unknown>;
105
+ }
106
+
107
+ export function forwardedHeaders(req: IncomingMessage): Record<string, string> {
108
+ const out: Record<string, string> = {};
109
+ for (const [name, value] of Object.entries(req.headers)) {
110
+ if (HOP_BY_HOP.has(name) || value === undefined) continue;
111
+ out[name] = Array.isArray(value) ? value.join(', ') : value;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ export interface PipeOptions {
117
+ keepalive?: boolean;
118
+ ownCredential?: boolean;
119
+ }
120
+
121
+ function openResponse(upstream: Response, res: ServerResponse, opts: PipeOptions): boolean {
122
+ const headers: Record<string, string> = {};
123
+ upstream.headers.forEach((value, name) => {
124
+ if (!RESPONSE_DROP.has(name)) headers[name] = value;
125
+ });
126
+ res.writeHead(opts.ownCredential === true ? providerStatus(upstream.status) : upstream.status, headers);
127
+ return (upstream.headers.get('content-type') ?? '').startsWith('text/event-stream');
128
+ }
129
+
130
+ async function pump(body: ReadableStream<Uint8Array>, res: ServerResponse, watch: Watch): Promise<void> {
131
+ const reader = body.getReader();
132
+ for (;;) {
133
+ const { done, value } = await reader.read();
134
+ if (done) return;
135
+ watch.touch();
136
+ res.write(value);
137
+ }
138
+ }
139
+
140
+ export async function pipeResponse(upstream: Response, res: ServerResponse, watch: Watch, opts: PipeOptions = {}): Promise<void> {
141
+ const streaming = openResponse(upstream, res, opts);
142
+ const body = upstream.body;
143
+ if (body === null) {
144
+ res.end();
145
+ return;
146
+ }
147
+ const ping = opts.keepalive === true && streaming ? setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS) : null;
148
+ try {
149
+ await pump(body, res, watch);
150
+ } catch (err) {
151
+ if (!watch.idle()) throw err;
152
+ if (streaming) res.write(errorFrame('api_error', idleMessage(watch.ms)));
153
+ } finally {
154
+ if (ping !== null) clearInterval(ping);
155
+ watch.stop();
156
+ res.end();
157
+ }
158
+ }
159
+
160
+ export function upstreamMessage(text: string, fallback: string): string {
161
+ if (text === '') return fallback;
162
+ try {
163
+ const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown; error?: { message?: unknown } };
164
+ const message = parsed.message ?? parsed.Message ?? parsed.error?.message;
165
+ return typeof message === 'string' && message !== '' ? message : text;
166
+ } catch {
167
+ return text;
168
+ }
169
+ }
@@ -0,0 +1,193 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { errMsg, log } from '../daemon/log.js';
3
+ import { agentIdForKey } from '../db/key-map.js';
4
+ import {
5
+ assertBedrockReady,
6
+ bedrockBase,
7
+ bedrockCount,
8
+ bedrockMessages,
9
+ freshAdaptations,
10
+ type Adaptations,
11
+ } from './bedrock.js';
12
+ import { forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
13
+ import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, writeModelConfig, type ModelConfig, type Route } from './model-config.js';
14
+ import { codexCount, codexMessages, freshCodexState, type CodexDeps } from './codex.js';
15
+ import { OPENROUTER_BASE } from './openrouter.js';
16
+ import type { CodexTokens } from './codex-auth.js';
17
+
18
+ export const GATEWAY_PREFIX = '/gateway';
19
+ export const ANTHROPIC_BASE = 'https://api.anthropic.com';
20
+ const MESSAGES = '/v1/messages';
21
+ const COUNT = '/v1/messages/count_tokens';
22
+ const MODELS = '/v1/models';
23
+ const HELLO = '/api/hello';
24
+
25
+ export interface GatewayDeps {
26
+ config: () => ModelConfig;
27
+ identify?: (key: string) => boolean;
28
+ anthropicBase?: string;
29
+ bedrockBase?: string;
30
+ openrouterBase?: string;
31
+ codex?: Partial<CodexDeps>;
32
+ }
33
+
34
+ const learned: Adaptations = freshAdaptations();
35
+ const codexState = freshCodexState();
36
+
37
+ export function resetGatewayState(): void {
38
+ learned.fields.clear();
39
+ learned.dropBetas = false;
40
+ Object.assign(codexState, freshCodexState());
41
+ }
42
+
43
+ const saveCodexTokens = (tokens: CodexTokens): void => {
44
+ writeModelConfig(setCodexAuth(readModelConfig(), tokens));
45
+ };
46
+
47
+ const keyOf = (req: IncomingMessage): string => {
48
+ const raw = req.headers['x-metro-key'];
49
+ return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
50
+ };
51
+
52
+ const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
53
+
54
+ function modelsBody(cfg: ModelConfig): Record<string, unknown> {
55
+ const data: Record<string, string>[] = [];
56
+ if (cfg.bedrock.model !== '')
57
+ data.push({ id: `bedrock:${cfg.bedrock.model}`, display_name: `Bedrock · ${cfg.bedrock.model}`, description: 'Through metro, billed to Amazon Bedrock' });
58
+ if (cfg.openrouter.model !== '')
59
+ data.push({ id: `openrouter:${cfg.openrouter.model}`, display_name: `OpenRouter · ${cfg.openrouter.model}`, description: 'Through metro, billed to OpenRouter' });
60
+ if (cfg.codex.model !== '')
61
+ data.push({ id: `codex:${cfg.codex.model}`, display_name: `Codex · ${cfg.codex.model}`, description: 'Through metro, on your ChatGPT subscription' });
62
+ return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
63
+ }
64
+
65
+ function requestedModel(body: Record<string, unknown>): string {
66
+ const model = body.model;
67
+ if (typeof model !== 'string' || model === '') throw new GatewayError(400, 'invalid_request_error', 'model is required');
68
+ return model;
69
+ }
70
+
71
+ async function toAnthropic(
72
+ req: IncomingMessage,
73
+ res: ServerResponse,
74
+ raw: Buffer,
75
+ body: Record<string, unknown>,
76
+ route: Route,
77
+ deps: GatewayDeps,
78
+ ): Promise<void> {
79
+ const explicit = typeof body.model === 'string' && body.model !== route.model;
80
+ const payload = explicit ? Buffer.from(JSON.stringify({ ...body, model: route.model })) : raw;
81
+ const url = `${deps.anthropicBase ?? ANTHROPIC_BASE}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
82
+ const watch = watchUpstream(res);
83
+ const upstream = await fetch(url, {
84
+ method: 'POST',
85
+ headers: forwardedHeaders(req),
86
+ body: new Uint8Array(payload),
87
+ signal: watch.signal,
88
+ redirect: 'manual',
89
+ });
90
+ await pipeResponse(upstream, res, watch);
91
+ }
92
+
93
+ async function toOpenRouter(
94
+ req: IncomingMessage,
95
+ res: ServerResponse,
96
+ body: Record<string, unknown>,
97
+ route: Route,
98
+ cfg: ModelConfig,
99
+ deps: GatewayDeps,
100
+ ): Promise<void> {
101
+ const reason = notReady(cfg, 'openrouter');
102
+ if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
103
+ const headers: Record<string, string> = {
104
+ 'content-type': 'application/json',
105
+ accept: req.headers.accept ?? 'application/json',
106
+ authorization: `Bearer ${cfg.openrouter.apiKey}`,
107
+ 'anthropic-version': typeof req.headers['anthropic-version'] === 'string' ? req.headers['anthropic-version'] : '2023-06-01',
108
+ 'http-referer': 'https://metro.box',
109
+ 'x-title': 'metro',
110
+ };
111
+ const beta = req.headers['anthropic-beta'];
112
+ if (typeof beta === 'string' && beta !== '') headers['anthropic-beta'] = beta;
113
+ const watch = watchUpstream(res);
114
+ const upstream = await fetch(`${deps.openrouterBase ?? OPENROUTER_BASE}${MESSAGES}`, {
115
+ method: 'POST',
116
+ headers,
117
+ body: JSON.stringify({ ...body, model: route.model }),
118
+ signal: watch.signal,
119
+ redirect: 'manual',
120
+ });
121
+ await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true });
122
+ }
123
+
124
+ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
125
+ const cfg = deps.config();
126
+ const raw = await readBody(req);
127
+ const body = parseJson(raw);
128
+ const route = resolveRoute(requestedModel(body), cfg);
129
+ log.info({ route: routeLabel(route), path }, 'gateway: routing');
130
+ if (route.provider === 'bedrock') {
131
+ assertBedrockReady(cfg.bedrock);
132
+ const up = { settings: cfg.bedrock, base: deps.bedrockBase ?? bedrockBase(cfg.bedrock.region), learned, watch: watchUpstream(res) };
133
+ if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
134
+ else await bedrockMessages(req, res, body, route.model, up);
135
+ return;
136
+ }
137
+ if (route.provider === 'openrouter') {
138
+ if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
139
+ await toOpenRouter(req, res, body, route, cfg, deps);
140
+ return;
141
+ }
142
+ if (route.provider === 'codex') {
143
+ if (path === COUNT) {
144
+ codexCount(res, body);
145
+ return;
146
+ }
147
+ const codexDeps: CodexDeps = { save: saveCodexTokens, ...deps.codex };
148
+ await codexMessages(req, res, body, route.model, cfg, codexDeps, codexState, watchUpstream(res));
149
+ return;
150
+ }
151
+ await toAnthropic(req, res, raw, body, route, deps);
152
+ }
153
+
154
+ function failed(res: ServerResponse, err: unknown): void {
155
+ if (res.headersSent) {
156
+ res.end();
157
+ return;
158
+ }
159
+ if (err instanceof GatewayError) {
160
+ sendError(res, err.status, err.kind, err.message);
161
+ return;
162
+ }
163
+ log.warn({ err: errMsg(err) }, 'gateway: request failed');
164
+ sendError(res, 502, 'api_error', `metro gateway: ${errMsg(err)}`);
165
+ }
166
+
167
+ function answer(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): void {
168
+ if (req.method === 'GET' && path === MODELS) {
169
+ res.writeHead(200, { 'content-type': 'application/json' });
170
+ res.end(JSON.stringify(modelsBody(deps.config())));
171
+ return;
172
+ }
173
+ if (req.method === 'POST' && (path === MESSAGES || path === COUNT)) {
174
+ dispatch(req, res, path, deps).catch((err: unknown) => {
175
+ failed(res, err);
176
+ });
177
+ return;
178
+ }
179
+ sendError(res, 404, 'not_found_error', 'not found');
180
+ }
181
+
182
+ export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse, deps: GatewayDeps): boolean {
183
+ const full = req.url ?? '';
184
+ if (full !== GATEWAY_PREFIX && !full.startsWith(`${GATEWAY_PREFIX}/`)) return false;
185
+ const path = (full.split('?')[0] ?? '').slice(GATEWAY_PREFIX.length);
186
+ if (req.method === 'HEAD' && path === HELLO) {
187
+ res.writeHead(200).end();
188
+ return true;
189
+ }
190
+ if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
191
+ else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start Claude Code with metro claude');
192
+ return true;
193
+ }
@@ -0,0 +1,180 @@
1
+ import { join } from 'node:path';
2
+ import { readJson, writeSecure } from '../daemon/secure-fs.js';
3
+ import { agentsDir } from '../db/file-source.js';
4
+ import { isRecord } from '../daemon/is-record.js';
5
+ import type { CodexTokens } from './codex-auth.js';
6
+
7
+ export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
8
+ export type Provider = (typeof PROVIDERS)[number];
9
+
10
+ export interface BedrockSettings {
11
+ region: string;
12
+ apiKey: string;
13
+ model: string;
14
+ }
15
+
16
+ export interface OpenRouterSettings {
17
+ apiKey: string;
18
+ model: string;
19
+ }
20
+
21
+ export interface CodexSettings {
22
+ model: string;
23
+ auth: CodexTokens | null;
24
+ }
25
+
26
+ export interface ModelConfig {
27
+ version: 1;
28
+ provider: Provider;
29
+ bedrock: BedrockSettings;
30
+ openrouter: OpenRouterSettings;
31
+ codex: CodexSettings;
32
+ }
33
+
34
+ export interface Route {
35
+ provider: Provider;
36
+ model: string;
37
+ }
38
+
39
+ export class ModelConfigError extends Error {}
40
+
41
+ export const MODEL_FILE = 'model.json';
42
+ const MAX_FIELD = 512;
43
+ const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
44
+
45
+ const empty = (): ModelConfig => ({
46
+ version: 1,
47
+ provider: 'anthropic',
48
+ bedrock: { region: '', apiKey: '', model: '' },
49
+ openrouter: { apiKey: '', model: '' },
50
+ codex: { model: '', auth: null },
51
+ });
52
+
53
+ const isProvider = (value: unknown): value is Provider =>
54
+ typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
55
+
56
+ const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
57
+ const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
58
+
59
+ function tokensFromDisk(raw: unknown): CodexTokens | null {
60
+ if (!isRecord(raw)) return null;
61
+ const accessToken = text(raw.accessToken);
62
+ const refreshToken = text(raw.refreshToken);
63
+ const accountId = text(raw.accountId);
64
+ if (accessToken === '' || refreshToken === '' || accountId === '') return null;
65
+ return {
66
+ accessToken,
67
+ refreshToken,
68
+ idToken: text(raw.idToken),
69
+ accountId,
70
+ email: maybe(raw.email),
71
+ plan: maybe(raw.plan),
72
+ savedAt: text(raw.savedAt) === '' ? new Date(0).toISOString() : text(raw.savedAt),
73
+ };
74
+ }
75
+
76
+ function fromDisk(raw: unknown): ModelConfig {
77
+ const base = empty();
78
+ if (!isRecord(raw)) return base;
79
+ const bedrock = isRecord(raw.bedrock) ? raw.bedrock : {};
80
+ const openrouter = isRecord(raw.openrouter) ? raw.openrouter : {};
81
+ const codex = isRecord(raw.codex) ? raw.codex : {};
82
+ return {
83
+ version: 1,
84
+ provider: isProvider(raw.provider) ? raw.provider : 'anthropic',
85
+ bedrock: { region: text(bedrock.region), apiKey: text(bedrock.apiKey), model: text(bedrock.model) },
86
+ openrouter: { apiKey: text(openrouter.apiKey), model: text(openrouter.model) },
87
+ codex: { model: text(codex.model), auth: tokensFromDisk(codex.auth) },
88
+ };
89
+ }
90
+
91
+ export function readModelConfig(dir = agentsDir()): ModelConfig {
92
+ return fromDisk(readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' }));
93
+ }
94
+
95
+ export function writeModelConfig(cfg: ModelConfig, dir = agentsDir()): void {
96
+ writeSecure(join(dir, MODEL_FILE), JSON.stringify(cfg, null, 2));
97
+ }
98
+
99
+ function field(patch: Record<string, unknown>, key: string, current: string, what: string): string {
100
+ if (!(key in patch)) return current;
101
+ const value = patch[key];
102
+ if (typeof value !== 'string') throw new ModelConfigError(`${what} must be a string`);
103
+ if (value.length > MAX_FIELD) throw new ModelConfigError(`${what} is too long`);
104
+ return value.trim();
105
+ }
106
+
107
+ export function applyModelUpdate(cfg: ModelConfig, patch: unknown): ModelConfig {
108
+ if (!isRecord(patch)) throw new ModelConfigError('body must be a JSON object');
109
+ const provider = 'provider' in patch ? patch.provider : cfg.provider;
110
+ if (!isProvider(provider)) throw new ModelConfigError(`provider must be one of ${PROVIDERS.join(', ')}`);
111
+ const bedrock = isRecord(patch.bedrock) ? patch.bedrock : {};
112
+ const openrouter = isRecord(patch.openrouter) ? patch.openrouter : {};
113
+ const codex = isRecord(patch.codex) ? patch.codex : {};
114
+ return {
115
+ version: 1,
116
+ provider,
117
+ bedrock: {
118
+ region: field(bedrock, 'region', cfg.bedrock.region, 'Bedrock region'),
119
+ apiKey: field(bedrock, 'apiKey', cfg.bedrock.apiKey, 'Bedrock API key'),
120
+ model: field(bedrock, 'model', cfg.bedrock.model, 'Bedrock model'),
121
+ },
122
+ openrouter: {
123
+ apiKey: field(openrouter, 'apiKey', cfg.openrouter.apiKey, 'OpenRouter API key'),
124
+ model: field(openrouter, 'model', cfg.openrouter.model, 'OpenRouter model'),
125
+ },
126
+ codex: { model: field(codex, 'model', cfg.codex.model, 'Codex model'), auth: cfg.codex.auth },
127
+ };
128
+ }
129
+
130
+ export const setCodexAuth = (cfg: ModelConfig, auth: CodexTokens | null): ModelConfig => ({ ...cfg, codex: { ...cfg.codex, auth } });
131
+
132
+ const CHECKS: Record<Provider, [(cfg: ModelConfig) => boolean, string][]> = {
133
+ anthropic: [],
134
+ bedrock: [
135
+ [(cfg) => cfg.bedrock.apiKey === '', 'Bedrock needs an API key: add it on the Model page.'],
136
+ [(cfg) => cfg.bedrock.region === '', 'Bedrock needs a region: add it on the Model page.'],
137
+ ],
138
+ openrouter: [
139
+ [(cfg) => cfg.openrouter.apiKey === '', 'OpenRouter needs an API key: add it on the Model page.'],
140
+ [(cfg) => cfg.openrouter.model === '', 'OpenRouter needs a model id: choose one on the Model page.'],
141
+ ],
142
+ codex: [
143
+ [(cfg) => cfg.codex.auth === null, 'Codex is not connected: sign in with ChatGPT on the Model page.'],
144
+ [(cfg) => cfg.codex.model === '', 'Codex needs a model id: choose one on the Model page.'],
145
+ ],
146
+ };
147
+
148
+ export function notReady(cfg: ModelConfig, provider: Provider = cfg.provider): string | null {
149
+ return CHECKS[provider].find(([missing]) => missing(cfg))?.[1] ?? null;
150
+ }
151
+
152
+ export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
153
+ const auth = cfg.codex.auth;
154
+ return {
155
+ provider: cfg.provider,
156
+ ready: notReady(cfg) === null,
157
+ reason: notReady(cfg),
158
+ bedrock: { region: cfg.bedrock.region, model: cfg.bedrock.model, hasKey: cfg.bedrock.apiKey !== '' },
159
+ openrouter: { model: cfg.openrouter.model, hasKey: cfg.openrouter.apiKey !== '' },
160
+ codex: { model: cfg.codex.model, signedIn: auth !== null, account: auth?.email ?? null, plan: auth?.plan ?? null },
161
+ };
162
+ }
163
+
164
+ function defaultModelFor(provider: Provider, requested: string, cfg: ModelConfig): string {
165
+ if (provider === 'openrouter') return requested.includes('/') ? requested : cfg.openrouter.model;
166
+ if (provider === 'bedrock') return cfg.bedrock.model === '' ? requested : cfg.bedrock.model;
167
+ if (provider === 'codex') return requested.startsWith('gpt-') ? requested : cfg.codex.model;
168
+ return requested;
169
+ }
170
+
171
+ export function resolveRoute(requested: string, cfg: ModelConfig): Route {
172
+ const explicit = PREFIX_RE.exec(requested);
173
+ if (explicit !== null && isProvider(explicit[1]))
174
+ return { provider: explicit[1], model: (explicit[2] ?? '').trim() };
175
+ return { provider: cfg.provider, model: defaultModelFor(cfg.provider, requested, cfg) };
176
+ }
177
+
178
+ export function routeLabel(route: Route): string {
179
+ return route.provider === 'anthropic' ? route.model : `${route.provider}:${route.model}`;
180
+ }
@@ -0,0 +1,31 @@
1
+ import { GatewayError } from './forward.js';
2
+
3
+ export const OPENROUTER_BASE = 'https://openrouter.ai/api';
4
+ const MODELS_MAX = 2000;
5
+
6
+ export interface OpenRouterModel {
7
+ id: string;
8
+ name: string;
9
+ }
10
+
11
+ const str = (value: unknown): string => (typeof value === 'string' ? value : '');
12
+
13
+ function modelOf(entry: unknown): OpenRouterModel | null {
14
+ if (typeof entry !== 'object' || entry === null) return null;
15
+ const row = entry as { id?: unknown; name?: unknown };
16
+ const id = str(row.id);
17
+ return id === '' ? null : { id, name: str(row.name) || id };
18
+ }
19
+
20
+ export async function openrouterModels(base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<OpenRouterModel[]> {
21
+ const res = await fetchImpl(`${base}/v1/models`, { headers: { accept: 'application/json' }, redirect: 'manual' });
22
+ if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not list its models (${String(res.status)})`);
23
+ const body: unknown = await res.json();
24
+ const data = typeof body === 'object' && body !== null ? (body as { data?: unknown }).data : undefined;
25
+ if (!Array.isArray(data)) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no model list');
26
+ return data
27
+ .map(modelOf)
28
+ .filter((model): model is OpenRouterModel => model !== null)
29
+ .slice(0, MODELS_MAX)
30
+ .sort((a, b) => a.id.localeCompare(b.id));
31
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.70"
2
+ "version": "0.1.0-beta.72"
3
3
  }