@stage-labs/metro 0.1.0-beta.251 → 0.1.0-beta.253
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/boot/boot.ts +2 -0
- 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/fallback.ts +147 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +48 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +97 -22
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +34 -21
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +68 -5
- 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 +8 -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 +37 -14
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +28 -3
- 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/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.253",
|
|
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.253",
|
|
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",
|
|
@@ -54,6 +54,7 @@ import type { StationName } from '@metro-labs/core/station-names';
|
|
|
54
54
|
import { startUploadReaper } from '../files/upload-store.js';
|
|
55
55
|
import { startAttachReaper } from '../files/attach-reaper.js';
|
|
56
56
|
import { startCloudWatchPublisher } from '../server/cloudwatch.js';
|
|
57
|
+
import { watchFallbackUsage } from '../gateway/usage-refresh.js';
|
|
57
58
|
import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } from '../voice/calls.js';
|
|
58
59
|
|
|
59
60
|
installCrashGuard();
|
|
@@ -153,6 +154,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
153
154
|
startUploadReaper();
|
|
154
155
|
startAttachReaper();
|
|
155
156
|
startCloudWatchPublisher();
|
|
157
|
+
watchFallbackUsage();
|
|
156
158
|
announceLocalEndpoint();
|
|
157
159
|
tunnel?.start();
|
|
158
160
|
log.info(
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
import { lastServed } from './served.js';
|
|
3
|
+
import { PER_MINUTE, usageOf, type UsageWindow } from './usage.js';
|
|
4
|
+
import { bareModel, connectionOf, isSmallModel, notReady, routedConnection, routeLabel, type Connection, type Fallback, type ModelConfig, type Route } from './model-config.js';
|
|
5
|
+
|
|
6
|
+
export const USAGE_LIMIT = 0.95;
|
|
7
|
+
const COOLDOWN_MS = 60_000;
|
|
8
|
+
const SCOPED = 'Weekly, ';
|
|
9
|
+
const MS_PER_SECOND = 1000;
|
|
10
|
+
|
|
11
|
+
export interface Hold {
|
|
12
|
+
reason: string;
|
|
13
|
+
until: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Cooling {
|
|
17
|
+
until: number;
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const cooling = new Map<string, Cooling>();
|
|
22
|
+
let lastChoice = '';
|
|
23
|
+
|
|
24
|
+
const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
|
|
25
|
+
|
|
26
|
+
export function forgetFallbackState(): void {
|
|
27
|
+
cooling.clear();
|
|
28
|
+
lastChoice = '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scopeOf(label: string): string | null {
|
|
32
|
+
if (!label.startsWith(SCOPED)) return null;
|
|
33
|
+
return (label.slice(SCOPED.length).trim().split(/\s+/)[0] ?? '').toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function applies(window: UsageWindow, route: Route, now: number): boolean {
|
|
37
|
+
if (window.used === null || window.label === PER_MINUTE) return false;
|
|
38
|
+
if (window.resetAt !== null && Date.parse(window.resetAt) <= now) return false;
|
|
39
|
+
if (route.connection.provider === 'gemini') return window.label === route.model;
|
|
40
|
+
const scope = scopeOf(window.label);
|
|
41
|
+
return scope === null || route.model.toLowerCase().split(/[^a-z0-9]+/).includes(scope);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function windowsFor(route: Route, now = Date.now()): UsageWindow[] {
|
|
45
|
+
return (usageOf(route.connection.id)?.windows ?? []).filter((w) => applies(w, route, now));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const percent = (used: number): string => `${String(Math.round(used * 100))}%`;
|
|
49
|
+
|
|
50
|
+
export function holdOf(route: Route, now = Date.now()): Hold | null {
|
|
51
|
+
const cool = cooling.get(keyOf(route));
|
|
52
|
+
if (cool !== undefined && cool.until > now) return { reason: cool.reason, until: new Date(cool.until).toISOString() };
|
|
53
|
+
const over = windowsFor(route, now).find((w) => (w.used ?? 0) > USAGE_LIMIT);
|
|
54
|
+
return over === undefined ? null : { reason: `${over.label} ${percent(over.used ?? 0)}`, until: over.resetAt };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fallbackModel(requested: string, conn: Connection, model: string): string {
|
|
58
|
+
const bare = bareModel(requested);
|
|
59
|
+
return conn.provider === 'anthropic' && isSmallModel(bare) ? bare : model;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fallbackRoute(cfg: ModelConfig, fallback: Fallback, requested: string): Route | null {
|
|
63
|
+
const conn = connectionOf(cfg, fallback.connection);
|
|
64
|
+
if (conn === null || notReady(cfg, conn) !== null) return null;
|
|
65
|
+
return { connection: conn, model: fallbackModel(requested, conn, fallback.model) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const unique = (routes: Route[]): Route[] => routes.filter((route, at) => routes.findIndex((other) => keyOf(other) === keyOf(route)) === at);
|
|
69
|
+
|
|
70
|
+
export const chainOf = (primary: Route, cfg: ModelConfig, requested: string): Route[] =>
|
|
71
|
+
unique([primary, ...(cfg.fallbacks ?? []).flatMap((f) => fallbackRoute(cfg, f, requested) ?? [])]);
|
|
72
|
+
|
|
73
|
+
export function routesToTry(chain: Route[], now = Date.now()): Route[] {
|
|
74
|
+
const open = chain.filter((route) => holdOf(route, now) === null);
|
|
75
|
+
return open.length > 0 ? open : chain.slice(0, 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function secondsHeader(headers: Headers, name: string): number | null {
|
|
79
|
+
const raw = headers.get(name);
|
|
80
|
+
if (raw === null || raw.trim() === '') return null;
|
|
81
|
+
const value = Number(raw);
|
|
82
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function coolUntil(headers: Headers, now: number): number {
|
|
86
|
+
const after = secondsHeader(headers, 'retry-after');
|
|
87
|
+
if (after !== null) return now + after * MS_PER_SECOND;
|
|
88
|
+
const reset = secondsHeader(headers, 'anthropic-ratelimit-unified-reset');
|
|
89
|
+
if (reset !== null && reset * MS_PER_SECOND > now) return reset * MS_PER_SECOND;
|
|
90
|
+
return now + COOLDOWN_MS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function noteRefused(route: Route, status: number, headers: Headers, next: Route | undefined, now = Date.now()): void {
|
|
94
|
+
const reason = `answered ${String(status)}`;
|
|
95
|
+
cooling.set(keyOf(route), { until: coolUntil(headers, now), reason });
|
|
96
|
+
log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, 'gateway: the model hit its limit, so the request goes to the next fallback model');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
|
|
100
|
+
const key = keyOf(chosen);
|
|
101
|
+
if (key === lastChoice) return;
|
|
102
|
+
const first = lastChoice === '';
|
|
103
|
+
lastChoice = key;
|
|
104
|
+
if (key === keyOf(primary)) {
|
|
105
|
+
if (!first) log.info({ model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const hold = holdOf(primary, now);
|
|
109
|
+
log.info(
|
|
110
|
+
{ from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
|
|
111
|
+
'gateway: the first model is over its limit, so requests go to a fallback model',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ChainRow {
|
|
116
|
+
connection: string;
|
|
117
|
+
model: string;
|
|
118
|
+
used: number | null;
|
|
119
|
+
hold: Hold | null;
|
|
120
|
+
active: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function primaryOf(cfg: ModelConfig): Route | null {
|
|
124
|
+
const conn = routedConnection(cfg);
|
|
125
|
+
if (conn === null) return null;
|
|
126
|
+
const served = lastServed();
|
|
127
|
+
const model = conn.model !== '' ? conn.model : served?.connection === conn.id ? served.model : '';
|
|
128
|
+
return { connection: conn, model };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function rowOf(cfg: ModelConfig, route: Route, now: number): Omit<ChainRow, 'active'> {
|
|
132
|
+
const reason = notReady(cfg, route.connection);
|
|
133
|
+
const used = windowsFor(route, now).reduce<number | null>((most, w) => Math.max(most ?? 0, w.used ?? 0), null);
|
|
134
|
+
return { connection: route.connection.id, model: route.model, used, hold: reason === null ? holdOf(route, now) : { reason, until: null } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function chainStatus(cfg: ModelConfig, now = Date.now()): ChainRow[] {
|
|
138
|
+
const primary = primaryOf(cfg);
|
|
139
|
+
if (primary === null) return [];
|
|
140
|
+
const listed = (cfg.fallbacks ?? []).flatMap((f) => {
|
|
141
|
+
const conn = connectionOf(cfg, f.connection);
|
|
142
|
+
return conn === null ? [] : [{ connection: conn, model: f.model }];
|
|
143
|
+
});
|
|
144
|
+
const rows = unique([primary, ...listed]).map((route) => rowOf(cfg, route, now));
|
|
145
|
+
const active = Math.max(0, rows.findIndex((row) => row.hold === null));
|
|
146
|
+
return rows.map((row, at) => ({ ...row, active: at === active }));
|
|
147
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
1
|
+
import type { IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from 'node:http';
|
|
2
2
|
import { UsageScanner } from './usage.js';
|
|
3
3
|
|
|
4
4
|
export class GatewayError extends Error {
|
|
@@ -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(',')
|
|
@@ -235,3 +244,41 @@ export function upstreamMessage(text: string, fallback: string): string {
|
|
|
235
244
|
return text;
|
|
236
245
|
}
|
|
237
246
|
}
|
|
247
|
+
|
|
248
|
+
const LIMIT_STATUSES = new Set([402, 429]);
|
|
249
|
+
|
|
250
|
+
export interface Refusal {
|
|
251
|
+
status: number;
|
|
252
|
+
headers: Headers;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface Held {
|
|
256
|
+
res: ServerResponse;
|
|
257
|
+
refusal: () => Refusal | null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function headersOf(raw: OutgoingHttpHeaders | OutgoingHttpHeader[] | undefined): Headers {
|
|
261
|
+
const out = new Headers();
|
|
262
|
+
if (raw === undefined || Array.isArray(raw)) return out;
|
|
263
|
+
for (const [name, value] of Object.entries(raw)) if (value !== undefined) out.set(name, Array.isArray(value) ? value.join(', ') : String(value));
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function holdLimitRefusal(res: ServerResponse): Held {
|
|
268
|
+
let refusal: Refusal | null = null;
|
|
269
|
+
const swallow = (): boolean => true;
|
|
270
|
+
const held: ServerResponse = new Proxy(res, {
|
|
271
|
+
get(target, prop) {
|
|
272
|
+
if (prop === 'writeHead')
|
|
273
|
+
return (status: number, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]): ServerResponse => {
|
|
274
|
+
if (!LIMIT_STATUSES.has(status)) return target.writeHead(status, headers);
|
|
275
|
+
refusal = { status, headers: headersOf(headers) };
|
|
276
|
+
return held;
|
|
277
|
+
};
|
|
278
|
+
if (refusal !== null && (prop === 'write' || prop === 'end')) return swallow;
|
|
279
|
+
const value: unknown = Reflect.get(target, prop, target);
|
|
280
|
+
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
return { res: held, refusal: () => refusal };
|
|
284
|
+
}
|