@stage-labs/metro 0.1.0-beta.310 → 0.1.0-beta.312

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/claude-env.js +1 -1
  2. package/dist/route.js +1 -1
  3. package/package.json +1 -1
  4. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  5. package/runtime/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -3
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -36
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +4 -13
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -9
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +4 -15
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +5 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +6 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +66 -67
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +18 -32
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +15 -46
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-leftovers.ts +24 -3
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/routeway.ts +19 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/subscription.ts +1 -61
  20. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +11 -55
  21. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +1 -66
  22. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -1
  23. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +7 -1
  24. package/runtime/runtime.json +1 -1
  25. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  26. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
  27. package/runtime/sdk-runner/runner.json +1 -1
  28. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +0 -243
  29. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +0 -38
  30. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +0 -128
  31. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +0 -192
  32. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +0 -59
  33. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -40
  34. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +0 -77
@@ -1,14 +1,10 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { ApiError } from '@metro-labs/http/api-error';
3
- import { apiFailure, apiSession, cors, requireAdmin, sendJson, type ApiSession } from '@metro-labs/http/api-http';
4
- import { agentsDir } from '../agents/files.js';
5
- import { removeClaudeLogin } from './claude-logins.js';
6
- import { readClaudeLogins, type ClaudeLogin } from './claude-store.js';
3
+ import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
7
4
  import { effectiveSetup } from './effective.js';
8
5
  import { chainStatus } from './fallback.js';
9
6
  import { notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
10
7
  import { orgModels, type SyncStatus } from './org-models.js';
11
- import { claudeModels } from './provider-models.js';
12
8
  import { lastServed } from './served.js';
13
9
  import { refreshUsage, type UsageDeps } from './usage-refresh.js';
14
10
  import { usageSeen } from './usage.js';
@@ -16,19 +12,15 @@ import { usageSeen } from './usage.js';
16
12
  const PATH = '/api/model';
17
13
  const SNAPSHOT = '/api/model/snapshot';
18
14
  const SYNC = '/api/model/sync';
19
- const CLAUDE_MODELS = '/api/model/claude-models';
20
- const CLAUDE_LOGIN = '/api/model/claude/';
21
- const LOGIN_RE = /^[A-Za-z0-9_-]{11}$/;
22
15
 
23
16
  export interface ModelApiDeps extends UsageDeps {
24
- dir?: () => string;
25
17
  setup?: () => ModelSetup;
26
18
  sync?: () => Promise<void>;
27
19
  syncStatus?: () => SyncStatus;
28
- anthropicBase?: string;
29
20
  }
30
21
 
31
22
  const NO_SYNC: SyncStatus = { enrolled: false, at: null, error: null };
23
+ const NO_MODEL = "No model is set up for this agent yet: choose one on the organization's Models page.";
32
24
 
33
25
  function publicConnection(c: Connection): Record<string, unknown> {
34
26
  return {
@@ -37,9 +29,9 @@ function publicConnection(c: Connection): Record<string, unknown> {
37
29
  label: c.label,
38
30
  model: c.model,
39
31
  hasKey: c.apiKey !== '',
32
+ subscription: c.claudeToken !== '',
40
33
  region: c.region,
41
34
  zdr: c.zdr,
42
- signedIn: c.codex !== null || (c.claude !== null && !c.signedOut),
43
35
  signedOut: c.signedOut,
44
36
  account: c.account.email,
45
37
  plan: c.account.plan,
@@ -47,13 +39,12 @@ function publicConnection(c: Connection): Record<string, unknown> {
47
39
  };
48
40
  }
49
41
 
50
- const loginsOf = (deps: ModelApiDeps): ClaudeLogin[] => readClaudeLogins(deps.dir?.() ?? agentsDir());
51
- const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup(deps.dir?.())))();
42
+ const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup()))();
52
43
 
53
44
  export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
54
45
  const setup = setupOf(deps);
55
46
  const primary = primaryRoute(setup);
56
- const reason = primary === null ? null : notReady(primary);
47
+ const reason = primary === null ? NO_MODEL : notReady(primary);
57
48
  return {
58
49
  source: setup.source,
59
50
  sync: (deps.syncStatus ?? (() => orgModels()?.syncStatus() ?? NO_SYNC))(),
@@ -64,14 +55,13 @@ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
64
55
  connections: setup.connections.map(publicConnection),
65
56
  fallbacks: setup.fallbacks,
66
57
  chain: chainStatus(setup),
67
- claude: loginsOf(deps),
68
58
  lastServed: lastServed(),
69
59
  usage: usageSeen(),
70
60
  };
71
61
  }
72
62
 
73
63
  async function withUsage(deps: ModelApiDeps): Promise<Record<string, unknown>> {
74
- await refreshUsage(setupOf(deps), loginsOf(deps), deps);
64
+ await refreshUsage(setupOf(deps), deps);
75
65
  return settingsBody(deps);
76
66
  }
77
67
 
@@ -80,23 +70,19 @@ async function synced(deps: ModelApiDeps): Promise<Record<string, unknown>> {
80
70
  return settingsBody(deps);
81
71
  }
82
72
 
83
- function dropLogin(session: ApiSession, login: string, deps: ModelApiDeps): Record<string, unknown> {
84
- requireAdmin(session);
85
- removeClaudeLogin(login, { ...deps.claudeLogins, dir: deps.dir?.() ?? deps.claudeLogins?.dir });
86
- return settingsBody(deps);
87
- }
73
+ type Run = () => Promise<unknown>;
88
74
 
89
- type Run = (session: ApiSession) => Promise<unknown>;
75
+ const ROUTES: Record<string, [string, (deps: ModelApiDeps) => Promise<unknown>]> = {
76
+ [PATH]: ['GET', withUsage],
77
+ [SNAPSHOT]: ['GET', (deps) => Promise.resolve(settingsBody(deps))],
78
+ [SYNC]: ['POST', synced],
79
+ };
90
80
 
91
81
  function routeFor(path: string, method: string | undefined, deps: ModelApiDeps): Run | number {
92
- const only = (wanted: string, run: Run): Run | number => (method === wanted ? run : 405);
93
- if (path === PATH) return only('GET', () => withUsage(deps));
94
- if (path === SNAPSHOT) return only('GET', () => Promise.resolve(settingsBody(deps)));
95
- if (path === SYNC) return only('POST', () => synced(deps));
96
- if (path === CLAUDE_MODELS) return only('GET', () => Promise.resolve({ models: claudeModels(deps.anthropicBase) }));
97
- const login = path.slice(CLAUDE_LOGIN.length);
98
- if (!LOGIN_RE.test(login)) return 404;
99
- return only('DELETE', (session) => Promise.resolve(dropLogin(session, login, deps)));
82
+ const known = ROUTES[path];
83
+ if (known === undefined) return 404;
84
+ const [wanted, run] = known;
85
+ return method === wanted ? () => run(deps) : 405;
100
86
  }
101
87
 
102
88
  const mine = (path: string): boolean => path === PATH || path.startsWith(`${PATH}/`);
@@ -108,7 +94,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
108
94
  res.writeHead(204, cors(req)).end();
109
95
  return true;
110
96
  }
111
- const route = path === PATH || path.startsWith(CLAUDE_LOGIN) || [SNAPSHOT, SYNC, CLAUDE_MODELS].includes(path) ? routeFor(path, req.method, deps) : 404;
97
+ const route = routeFor(path, req.method, deps);
112
98
  if (typeof route === 'number') {
113
99
  sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
114
100
  return true;
@@ -116,7 +102,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
116
102
  apiSession(req)
117
103
  .then(async (session) => {
118
104
  if (!session) throw new ApiError('unauthorized', 401);
119
- sendJson(req, res, 200, await route(session));
105
+ sendJson(req, res, 200, await route());
120
106
  })
121
107
  .catch((err: unknown) => {
122
108
  apiFailure(req, res, err, 'model-api');
@@ -1,4 +1,4 @@
1
- export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
1
+ export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'routeway'] as const;
2
2
  export type Provider = (typeof PROVIDERS)[number];
3
3
  export type CodexMethod = 'chatgpt' | 'code';
4
4
  export type RouteSource = 'agent' | 'organization';
@@ -26,7 +26,7 @@ export interface Connection {
26
26
  zdr: boolean;
27
27
  revision: number;
28
28
  codex: CodexAccess | null;
29
- claude: string | null;
29
+ claudeToken: string;
30
30
  account: Account;
31
31
  signedOut: boolean;
32
32
  }
@@ -48,64 +48,28 @@ export interface Route {
48
48
  model: string;
49
49
  }
50
50
 
51
- export const LOCAL_PREFIX = 'local:';
52
- export const SESSION_LOGIN = `${LOCAL_PREFIX}session`;
53
51
  export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's Models page.";
54
52
 
55
- const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
53
+ const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|routeway):(.+)$/;
56
54
  const SMALL_RE = /haiku/i;
57
- const NO_ACCOUNT: Account = { email: null, plan: null, method: null };
58
55
 
59
56
  export const isProvider = (value: unknown): value is Provider => typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
60
57
 
61
58
  export const emptySetup = (): ModelSetup => ({ primary: null, fallbacks: [], connections: [], source: null });
62
59
 
63
- export const loginRef = (login: string): string => `${LOCAL_PREFIX}${login}`;
64
-
65
- const localConnection = (id: string, label: string, claude: string | null, account: Account, missing: boolean): Connection => ({
66
- id,
67
- provider: 'anthropic',
68
- label,
69
- model: '',
70
- apiKey: '',
71
- region: '',
72
- zdr: false,
73
- revision: 0,
74
- codex: null,
75
- claude,
76
- account,
77
- signedOut: missing,
78
- });
79
-
80
- export const sessionConnection = (): Connection => localConnection(SESSION_LOGIN, 'Claude Code login', null, NO_ACCOUNT, false);
81
-
82
- export interface LocalLogin {
83
- id: string;
84
- email: string | null;
85
- plan: string | null;
86
- }
87
-
88
- export function loginConnection(login: string, known: LocalLogin | undefined): Connection {
89
- const account = known === undefined ? NO_ACCOUNT : { email: known.email, plan: known.plan, method: null };
90
- return localConnection(loginRef(login), known?.email ?? 'Claude login', login, account, known === undefined);
91
- }
92
-
93
60
  export const connectionOf = (setup: ModelSetup, id: string): Connection | null => setup.connections.find((c) => c.id === id) ?? null;
94
61
 
95
62
  export function primaryRoute(setup: ModelSetup): Route | null {
96
- const primary = setup.primary;
97
- const conn = primary === null ? null : connectionOf(setup, primary.connection);
98
- if (primary === null || conn === null) return null;
99
- return { connection: conn, model: primary.model === '' ? conn.model : primary.model };
63
+ const steps = setup.primary === null ? [] : [setup.primary, ...setup.fallbacks];
64
+ for (const step of steps) {
65
+ const conn = connectionOf(setup, step.connection);
66
+ if (conn !== null) return { connection: conn, model: step.model === '' ? conn.model : step.model };
67
+ }
68
+ return null;
100
69
  }
101
70
 
102
- const MISSING_LOGIN = 'This Claude login is not on this box: sign in again on the Model page.';
103
-
104
71
  const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
105
- anthropic: [
106
- [(r) => r.connection.claude !== null && r.connection.signedOut, MISSING_LOGIN],
107
- [(r) => !r.connection.id.startsWith(LOCAL_PREFIX) && r.connection.apiKey === '', "Anthropic has no API key: add it on the organization's Models page."],
108
- ],
72
+ anthropic: [[(r) => r.connection.apiKey === '' && r.connection.claudeToken === '', "Anthropic has no API key or Claude subscription: add one on the organization's Models page."]],
109
73
  bedrock: [
110
74
  [(r) => r.connection.apiKey === '', "Bedrock has no API key: add it on the organization's Models page."],
111
75
  [(r) => r.connection.region === '', "Bedrock needs a region: add it on the organization's Models page."],
@@ -118,6 +82,10 @@ const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
118
82
  [(r) => r.connection.codex === null, CODEX_SIGN_IN],
119
83
  [(r) => r.model === '', "Codex needs a model id: choose one on the organization's Models page."],
120
84
  ],
85
+ routeway: [
86
+ [(r) => r.connection.apiKey === '', "Routeway has no API key: add it on the organization's Models page."],
87
+ [(r) => r.model === '', "Routeway needs a model id: choose one on the organization's Models page."],
88
+ ],
121
89
  };
122
90
 
123
91
  export const notReady = (route: Route): string | null => CHECKS[route.connection.provider].find(([missing]) => missing(route))?.[1] ?? null;
@@ -128,6 +96,7 @@ const DEFAULTS: Record<Provider, (requested: string, model: string) => string> =
128
96
  openrouter: (requested, model) => (requested.includes('/') ? requested : model),
129
97
  bedrock: (requested, model) => (model === '' ? requested : model),
130
98
  codex: (requested, model) => (requested.startsWith('gpt-') ? requested : model),
99
+ routeway: (_requested, model) => model,
131
100
  anthropic: (requested, model) => (model === '' || isSmallModel(requested) ? requested : model),
132
101
  };
133
102
 
@@ -1,9 +1,13 @@
1
1
  import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  import { errMsg, log } from '@metro-labs/core/log';
4
5
  import { agentsDir } from '../agents/files.js';
6
+ import { existsSync } from '../agent-user/agent-fs.js';
7
+ import { removeHome } from '../agent-user/home-fs.js';
8
+ import { agentUser, agentUserExpected } from '../agent-user/user.js';
5
9
 
6
- const LEFTOVER_RE = /^chatgpt-host\.json(?:\.tmp-[0-9]+)?$/;
10
+ const LEFTOVER_RE = /^(?:chatgpt-host|model)\.json(?:\.tmp-[0-9]+)?$/;
7
11
 
8
12
  const codeOf = (err: unknown): unknown => (err instanceof Error && 'code' in err ? err.code : undefined);
9
13
 
@@ -30,9 +34,26 @@ function removeOne(path: string): boolean {
30
34
  }
31
35
  }
32
36
 
33
- export function removeModelLeftovers(dir = agentsDir()): number {
37
+ export function claudeLoginsDir(): string | null {
38
+ const user = agentUser();
39
+ if (user === null && agentUserExpected()) return null;
40
+ return join(user?.home ?? homedir(), '.metro', 'claude-logins');
41
+ }
42
+
43
+ function removeClaudeLogins(dir: string | null): number {
44
+ if (dir === null || !existsSync(dir)) return 0;
45
+ try {
46
+ removeHome(dir, true);
47
+ return 1;
48
+ } catch (err) {
49
+ log.warn({ err: errMsg(err), dir }, 'org-models: could not remove the folder of the old Claude logins');
50
+ return 0;
51
+ }
52
+ }
53
+
54
+ export function removeModelLeftovers(dir = agentsDir(), logins: string | null = claudeLoginsDir()): number {
34
55
  try {
35
- const removed = namesIn(dir).filter((name) => removeOne(join(dir, name))).length;
56
+ const removed = namesIn(dir).filter((name) => removeOne(join(dir, name))).length + removeClaudeLogins(logins);
36
57
  log.info({ removed }, 'org-models: removed the files of the old model setup');
37
58
  return removed;
38
59
  } catch (err) {
@@ -0,0 +1,19 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { anthropicHeaders, GatewayError, type Watch } from './forward.js';
3
+
4
+ export const ROUTEWAY_BASE = 'https://api.routeway.ai';
5
+ const USER_AGENT = 'metro (+https://metro.box)';
6
+ const HANDED_OVER = new Set([402, 429]);
7
+
8
+ export function routewayHeaders(req: IncomingMessage, apiKey: string): Record<string, string> {
9
+ const headers = anthropicHeaders(req, apiKey);
10
+ headers['user-agent'] ??= USER_AGENT;
11
+ return headers;
12
+ }
13
+
14
+ export async function refusedWithPage(upstream: Response, watch: Watch): Promise<void> {
15
+ if (upstream.ok || HANDED_OVER.has(upstream.status) || !(upstream.headers.get('content-type') ?? '').includes('text/html')) return;
16
+ await upstream.body?.cancel();
17
+ watch.stop();
18
+ throw new GatewayError(502, 'api_error', `metro gateway: Routeway answered with a web page instead of its API (${String(upstream.status)}); try again in a moment`);
19
+ }
@@ -1,71 +1,11 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
- import { errMsg } from '@metro-labs/core/log';
4
3
  import { assembleMessage, SseParser, type SseEvent } from './frames.js';
5
- import { GatewayError, relayFrames, type Watch } from './forward.js';
4
+ import { relayFrames, type Watch } from './forward.js';
6
5
  import { UsageScanner } from './usage.js';
7
6
 
8
7
  const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
9
8
 
10
- interface Slot<T> {
11
- refreshing: Promise<T> | null;
12
- latest: T | null;
13
- }
14
-
15
- export type TokenState<T> = Map<string, Slot<T>>;
16
-
17
- export interface TokenSource<T extends { savedAt: string }> {
18
- label: string;
19
- stale: (tokens: T) => boolean;
20
- refresh: (tokens: T) => Promise<T>;
21
- save: (id: string, tokens: T) => void;
22
- }
23
-
24
- function slotFor<T>(state: TokenState<T>, id: string): Slot<T> {
25
- const found = state.get(id);
26
- if (found !== undefined) return found;
27
- const made: Slot<T> = { refreshing: null, latest: null };
28
- state.set(id, made);
29
- return made;
30
- }
31
-
32
- const newerThan = (a: { savedAt: string }, b: { savedAt: string }): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
33
-
34
- export function refreshed<T extends { savedAt: string }>(state: TokenState<T>, id: string, tokens: T, source: TokenSource<T>): Promise<T> {
35
- const slot = slotFor(state, id);
36
- const latest = slot.latest;
37
- if (latest !== null && newerThan(latest, tokens) && !source.stale(latest)) return Promise.resolve(latest);
38
- if (slot.refreshing !== null) return slot.refreshing;
39
- const run = source
40
- .refresh(tokens)
41
- .then((fresh) => {
42
- source.save(id, fresh);
43
- slot.latest = fresh;
44
- return fresh;
45
- })
46
- .catch((err: unknown) => {
47
- throw new GatewayError(403, 'permission_error', `${source.label} sign-in expired (${errMsg(err)}): connect again on the Model page`);
48
- })
49
- .finally(() => {
50
- slot.refreshing = null;
51
- });
52
- slot.refreshing = run;
53
- return run;
54
- }
55
-
56
- export function currentOf<T extends { savedAt: string }>(state: TokenState<T>, id: string, tokens: T | null, source: TokenSource<T>, missing: string): Promise<T> {
57
- if (tokens === null) throw new GatewayError(400, 'invalid_request_error', missing);
58
- return source.stale(tokens) ? refreshed(state, id, tokens, source) : Promise.resolve(tokens);
59
- }
60
-
61
- export async function reach<T>(current: () => Promise<T>, again: (tokens: T) => Promise<T>, send: (tokens: T) => Promise<Response>): Promise<Response> {
62
- const tokens = await current();
63
- const upstream = await send(tokens);
64
- if (upstream.status !== 401) return upstream;
65
- await upstream.body?.cancel();
66
- return send(await again(tokens));
67
- }
68
-
69
9
  export function errorKind(status: number, invalid: readonly number[] = [400, 404]): string {
70
10
  if (status === 401 || status === 403) return 'permission_error';
71
11
  if (status === 429) return 'rate_limit_error';
@@ -1,20 +1,14 @@
1
1
  import { errMsg, log } from '@metro-labs/core/log';
2
- import { readClaudeUsage } from '../claude/usage-probe.js';
3
2
  import { openrouterKey } from './openrouter.js';
4
- import { claudeLoginUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
5
- import { loginRef, primaryRoute, SESSION_LOGIN, type Connection, type ModelSetup } from './model-config.js';
6
- import { probeClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
7
- import type { ClaudeLogin } from './claude-store.js';
3
+ import { mayProbe, noteUsage, openrouterUsage, usageOf } from './usage.js';
4
+ import type { Connection, ModelSetup } from './model-config.js';
8
5
 
9
6
  export const CREDITS_TTL_MS = 5 * 60_000;
10
7
  const WAIT_MS = 4_000;
11
- const LOGIN_PROBE = 'claude-login';
12
8
 
13
9
  export interface UsageDeps {
14
10
  fetchImpl?: typeof fetch;
15
11
  openrouterBase?: string;
16
- claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
17
- claudeLogins?: ClaudeLoginDeps;
18
12
  usageWaitMs?: number;
19
13
  }
20
14
 
@@ -23,47 +17,15 @@ const fresh = (id: string, now: number): boolean => {
23
17
  return seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS;
24
18
  };
25
19
 
26
- const due = (id: string, now: number): boolean => !fresh(id, now) && mayProbe(id, now, CREDITS_TTL_MS);
27
-
28
- async function probe(what: string, run: () => Promise<void>): Promise<void> {
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;
29
22
  try {
30
- await run();
23
+ noteUsage(conn.id, openrouterUsage(await openrouterKey(conn.apiKey, deps.openrouterBase, deps.fetchImpl), new Date(now)));
31
24
  } catch (err) {
32
- log.warn({ err: errMsg(err) }, `model-api: could not read the ${what}`);
25
+ log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter key spend');
33
26
  }
34
27
  }
35
28
 
36
- async function refreshKey(conn: Connection, deps: UsageDeps, now: number): Promise<void> {
37
- if (conn.provider !== 'openrouter' || conn.apiKey === '' || !due(conn.id, now)) return;
38
- await probe('OpenRouter key spend', async () => {
39
- noteUsage(conn.id, openrouterUsage(await openrouterKey(conn.apiKey, deps.openrouterBase, deps.fetchImpl), new Date(now)));
40
- });
41
- }
42
-
43
- const hasFallbacks = (setup: ModelSetup): boolean => setup.fallbacks.length > 0;
44
-
45
- async function refreshOwnLogin(login: string, setup: ModelSetup, deps: UsageDeps, now: number): Promise<void> {
46
- const key = loginRef(login);
47
- if ((fresh(key, now) && !hasFallbacks(setup)) || !mayProbe(key, now, CREDITS_TTL_MS)) return;
48
- await probe('usage of a Claude login', async () => {
49
- const usage = claudeLoginUsage(await probeClaudeLogin(login, deps.claudeLogins), new Date(now));
50
- if (usage !== null) mergeUsage(key, usage);
51
- });
52
- }
53
-
54
- const ridesSession = (setup: ModelSetup): boolean => primaryRoute(setup) === null || setup.connections.some((c) => c.id === SESSION_LOGIN);
55
-
56
- async function refreshSession(setup: ModelSetup, deps: UsageDeps, now: number): Promise<void> {
57
- if (!ridesSession(setup) || (fresh(SESSION_LOGIN, now) && !hasFallbacks(setup))) return;
58
- if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
59
- await probe('usage of the Claude Code login', async () => {
60
- const usage = claudeLoginUsage(await (deps.claudeUsage ?? (() => readClaudeUsage()))(), new Date(now));
61
- if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
62
- });
63
- const usage = probeAnswer(LOGIN_PROBE);
64
- if (usage !== undefined && now - Date.parse(usage.at) < CREDITS_TTL_MS) mergeUsage(SESSION_LOGIN, usage);
65
- }
66
-
67
29
  async function within(ms: number, work: Promise<unknown>): Promise<void> {
68
30
  let timer: ReturnType<typeof setTimeout> | undefined;
69
31
  const waited = new Promise<void>((resolve) => {
@@ -73,21 +35,15 @@ async function within(ms: number, work: Promise<unknown>): Promise<void> {
73
35
  clearTimeout(timer);
74
36
  }
75
37
 
76
- export async function refreshUsage(setup: ModelSetup, logins: ClaudeLogin[], deps: UsageDeps = {}, now = Date.now()): Promise<void> {
77
- const own = new Set([...logins.map((l) => l.id), ...setup.connections.flatMap((c) => (c.claude === null || c.signedOut ? [] : [c.claude]))]);
78
- const work = Promise.all([
79
- ...setup.connections.map((conn) => refreshKey(conn, deps, now)),
80
- ...[...own].map((login) => refreshOwnLogin(login, setup, deps, now)),
81
- refreshSession(setup, deps, now),
82
- ]);
83
- await within(deps.usageWaitMs ?? WAIT_MS, work);
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))));
84
40
  }
85
41
 
86
- export function watchFallbackUsage(setup: () => ModelSetup, logins: () => ClaudeLogin[], deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
42
+ export function watchFallbackUsage(setup: () => ModelSetup, deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
87
43
  const run = (): void => {
88
44
  const current = setup();
89
- if (!hasFallbacks(current)) return;
90
- refreshUsage(current, logins(), deps).catch((err: unknown) => {
45
+ if (current.fallbacks.length === 0) return;
46
+ refreshUsage(current, deps).catch((err: unknown) => {
91
47
  log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
92
48
  });
93
49
  };
@@ -1,6 +1,3 @@
1
- import { isRecord } from '@metro-labs/core/is-record';
2
- import { stringOf } from '@metro-labs/http/api-http';
3
-
4
1
  type Key = string;
5
2
 
6
3
  export interface UsageWindow {
@@ -31,14 +28,13 @@ const latest = new Map<Key, Reported>();
31
28
  const stamps = new Map<Key, Map<string, number>>();
32
29
  const tallies = new Map<Key, Tally>();
33
30
  const probed = new Map<Key, number>();
34
- const answers = new Map<Key, Reported>();
35
31
 
36
32
  export const noteUsage = (key: Key, usage: Reported): void => {
37
33
  latest.set(key, usage);
38
34
  stamps.set(key, new Map(usage.windows.map((w) => [w.label, Date.parse(usage.at)])));
39
35
  };
40
36
 
41
- export function mergeUsage(key: Key, usage: Reported): void {
37
+ function mergeUsage(key: Key, usage: Reported): void {
42
38
  const seen = latest.get(key);
43
39
  const marks = stamps.get(key);
44
40
  if (seen === undefined || marks === undefined) {
@@ -66,25 +62,11 @@ export function usageSeen(): Record<Key, ProviderUsage> {
66
62
 
67
63
  export const usageOf = (key: Key): Reported | undefined => latest.get(key);
68
64
 
69
- export const forgetOne = (key: Key): void => {
70
- latest.delete(key);
71
- stamps.delete(key);
72
- tallies.delete(key);
73
- probed.delete(key);
74
- };
75
-
76
- export const forgetReported = (key: Key): void => {
77
- latest.delete(key);
78
- stamps.delete(key);
79
- probed.delete(key);
80
- };
81
-
82
65
  export const forgetUsage = (): void => {
83
66
  latest.clear();
84
67
  stamps.clear();
85
68
  tallies.clear();
86
69
  probed.clear();
87
- answers.clear();
88
70
  };
89
71
 
90
72
  export function mayProbe(key: Key, now: number, every: number): boolean {
@@ -93,12 +75,6 @@ export function mayProbe(key: Key, now: number, every: number): boolean {
93
75
  return true;
94
76
  }
95
77
 
96
- export const probeAnswer = (key: Key): Reported | undefined => answers.get(key);
97
-
98
- export const keepProbeAnswer = (key: Key, usage: Reported): void => {
99
- answers.set(key, usage);
100
- };
101
-
102
78
  export interface Counted {
103
79
  input: number;
104
80
  output: number;
@@ -265,47 +241,6 @@ export function codexUsage(headers: Headers, now = new Date()): Reported | null
265
241
  return { windows, note: reached === null || reached === '' ? null : reached.replaceAll('_', ' '), at: now.toISOString() };
266
242
  }
267
243
 
268
- const present = (w: UsageWindow | null): w is UsageWindow => w !== null;
269
-
270
- const textOf = (raw: unknown): string | null => (typeof raw === 'number' || typeof raw === 'string' ? String(raw) : null);
271
-
272
- const numberOf = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
273
-
274
- const CLAUDE_WINDOWS: [string, string][] = [
275
- ['five_hour', '5-hour window'],
276
- ['seven_day', 'Weekly, all models'],
277
- ['seven_day_sonnet', 'Weekly, Sonnet'],
278
- ['seven_day_opus', 'Weekly, Opus'],
279
- ];
280
-
281
- function percentWindow(label: string, raw: unknown, now: Date): UsageWindow | null {
282
- if (!isRecord(raw)) return null;
283
- const percent = numberOf(raw.utilization) ?? numberOf(raw.percent);
284
- if (percent === null) return null;
285
- return { label, used: clamp(percent / 100), resetAt: whenFrom(textOf(raw.resets_at), now), detail: null };
286
- }
287
-
288
- function scopedName(entry: Record<string, unknown>): string {
289
- if (stringOf(entry.display_name) !== '') return stringOf(entry.display_name);
290
- if (entry.kind !== 'weekly_scoped' || !isRecord(entry.scope) || !isRecord(entry.scope.model)) return '';
291
- return stringOf(entry.scope.model.display_name);
292
- }
293
-
294
- function scopedWindows(limits: Record<string, unknown>, now: Date): (UsageWindow | null)[] {
295
- const listed = [limits.model_scoped, limits.limits].flatMap((list): unknown[] => (Array.isArray(list) ? (list as unknown[]) : []));
296
- return listed.map((entry) => {
297
- const name = isRecord(entry) ? scopedName(entry) : '';
298
- return name === '' ? null : percentWindow(`Weekly, ${name}`, entry, now);
299
- });
300
- }
301
-
302
- export function claudeLoginUsage(limits: unknown, now = new Date()): Reported | null {
303
- if (!isRecord(limits)) return null;
304
- const all = [...CLAUDE_WINDOWS.map(([key, label]) => percentWindow(label, limits[key], now)), ...scopedWindows(limits, now)].filter(present);
305
- const windows = all.filter((w, index) => all.findIndex((other) => other.label === w.label) === index);
306
- return windows.length === 0 ? null : { windows, note: null, at: now.toISOString() };
307
- }
308
-
309
244
  export function noteUsageHeaders(kind: 'anthropic' | 'codex', key: Key, headers: Headers, now = new Date()): void {
310
245
  const usage = kind === 'anthropic' ? anthropicUsage(headers, now) : codexUsage(headers, now);
311
246
  if (usage === null) return;
@@ -33,7 +33,7 @@ export function requireAdmin(session: ApiSession): void {
33
33
  export function cors(req: IncomingMessage): Record<string, string> {
34
34
  return {
35
35
  'access-control-allow-origin': req.headers.origin ?? '*',
36
- 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
36
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
37
37
  'access-control-allow-headers': 'Authorization, Content-Type',
38
38
  'access-control-allow-private-network': 'true',
39
39
  'access-control-max-age': '86400',
@@ -9,6 +9,8 @@ export interface RemoteTool {
9
9
  title: string;
10
10
  description: string;
11
11
  readOnly: boolean;
12
+ destructive?: boolean;
13
+ openWorld?: boolean;
12
14
  inputSchema?: Record<string, unknown>;
13
15
  }
14
16
 
@@ -36,11 +38,15 @@ async function rpc(url: URL, auth: ConnectorAuth, session: Map<string, string>,
36
38
  return resultOf(text, res.headers.get('content-type') ?? '');
37
39
  }
38
40
 
41
+ function hintsOf(annotations: Record<string, unknown>): Pick<RemoteTool, 'destructive' | 'openWorld'> {
42
+ return { ...(annotations.destructiveHint === true ? { destructive: true } : {}), ...(annotations.openWorldHint === true ? { openWorld: true } : {}) };
43
+ }
44
+
39
45
  function toTool(raw: unknown): RemoteTool | null {
40
46
  if (!isRecord(raw) || typeof raw.name !== 'string') return null;
41
47
  const annotations = isRecord(raw.annotations) ? raw.annotations : {};
42
48
  const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
43
- return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
49
+ return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...hintsOf(annotations), ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
44
50
  }
45
51
 
46
52
  async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.310"
2
+ "version": "0.1.0-beta.312"
3
3
  }
@@ -18,6 +18,7 @@
18
18
  "./train-error": "./src/train-error.ts",
19
19
  "./trains/protocol": "./src/protocol.ts",
20
20
  "./ids": "./src/ids.ts",
21
+ "./claude-token": "./src/claude-token.ts",
21
22
  "./tickets": "./src/tickets.ts",
22
23
  "./station-names": "./src/station-names.ts",
23
24
  "./str": "./src/str.ts",
@@ -0,0 +1,3 @@
1
+ const TOKEN_RE = /^sk-ant-oat01-[A-Za-z0-9_-]{32,512}$/;
2
+
3
+ export const isClaudeToken = (value: string): boolean => TOKEN_RE.test(value);
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.310",
2
+ "version": "0.1.0-beta.312",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",