@stage-labs/metro 0.1.0-beta.311 → 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.
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.311",
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": {
@@ -43,6 +43,7 @@ import { notReady, resolveRoute, routeLabel, type Connection, type ModelSetup, t
43
43
  import { codexMessages, type CodexDeps } from './codex.js';
44
44
  import { countTokens } from './subscription.js';
45
45
  import { OPENROUTER_BASE } from './openrouter.js';
46
+ import { refusedWithPage, ROUTEWAY_BASE, routewayHeaders } from './routeway.js';
46
47
  import { isRecord } from '@metro-labs/core/is-record';
47
48
  import { forgetServed, noteServed } from './served.js';
48
49
  import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
@@ -63,6 +64,7 @@ export interface GatewayDeps {
63
64
  anthropicBase?: string;
64
65
  bedrockBase?: string;
65
66
  openrouterBase?: string;
67
+ routewayBase?: string;
66
68
  codex?: Partial<CodexDeps>;
67
69
  }
68
70
 
@@ -118,13 +120,21 @@ interface Payloads {
118
120
  bound: boolean;
119
121
  }
120
122
 
121
- 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 {
122
131
  const runnable = withThinkingFor(sent, model);
123
132
  const rewrite = runnable !== sent || (typeof sent.model === 'string' && sent.model !== model);
124
133
  const asSent = rewrite ? Buffer.from(JSON.stringify({ ...runnable, model })) : raw;
125
- const bound = withBlockBinding(withThinkingFor(shaped, model));
134
+ const fitted = withThinkingFor(shaped, model);
135
+ const bound = bind ? withBlockBinding(fitted) : fitted;
126
136
  if (bound === sent) return { metro: asSent, asSent: null, bound: false };
127
- 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 };
128
138
  }
129
139
 
130
140
  type Send = (payload: Buffer, headers: Record<string, string>) => Promise<Response>;
@@ -153,32 +163,38 @@ async function refusedSubscription(conn: Connection, upstream: Response, watch:
153
163
  throw new SubscriptionRefused(conn.label);
154
164
  }
155
165
 
156
- async function toAnthropic(
157
- req: IncomingMessage,
158
- res: ServerResponse,
159
- raw: Buffer,
160
- sent: Record<string, unknown>,
161
- shaped: Record<string, unknown>,
162
- route: Route,
163
- deps: GatewayDeps,
164
- ): Promise<void> {
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;
170
+ }
171
+
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);
176
+
177
+ async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
165
178
  const conn = route.connection;
166
- const url = `${deps.anthropicBase ?? ANTHROPIC_API}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
167
179
  const watch = watchUpstream(res);
168
- const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
169
- fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
170
- const attempt = async (base: Record<string, string>): Promise<Response> => {
171
- const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
172
- const first = await shape();
173
- return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
174
- };
175
- const upstream = await attempt(conn.apiKey !== '' ? anthropicHeaders(req, conn.apiKey) : loginHeaders(req, conn.claudeToken));
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);
176
182
  await refusedSubscription(conn, upstream, watch);
177
183
  noteRefusal(conn.label, route.model, upstream);
178
184
  noteUsageHeaders('anthropic', conn.id, upstream.headers);
179
185
  await pipeResponse(upstream, res, watch, { ownCredential: true, scanner: new UsageScanner(conn.id) });
180
186
  }
181
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) });
196
+ }
197
+
182
198
  async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
183
199
  const conn = route.connection;
184
200
  const headers: Record<string, string> = {
@@ -260,7 +276,8 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
260
276
  await toCodex(req, res, path, body, route, deps);
261
277
  return;
262
278
  }
263
- 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);
264
281
  }
265
282
 
266
283
  async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: GatewayDeps): Promise<Refusal | null> {
@@ -1,4 +1,4 @@
1
- export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
1
+ export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'routeway'] as const;
2
2
  export type Provider = (typeof PROVIDERS)[number];
3
3
  export type CodexMethod = 'chatgpt' | 'code';
4
4
  export type RouteSource = 'agent' | 'organization';
@@ -50,7 +50,7 @@ export interface Route {
50
50
 
51
51
  export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's Models page.";
52
52
 
53
- const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
53
+ const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|routeway):(.+)$/;
54
54
  const SMALL_RE = /haiku/i;
55
55
 
56
56
  export const isProvider = (value: unknown): value is Provider => typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
@@ -60,10 +60,12 @@ export const emptySetup = (): ModelSetup => ({ primary: null, fallbacks: [], con
60
60
  export const connectionOf = (setup: ModelSetup, id: string): Connection | null => setup.connections.find((c) => c.id === id) ?? null;
61
61
 
62
62
  export function primaryRoute(setup: ModelSetup): Route | null {
63
- const primary = setup.primary;
64
- const conn = primary === null ? null : connectionOf(setup, primary.connection);
65
- if (primary === null || conn === null) return null;
66
- return { connection: conn, model: primary.model === '' ? conn.model : primary.model };
63
+ const steps = setup.primary === null ? [] : [setup.primary, ...setup.fallbacks];
64
+ for (const step of steps) {
65
+ const conn = connectionOf(setup, step.connection);
66
+ if (conn !== null) return { connection: conn, model: step.model === '' ? conn.model : step.model };
67
+ }
68
+ return null;
67
69
  }
68
70
 
69
71
  const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
@@ -80,6 +82,10 @@ const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
80
82
  [(r) => r.connection.codex === null, CODEX_SIGN_IN],
81
83
  [(r) => r.model === '', "Codex needs a model id: choose one on the organization's Models page."],
82
84
  ],
85
+ routeway: [
86
+ [(r) => r.connection.apiKey === '', "Routeway has no API key: add it on the organization's Models page."],
87
+ [(r) => r.model === '', "Routeway needs a model id: choose one on the organization's Models page."],
88
+ ],
83
89
  };
84
90
 
85
91
  export const notReady = (route: Route): string | null => CHECKS[route.connection.provider].find(([missing]) => missing(route))?.[1] ?? null;
@@ -90,6 +96,7 @@ const DEFAULTS: Record<Provider, (requested: string, model: string) => string> =
90
96
  openrouter: (requested, model) => (requested.includes('/') ? requested : model),
91
97
  bedrock: (requested, model) => (model === '' ? requested : model),
92
98
  codex: (requested, model) => (requested.startsWith('gpt-') ? requested : model),
99
+ routeway: (_requested, model) => model,
93
100
  anthropic: (requested, model) => (model === '' || isSmallModel(requested) ? requested : model),
94
101
  };
95
102
 
@@ -0,0 +1,19 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { anthropicHeaders, GatewayError, type Watch } from './forward.js';
3
+
4
+ export const ROUTEWAY_BASE = 'https://api.routeway.ai';
5
+ const USER_AGENT = 'metro (+https://metro.box)';
6
+ const HANDED_OVER = new Set([402, 429]);
7
+
8
+ export function routewayHeaders(req: IncomingMessage, apiKey: string): Record<string, string> {
9
+ const headers = anthropicHeaders(req, apiKey);
10
+ headers['user-agent'] ??= USER_AGENT;
11
+ return headers;
12
+ }
13
+
14
+ export async function refusedWithPage(upstream: Response, watch: Watch): Promise<void> {
15
+ if (upstream.ok || HANDED_OVER.has(upstream.status) || !(upstream.headers.get('content-type') ?? '').includes('text/html')) return;
16
+ await upstream.body?.cancel();
17
+ watch.stop();
18
+ throw new GatewayError(502, 'api_error', `metro gateway: Routeway answered with a web page instead of its API (${String(upstream.status)}); try again in a moment`);
19
+ }
@@ -33,7 +33,7 @@ export function requireAdmin(session: ApiSession): void {
33
33
  export function cors(req: IncomingMessage): Record<string, string> {
34
34
  return {
35
35
  'access-control-allow-origin': req.headers.origin ?? '*',
36
- 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
36
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
37
37
  'access-control-allow-headers': 'Authorization, Content-Type',
38
38
  'access-control-allow-private-network': 'true',
39
39
  'access-control-max-age': '86400',
@@ -9,6 +9,8 @@ export interface RemoteTool {
9
9
  title: string;
10
10
  description: string;
11
11
  readOnly: boolean;
12
+ destructive?: boolean;
13
+ openWorld?: boolean;
12
14
  inputSchema?: Record<string, unknown>;
13
15
  }
14
16
 
@@ -36,11 +38,15 @@ async function rpc(url: URL, auth: ConnectorAuth, session: Map<string, string>,
36
38
  return resultOf(text, res.headers.get('content-type') ?? '');
37
39
  }
38
40
 
41
+ function hintsOf(annotations: Record<string, unknown>): Pick<RemoteTool, 'destructive' | 'openWorld'> {
42
+ return { ...(annotations.destructiveHint === true ? { destructive: true } : {}), ...(annotations.openWorldHint === true ? { openWorld: true } : {}) };
43
+ }
44
+
39
45
  function toTool(raw: unknown): RemoteTool | null {
40
46
  if (!isRecord(raw) || typeof raw.name !== 'string') return null;
41
47
  const annotations = isRecord(raw.annotations) ? raw.annotations : {};
42
48
  const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
43
- return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
49
+ return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...hintsOf(annotations), ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
44
50
  }
45
51
 
46
52
  async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.311"
2
+ "version": "0.1.0-beta.312"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.311",
2
+ "version": "0.1.0-beta.312",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",