@stage-labs/metro 0.1.0-beta.308 → 0.1.0-beta.309
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/daemon/src/agent-user/view.ts +3 -9
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +11 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +1 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +36 -64
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +99 -213
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -4
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +0 -219
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -82
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { errMsg, log } from '@metro-labs/core/log';
|
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
5
|
import { agentsDir } from '../agents/files.js';
|
|
6
6
|
import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
|
|
7
|
+
import { effectiveSetup, modelViewText } from '../gateway/effective.js';
|
|
7
8
|
import { removeHome, writeHomeText } from './home-fs.js';
|
|
8
9
|
import { closeMetroHome } from './metro-home.js';
|
|
9
10
|
import { agentUser, agentViewDir, type AgentUser } from './user.js';
|
|
@@ -39,17 +40,10 @@ function agentFile(dir: string): string | null {
|
|
|
39
40
|
return json({ version: 1, id: file.id, key: file.key });
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function
|
|
43
|
-
const cfg = readRecord(join(dir, 'model.json'));
|
|
44
|
-
if (cfg === null || !Array.isArray(cfg.connections)) return null;
|
|
45
|
-
const connections = cfg.connections.filter(isRecord).map((c) => ({ id: c.id, provider: c.provider, model: c.model }));
|
|
46
|
-
return json({ version: 2, route: cfg.route, connections });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function viewFiles(dir = agentsDir()): Map<string, string | null> {
|
|
43
|
+
export function viewFiles(dir = agentsDir(), model: () => string | null = () => modelViewText(effectiveSetup(dir))): Map<string, string | null> {
|
|
50
44
|
const files = new Map<string, string | null>([
|
|
51
45
|
['agent.json', agentFile(dir)],
|
|
52
|
-
['model.json',
|
|
46
|
+
['model.json', model()],
|
|
53
47
|
]);
|
|
54
48
|
for (const name of COPIED) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
|
|
55
49
|
for (const name of NEVER_COPIED) files.set(name, null);
|
|
@@ -60,6 +60,11 @@ import { startUploadReaper } from '../files/upload-store.js';
|
|
|
60
60
|
import { startAttachReaper } from '../files/attach-reaper.js';
|
|
61
61
|
import { startCloudWatchPublisher } from '../server/cloudwatch.js';
|
|
62
62
|
import { watchFallbackUsage } from '../gateway/usage-refresh.js';
|
|
63
|
+
import { startOrgModels, stopOrgModels } from '../gateway/org-models.js';
|
|
64
|
+
import { effectiveSetup, watchModelChanges } from '../gateway/effective.js';
|
|
65
|
+
import { readClaudeLogins } from '../gateway/claude-store.js';
|
|
66
|
+
import { readAgentKey } from '../connectors/agent-key.js';
|
|
67
|
+
import { METRO_API } from '../connectors/metro-api.js';
|
|
63
68
|
import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } from '../voice/calls.js';
|
|
64
69
|
|
|
65
70
|
installCrashGuard();
|
|
@@ -114,7 +119,7 @@ async function syncStations(station: StationName): Promise<void> {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
function sessionApis(setLiveEvents: (on: boolean) => void
|
|
122
|
+
function sessionApis(setLiveEvents: (on: boolean) => void): SessionApis {
|
|
118
123
|
return localSessionApis({
|
|
119
124
|
syncStations,
|
|
120
125
|
reloadAgents: async () => {
|
|
@@ -133,7 +138,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
|
|
|
133
138
|
toolGroups: stationToolGroups,
|
|
134
139
|
prepareAccount,
|
|
135
140
|
liveEvents: setLiveEvents,
|
|
136
|
-
switchModel,
|
|
137
141
|
});
|
|
138
142
|
}
|
|
139
143
|
|
|
@@ -150,10 +154,12 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
150
154
|
syncGmailHost();
|
|
151
155
|
supervisor.start();
|
|
152
156
|
sweepLeftoverCalls();
|
|
157
|
+
const models = startOrgModels({ owner: localOwner, key: () => readAgentKey(), api: METRO_API, dir: agentsDir });
|
|
153
158
|
const metroMcp = await createMetroMcp({ liveEvents: liveEvents() });
|
|
159
|
+
watchModelChanges(models, metroMcp.switchModel);
|
|
154
160
|
webhookServer = await startWebhookServer(
|
|
155
161
|
emit,
|
|
156
|
-
sessionApis(metroMcp.setLiveEvents
|
|
162
|
+
sessionApis(metroMcp.setLiveEvents),
|
|
157
163
|
metroMcp.httpHandler,
|
|
158
164
|
true,
|
|
159
165
|
);
|
|
@@ -174,7 +180,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
174
180
|
});
|
|
175
181
|
watchConnectorTools();
|
|
176
182
|
startCloudWatchPublisher();
|
|
177
|
-
watchFallbackUsage();
|
|
183
|
+
watchFallbackUsage(() => effectiveSetup(), () => readClaudeLogins());
|
|
178
184
|
announceLocalEndpoint();
|
|
179
185
|
tunnel?.start();
|
|
180
186
|
log.info(
|
|
@@ -221,6 +227,7 @@ async function shutdown(): Promise<void> {
|
|
|
221
227
|
log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
|
|
222
228
|
});
|
|
223
229
|
const leaving = leaveCallsForShutdown();
|
|
230
|
+
stopOrgModels();
|
|
224
231
|
unwatchSession();
|
|
225
232
|
tunnel?.stop();
|
|
226
233
|
if (webhookServer) {
|
|
@@ -11,7 +11,8 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
11
11
|
import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
12
12
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
13
13
|
import { agentsDir, listAgentFiles } from '../agents/files.js';
|
|
14
|
-
import { notReady,
|
|
14
|
+
import { notReady, primaryRoute, type ModelSetup } from '../gateway/model-config.js';
|
|
15
|
+
import { effectiveSetup } from '../gateway/effective.js';
|
|
15
16
|
import { claudeAccount, claudeInstalled, forgetClaudeChecks } from './login.js';
|
|
16
17
|
import { inSessionScope } from './memory.js';
|
|
17
18
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
@@ -38,6 +39,7 @@ export interface SessionDeps {
|
|
|
38
39
|
home?: string;
|
|
39
40
|
agents?: string;
|
|
40
41
|
signedIn?: () => boolean;
|
|
42
|
+
model?: () => ModelSetup;
|
|
41
43
|
now?: () => number;
|
|
42
44
|
version?: string;
|
|
43
45
|
}
|
|
@@ -138,13 +140,12 @@ function credentialReady(deps: SessionDeps): string | null {
|
|
|
138
140
|
const signedIn = deps.signedIn ?? loginNotRuledOut;
|
|
139
141
|
if (signedIn()) return null;
|
|
140
142
|
try {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
if (conn !== null && notReady(cfg) === null && (conn.provider !== 'anthropic' || conn.apiKey !== '')) return null;
|
|
143
|
+
const primary = primaryRoute((deps.model ?? (() => effectiveSetup(deps.agents ?? agentsDir())))());
|
|
144
|
+
if (primary !== null && notReady(primary) === null && (primary.connection.provider !== 'anthropic' || primary.connection.apiKey !== '')) return null;
|
|
144
145
|
} catch (err) {
|
|
145
|
-
return `the
|
|
146
|
+
return `the model setup is not readable (${errMsg(err)})`;
|
|
146
147
|
}
|
|
147
|
-
return 'Claude Code is not signed in and the
|
|
148
|
+
return 'Claude Code is not signed in and the organization routes this agent to no model yet';
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
const agentUserMissing = (deps: SessionDeps): boolean =>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { log } from '@metro-labs/core/log';
|
|
3
3
|
import { EventStreamDecoder, type EventStreamMessage } from './eventstream.js';
|
|
4
|
-
import { errorFrame,
|
|
4
|
+
import { errorFrame, providerStatus, relayFrames, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
5
5
|
import type { Connection } from './model-config.js';
|
|
6
6
|
import { estimateTokens } from './subscription.js';
|
|
7
7
|
import { UsageScanner } from './usage.js';
|
|
@@ -226,8 +226,3 @@ export async function bedrockCount(
|
|
|
226
226
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
227
227
|
res.end(JSON.stringify({ input_tokens: counted ?? estimateTokens(rewritten.body) }));
|
|
228
228
|
}
|
|
229
|
-
|
|
230
|
-
export function assertBedrockReady(settings: Connection): void {
|
|
231
|
-
if (settings.apiKey === '' || settings.region === '')
|
|
232
|
-
throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key and a region: add them on the Model page.');
|
|
233
|
-
}
|
|
@@ -5,13 +5,14 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
6
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
7
7
|
import { readFileSync } from '../agent-user/agent-fs.js';
|
|
8
|
-
import { removeHome } from '../agent-user/home-fs.js';
|
|
8
|
+
import { moveHome, removeHome } from '../agent-user/home-fs.js';
|
|
9
9
|
import { claudeHome } from '../agent-user/user.js';
|
|
10
10
|
import { claudeAccountIn, type ClaudeAccount, type LoginTarget } from '../claude/login.js';
|
|
11
11
|
import { readClaudeUsage } from '../claude/usage-probe.js';
|
|
12
12
|
import { currentOf, reach, refreshed, type TokenSource, type TokenState } from './subscription.js';
|
|
13
|
-
import { forgetReported } from './usage.js';
|
|
14
|
-
import {
|
|
13
|
+
import { forgetOne, forgetReported } from './usage.js';
|
|
14
|
+
import { readClaudeLogins, writeClaudeLogins, type ClaudeLogin } from './claude-store.js';
|
|
15
|
+
import { loginRef } from './model-config.js';
|
|
15
16
|
|
|
16
17
|
const CREDENTIALS = '.credentials.json';
|
|
17
18
|
const RENEW_MARGIN_MS = 60_000;
|
|
@@ -22,8 +23,7 @@ export interface ClaudeLoginDeps {
|
|
|
22
23
|
root?: string;
|
|
23
24
|
probe?: (env: Record<string, string>) => Promise<unknown>;
|
|
24
25
|
account?: (env: Record<string, string>) => ClaudeAccount;
|
|
25
|
-
|
|
26
|
-
write?: (cfg: ModelConfig) => void;
|
|
26
|
+
dir?: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
interface ClaudeTokens {
|
|
@@ -110,56 +110,80 @@ export function withClaudeLogin(login: string, deps: ClaudeLoginDeps, send: (tok
|
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
const folderOf = (login: string, deps: ClaudeLoginDeps): string => claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '';
|
|
114
|
+
|
|
115
|
+
function forgetTokens(login: string): void {
|
|
114
116
|
seen.delete(login);
|
|
115
117
|
state.delete(login);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function dropFolder(login: string, deps: ClaudeLoginDeps): void {
|
|
121
|
+
forgetTokens(login);
|
|
116
122
|
try {
|
|
117
|
-
removeHome(
|
|
123
|
+
removeHome(folderOf(login, deps), true);
|
|
118
124
|
} catch (err) {
|
|
119
|
-
log.warn({ err: errMsg(err) }, 'claude-logins: could not remove a Claude login that is no longer used');
|
|
125
|
+
log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of a Claude login that is no longer used');
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
export function
|
|
124
|
-
const
|
|
125
|
-
|
|
129
|
+
export function removeClaudeLogin(login: string, deps: ClaudeLoginDeps = {}): void {
|
|
130
|
+
const logins = readClaudeLogins(deps.dir);
|
|
131
|
+
if (!logins.some((l) => l.id === login)) throw new ApiError('no such Claude login on this box', 404);
|
|
132
|
+
writeClaudeLogins(logins.filter((l) => l.id !== login), deps.dir);
|
|
133
|
+
dropFolder(login, deps);
|
|
134
|
+
forgetOne(loginRef(login));
|
|
135
|
+
log.info({ login }, 'claude-logins: a Claude login was removed from this box');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function takeOver(fresh: string, replaced: string, deps: ClaudeLoginDeps): void {
|
|
139
|
+
const aside = `${folderOf(replaced, deps)}.old-${newId()}`;
|
|
140
|
+
moveHome(folderOf(replaced, deps), aside);
|
|
141
|
+
try {
|
|
142
|
+
moveHome(folderOf(fresh, deps), folderOf(replaced, deps));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
moveHome(aside, folderOf(replaced, deps));
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
forgetTokens(replaced);
|
|
148
|
+
try {
|
|
149
|
+
removeHome(aside, true);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of the login that was signed in again');
|
|
152
|
+
}
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
function keep(login: string,
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
(
|
|
135
|
-
|
|
136
|
-
forgetReported(id);
|
|
137
|
-
log.info({ connection: id, plan: account.plan, added: connection === null }, 'claude-logins: the Claude login was kept for its own connection');
|
|
155
|
+
function keep(login: string, replacing: string | null, account: ClaudeAccount, deps: ClaudeLoginDeps): string | null {
|
|
156
|
+
const logins = readClaudeLogins(deps.dir);
|
|
157
|
+
if (replacing !== null && !logins.some((l) => l.id === replacing)) return 'that Claude login was removed during the sign-in';
|
|
158
|
+
if (replacing !== null) takeOver(login, replacing, deps);
|
|
159
|
+
const entry: ClaudeLogin = { id: replacing ?? login, email: account.account, plan: account.plan, savedAt: new Date().toISOString() };
|
|
160
|
+
writeClaudeLogins(replacing === null ? [...logins, entry] : logins.map((l) => (l.id === replacing ? entry : l)), deps.dir);
|
|
161
|
+
forgetReported(loginRef(entry.id));
|
|
162
|
+
log.info({ login: entry.id, plan: account.plan, added: replacing === null }, 'claude-logins: the Claude login was kept on this box');
|
|
138
163
|
return null;
|
|
139
164
|
}
|
|
140
165
|
|
|
141
|
-
function settle(ok: boolean, login: string,
|
|
166
|
+
function settle(ok: boolean, login: string, replacing: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
|
|
142
167
|
if (!ok) {
|
|
143
|
-
|
|
168
|
+
dropFolder(login, deps);
|
|
144
169
|
return Promise.resolve(null);
|
|
145
170
|
}
|
|
146
171
|
let problem: string | null;
|
|
147
172
|
try {
|
|
148
173
|
const account = (deps.account ?? claudeAccountIn)(claudeLoginEnv(login, deps));
|
|
149
|
-
problem = account.signedIn ? keep(login,
|
|
174
|
+
problem = account.signedIn ? keep(login, replacing, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
|
|
150
175
|
} catch (err) {
|
|
151
176
|
problem = errMsg(err);
|
|
152
177
|
}
|
|
153
|
-
if (problem !== null)
|
|
178
|
+
if (problem !== null) dropFolder(login, deps);
|
|
154
179
|
return Promise.resolve(problem);
|
|
155
180
|
}
|
|
156
181
|
|
|
157
182
|
export function claudeLoginTarget(asked: string, deps: ClaudeLoginDeps = {}): LoginTarget {
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
160
|
-
throw new ApiError('no such Anthropic connection', 404);
|
|
183
|
+
const replacing = asked === NEW ? null : asked;
|
|
184
|
+
if (replacing !== null && !readClaudeLogins(deps.dir).some((l) => l.id === replacing)) throw new ApiError('no such Claude login on this box', 404);
|
|
161
185
|
const login = newId();
|
|
162
|
-
return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login,
|
|
186
|
+
return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, replacing, deps) };
|
|
163
187
|
}
|
|
164
188
|
|
|
165
189
|
export function forgetClaudeTokens(): void {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { ID_RE, newId } from '@metro-labs/core/ids';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
|
+
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
|
|
7
|
+
export interface ClaudeLogin {
|
|
8
|
+
id: string;
|
|
9
|
+
email: string | null;
|
|
10
|
+
plan: string | null;
|
|
11
|
+
savedAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MODEL_FILE = 'model.json';
|
|
15
|
+
|
|
16
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
17
|
+
const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
18
|
+
|
|
19
|
+
export function claudeLoginOf(raw: unknown): ClaudeLogin | null {
|
|
20
|
+
if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
|
|
21
|
+
return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const modelPath = (dir: string): string => join(dir, MODEL_FILE);
|
|
25
|
+
|
|
26
|
+
export const readModelFile = (dir = agentsDir()): unknown =>
|
|
27
|
+
readJson<unknown>(modelPath(dir), null, { warn: 'model.json is unreadable, so the Claude logins of this box are not seen until it is fixed' });
|
|
28
|
+
|
|
29
|
+
export const isV2 = (raw: unknown): raw is Record<string, unknown> & { connections: unknown[] } =>
|
|
30
|
+
isRecord(raw) && raw.version !== 3 && Array.isArray(raw.connections);
|
|
31
|
+
|
|
32
|
+
const unique = (logins: ClaudeLogin[]): ClaudeLogin[] => logins.filter((l, at) => logins.findIndex((o) => o.id === l.id) === at);
|
|
33
|
+
|
|
34
|
+
export function claudeLoginsIn(raw: unknown): ClaudeLogin[] {
|
|
35
|
+
if (isV2(raw)) return unique(raw.connections.flatMap((c: unknown) => (isRecord(c) && c.provider === 'anthropic' ? (claudeLoginOf(c.claude) ?? []) : [])));
|
|
36
|
+
if (!isRecord(raw) || !Array.isArray(raw.claude)) return [];
|
|
37
|
+
return unique(raw.claude.flatMap((c: unknown) => claudeLoginOf(c) ?? []));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const readClaudeLogins = (dir = agentsDir()): ClaudeLogin[] => claudeLoginsIn(readModelFile(dir));
|
|
41
|
+
|
|
42
|
+
export const v3Text = (logins: ClaudeLogin[]): string => JSON.stringify({ version: 3, claude: logins }, null, 2);
|
|
43
|
+
|
|
44
|
+
function v2With(raw: Record<string, unknown> & { connections: unknown[] }, logins: ClaudeLogin[]): Record<string, unknown> {
|
|
45
|
+
const holds = (c: unknown): c is Record<string, unknown> => isRecord(c) && c.provider === 'anthropic' && claudeLoginOf(c.claude) !== null;
|
|
46
|
+
const held = new Map(raw.connections.filter(holds).map((c) => [claudeLoginOf(c.claude)?.id ?? '', c]));
|
|
47
|
+
const others = raw.connections.filter((c) => !holds(c));
|
|
48
|
+
const logged = logins.map((login) => ({ ...(held.get(login.id) ?? { id: newId(), provider: 'anthropic', label: 'Anthropic', model: '' }), claude: login }));
|
|
49
|
+
return { ...raw, connections: [...others, ...logged] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function writeClaudeLogins(logins: ClaudeLogin[], dir = agentsDir()): void {
|
|
53
|
+
const raw = readModelFile(dir);
|
|
54
|
+
writeSecure(modelPath(dir), isV2(raw) ? JSON.stringify(v2With(raw, logins), null, 2) : v3Text(logins));
|
|
55
|
+
}
|
|
@@ -2,54 +2,40 @@ 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 '@metro-labs/core/is-record';
|
|
5
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
6
|
import { stringOf } from '@metro-labs/http/api-http';
|
|
6
|
-
import { OPENAI_API, CODEX_SIGN_IN, refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
|
|
7
|
-
import { refreshCodeTokens } from './codex-device.js';
|
|
8
7
|
import { codexVersion, learnCodexVersion } from './codex-version.js';
|
|
9
8
|
import { CodexEventTranslator, withUsageLink } from './codex-stream.js';
|
|
10
9
|
import { ToolNames, toResponsesRequest } from './codex-translate.js';
|
|
11
10
|
import { GatewayError, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
12
11
|
import { parseEvent, SseParser, type SseEvent } from './frames.js';
|
|
13
|
-
import type
|
|
14
|
-
import {
|
|
15
|
-
import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
|
|
12
|
+
import { CODEX_SIGN_IN, type CodexAccess, type Connection } from './model-config.js';
|
|
13
|
+
import { answerWhole, errorKind, relayTranslated, sessionHeader } from './subscription.js';
|
|
16
14
|
import { noteUsageHeaders } from './usage.js';
|
|
17
15
|
|
|
18
16
|
const CODEX_BACKEND = 'https://chatgpt.com/backend-api/codex';
|
|
17
|
+
const OPENAI_API = 'https://api.openai.com/v1';
|
|
19
18
|
const INVALID = [400, 404, 422];
|
|
19
|
+
const RENEW_FAILED = "Codex refused its sign-in and api.metro.box could not renew it: sign in again on the organization's Models page.";
|
|
20
20
|
|
|
21
21
|
export interface CodexDeps {
|
|
22
22
|
base?: string;
|
|
23
|
-
issuer?: string;
|
|
24
23
|
fetchImpl?: typeof fetch;
|
|
25
|
-
|
|
24
|
+
refresh: (conn: Connection) => Promise<Connection | null>;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
export type CodexState = TokenState<CodexTokens>;
|
|
29
|
-
|
|
30
|
-
export const sharedCodexState: CodexState = new Map();
|
|
31
|
-
|
|
32
|
-
const codexLists = listCache<string>('codex');
|
|
33
|
-
|
|
34
|
-
const sourceOf = (deps: CodexDeps): TokenSource<CodexTokens> => ({
|
|
35
|
-
label: 'Codex',
|
|
36
|
-
stale: (tokens) => tokensStale(tokens),
|
|
37
|
-
refresh: (tokens) => (tokens.method === 'code' ? refreshCodeTokens(tokens, deps.issuer, deps.fetchImpl) : refreshTokens(tokens, deps.issuer, deps.fetchImpl)),
|
|
38
|
-
save: deps.save,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
27
|
const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
|
|
42
28
|
|
|
43
29
|
export const userAgent = (): string => `codex_cli_rs/${codexVersion()} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
|
|
44
30
|
|
|
45
|
-
const baseFor = (
|
|
31
|
+
const baseFor = (access: CodexAccess, deps: Pick<CodexDeps, 'base'>): string => deps.base ?? (access.method === 'code' ? CODEX_BACKEND : OPENAI_API);
|
|
46
32
|
|
|
47
|
-
function headersFor(
|
|
48
|
-
const plain = { authorization: `Bearer ${
|
|
49
|
-
if (
|
|
33
|
+
function headersFor(access: CodexAccess, sessionId: string): Record<string, string> {
|
|
34
|
+
const plain = { authorization: `Bearer ${access.accessToken}`, 'content-type': 'application/json', accept: 'text/event-stream' };
|
|
35
|
+
if (access.method === 'chatgpt') return plain;
|
|
50
36
|
return {
|
|
51
37
|
...plain,
|
|
52
|
-
'chatgpt-account-id':
|
|
38
|
+
'chatgpt-account-id': access.accountId ?? '',
|
|
53
39
|
originator: 'codex_cli_rs',
|
|
54
40
|
'user-agent': userAgent(),
|
|
55
41
|
'openai-beta': 'responses=experimental',
|
|
@@ -57,9 +43,6 @@ function headersFor(tokens: CodexTokens, accept: string, sessionId: string): Rec
|
|
|
57
43
|
};
|
|
58
44
|
}
|
|
59
45
|
|
|
60
|
-
export const currentTokens = (conn: Connection, deps: CodexDeps, state: CodexState): Promise<CodexTokens> =>
|
|
61
|
-
currentOf(state, conn.id, conn.codex, sourceOf(deps), CODEX_SIGN_IN);
|
|
62
|
-
|
|
63
46
|
interface Call {
|
|
64
47
|
body: Record<string, unknown>;
|
|
65
48
|
model: string;
|
|
@@ -69,11 +52,11 @@ interface Call {
|
|
|
69
52
|
names: ToolNames;
|
|
70
53
|
}
|
|
71
54
|
|
|
72
|
-
function send(call: Call,
|
|
55
|
+
function send(call: Call, access: CodexAccess): Promise<Response> {
|
|
73
56
|
const request = toResponsesRequest(call.body, call.model, { promptCacheKey: call.sessionId, names: call.names });
|
|
74
|
-
return (call.deps.fetchImpl ?? fetch)(`${baseFor(
|
|
57
|
+
return (call.deps.fetchImpl ?? fetch)(`${baseFor(access, call.deps)}/responses`, {
|
|
75
58
|
method: 'POST',
|
|
76
|
-
headers: headersFor(
|
|
59
|
+
headers: headersFor(access, call.sessionId),
|
|
77
60
|
body: JSON.stringify(request),
|
|
78
61
|
signal: call.watch.signal,
|
|
79
62
|
redirect: 'manual',
|
|
@@ -91,22 +74,33 @@ function refusalOf(text: string, status: number): string {
|
|
|
91
74
|
return withUsageLink(code, upstreamMessage(text, `OpenAI answered ${String(status)}`));
|
|
92
75
|
}
|
|
93
76
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
77
|
+
let learning: Promise<unknown> | null = null;
|
|
78
|
+
|
|
79
|
+
function learnVersion(access: CodexAccess): void {
|
|
80
|
+
if (access.method !== 'code' || learning !== null) return;
|
|
81
|
+
learning = learnCodexVersion()
|
|
82
|
+
.catch((err: unknown) => {
|
|
83
|
+
log.warn({ err: errMsg(err) }, 'gateway: the latest Codex version could not be learned');
|
|
84
|
+
})
|
|
85
|
+
.finally(() => {
|
|
86
|
+
learning = null;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function reach(call: Call, conn: Connection): Promise<Response> {
|
|
91
|
+
if (conn.codex === null) throw new GatewayError(400, 'invalid_request_error', CODEX_SIGN_IN);
|
|
92
|
+
learnVersion(conn.codex);
|
|
93
|
+
const upstream = await send(call, conn.codex);
|
|
94
|
+
if (upstream.status !== 401) return upstream;
|
|
95
|
+
await upstream.body?.cancel();
|
|
96
|
+
const fresh = await call.deps.refresh(conn);
|
|
97
|
+
if (fresh?.codex === null || fresh?.codex === undefined) throw new GatewayError(403, 'permission_error', RENEW_FAILED);
|
|
98
|
+
return send(call, fresh.codex);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function codexMessages(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, model: string, conn: Connection, deps: CodexDeps, watch: Watch): Promise<void> {
|
|
104
102
|
const call: Call = { body, model, sessionId: sessionHeader(req) || randomUUID(), watch, deps, names: new ToolNames() };
|
|
105
|
-
const upstream = await reach(
|
|
106
|
-
() => currentTokens(conn, deps, state),
|
|
107
|
-
(tokens) => refreshed(state, conn.id, tokens, sourceOf(deps)),
|
|
108
|
-
(tokens) => send(call, tokens),
|
|
109
|
-
);
|
|
103
|
+
const upstream = await reach(call, conn);
|
|
110
104
|
noteUsageHeaders('codex', conn.id, upstream.headers);
|
|
111
105
|
if (!upstream.ok) {
|
|
112
106
|
const text = await upstream.text();
|
|
@@ -125,19 +119,3 @@ export async function codexMessages(
|
|
|
125
119
|
const frames = new SseParser().push(await upstream.text()).map(onEvent).join('');
|
|
126
120
|
answerWhole(res, frames + translator.close(), conn.id);
|
|
127
121
|
}
|
|
128
|
-
|
|
129
|
-
export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
|
|
130
|
-
const base = baseFor(tokens, deps);
|
|
131
|
-
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
132
|
-
if (tokens.method === 'chatgpt') return codexLists.get(`${base}:${tokens.clientId}`, () => listCodex(tokens, `${base}/models`, fetchImpl));
|
|
133
|
-
const version = await learnCodexVersion();
|
|
134
|
-
return codexLists.get(`${base}:${tokens.accountId}:${version}`, () => listCodex(tokens, `${base}/models?client_version=${version}`, fetchImpl));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function listCodex(tokens: CodexTokens, url: string, fetchImpl: typeof fetch): Promise<string[]> {
|
|
138
|
-
const res = await fetchImpl(url, { headers: headersFor(tokens, 'application/json', randomUUID()), redirect: 'manual' });
|
|
139
|
-
if (!res.ok) throw new GatewayError(res.status, errorKind(res.status, INVALID), `OpenAI would not list the models of this ChatGPT plan (${String(res.status)})`);
|
|
140
|
-
const body: unknown = await res.json();
|
|
141
|
-
const models: unknown[] = isRecord(body) && Array.isArray(body.models) ? body.models : [];
|
|
142
|
-
return models.filter(isRecord).flatMap((m) => ((m.visibility ?? 'list') === 'list' && stringOf(m.slug) !== '' ? [stringOf(m.slug)] : []));
|
|
143
|
-
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import { isProvider, LOCAL_PREFIX, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
|
|
3
|
+
|
|
4
|
+
export interface DeliveredRoute {
|
|
5
|
+
primary: Fallback | null;
|
|
6
|
+
fallbacks: Fallback[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Delivered {
|
|
10
|
+
route: DeliveredRoute | null;
|
|
11
|
+
source: RouteSource | null;
|
|
12
|
+
connections: Connection[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_FALLBACKS = 8;
|
|
16
|
+
const MAX_CONNECTIONS = 20;
|
|
17
|
+
|
|
18
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
19
|
+
const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
20
|
+
|
|
21
|
+
function refOf(raw: unknown): Fallback | null {
|
|
22
|
+
if (!isRecord(raw)) return null;
|
|
23
|
+
const ref = { connection: text(raw.connection), model: text(raw.model) };
|
|
24
|
+
return ref.connection === '' ? null : ref;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function routeOf(raw: unknown): DeliveredRoute | null {
|
|
28
|
+
if (!isRecord(raw)) return null;
|
|
29
|
+
const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
|
|
30
|
+
return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function accountOf(raw: unknown): Account {
|
|
34
|
+
const account = isRecord(raw) ? raw : {};
|
|
35
|
+
const method = account.method === 'chatgpt' || account.method === 'code' ? account.method : null;
|
|
36
|
+
return { email: maybe(account.email), plan: maybe(account.plan), method };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const numberOf = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
40
|
+
|
|
41
|
+
function codexOf(raw: Record<string, unknown>): CodexAccess | null {
|
|
42
|
+
const accessToken = text(raw.accessToken);
|
|
43
|
+
if (accessToken === '' || (raw.method !== 'chatgpt' && raw.method !== 'code')) return null;
|
|
44
|
+
return { method: raw.method, accessToken, expiresAt: numberOf(raw.expiresAt), accountId: maybe(raw.accountId) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function credentialOf(provider: Provider, raw: unknown): { apiKey: string; codex: CodexAccess | null } {
|
|
48
|
+
if (!isRecord(raw)) return { apiKey: '', codex: null };
|
|
49
|
+
return provider === 'codex' ? { apiKey: '', codex: codexOf(raw) } : { apiKey: text(raw.apiKey), codex: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deliveredConnection(raw: unknown): Connection | null {
|
|
53
|
+
if (!isRecord(raw) || !isProvider(raw.provider)) return null;
|
|
54
|
+
const id = text(raw.id);
|
|
55
|
+
if (id === '' || id.startsWith(LOCAL_PREFIX)) return null;
|
|
56
|
+
const { apiKey, codex } = credentialOf(raw.provider, raw.credential);
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
provider: raw.provider,
|
|
60
|
+
label: text(raw.label) === '' ? raw.provider : text(raw.label),
|
|
61
|
+
model: text(raw.model),
|
|
62
|
+
apiKey,
|
|
63
|
+
region: text(raw.region),
|
|
64
|
+
zdr: raw.zdr === true,
|
|
65
|
+
revision: numberOf(raw.revision),
|
|
66
|
+
codex,
|
|
67
|
+
claude: null,
|
|
68
|
+
account: accountOf(raw.account),
|
|
69
|
+
signedOut: codex === null && apiKey === '',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function deliveredOf(raw: Record<string, unknown>): Delivered | null {
|
|
74
|
+
if (!Array.isArray(raw.connections) || (raw.route !== null && !isRecord(raw.route))) return null;
|
|
75
|
+
const source = raw.source === 'agent' || raw.source === 'organization' ? raw.source : null;
|
|
76
|
+
const connections = raw.connections.flatMap((c: unknown) => deliveredConnection(c) ?? []).slice(0, MAX_CONNECTIONS);
|
|
77
|
+
return { route: routeOf(raw.route), source, connections };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function refreshedConnection(raw: Record<string, unknown>): Connection | null {
|
|
81
|
+
return deliveredConnection(isRecord(raw.connection) ? raw.connection : raw);
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
import { agentsDir } from '../agents/files.js';
|
|
3
|
+
import { claudeLoginsIn, isV2, readModelFile, type ClaudeLogin } from './claude-store.js';
|
|
4
|
+
import type { Delivered } from './delivered.js';
|
|
5
|
+
import { emptySetup, LOCAL_PREFIX, loginConnection, primaryRoute, runnerModel, SESSION_LOGIN, sessionConnection, type Connection, type ModelSetup } from './model-config.js';
|
|
6
|
+
import { v2Setup } from './model-v2.js';
|
|
7
|
+
import { deliveredSetup, type OrgModels } from './org-models.js';
|
|
8
|
+
|
|
9
|
+
function localOf(id: string, logins: ClaudeLogin[]): Connection | null {
|
|
10
|
+
if (id === SESSION_LOGIN) return sessionConnection();
|
|
11
|
+
if (!id.startsWith(LOCAL_PREFIX)) return null;
|
|
12
|
+
const login = id.slice(LOCAL_PREFIX.length);
|
|
13
|
+
return loginConnection(login, logins.find((l) => l.id === login));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function setupOf(delivered: Delivered | null, logins: ClaudeLogin[]): ModelSetup {
|
|
17
|
+
const route = delivered?.route ?? null;
|
|
18
|
+
if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
|
|
19
|
+
const named = [...(route.primary === null ? [] : [route.primary]), ...route.fallbacks].map((r) => r.connection);
|
|
20
|
+
const locals = [...new Set(named)].flatMap((id) => localOf(id, logins) ?? []);
|
|
21
|
+
return { primary: route.primary, fallbacks: route.fallbacks, connections: [...delivered.connections, ...locals], source: delivered.source };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function effectiveSetup(dir = agentsDir(), delivered: Delivered | null = deliveredSetup()): ModelSetup {
|
|
25
|
+
const raw = readModelFile(dir);
|
|
26
|
+
return isV2(raw) ? v2Setup(raw) : setupOf(delivered, claudeLoginsIn(raw));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function modelViewText(setup: ModelSetup): string | null {
|
|
30
|
+
const primary = primaryRoute(setup);
|
|
31
|
+
if (primary === null) return null;
|
|
32
|
+
const connections = [{ id: primary.connection.id, provider: primary.connection.provider, model: primary.model }];
|
|
33
|
+
return `${JSON.stringify({ version: 2, route: primary.connection.id, connections }, null, 2)}\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function watchModelChanges(models: Pick<OrgModels, 'onChange'>, switchModel: (model: string | null) => boolean, setup: () => ModelSetup = effectiveSetup): () => void {
|
|
37
|
+
let last = runnerModel(setup());
|
|
38
|
+
const changed = (): void => {
|
|
39
|
+
const now = runnerModel(setup());
|
|
40
|
+
if (now === last) return;
|
|
41
|
+
const was = last;
|
|
42
|
+
last = now;
|
|
43
|
+
if (switchModel(now)) log.info({ was, now }, 'org-models: the model changed, so the Agent SDK session switches to it live, with no restart');
|
|
44
|
+
else log.info({ was, now }, 'org-models: the model changed while no Agent SDK session listens; its next start loads it');
|
|
45
|
+
};
|
|
46
|
+
return models.onChange(changed);
|
|
47
|
+
}
|