@stage-labs/metro 0.1.0-beta.70 → 0.1.0-beta.71
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/bedrock.js +4 -73
- package/dist/claude-settings.js +40 -0
- package/dist/claude.js +101 -9
- package/dist/hold.js +20 -6
- package/dist/provider-flags.js +9 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +9 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +3 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/model-api.ts +169 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +5 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/bedrock.ts +252 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-auth.ts +231 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-device.ts +91 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-stream.ts +288 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-translate.ts +203 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex.ts +211 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/eventstream.ts +123 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/forward.ts +169 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/gateway.ts +193 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/model-config.ts +180 -0
- package/runtime/runtime.json +1 -1
package/dist/bedrock.js
CHANGED
|
@@ -1,63 +1,10 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { claudeArgs } from './claude.js';
|
|
2
|
+
import { claudeArgs, runClaude } from './claude.js';
|
|
7
3
|
import { bedrockConfigFromEnv, startBedrockProxy } from './bedrock-proxy.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
'CLAUDE_CODE_USE_VERTEX',
|
|
12
|
-
'CLAUDE_CODE_USE_FOUNDRY',
|
|
13
|
-
'CLAUDE_CODE_USE_ANTHROPIC_AWS',
|
|
14
|
-
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
|
|
15
|
-
'CLAUDE_CODE_USE_GATEWAY',
|
|
16
|
-
];
|
|
17
|
-
const CONFLICTING = [
|
|
18
|
-
...PROVIDER_FLAGS,
|
|
19
|
-
'ANTHROPIC_BASE_URL',
|
|
20
|
-
'ANTHROPIC_API_KEY',
|
|
21
|
-
'ANTHROPIC_AUTH_TOKEN',
|
|
22
|
-
];
|
|
4
|
+
import { settingsConflicts, settingsFiles } from './claude-settings.js';
|
|
5
|
+
import { PROVIDER_FLAGS } from './provider-flags.js';
|
|
6
|
+
export { PROVIDER_FLAGS, settingsConflicts, settingsFiles };
|
|
23
7
|
const SCRUBBED = [...PROVIDER_FLAGS, 'ANTHROPIC_API_KEY', 'AWS_BEARER_TOKEN_BEDROCK'];
|
|
24
|
-
export function settingsFiles(cwd = process.cwd(), env = process.env) {
|
|
25
|
-
const explicit = env.CLAUDE_CONFIG_DIR?.trim() ?? '';
|
|
26
|
-
const configDir = explicit === '' ? join(homedir(), '.claude') : explicit;
|
|
27
|
-
return [
|
|
28
|
-
...new Set([
|
|
29
|
-
join(configDir, 'settings.json'),
|
|
30
|
-
join(cwd, '.claude', 'settings.json'),
|
|
31
|
-
join(cwd, '.claude', 'settings.local.json'),
|
|
32
|
-
]),
|
|
33
|
-
];
|
|
34
|
-
}
|
|
35
|
-
function envBlock(file) {
|
|
36
|
-
if (!existsSync(file))
|
|
37
|
-
return {};
|
|
38
|
-
try {
|
|
39
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
40
|
-
const block = parsed.env;
|
|
41
|
-
return typeof block === 'object' && block !== null
|
|
42
|
-
? block
|
|
43
|
-
: {};
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
return {};
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
export function settingsConflicts(files) {
|
|
50
|
-
const out = [];
|
|
51
|
-
for (const file of files) {
|
|
52
|
-
const block = envBlock(file);
|
|
53
|
-
for (const key of CONFLICTING) {
|
|
54
|
-
const value = block[key];
|
|
55
|
-
if (typeof value === 'string' && value.trim() !== '')
|
|
56
|
-
out.push(`${file}: ${key}`);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return out;
|
|
60
|
-
}
|
|
61
8
|
export function firstPartyModelId(bedrockId) {
|
|
62
9
|
return bedrockId.replace(/^(?:[a-z-]+\.)?anthropic\./, '').replace(/-v\d+:\d+$/, '');
|
|
63
10
|
}
|
|
@@ -71,22 +18,6 @@ export function claudeEnv(base, port, token, pinned = null) {
|
|
|
71
18
|
ANTHROPIC_AUTH_TOKEN: token,
|
|
72
19
|
};
|
|
73
20
|
}
|
|
74
|
-
function runClaude(args, env) {
|
|
75
|
-
return new Promise((resolve, reject) => {
|
|
76
|
-
const leaveToChild = () => undefined;
|
|
77
|
-
process.on('SIGINT', leaveToChild);
|
|
78
|
-
process.on('SIGTERM', leaveToChild);
|
|
79
|
-
const child = spawn('claude', args, { stdio: 'inherit', env });
|
|
80
|
-
child.on('error', (err) => {
|
|
81
|
-
reject(new Error(err.code === 'ENOENT'
|
|
82
|
-
? 'the `claude` command is not on PATH — install Claude Code first'
|
|
83
|
-
: err.message));
|
|
84
|
-
});
|
|
85
|
-
child.on('exit', (code) => {
|
|
86
|
-
resolve(code ?? 1);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
21
|
export async function bedrock(argv) {
|
|
91
22
|
const cfg = bedrockConfigFromEnv();
|
|
92
23
|
const conflicts = settingsConflicts(settingsFiles());
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { PROVIDER_FLAGS } from './provider-flags.js';
|
|
5
|
+
const CONFLICTING = [...PROVIDER_FLAGS, 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
|
|
6
|
+
export function settingsFiles(cwd = process.cwd(), env = process.env) {
|
|
7
|
+
const explicit = env.CLAUDE_CONFIG_DIR?.trim() ?? '';
|
|
8
|
+
const configDir = explicit === '' ? join(homedir(), '.claude') : explicit;
|
|
9
|
+
return [
|
|
10
|
+
...new Set([
|
|
11
|
+
join(configDir, 'settings.json'),
|
|
12
|
+
join(cwd, '.claude', 'settings.json'),
|
|
13
|
+
join(cwd, '.claude', 'settings.local.json'),
|
|
14
|
+
]),
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
function envBlock(file) {
|
|
18
|
+
if (!existsSync(file))
|
|
19
|
+
return {};
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
22
|
+
const block = parsed.env;
|
|
23
|
+
return typeof block === 'object' && block !== null ? block : {};
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function settingsConflicts(files) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const block = envBlock(file);
|
|
33
|
+
for (const key of CONFLICTING) {
|
|
34
|
+
const value = block[key];
|
|
35
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
36
|
+
out.push(`${file}: ${key}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
package/dist/claude.js
CHANGED
|
@@ -1,15 +1,107 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { settingsConflicts, settingsFiles } from './claude-settings.js';
|
|
3
|
+
import { localAgents, pickLocalAgent } from './local.js';
|
|
4
|
+
import { PROVIDER_FLAGS } from './provider-flags.js';
|
|
5
|
+
import { localPort, localUrl } from './runtime.js';
|
|
2
6
|
const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
|
|
7
|
+
const KEY_HEADER = 'x-metro-key';
|
|
8
|
+
const PROBE_MS = 3_000;
|
|
3
9
|
export const claudeArgs = (extra) => [
|
|
4
10
|
...CHANNEL_FLAGS,
|
|
5
11
|
...extra,
|
|
6
12
|
];
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const set = (env, name) => (env[name] ?? '').trim() !== '';
|
|
14
|
+
export function pinnedBy(env) {
|
|
15
|
+
if (set(env, 'ANTHROPIC_BASE_URL'))
|
|
16
|
+
return 'ANTHROPIC_BASE_URL';
|
|
17
|
+
return PROVIDER_FLAGS.find((flag) => set(env, flag)) ?? null;
|
|
18
|
+
}
|
|
19
|
+
export function gatewayEnv(base, agentKey, port) {
|
|
20
|
+
if (agentKey === null || pinnedBy(base) !== null)
|
|
21
|
+
return base;
|
|
22
|
+
const own = (base.ANTHROPIC_CUSTOM_HEADERS ?? '').trim();
|
|
23
|
+
const mine = `${KEY_HEADER}: ${agentKey}`;
|
|
24
|
+
return {
|
|
25
|
+
...base,
|
|
26
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${String(port)}/gateway`,
|
|
27
|
+
ANTHROPIC_CUSTOM_HEADERS: own === '' ? mine : `${own}\n${mine}`,
|
|
28
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function servingDaemon(body) {
|
|
32
|
+
if (typeof body !== 'object' || body === null)
|
|
33
|
+
return false;
|
|
34
|
+
const mode = body;
|
|
35
|
+
return mode.mode === 'local' && mode.stopped !== true;
|
|
36
|
+
}
|
|
37
|
+
async function daemonServing(base = localUrl()) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${base}/api/mode`, { signal: AbortSignal.timeout(PROBE_MS) });
|
|
40
|
+
return res.ok && servingDaemon(await res.json());
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function agentKey(agents, wanted) {
|
|
47
|
+
if (agents.length === 0)
|
|
48
|
+
return { skip: 'no agent lives on this machine yet, so Claude Code talks to Anthropic directly' };
|
|
49
|
+
try {
|
|
50
|
+
return { key: pickLocalAgent(agents, wanted).key };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return {
|
|
54
|
+
skip: wanted === undefined || wanted === ''
|
|
55
|
+
? 'several agents live here; set METRO_AGENT=<name> to route through the daemon, Claude Code talks to Anthropic directly for now'
|
|
56
|
+
: `no agent named '${wanted}' lives here, so Claude Code talks to Anthropic directly`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function localAgentList() {
|
|
61
|
+
try {
|
|
62
|
+
return localAgents();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function verdict() {
|
|
69
|
+
const pinned = pinnedBy(process.env);
|
|
70
|
+
if (pinned !== null)
|
|
71
|
+
return { skip: `${pinned} is set, so Claude Code keeps talking to it` };
|
|
72
|
+
const conflicts = settingsConflicts(settingsFiles());
|
|
73
|
+
if (conflicts.length > 0)
|
|
74
|
+
return { skip: `a settings file pins the provider (${conflicts.join(', ')}), so Claude Code keeps it` };
|
|
75
|
+
const picked = agentKey(localAgentList(), process.env.METRO_AGENT);
|
|
76
|
+
if ('skip' in picked)
|
|
77
|
+
return picked;
|
|
78
|
+
if (await daemonServing())
|
|
79
|
+
return picked;
|
|
80
|
+
return { skip: 'the daemon is not serving here (stopped, or not running), so Claude Code talks to Anthropic directly' };
|
|
81
|
+
}
|
|
82
|
+
export function runClaude(args, env) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const leaveToChild = () => undefined;
|
|
85
|
+
process.on('SIGINT', leaveToChild);
|
|
86
|
+
process.on('SIGTERM', leaveToChild);
|
|
87
|
+
const child = spawn('claude', args, { stdio: 'inherit', env });
|
|
88
|
+
child.on('error', (err) => {
|
|
89
|
+
reject(new Error(err.code === 'ENOENT'
|
|
90
|
+
? 'the `claude` command is not on PATH — install Claude Code first'
|
|
91
|
+
: err.message));
|
|
92
|
+
});
|
|
93
|
+
child.on('exit', (code) => {
|
|
94
|
+
resolve(code ?? 1);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export async function launchClaude(extra) {
|
|
99
|
+
const decision = await verdict();
|
|
100
|
+
if ('skip' in decision) {
|
|
101
|
+
process.stderr.write(`metro claude: ${decision.skip}\n`);
|
|
102
|
+
return runClaude(claudeArgs(extra), process.env);
|
|
103
|
+
}
|
|
104
|
+
const port = localPort();
|
|
105
|
+
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`);
|
|
106
|
+
return runClaude(claudeArgs(extra), gatewayEnv(process.env, decision.key, port));
|
|
15
107
|
}
|
package/dist/hold.js
CHANGED
|
@@ -145,8 +145,11 @@ async function listen(server, info) {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
const CLOSE_WAIT_MS = 2_000;
|
|
148
149
|
const close = (server) => new Promise((resolve) => {
|
|
150
|
+
const timer = setTimeout(resolve, CLOSE_WAIT_MS);
|
|
149
151
|
server.close(() => {
|
|
152
|
+
clearTimeout(timer);
|
|
150
153
|
resolve();
|
|
151
154
|
});
|
|
152
155
|
server.closeAllConnections();
|
|
@@ -160,6 +163,13 @@ function releaseLock(lockFile) {
|
|
|
160
163
|
return;
|
|
161
164
|
}
|
|
162
165
|
}
|
|
166
|
+
function armFunnel(info, log, ending) {
|
|
167
|
+
if (info.funnel === null || ending !== null)
|
|
168
|
+
return null;
|
|
169
|
+
const funnel = new HeldFunnel(info.funnel, info.port, log);
|
|
170
|
+
funnel.start();
|
|
171
|
+
return funnel;
|
|
172
|
+
}
|
|
163
173
|
export const holdBanner = (info) => `metro is stopped. Holding http://${info.host}:${String(info.port)}${info.funnel === null ? '' : ' and the Funnel address'} ` +
|
|
164
174
|
'until Start on the Server page; Ctrl-C or metro stop ends metro serve';
|
|
165
175
|
export async function holdUntilStart(info, deps = {}) {
|
|
@@ -169,8 +179,12 @@ export async function holdUntilStart(info, deps = {}) {
|
|
|
169
179
|
process.stderr.write(`${line}\n`);
|
|
170
180
|
});
|
|
171
181
|
let finish = () => undefined;
|
|
182
|
+
let ending = null;
|
|
172
183
|
const ended = new Promise((resolve) => {
|
|
173
|
-
finish =
|
|
184
|
+
finish = (end) => {
|
|
185
|
+
ending ??= end;
|
|
186
|
+
resolve(end);
|
|
187
|
+
};
|
|
174
188
|
});
|
|
175
189
|
const server = holdServer(info, () => {
|
|
176
190
|
finish('start');
|
|
@@ -178,14 +192,14 @@ export async function holdUntilStart(info, deps = {}) {
|
|
|
178
192
|
const onSignal = () => {
|
|
179
193
|
finish('exit');
|
|
180
194
|
};
|
|
195
|
+
signals.on('SIGINT', onSignal);
|
|
196
|
+
signals.on('SIGTERM', onSignal);
|
|
181
197
|
if (info.lockFile !== null)
|
|
182
198
|
writeFileSync(info.lockFile, String(process.pid));
|
|
183
199
|
await listen(server, info);
|
|
184
|
-
const funnel =
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
signals.on('SIGTERM', onSignal);
|
|
188
|
-
log(holdBanner(info));
|
|
200
|
+
const funnel = armFunnel(info, log, ending);
|
|
201
|
+
if (ending === null)
|
|
202
|
+
log(holdBanner(info));
|
|
189
203
|
const end = await ended;
|
|
190
204
|
signals.off('SIGINT', onSignal);
|
|
191
205
|
signals.off('SIGTERM', onSignal);
|
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.71",
|
|
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,4 +1,5 @@
|
|
|
1
1
|
import { handleSessionApis, type SessionApis } from './session-apis.js';
|
|
2
|
+
import { handleGatewayRequest } from '../gateway/gateway.js';
|
|
2
3
|
import { handleRelayRequest } from './relay.js';
|
|
3
4
|
import {
|
|
4
5
|
createServer,
|
|
@@ -348,6 +349,13 @@ function handleSignInRoutes(
|
|
|
348
349
|
return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
function handleEarlyRoutes(req: IncomingMessage, res: ServerResponse, apis: SessionApis): boolean {
|
|
353
|
+
if (handleHealth(req, res)) return true;
|
|
354
|
+
if (apis.gateway !== undefined && handleGatewayRequest(req, res, apis.gateway)) return true;
|
|
355
|
+
if (handleSignInRoutes(req, res, apis)) return true;
|
|
356
|
+
return handleSessionApis(req, res, apis);
|
|
357
|
+
}
|
|
358
|
+
|
|
351
359
|
async function handlePreMcpRoutes(
|
|
352
360
|
req: IncomingMessage,
|
|
353
361
|
res: ServerResponse,
|
|
@@ -355,9 +363,7 @@ async function handlePreMcpRoutes(
|
|
|
355
363
|
apis: SessionApis,
|
|
356
364
|
monitorCall?: MonitorCall,
|
|
357
365
|
): Promise<boolean> {
|
|
358
|
-
if (
|
|
359
|
-
if (handleSignInRoutes(req, res, apis)) return true;
|
|
360
|
-
if (handleSessionApis(req, res, apis)) return true;
|
|
366
|
+
if (handleEarlyRoutes(req, res, apis)) return true;
|
|
361
367
|
if (handleUploadRequest(req, res)) return true;
|
|
362
368
|
if (handleAttachRequest(req, res)) return true;
|
|
363
369
|
if (apis.relayApi && handleRelayRequest(req, res, apis.relayApi)) return true;
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
readLocalAgentFile,
|
|
42
42
|
} from '../db/file-admin.js';
|
|
43
43
|
import { listAgentFiles, readAgentFile } from '../db/file-source.js';
|
|
44
|
+
import { readModelConfig } from '../gateway/model-config.js';
|
|
44
45
|
import type { StationName } from '../db/stations.js';
|
|
45
46
|
|
|
46
47
|
export interface LocalModeDeps {
|
|
@@ -177,6 +178,8 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
177
178
|
updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
|
|
178
179
|
controlApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart, stop: deps.stop },
|
|
179
180
|
machineApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
181
|
+
modelApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
182
|
+
gateway: { config: readModelConfig },
|
|
180
183
|
terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
181
184
|
identity: { owner: localOwner },
|
|
182
185
|
mode: localModeInfo,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { ApiError } from './api-error.js';
|
|
3
|
+
import { apiFailure, apiSession, cors, readJsonBody, sendJson } from './api-http.js';
|
|
4
|
+
import { isRecord } from './is-record.js';
|
|
5
|
+
import { log } from './log.js';
|
|
6
|
+
import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from '../gateway/codex-auth.js';
|
|
7
|
+
import { beginDeviceLogin, pollDeviceLogin } from '../gateway/codex-device.js';
|
|
8
|
+
import { codexModels, currentTokens, freshCodexState } from '../gateway/codex.js';
|
|
9
|
+
import type { CodexTokens } from '../gateway/codex-auth.js';
|
|
10
|
+
import { GatewayError } from '../gateway/forward.js';
|
|
11
|
+
import {
|
|
12
|
+
applyModelUpdate,
|
|
13
|
+
ModelConfigError,
|
|
14
|
+
publicModelConfig,
|
|
15
|
+
readModelConfig,
|
|
16
|
+
setCodexAuth,
|
|
17
|
+
writeModelConfig,
|
|
18
|
+
type ModelConfig,
|
|
19
|
+
} from '../gateway/model-config.js';
|
|
20
|
+
|
|
21
|
+
const PATH = '/api/model';
|
|
22
|
+
const CODEX = '/api/model/codex/';
|
|
23
|
+
const BODY_MAX = 16 * 1024;
|
|
24
|
+
const DEVICE_PREFIX = 'device/';
|
|
25
|
+
const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
26
|
+
|
|
27
|
+
export interface ModelApiDeps {
|
|
28
|
+
authorize: (subject: string) => void;
|
|
29
|
+
read?: () => ModelConfig;
|
|
30
|
+
write?: (cfg: ModelConfig) => void;
|
|
31
|
+
issuer?: string;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
codexHome?: string;
|
|
34
|
+
codexBase?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Store {
|
|
38
|
+
read: () => ModelConfig;
|
|
39
|
+
write: (cfg: ModelConfig) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Handler = (req: IncomingMessage, deps: ModelApiDeps, store: Store) => Promise<unknown>;
|
|
43
|
+
|
|
44
|
+
interface Route {
|
|
45
|
+
method: 'GET' | 'POST';
|
|
46
|
+
run: Handler;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function asApiError(err: unknown): never {
|
|
50
|
+
if (err instanceof ModelConfigError || err instanceof CodexAuthError) throw new ApiError(err.message, 400);
|
|
51
|
+
if (err instanceof GatewayError) throw new ApiError(err.message, 502);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function update(req: IncomingMessage, store: Store): Promise<unknown> {
|
|
56
|
+
const patch = await readJsonBody(req, BODY_MAX);
|
|
57
|
+
let next: ModelConfig;
|
|
58
|
+
try {
|
|
59
|
+
next = applyModelUpdate(store.read(), patch);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
asApiError(err);
|
|
62
|
+
}
|
|
63
|
+
store.write(next);
|
|
64
|
+
log.info({ provider: next.provider }, 'model-api: route updated');
|
|
65
|
+
return publicModelConfig(next);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function saveCodex(store: Store, cfg: ModelConfig, note: string): unknown {
|
|
69
|
+
store.write(cfg);
|
|
70
|
+
log.info({ signedIn: cfg.codex.auth !== null, plan: cfg.codex.auth?.plan ?? null }, note);
|
|
71
|
+
return publicModelConfig(cfg);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const modelApiState = freshCodexState();
|
|
75
|
+
|
|
76
|
+
async function pollDevice(id: string, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
77
|
+
const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
|
|
78
|
+
if (result.status !== 'done') return result;
|
|
79
|
+
return { status: 'done', settings: saveCodex(store, setCodexAuth(store.read(), result.tokens), 'model-api: Codex connected by device code') };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const CODEX_ROUTES: Record<string, Route> = {
|
|
83
|
+
device: {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
run: async (_req, deps) => {
|
|
86
|
+
const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
87
|
+
return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
login: {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
run: (_req, deps) => Promise.resolve({ url: beginLogin(deps.issuer).url }),
|
|
93
|
+
},
|
|
94
|
+
callback: {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
run: async (req, deps, store) => {
|
|
97
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
98
|
+
const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
|
|
99
|
+
const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
100
|
+
return saveCodex(store, setCodexAuth(store.read(), tokens), 'model-api: Codex connected');
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
logout: {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
run: (_req, _deps, store) => Promise.resolve(saveCodex(store, setCodexAuth(store.read(), null), 'model-api: Codex disconnected')),
|
|
106
|
+
},
|
|
107
|
+
import: {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
run: (_req, deps, store) => {
|
|
110
|
+
let tokens;
|
|
111
|
+
try {
|
|
112
|
+
tokens = readCodexCliAuth(deps.codexHome);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
asApiError(err);
|
|
115
|
+
}
|
|
116
|
+
return Promise.resolve(saveCodex(store, setCodexAuth(store.read(), tokens), 'model-api: Codex CLI login imported'));
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
models: {
|
|
120
|
+
method: 'GET',
|
|
121
|
+
run: async (_req, deps, store) => {
|
|
122
|
+
const cfg = store.read();
|
|
123
|
+
if (cfg.codex.auth === null) throw new ApiError('Codex is not connected: sign in with ChatGPT first', 400);
|
|
124
|
+
const codexDeps = { issuer: deps.issuer, fetchImpl: deps.fetchImpl, base: deps.codexBase, save: (t: CodexTokens) => { store.write(setCodexAuth(store.read(), t)); } };
|
|
125
|
+
const auth = await currentTokens(cfg, codexDeps, modelApiState).catch(asApiError);
|
|
126
|
+
const models = await codexModels(auth, codexDeps).catch(asApiError);
|
|
127
|
+
return { models };
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function routeFor(path: string, method: string | undefined): Route | number {
|
|
133
|
+
if (path === PATH) {
|
|
134
|
+
if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(publicModelConfig(store.read())) };
|
|
135
|
+
if (method === 'PUT') return { method: 'POST', run: (req, _deps, store) => update(req, store) };
|
|
136
|
+
return 405;
|
|
137
|
+
}
|
|
138
|
+
const rest = path.slice(CODEX.length);
|
|
139
|
+
const route = CODEX_ROUTES[rest];
|
|
140
|
+
if (route !== undefined) return route.method === method ? route : 405;
|
|
141
|
+
const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
|
|
142
|
+
if (!DEVICE_ID_RE.test(id)) return 404;
|
|
143
|
+
return method === 'GET' ? { method: 'GET', run: (_req, deps, store) => pollDevice(id, deps, store) } : 405;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
|
|
147
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
148
|
+
if (path !== PATH && !path.startsWith(CODEX)) return false;
|
|
149
|
+
if (req.method === 'OPTIONS') {
|
|
150
|
+
res.writeHead(204, cors(req)).end();
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
const route = routeFor(path, req.method);
|
|
154
|
+
if (typeof route === 'number') {
|
|
155
|
+
sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
|
|
159
|
+
apiSession(req)
|
|
160
|
+
.then(async (session) => {
|
|
161
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
162
|
+
deps.authorize(session.subject);
|
|
163
|
+
sendJson(req, res, 200, await route.run(req, deps, store));
|
|
164
|
+
})
|
|
165
|
+
.catch((err: unknown) => {
|
|
166
|
+
apiFailure(req, res, err, 'model-api');
|
|
167
|
+
});
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
@@ -16,6 +16,8 @@ import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
|
|
|
16
16
|
import { handleUpdateRequest, type UpdateApiDeps } from './update-api.js';
|
|
17
17
|
import { handleControlRequest, type ControlApiDeps } from './control-api.js';
|
|
18
18
|
import { handleMachineRequest, type MachineApiDeps } from './machine-api.js';
|
|
19
|
+
import { handleModelRequest, type ModelApiDeps } from './model-api.js';
|
|
20
|
+
import type { GatewayDeps } from '../gateway/gateway.js';
|
|
19
21
|
import { handleTerminalRequest, type TerminalApiDeps } from './terminal-api.js';
|
|
20
22
|
|
|
21
23
|
export interface SessionApis {
|
|
@@ -26,6 +28,8 @@ export interface SessionApis {
|
|
|
26
28
|
updateApi?: UpdateApiDeps;
|
|
27
29
|
controlApi?: ControlApiDeps;
|
|
28
30
|
machineApi?: MachineApiDeps;
|
|
31
|
+
modelApi?: ModelApiDeps;
|
|
32
|
+
gateway?: GatewayDeps;
|
|
29
33
|
terminalApi?: TerminalApiDeps;
|
|
30
34
|
localCli?: LocalCliDeps;
|
|
31
35
|
claudeApi?: ClaudeApiDeps;
|
|
@@ -53,6 +57,7 @@ export function handleSessionApis(
|
|
|
53
57
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
|
54
58
|
...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
|
|
55
59
|
...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
|
|
60
|
+
...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
|
|
56
61
|
...when(apis.terminalApi, (d) => handleTerminalRequest(req, res, d)),
|
|
57
62
|
...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
|
|
58
63
|
...when(apis.agentApi, (d) => handleAgentApiRequest(req, res, d)),
|