@stage-labs/metro 0.1.0-beta.252 → 0.1.0-beta.254
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 +2 -2
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +9 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +52 -18
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +5 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +168 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +14 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +9 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +58 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +12 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +21 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +5 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +16 -5
- package/runtime/node_modules/@metro-labs/discord-bot/package.json +1 -1
- package/runtime/node_modules/@metro-labs/gmail/package.json +1 -1
- package/runtime/node_modules/@metro-labs/http/package.json +1 -1
- package/runtime/node_modules/@metro-labs/http/src/return-to.ts +4 -0
- package/runtime/node_modules/@metro-labs/outlook/package.json +1 -1
- package/runtime/node_modules/@metro-labs/telegram/package.json +1 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/package.json +1 -1
- package/runtime/node_modules/@metro-labs/threema/package.json +1 -1
- package/runtime/node_modules/@metro-labs/webhook/package.json +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/package.json +1 -1
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.254",
|
|
4
4
|
"description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test": "tsc --noEmit && node scripts/stage-runtime.mjs && bun test test/"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@stage-labs/config": "0.1.0-beta.
|
|
33
|
+
"@stage-labs/config": "0.1.0-beta.3",
|
|
34
34
|
"eslint": "^10.3.0",
|
|
35
35
|
"knip": "^6.15.0",
|
|
36
36
|
"madge": "^8.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.254",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
endClaudeLogin,
|
|
20
20
|
startClaudeLogin,
|
|
21
21
|
type LoginDeps,
|
|
22
|
+
type LoginTarget,
|
|
22
23
|
} from './login.js';
|
|
24
|
+
import { claudeLoginTarget, type ClaudeLoginDeps } from '../gateway/claude-logins.js';
|
|
23
25
|
import {
|
|
24
26
|
claudeSetupStatus,
|
|
25
27
|
ensureClaudeSetup,
|
|
@@ -71,6 +73,7 @@ const PAGE_MAX = 500;
|
|
|
71
73
|
export interface ClaudeApiDeps {
|
|
72
74
|
dir?: () => string;
|
|
73
75
|
login?: LoginDeps;
|
|
76
|
+
claudeLogins?: ClaudeLoginDeps;
|
|
74
77
|
session?: SessionDeps;
|
|
75
78
|
setup?: SetupDeps;
|
|
76
79
|
version?: VersionDeps;
|
|
@@ -265,10 +268,15 @@ async function sessionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise
|
|
|
265
268
|
return sessionCommand(body, session);
|
|
266
269
|
}
|
|
267
270
|
|
|
271
|
+
function loginTarget(req: IncomingMessage, deps: ClaudeApiDeps): LoginTarget | null {
|
|
272
|
+
const asked = new URL(req.url ?? '', 'http://metro').searchParams.get('connection');
|
|
273
|
+
return asked === null ? null : claudeLoginTarget(asked, deps.claudeLogins);
|
|
274
|
+
}
|
|
275
|
+
|
|
268
276
|
async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps): Promise<unknown> {
|
|
269
277
|
const method = req.method ?? 'GET';
|
|
270
278
|
if (id === '') {
|
|
271
|
-
if (method === 'POST') return startClaudeLogin(deps.login);
|
|
279
|
+
if (method === 'POST') return startClaudeLogin(deps.login, Date.now(), loginTarget(req, deps));
|
|
272
280
|
if (method === 'GET') return { available: claudeInstalled(), ...claudeAccount() };
|
|
273
281
|
throw new ApiError('method not allowed', 405);
|
|
274
282
|
}
|
|
@@ -26,12 +26,19 @@ export interface LoginView {
|
|
|
26
26
|
error: string | null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export interface LoginTarget {
|
|
30
|
+
key: string;
|
|
31
|
+
env: Record<string, string>;
|
|
32
|
+
settle: (ok: boolean) => Promise<string | null>;
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
interface Live {
|
|
30
36
|
id: string;
|
|
31
37
|
startedAt: number;
|
|
32
38
|
state: LoginState;
|
|
33
39
|
output: string;
|
|
34
40
|
error: string | null;
|
|
41
|
+
target: LoginTarget | null;
|
|
35
42
|
write: (text: string) => void;
|
|
36
43
|
kill: () => void;
|
|
37
44
|
}
|
|
@@ -102,27 +109,32 @@ export function claudeInstalled(): boolean {
|
|
|
102
109
|
});
|
|
103
110
|
}
|
|
104
111
|
|
|
105
|
-
interface ClaudeAccount {
|
|
112
|
+
export interface ClaudeAccount {
|
|
106
113
|
signedIn: boolean;
|
|
107
114
|
account: string | null;
|
|
115
|
+
plan: string | null;
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
const SIGNED_OUT: ClaudeAccount = { signedIn: false, account: null, plan: null };
|
|
119
|
+
|
|
110
120
|
export function claudeAccount(): ClaudeAccount {
|
|
111
|
-
return remembered(checks.account,
|
|
121
|
+
return remembered(checks.account, () => claudeAccountIn({}), (c) => {
|
|
112
122
|
checks.account = c;
|
|
113
123
|
});
|
|
114
124
|
}
|
|
115
125
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
const textOf = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
127
|
+
|
|
128
|
+
export function claudeAccountIn(env: Record<string, string>): ClaudeAccount {
|
|
129
|
+
const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND, env), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, ...env } });
|
|
130
|
+
if (run.error !== undefined || typeof run.stdout !== 'string') return SIGNED_OUT;
|
|
119
131
|
try {
|
|
120
132
|
const parsed: unknown = JSON.parse(run.stdout);
|
|
121
|
-
if (!isRecord(parsed)) return
|
|
122
|
-
const account =
|
|
123
|
-
return { signedIn: parsed.authenticated === true || parsed.loggedIn === true || account !== null, account };
|
|
133
|
+
if (!isRecord(parsed)) return SIGNED_OUT;
|
|
134
|
+
const account = textOf(parsed.email) ?? textOf(parsed.organization) ?? textOf(parsed.orgName);
|
|
135
|
+
return { signedIn: parsed.authenticated === true || parsed.loggedIn === true || account !== null, account, plan: textOf(parsed.subscriptionType) };
|
|
124
136
|
} catch {
|
|
125
|
-
return
|
|
137
|
+
return SIGNED_OUT;
|
|
126
138
|
}
|
|
127
139
|
}
|
|
128
140
|
|
|
@@ -131,16 +143,37 @@ function note(session: Live, chunk: string): void {
|
|
|
131
143
|
session.output = next.length > OUTPUT_MAX ? next.slice(next.length - OUTPUT_MAX) : next;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
|
-
|
|
146
|
+
async function verdict(session: Live, code: number): Promise<string | null> {
|
|
147
|
+
const problem = code === 0 ? null : `the login ended with status ${String(code)}`;
|
|
148
|
+
if (session.target === null) return problem;
|
|
149
|
+
try {
|
|
150
|
+
return (await session.target.settle(problem === null)) ?? problem;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return errMsg(err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function runningFor(target: LoginTarget | null): Live | null {
|
|
157
|
+
if (live === null || live.state !== 'pending') return null;
|
|
158
|
+
if (live.target?.key === target?.key) return live;
|
|
159
|
+
live.kill();
|
|
160
|
+
live = null;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now(), target: LoginTarget | null = null): LoginView {
|
|
135
165
|
sweep(now);
|
|
136
|
-
|
|
137
|
-
|
|
166
|
+
const running = runningFor(target);
|
|
167
|
+
if (running !== null) return view(running);
|
|
168
|
+
const env = { TERM: 'xterm-256color', ...target?.env };
|
|
169
|
+
const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], env);
|
|
138
170
|
const session: Live = {
|
|
139
171
|
id: randomBytes(9).toString('base64url'),
|
|
140
172
|
startedAt: now,
|
|
141
173
|
state: 'pending',
|
|
142
174
|
output: '',
|
|
143
175
|
error: null,
|
|
176
|
+
target,
|
|
144
177
|
write: () => undefined,
|
|
145
178
|
kill: () => undefined,
|
|
146
179
|
};
|
|
@@ -151,7 +184,7 @@ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginV
|
|
|
151
184
|
note(session, Buffer.from(chunk).toString('utf8'));
|
|
152
185
|
},
|
|
153
186
|
});
|
|
154
|
-
const proc = Bun.spawn(command, { terminal, env: { ...process.env,
|
|
187
|
+
const proc = Bun.spawn(command, { terminal, env: { ...process.env, ...env } });
|
|
155
188
|
session.write = (text: string) => {
|
|
156
189
|
terminal.write(Buffer.from(`${text}\r`, 'utf8'));
|
|
157
190
|
};
|
|
@@ -160,13 +193,14 @@ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginV
|
|
|
160
193
|
terminal.close();
|
|
161
194
|
};
|
|
162
195
|
proc.exited
|
|
163
|
-
.then((code) => {
|
|
164
|
-
session.
|
|
165
|
-
if (code !== 0) session.error = `the login ended with status ${String(code)}`;
|
|
196
|
+
.then(async (code) => {
|
|
197
|
+
session.write = () => undefined;
|
|
166
198
|
terminal.close();
|
|
167
199
|
forgetClaudeChecks();
|
|
168
|
-
|
|
169
|
-
|
|
200
|
+
session.error = await verdict(session, code);
|
|
201
|
+
session.state = session.error === null ? 'done' : 'failed';
|
|
202
|
+
const onboarding = session.error === null ? markOnboardingDone() : 'skipped';
|
|
203
|
+
log.info({ state: session.state, onboarding, own: session.target !== null }, 'claude-login: the official login finished');
|
|
170
204
|
})
|
|
171
205
|
.catch((err: unknown) => {
|
|
172
206
|
session.state = 'failed';
|
|
@@ -35,11 +35,12 @@ const LINE_MAX = 1024 * 1024;
|
|
|
35
35
|
export interface UsageProbeDeps {
|
|
36
36
|
command?: string[];
|
|
37
37
|
timeoutMs?: number;
|
|
38
|
+
env?: Record<string, string>;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
export function usageProbeCommand(): string[] | null {
|
|
41
|
+
export function usageProbeCommand(env: Record<string, string> = {}): string[] | null {
|
|
41
42
|
if (runningAsRoot() || (agentUserExpected() && agentUser() === null)) return null;
|
|
42
|
-
return agentCommand([claudeBin(), ...ARGS], PROBE_ENV);
|
|
43
|
+
return agentCommand([claudeBin(), ...ARGS], { ...PROBE_ENV, ...env });
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
function answerOf(line: string): Record<string, unknown> | null {
|
|
@@ -67,11 +68,11 @@ export function usageIn(output: string): unknown {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export function readClaudeUsage(deps: UsageProbeDeps = {}): Promise<unknown> {
|
|
70
|
-
const command = deps.command ?? usageProbeCommand();
|
|
71
|
+
const command = deps.command ?? usageProbeCommand(deps.env);
|
|
71
72
|
if (command === null) return Promise.reject(new Error('Claude Code runs only as the agent user, and this box has none'));
|
|
72
73
|
const [file = '', ...args] = command;
|
|
73
74
|
return new Promise((resolve, reject) => {
|
|
74
|
-
const child = spawn(file, args, { cwd: '/', stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, ...PROBE_ENV } });
|
|
75
|
+
const child = spawn(file, args, { cwd: '/', stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, ...PROBE_ENV, ...deps.env } });
|
|
75
76
|
let carry = '';
|
|
76
77
|
let settled = false;
|
|
77
78
|
let hardKill: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -0,0 +1,168 @@
|
|
|
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 { 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 { forgetReported } from './usage.js';
|
|
14
|
+
import { addConnection, connectionOf, readModelConfig, setClaudeLogin, writeModelConfig, type ModelConfig } from './model-config.js';
|
|
15
|
+
|
|
16
|
+
const CREDENTIALS = '.credentials.json';
|
|
17
|
+
const RENEW_MARGIN_MS = 60_000;
|
|
18
|
+
const NEW = 'new';
|
|
19
|
+
const MISSING = 'this Claude login is no longer on the box: sign in again on the Model page';
|
|
20
|
+
|
|
21
|
+
export interface ClaudeLoginDeps {
|
|
22
|
+
root?: string;
|
|
23
|
+
probe?: (env: Record<string, string>) => Promise<unknown>;
|
|
24
|
+
account?: (env: Record<string, string>) => ClaudeAccount;
|
|
25
|
+
read?: () => ModelConfig;
|
|
26
|
+
write?: (cfg: ModelConfig) => void;
|
|
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
|
+
function dropClaudeLogin(login: string, deps: ClaudeLoginDeps): void {
|
|
114
|
+
seen.delete(login);
|
|
115
|
+
state.delete(login);
|
|
116
|
+
try {
|
|
117
|
+
removeHome(claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '', true);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
log.warn({ err: errMsg(err) }, 'claude-logins: could not remove a Claude login that is no longer used');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function dropUnusedClaudeLogins(before: ModelConfig, after: ModelConfig, deps: ClaudeLoginDeps = {}): void {
|
|
124
|
+
const kept = new Set(after.connections.flatMap((c) => (c.claude === null ? [] : [c.claude.id])));
|
|
125
|
+
for (const c of before.connections) if (c.claude !== null && !kept.has(c.claude.id)) dropClaudeLogin(c.claude.id, deps);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function keep(login: string, connection: string | null, account: ClaudeAccount, deps: ClaudeLoginDeps): string | null {
|
|
129
|
+
const before = (deps.read ?? readModelConfig)();
|
|
130
|
+
const cfg = connection === null ? addConnection(before, { provider: 'anthropic' }) : before;
|
|
131
|
+
const id = connection ?? cfg.connections.at(-1)?.id ?? '';
|
|
132
|
+
if (connectionOf(cfg, id)?.provider !== 'anthropic') return 'that connection was removed during the sign-in';
|
|
133
|
+
const next = setClaudeLogin(cfg, id, { id: login, email: account.account, plan: account.plan, savedAt: new Date().toISOString() });
|
|
134
|
+
(deps.write ?? writeModelConfig)(next);
|
|
135
|
+
dropUnusedClaudeLogins(before, next, deps);
|
|
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');
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function settle(ok: boolean, login: string, connection: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
|
|
142
|
+
if (!ok) {
|
|
143
|
+
dropClaudeLogin(login, deps);
|
|
144
|
+
return Promise.resolve(null);
|
|
145
|
+
}
|
|
146
|
+
let problem: string | null;
|
|
147
|
+
try {
|
|
148
|
+
const account = (deps.account ?? claudeAccountIn)(claudeLoginEnv(login, deps));
|
|
149
|
+
problem = account.signedIn ? keep(login, connection, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
|
|
150
|
+
} catch (err) {
|
|
151
|
+
problem = errMsg(err);
|
|
152
|
+
}
|
|
153
|
+
if (problem !== null) dropClaudeLogin(login, deps);
|
|
154
|
+
return Promise.resolve(problem);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function claudeLoginTarget(asked: string, deps: ClaudeLoginDeps = {}): LoginTarget {
|
|
158
|
+
const connection = asked === NEW ? null : asked;
|
|
159
|
+
if (connection !== null && connectionOf((deps.read ?? readModelConfig)(), connection)?.provider !== 'anthropic')
|
|
160
|
+
throw new ApiError('no such Anthropic connection', 404);
|
|
161
|
+
const login = newId();
|
|
162
|
+
return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, connection, deps) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function forgetClaudeTokens(): void {
|
|
166
|
+
seen.clear();
|
|
167
|
+
state.clear();
|
|
168
|
+
}
|
|
@@ -38,16 +38,26 @@ export function requestKind(req: IncomingMessage, body: Body): RequestKind {
|
|
|
38
38
|
return chore(body) ? 'other' : 'main';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const messagesOf = (body: Body): unknown[] => (Array.isArray(body.messages) ? body.messages : []);
|
|
42
|
+
|
|
43
|
+
const askedEfforts = (body: Body): string[] => [body, ...messagesOf(body).map(record)].map(effortOf).filter((effort) => effort !== '');
|
|
44
|
+
|
|
41
45
|
export function plannedEffort(req: IncomingMessage, body: Body): string | null {
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
46
|
+
const asked = askedEfforts(body);
|
|
47
|
+
if (asked.length === 0) return null;
|
|
44
48
|
const kind = requestKind(req, body);
|
|
45
49
|
if (kind === 'other') return null;
|
|
46
50
|
const wanted = kind === 'subagent' ? SUBAGENT_EFFORT : MAIN_EFFORT;
|
|
47
|
-
return
|
|
51
|
+
return asked.every((effort) => effort === wanted) ? null : wanted;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
const withOwnEffort = (item: Body, effort: string): Body => (effortOf(item) === '' ? item : { ...item, output_config: { ...record(item.output_config), effort } });
|
|
55
|
+
|
|
56
|
+
export function withEffort(body: Body, effort: string): Body {
|
|
57
|
+
const top = withOwnEffort(body, effort);
|
|
58
|
+
if (!Array.isArray(body.messages)) return top;
|
|
59
|
+
return { ...top, messages: body.messages.map((message: unknown) => (isRecord(message) ? withOwnEffort(message, effort) : message)) };
|
|
60
|
+
}
|
|
51
61
|
|
|
52
62
|
export function withoutEffort(body: Body): Body {
|
|
53
63
|
const config = Object.fromEntries(Object.entries(record(body.output_config)).filter(([key]) => key !== 'effort'));
|
|
@@ -128,6 +128,15 @@ export function anthropicHeaders(req: IncomingMessage, apiKey: string): Record<s
|
|
|
128
128
|
return headers;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
export const OAUTH_BETA = 'oauth-2025-04-20';
|
|
132
|
+
|
|
133
|
+
export function loginHeaders(req: IncomingMessage, token: string): Record<string, string> {
|
|
134
|
+
const headers = forwardedHeaders(req);
|
|
135
|
+
delete headers['x-api-key'];
|
|
136
|
+
headers.authorization = `Bearer ${token}`;
|
|
137
|
+
return addBeta(headers, OAUTH_BETA);
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
export function addBeta(headers: Record<string, string>, beta: string): Record<string, string> {
|
|
132
141
|
const listed = (headers['anthropic-beta'] ?? '')
|
|
133
142
|
.split(',')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
1
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { cameThroughProxy } from '@metro-labs/http/api-http';
|
|
4
4
|
import { agentIdForKey } from '../agents/keys.js';
|
|
@@ -10,7 +10,20 @@ import {
|
|
|
10
10
|
freshAdaptations,
|
|
11
11
|
type Adaptations,
|
|
12
12
|
} from './bedrock.js';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
addBeta,
|
|
15
|
+
anthropicHeaders,
|
|
16
|
+
forwardedHeaders,
|
|
17
|
+
GatewayError,
|
|
18
|
+
holdLimitRefusal,
|
|
19
|
+
loginHeaders,
|
|
20
|
+
parseJson,
|
|
21
|
+
pipeResponse,
|
|
22
|
+
readBody,
|
|
23
|
+
sendError,
|
|
24
|
+
upstreamMessage,
|
|
25
|
+
watchUpstream,
|
|
26
|
+
} from './forward.js';
|
|
14
27
|
import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
|
|
15
28
|
import {
|
|
16
29
|
BINDING_BETA,
|
|
@@ -38,6 +51,7 @@ import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
|
38
51
|
import { fitToolSearch } from './tool-search.js';
|
|
39
52
|
import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
|
|
40
53
|
import type { CodexTokens } from './codex-auth.js';
|
|
54
|
+
import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
|
|
41
55
|
|
|
42
56
|
const GATEWAY_PREFIX = '/gateway';
|
|
43
57
|
const MESSAGES = '/v1/messages';
|
|
@@ -53,6 +67,7 @@ export interface GatewayDeps {
|
|
|
53
67
|
openrouterBase?: string;
|
|
54
68
|
codex?: Partial<CodexDeps>;
|
|
55
69
|
gemini?: Partial<GeminiDeps>;
|
|
70
|
+
claudeLogins?: ClaudeLoginDeps;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
const learned: Adaptations = freshAdaptations();
|
|
@@ -66,6 +81,7 @@ export function resetGatewayState(): void {
|
|
|
66
81
|
forgetLearnedThinking();
|
|
67
82
|
sharedCodexState.clear();
|
|
68
83
|
sharedGeminiState.clear();
|
|
84
|
+
forgetClaudeTokens();
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
|
|
@@ -90,7 +106,18 @@ function modelsBody(cfg: ModelConfig): Record<string, unknown> {
|
|
|
90
106
|
return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
|
|
91
107
|
}
|
|
92
108
|
|
|
93
|
-
const PASSTHROUGH: Connection = {
|
|
109
|
+
const PASSTHROUGH: Connection = {
|
|
110
|
+
id: PASSTHROUGH_ID,
|
|
111
|
+
provider: 'anthropic',
|
|
112
|
+
label: 'Claude Code login',
|
|
113
|
+
model: '',
|
|
114
|
+
apiKey: '',
|
|
115
|
+
region: '',
|
|
116
|
+
zdr: false,
|
|
117
|
+
codex: null,
|
|
118
|
+
gemini: null,
|
|
119
|
+
claude: null,
|
|
120
|
+
};
|
|
94
121
|
|
|
95
122
|
const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
|
|
96
123
|
|
|
@@ -139,12 +166,27 @@ async function refusedThinkingOff(upstream: Response, shaped: Record<string, unk
|
|
|
139
166
|
return true;
|
|
140
167
|
}
|
|
141
168
|
|
|
142
|
-
function listLoginModels(
|
|
143
|
-
refreshLoginModels(
|
|
169
|
+
function listLoginModels(headers: IncomingHttpHeaders, anthropicBase: string): void {
|
|
170
|
+
refreshLoginModels(headers, anthropicBase).catch((err: unknown) => {
|
|
144
171
|
log.warn({ err: errMsg(err) }, 'gateway: could not list the models of the Claude Code login');
|
|
145
172
|
});
|
|
146
173
|
}
|
|
147
174
|
|
|
175
|
+
function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
|
|
176
|
+
if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
|
|
177
|
+
if (conn.claude === null) return attempt(forwardedHeaders(req));
|
|
178
|
+
return withClaudeLogin(conn.claude.id, deps.claudeLogins ?? {}, (token) => {
|
|
179
|
+
listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
|
|
180
|
+
return attempt(loginHeaders(req, token));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
|
|
185
|
+
if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
|
|
186
|
+
await upstream.body?.cancel();
|
|
187
|
+
throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login of ${conn.label}: sign in again on the Model page`);
|
|
188
|
+
}
|
|
189
|
+
|
|
148
190
|
async function toAnthropic(
|
|
149
191
|
req: IncomingMessage,
|
|
150
192
|
res: ServerResponse,
|
|
@@ -157,25 +199,28 @@ async function toAnthropic(
|
|
|
157
199
|
const conn = route.connection;
|
|
158
200
|
const anthropicBase = deps.anthropicBase ?? ANTHROPIC_API;
|
|
159
201
|
const url = `${anthropicBase}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
160
|
-
const
|
|
161
|
-
if (
|
|
202
|
+
const passthrough = conn.apiKey === '' && conn.claude === null;
|
|
203
|
+
if (passthrough && standsInFor(req))
|
|
162
204
|
throw new GatewayError(
|
|
163
205
|
403,
|
|
164
206
|
'permission_error',
|
|
165
207
|
'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
|
|
166
208
|
);
|
|
167
209
|
const watch = watchUpstream(res);
|
|
168
|
-
if (
|
|
169
|
-
const base = key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key);
|
|
210
|
+
if (passthrough) listLoginModels(req.headers, anthropicBase);
|
|
170
211
|
const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
|
|
171
212
|
fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
|
|
172
|
-
const attempt = (): Promise<Response> =>
|
|
173
|
-
|
|
174
|
-
|
|
213
|
+
const attempt = async (base: Record<string, string>): Promise<Response> => {
|
|
214
|
+
const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
|
|
215
|
+
const first = await shape();
|
|
216
|
+
return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
|
|
217
|
+
};
|
|
218
|
+
const upstream = await sendAnthropic(req, conn, anthropicBase, attempt, deps);
|
|
219
|
+
await refusedLogin(conn, upstream);
|
|
175
220
|
noteRefusal(conn.label, route.model, upstream);
|
|
176
221
|
noteUsageHeaders('anthropic', conn.id, upstream.headers);
|
|
177
222
|
const scanner = new UsageScanner(conn.id);
|
|
178
|
-
await pipeResponse(upstream, res, watch,
|
|
223
|
+
await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
|
|
179
224
|
}
|
|
180
225
|
|
|
181
226
|
async function toOpenRouter(
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
writeModelConfig,
|
|
21
21
|
type ModelConfig,
|
|
22
22
|
} from './model-config.js';
|
|
23
|
-
import { asApiError, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
23
|
+
import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
24
|
+
import { dropUnusedClaudeLogins } from './claude-logins.js';
|
|
24
25
|
import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
|
|
25
26
|
import { refreshUsage } from './usage-refresh.js';
|
|
26
27
|
|
|
@@ -89,14 +90,17 @@ async function change(req: IncomingMessage, deps: ModelApiDeps, store: Store, id
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
function drop(deps: ModelApiDeps, store: Store, id: string): unknown {
|
|
93
|
+
const before = store.read();
|
|
92
94
|
let next: ModelConfig;
|
|
93
95
|
try {
|
|
94
|
-
next = removeConnection(
|
|
96
|
+
next = removeConnection(before, id);
|
|
95
97
|
} catch (err) {
|
|
96
98
|
asApiError(err);
|
|
97
99
|
}
|
|
98
100
|
forgetOne(id);
|
|
99
|
-
|
|
101
|
+
const body = kept(store, next, deps, 'model-api: connection removed', { connection: id });
|
|
102
|
+
dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
|
|
103
|
+
return body;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
async function settingsWithUsage(deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
|
|
@@ -132,7 +136,11 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
|
|
|
132
136
|
async function restore(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
|
|
133
137
|
const body = await readJsonBody(req, RESTORE_MAX);
|
|
134
138
|
if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
|
|
135
|
-
|
|
139
|
+
const before = store.read();
|
|
140
|
+
const next = parseModelConfig(body);
|
|
141
|
+
const answer = kept(store, next, deps, 'model-api: model setup restored from a file', {});
|
|
142
|
+
dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
|
|
143
|
+
return answer;
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
function bundleRoute(path: string, method: string | undefined): Route | number {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { newId } from '@metro-labs/core/ids';
|
|
2
|
+
import { ID_RE, newId } from '@metro-labs/core/ids';
|
|
3
3
|
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
4
|
import { agentsDir } from '../agents/files.js';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
@@ -9,6 +9,13 @@ import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gem
|
|
|
9
9
|
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
|
|
10
10
|
export type Provider = (typeof PROVIDERS)[number];
|
|
11
11
|
|
|
12
|
+
export interface ClaudeLogin {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string | null;
|
|
15
|
+
plan: string | null;
|
|
16
|
+
savedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
export interface Connection {
|
|
13
20
|
id: string;
|
|
14
21
|
provider: Provider;
|
|
@@ -19,6 +26,7 @@ export interface Connection {
|
|
|
19
26
|
zdr: boolean;
|
|
20
27
|
codex: CodexTokens | null;
|
|
21
28
|
gemini: GeminiTokens | null;
|
|
29
|
+
claude: ClaudeLogin | null;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
export interface Fallback {
|
|
@@ -73,8 +81,14 @@ const newConnection = (provider: Provider, label: string): Connection => ({
|
|
|
73
81
|
zdr: false,
|
|
74
82
|
codex: null,
|
|
75
83
|
gemini: null,
|
|
84
|
+
claude: null,
|
|
76
85
|
});
|
|
77
86
|
|
|
87
|
+
function claudeFromDisk(raw: unknown): ClaudeLogin | null {
|
|
88
|
+
if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
|
|
89
|
+
return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
function codexFromDisk(raw: unknown): CodexTokens | null {
|
|
79
93
|
if (!isRecord(raw)) return null;
|
|
80
94
|
const accessToken = text(raw.accessToken);
|
|
@@ -106,6 +120,7 @@ function connectionFromDisk(raw: unknown): Connection | null {
|
|
|
106
120
|
zdr: raw.zdr === true,
|
|
107
121
|
codex: codexFromDisk(raw.codex),
|
|
108
122
|
gemini: geminiTokensFromDisk(raw.gemini),
|
|
123
|
+
claude: provider === 'anthropic' ? claudeFromDisk(raw.claude) : null,
|
|
109
124
|
};
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -219,6 +234,7 @@ export const routeLabel = (route: Route): string =>
|
|
|
219
234
|
export const PASSTHROUGH_ID = 'passthrough';
|
|
220
235
|
|
|
221
236
|
function signedInAs(c: Connection): { account: string | null; plan: string | null } {
|
|
237
|
+
if (c.claude !== null) return { account: c.claude.email, plan: c.claude.plan };
|
|
222
238
|
if (c.codex === null) return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
|
|
223
239
|
const claims = claimsOf(c.codex.idToken);
|
|
224
240
|
return { account: c.codex.email ?? claims.email, plan: c.codex.plan ?? claims.plan };
|
|
@@ -233,7 +249,7 @@ function publicConnection(c: Connection): Record<string, unknown> {
|
|
|
233
249
|
hasKey: c.apiKey !== '',
|
|
234
250
|
region: c.region,
|
|
235
251
|
zdr: c.zdr,
|
|
236
|
-
signedIn: c.codex !== null || c.gemini !== null,
|
|
252
|
+
signedIn: c.codex !== null || c.gemini !== null || c.claude !== null,
|
|
237
253
|
...signedInAs(c),
|
|
238
254
|
};
|
|
239
255
|
}
|
|
@@ -254,6 +270,9 @@ export const setCodexAuth = (cfg: ModelConfig, id: string, auth: CodexTokens | n
|
|
|
254
270
|
export const setGeminiAuth = (cfg: ModelConfig, id: string, auth: GeminiTokens | null): ModelConfig =>
|
|
255
271
|
withConnection(cfg, id, (c) => ({ ...c, gemini: auth }));
|
|
256
272
|
|
|
273
|
+
export const setClaudeLogin = (cfg: ModelConfig, id: string, login: ClaudeLogin | null): ModelConfig =>
|
|
274
|
+
withConnection(cfg, id, (c) => ({ ...c, claude: login }));
|
|
275
|
+
|
|
257
276
|
function field(patch: Record<string, unknown>, key: string, current: string, what: string, max = MAX_FIELD): string {
|
|
258
277
|
if (!(key in patch)) return current;
|
|
259
278
|
const value = patch[key];
|
|
@@ -42,6 +42,8 @@ const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connectio
|
|
|
42
42
|
|
|
43
43
|
function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens | null, note: string): unknown {
|
|
44
44
|
const { cfg, id } = connectionToFill(store, req, 'codex');
|
|
45
|
+
const twin = cfg.connections.find((c) => c.id !== id && tokens !== null && c.codex?.refreshToken === tokens.refreshToken);
|
|
46
|
+
if (twin !== undefined) throw new ApiError(`this ChatGPT login is already connected as ${twin.label}; sign in with ChatGPT to add another one`, 409);
|
|
45
47
|
const next = setCodexAuth(cfg, id, tokens);
|
|
46
48
|
return saved(store, next, note, { connection: id, plan: tokens?.plan ?? null });
|
|
47
49
|
}
|
|
@@ -8,6 +8,7 @@ import { usageSeen } from './usage.js';
|
|
|
8
8
|
import { chainStatus } from './fallback.js';
|
|
9
9
|
import { ModelConfigError, connectionOf, publicModelConfig, routedConnection, type Connection, type ModelConfig, type Provider } from './model-config.js';
|
|
10
10
|
import type { SetupDeps } from '../claude/setup.js';
|
|
11
|
+
import type { ClaudeLoginDeps } from './claude-logins.js';
|
|
11
12
|
|
|
12
13
|
export interface ModelApiDeps {
|
|
13
14
|
read?: () => ModelConfig;
|
|
@@ -25,10 +26,13 @@ export interface ModelApiDeps {
|
|
|
25
26
|
bedrockControlBase?: string;
|
|
26
27
|
setup?: SetupDeps;
|
|
27
28
|
restartSession?: () => boolean;
|
|
28
|
-
claudeUsage?: () => Promise<unknown>;
|
|
29
|
+
claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
|
|
30
|
+
claudeLoginsRoot?: string;
|
|
29
31
|
usageWaitMs?: number;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
export const claudeLoginDeps = (deps: ModelApiDeps): ClaudeLoginDeps => ({ root: deps.claudeLoginsRoot, probe: deps.claudeUsage });
|
|
35
|
+
|
|
32
36
|
export interface Store {
|
|
33
37
|
read: () => ModelConfig;
|
|
34
38
|
write: (cfg: ModelConfig) => void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import { GatewayError } from './forward.js';
|
|
3
|
+
import { GatewayError, OAUTH_BETA } from './forward.js';
|
|
4
4
|
import type { Connection } from './model-config.js';
|
|
5
5
|
import { fingerprint, listCache } from './model-lists.js';
|
|
6
6
|
import { stringOf } from '@metro-labs/http/api-http';
|
|
@@ -9,7 +9,6 @@ export const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
|
9
9
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
10
10
|
const LIST_MAX = 1000;
|
|
11
11
|
const LIST_TIMEOUT_MS = 10_000;
|
|
12
|
-
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
13
12
|
const BEARER = 'Bearer ';
|
|
14
13
|
|
|
15
14
|
export interface ProviderModel {
|
|
@@ -6,7 +6,8 @@ import { lastServed } from './served.js';
|
|
|
6
6
|
import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
|
|
7
7
|
import { PASSTHROUGH_ID, readModelConfig, routedConnection, writeModelConfig, type Connection, type ModelConfig } from './model-config.js';
|
|
8
8
|
import { codexDepsFor, geminiModelsOf } from './model-signin.js';
|
|
9
|
-
import { CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
9
|
+
import { claudeLoginDeps, CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
10
|
+
import { probeClaudeLogin } from './claude-logins.js';
|
|
10
11
|
|
|
11
12
|
const WAIT_MS = 4_000;
|
|
12
13
|
const LOGIN_PROBE = 'claude-login';
|
|
@@ -62,17 +63,26 @@ async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store,
|
|
|
62
63
|
|
|
63
64
|
const loginIds = (cfg: ModelConfig): string[] => [
|
|
64
65
|
...(routedConnection(cfg) === null ? [PASSTHROUGH_ID] : []),
|
|
65
|
-
...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '').map((c) => c.id),
|
|
66
|
+
...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '' && c.claude === null).map((c) => c.id),
|
|
66
67
|
];
|
|
67
68
|
|
|
68
69
|
const hasFallbacks = (cfg: ModelConfig): boolean => (cfg.fallbacks ?? []).length > 0;
|
|
69
70
|
|
|
71
|
+
async function refreshOwnLogin(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
|
|
72
|
+
const login = conn.apiKey === '' ? conn.claude : null;
|
|
73
|
+
if (login === null || (fresh(conn.id, now) && !hasFallbacks(cfg)) || !mayProbe(conn.id, now, CREDITS_TTL_MS)) return;
|
|
74
|
+
await probe('usage of a Claude login', async () => {
|
|
75
|
+
const usage = claudeLoginUsage(await probeClaudeLogin(login.id, claudeLoginDeps(deps)), new Date(now));
|
|
76
|
+
if (usage !== null) mergeUsage(conn.id, usage);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
|
|
71
81
|
const ids = loginIds(cfg);
|
|
72
82
|
if (!ids.some((id) => !fresh(id, now)) && !(hasFallbacks(cfg) && ids.length > 0)) return;
|
|
73
83
|
if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
|
|
74
84
|
await probe('usage of the Claude Code login', async () => {
|
|
75
|
-
const usage = claudeLoginUsage(await (deps.claudeUsage ?? readClaudeUsage)(), new Date(now));
|
|
85
|
+
const usage = claudeLoginUsage(await (deps.claudeUsage ?? (() => readClaudeUsage()))(), new Date(now));
|
|
76
86
|
if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
|
|
77
87
|
});
|
|
78
88
|
const usage = probeAnswer(LOGIN_PROBE);
|
|
@@ -80,10 +90,11 @@ async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number):
|
|
|
80
90
|
for (const id of ids) mergeUsage(id, usage);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
function refreshOne(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
93
|
+
function refreshOne(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
84
94
|
if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
|
|
85
95
|
if (conn.provider === 'codex') return refreshCodex(conn, deps, store, now);
|
|
86
96
|
if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
|
|
97
|
+
if (conn.provider === 'anthropic') return refreshOwnLogin(conn, cfg, deps, now);
|
|
87
98
|
return Promise.resolve();
|
|
88
99
|
}
|
|
89
100
|
|
|
@@ -98,7 +109,7 @@ async function within(ms: number, work: Promise<unknown>): Promise<void> {
|
|
|
98
109
|
|
|
99
110
|
export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
|
|
100
111
|
const cfg = store.read();
|
|
101
|
-
const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, deps, store, now)), refreshLogin(cfg, deps, now)]);
|
|
112
|
+
const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, cfg, deps, store, now)), refreshLogin(cfg, deps, now)]);
|
|
102
113
|
await within(deps.usageWaitMs ?? WAIT_MS, work);
|
|
103
114
|
}
|
|
104
115
|
|
|
@@ -6,6 +6,10 @@ export function allowedWebHost(host: string): boolean {
|
|
|
6
6
|
return PREVIEW.test(host);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export const APP_RETURN = 'metro://auth';
|
|
10
|
+
|
|
11
|
+
export const isAppReturn = (returnTo: string): boolean => returnTo === APP_RETURN;
|
|
12
|
+
|
|
9
13
|
export function validateReturnTo(returnTo: string): boolean {
|
|
10
14
|
let url: URL;
|
|
11
15
|
try {
|
package/runtime/runtime.json
CHANGED