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

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.322",
3
+ "version": "0.1.0-beta.324",
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,7 +11,7 @@ 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 { notReady, 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';
@@ -133,7 +133,7 @@ const NO_MODEL = "the organization routes this agent to no model yet: choose one
133
133
  function credentialReady(deps: SessionDeps): string | null {
134
134
  try {
135
135
  const primary = primaryRoute((deps.model ?? (() => effectiveSetup()))());
136
- return primary === null ? NO_MODEL : notReady(primary);
136
+ return primary === null ? NO_MODEL : setupNotReady(primary);
137
137
  } catch (err) {
138
138
  return `the model setup is not readable (${errMsg(err)})`;
139
139
  }
@@ -4,17 +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, notReady, primaryRoute, type Connection, type ModelSetup } from '@metro-labs/inference/model-config';
7
+ import { helperSetup, primaryRoute, setupNotReady, type Connection, type ModelSetup } from '@metro-labs/inference/model-config';
8
8
  import { orgModels, type SyncStatus } from './org-models.js';
9
- import { lastServed } from '@metro-labs/inference/served';
10
- import { refreshUsage, type UsageDeps } from '@metro-labs/inference/usage-refresh';
11
- import { usageSeen } from '@metro-labs/inference/usage';
12
9
 
13
10
  const PATH = '/api/model';
14
11
  const SNAPSHOT = '/api/model/snapshot';
15
12
  const SYNC = '/api/model/sync';
16
13
 
17
- export interface ModelApiDeps extends UsageDeps {
14
+ export interface ModelApiDeps {
18
15
  setup?: () => ModelSetup;
19
16
  sync?: () => Promise<void>;
20
17
  syncStatus?: () => SyncStatus;
@@ -29,8 +26,8 @@ function publicConnection(c: Connection): Record<string, unknown> {
29
26
  provider: c.provider,
30
27
  label: c.label,
31
28
  model: c.model,
32
- hasKey: c.apiKey !== '',
33
- subscription: c.claudeToken !== '',
29
+ hasKey: c.apiKey !== '' || c.auth === 'key',
30
+ subscription: c.claudeToken !== '' || c.auth === 'subscription',
34
31
  region: c.region,
35
32
  zdr: c.zdr,
36
33
  signedOut: c.signedOut,
@@ -52,7 +49,7 @@ function helpersBody(setup: ModelSetup): Record<string, unknown> | null {
52
49
  export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
53
50
  const setup = setupOf(deps);
54
51
  const primary = primaryRoute(setup);
55
- const reason = primary === null ? NO_MODEL : notReady(primary);
52
+ const reason = primary === null ? NO_MODEL : setupNotReady(primary);
56
53
  return {
57
54
  source: setup.source,
58
55
  sync: (deps.syncStatus ?? (() => orgModels()?.syncStatus() ?? NO_SYNC))(),
@@ -65,16 +62,9 @@ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
65
62
  chain: chainStatus(setup),
66
63
  helpers: helpersBody(setup),
67
64
  effort: { ...EFFORT_DEFAULTS, ...setup.effort },
68
- lastServed: lastServed(),
69
- usage: usageSeen(),
70
65
  };
71
66
  }
72
67
 
73
- async function withUsage(deps: ModelApiDeps): Promise<Record<string, unknown>> {
74
- await refreshUsage(setupOf(deps), deps);
75
- return settingsBody(deps);
76
- }
77
-
78
68
  async function synced(deps: ModelApiDeps): Promise<Record<string, unknown>> {
79
69
  await (deps.sync ?? (() => orgModels()?.requested() ?? Promise.resolve()))();
80
70
  return settingsBody(deps);
@@ -83,7 +73,7 @@ async function synced(deps: ModelApiDeps): Promise<Record<string, unknown>> {
83
73
  type Run = () => Promise<unknown>;
84
74
 
85
75
  const ROUTES: Record<string, [string, (deps: ModelApiDeps) => Promise<unknown>]> = {
86
- [PATH]: ['GET', withUsage],
76
+ [PATH]: ['GET', (deps) => Promise.resolve(settingsBody(deps))],
87
77
  [SNAPSHOT]: ['GET', (deps) => Promise.resolve(settingsBody(deps))],
88
78
  [SYNC]: ['POST', synced],
89
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:*",
@@ -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 { emptySetup, isProvider, type ModelSetup, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
3
+ import { emptySetup, isProvider, type AuthKind, 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;
@@ -69,6 +69,10 @@ function credentialOf(provider: Provider, raw: unknown): Credential {
69
69
  return { apiKey: text(raw.apiKey), claudeToken: provider === 'anthropic' ? text(raw.claudeToken) : '', codex: null };
70
70
  }
71
71
 
72
+ const AUTH_KINDS: readonly AuthKind[] = ['key', 'subscription', 'codex'];
73
+
74
+ const authOf = (raw: unknown): AuthKind | null => AUTH_KINDS.find((kind) => kind === raw) ?? null;
75
+
72
76
  export function deliveredConnection(raw: unknown): Connection | null {
73
77
  if (!isRecord(raw) || !isProvider(raw.provider)) return null;
74
78
  const id = text(raw.id);
@@ -86,7 +90,8 @@ export function deliveredConnection(raw: unknown): Connection | null {
86
90
  codex,
87
91
  claudeToken,
88
92
  account: accountOf(raw.account),
89
- signedOut: codex === null && apiKey === '' && claudeToken === '',
93
+ signedOut: typeof raw.signedOut === 'boolean' ? raw.signedOut : codex === null && apiKey === '' && claudeToken === '',
94
+ auth: authOf(raw.auth),
90
95
  };
91
96
  }
92
97
 
@@ -31,8 +31,11 @@ export interface Connection {
31
31
  claudeToken: string;
32
32
  account: Account;
33
33
  signedOut: boolean;
34
+ auth?: AuthKind | null;
34
35
  }
35
36
 
37
+ export type AuthKind = 'key' | 'subscription' | 'codex';
38
+
36
39
  export interface Fallback {
37
40
  connection: string;
38
41
  model: string;
@@ -77,28 +80,36 @@ export function primaryRoute(setup: ModelSetup): Route | null {
77
80
  return null;
78
81
  }
79
82
 
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 Gateway page."]],
83
+ type Check = [(r: Route) => boolean, string, 'credential' | 'setup'];
84
+
85
+ const CHECKS: Record<Provider, Check[]> = {
86
+ anthropic: [[(r) => r.connection.apiKey === '' && r.connection.claudeToken === '', "Anthropic has no API key or Claude subscription: add one on the organization's Gateway page.", 'credential']],
82
87
  bedrock: [
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."],
88
+ [(r) => r.connection.apiKey === '', "Bedrock has no API key: add it on the organization's Gateway page.", 'credential'],
89
+ [(r) => r.connection.region === '', "Bedrock needs a region: add it on the organization's Gateway page.", 'setup'],
85
90
  ],
86
91
  openrouter: [
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."],
92
+ [(r) => r.connection.apiKey === '', "OpenRouter has no API key: add it on the organization's Gateway page.", 'credential'],
93
+ [(r) => r.model === '', "OpenRouter needs a model id: choose one on the organization's Gateway page.", 'setup'],
89
94
  ],
90
95
  codex: [
91
- [(r) => r.connection.codex === null, CODEX_SIGN_IN],
92
- [(r) => r.model === '', "Codex needs a model id: choose one on the organization's Gateway page."],
96
+ [(r) => r.connection.codex === null, CODEX_SIGN_IN, 'credential'],
97
+ [(r) => r.model === '', "Codex needs a model id: choose one on the organization's Gateway page.", 'setup'],
93
98
  ],
94
99
  routeway: [
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."],
100
+ [(r) => r.connection.apiKey === '', "Routeway has no API key: add it on the organization's Gateway page.", 'credential'],
101
+ [(r) => r.model === '', "Routeway needs a model id: choose one on the organization's Gateway page.", 'setup'],
97
102
  ],
98
103
  };
99
104
 
100
105
  export const notReady = (route: Route): string | null => CHECKS[route.connection.provider].find(([missing]) => missing(route))?.[1] ?? null;
101
106
 
107
+ export function setupNotReady(route: Route): string | null {
108
+ const checks = CHECKS[route.connection.provider];
109
+ if (route.connection.signedOut) return checks.find(([, , kind]) => kind === 'credential')?.[1] ?? null;
110
+ return checks.find(([missing, , kind]) => kind === 'setup' && missing(route))?.[1] ?? null;
111
+ }
112
+
102
113
  export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
103
114
 
104
115
  const DEFAULTS: Record<Provider, (requested: string, model: string) => string> = {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.322"
2
+ "version": "0.1.0-beta.324"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.322",
2
+ "version": "0.1.0-beta.324",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,32 +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 { agentsDir } from '../agents/files.js';
5
- import { agentIdForKey } from '../agents/keys.js';
6
- import { reregisterMcpToken } from '../mcp-server/link.js';
7
- import { readMcpToken } from '../mcp-server/token.js';
8
- import { effectiveSetup } from './effective.js';
9
- import { orgModels } from './org-models.js';
10
- import { gatewayUrl, handleGatewayRelay } from './relay.js';
11
-
12
- type Handler = (req: IncomingMessage, res: ServerResponse) => boolean;
13
-
14
- const identify = (key: string): boolean => agentIdForKey(key) !== undefined;
15
-
16
- export const boxGateway = (): GatewayDeps => ({
17
- setup: () => effectiveSetup(),
18
- identify,
19
- codex: { refresh: (conn) => orgModels()?.refresh(conn) ?? Promise.resolve(null) },
20
- });
21
-
22
- export function boxGatewayHandler(): Handler {
23
- const url = gatewayUrl();
24
- if (url === null) {
25
- const deps = boxGateway();
26
- log.info('gateway: this box routes its model requests itself (METRO_GATEWAY_URL is empty)');
27
- return (req, res) => handleGatewayRequest(req, res, deps);
28
- }
29
- log.info({ url }, 'gateway: model requests go through gateway.metro.box');
30
- const deps = { url, identify, token: () => readMcpToken(agentsDir()), reregister: reregisterMcpToken };
31
- return (req, res) => handleGatewayRelay(req, res, deps);
32
- }
@@ -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
- }