@stage-labs/metro 0.1.0-beta.310 → 0.1.0-beta.311
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-env.js +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -36
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +4 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -9
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +4 -15
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +5 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +31 -49
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +18 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +2 -40
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-leftovers.ts +24 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/subscription.ts +1 -61
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +11 -55
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +1 -66
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +0 -243
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +0 -128
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +0 -192
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -40
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +0 -77
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from '../agent-user/agent-fs.js';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import { writeHomeText } from '../agent-user/home-fs.js';
|
|
6
|
-
import { agentUser } from '../agent-user/user.js';
|
|
7
|
-
|
|
8
|
-
const FILE = '.claude.json';
|
|
9
|
-
const FLAG = 'hasCompletedOnboarding';
|
|
10
|
-
|
|
11
|
-
export type OnboardingMark = 'marked' | 'already' | 'unreadable';
|
|
12
|
-
|
|
13
|
-
const given = (value: string | undefined): string | undefined =>
|
|
14
|
-
value !== undefined && value.trim() !== '' ? value.trim() : undefined;
|
|
15
|
-
|
|
16
|
-
export function claudeConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
-
const user = agentUser();
|
|
18
|
-
if (user !== null) return join(user.home, FILE);
|
|
19
|
-
return join(given(env.CLAUDE_CONFIG_DIR) ?? given(env.HOME) ?? homedir(), FILE);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function readConfig(path: string): Record<string, unknown> | null {
|
|
23
|
-
if (!existsSync(path)) return {};
|
|
24
|
-
try {
|
|
25
|
-
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
26
|
-
return isRecord(parsed) ? parsed : null;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function markOnboardingDone(path = claudeConfigPath()): OnboardingMark {
|
|
33
|
-
const config = readConfig(path);
|
|
34
|
-
if (config === null) return 'unreadable';
|
|
35
|
-
if (config[FLAG] === true) return 'already';
|
|
36
|
-
writeHomeText(path, `${JSON.stringify({ ...config, [FLAG]: true }, null, 2)}\n`, 0o600);
|
|
37
|
-
return 'marked';
|
|
38
|
-
}
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
-
import { agentCommand, agentUser, agentUserExpected, CLAUDE_ENV, claudeBin } from '../agent-user/user.js';
|
|
5
|
-
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
6
|
-
import { PRIVACY_ENV } from './setup.js';
|
|
7
|
-
|
|
8
|
-
const REQUEST_ID = 'metro-usage';
|
|
9
|
-
const REQUEST = `${JSON.stringify({ type: 'control_request', request_id: REQUEST_ID, request: { subtype: 'get_usage', skip_behaviors: true } })}\n`;
|
|
10
|
-
const ARGS = [
|
|
11
|
-
'-p',
|
|
12
|
-
'--setting-sources',
|
|
13
|
-
'project',
|
|
14
|
-
'--strict-mcp-config',
|
|
15
|
-
'--mcp-config',
|
|
16
|
-
'{"mcpServers":{}}',
|
|
17
|
-
'--no-session-persistence',
|
|
18
|
-
'--input-format',
|
|
19
|
-
'stream-json',
|
|
20
|
-
'--output-format',
|
|
21
|
-
'stream-json',
|
|
22
|
-
'--verbose',
|
|
23
|
-
];
|
|
24
|
-
const BLOCKS_USAGE = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC';
|
|
25
|
-
const PROBE_ENV: Record<string, string> = {
|
|
26
|
-
...Object.fromEntries(Object.entries(PRIVACY_ENV).filter(([name]) => name !== BLOCKS_USAGE)),
|
|
27
|
-
...CLAUDE_ENV,
|
|
28
|
-
DISABLE_BUG_COMMAND: '1',
|
|
29
|
-
ENABLE_CLAUDEAI_MCP_SERVERS: 'false',
|
|
30
|
-
};
|
|
31
|
-
const TIMEOUT_MS = 20_000;
|
|
32
|
-
const KILL_GRACE_MS = 2_000;
|
|
33
|
-
const LINE_MAX = 1024 * 1024;
|
|
34
|
-
|
|
35
|
-
export interface UsageProbeDeps {
|
|
36
|
-
command?: string[];
|
|
37
|
-
timeoutMs?: number;
|
|
38
|
-
env?: Record<string, string>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function usageProbeCommand(env: Record<string, string> = {}): string[] | null {
|
|
42
|
-
if (runningAsRoot() || (agentUserExpected() && agentUser() === null)) return null;
|
|
43
|
-
return agentCommand([claudeBin(), ...ARGS], { ...PROBE_ENV, ...env });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function answerOf(line: string): Record<string, unknown> | null {
|
|
47
|
-
try {
|
|
48
|
-
const parsed: unknown = JSON.parse(line);
|
|
49
|
-
if (!isRecord(parsed) || parsed.type !== 'control_response' || !isRecord(parsed.response)) return null;
|
|
50
|
-
return parsed.response.request_id === REQUEST_ID ? parsed.response : null;
|
|
51
|
-
} catch {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function limitsOf(answer: Record<string, unknown>): unknown {
|
|
57
|
-
if (answer.subtype !== 'success') throw new Error(`Claude Code refused the usage request: ${typeof answer.error === 'string' ? answer.error : 'no reason given'}`);
|
|
58
|
-
return isRecord(answer.response) ? (answer.response.rate_limits ?? null) : null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function usageIn(output: string): unknown {
|
|
62
|
-
const answer = output
|
|
63
|
-
.split('\n')
|
|
64
|
-
.map(answerOf)
|
|
65
|
-
.find((found) => found !== null);
|
|
66
|
-
if (answer === undefined || answer === null) throw new Error('Claude Code gave no usage answer');
|
|
67
|
-
return limitsOf(answer);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function readClaudeUsage(deps: UsageProbeDeps = {}): Promise<unknown> {
|
|
71
|
-
const command = deps.command ?? usageProbeCommand(deps.env);
|
|
72
|
-
if (command === null) return Promise.reject(new Error('Claude Code runs only as the agent user, and this box has none'));
|
|
73
|
-
const [file = '', ...args] = command;
|
|
74
|
-
return new Promise((resolve, reject) => {
|
|
75
|
-
const child = spawn(file, args, { cwd: '/', stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, ...PROBE_ENV, ...deps.env } });
|
|
76
|
-
let carry = '';
|
|
77
|
-
let settled = false;
|
|
78
|
-
let hardKill: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
-
const settle = (run: () => void): void => {
|
|
80
|
-
if (settled) return;
|
|
81
|
-
settled = true;
|
|
82
|
-
run();
|
|
83
|
-
};
|
|
84
|
-
const overdue = setTimeout(() => {
|
|
85
|
-
settle(() => {
|
|
86
|
-
reject(new Error('Claude Code did not report the usage in time'));
|
|
87
|
-
});
|
|
88
|
-
child.stdout.destroy();
|
|
89
|
-
child.kill('SIGTERM');
|
|
90
|
-
hardKill = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS);
|
|
91
|
-
}, deps.timeoutMs ?? TIMEOUT_MS);
|
|
92
|
-
const take = (line: string): void => {
|
|
93
|
-
const answer = answerOf(line);
|
|
94
|
-
if (answer === null) return;
|
|
95
|
-
settle(() => {
|
|
96
|
-
try {
|
|
97
|
-
resolve(limitsOf(answer));
|
|
98
|
-
} catch (err) {
|
|
99
|
-
reject(err instanceof Error ? err : new Error(errMsg(err)));
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
};
|
|
103
|
-
child.stdout.on('data', (chunk: Buffer) => {
|
|
104
|
-
const lines = (carry + chunk.toString('utf8')).split('\n');
|
|
105
|
-
carry = lines.pop() ?? '';
|
|
106
|
-
if (carry.length > LINE_MAX) carry = '';
|
|
107
|
-
lines.forEach(take);
|
|
108
|
-
});
|
|
109
|
-
child.stdin.on('error', (err) => {
|
|
110
|
-
log.debug({ err: errMsg(err) }, 'claude-usage: Claude Code closed its input early');
|
|
111
|
-
});
|
|
112
|
-
child.on('error', (err) => {
|
|
113
|
-
clearTimeout(overdue);
|
|
114
|
-
settle(() => {
|
|
115
|
-
reject(err);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
child.on('close', () => {
|
|
119
|
-
clearTimeout(overdue);
|
|
120
|
-
clearTimeout(hardKill);
|
|
121
|
-
take(carry);
|
|
122
|
-
settle(() => {
|
|
123
|
-
reject(new Error('Claude Code gave no usage answer'));
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
child.stdin.end(REQUEST);
|
|
127
|
-
});
|
|
128
|
-
}
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { homedir } from 'node:os';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { ID_RE, newId } from '@metro-labs/core/ids';
|
|
4
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
7
|
-
import { readFileSync } from '../agent-user/agent-fs.js';
|
|
8
|
-
import { moveHome, removeHome } from '../agent-user/home-fs.js';
|
|
9
|
-
import { claudeHome } from '../agent-user/user.js';
|
|
10
|
-
import { claudeAccountIn, type ClaudeAccount, type LoginTarget } from '../claude/login.js';
|
|
11
|
-
import { readClaudeUsage } from '../claude/usage-probe.js';
|
|
12
|
-
import { currentOf, reach, refreshed, type TokenSource, type TokenState } from './subscription.js';
|
|
13
|
-
import { forgetOne, forgetReported } from './usage.js';
|
|
14
|
-
import { readClaudeLogins, writeClaudeLogins, type ClaudeLogin } from './claude-store.js';
|
|
15
|
-
import { loginRef } from './model-config.js';
|
|
16
|
-
|
|
17
|
-
const CREDENTIALS = '.credentials.json';
|
|
18
|
-
const RENEW_MARGIN_MS = 60_000;
|
|
19
|
-
const NEW = 'new';
|
|
20
|
-
const MISSING = 'this Claude login is no longer on the box: sign in again on the Model page';
|
|
21
|
-
|
|
22
|
-
export interface ClaudeLoginDeps {
|
|
23
|
-
root?: string;
|
|
24
|
-
probe?: (env: Record<string, string>) => Promise<unknown>;
|
|
25
|
-
account?: (env: Record<string, string>) => ClaudeAccount;
|
|
26
|
-
dir?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface ClaudeTokens {
|
|
30
|
-
accessToken: string;
|
|
31
|
-
expiresAt: number | null;
|
|
32
|
-
savedAt: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const state: TokenState<ClaudeTokens> = new Map();
|
|
36
|
-
const seen = new Map<string, ClaudeTokens>();
|
|
37
|
-
|
|
38
|
-
const claudeLoginsRoot = (): string => join(claudeHome() ?? homedir(), '.metro', 'claude-logins');
|
|
39
|
-
|
|
40
|
-
const rootOf = (deps: ClaudeLoginDeps): string => deps.root ?? claudeLoginsRoot();
|
|
41
|
-
|
|
42
|
-
function claudeLoginEnv(login: string, deps: ClaudeLoginDeps = {}): Record<string, string> {
|
|
43
|
-
if (!ID_RE.test(login)) throw new Error('not a Claude login id');
|
|
44
|
-
return { CLAUDE_CONFIG_DIR: join(rootOf(deps), login) };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export const probeClaudeLogin = (login: string, deps: ClaudeLoginDeps = {}): Promise<unknown> =>
|
|
48
|
-
(deps.probe ?? ((env) => readClaudeUsage({ env })))(claudeLoginEnv(login, deps));
|
|
49
|
-
|
|
50
|
-
const stale = (tokens: ClaudeTokens, margin = RENEW_MARGIN_MS): boolean => tokens.expiresAt !== null && tokens.expiresAt - margin <= Date.now();
|
|
51
|
-
|
|
52
|
-
function onDisk(login: string, deps: ClaudeLoginDeps): ClaudeTokens | null {
|
|
53
|
-
let raw: unknown;
|
|
54
|
-
try {
|
|
55
|
-
raw = JSON.parse(readFileSync(join(claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '', CREDENTIALS), 'utf8'));
|
|
56
|
-
} catch {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
const oauth = isRecord(raw) && isRecord(raw.claudeAiOauth) ? raw.claudeAiOauth : {};
|
|
60
|
-
const accessToken = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
|
|
61
|
-
const expiresAt = typeof oauth.expiresAt === 'number' && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : null;
|
|
62
|
-
return accessToken === '' ? null : { accessToken, expiresAt, savedAt: new Date(expiresAt ?? 0).toISOString() };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function remembered(login: string, tokens: ClaudeTokens | null): ClaudeTokens | null {
|
|
66
|
-
if (tokens === null) seen.delete(login);
|
|
67
|
-
else seen.set(login, tokens);
|
|
68
|
-
return tokens;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function known(login: string, deps: ClaudeLoginDeps): ClaudeTokens | null {
|
|
72
|
-
const kept = seen.get(login);
|
|
73
|
-
if (kept !== undefined && !stale(kept)) return kept;
|
|
74
|
-
const read = onDisk(login, deps);
|
|
75
|
-
return read === null && kept !== undefined ? kept : remembered(login, read);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function sourceFor(login: string, deps: ClaudeLoginDeps): TokenSource<ClaudeTokens> {
|
|
79
|
-
return {
|
|
80
|
-
label: 'Claude',
|
|
81
|
-
stale: (tokens) => stale(tokens),
|
|
82
|
-
refresh: async () => {
|
|
83
|
-
try {
|
|
84
|
-
await probeClaudeLogin(login, deps);
|
|
85
|
-
} catch (err) {
|
|
86
|
-
log.warn({ err: errMsg(err) }, 'claude-logins: Claude Code stumbled while renewing a login, so its folder is read as it is');
|
|
87
|
-
}
|
|
88
|
-
const fresh = onDisk(login, deps);
|
|
89
|
-
if (fresh === null || stale(fresh, 0)) throw new Error('Claude Code could not renew this login');
|
|
90
|
-
return fresh;
|
|
91
|
-
},
|
|
92
|
-
save: (id, tokens) => {
|
|
93
|
-
remembered(id, tokens);
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function again(login: string, failed: ClaudeTokens, deps: ClaudeLoginDeps, source: TokenSource<ClaudeTokens>): Promise<ClaudeTokens> {
|
|
99
|
-
const disk = onDisk(login, deps);
|
|
100
|
-
if (disk !== null && disk.accessToken !== failed.accessToken && !stale(disk)) return Promise.resolve(remembered(login, disk) ?? disk);
|
|
101
|
-
return refreshed(state, login, failed, source);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function withClaudeLogin(login: string, deps: ClaudeLoginDeps, send: (token: string) => Promise<Response>): Promise<Response> {
|
|
105
|
-
const source = sourceFor(login, deps);
|
|
106
|
-
return reach(
|
|
107
|
-
() => currentOf(state, login, known(login, deps), source, MISSING),
|
|
108
|
-
(failed) => again(login, failed, deps, source),
|
|
109
|
-
(tokens) => send(tokens.accessToken),
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const folderOf = (login: string, deps: ClaudeLoginDeps): string => claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '';
|
|
114
|
-
|
|
115
|
-
function forgetTokens(login: string): void {
|
|
116
|
-
seen.delete(login);
|
|
117
|
-
state.delete(login);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function dropFolder(login: string, deps: ClaudeLoginDeps): void {
|
|
121
|
-
forgetTokens(login);
|
|
122
|
-
try {
|
|
123
|
-
removeHome(folderOf(login, deps), true);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of a Claude login that is no longer used');
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
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
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
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');
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function settle(ok: boolean, login: string, replacing: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
|
|
167
|
-
if (!ok) {
|
|
168
|
-
dropFolder(login, deps);
|
|
169
|
-
return Promise.resolve(null);
|
|
170
|
-
}
|
|
171
|
-
let problem: string | null;
|
|
172
|
-
try {
|
|
173
|
-
const account = (deps.account ?? claudeAccountIn)(claudeLoginEnv(login, deps));
|
|
174
|
-
problem = account.signedIn ? keep(login, replacing, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
|
|
175
|
-
} catch (err) {
|
|
176
|
-
problem = errMsg(err);
|
|
177
|
-
}
|
|
178
|
-
if (problem !== null) dropFolder(login, deps);
|
|
179
|
-
return Promise.resolve(problem);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function claudeLoginTarget(asked: string, deps: ClaudeLoginDeps = {}): LoginTarget {
|
|
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);
|
|
185
|
-
const login = newId();
|
|
186
|
-
return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, replacing, deps) };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export function forgetClaudeTokens(): void {
|
|
190
|
-
seen.clear();
|
|
191
|
-
state.clear();
|
|
192
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
import { ID_RE } from '@metro-labs/core/ids';
|
|
3
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
-
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
6
|
-
import { agentsDir } from '../agents/files.js';
|
|
7
|
-
|
|
8
|
-
export interface ClaudeLogin {
|
|
9
|
-
id: string;
|
|
10
|
-
email: string | null;
|
|
11
|
-
plan: string | null;
|
|
12
|
-
savedAt: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const MODEL_FILE = 'model.json';
|
|
16
|
-
|
|
17
|
-
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
18
|
-
const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
19
|
-
|
|
20
|
-
function claudeLoginOf(raw: unknown): ClaudeLogin | null {
|
|
21
|
-
if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
|
|
22
|
-
return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const modelPath = (dir: string): string => join(dir, MODEL_FILE);
|
|
26
|
-
|
|
27
|
-
const readModelFile = (dir: string): unknown =>
|
|
28
|
-
readJson<unknown>(modelPath(dir), null, { warn: 'model.json is unreadable, so the Claude logins of this box are not seen until it is fixed' });
|
|
29
|
-
|
|
30
|
-
const isV2 = (raw: unknown): raw is Record<string, unknown> & { connections: unknown[] } =>
|
|
31
|
-
isRecord(raw) && raw.version !== 3 && Array.isArray(raw.connections);
|
|
32
|
-
|
|
33
|
-
const unique = (logins: ClaudeLogin[]): ClaudeLogin[] => logins.filter((l, at) => logins.findIndex((o) => o.id === l.id) === at);
|
|
34
|
-
|
|
35
|
-
function claudeLoginsIn(raw: unknown): ClaudeLogin[] {
|
|
36
|
-
if (isV2(raw)) return unique(raw.connections.flatMap((c: unknown) => (isRecord(c) && c.provider === 'anthropic' ? (claudeLoginOf(c.claude) ?? []) : [])));
|
|
37
|
-
if (!isRecord(raw) || !Array.isArray(raw.claude)) return [];
|
|
38
|
-
return unique(raw.claude.flatMap((c: unknown) => claudeLoginOf(c) ?? []));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export const readClaudeLogins = (dir = agentsDir()): ClaudeLogin[] => claudeLoginsIn(readModelFile(dir));
|
|
42
|
-
|
|
43
|
-
export function writeClaudeLogins(logins: ClaudeLogin[], dir = agentsDir()): void {
|
|
44
|
-
writeSecure(modelPath(dir), JSON.stringify({ version: 3, claude: logins }, null, 2));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function retireV2ModelFile(dir = agentsDir()): boolean {
|
|
48
|
-
try {
|
|
49
|
-
const raw = readModelFile(dir);
|
|
50
|
-
if (!isV2(raw)) return false;
|
|
51
|
-
const logins = claudeLoginsIn(raw);
|
|
52
|
-
writeClaudeLogins(logins, dir);
|
|
53
|
-
log.warn({ claudeLogins: logins.length, connections: raw.connections.length }, 'org-models: model.json was still the old version 2; it keeps only its Claude logins now, and its connections are never served');
|
|
54
|
-
return true;
|
|
55
|
-
} catch (err) {
|
|
56
|
-
log.warn({ err: errMsg(err) }, 'org-models: the old version 2 model.json could not be rewritten; its connections are never served');
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
-
|
|
3
|
-
export const LIST_TTL_MS = 60 * 60_000;
|
|
4
|
-
export const RETRY_MS = 5 * 60_000;
|
|
5
|
-
|
|
6
|
-
export interface ListCache<T> {
|
|
7
|
-
peek: (key: string) => T[] | null;
|
|
8
|
-
refresh: (key: string, load: () => Promise<T[]>) => Promise<void>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const clears = new Set<() => void>();
|
|
12
|
-
|
|
13
|
-
export function listCache<T>(name: string, now: () => number = Date.now): ListCache<T> {
|
|
14
|
-
const kept = new Map<string, { at: number; list: T[] }>();
|
|
15
|
-
const tried = new Map<string, number>();
|
|
16
|
-
clears.add(() => {
|
|
17
|
-
kept.clear();
|
|
18
|
-
tried.clear();
|
|
19
|
-
});
|
|
20
|
-
const fresh = (key: string): boolean => {
|
|
21
|
-
const hit = kept.get(key);
|
|
22
|
-
return hit !== undefined && now() - hit.at < LIST_TTL_MS;
|
|
23
|
-
};
|
|
24
|
-
return {
|
|
25
|
-
peek: (key) => kept.get(key)?.list ?? null,
|
|
26
|
-
async refresh(key, load) {
|
|
27
|
-
if (fresh(key) || now() - (tried.get(key) ?? Number.NEGATIVE_INFINITY) < RETRY_MS) return;
|
|
28
|
-
tried.set(key, now());
|
|
29
|
-
try {
|
|
30
|
-
kept.set(key, { at: now(), list: await load() });
|
|
31
|
-
} catch (err) {
|
|
32
|
-
log.warn({ list: name, err: errMsg(err) }, 'model-lists: the provider would not list its models, so the last list it gave stands');
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function forgetModelLists(): void {
|
|
39
|
-
for (const clear of clears) clear();
|
|
40
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import { GatewayError, OAUTH_BETA } from './forward.js';
|
|
4
|
-
import { listCache } from './model-lists.js';
|
|
5
|
-
import { stringOf } from '@metro-labs/http/api-http';
|
|
6
|
-
|
|
7
|
-
export const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
8
|
-
const ANTHROPIC_VERSION = '2023-06-01';
|
|
9
|
-
const LIST_MAX = 1000;
|
|
10
|
-
const LIST_TIMEOUT_MS = 10_000;
|
|
11
|
-
const BEARER = 'Bearer ';
|
|
12
|
-
|
|
13
|
-
export interface ProviderModel {
|
|
14
|
-
id: string;
|
|
15
|
-
name: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface Dated extends ProviderModel {
|
|
19
|
-
created: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const KNOWN_CLAUDE: ProviderModel[] = [
|
|
23
|
-
{ id: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5' },
|
|
24
|
-
{ id: 'claude-opus-5-5', name: 'Claude Opus 5.5' },
|
|
25
|
-
{ id: 'claude-fable-5-1', name: 'Claude Fable 5.1' },
|
|
26
|
-
{ id: 'claude-opus-5', name: 'Claude Opus 5' },
|
|
27
|
-
{ id: 'claude-sonnet-5', name: 'Claude Sonnet 5' },
|
|
28
|
-
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
const claudeLists = listCache<ProviderModel>('anthropic');
|
|
32
|
-
|
|
33
|
-
const newestFirst = (list: Dated[]): ProviderModel[] =>
|
|
34
|
-
list.sort((a, b) => b.created.localeCompare(a.created) || a.id.localeCompare(b.id)).map(({ id, name }) => ({ id, name }));
|
|
35
|
-
|
|
36
|
-
function claudeEntry(entry: unknown): Dated[] {
|
|
37
|
-
if (!isRecord(entry) || stringOf(entry.id) === '') return [];
|
|
38
|
-
const type = stringOf(entry.type);
|
|
39
|
-
if (type !== '' && type !== 'model') return [];
|
|
40
|
-
const id = stringOf(entry.id);
|
|
41
|
-
return [{ id, name: stringOf(entry.display_name) || id, created: stringOf(entry.created_at) }];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function listClaude(auth: Record<string, string>, what: string, base: string, fetchImpl: typeof fetch): Promise<ProviderModel[]> {
|
|
45
|
-
const res = await fetchImpl(`${base}/v1/models?limit=${String(LIST_MAX)}`, {
|
|
46
|
-
headers: { ...auth, 'anthropic-version': ANTHROPIC_VERSION, accept: 'application/json' },
|
|
47
|
-
redirect: 'manual',
|
|
48
|
-
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
49
|
-
});
|
|
50
|
-
if (!res.ok) throw new GatewayError(res.status, 'api_error', `Anthropic would not list its models with ${what} (${String(res.status)})`);
|
|
51
|
-
const body: unknown = await res.json();
|
|
52
|
-
const data = isRecord(body) && Array.isArray(body.data) ? body.data : null;
|
|
53
|
-
if (data === null) throw new GatewayError(502, 'api_error', 'Anthropic answered with no model list');
|
|
54
|
-
return newestFirst(data.flatMap(claudeEntry));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const loginKey = (base: string): string => `login:${base}`;
|
|
58
|
-
|
|
59
|
-
const first = (value: string | string[] | undefined): string => (Array.isArray(value) ? value[0] : value)?.trim() ?? '';
|
|
60
|
-
|
|
61
|
-
function loginAuth(headers: IncomingHttpHeaders): Record<string, string> | null {
|
|
62
|
-
const bearer = first(headers.authorization);
|
|
63
|
-
if (bearer.startsWith(BEARER) && bearer.length > BEARER.length) return { authorization: bearer, 'anthropic-beta': OAUTH_BETA };
|
|
64
|
-
const key = first(headers['x-api-key']);
|
|
65
|
-
return key === '' ? null : { 'x-api-key': key };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function refreshLoginModels(headers: IncomingHttpHeaders, base = ANTHROPIC_API, fetchImpl: typeof fetch = fetch): Promise<void> {
|
|
69
|
-
const auth = loginAuth(headers);
|
|
70
|
-
if (auth === null) return Promise.resolve();
|
|
71
|
-
return claudeLists.refresh(loginKey(base), () => listClaude(auth, 'the Claude Code login', base, fetchImpl));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function claudeModels(base = ANTHROPIC_API): ProviderModel[] {
|
|
75
|
-
const live = claudeLists.peek(loginKey(base)) ?? [];
|
|
76
|
-
return live.length > 0 ? live : KNOWN_CLAUDE;
|
|
77
|
-
}
|