@stage-labs/metro 0.1.0-beta.323 → 0.1.0-beta.325

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.323",
3
+ "version": "0.1.0-beta.325",
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": {
@@ -58,9 +58,8 @@ import type { StationName } from '@metro-labs/core/station-names';
58
58
  import { startUploadReaper } from '../files/upload-store.js';
59
59
  import { startAttachReaper } from '../files/attach-reaper.js';
60
60
  import { startCloudWatchPublisher } from '../server/cloudwatch.js';
61
- import { watchFallbackUsage } from '@metro-labs/inference/usage-refresh';
62
61
  import { startOrgModels, stopOrgModels } from '../gateway/org-models.js';
63
- import { effectiveSetup, watchModelChanges } from '../gateway/effective.js';
62
+ import { watchModelChanges } from '../gateway/effective.js';
64
63
  import { removeModelLeftovers } from '../gateway/model-leftovers.js';
65
64
  import { readAgentKey } from '../connectors/agent-key.js';
66
65
  import { METRO_API } from '../connectors/metro-api.js';
@@ -180,7 +179,6 @@ installBearerSessions(agentsDir(), localOwner);
180
179
  });
181
180
  watchConnectorTools();
182
181
  startCloudWatchPublisher();
183
- watchFallbackUsage(() => effectiveSetup());
184
182
  announceLocalEndpoint();
185
183
  tunnel?.start();
186
184
  log.info(
@@ -11,12 +11,11 @@ import { isRecord } from '@metro-labs/core/is-record';
11
11
  import { readJson, writeJson } from '@metro-labs/core/secure-fs';
12
12
  import { METRO_VERSION } from '@metro-labs/core/version';
13
13
  import { agentsDir, listAgentFiles } from '../agents/files.js';
14
- import { primaryRoute, type ModelSetup } from '@metro-labs/inference/model-config';
14
+ import { primaryRoute, setupNotReady, type ModelSetup } from '@metro-labs/inference/model-config';
15
15
  import { effectiveSetup } from '../gateway/effective.js';
16
16
  import { inSessionScope } from './memory.js';
17
17
  import { runningAsRoot } from '../metro-user/privilege.js';
18
18
  import { agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
19
- import { routeReadiness } from '../gateway/box-gateway.js';
20
19
 
21
20
  function sessionEnv(): Record<string, string> {
22
21
  const user = agentUser();
@@ -134,7 +133,7 @@ const NO_MODEL = "the organization routes this agent to no model yet: choose one
134
133
  function credentialReady(deps: SessionDeps): string | null {
135
134
  try {
136
135
  const primary = primaryRoute((deps.model ?? (() => effectiveSetup()))());
137
- return primary === null ? NO_MODEL : routeReadiness(primary);
136
+ return primary === null ? NO_MODEL : setupNotReady(primary);
138
137
  } catch (err) {
139
138
  return `the model setup is not readable (${errMsg(err)})`;
140
139
  }
@@ -4,18 +4,14 @@ import { ApiError } from '@metro-labs/http/api-error';
4
4
  import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
5
5
  import { effectiveSetup } from './effective.js';
6
6
  import { chainStatus } from '@metro-labs/inference/fallback';
7
- import { helperSetup, primaryRoute, type Connection, type ModelSetup } from '@metro-labs/inference/model-config';
8
- import { routeReadiness } from './box-gateway.js';
7
+ import { helperSetup, primaryRoute, setupNotReady, type Connection, type ModelSetup } from '@metro-labs/inference/model-config';
9
8
  import { orgModels, type SyncStatus } from './org-models.js';
10
- import { lastServed } from '@metro-labs/inference/served';
11
- import { refreshUsage, type UsageDeps } from '@metro-labs/inference/usage-refresh';
12
- import { usageSeen } from '@metro-labs/inference/usage';
13
9
 
14
10
  const PATH = '/api/model';
15
11
  const SNAPSHOT = '/api/model/snapshot';
16
12
  const SYNC = '/api/model/sync';
17
13
 
18
- export interface ModelApiDeps extends UsageDeps {
14
+ export interface ModelApiDeps {
19
15
  setup?: () => ModelSetup;
20
16
  sync?: () => Promise<void>;
21
17
  syncStatus?: () => SyncStatus;
@@ -47,13 +43,13 @@ function helpersBody(setup: ModelSetup): Record<string, unknown> | null {
47
43
  const helpers = helperSetup(setup);
48
44
  if (helpers === null) return null;
49
45
  const primary = primaryRoute(helpers);
50
- return { route: primary?.connection.id ?? '', model: primary?.model ?? '', fallbacks: helpers.fallbacks, chain: chainStatus(helpers) };
46
+ return { route: primary?.connection.id ?? '', model: primary?.model ?? '', fallbacks: helpers.fallbacks, chain: chainStatus(helpers, Date.now(), setupNotReady) };
51
47
  }
52
48
 
53
49
  export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
54
50
  const setup = setupOf(deps);
55
51
  const primary = primaryRoute(setup);
56
- const reason = primary === null ? NO_MODEL : routeReadiness(primary);
52
+ const reason = primary === null ? NO_MODEL : setupNotReady(primary);
57
53
  return {
58
54
  source: setup.source,
59
55
  sync: (deps.syncStatus ?? (() => orgModels()?.syncStatus() ?? NO_SYNC))(),
@@ -63,19 +59,12 @@ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
63
59
  reason,
64
60
  connections: setup.connections.map(publicConnection),
65
61
  fallbacks: setup.fallbacks,
66
- chain: chainStatus(setup),
62
+ chain: chainStatus(setup, Date.now(), setupNotReady),
67
63
  helpers: helpersBody(setup),
68
64
  effort: { ...EFFORT_DEFAULTS, ...setup.effort },
69
- lastServed: lastServed(),
70
- usage: usageSeen(),
71
65
  };
72
66
  }
73
67
 
74
- async function withUsage(deps: ModelApiDeps): Promise<Record<string, unknown>> {
75
- await refreshUsage(setupOf(deps), deps);
76
- return settingsBody(deps);
77
- }
78
-
79
68
  async function synced(deps: ModelApiDeps): Promise<Record<string, unknown>> {
80
69
  await (deps.sync ?? (() => orgModels()?.requested() ?? Promise.resolve()))();
81
70
  return settingsBody(deps);
@@ -84,7 +73,7 @@ async function synced(deps: ModelApiDeps): Promise<Record<string, unknown>> {
84
73
  type Run = () => Promise<unknown>;
85
74
 
86
75
  const ROUTES: Record<string, [string, (deps: ModelApiDeps) => Promise<unknown>]> = {
87
- [PATH]: ['GET', withUsage],
76
+ [PATH]: ['GET', (deps) => Promise.resolve(settingsBody(deps))],
88
77
  [SNAPSHOT]: ['GET', (deps) => Promise.resolve(settingsBody(deps))],
89
78
  [SYNC]: ['POST', synced],
90
79
  };
@@ -1,11 +1,9 @@
1
1
  import { errMsg, log } from '@metro-labs/core/log';
2
2
  import type { AgentKey } from '../connectors/agent-key.js';
3
3
  import { agentCall, MetroApiRefusal, type MetroApi } from '../connectors/metro-api.js';
4
- import { deliveredOf, refreshedConnection, type Delivered } from '@metro-labs/inference/delivered';
5
- import type { Connection } from '@metro-labs/inference/model-config';
4
+ import { deliveredOf, type Delivered } from '@metro-labs/inference/delivered';
6
5
 
7
6
  const MODELS_PATH = '/api/agents/models';
8
- const REFRESH_PATH = '/api/agents/models/refresh';
9
7
  const SYNC_MS = 60_000;
10
8
  const PAGE_SYNC_MS = 5_000;
11
9
  const AGENT_KEY_MISSING = 'agent_key_missing';
@@ -32,7 +30,6 @@ export class OrgModels {
32
30
  private timer: ReturnType<typeof setInterval> | null = null;
33
31
  private trailing: ReturnType<typeof setTimeout> | null = null;
34
32
  private pageAt = Number.NEGATIVE_INFINITY;
35
- private readonly refreshing = new Map<string, Promise<Connection | null>>();
36
33
  private readonly listeners = new Set<() => void>();
37
34
 
38
35
  constructor(private readonly deps: OrgModelsDeps) {}
@@ -89,19 +86,6 @@ export class OrgModels {
89
86
  this.trailing = null;
90
87
  }
91
88
 
92
- refresh(conn: Connection): Promise<Connection | null> {
93
- const newer = this.kept?.connections.find((c) => c.id === conn.id && c.revision > conn.revision);
94
- if (newer !== undefined) return Promise.resolve(newer);
95
- const slot = `${conn.id}\n${String(conn.revision)}`;
96
- const found = this.refreshing.get(slot);
97
- if (found !== undefined) return found;
98
- const run = this.askRefresh(conn).finally(() => {
99
- this.refreshing.delete(slot);
100
- });
101
- this.refreshing.set(slot, run);
102
- return run;
103
- }
104
-
105
89
  private later(wait: number): ReturnType<typeof setTimeout> {
106
90
  const timer = setTimeout(() => {
107
91
  this.trailing = null;
@@ -166,22 +150,6 @@ export class OrgModels {
166
150
  this.failed(errMsg(err), identityRefused(err));
167
151
  }
168
152
  }
169
-
170
- private async askRefresh(conn: Connection): Promise<Connection | null> {
171
- const key = this.enrolledKey();
172
- if (key === null) return null;
173
- try {
174
- const fresh = refreshedConnection(await agentCall(this.deps.api, key, REFRESH_PATH, { connection: conn.id, revision: conn.revision }));
175
- if (fresh?.id !== conn.id) throw new Error('api.metro.box answered the refresh with another connection');
176
- const kept = this.kept;
177
- if (kept !== null) this.keep({ ...kept, connections: kept.connections.map((c) => (c.id === fresh.id ? fresh : c)) });
178
- log.info({ connection: conn.id, revision: fresh.revision }, 'org-models: api.metro.box renewed the sign-in of a connection');
179
- return fresh;
180
- } catch (err) {
181
- log.warn({ connection: conn.id, err: errMsg(err) }, 'org-models: api.metro.box could not renew the sign-in of a connection');
182
- return null;
183
- }
184
- }
185
153
  }
186
154
 
187
155
  let shared: OrgModels | null = null;
@@ -2,7 +2,11 @@ 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 { forwardedHeaders, pipeResponse, readBody, sendError, watchUpstream } from '@metro-labs/inference/forward';
5
+ import { agentsDir } from '../agents/files.js';
6
+ import { agentIdForKey } from '../agents/keys.js';
5
7
  import { usableServerUrl } from '../mcp-server/config.js';
8
+ import { reregisterMcpToken } from '../mcp-server/link.js';
9
+ import { readMcpToken } from '../mcp-server/token.js';
6
10
 
7
11
  const PREFIX = '/gateway';
8
12
  const HELLO = '/api/hello';
@@ -11,10 +15,11 @@ const DEFAULT_URL = 'https://gateway.metro.box';
11
15
  const DROPPED = ['authorization', 'x-api-key', 'cookie'];
12
16
  const NOT_LINKED = 'metro gateway: this agent is not linked to its organization yet, so it cannot reach its models. It links once an admin of the organization opens its page on metro.box.';
13
17
  const NOT_ACCEPTED = 'metro gateway: gateway.metro.box did not accept this agent yet. It registers again; Claude Code retries.';
18
+ const NO_GATEWAY = 'metro gateway: this Metro has no model gateway, since METRO_GATEWAY_URL is empty or not an https url.';
14
19
  const UNREACHABLE = 'metro gateway: gateway.metro.box could not be reached. Claude Code retries.';
15
20
 
16
21
  export interface RelayDeps {
17
- url: string;
22
+ url: string | null;
18
23
  identify: (key: string) => boolean;
19
24
  token: () => string | null;
20
25
  reregister: () => void;
@@ -29,7 +34,7 @@ export function gatewayUrl(env: NodeJS.ProcessEnv = process.env): string | null
29
34
  if (usableServerUrl(raw)) return raw.replace(/\/+$/, '');
30
35
  if (refused !== raw) {
31
36
  refused = raw;
32
- log.warn({ url: raw }, 'gateway: METRO_GATEWAY_URL must be an https url (http only on this machine), so the box routes its models itself');
37
+ log.warn({ url: raw }, 'gateway: METRO_GATEWAY_URL must be an https url (http only on this machine), so this box has no model gateway');
33
38
  }
34
39
  return null;
35
40
  }
@@ -45,20 +50,26 @@ function upstreamHeaders(req: IncomingMessage, token: string): Record<string, st
45
50
  return { ...headers, authorization: `Bearer ${token}` };
46
51
  }
47
52
 
48
- async function relay(req: IncomingMessage, res: ServerResponse, path: string, deps: RelayDeps): Promise<void> {
53
+ function targetOf(deps: RelayDeps): { url: string; token: string } | string {
54
+ if (deps.url === null) return NO_GATEWAY;
49
55
  const token = deps.token();
50
- if (token === null) {
56
+ return token === null ? NOT_LINKED : { url: deps.url, token };
57
+ }
58
+
59
+ async function relay(req: IncomingMessage, res: ServerResponse, path: string, deps: RelayDeps): Promise<void> {
60
+ const target = targetOf(deps);
61
+ if (typeof target === 'string') {
51
62
  req.resume();
52
- sendError(res, 503, 'api_error', NOT_LINKED);
63
+ sendError(res, 503, 'api_error', target);
53
64
  return;
54
65
  }
55
66
  const body = req.method === 'POST' ? await readBody(req) : undefined;
56
67
  const watch = watchUpstream(res);
57
68
  let upstream: Response;
58
69
  try {
59
- upstream = await (deps.fetch ?? fetch)(`${deps.url}${path}`, {
70
+ upstream = await (deps.fetch ?? fetch)(`${target.url}${path}`, {
60
71
  method: req.method ?? 'GET',
61
- headers: upstreamHeaders(req, token),
72
+ headers: upstreamHeaders(req, target.token),
62
73
  ...(body === undefined ? {} : { body: new Uint8Array(body) }),
63
74
  signal: watch.signal,
64
75
  redirect: 'manual',
@@ -106,3 +117,11 @@ export function handleGatewayRelay(req: IncomingMessage, res: ServerResponse, de
106
117
  });
107
118
  return true;
108
119
  }
120
+
121
+ export function boxGatewayHandler(): (req: IncomingMessage, res: ServerResponse) => boolean {
122
+ const url = gatewayUrl();
123
+ if (url === null) log.warn('gateway: METRO_GATEWAY_URL is empty, so model requests are refused');
124
+ else log.info({ url }, 'gateway: model requests go through gateway.metro.box');
125
+ const deps: RelayDeps = { url, identify: (key) => agentIdForKey(key) !== undefined, token: () => readMcpToken(agentsDir()), reregister: reregisterMcpToken };
126
+ return (req, res) => handleGatewayRelay(req, res, deps);
127
+ }
@@ -24,7 +24,7 @@ import {
24
24
  localOwner,
25
25
  readLocalAgentFile,
26
26
  } from '../agents/file-admin.js';
27
- import { boxGatewayHandler } from '../gateway/box-gateway.js';
27
+ import { boxGatewayHandler } from '../gateway/relay.js';
28
28
  import type { StationName } from '@metro-labs/core/station-names';
29
29
 
30
30
  export interface LocalModeDeps {
@@ -2,7 +2,7 @@
2
2
  "name": "@metro-labs/inference",
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
- "description": "The model router: Anthropic, Routeway, OpenRouter, Bedrock and Codex, backups with holds, effort per thread, tool search fitting and usage from headers and stream frames. Run by the daemon's /gateway today, by gateway.metro.box next (docs/GATEWAY-SERVER.md). Imports only core and http, never a station.",
5
+ "description": "The model router: Anthropic, Routeway, OpenRouter, Bedrock and Codex, backups with holds, effort per thread, tool search fitting and usage from headers and stream frames. Run by gateway.metro.box (apps/gateway, docs/GATEWAY-SERVER.md). Imports only core and http, never a station.",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./attempts": "./src/attempts.ts",
@@ -21,12 +21,11 @@
21
21
  "./model-config": "./src/model-config.ts",
22
22
  "./served": "./src/served.ts",
23
23
  "./tool-search": "./src/tool-search.ts",
24
- "./usage": "./src/usage.ts",
25
- "./usage-refresh": "./src/usage-refresh.ts"
24
+ "./usage": "./src/usage.ts"
26
25
  },
27
26
  "scripts": {
28
27
  "build": "tsc",
29
- "test": "tsc --noEmit"
28
+ "test": "tsc --noEmit && bun test test/"
30
29
  },
31
30
  "dependencies": {
32
31
  "@metro-labs/core": "workspace:*",
@@ -131,20 +131,22 @@ function primaryOf(setup: ModelSetup): Route | null {
131
131
  return { connection: primary.connection, model };
132
132
  }
133
133
 
134
- function rowOf(route: Route, now: number): Omit<ChainRow, 'active'> {
135
- const reason = notReady(route);
134
+ type Readiness = (route: Route) => string | null;
135
+
136
+ function rowOf(route: Route, now: number, ready: Readiness): Omit<ChainRow, 'active'> {
137
+ const reason = ready(route);
136
138
  const used = windowsFor(route, now).reduce<number | null>((most, w) => Math.max(most ?? 0, w.used ?? 0), null);
137
139
  return { connection: route.connection.id, model: route.model, used, hold: reason === null ? holdOf(route, now) : { reason, until: null } };
138
140
  }
139
141
 
140
- export function chainStatus(setup: ModelSetup, now = Date.now()): ChainRow[] {
142
+ export function chainStatus(setup: ModelSetup, now = Date.now(), ready: Readiness = notReady): ChainRow[] {
141
143
  const primary = primaryOf(setup);
142
144
  if (primary === null) return [];
143
145
  const listed = setup.fallbacks.flatMap((f) => {
144
146
  const conn = connectionOf(setup, f.connection);
145
147
  return conn === null ? [] : [{ connection: conn, model: f.model }];
146
148
  });
147
- const rows = unique([primary, ...listed]).map((route) => rowOf(route, now));
149
+ const rows = unique([primary, ...listed]).map((route) => rowOf(route, now, ready));
148
150
  const active = Math.max(0, rows.findIndex((row) => row.hold === null));
149
151
  return rows.map((row, at) => ({ ...row, active: at === active }));
150
152
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.323"
2
+ "version": "0.1.0-beta.325"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.323",
2
+ "version": "0.1.0-beta.325",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,35 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { log } from '@metro-labs/core/log';
3
- import { handleGatewayRequest, type GatewayDeps } from '@metro-labs/inference/gateway';
4
- import { notReady, setupNotReady, type Route } from '@metro-labs/inference/model-config';
5
- import { agentsDir } from '../agents/files.js';
6
- import { agentIdForKey } from '../agents/keys.js';
7
- import { reregisterMcpToken } from '../mcp-server/link.js';
8
- import { readMcpToken } from '../mcp-server/token.js';
9
- import { effectiveSetup } from './effective.js';
10
- import { orgModels } from './org-models.js';
11
- import { gatewayUrl, handleGatewayRelay } from './relay.js';
12
-
13
- type Handler = (req: IncomingMessage, res: ServerResponse) => boolean;
14
-
15
- const identify = (key: string): boolean => agentIdForKey(key) !== undefined;
16
-
17
- export const routeReadiness = (route: Route): string | null => (gatewayUrl() === null ? notReady(route) : setupNotReady(route));
18
-
19
- export const boxGateway = (): GatewayDeps => ({
20
- setup: () => effectiveSetup(),
21
- identify,
22
- codex: { refresh: (conn) => orgModels()?.refresh(conn) ?? Promise.resolve(null) },
23
- });
24
-
25
- export function boxGatewayHandler(): Handler {
26
- const url = gatewayUrl();
27
- if (url === null) {
28
- const deps = boxGateway();
29
- log.info('gateway: this box routes its model requests itself (METRO_GATEWAY_URL is empty)');
30
- return (req, res) => handleGatewayRequest(req, res, deps);
31
- }
32
- log.info({ url }, 'gateway: model requests go through gateway.metro.box');
33
- const deps = { url, identify, token: () => readMcpToken(agentsDir()), reregister: reregisterMcpToken };
34
- return (req, res) => handleGatewayRelay(req, res, deps);
35
- }
@@ -1,51 +0,0 @@
1
- import { errMsg, log } from '@metro-labs/core/log';
2
- import { openrouterKey } from './openrouter.js';
3
- import { mayProbe, noteUsage, openrouterUsage, usageOf } from './usage.js';
4
- import type { Connection, ModelSetup } from './model-config.js';
5
-
6
- export const CREDITS_TTL_MS = 5 * 60_000;
7
- const WAIT_MS = 4_000;
8
-
9
- export interface UsageDeps {
10
- fetchImpl?: typeof fetch;
11
- openrouterBase?: string;
12
- usageWaitMs?: number;
13
- }
14
-
15
- const fresh = (id: string, now: number): boolean => {
16
- const seen = usageOf(id);
17
- return seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS;
18
- };
19
-
20
- async function refreshKey(conn: Connection, deps: UsageDeps, now: number): Promise<void> {
21
- if (conn.provider !== 'openrouter' || conn.apiKey === '' || fresh(conn.id, now) || !mayProbe(conn.id, now, CREDITS_TTL_MS)) return;
22
- try {
23
- noteUsage(conn.id, openrouterUsage(await openrouterKey(conn.apiKey, deps.openrouterBase, deps.fetchImpl), new Date(now)));
24
- } catch (err) {
25
- log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter key spend');
26
- }
27
- }
28
-
29
- async function within(ms: number, work: Promise<unknown>): Promise<void> {
30
- let timer: ReturnType<typeof setTimeout> | undefined;
31
- const waited = new Promise<void>((resolve) => {
32
- timer = setTimeout(resolve, ms);
33
- });
34
- await Promise.race([work, waited]);
35
- clearTimeout(timer);
36
- }
37
-
38
- export async function refreshUsage(setup: ModelSetup, deps: UsageDeps = {}, now = Date.now()): Promise<void> {
39
- await within(deps.usageWaitMs ?? WAIT_MS, Promise.all(setup.connections.map((conn) => refreshKey(conn, deps, now))));
40
- }
41
-
42
- export function watchFallbackUsage(setup: () => ModelSetup, deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
43
- const run = (): void => {
44
- const current = setup();
45
- if (current.fallbacks.length === 0 && (current.helpers ?? null) === null) return;
46
- refreshUsage(current, deps).catch((err: unknown) => {
47
- log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
48
- });
49
- };
50
- setInterval(run, every).unref();
51
- }