@stage-labs/metro 0.1.0-beta.314 → 0.1.0-beta.316

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.314",
3
+ "version": "0.1.0-beta.316",
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": {
@@ -19,6 +19,7 @@
19
19
  "./trains/protocol": "./src/protocol.ts",
20
20
  "./ids": "./src/ids.ts",
21
21
  "./claude-token": "./src/claude-token.ts",
22
+ "./effort": "./src/effort.ts",
22
23
  "./tickets": "./src/tickets.ts",
23
24
  "./station-names": "./src/station-names.ts",
24
25
  "./str": "./src/str.ts",
@@ -0,0 +1,21 @@
1
+ import { isRecord } from './is-record.js';
2
+
3
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max', 'asked'] as const;
4
+
5
+ export type EffortLevel = (typeof EFFORT_LEVELS)[number];
6
+
7
+ export interface RouteEffort {
8
+ main?: EffortLevel;
9
+ helpers?: EffortLevel;
10
+ }
11
+
12
+ export const EFFORT_DEFAULTS: Required<RouteEffort> = { main: 'low', helpers: 'max' };
13
+
14
+ export const isEffortLevel = (value: unknown): value is EffortLevel => typeof value === 'string' && (EFFORT_LEVELS as readonly string[]).includes(value);
15
+
16
+ export function readEffort(raw: unknown): RouteEffort {
17
+ if (!isRecord(raw)) return {};
18
+ return { ...(isEffortLevel(raw.main) ? { main: raw.main } : {}), ...(isEffortLevel(raw.helpers) ? { helpers: raw.helpers } : {}) };
19
+ }
20
+
21
+ export const effortSet = (effort: RouteEffort): boolean => effort.main !== undefined || effort.helpers !== undefined;
@@ -1,9 +1,12 @@
1
+ import { readEffort, type RouteEffort } from '@metro-labs/core/effort';
1
2
  import { isRecord } from '@metro-labs/core/is-record';
2
- import { isProvider, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
3
+ import { isProvider, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
3
4
 
4
5
  export interface DeliveredRoute {
5
6
  primary: Fallback | null;
6
7
  fallbacks: Fallback[];
8
+ helpers: Chain | null;
9
+ effort: RouteEffort;
7
10
  }
8
11
 
9
12
  export interface Delivered {
@@ -24,10 +27,20 @@ function refOf(raw: unknown): Fallback | null {
24
27
  return ref.connection === '' ? null : ref;
25
28
  }
26
29
 
30
+ function fallbacksOf(raw: Record<string, unknown>): Fallback[] {
31
+ const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
32
+ return fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS);
33
+ }
34
+
35
+ function helpersOf(raw: unknown): Chain | null {
36
+ if (!isRecord(raw)) return null;
37
+ const primary = refOf(raw.primary);
38
+ return primary === null ? null : { primary, fallbacks: fallbacksOf(raw) };
39
+ }
40
+
27
41
  function routeOf(raw: unknown): DeliveredRoute | null {
28
42
  if (!isRecord(raw)) return null;
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 !== '').slice(0, MAX_FALLBACKS) };
43
+ return { primary: refOf(raw.primary), fallbacks: fallbacksOf(raw), helpers: helpersOf(raw.helpers), effort: readEffort(raw.effort) };
31
44
  }
32
45
 
33
46
  function accountOf(raw: unknown): Account {
@@ -6,7 +6,7 @@ import { deliveredSetup, type OrgModels } from './org-models.js';
6
6
  export function setupOf(delivered: Delivered | null): ModelSetup {
7
7
  const route = delivered?.route ?? null;
8
8
  if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
9
- return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source };
9
+ return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source, helpers: route.helpers, effort: route.effort };
10
10
  }
11
11
 
12
12
  export const effectiveSetup = (delivered: Delivered | null = deliveredSetup()): ModelSetup => setupOf(delivered);
@@ -1,8 +1,7 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
+ import { EFFORT_DEFAULTS, type RouteEffort } from '@metro-labs/core/effort';
2
3
  import { isRecord } from '@metro-labs/core/is-record';
3
4
 
4
- const MAIN_EFFORT = 'low';
5
- const SUBAGENT_EFFORT = 'max';
6
5
  export const BINDING_BETA = 'thinking-binding-controls-2026-08-01';
7
6
 
8
7
  const SUBAGENT_HEADER = 'x-claude-code-agent-id';
@@ -42,12 +41,13 @@ const messagesOf = (body: Body): unknown[] => (Array.isArray(body.messages) ? bo
42
41
 
43
42
  const askedEfforts = (body: Body): string[] => [body, ...messagesOf(body).map(record)].map(effortOf).filter((effort) => effort !== '');
44
43
 
45
- export function plannedEffort(req: IncomingMessage, body: Body): string | null {
44
+ export function plannedEffort(req: IncomingMessage, body: Body, setting: RouteEffort = {}): string | null {
46
45
  const asked = askedEfforts(body);
47
46
  if (asked.length === 0) return null;
48
47
  const kind = requestKind(req, body);
49
48
  if (kind === 'other') return null;
50
- const wanted = kind === 'subagent' ? SUBAGENT_EFFORT : MAIN_EFFORT;
49
+ const wanted = kind === 'subagent' ? (setting.helpers ?? EFFORT_DEFAULTS.helpers) : (setting.main ?? EFFORT_DEFAULTS.main);
50
+ if (wanted === 'asked') return null;
51
51
  return asked.every((effort) => effort === wanted) ? null : wanted;
52
52
  }
53
53
 
@@ -18,14 +18,16 @@ interface Cooling {
18
18
  reason: string;
19
19
  }
20
20
 
21
+ export type Lane = 'main' | 'helpers';
22
+
21
23
  const cooling = new Map<string, Cooling>();
22
- let lastChoice = '';
24
+ const lastChoice = new Map<Lane, string>();
23
25
 
24
26
  const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
25
27
 
26
28
  export function forgetFallbackState(): void {
27
29
  cooling.clear();
28
- lastChoice = '';
30
+ lastChoice.clear();
29
31
  }
30
32
 
31
33
  function scopeOf(label: string): string | null {
@@ -96,18 +98,18 @@ export function noteRefused(route: Route, status: number, headers: Headers, next
96
98
  log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, why);
97
99
  }
98
100
 
99
- export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
101
+ export function noteChoice(primary: Route, chosen: Route, lane: Lane, now = Date.now()): void {
100
102
  const key = keyOf(chosen);
101
- if (key === lastChoice) return;
102
- const first = lastChoice === '';
103
- lastChoice = key;
103
+ const last = lastChoice.get(lane);
104
+ if (key === last) return;
105
+ lastChoice.set(lane, key);
104
106
  if (key === keyOf(primary)) {
105
- if (!first) log.info({ model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
107
+ if (last !== undefined) log.info({ lane, model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
106
108
  return;
107
109
  }
108
110
  const hold = holdOf(primary, now);
109
111
  log.info(
110
- { from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
112
+ { lane, from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
111
113
  'gateway: the first model is over its limit, so requests go to a fallback model',
112
114
  );
113
115
  }
@@ -26,7 +26,7 @@ import {
26
26
  type Watch,
27
27
  type Refusal,
28
28
  } from './forward.js';
29
- import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
29
+ import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry, type Lane } from './fallback.js';
30
30
  import {
31
31
  BINDING_BETA,
32
32
  cappedEffort,
@@ -34,12 +34,13 @@ import {
34
34
  forgetLearnedThinking,
35
35
  learnAlwaysThinks,
36
36
  plannedEffort,
37
+ requestKind,
37
38
  thinkingOff,
38
39
  withBlockBinding,
39
40
  withEffort,
40
41
  withThinkingFor,
41
42
  } from './effort.js';
42
- import { notReady, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
43
+ import { helperSetup, notReady, pinnedRoute, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
43
44
  import { codexMessages, type CodexDeps } from './codex.js';
44
45
  import { countTokens } from './subscription.js';
45
46
  import { OPENROUTER_BASE } from './openrouter.js';
@@ -143,8 +144,8 @@ async function asAnthropicWants(send: Send, base: Record<string, string>, payloa
143
144
  const { metro, asSent, bound } = payloads;
144
145
  const upstream = await send(metro, bound ? addBeta(base, BINDING_BETA) : base);
145
146
  if (upstream.status !== 400 || asSent === null) return upstream;
146
- await upstream.body?.cancel();
147
- log.warn({ connection, model }, 'gateway: Anthropic refused the request metro shaped, so it was sent again as Claude Code wrote it');
147
+ const reason = upstreamMessage(await upstream.text().catch(() => ''), '').slice(0, 300);
148
+ log.warn({ connection, model, reason }, 'gateway: the provider refused the request metro shaped, so it was sent again as Claude Code wrote it');
148
149
  return send(asSent, base);
149
150
  }
150
151
 
@@ -241,11 +242,23 @@ async function toCodex(req: IncomingMessage, res: ServerResponse, path: string,
241
242
  else await codexMessages(req, res, body, route.model, route.connection, { refresh: refreshCodex, ...deps.codex }, watchUpstream(res));
242
243
  }
243
244
 
244
- function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
245
- const effort = plannedEffort(req, sent);
245
+ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup): Record<string, unknown> {
246
+ const effort = plannedEffort(req, sent, setup.effort);
246
247
  return effort === null ? sent : withEffort(sent, effort);
247
248
  }
248
249
 
250
+ interface Chosen {
251
+ lane: Lane;
252
+ setup: ModelSetup;
253
+ primary: Route;
254
+ }
255
+
256
+ function chosenChain(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup, requested: string): Chosen {
257
+ const helpers = requestKind(req, sent) === 'subagent' ? helperSetup(setup) : null;
258
+ if (helpers === null) return { lane: 'main', setup, primary: resolveRoute(requested, setup) ?? noRoute() };
259
+ return { lane: 'helpers', setup: helpers, primary: pinnedRoute(requested, helpers) ?? noRoute() };
260
+ }
261
+
249
262
  const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
250
263
 
251
264
  interface Request {
@@ -299,12 +312,12 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
299
312
  const parsed = parseJson(raw);
300
313
  const sent = fitToolSearch(req, parsed);
301
314
  const requested = requestedModel(sent);
302
- const primary = resolveRoute(requested, setup) ?? noRoute();
303
- const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
304
- const routes = routesToTry(chainOf(primary, setup, requested));
315
+ const { lane, setup: chain, primary } = chosenChain(req, sent, setup, requested);
316
+ const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent, setup), setup };
317
+ const routes = routesToTry(chainOf(primary, chain, requested));
305
318
  for (const [at, route] of routes.entries()) {
306
319
  const next = routes[at + 1];
307
- if (path === MESSAGES) noteChoice(primary, route);
320
+ if (path === MESSAGES) noteChoice(primary, route, lane);
308
321
  if (next === undefined) {
309
322
  await attempt(req, res, ask, route, deps);
310
323
  return;
@@ -1,9 +1,10 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { EFFORT_DEFAULTS } from '@metro-labs/core/effort';
2
3
  import { ApiError } from '@metro-labs/http/api-error';
3
4
  import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
4
5
  import { effectiveSetup } from './effective.js';
5
6
  import { chainStatus } from './fallback.js';
6
- import { notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
7
+ import { helperSetup, notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
7
8
  import { orgModels, type SyncStatus } from './org-models.js';
8
9
  import { lastServed } from './served.js';
9
10
  import { refreshUsage, type UsageDeps } from './usage-refresh.js';
@@ -41,6 +42,13 @@ function publicConnection(c: Connection): Record<string, unknown> {
41
42
 
42
43
  const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup()))();
43
44
 
45
+ function helpersBody(setup: ModelSetup): Record<string, unknown> | null {
46
+ const helpers = helperSetup(setup);
47
+ if (helpers === null) return null;
48
+ const primary = primaryRoute(helpers);
49
+ return { route: primary?.connection.id ?? '', model: primary?.model ?? '', fallbacks: helpers.fallbacks, chain: chainStatus(helpers) };
50
+ }
51
+
44
52
  export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
45
53
  const setup = setupOf(deps);
46
54
  const primary = primaryRoute(setup);
@@ -55,6 +63,8 @@ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
55
63
  connections: setup.connections.map(publicConnection),
56
64
  fallbacks: setup.fallbacks,
57
65
  chain: chainStatus(setup),
66
+ helpers: helpersBody(setup),
67
+ effort: { ...EFFORT_DEFAULTS, ...setup.effort },
58
68
  lastServed: lastServed(),
59
69
  usage: usageSeen(),
60
70
  };
@@ -1,3 +1,5 @@
1
+ import type { RouteEffort } from '@metro-labs/core/effort';
2
+
1
3
  export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'routeway'] as const;
2
4
  export type Provider = (typeof PROVIDERS)[number];
3
5
  export type CodexMethod = 'chatgpt' | 'code';
@@ -36,11 +38,18 @@ export interface Fallback {
36
38
  model: string;
37
39
  }
38
40
 
41
+ export interface Chain {
42
+ primary: Fallback;
43
+ fallbacks: Fallback[];
44
+ }
45
+
39
46
  export interface ModelSetup {
40
47
  primary: Fallback | null;
41
48
  fallbacks: Fallback[];
42
49
  connections: Connection[];
43
50
  source: RouteSource | null;
51
+ helpers?: Chain | null;
52
+ effort?: RouteEffort;
44
53
  }
45
54
 
46
55
  export interface Route {
@@ -119,6 +128,19 @@ export function resolveRoute(requested: string, setup: ModelSetup): Route | null
119
128
  return { connection: primary.connection, model: DEFAULTS[primary.connection.provider](bareModel(requested), primary.model) };
120
129
  }
121
130
 
131
+ export function pinnedRoute(requested: string, setup: ModelSetup): Route | null {
132
+ const primary = primaryRoute(setup);
133
+ if (primary === null) return null;
134
+ const bare = bareModel(requested);
135
+ const passes = primary.connection.provider === 'anthropic' && (primary.model === '' || isSmallModel(bare));
136
+ return passes ? { connection: primary.connection, model: bare } : primary;
137
+ }
138
+
139
+ export function helperSetup(setup: ModelSetup): ModelSetup | null {
140
+ const helpers = setup.helpers ?? null;
141
+ return helpers === null ? null : { ...setup, primary: helpers.primary, fallbacks: helpers.fallbacks, helpers: null };
142
+ }
143
+
122
144
  export const routeLabel = (route: Route): string =>
123
145
  route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
124
146
 
@@ -42,7 +42,7 @@ export async function refreshUsage(setup: ModelSetup, deps: UsageDeps = {}, now
42
42
  export function watchFallbackUsage(setup: () => ModelSetup, deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
43
43
  const run = (): void => {
44
44
  const current = setup();
45
- if (current.fallbacks.length === 0) return;
45
+ if (current.fallbacks.length === 0 && (current.helpers ?? null) === null) return;
46
46
  refreshUsage(current, deps).catch((err: unknown) => {
47
47
  log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
48
48
  });
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.314"
2
+ "version": "0.1.0-beta.316"
3
3
  }
@@ -19,6 +19,7 @@
19
19
  "./trains/protocol": "./src/protocol.ts",
20
20
  "./ids": "./src/ids.ts",
21
21
  "./claude-token": "./src/claude-token.ts",
22
+ "./effort": "./src/effort.ts",
22
23
  "./tickets": "./src/tickets.ts",
23
24
  "./station-names": "./src/station-names.ts",
24
25
  "./str": "./src/str.ts",
@@ -0,0 +1,21 @@
1
+ import { isRecord } from './is-record.js';
2
+
3
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max', 'asked'] as const;
4
+
5
+ export type EffortLevel = (typeof EFFORT_LEVELS)[number];
6
+
7
+ export interface RouteEffort {
8
+ main?: EffortLevel;
9
+ helpers?: EffortLevel;
10
+ }
11
+
12
+ export const EFFORT_DEFAULTS: Required<RouteEffort> = { main: 'low', helpers: 'max' };
13
+
14
+ export const isEffortLevel = (value: unknown): value is EffortLevel => typeof value === 'string' && (EFFORT_LEVELS as readonly string[]).includes(value);
15
+
16
+ export function readEffort(raw: unknown): RouteEffort {
17
+ if (!isRecord(raw)) return {};
18
+ return { ...(isEffortLevel(raw.main) ? { main: raw.main } : {}), ...(isEffortLevel(raw.helpers) ? { helpers: raw.helpers } : {}) };
19
+ }
20
+
21
+ export const effortSet = (effort: RouteEffort): boolean => effort.main !== undefined || effort.helpers !== undefined;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.314",
2
+ "version": "0.1.0-beta.316",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",