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

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 (34) hide show
  1. package/dist/claude-env.js +1 -1
  2. package/dist/route.js +1 -1
  3. package/package.json +1 -1
  4. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  5. package/runtime/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -3
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -36
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +4 -13
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -9
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +4 -15
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +5 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +6 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +66 -67
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +18 -32
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +15 -46
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-leftovers.ts +24 -3
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/routeway.ts +19 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/subscription.ts +1 -61
  20. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +11 -55
  21. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +1 -66
  22. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -1
  23. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +7 -1
  24. package/runtime/runtime.json +1 -1
  25. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  26. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
  27. package/runtime/sdk-runner/runner.json +1 -1
  28. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +0 -243
  29. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +0 -38
  30. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +0 -128
  31. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +0 -192
  32. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +0 -59
  33. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -40
  34. 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/dist/route.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { agentsDir } from './local.js';
4
- const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini']);
4
+ const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini', 'routeway']);
5
5
  const ANTHROPIC = 'anthropic';
6
6
  const text = (value) => (typeof value === 'string' ? value : '');
7
7
  function routedIn(cfg) {
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.312",
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,16 +39,15 @@ 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';
46
+ import { refusedWithPage, ROUTEWAY_BASE, routewayHeaders } from './routeway.js';
43
47
  import { isRecord } from '@metro-labs/core/is-record';
44
48
  import { forgetServed, noteServed } from './served.js';
45
49
  import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
46
50
  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
51
  import { orgModels } from './org-models.js';
50
52
 
51
53
  const GATEWAY_PREFIX = '/gateway';
@@ -53,6 +55,8 @@ const MESSAGES = '/v1/messages';
53
55
  const COUNT = '/v1/messages/count_tokens';
54
56
  const MODELS = '/v1/models';
55
57
  const HELLO = '/api/hello';
58
+ const ANTHROPIC_API = 'https://api.anthropic.com';
59
+ 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
60
 
57
61
  export interface GatewayDeps {
58
62
  setup: () => ModelSetup;
@@ -60,8 +64,8 @@ export interface GatewayDeps {
60
64
  anthropicBase?: string;
61
65
  bedrockBase?: string;
62
66
  openrouterBase?: string;
67
+ routewayBase?: string;
63
68
  codex?: Partial<CodexDeps>;
64
- claudeLogins?: ClaudeLoginDeps;
65
69
  }
66
70
 
67
71
  const learned: Adaptations = freshAdaptations();
@@ -73,7 +77,6 @@ export function resetGatewayState(): void {
73
77
  learned.fields.clear();
74
78
  learned.dropBetas = false;
75
79
  forgetLearnedThinking();
76
- forgetClaudeTokens();
77
80
  }
78
81
 
79
82
  const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
@@ -101,7 +104,9 @@ function modelsBody(setup: ModelSetup): Record<string, unknown> {
101
104
  return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
102
105
  }
103
106
 
104
- const passthrough = (body: Record<string, unknown>): Route => ({ connection: sessionConnection(), model: requestedModel(body) });
107
+ const noRoute = (): never => {
108
+ throw new GatewayError(503, 'api_error', NO_ROUTE);
109
+ };
105
110
 
106
111
  function requestedModel(body: Record<string, unknown>): string {
107
112
  const model = body.model;
@@ -109,24 +114,27 @@ function requestedModel(body: Record<string, unknown>): string {
109
114
  return model;
110
115
  }
111
116
 
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
117
  interface Payloads {
118
118
  metro: Buffer;
119
119
  asSent: Buffer | null;
120
120
  bound: boolean;
121
121
  }
122
122
 
123
- function anthropicPayloads(raw: Buffer, sent: Record<string, unknown>, shaped: Record<string, unknown>, model: string): Payloads {
123
+ interface Shaped {
124
+ raw: Buffer;
125
+ sent: Record<string, unknown>;
126
+ shaped: Record<string, unknown>;
127
+ bind: boolean;
128
+ }
129
+
130
+ function anthropicPayloads({ raw, sent, shaped, bind }: Shaped, model: string): Payloads {
124
131
  const runnable = withThinkingFor(sent, model);
125
132
  const rewrite = runnable !== sent || (typeof sent.model === 'string' && sent.model !== model);
126
133
  const asSent = rewrite ? Buffer.from(JSON.stringify({ ...runnable, model })) : raw;
127
- const bound = withBlockBinding(withThinkingFor(shaped, model));
134
+ const fitted = withThinkingFor(shaped, model);
135
+ const bound = bind ? withBlockBinding(fitted) : fitted;
128
136
  if (bound === sent) return { metro: asSent, asSent: null, bound: false };
129
- return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bound !== shaped };
137
+ return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bind && bound !== shaped };
130
138
  }
131
139
 
132
140
  type Send = (payload: Buffer, headers: Record<string, string>) => Promise<Response>;
@@ -148,61 +156,43 @@ async function refusedThinkingOff(upstream: Response, shaped: Record<string, unk
148
156
  return true;
149
157
  }
150
158
 
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
- });
159
+ async function refusedSubscription(conn: Connection, upstream: Response, watch: Watch): Promise<void> {
160
+ if (conn.apiKey !== '' || upstream.status !== 401) return;
161
+ await upstream.body?.cancel();
162
+ watch.stop();
163
+ throw new SubscriptionRefused(conn.label);
155
164
  }
156
165
 
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
- });
166
+ async function shapedAttempt(send: Send, base: Record<string, string>, body: Shaped, route: Route): Promise<Response> {
167
+ const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(body, route.model), route.connection.label, route.model);
168
+ const first = await shape();
169
+ return (await refusedThinkingOff(first, body.shaped, route.model)) ? shape() : first;
164
170
  }
165
171
 
166
- async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
167
- if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
168
- 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`);
170
- }
172
+ const sender = (url: string, watch: Watch): Send => (payload, headers) =>
173
+ fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
174
+
175
+ const upstreamPath = (req: IncomingMessage): string => (req.url ?? '').slice(GATEWAY_PREFIX.length);
171
176
 
172
- async function toAnthropic(
173
- req: IncomingMessage,
174
- res: ServerResponse,
175
- raw: Buffer,
176
- sent: Record<string, unknown>,
177
- shaped: Record<string, unknown>,
178
- route: Route,
179
- deps: GatewayDeps,
180
- ): Promise<void> {
177
+ async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
181
178
  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
- );
191
179
  const watch = watchUpstream(res);
192
- if (passthrough) listLoginModels(req.headers, anthropicBase);
193
- const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
194
- fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
195
- const attempt = async (base: Record<string, string>): Promise<Response> => {
196
- const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
197
- const first = await shape();
198
- return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
199
- };
200
- const upstream = await sendAnthropic(req, conn, anthropicBase, attempt, deps);
201
- await refusedLogin(conn, upstream);
180
+ const send = sender(`${deps.anthropicBase ?? ANTHROPIC_API}${upstreamPath(req)}`, watch);
181
+ const upstream = await shapedAttempt(send, conn.apiKey !== '' ? anthropicHeaders(req, conn.apiKey) : loginHeaders(req, conn.claudeToken), body, route);
182
+ await refusedSubscription(conn, upstream, watch);
202
183
  noteRefusal(conn.label, route.model, upstream);
203
184
  noteUsageHeaders('anthropic', conn.id, upstream.headers);
204
- const scanner = new UsageScanner(conn.id);
205
- await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
185
+ await pipeResponse(upstream, res, watch, { ownCredential: true, scanner: new UsageScanner(conn.id) });
186
+ }
187
+
188
+ async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
189
+ const conn = route.connection;
190
+ const watch = watchUpstream(res);
191
+ const send = sender(`${deps.routewayBase ?? ROUTEWAY_BASE}${upstreamPath(req)}`, watch);
192
+ const upstream = await shapedAttempt(send, routewayHeaders(req, conn.apiKey), { ...body, bind: false }, route);
193
+ noteRefusal(conn.label, route.model, upstream);
194
+ await refusedWithPage(upstream, watch);
195
+ await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(conn.id) });
206
196
  }
207
197
 
208
198
  async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
@@ -286,7 +276,18 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
286
276
  await toCodex(req, res, path, body, route, deps);
287
277
  return;
288
278
  }
289
- await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
279
+ const shaped: Shaped = { raw: bytesOf(ask.raw, ask.parsed, ask.sent), sent: ask.sent, shaped: body, bind: true };
280
+ await (conn.provider === 'routeway' ? toRouteway : toAnthropic)(req, res, shaped, route, deps);
281
+ }
282
+
283
+ async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: GatewayDeps): Promise<Refusal | null> {
284
+ try {
285
+ await attempt(req, held.res, ask, route, deps);
286
+ } catch (err) {
287
+ if (!(err instanceof SubscriptionRefused)) throw err;
288
+ return { status: 401, headers: new Headers() };
289
+ }
290
+ return held.refusal();
290
291
  }
291
292
 
292
293
  async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
@@ -295,7 +296,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
295
296
  const parsed = parseJson(raw);
296
297
  const sent = fitToolSearch(req, parsed);
297
298
  const requested = requestedModel(sent);
298
- const primary = resolveRoute(requested, setup) ?? passthrough(sent);
299
+ const primary = resolveRoute(requested, setup) ?? noRoute();
299
300
  const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
300
301
  const routes = routesToTry(chainOf(primary, setup, requested));
301
302
  for (const [at, route] of routes.entries()) {
@@ -305,9 +306,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
305
306
  await attempt(req, res, ask, route, deps);
306
307
  return;
307
308
  }
308
- const held = holdLimitRefusal(res);
309
- await attempt(req, held.res, ask, route, deps);
310
- const refusal = held.refusal();
309
+ const refusal = await heldAttempt(req, holdLimitRefusal(res), ask, route, deps);
311
310
  if (refusal === null) return;
312
311
  noteRefused(route, refusal.status, refusal.headers, next);
313
312
  }