@stage-labs/metro 0.1.0-beta.71 → 0.1.0-beta.73
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/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +22 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-settings.ts +117 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/model-api.ts +33 -10
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/terminal-ws.ts +18 -9
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex.ts +13 -4
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/gateway.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/openrouter.ts +31 -0
- package/runtime/runtime.json +1 -1
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.73",
|
|
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,7 +1,9 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from './log.js';
|
|
3
|
-
import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
|
|
3
|
+
import { apiFailure, apiSession, cors, readJsonBody, sendJson } from './api-http.js';
|
|
4
4
|
import { ApiError } from './api-error.js';
|
|
5
|
+
import { isRecord } from './is-record.js';
|
|
6
|
+
import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './claude-settings.js';
|
|
5
7
|
import {
|
|
6
8
|
claudeDir,
|
|
7
9
|
deleteClaudeSession,
|
|
@@ -13,6 +15,8 @@ import {
|
|
|
13
15
|
} from './claude-files.js';
|
|
14
16
|
|
|
15
17
|
const PREFIX = '/api/claude';
|
|
18
|
+
const BODY_MAX = SETTINGS_MAX + 4096;
|
|
19
|
+
const WRITABLE = new Set(['GET', 'DELETE', 'PUT']);
|
|
16
20
|
const PAGE = 100;
|
|
17
21
|
const PAGE_MAX = 500;
|
|
18
22
|
|
|
@@ -39,6 +43,7 @@ const COLLECTIONS: Record<string, Handler> = {
|
|
|
39
43
|
projects: (_query, dir) => ({ projects: listClaudeProjects(dir) }),
|
|
40
44
|
sessions: (query, dir) => ({ sessions: listClaudeSessions(projectOf(query), dir) }),
|
|
41
45
|
memory: (query, dir) => listMemory(projectOf(query), dir),
|
|
46
|
+
settings: (_query, dir) => ({ files: listClaudeSettings(dir) }),
|
|
42
47
|
};
|
|
43
48
|
|
|
44
49
|
const ITEMS: Record<string, Handler> = {
|
|
@@ -49,8 +54,19 @@ const ITEMS: Record<string, Handler> = {
|
|
|
49
54
|
memory: (query, dir, name) => ({ name, content: readMemoryFile(projectOf(query), name, dir) }),
|
|
50
55
|
};
|
|
51
56
|
|
|
57
|
+
const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/').filter(Boolean);
|
|
58
|
+
|
|
59
|
+
async function writeAnswer(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
|
|
60
|
+
const [head = '', item = ''] = parts(path);
|
|
61
|
+
if (head !== 'settings' || item === '') throw new ApiError('method not allowed', 405);
|
|
62
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
63
|
+
if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
|
|
64
|
+
const seenAt = 'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
|
|
65
|
+
return writeClaudeSettings(item, body.text, seenAt, dir);
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
function answer(method: string, path: string, query: URLSearchParams, dir: string): unknown {
|
|
53
|
-
const rest = path
|
|
69
|
+
const rest = parts(path);
|
|
54
70
|
const [head = '', item] = rest;
|
|
55
71
|
if (method === 'DELETE') {
|
|
56
72
|
if (rest.length !== 2 || head !== 'sessions') throw new ApiError('method not allowed', 405);
|
|
@@ -73,7 +89,7 @@ export function handleClaudeRequest(
|
|
|
73
89
|
res.writeHead(204, cors(req)).end();
|
|
74
90
|
return true;
|
|
75
91
|
}
|
|
76
|
-
if (
|
|
92
|
+
if (!WRITABLE.has(req.method ?? '')) {
|
|
77
93
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
78
94
|
return true;
|
|
79
95
|
}
|
|
@@ -81,7 +97,9 @@ export function handleClaudeRequest(
|
|
|
81
97
|
.then((session) => {
|
|
82
98
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
83
99
|
deps.authorize(session.subject);
|
|
84
|
-
|
|
100
|
+
const dir = (deps.dir ?? claudeDir)();
|
|
101
|
+
if (req.method === 'PUT') return writeAnswer(req, path, dir);
|
|
102
|
+
return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
|
|
85
103
|
})
|
|
86
104
|
.then((body) => {
|
|
87
105
|
sendJson(req, res, 200, body);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { ApiError } from './api-error.js';
|
|
4
|
+
import { claudeDir, listClaudeProjects } from './claude-files.js';
|
|
5
|
+
import { isRecord } from './is-record.js';
|
|
6
|
+
import { errMsg } from './log.js';
|
|
7
|
+
|
|
8
|
+
export const SETTINGS_MAX = 256 * 1024;
|
|
9
|
+
const USER_ID = 'user';
|
|
10
|
+
const LOCAL_SUFFIX = '.local';
|
|
11
|
+
const DEFAULT_MODE = 0o644;
|
|
12
|
+
|
|
13
|
+
export type SettingsScope = 'user' | 'project' | 'local';
|
|
14
|
+
|
|
15
|
+
export interface ClaudeSettingsFile {
|
|
16
|
+
id: string;
|
|
17
|
+
scope: SettingsScope;
|
|
18
|
+
label: string;
|
|
19
|
+
path: string;
|
|
20
|
+
exists: boolean;
|
|
21
|
+
editable: boolean;
|
|
22
|
+
text: string;
|
|
23
|
+
modifiedAt: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const missing = (id: string, scope: SettingsScope, label: string, path: string): ClaudeSettingsFile => ({
|
|
27
|
+
id,
|
|
28
|
+
scope,
|
|
29
|
+
label,
|
|
30
|
+
path,
|
|
31
|
+
exists: false,
|
|
32
|
+
editable: true,
|
|
33
|
+
text: '',
|
|
34
|
+
modifiedAt: null,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function entryOf(id: string, scope: SettingsScope, label: string, path: string): ClaudeSettingsFile {
|
|
38
|
+
if (!existsSync(path)) return missing(id, scope, label, path);
|
|
39
|
+
const stat = statSync(path);
|
|
40
|
+
const editable = stat.size <= SETTINGS_MAX;
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
scope,
|
|
44
|
+
label,
|
|
45
|
+
path,
|
|
46
|
+
exists: true,
|
|
47
|
+
editable,
|
|
48
|
+
text: editable ? readFileSync(path, 'utf8') : '',
|
|
49
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PROJECT_FILES: [string, SettingsScope, string][] = [
|
|
54
|
+
['', 'project', 'settings.json'],
|
|
55
|
+
[LOCAL_SUFFIX, 'local', 'settings.local.json'],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function projectEntries(dir: string): ClaudeSettingsFile[] {
|
|
59
|
+
const out: ClaudeSettingsFile[] = [];
|
|
60
|
+
for (const project of listClaudeProjects(dir)) {
|
|
61
|
+
const cwd = project.cwd;
|
|
62
|
+
if (cwd === null) continue;
|
|
63
|
+
for (const [suffix, scope, name] of PROJECT_FILES) {
|
|
64
|
+
const path = join(cwd, '.claude', name);
|
|
65
|
+
if (existsSync(path)) out.push(entryOf(`${project.id}${suffix}`, scope, cwd, path));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listClaudeSettings(dir = claudeDir()): ClaudeSettingsFile[] {
|
|
72
|
+
return [entryOf(USER_ID, 'user', 'This machine', join(dir, 'settings.json')), ...projectEntries(dir)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertSettingsJson(text: string): void {
|
|
76
|
+
if (text.length > SETTINGS_MAX) throw new ApiError('that is more text than a settings file may hold', 413);
|
|
77
|
+
let parsed: unknown;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(text);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new ApiError(`that is not valid JSON: ${errMsg(err)}`, 400);
|
|
82
|
+
}
|
|
83
|
+
if (!isRecord(parsed)) throw new ApiError('Claude Code settings must be a JSON object', 400);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function modeOf(path: string): number {
|
|
87
|
+
try {
|
|
88
|
+
return statSync(path).mode & 0o777;
|
|
89
|
+
} catch {
|
|
90
|
+
return DEFAULT_MODE;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeAtomic(path: string, text: string): void {
|
|
95
|
+
const mode = existsSync(path) ? modeOf(path) : DEFAULT_MODE;
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
97
|
+
const tmp = `${path}.metro-${String(process.pid)}`;
|
|
98
|
+
writeFileSync(tmp, text, { mode });
|
|
99
|
+
chmodSync(tmp, mode);
|
|
100
|
+
renameSync(tmp, path);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function writeClaudeSettings(
|
|
104
|
+
id: string,
|
|
105
|
+
text: string,
|
|
106
|
+
seenAt: string | null | undefined,
|
|
107
|
+
dir = claudeDir(),
|
|
108
|
+
): ClaudeSettingsFile {
|
|
109
|
+
const target = listClaudeSettings(dir).find((file) => file.id === id);
|
|
110
|
+
if (target === undefined) throw new ApiError('no such settings file', 404);
|
|
111
|
+
if (!target.editable) throw new ApiError('that settings file is too large to edit here', 409);
|
|
112
|
+
assertSettingsJson(text);
|
|
113
|
+
if (seenAt !== undefined && seenAt !== target.modifiedAt)
|
|
114
|
+
throw new ApiError('that file changed on disk since you opened it; reload it before saving', 409);
|
|
115
|
+
writeAtomic(target.path, text);
|
|
116
|
+
return entryOf(target.id, target.scope, target.label, target.path);
|
|
117
|
+
}
|
|
@@ -6,6 +6,7 @@ import { log } from './log.js';
|
|
|
6
6
|
import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from '../gateway/codex-auth.js';
|
|
7
7
|
import { beginDeviceLogin, pollDeviceLogin } from '../gateway/codex-device.js';
|
|
8
8
|
import { codexModels, currentTokens, freshCodexState } from '../gateway/codex.js';
|
|
9
|
+
import { openrouterModels } from '../gateway/openrouter.js';
|
|
9
10
|
import type { CodexTokens } from '../gateway/codex-auth.js';
|
|
10
11
|
import { GatewayError } from '../gateway/forward.js';
|
|
11
12
|
import {
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
|
|
21
22
|
const PATH = '/api/model';
|
|
22
23
|
const CODEX = '/api/model/codex/';
|
|
24
|
+
const OPENROUTER = '/api/model/openrouter/';
|
|
23
25
|
const BODY_MAX = 16 * 1024;
|
|
24
26
|
const DEVICE_PREFIX = 'device/';
|
|
25
27
|
const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
@@ -32,6 +34,7 @@ export interface ModelApiDeps {
|
|
|
32
34
|
fetchImpl?: typeof fetch;
|
|
33
35
|
codexHome?: string;
|
|
34
36
|
codexBase?: string;
|
|
37
|
+
openrouterBase?: string;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
interface Store {
|
|
@@ -129,23 +132,43 @@ const CODEX_ROUTES: Record<string, Route> = {
|
|
|
129
132
|
},
|
|
130
133
|
};
|
|
131
134
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
const OPENROUTER_ROUTES: Record<string, Route> = {
|
|
136
|
+
models: {
|
|
137
|
+
method: 'GET',
|
|
138
|
+
run: async (_req, deps) => ({ models: await openrouterModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const named = (table: Record<string, Route>, name: string, method: string | undefined): Route | number => {
|
|
143
|
+
const route = table[name];
|
|
144
|
+
if (route === undefined) return 404;
|
|
145
|
+
return route.method === method ? route : 405;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function settingsRoute(method: string | undefined): Route | number {
|
|
149
|
+
if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(publicModelConfig(store.read())) };
|
|
150
|
+
if (method === 'PUT') return { method: 'POST', run: (req, _deps, store) => update(req, store) };
|
|
151
|
+
return 405;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function codexRoute(rest: string, method: string | undefined): Route | number {
|
|
155
|
+
if (rest in CODEX_ROUTES) return named(CODEX_ROUTES, rest, method);
|
|
141
156
|
const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
|
|
142
157
|
if (!DEVICE_ID_RE.test(id)) return 404;
|
|
143
158
|
return method === 'GET' ? { method: 'GET', run: (_req, deps, store) => pollDevice(id, deps, store) } : 405;
|
|
144
159
|
}
|
|
145
160
|
|
|
161
|
+
const mine = (path: string): boolean => path === PATH || path.startsWith(CODEX) || path.startsWith(OPENROUTER);
|
|
162
|
+
|
|
163
|
+
function routeFor(path: string, method: string | undefined): Route | number {
|
|
164
|
+
if (path === PATH) return settingsRoute(method);
|
|
165
|
+
if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
|
|
166
|
+
return codexRoute(path.slice(CODEX.length), method);
|
|
167
|
+
}
|
|
168
|
+
|
|
146
169
|
export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
|
|
147
170
|
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
148
|
-
if (
|
|
171
|
+
if (!mine(path)) return false;
|
|
149
172
|
if (req.method === 'OPTIONS') {
|
|
150
173
|
res.writeHead(204, cors(req)).end();
|
|
151
174
|
return true;
|
|
@@ -34,12 +34,22 @@ function sizeTmuxWindow(command: string[], session: string, cols: number, rows:
|
|
|
34
34
|
if (command[0] !== 'tmux') return;
|
|
35
35
|
const child = spawn('tmux', resizeWindowArgs(session, cols, rows), { stdio: 'ignore' });
|
|
36
36
|
child.on('error', (err) => {
|
|
37
|
-
log.
|
|
37
|
+
log.warn({ err: errMsg(err) }, 'terminal: resize-window could not run');
|
|
38
|
+
});
|
|
39
|
+
child.on('exit', (code) => {
|
|
40
|
+
if (code !== 0) log.warn({ code, session, cols, rows }, 'terminal: tmux refused the window resize');
|
|
38
41
|
});
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
const dimension = (raw: unknown
|
|
42
|
-
typeof raw === 'number' && Number.isInteger(raw) && raw > 1 && raw <= MAX_DIMENSION ? raw :
|
|
44
|
+
const dimension = (raw: unknown): number | null =>
|
|
45
|
+
typeof raw === 'number' && Number.isInteger(raw) && raw > 1 && raw <= MAX_DIMENSION ? raw : null;
|
|
46
|
+
|
|
47
|
+
export function sizeFrom(control: unknown): { cols: number; rows: number } | null {
|
|
48
|
+
if (!isRecord(control)) return null;
|
|
49
|
+
const cols = dimension(control.cols);
|
|
50
|
+
const rows = dimension(control.rows);
|
|
51
|
+
return cols === null || rows === null ? null : { cols, rows };
|
|
52
|
+
}
|
|
43
53
|
|
|
44
54
|
function runTerminal(ws: WebSocket, command: string[], subject: string, session: string): void {
|
|
45
55
|
const terminal = new Bun.Terminal({
|
|
@@ -61,12 +71,11 @@ function runTerminal(ws: WebSocket, command: string[], subject: string, session:
|
|
|
61
71
|
return;
|
|
62
72
|
}
|
|
63
73
|
try {
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
sizeTmuxWindow(command, session, cols, rows);
|
|
74
|
+
const size = sizeFrom(JSON.parse(bytes.toString('utf8')));
|
|
75
|
+
if (size !== null) {
|
|
76
|
+
terminal.resize(size.cols, size.rows);
|
|
77
|
+
proc.kill('SIGWINCH');
|
|
78
|
+
sizeTmuxWindow(command, session, size.cols, size.rows);
|
|
70
79
|
}
|
|
71
80
|
} catch (err) {
|
|
72
81
|
log.warn({ err: errMsg(err) }, 'terminal: bad control frame');
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
3
|
import { arch, platform, release } from 'node:os';
|
|
4
4
|
import { isRecord } from '../daemon/is-record.js';
|
|
5
|
-
import { errMsg } from '../daemon/log.js';
|
|
5
|
+
import { errMsg, log } from '../daemon/log.js';
|
|
6
6
|
import { refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
|
|
7
7
|
import { assembleMessage, CodexEventTranslator, parseEvent, SseParser } from './codex-stream.js';
|
|
8
8
|
import { ToolNames, toResponsesRequest } from './codex-translate.js';
|
|
@@ -10,7 +10,8 @@ import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage,
|
|
|
10
10
|
import type { ModelConfig } from './model-config.js';
|
|
11
11
|
|
|
12
12
|
export const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
|
|
13
|
-
const CODEX_VERSION = '0.
|
|
13
|
+
const CODEX_VERSION = '0.153.4';
|
|
14
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/;
|
|
14
15
|
const PING_MS = 25_000;
|
|
15
16
|
const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
|
|
16
17
|
|
|
@@ -30,7 +31,15 @@ export const freshCodexState = (): CodexState => ({ refreshing: null, latest: nu
|
|
|
30
31
|
|
|
31
32
|
const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
|
|
32
33
|
|
|
33
|
-
export
|
|
34
|
+
export function codexVersion(): string {
|
|
35
|
+
const wanted = process.env.METRO_CODEX_VERSION?.trim() ?? '';
|
|
36
|
+
if (wanted === '') return CODEX_VERSION;
|
|
37
|
+
if (VERSION_RE.test(wanted)) return wanted;
|
|
38
|
+
log.warn({ value: wanted }, 'gateway: METRO_CODEX_VERSION is not a version like 0.153.4; using the built-in one');
|
|
39
|
+
return CODEX_VERSION;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const userAgent = (): string => `codex_cli_rs/${codexVersion()} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
|
|
34
43
|
|
|
35
44
|
function headersFor(tokens: CodexTokens, sessionId: string): Record<string, string> {
|
|
36
45
|
return {
|
|
@@ -192,7 +201,7 @@ export function codexCount(res: ServerResponse, body: Record<string, unknown>):
|
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
|
|
195
|
-
const res = await (deps.fetchImpl ?? fetch)(`${deps.base ?? CODEX_BASE}/models?client_version=${
|
|
204
|
+
const res = await (deps.fetchImpl ?? fetch)(`${deps.base ?? CODEX_BASE}/models?client_version=${codexVersion()}`, {
|
|
196
205
|
headers: { ...headersFor(tokens, randomUUID()), accept: 'application/json' },
|
|
197
206
|
redirect: 'manual',
|
|
198
207
|
});
|
|
@@ -12,11 +12,11 @@ import {
|
|
|
12
12
|
import { forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
|
|
13
13
|
import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, writeModelConfig, type ModelConfig, type Route } from './model-config.js';
|
|
14
14
|
import { codexCount, codexMessages, freshCodexState, type CodexDeps } from './codex.js';
|
|
15
|
+
import { OPENROUTER_BASE } from './openrouter.js';
|
|
15
16
|
import type { CodexTokens } from './codex-auth.js';
|
|
16
17
|
|
|
17
18
|
export const GATEWAY_PREFIX = '/gateway';
|
|
18
19
|
export const ANTHROPIC_BASE = 'https://api.anthropic.com';
|
|
19
|
-
export const OPENROUTER_BASE = 'https://openrouter.ai/api';
|
|
20
20
|
const MESSAGES = '/v1/messages';
|
|
21
21
|
const COUNT = '/v1/messages/count_tokens';
|
|
22
22
|
const MODELS = '/v1/models';
|
|
@@ -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
|
+
}
|
package/runtime/runtime.json
CHANGED