@stage-labs/metro 0.1.0-beta.310 → 0.1.0-beta.311

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/claude-env.js +1 -1
  2. package/package.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  4. package/runtime/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -3
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -36
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +4 -13
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -9
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +4 -15
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +5 -4
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +6 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +31 -49
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +18 -32
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +2 -40
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-leftovers.ts +24 -3
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/subscription.ts +1 -61
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +11 -55
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +1 -66
  20. package/runtime/runtime.json +1 -1
  21. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  22. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
  23. package/runtime/sdk-runner/runner.json +1 -1
  24. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +0 -243
  25. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +0 -38
  26. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +0 -128
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +0 -192
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +0 -59
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -40
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +0 -77
@@ -89,7 +89,7 @@ function gatewayLaunchEnv(key, port, claude, say) {
89
89
  say('MCP tool search is on, so connector tools load when Claude needs them instead of all at start');
90
90
  const env = credentialEnv(routed, key, claudeSignedIn(claude));
91
91
  if (env !== routed)
92
- say("Claude Code has no login of its own here, so metro's key stands in as its credential; the Model page must route to Bedrock, OpenRouter or Codex");
92
+ say("Claude Code has no login of its own here, so metro's key stands in as its credential; the organization's Models page decides where requests go");
93
93
  return env;
94
94
  }
95
95
  export async function headlessEnv(claude, command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.310",
3
+ "version": "0.1.0-beta.311",
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": {
@@ -18,6 +18,7 @@
18
18
  "./train-error": "./src/train-error.ts",
19
19
  "./trains/protocol": "./src/protocol.ts",
20
20
  "./ids": "./src/ids.ts",
21
+ "./claude-token": "./src/claude-token.ts",
21
22
  "./tickets": "./src/tickets.ts",
22
23
  "./station-names": "./src/station-names.ts",
23
24
  "./str": "./src/str.ts",
@@ -0,0 +1,3 @@
1
+ const TOKEN_RE = /^sk-ant-oat01-[A-Za-z0-9_-]{32,512}$/;
2
+
3
+ export const isClaudeToken = (value: string): boolean => TOKEN_RE.test(value);
@@ -40,7 +40,7 @@ function agentFile(dir: string): string | null {
40
40
  return json({ version: 1, id: file.id, key: file.key });
41
41
  }
42
42
 
43
- export function viewFiles(dir = agentsDir(), model: () => string | null = () => modelViewText(effectiveSetup(dir))): Map<string, string | null> {
43
+ export function viewFiles(dir = agentsDir(), model: () => string | null = () => modelViewText(effectiveSetup())): Map<string, string | null> {
44
44
  const files = new Map<string, string | null>([
45
45
  ['agent.json', agentFile(dir)],
46
46
  ['model.json', model()],
@@ -62,7 +62,6 @@ import { startCloudWatchPublisher } from '../server/cloudwatch.js';
62
62
  import { watchFallbackUsage } from '../gateway/usage-refresh.js';
63
63
  import { startOrgModels, stopOrgModels } from '../gateway/org-models.js';
64
64
  import { effectiveSetup, watchModelChanges } from '../gateway/effective.js';
65
- import { readClaudeLogins, retireV2ModelFile } from '../gateway/claude-store.js';
66
65
  import { removeModelLeftovers } from '../gateway/model-leftovers.js';
67
66
  import { readAgentKey } from '../connectors/agent-key.js';
68
67
  import { METRO_API } from '../connectors/metro-api.js';
@@ -146,7 +145,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void): SessionApis {
146
145
  async function main(): Promise<void> {
147
146
  removeConnectorLeftovers();
148
147
  removeModelLeftovers();
149
- retireV2ModelFile();
150
148
  applyLocalOwner();
151
149
  installBearerSessions(agentsDir(), localOwner);
152
150
  watchPolicySnapshot();
@@ -183,7 +181,7 @@ installBearerSessions(agentsDir(), localOwner);
183
181
  });
184
182
  watchConnectorTools();
185
183
  startCloudWatchPublisher();
186
- watchFallbackUsage(() => effectiveSetup(), () => readClaudeLogins());
184
+ watchFallbackUsage(() => effectiveSetup());
187
185
  announceLocalEndpoint();
188
186
  tunnel?.start();
189
187
  log.info(
@@ -13,17 +13,6 @@ import {
13
13
  readClaudeSkill,
14
14
  writeClaudeSkill,
15
15
  } from './skills.js';
16
- import {
17
- answerClaudeLogin,
18
- claudeAccount,
19
- claudeInstalled,
20
- claudeLoginView,
21
- endClaudeLogin,
22
- startClaudeLogin,
23
- type LoginDeps,
24
- type LoginTarget,
25
- } from './login.js';
26
- import { claudeLoginTarget, type ClaudeLoginDeps } from '../gateway/claude-logins.js';
27
16
  import { setupAnswer, type SetupApiDeps } from './setup-api.js';
28
17
  import {
29
18
  autostartEnabled,
@@ -58,8 +47,6 @@ const PAGE_MAX = 500;
58
47
 
59
48
  export interface ClaudeApiDeps extends SetupApiDeps {
60
49
  dir?: () => string;
61
- login?: LoginDeps;
62
- claudeLogins?: ClaudeLoginDeps;
63
50
  skillSource?: GitHubSkills;
64
51
  }
65
52
 
@@ -133,8 +120,7 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
133
120
  return createClaudeSkill(body.name, typeof body.text === 'string' ? body.text : undefined, dir);
134
121
  }
135
122
 
136
- const ADMIN_ONLY = /^\/api\/claude\/(login|session|setup|skill-source)(\/|$)/;
137
- const LOGIN = 'login';
123
+ const ADMIN_ONLY = /^\/api\/claude\/(session|setup|skill-source)(\/|$)/;
138
124
  const SESSION = 'session';
139
125
  const SETUP = 'setup';
140
126
 
@@ -166,26 +152,6 @@ async function sessionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise
166
152
  return sessionCommand(body, session);
167
153
  }
168
154
 
169
- function loginTarget(req: IncomingMessage, deps: ClaudeApiDeps): LoginTarget | null {
170
- const asked = new URL(req.url ?? '', 'http://metro').searchParams.get('connection');
171
- return asked === null ? null : claudeLoginTarget(asked, deps.claudeLogins);
172
- }
173
-
174
- async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps): Promise<unknown> {
175
- const method = req.method ?? 'GET';
176
- if (id === '') {
177
- if (method === 'POST') return startClaudeLogin(deps.login, Date.now(), loginTarget(req, deps));
178
- if (method === 'GET') return { available: claudeInstalled(), ...claudeAccount() };
179
- throw new ApiError('method not allowed', 405);
180
- }
181
- if (method === 'GET') return claudeLoginView(id);
182
- if (method === 'DELETE') return endClaudeLogin(id);
183
- if (method !== 'POST') throw new ApiError('method not allowed', 405);
184
- const body = await readJsonBody(req);
185
- if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
186
- return answerClaudeLogin(id, body.text);
187
- }
188
-
189
155
  function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
190
156
  const [head = '', item = ''] = rest;
191
157
  if (rest.length !== 2) throw new ApiError('method not allowed', 405);
@@ -236,7 +202,6 @@ function fileAnswer(req: IncomingMessage, path: string, search: string, dir: str
236
202
  function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
237
203
  const dir = (deps.dir ?? claudeDir)();
238
204
  const [head = '', item = ''] = parts(path);
239
- if (head === LOGIN) return loginAnswer(req, item, deps);
240
205
  if (head === 'skill-source' && parts(path).length === 1) return githubSkillsAnswer(req, deps.skillSource);
241
206
  if (head === 'skills') return skillsAnswer(req, path, search, dir, sourceOf(deps));
242
207
  const single = item === '' ? SINGLETONS[head] : undefined;
@@ -13,7 +13,6 @@ import { METRO_VERSION } from '@metro-labs/core/version';
13
13
  import { agentsDir, listAgentFiles } from '../agents/files.js';
14
14
  import { notReady, primaryRoute, type ModelSetup } from '../gateway/model-config.js';
15
15
  import { effectiveSetup } from '../gateway/effective.js';
16
- import { claudeAccount, claudeInstalled, forgetClaudeChecks } from './login.js';
17
16
  import { inSessionScope } from './memory.js';
18
17
  import { runningAsRoot } from '../metro-user/privilege.js';
19
18
  import { agentExtraEnv, agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
@@ -38,7 +37,6 @@ export interface SessionDeps {
38
37
  metro?: string[];
39
38
  home?: string;
40
39
  agents?: string;
41
- signedIn?: () => boolean;
42
40
  model?: () => ModelSetup;
43
41
  now?: () => number;
44
42
  version?: string;
@@ -122,30 +120,23 @@ export function prepareRunnerStore(): Promise<void> {
122
120
  child.stderr.on('data', (chunk: Buffer) => {
123
121
  said = (said + chunk.toString('utf8')).slice(-2000);
124
122
  });
125
- child.on('error', (err) => {
126
- forgetClaudeChecks();
127
- reject(err);
128
- });
123
+ child.on('error', reject);
129
124
  child.on('close', (code) => {
130
- forgetClaudeChecks();
131
125
  if (code === 0) resolve();
132
126
  else reject(new Error(`metro agent --prepare failed: ${said.trim() || `exit ${String(code)}`}`));
133
127
  });
134
128
  });
135
129
  }
136
130
 
137
- const loginNotRuledOut = (): boolean => claudeAccount().signedIn || !claudeInstalled();
131
+ const NO_MODEL = "the organization routes this agent to no model yet: choose one on the organization's Models page";
138
132
 
139
133
  function credentialReady(deps: SessionDeps): string | null {
140
- const signedIn = deps.signedIn ?? loginNotRuledOut;
141
- if (signedIn()) return null;
142
134
  try {
143
- const primary = primaryRoute((deps.model ?? (() => effectiveSetup(deps.agents ?? agentsDir())))());
144
- if (primary !== null && notReady(primary) === null && (primary.connection.provider !== 'anthropic' || primary.connection.apiKey !== '')) return null;
135
+ const primary = primaryRoute((deps.model ?? (() => effectiveSetup()))());
136
+ return primary === null ? NO_MODEL : notReady(primary);
145
137
  } catch (err) {
146
138
  return `the model setup is not readable (${errMsg(err)})`;
147
139
  }
148
- return 'Claude Code is not signed in and the organization routes this agent to no model yet';
149
140
  }
150
141
 
151
142
  const agentUserMissing = (deps: SessionDeps): boolean =>
@@ -1,5 +1,5 @@
1
1
  import { isRecord } from '@metro-labs/core/is-record';
2
- import { isProvider, LOCAL_PREFIX, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
2
+ import { isProvider, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
3
3
 
4
4
  export interface DeliveredRoute {
5
5
  primary: Fallback | null;
@@ -27,7 +27,7 @@ function refOf(raw: unknown): Fallback | null {
27
27
  function routeOf(raw: unknown): DeliveredRoute | null {
28
28
  if (!isRecord(raw)) return null;
29
29
  const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
30
- return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== '' || f.connection.startsWith(LOCAL_PREFIX)).slice(0, MAX_FALLBACKS) };
30
+ return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS) };
31
31
  }
32
32
 
33
33
  function accountOf(raw: unknown): Account {
@@ -44,16 +44,23 @@ function codexOf(raw: Record<string, unknown>): CodexAccess | null {
44
44
  return { method: raw.method, accessToken, expiresAt: numberOf(raw.expiresAt), accountId: maybe(raw.accountId) };
45
45
  }
46
46
 
47
- function credentialOf(provider: Provider, raw: unknown): { apiKey: string; codex: CodexAccess | null } {
48
- if (!isRecord(raw)) return { apiKey: '', codex: null };
49
- return provider === 'codex' ? { apiKey: '', codex: codexOf(raw) } : { apiKey: text(raw.apiKey), codex: null };
47
+ interface Credential {
48
+ apiKey: string;
49
+ claudeToken: string;
50
+ codex: CodexAccess | null;
51
+ }
52
+
53
+ function credentialOf(provider: Provider, raw: unknown): Credential {
54
+ if (!isRecord(raw)) return { apiKey: '', claudeToken: '', codex: null };
55
+ if (provider === 'codex') return { apiKey: '', claudeToken: '', codex: codexOf(raw) };
56
+ return { apiKey: text(raw.apiKey), claudeToken: provider === 'anthropic' ? text(raw.claudeToken) : '', codex: null };
50
57
  }
51
58
 
52
59
  export function deliveredConnection(raw: unknown): Connection | null {
53
60
  if (!isRecord(raw) || !isProvider(raw.provider)) return null;
54
61
  const id = text(raw.id);
55
- if (id === '' || id.startsWith(LOCAL_PREFIX)) return null;
56
- const { apiKey, codex } = credentialOf(raw.provider, raw.credential);
62
+ if (id === '') return null;
63
+ const { apiKey, claudeToken, codex } = credentialOf(raw.provider, raw.credential);
57
64
  return {
58
65
  id,
59
66
  provider: raw.provider,
@@ -64,9 +71,9 @@ export function deliveredConnection(raw: unknown): Connection | null {
64
71
  zdr: raw.zdr === true,
65
72
  revision: numberOf(raw.revision),
66
73
  codex,
67
- claude: null,
74
+ claudeToken,
68
75
  account: accountOf(raw.account),
69
- signedOut: codex === null && apiKey === '',
76
+ signedOut: codex === null && apiKey === '' && claudeToken === '',
70
77
  };
71
78
  }
72
79
 
@@ -1,26 +1,15 @@
1
1
  import { log } from '@metro-labs/core/log';
2
- import { agentsDir } from '../agents/files.js';
3
- import { readClaudeLogins, type ClaudeLogin } from './claude-store.js';
4
2
  import type { Delivered } from './delivered.js';
5
- import { emptySetup, LOCAL_PREFIX, loginConnection, primaryRoute, runnerModel, SESSION_LOGIN, sessionConnection, type Connection, type ModelSetup } from './model-config.js';
3
+ import { emptySetup, primaryRoute, runnerModel, type ModelSetup } from './model-config.js';
6
4
  import { deliveredSetup, type OrgModels } from './org-models.js';
7
5
 
8
- function localOf(id: string, logins: ClaudeLogin[]): Connection | null {
9
- if (id === SESSION_LOGIN) return sessionConnection();
10
- if (!id.startsWith(LOCAL_PREFIX)) return null;
11
- const login = id.slice(LOCAL_PREFIX.length);
12
- return loginConnection(login, logins.find((l) => l.id === login));
13
- }
14
-
15
- export function setupOf(delivered: Delivered | null, logins: ClaudeLogin[]): ModelSetup {
6
+ export function setupOf(delivered: Delivered | null): ModelSetup {
16
7
  const route = delivered?.route ?? null;
17
8
  if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
18
- const named = [...(route.primary === null ? [] : [route.primary]), ...route.fallbacks].map((r) => r.connection);
19
- const locals = [...new Set(named)].flatMap((id) => localOf(id, logins) ?? []);
20
- return { primary: route.primary, fallbacks: route.fallbacks, connections: [...delivered.connections, ...locals], source: delivered.source };
9
+ return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source };
21
10
  }
22
11
 
23
- export const effectiveSetup = (dir = agentsDir(), delivered: Delivered | null = deliveredSetup()): ModelSetup => setupOf(delivered, readClaudeLogins(dir));
12
+ export const effectiveSetup = (delivered: Delivered | null = deliveredSetup()): ModelSetup => setupOf(delivered);
24
13
 
25
14
  export function modelViewText(setup: ModelSetup): string | null {
26
15
  const primary = primaryRoute(setup);
@@ -55,7 +55,7 @@ export function holdOf(route: Route, now = Date.now()): Hold | null {
55
55
 
56
56
  function fallbackModel(requested: string, conn: Connection, model: string): string {
57
57
  const bare = bareModel(requested);
58
- return model === '' || (conn.provider === 'anthropic' && isSmallModel(bare)) ? bare : model;
58
+ return conn.provider === 'anthropic' && isSmallModel(bare) ? bare : model;
59
59
  }
60
60
 
61
61
  function fallbackRoute(setup: ModelSetup, fallback: Fallback, requested: string): Route | null {
@@ -90,9 +90,10 @@ function coolUntil(headers: Headers, now: number): number {
90
90
  }
91
91
 
92
92
  export function noteRefused(route: Route, status: number, headers: Headers, next: Route | undefined, now = Date.now()): void {
93
- const reason = `answered ${String(status)}`;
94
- cooling.set(keyOf(route), { until: coolUntil(headers, now), reason });
95
- 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');
93
+ const signedOut = status === 401;
94
+ cooling.set(keyOf(route), { until: coolUntil(headers, now), reason: signedOut ? 'token refused, sign in again' : `answered ${String(status)}` });
95
+ const why = signedOut ? 'gateway: Anthropic refused the Claude subscription, so the request goes to the next fallback model' : 'gateway: the model hit its limit, so the request goes to the next fallback model';
96
+ log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, why);
96
97
  }
97
98
 
98
99
  export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
@@ -31,6 +31,12 @@ const HOP_BY_HOP = new Set([
31
31
  ]);
32
32
  const RESPONSE_DROP = new Set(['content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive']);
33
33
 
34
+ export class SubscriptionRefused extends GatewayError {
35
+ constructor(label: string) {
36
+ super(403, 'permission_error', `Anthropic refused the Claude subscription ${label}: sign in again on the Models page`);
37
+ }
38
+ }
39
+
34
40
  export function sendError(res: ServerResponse, status: number, kind: string, message: string): void {
35
41
  res.writeHead(status, { 'content-type': 'application/json' });
36
42
  res.end(JSON.stringify({ type: 'error', error: { type: kind, message } }));
@@ -1,4 +1,4 @@
1
- import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
1
+ import type { 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';
@@ -12,9 +12,9 @@ import {
12
12
  import {
13
13
  addBeta,
14
14
  anthropicHeaders,
15
- forwardedHeaders,
16
15
  GatewayError,
17
16
  holdLimitRefusal,
17
+ SubscriptionRefused,
18
18
  loginHeaders,
19
19
  parseJson,
20
20
  pipeResponse,
@@ -22,6 +22,9 @@ import {
22
22
  sendError,
23
23
  upstreamMessage,
24
24
  watchUpstream,
25
+ type Held,
26
+ type Watch,
27
+ type Refusal,
25
28
  } from './forward.js';
26
29
  import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
27
30
  import {
@@ -36,7 +39,7 @@ import {
36
39
  withEffort,
37
40
  withThinkingFor,
38
41
  } from './effort.js';
39
- import { notReady, resolveRoute, routeLabel, sessionConnection, type Connection, type ModelSetup, type Route } from './model-config.js';
42
+ import { notReady, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
40
43
  import { codexMessages, type CodexDeps } from './codex.js';
41
44
  import { countTokens } from './subscription.js';
42
45
  import { OPENROUTER_BASE } from './openrouter.js';
@@ -44,8 +47,6 @@ import { isRecord } from '@metro-labs/core/is-record';
44
47
  import { forgetServed, noteServed } from './served.js';
45
48
  import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
46
49
  import { fitToolSearch } from './tool-search.js';
47
- import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
48
- import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
49
50
  import { orgModels } from './org-models.js';
50
51
 
51
52
  const GATEWAY_PREFIX = '/gateway';
@@ -53,6 +54,8 @@ const MESSAGES = '/v1/messages';
53
54
  const COUNT = '/v1/messages/count_tokens';
54
55
  const MODELS = '/v1/models';
55
56
  const HELLO = '/api/hello';
57
+ const ANTHROPIC_API = 'https://api.anthropic.com';
58
+ const NO_ROUTE = "metro gateway: no model is set up for this agent yet: enroll it with its organization and choose a model on the organization's Models page";
56
59
 
57
60
  export interface GatewayDeps {
58
61
  setup: () => ModelSetup;
@@ -61,7 +64,6 @@ export interface GatewayDeps {
61
64
  bedrockBase?: string;
62
65
  openrouterBase?: string;
63
66
  codex?: Partial<CodexDeps>;
64
- claudeLogins?: ClaudeLoginDeps;
65
67
  }
66
68
 
67
69
  const learned: Adaptations = freshAdaptations();
@@ -73,7 +75,6 @@ export function resetGatewayState(): void {
73
75
  learned.fields.clear();
74
76
  learned.dropBetas = false;
75
77
  forgetLearnedThinking();
76
- forgetClaudeTokens();
77
78
  }
78
79
 
79
80
  const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
@@ -101,7 +102,9 @@ function modelsBody(setup: ModelSetup): Record<string, unknown> {
101
102
  return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
102
103
  }
103
104
 
104
- const passthrough = (body: Record<string, unknown>): Route => ({ connection: sessionConnection(), model: requestedModel(body) });
105
+ const noRoute = (): never => {
106
+ throw new GatewayError(503, 'api_error', NO_ROUTE);
107
+ };
105
108
 
106
109
  function requestedModel(body: Record<string, unknown>): string {
107
110
  const model = body.model;
@@ -109,11 +112,6 @@ function requestedModel(body: Record<string, unknown>): string {
109
112
  return model;
110
113
  }
111
114
 
112
- const standsInFor = (req: IncomingMessage): boolean => {
113
- const metroKey = req.headers['x-metro-key'];
114
- return typeof metroKey === 'string' && metroKey !== '' && req.headers.authorization === `Bearer ${metroKey}`;
115
- };
116
-
117
115
  interface Payloads {
118
116
  metro: Buffer;
119
117
  asSent: Buffer | null;
@@ -148,25 +146,11 @@ async function refusedThinkingOff(upstream: Response, shaped: Record<string, unk
148
146
  return true;
149
147
  }
150
148
 
151
- function listLoginModels(headers: IncomingHttpHeaders, anthropicBase: string): void {
152
- refreshLoginModels(headers, anthropicBase).catch((err: unknown) => {
153
- log.warn({ err: errMsg(err) }, 'gateway: could not list the models of the Claude Code login');
154
- });
155
- }
156
-
157
- function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
158
- if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
159
- if (conn.claude === null) return attempt(forwardedHeaders(req));
160
- return withClaudeLogin(conn.claude, deps.claudeLogins ?? {}, (token) => {
161
- listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
162
- return attempt(loginHeaders(req, token));
163
- });
164
- }
165
-
166
- async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
167
- if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
149
+ async function refusedSubscription(conn: Connection, upstream: Response, watch: Watch): Promise<void> {
150
+ if (conn.apiKey !== '' || upstream.status !== 401) return;
168
151
  await upstream.body?.cancel();
169
- throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login ${conn.label}: sign in again on the Model page`);
152
+ watch.stop();
153
+ throw new SubscriptionRefused(conn.label);
170
154
  }
171
155
 
172
156
  async function toAnthropic(
@@ -179,17 +163,8 @@ async function toAnthropic(
179
163
  deps: GatewayDeps,
180
164
  ): Promise<void> {
181
165
  const conn = route.connection;
182
- const anthropicBase = deps.anthropicBase ?? ANTHROPIC_API;
183
- const url = `${anthropicBase}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
184
- const passthrough = conn.apiKey === '' && conn.claude === null;
185
- if (passthrough && standsInFor(req))
186
- throw new GatewayError(
187
- 403,
188
- 'permission_error',
189
- "Claude Code on this machine has no Anthropic login of its own; route this agent to a model on the organization's Models page, or sign in a Claude login on the Model page",
190
- );
166
+ const url = `${deps.anthropicBase ?? ANTHROPIC_API}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
191
167
  const watch = watchUpstream(res);
192
- if (passthrough) listLoginModels(req.headers, anthropicBase);
193
168
  const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
194
169
  fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
195
170
  const attempt = async (base: Record<string, string>): Promise<Response> => {
@@ -197,12 +172,11 @@ async function toAnthropic(
197
172
  const first = await shape();
198
173
  return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
199
174
  };
200
- const upstream = await sendAnthropic(req, conn, anthropicBase, attempt, deps);
201
- await refusedLogin(conn, upstream);
175
+ const upstream = await attempt(conn.apiKey !== '' ? anthropicHeaders(req, conn.apiKey) : loginHeaders(req, conn.claudeToken));
176
+ await refusedSubscription(conn, upstream, watch);
202
177
  noteRefusal(conn.label, route.model, upstream);
203
178
  noteUsageHeaders('anthropic', conn.id, upstream.headers);
204
- const scanner = new UsageScanner(conn.id);
205
- await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
179
+ await pipeResponse(upstream, res, watch, { ownCredential: true, scanner: new UsageScanner(conn.id) });
206
180
  }
207
181
 
208
182
  async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
@@ -289,13 +263,23 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
289
263
  await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
290
264
  }
291
265
 
266
+ async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: GatewayDeps): Promise<Refusal | null> {
267
+ try {
268
+ await attempt(req, held.res, ask, route, deps);
269
+ } catch (err) {
270
+ if (!(err instanceof SubscriptionRefused)) throw err;
271
+ return { status: 401, headers: new Headers() };
272
+ }
273
+ return held.refusal();
274
+ }
275
+
292
276
  async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
293
277
  const setup = deps.setup();
294
278
  const raw = await readBody(req);
295
279
  const parsed = parseJson(raw);
296
280
  const sent = fitToolSearch(req, parsed);
297
281
  const requested = requestedModel(sent);
298
- const primary = resolveRoute(requested, setup) ?? passthrough(sent);
282
+ const primary = resolveRoute(requested, setup) ?? noRoute();
299
283
  const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
300
284
  const routes = routesToTry(chainOf(primary, setup, requested));
301
285
  for (const [at, route] of routes.entries()) {
@@ -305,9 +289,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
305
289
  await attempt(req, res, ask, route, deps);
306
290
  return;
307
291
  }
308
- const held = holdLimitRefusal(res);
309
- await attempt(req, held.res, ask, route, deps);
310
- const refusal = held.refusal();
292
+ const refusal = await heldAttempt(req, holdLimitRefusal(res), ask, route, deps);
311
293
  if (refusal === null) return;
312
294
  noteRefused(route, refusal.status, refusal.headers, next);
313
295
  }
@@ -1,14 +1,10 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { ApiError } from '@metro-labs/http/api-error';
3
- import { apiFailure, apiSession, cors, requireAdmin, sendJson, type ApiSession } from '@metro-labs/http/api-http';
4
- import { agentsDir } from '../agents/files.js';
5
- import { removeClaudeLogin } from './claude-logins.js';
6
- import { readClaudeLogins, type ClaudeLogin } from './claude-store.js';
3
+ import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
7
4
  import { effectiveSetup } from './effective.js';
8
5
  import { chainStatus } from './fallback.js';
9
6
  import { notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
10
7
  import { orgModels, type SyncStatus } from './org-models.js';
11
- import { claudeModels } from './provider-models.js';
12
8
  import { lastServed } from './served.js';
13
9
  import { refreshUsage, type UsageDeps } from './usage-refresh.js';
14
10
  import { usageSeen } from './usage.js';
@@ -16,19 +12,15 @@ import { usageSeen } from './usage.js';
16
12
  const PATH = '/api/model';
17
13
  const SNAPSHOT = '/api/model/snapshot';
18
14
  const SYNC = '/api/model/sync';
19
- const CLAUDE_MODELS = '/api/model/claude-models';
20
- const CLAUDE_LOGIN = '/api/model/claude/';
21
- const LOGIN_RE = /^[A-Za-z0-9_-]{11}$/;
22
15
 
23
16
  export interface ModelApiDeps extends UsageDeps {
24
- dir?: () => string;
25
17
  setup?: () => ModelSetup;
26
18
  sync?: () => Promise<void>;
27
19
  syncStatus?: () => SyncStatus;
28
- anthropicBase?: string;
29
20
  }
30
21
 
31
22
  const NO_SYNC: SyncStatus = { enrolled: false, at: null, error: null };
23
+ const NO_MODEL = "No model is set up for this agent yet: choose one on the organization's Models page.";
32
24
 
33
25
  function publicConnection(c: Connection): Record<string, unknown> {
34
26
  return {
@@ -37,9 +29,9 @@ function publicConnection(c: Connection): Record<string, unknown> {
37
29
  label: c.label,
38
30
  model: c.model,
39
31
  hasKey: c.apiKey !== '',
32
+ subscription: c.claudeToken !== '',
40
33
  region: c.region,
41
34
  zdr: c.zdr,
42
- signedIn: c.codex !== null || (c.claude !== null && !c.signedOut),
43
35
  signedOut: c.signedOut,
44
36
  account: c.account.email,
45
37
  plan: c.account.plan,
@@ -47,13 +39,12 @@ function publicConnection(c: Connection): Record<string, unknown> {
47
39
  };
48
40
  }
49
41
 
50
- const loginsOf = (deps: ModelApiDeps): ClaudeLogin[] => readClaudeLogins(deps.dir?.() ?? agentsDir());
51
- const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup(deps.dir?.())))();
42
+ const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup()))();
52
43
 
53
44
  export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
54
45
  const setup = setupOf(deps);
55
46
  const primary = primaryRoute(setup);
56
- const reason = primary === null ? null : notReady(primary);
47
+ const reason = primary === null ? NO_MODEL : notReady(primary);
57
48
  return {
58
49
  source: setup.source,
59
50
  sync: (deps.syncStatus ?? (() => orgModels()?.syncStatus() ?? NO_SYNC))(),
@@ -64,14 +55,13 @@ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
64
55
  connections: setup.connections.map(publicConnection),
65
56
  fallbacks: setup.fallbacks,
66
57
  chain: chainStatus(setup),
67
- claude: loginsOf(deps),
68
58
  lastServed: lastServed(),
69
59
  usage: usageSeen(),
70
60
  };
71
61
  }
72
62
 
73
63
  async function withUsage(deps: ModelApiDeps): Promise<Record<string, unknown>> {
74
- await refreshUsage(setupOf(deps), loginsOf(deps), deps);
64
+ await refreshUsage(setupOf(deps), deps);
75
65
  return settingsBody(deps);
76
66
  }
77
67
 
@@ -80,23 +70,19 @@ async function synced(deps: ModelApiDeps): Promise<Record<string, unknown>> {
80
70
  return settingsBody(deps);
81
71
  }
82
72
 
83
- function dropLogin(session: ApiSession, login: string, deps: ModelApiDeps): Record<string, unknown> {
84
- requireAdmin(session);
85
- removeClaudeLogin(login, { ...deps.claudeLogins, dir: deps.dir?.() ?? deps.claudeLogins?.dir });
86
- return settingsBody(deps);
87
- }
73
+ type Run = () => Promise<unknown>;
88
74
 
89
- type Run = (session: ApiSession) => Promise<unknown>;
75
+ const ROUTES: Record<string, [string, (deps: ModelApiDeps) => Promise<unknown>]> = {
76
+ [PATH]: ['GET', withUsage],
77
+ [SNAPSHOT]: ['GET', (deps) => Promise.resolve(settingsBody(deps))],
78
+ [SYNC]: ['POST', synced],
79
+ };
90
80
 
91
81
  function routeFor(path: string, method: string | undefined, deps: ModelApiDeps): Run | number {
92
- const only = (wanted: string, run: Run): Run | number => (method === wanted ? run : 405);
93
- if (path === PATH) return only('GET', () => withUsage(deps));
94
- if (path === SNAPSHOT) return only('GET', () => Promise.resolve(settingsBody(deps)));
95
- if (path === SYNC) return only('POST', () => synced(deps));
96
- if (path === CLAUDE_MODELS) return only('GET', () => Promise.resolve({ models: claudeModels(deps.anthropicBase) }));
97
- const login = path.slice(CLAUDE_LOGIN.length);
98
- if (!LOGIN_RE.test(login)) return 404;
99
- return only('DELETE', (session) => Promise.resolve(dropLogin(session, login, deps)));
82
+ const known = ROUTES[path];
83
+ if (known === undefined) return 404;
84
+ const [wanted, run] = known;
85
+ return method === wanted ? () => run(deps) : 405;
100
86
  }
101
87
 
102
88
  const mine = (path: string): boolean => path === PATH || path.startsWith(`${PATH}/`);
@@ -108,7 +94,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
108
94
  res.writeHead(204, cors(req)).end();
109
95
  return true;
110
96
  }
111
- const route = path === PATH || path.startsWith(CLAUDE_LOGIN) || [SNAPSHOT, SYNC, CLAUDE_MODELS].includes(path) ? routeFor(path, req.method, deps) : 404;
97
+ const route = routeFor(path, req.method, deps);
112
98
  if (typeof route === 'number') {
113
99
  sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
114
100
  return true;
@@ -116,7 +102,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
116
102
  apiSession(req)
117
103
  .then(async (session) => {
118
104
  if (!session) throw new ApiError('unauthorized', 401);
119
- sendJson(req, res, 200, await route(session));
105
+ sendJson(req, res, 200, await route());
120
106
  })
121
107
  .catch((err: unknown) => {
122
108
  apiFailure(req, res, err, 'model-api');