@stage-labs/metro 0.1.0-beta.320 → 0.1.0-beta.322

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 (58) hide show
  1. package/dist/claude-env.js +2 -2
  2. package/dist/cli.js +1 -1
  3. package/package.json +1 -1
  4. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +12 -12
  5. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/box-gateway.ts +32 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +2 -8
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +6 -6
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +2 -2
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/relay.ts +108 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +2 -2
  15. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  16. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -2
  17. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +1 -2
  18. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +14 -1
  19. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +10 -1
  20. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +21 -7
  21. package/runtime/node_modules/@metro-labs/inference/package.json +42 -0
  22. package/runtime/node_modules/@metro-labs/inference/src/attempts.ts +120 -0
  23. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/codex.ts +1 -1
  24. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/delivered.ts +7 -1
  25. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/effort.ts +2 -2
  26. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/failures.ts +11 -1
  27. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/fallback.ts +5 -4
  28. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/forward.ts +1 -1
  29. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/gateway.ts +47 -37
  30. package/runtime/node_modules/@metro-labs/inference/src/measure.ts +42 -0
  31. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/model-config.ts +9 -9
  32. package/runtime/node_modules/@metro-labs/inference/src/named.ts +36 -0
  33. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/routeway.ts +2 -1
  34. package/runtime/node_modules/@metro-labs/inference/src/served.ts +18 -0
  35. package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/usage.ts +34 -9
  36. package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +8 -1
  37. package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +1 -0
  38. package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +14 -6
  39. package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +8 -2
  40. package/runtime/runtime.json +1 -1
  41. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +12 -12
  42. package/runtime/sdk-runner/runner.json +1 -1
  43. package/runtime/sdk-runner/src/app.ts +1 -1
  44. package/runtime/sdk-runner/src/runner.ts +1 -1
  45. package/skills/STAGE.md +3 -2
  46. package/runtime/node_modules/@metro-labs/daemon/src/gateway/served.ts +0 -18
  47. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/bedrock.ts +0 -0
  48. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/cache-ttl.ts +0 -0
  49. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/codex-stream.ts +0 -0
  50. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/codex-translate.ts +0 -0
  51. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/codex-version.ts +0 -0
  52. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/eventstream.ts +0 -0
  53. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/frames.ts +0 -0
  54. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/openrouter.ts +0 -0
  55. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/subscription.ts +0 -0
  56. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/text.ts +0 -0
  57. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/tool-search.ts +0 -0
  58. /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/usage-refresh.ts +0 -0
@@ -0,0 +1,120 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from 'node:http';
3
+ import type { RequestKind } from './effort.js';
4
+ import type { Route } from './model-config.js';
5
+
6
+ export type AttemptOutcome = 'ok' | 'rate_limited' | 'refused' | 'provider_error' | 'cancelled';
7
+
8
+ export type AttemptKind = 'main' | 'subagent' | 'compaction' | 'chore';
9
+
10
+ export interface AttemptTokens {
11
+ input: number;
12
+ output: number;
13
+ cacheRead: number;
14
+ cacheWrite5m: number;
15
+ cacheWrite1h: number;
16
+ }
17
+
18
+ export interface AttemptCall {
19
+ connection: string;
20
+ provider: string;
21
+ model: string;
22
+ lane: 'main' | 'workers';
23
+ kind: AttemptKind;
24
+ attempt: number;
25
+ status: number;
26
+ outcome: AttemptOutcome;
27
+ tokens: AttemptTokens;
28
+ providerCost: number | null;
29
+ ms: number;
30
+ firstTokenMs: number | null;
31
+ at: number;
32
+ }
33
+
34
+ export type OnCall = (call: AttemptCall) => void;
35
+
36
+ const KINDS: Record<RequestKind, AttemptKind> = { main: 'main', subagent: 'subagent', compact: 'compaction', other: 'chore' };
37
+
38
+ const NO_TOKENS: AttemptTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 };
39
+
40
+ export class Attempt {
41
+ private readonly started = Date.now();
42
+ private firstAt: number | null = null;
43
+ private status: number | null = null;
44
+ private failedMidStream = false;
45
+ private tokens: AttemptTokens = NO_TOKENS;
46
+ private cost: number | null = null;
47
+
48
+ constructor(
49
+ private readonly route: Route,
50
+ private readonly lane: 'main' | 'helpers',
51
+ private readonly kind: RequestKind,
52
+ private readonly index: number,
53
+ ) {}
54
+
55
+ firstByte(): void {
56
+ this.firstAt ??= Date.now();
57
+ }
58
+
59
+ answered(status: number): void {
60
+ this.status ??= status;
61
+ }
62
+
63
+ failed(): void {
64
+ this.failedMidStream = true;
65
+ }
66
+
67
+ counted(tokens: AttemptTokens, cost: number | null): void {
68
+ this.tokens = tokens;
69
+ this.cost = cost;
70
+ }
71
+
72
+ call(fallbackStatus: number, cancelled: boolean): AttemptCall {
73
+ const status = this.status ?? fallbackStatus;
74
+ const ended = Date.now();
75
+ return {
76
+ connection: this.route.connection.id,
77
+ provider: this.route.connection.provider,
78
+ model: this.route.model,
79
+ lane: this.lane === 'helpers' ? 'workers' : 'main',
80
+ kind: KINDS[this.kind],
81
+ attempt: this.index,
82
+ status,
83
+ outcome: cancelled ? 'cancelled' : outcomeOf(status, this.failedMidStream),
84
+ tokens: this.tokens,
85
+ providerCost: this.cost,
86
+ ms: ended - this.started,
87
+ firstTokenMs: this.firstAt === null ? null : this.firstAt - this.started,
88
+ at: ended,
89
+ };
90
+ }
91
+ }
92
+
93
+ function outcomeOf(status: number, failedMidStream: boolean): AttemptOutcome {
94
+ if (status === 402 || status === 429) return 'rate_limited';
95
+ if (status >= 500) return 'provider_error';
96
+ if (status >= 400) return 'refused';
97
+ return failedMidStream ? 'provider_error' : 'ok';
98
+ }
99
+
100
+ const current = new AsyncLocalStorage<Attempt>();
101
+
102
+ export const currentAttempt = (): Attempt | undefined => current.getStore();
103
+
104
+ export const inAttempt = <T>(attempt: Attempt, run: () => Promise<T>): Promise<T> => current.run(attempt, run);
105
+
106
+ export function tracked(res: ServerResponse, attempt: Attempt): ServerResponse {
107
+ const proxy: ServerResponse = new Proxy(res, {
108
+ get(target, prop) {
109
+ if (prop === 'writeHead')
110
+ return (status: number, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]): ServerResponse => {
111
+ attempt.answered(status);
112
+ target.writeHead(status, headers);
113
+ return proxy;
114
+ };
115
+ const value: unknown = Reflect.get(target, prop, target);
116
+ return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
117
+ },
118
+ });
119
+ return proxy;
120
+ }
@@ -18,7 +18,7 @@ import { noteUsageHeaders } from './usage.js';
18
18
  const CODEX_BACKEND = 'https://chatgpt.com/backend-api/codex';
19
19
  const OPENAI_API = 'https://api.openai.com/v1';
20
20
  const INVALID = [400, 404, 422];
21
- const RENEW_FAILED = "Codex refused its sign-in and api.metro.box could not renew it: sign in again on the organization's Models page.";
21
+ const RENEW_FAILED = "Codex refused its sign-in and api.metro.box could not renew it: sign in again on the organization's Gateway page.";
22
22
 
23
23
  export interface CodexDeps {
24
24
  base?: string;
@@ -1,6 +1,6 @@
1
1
  import { readEffort, type RouteEffort } from '@metro-labs/core/effort';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
- import { isProvider, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
3
+ import { emptySetup, isProvider, type ModelSetup, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
4
4
 
5
5
  export interface DeliveredRoute {
6
6
  primary: Fallback | null;
@@ -100,3 +100,9 @@ export function deliveredOf(raw: Record<string, unknown>): Delivered | null {
100
100
  export function refreshedConnection(raw: Record<string, unknown>): Connection | null {
101
101
  return deliveredConnection(isRecord(raw.connection) ? raw.connection : raw);
102
102
  }
103
+
104
+ export function setupOf(delivered: Delivered | null): ModelSetup {
105
+ const route = delivered?.route ?? null;
106
+ if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
107
+ return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source, helpers: route.helpers, effort: route.effort };
108
+ }
@@ -70,10 +70,10 @@ function wantedEffort(kind: RequestKind, setting: RouteEffort): string | null {
70
70
  return wanted === 'asked' ? null : wanted;
71
71
  }
72
72
 
73
- export function plannedEffort(req: IncomingMessage, body: Body, setting: RouteEffort = {}): string | null {
73
+ export function plannedEffort(req: IncomingMessage, body: Body, setting: RouteEffort = {}, kind: RequestKind = requestKind(req, body)): string | null {
74
74
  const asked = askedEfforts(body);
75
75
  if (asked.length === 0) return null;
76
- const wanted = wantedEffort(requestKind(req, body), setting);
76
+ const wanted = wantedEffort(kind, setting);
77
77
  if (wanted === null) return null;
78
78
  return asked.every((effort) => effort === wanted) ? null : wanted;
79
79
  }
@@ -1,10 +1,19 @@
1
1
  import { isRecord } from '@metro-labs/core/is-record';
2
2
  import { log } from '@metro-labs/core/log';
3
3
  import { stringOf } from '@metro-labs/http/api-http';
4
+ import { currentAttempt } from './attempts.js';
4
5
  import { parseEvent, SseParser } from './frames.js';
5
6
  import type { Route } from './model-config.js';
6
7
 
7
8
  const MESSAGE_MAX = 300;
9
+
10
+ let keepProviderText = true;
11
+
12
+ export const quietProviderText = (): void => {
13
+ keepProviderText = false;
14
+ };
15
+
16
+ export const providerText = (text: string): string | undefined => (keepProviderText ? text.slice(0, MESSAGE_MAX) : undefined);
8
17
  const ERROR_DATA = /^\{\s*"type"\s*:\s*"error"/;
9
18
 
10
19
  export interface FailureSite {
@@ -21,7 +30,8 @@ export interface Failure {
21
30
  export const siteOf = (route: Route): FailureSite => ({ connection: route.connection.label, model: route.model });
22
31
 
23
32
  export function noteFailure(site: FailureSite, failure: Failure, what: string): void {
24
- log.warn({ connection: site.connection, model: site.model, ...failure, message: failure.message.slice(0, MESSAGE_MAX) }, what);
33
+ currentAttempt()?.failed();
34
+ log.warn({ connection: site.connection, model: site.model, ...failure, message: providerText(failure.message) }, what);
25
35
  }
26
36
 
27
37
  export class StreamErrors {
@@ -21,7 +21,7 @@ interface Cooling {
21
21
  export type Lane = 'main' | 'helpers';
22
22
 
23
23
  const cooling = new Map<string, Cooling>();
24
- const lastChoice = new Map<Lane, string>();
24
+ const lastChoice = new Map<string, string>();
25
25
 
26
26
  const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
27
27
 
@@ -98,11 +98,12 @@ export function noteRefused(route: Route, status: number, headers: Headers, next
98
98
  log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, why);
99
99
  }
100
100
 
101
- export function noteChoice(primary: Route, chosen: Route, lane: Lane, now = Date.now()): void {
101
+ export function noteChoice(primary: Route, chosen: Route, lane: Lane, now = Date.now(), scope = ''): void {
102
102
  const key = keyOf(chosen);
103
- const last = lastChoice.get(lane);
103
+ const at = `${scope}\n${lane}`;
104
+ const last = lastChoice.get(at);
104
105
  if (key === last) return;
105
- lastChoice.set(lane, key);
106
+ lastChoice.set(at, key);
106
107
  if (key === keyOf(primary)) {
107
108
  if (last !== undefined) log.info({ lane, model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
108
109
  return;
@@ -34,7 +34,7 @@ const RESPONSE_DROP = new Set(['content-length', 'content-encoding', 'transfer-e
34
34
 
35
35
  export class SubscriptionRefused extends GatewayError {
36
36
  constructor(label: string) {
37
- super(403, 'permission_error', `Anthropic refused the Claude subscription ${label}: sign in again on the Models page`);
37
+ super(403, 'permission_error', `Anthropic refused the Claude subscription ${label}: sign in again on the Gateway page`);
38
38
  }
39
39
  }
40
40
 
@@ -1,7 +1,6 @@
1
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
- import { agentIdForKey } from '../agents/keys.js';
5
4
  import {
6
5
  bedrockBase,
7
6
  bedrockCount,
@@ -36,11 +35,16 @@ import {
36
35
  helperRequest,
37
36
  learnAlwaysThinks,
38
37
  plannedEffort,
38
+ requestKind,
39
39
  thinkingOff,
40
40
  withBlockBinding,
41
41
  withEffort,
42
42
  withThinkingFor,
43
+ type RequestKind,
43
44
  } from './effort.js';
45
+ import { currentAttempt, type OnCall } from './attempts.js';
46
+ import { measured } from './measure.js';
47
+ import { NAMED_MODELS, namedChoice, WORKERS, type Chosen } from './named.js';
44
48
  import { helperSetup, notReady, pinnedRoute, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
45
49
  import { codexMessages, type CodexDeps } from './codex.js';
46
50
  import { countTokens } from './subscription.js';
@@ -50,8 +54,7 @@ import { isRecord } from '@metro-labs/core/is-record';
50
54
  import { forgetServed, noteServed } from './served.js';
51
55
  import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
52
56
  import { fitToolSearch } from './tool-search.js';
53
- import { siteOf } from './failures.js';
54
- import { orgModels } from './org-models.js';
57
+ import { providerText, siteOf } from './failures.js';
55
58
 
56
59
  const GATEWAY_PREFIX = '/gateway';
57
60
  const MESSAGES = '/v1/messages';
@@ -59,20 +62,28 @@ const COUNT = '/v1/messages/count_tokens';
59
62
  const MODELS = '/v1/models';
60
63
  const HELLO = '/api/hello';
61
64
  const ANTHROPIC_API = 'https://api.anthropic.com';
62
- 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";
65
+ 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 Gateway page";
63
66
 
64
- export interface GatewayDeps {
67
+ export interface ModelDeps {
65
68
  setup: () => ModelSetup;
66
- identify?: (key: string) => boolean;
67
69
  anthropicBase?: string;
68
70
  bedrockBase?: string;
69
71
  openrouterBase?: string;
70
72
  routewayBase?: string;
71
73
  codex?: Partial<CodexDeps>;
74
+ onCall?: OnCall;
75
+ scope?: string;
76
+ named?: boolean;
77
+ }
78
+
79
+ export interface GatewayDeps extends ModelDeps {
80
+ identify: (key: string) => boolean;
72
81
  }
73
82
 
74
83
  const learned: Adaptations = freshAdaptations();
75
84
 
85
+ const noRefresh = (): Promise<Connection | null> => Promise.resolve(null);
86
+
76
87
  export function resetGatewayState(): void {
77
88
  forgetServed();
78
89
  forgetFallbackState();
@@ -81,15 +92,11 @@ export function resetGatewayState(): void {
81
92
  forgetLearnedThinking();
82
93
  }
83
94
 
84
- const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
85
-
86
95
  const keyOf = (req: IncomingMessage): string => {
87
96
  const raw = req.headers['x-metro-key'];
88
97
  return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
89
98
  };
90
99
 
91
- const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
92
-
93
100
  function routesOf(setup: ModelSetup): Route[] {
94
101
  return [...(setup.primary === null ? [] : [setup.primary]), ...setup.fallbacks].flatMap((ref) => {
95
102
  const conn = setup.connections.find((c) => c.id === ref.connection);
@@ -145,7 +152,7 @@ async function asAnthropicWants(send: Send, base: Record<string, string>, payloa
145
152
  const { metro, asSent, bound } = payloads;
146
153
  const upstream = await send(metro, bound ? addBeta(base, BINDING_BETA) : base);
147
154
  if (upstream.status !== 400 || asSent === null) return upstream;
148
- const reason = upstreamMessage(await upstream.text().catch(() => ''), '').slice(0, 300);
155
+ const reason = providerText(upstreamMessage(await upstream.text().catch(() => ''), ''));
149
156
  log.warn({ connection, model, reason }, 'gateway: the provider refused the request metro shaped, so it was sent again as Claude Code wrote it');
150
157
  return send(asSent, base);
151
158
  }
@@ -174,9 +181,12 @@ async function shapedAttempt(send: Send, base: Record<string, string>, body: Sha
174
181
  const sender = (url: string, watch: Watch): Send => (payload, headers) =>
175
182
  fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
176
183
 
177
- const upstreamPath = (req: IncomingMessage): string => (req.url ?? '').slice(GATEWAY_PREFIX.length);
184
+ const upstreamPath = (req: IncomingMessage): string => {
185
+ const url = req.url ?? '';
186
+ return url.startsWith(`${GATEWAY_PREFIX}/`) ? url.slice(GATEWAY_PREFIX.length) : url;
187
+ };
178
188
 
179
- async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
189
+ async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: ModelDeps): Promise<void> {
180
190
  const conn = route.connection;
181
191
  const watch = watchUpstream(res);
182
192
  const send = sender(`${deps.anthropicBase ?? ANTHROPIC_API}${upstreamPath(req)}`, watch);
@@ -187,7 +197,7 @@ async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shap
187
197
  await pipeResponse(upstream, res, watch, { ownCredential: true, scanner: new UsageScanner(conn.id), site: siteOf(route) });
188
198
  }
189
199
 
190
- async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
200
+ async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: ModelDeps): Promise<void> {
191
201
  const conn = route.connection;
192
202
  const watch = watchUpstream(res);
193
203
  const send = sender(`${deps.routewayBase ?? ROUTEWAY_BASE}${upstreamPath(req)}`, watch);
@@ -200,7 +210,7 @@ async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shape
200
210
  await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(conn.id), site: siteOf(route) });
201
211
  }
202
212
 
203
- async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
213
+ async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: ModelDeps): Promise<void> {
204
214
  const conn = route.connection;
205
215
  const headers: Record<string, string> = {
206
216
  'content-type': 'application/json',
@@ -238,22 +248,16 @@ function noteRefusal(connection: string, model: string, upstream: Response): voi
238
248
  if (!upstream.ok) log.warn({ connection, model, status: upstream.status }, 'gateway: the provider refused the request');
239
249
  }
240
250
 
241
- async function toCodex(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
251
+ async function toCodex(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: ModelDeps): Promise<void> {
242
252
  if (path === COUNT) countTokens(res, body);
243
- else await codexMessages(req, res, body, route.model, route.connection, { refresh: refreshCodex, ...deps.codex }, watchUpstream(res));
253
+ else await codexMessages(req, res, body, route.model, route.connection, { refresh: noRefresh, ...deps.codex }, watchUpstream(res));
244
254
  }
245
255
 
246
- function shapedFor(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup): Record<string, unknown> {
247
- const effort = plannedEffort(req, sent, setup.effort);
256
+ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup, kind: RequestKind): Record<string, unknown> {
257
+ const effort = plannedEffort(req, sent, setup.effort, kind);
248
258
  return effort === null ? sent : withEffort(sent, effort);
249
259
  }
250
260
 
251
- interface Chosen {
252
- lane: Lane;
253
- setup: ModelSetup;
254
- primary: Route;
255
- }
256
-
257
261
  function chosenChain(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup, requested: string): Chosen {
258
262
  const helpers = helperRequest(req, sent) ? helperSetup(setup) : null;
259
263
  if (helpers === null) return { lane: 'main', setup, primary: resolveRoute(requested, setup) ?? noRoute() };
@@ -264,6 +268,8 @@ const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<stri
264
268
 
265
269
  interface Request {
266
270
  path: string;
271
+ lane: Lane;
272
+ kind: RequestKind;
267
273
  raw: Buffer;
268
274
  parsed: Record<string, unknown>;
269
275
  sent: Record<string, unknown>;
@@ -271,13 +277,13 @@ interface Request {
271
277
  setup: ModelSetup;
272
278
  }
273
279
 
274
- async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
280
+ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: ModelDeps): Promise<void> {
275
281
  const { path, body } = ask;
276
282
  const conn = route.connection;
277
283
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
278
284
  const reason = notReady(route);
279
285
  if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
280
- if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
286
+ if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() }, deps.scope);
281
287
  if (conn.provider === 'bedrock') {
282
288
  const up = { settings: conn, base: deps.bedrockBase ?? bedrockBase(conn.region), learned, watch: watchUpstream(res) };
283
289
  if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
@@ -297,33 +303,37 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
297
303
  await (conn.provider === 'routeway' ? toRouteway : toAnthropic)(req, res, shaped, route, deps);
298
304
  }
299
305
 
300
- async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: GatewayDeps): Promise<Refusal | null> {
306
+ async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: ModelDeps): Promise<Refusal | null> {
301
307
  try {
302
308
  await attempt(req, held.res, ask, route, deps);
303
309
  } catch (err) {
304
310
  if (!(err instanceof SubscriptionRefused)) throw err;
311
+ currentAttempt()?.answered(401);
305
312
  return { status: 401, headers: new Headers() };
306
313
  }
307
314
  return held.refusal();
308
315
  }
309
316
 
310
- async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
317
+ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: ModelDeps): Promise<void> {
311
318
  const setup = deps.setup();
312
319
  const raw = await readBody(req);
313
320
  const parsed = parseJson(raw);
314
321
  const sent = fitToolSearch(req, parsed);
315
322
  const requested = requestedModel(sent);
316
- const { lane, setup: chain, primary } = chosenChain(req, sent, setup, requested);
317
- const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent, setup), setup };
323
+ const named = deps.named === true ? namedChoice(requested, setup) : null;
324
+ const { lane, setup: chain, primary } = named ?? chosenChain(req, sent, setup, requested);
325
+ const kind: RequestKind = named === null ? requestKind(req, sent) : requested === WORKERS ? 'subagent' : 'main';
326
+ const ask: Request = { path, lane, kind, raw, parsed, sent, body: shapedFor(req, sent, setup, kind), setup };
318
327
  const routes = routesToTry(chainOf(primary, chain, requested));
319
328
  for (const [at, route] of routes.entries()) {
320
329
  const next = routes[at + 1];
321
- if (path === MESSAGES) noteChoice(primary, route, lane);
330
+ if (path === MESSAGES) noteChoice(primary, route, lane, Date.now(), deps.scope);
322
331
  if (next === undefined) {
323
- await attempt(req, res, ask, route, deps);
332
+ await measured(res, ask, route, at, deps.onCall, (out) => attempt(req, out, ask, route, deps));
324
333
  return;
325
334
  }
326
- const refusal = await heldAttempt(req, holdLimitRefusal(res), ask, route, deps);
335
+ const held = holdLimitRefusal(res);
336
+ const refusal = await measured(held.res, ask, route, at, deps.onCall, (out) => heldAttempt(req, { res: out, refusal: held.refusal }, ask, route, deps));
327
337
  if (refusal === null) return;
328
338
  noteRefused(route, refusal.status, refusal.headers, next);
329
339
  }
@@ -342,10 +352,10 @@ function failed(res: ServerResponse, err: unknown): void {
342
352
  sendError(res, 502, 'api_error', `metro gateway: ${errMsg(err)}`);
343
353
  }
344
354
 
345
- function answer(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): void {
355
+ export function answerModels(req: IncomingMessage, res: ServerResponse, path: string, deps: ModelDeps): void {
346
356
  if (req.method === 'GET' && path === MODELS) {
347
357
  res.writeHead(200, { 'content-type': 'application/json' });
348
- res.end(JSON.stringify(modelsBody(deps.setup())));
358
+ res.end(JSON.stringify(deps.named === true ? NAMED_MODELS : modelsBody(deps.setup())));
349
359
  return;
350
360
  }
351
361
  if (req.method === 'POST' && (path === MESSAGES || path === COUNT)) {
@@ -366,7 +376,7 @@ export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse,
366
376
  return true;
367
377
  }
368
378
  if (cameThroughProxy(req)) sendError(res, 401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address');
369
- else if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
379
+ else if (keyOf(req) !== '' && deps.identify(keyOf(req))) answerModels(req, res, path, deps);
370
380
  else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start the agent with metro agent');
371
381
  return true;
372
382
  }
@@ -0,0 +1,42 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ import { errMsg, log } from '@metro-labs/core/log';
3
+ import { Attempt, inAttempt, tracked, type AttemptCall, type OnCall } from './attempts.js';
4
+ import type { RequestKind } from './effort.js';
5
+ import { GatewayError, SubscriptionRefused } from './forward.js';
6
+ import type { Route } from './model-config.js';
7
+
8
+ const MESSAGES = '/v1/messages';
9
+
10
+ export interface Asked {
11
+ path: string;
12
+ lane: 'main' | 'helpers';
13
+ kind: RequestKind;
14
+ }
15
+
16
+ function statusOf(failure: unknown, res: ServerResponse): number {
17
+ if (failure === null) return res.statusCode;
18
+ if (failure instanceof GatewayError) return failure.status;
19
+ return failure instanceof SubscriptionRefused ? 401 : 502;
20
+ }
21
+
22
+ function report(onCall: OnCall, call: AttemptCall): void {
23
+ try {
24
+ onCall(call);
25
+ } catch (err) {
26
+ log.warn({ err: errMsg(err) }, 'gateway: recording a call failed');
27
+ }
28
+ }
29
+
30
+ export async function measured<T>(res: ServerResponse, ask: Asked, route: Route, at: number, onCall: OnCall | undefined, run: (out: ServerResponse) => Promise<T>): Promise<T> {
31
+ if (ask.path !== MESSAGES || onCall === undefined) return run(res);
32
+ const counted = new Attempt(route, ask.lane, ask.kind, at);
33
+ let failure: unknown = null;
34
+ try {
35
+ return await inAttempt(counted, () => run(tracked(res, counted)));
36
+ } catch (err) {
37
+ failure = err;
38
+ throw err;
39
+ } finally {
40
+ report(onCall, counted.call(statusOf(failure, res), res.destroyed && !res.writableFinished));
41
+ }
42
+ }
@@ -57,7 +57,7 @@ export interface Route {
57
57
  model: string;
58
58
  }
59
59
 
60
- export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's Models page.";
60
+ export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's Gateway page.";
61
61
 
62
62
  const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|routeway):(.+)$/;
63
63
  const SMALL_RE = /haiku/i;
@@ -78,22 +78,22 @@ export function primaryRoute(setup: ModelSetup): Route | null {
78
78
  }
79
79
 
80
80
  const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
81
- anthropic: [[(r) => r.connection.apiKey === '' && r.connection.claudeToken === '', "Anthropic has no API key or Claude subscription: add one on the organization's Models page."]],
81
+ anthropic: [[(r) => r.connection.apiKey === '' && r.connection.claudeToken === '', "Anthropic has no API key or Claude subscription: add one on the organization's Gateway page."]],
82
82
  bedrock: [
83
- [(r) => r.connection.apiKey === '', "Bedrock has no API key: add it on the organization's Models page."],
84
- [(r) => r.connection.region === '', "Bedrock needs a region: add it on the organization's Models page."],
83
+ [(r) => r.connection.apiKey === '', "Bedrock has no API key: add it on the organization's Gateway page."],
84
+ [(r) => r.connection.region === '', "Bedrock needs a region: add it on the organization's Gateway page."],
85
85
  ],
86
86
  openrouter: [
87
- [(r) => r.connection.apiKey === '', "OpenRouter has no API key: add it on the organization's Models page."],
88
- [(r) => r.model === '', "OpenRouter needs a model id: choose one on the organization's Models page."],
87
+ [(r) => r.connection.apiKey === '', "OpenRouter has no API key: add it on the organization's Gateway page."],
88
+ [(r) => r.model === '', "OpenRouter needs a model id: choose one on the organization's Gateway page."],
89
89
  ],
90
90
  codex: [
91
91
  [(r) => r.connection.codex === null, CODEX_SIGN_IN],
92
- [(r) => r.model === '', "Codex needs a model id: choose one on the organization's Models page."],
92
+ [(r) => r.model === '', "Codex needs a model id: choose one on the organization's Gateway page."],
93
93
  ],
94
94
  routeway: [
95
- [(r) => r.connection.apiKey === '', "Routeway has no API key: add it on the organization's Models page."],
96
- [(r) => r.model === '', "Routeway needs a model id: choose one on the organization's Models page."],
95
+ [(r) => r.connection.apiKey === '', "Routeway has no API key: add it on the organization's Gateway page."],
96
+ [(r) => r.model === '', "Routeway needs a model id: choose one on the organization's Gateway page."],
97
97
  ],
98
98
  };
99
99
 
@@ -0,0 +1,36 @@
1
+ import type { Lane } from './fallback.js';
2
+ import { GatewayError } from './forward.js';
3
+ import { helperSetup, primaryRoute, type ModelSetup, type Route } from './model-config.js';
4
+
5
+ export interface Chosen {
6
+ lane: Lane;
7
+ setup: ModelSetup;
8
+ primary: Route;
9
+ }
10
+
11
+ const NO_ROUTE = "metro gateway: no model is set up for this agent yet: choose one on the organization's Gateway page";
12
+
13
+ const noRoute = (message: string): never => {
14
+ throw new GatewayError(503, 'api_error', message);
15
+ };
16
+
17
+ const MAIN = 'metro';
18
+ export const WORKERS = 'metro-workers';
19
+ const NAMES = `Use the model "${MAIN}" (this agent's main models) or "${WORKERS}" (its worker models).`;
20
+
21
+ export const NAMED_MODELS = {
22
+ data: [
23
+ { id: MAIN, display_name: 'Main models', description: "This agent's main model order on Metro" },
24
+ { id: WORKERS, display_name: 'Worker models', description: "This agent's worker model order on Metro" },
25
+ ],
26
+ has_more: false,
27
+ first_id: MAIN,
28
+ last_id: WORKERS,
29
+ };
30
+
31
+ export function namedChoice(requested: string, setup: ModelSetup): Chosen {
32
+ if (requested !== MAIN && requested !== WORKERS) throw new GatewayError(400, 'invalid_request_error', NAMES);
33
+ const helpers = requested === WORKERS ? helperSetup(setup) : null;
34
+ const chain = helpers ?? setup;
35
+ return { lane: helpers === null ? 'main' : 'helpers', setup: chain, primary: primaryRoute(chain) ?? noRoute(NO_ROUTE) };
36
+ }
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { log } from '@metro-labs/core/log';
4
+ import { providerText } from './failures.js';
4
5
  import { withoutCacheTtl, withoutTtlBeta } from './cache-ttl.js';
5
6
  import { anthropicHeaders, GatewayError, type Watch } from './forward.js';
6
7
 
@@ -84,5 +85,5 @@ export async function noteRoutewayRefusal(upstream: Response): Promise<void> {
84
85
  const error = isRecord(body) && isRecord(body.error) ? body.error : null;
85
86
  if (error === null) return;
86
87
  const text = (value: unknown): string | null => (typeof value === 'string' ? value.slice(0, 300) : null);
87
- log.warn({ status: upstream.status, reason: text(error.message), param: text(error.param) }, 'gateway: Routeway refused the request');
88
+ log.warn({ status: upstream.status, reason: providerText(text(error.message) ?? ''), param: providerText(text(error.param) ?? '') }, 'gateway: Routeway refused the request');
88
89
  }
@@ -0,0 +1,18 @@
1
+ export interface Served {
2
+ connection: string;
3
+ provider: string;
4
+ model: string;
5
+ at: string;
6
+ }
7
+
8
+ const last = new Map<string, Served>();
9
+
10
+ export const noteServed = (served: Served, scope = ''): void => {
11
+ last.set(scope, served);
12
+ };
13
+
14
+ export const lastServed = (scope = ''): Served | null => last.get(scope) ?? null;
15
+
16
+ export const forgetServed = (): void => {
17
+ last.clear();
18
+ };