@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.
- package/dist/claude-env.js +1 -1
- package/dist/route.js +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -36
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +4 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -9
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +4 -15
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +5 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +66 -67
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +18 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +15 -46
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-leftovers.ts +24 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/routeway.ts +19 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/subscription.ts +1 -61
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +11 -55
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +1 -66
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +7 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +0 -243
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +0 -128
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +0 -192
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -40
- 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,
|
|
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
|
|
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 ?
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
|
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) },
|
|
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,
|
|
77
|
-
|
|
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,
|
|
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 (
|
|
90
|
-
refreshUsage(current,
|
|
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
|
-
|
|
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> {
|
package/runtime/runtime.json
CHANGED
|
@@ -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",
|