@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.
Files changed (30) hide show
  1. package/package.json +2 -2
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/package.json +1 -1
  4. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -1
  5. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +9 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +52 -18
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +5 -4
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +168 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +147 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +48 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +97 -22
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +34 -21
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +68 -5
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +2 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +8 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +1 -2
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -14
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +28 -3
  20. package/runtime/node_modules/@metro-labs/discord-bot/package.json +1 -1
  21. package/runtime/node_modules/@metro-labs/gmail/package.json +1 -1
  22. package/runtime/node_modules/@metro-labs/http/package.json +1 -1
  23. package/runtime/node_modules/@metro-labs/outlook/package.json +1 -1
  24. package/runtime/node_modules/@metro-labs/telegram/package.json +1 -1
  25. package/runtime/node_modules/@metro-labs/telegram-bot/package.json +1 -1
  26. package/runtime/node_modules/@metro-labs/threema/package.json +1 -1
  27. package/runtime/node_modules/@metro-labs/webhook/package.json +1 -1
  28. package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -1
  29. package/runtime/node_modules/@metro-labs/xmtp/package.json +1 -1
  30. 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.251",
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.2",
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.251",
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",
@@ -42,7 +42,7 @@
42
42
  "pino-pretty": "^13.1.3"
43
43
  },
44
44
  "devDependencies": {
45
- "@stage-labs/config": "0.1.0-beta.2",
45
+ "@stage-labs/config": "0.1.0-beta.3",
46
46
  "eslint": "^10.3.0",
47
47
  "knip": "^6.15.0",
48
48
  "madge": "^8.0.0",
@@ -41,7 +41,7 @@
41
41
  "zod": "^3.23.8"
42
42
  },
43
43
  "devDependencies": {
44
- "@stage-labs/config": "0.1.0-beta.2",
44
+ "@stage-labs/config": "0.1.0-beta.3",
45
45
  "@types/ws": "^8.18.0",
46
46
  "eslint": "^10.3.0",
47
47
  "knip": "^6.15.0",
@@ -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, readAccount, (c) => {
121
+ return remembered(checks.account, () => claudeAccountIn({}), (c) => {
112
122
  checks.account = c;
113
123
  });
114
124
  }
115
125
 
116
- function readAccount(): ClaudeAccount {
117
- const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
118
- if (run.error !== undefined || typeof run.stdout !== 'string') return { signedIn: false, account: null };
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 { signedIn: false, account: null };
122
- const account = typeof parsed.email === 'string' ? parsed.email : typeof parsed.organization === 'string' ? parsed.organization : null;
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 { signedIn: false, account: null };
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
- export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginView {
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
- if (live !== null && live.state === 'pending') return view(live);
137
- const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], { TERM: 'xterm-256color' });
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, TERM: 'xterm-256color' } });
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.state = code === 0 ? 'done' : 'failed';
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
- const onboarding = code === 0 ? markOnboardingDone() : 'skipped';
169
- log.info({ state: session.state, onboarding }, 'claude-login: the official login finished');
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
+ }