@stage-labs/metro 0.1.0-beta.310 → 0.1.0-beta.311
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/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 +31 -49
- 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 +2 -40
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-leftovers.ts +24 -3
- 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/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
package/dist/claude-env.js
CHANGED
|
@@ -89,7 +89,7 @@ function gatewayLaunchEnv(key, port, claude, say) {
|
|
|
89
89
|
say('MCP tool search is on, so connector tools load when Claude needs them instead of all at start');
|
|
90
90
|
const env = credentialEnv(routed, key, claudeSignedIn(claude));
|
|
91
91
|
if (env !== routed)
|
|
92
|
-
say("Claude Code has no login of its own here, so metro's key stands in as its credential; the
|
|
92
|
+
say("Claude Code has no login of its own here, so metro's key stands in as its credential; the organization's Models page decides where requests go");
|
|
93
93
|
return env;
|
|
94
94
|
}
|
|
95
95
|
export async function headlessEnv(claude, command) {
|
package/package.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",
|
|
@@ -40,7 +40,7 @@ function agentFile(dir: string): string | null {
|
|
|
40
40
|
return json({ version: 1, id: file.id, key: file.key });
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function viewFiles(dir = agentsDir(), model: () => string | null = () => modelViewText(effectiveSetup(
|
|
43
|
+
export function viewFiles(dir = agentsDir(), model: () => string | null = () => modelViewText(effectiveSetup())): Map<string, string | null> {
|
|
44
44
|
const files = new Map<string, string | null>([
|
|
45
45
|
['agent.json', agentFile(dir)],
|
|
46
46
|
['model.json', model()],
|
|
@@ -62,7 +62,6 @@ import { startCloudWatchPublisher } from '../server/cloudwatch.js';
|
|
|
62
62
|
import { watchFallbackUsage } from '../gateway/usage-refresh.js';
|
|
63
63
|
import { startOrgModels, stopOrgModels } from '../gateway/org-models.js';
|
|
64
64
|
import { effectiveSetup, watchModelChanges } from '../gateway/effective.js';
|
|
65
|
-
import { readClaudeLogins, retireV2ModelFile } from '../gateway/claude-store.js';
|
|
66
65
|
import { removeModelLeftovers } from '../gateway/model-leftovers.js';
|
|
67
66
|
import { readAgentKey } from '../connectors/agent-key.js';
|
|
68
67
|
import { METRO_API } from '../connectors/metro-api.js';
|
|
@@ -146,7 +145,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void): SessionApis {
|
|
|
146
145
|
async function main(): Promise<void> {
|
|
147
146
|
removeConnectorLeftovers();
|
|
148
147
|
removeModelLeftovers();
|
|
149
|
-
retireV2ModelFile();
|
|
150
148
|
applyLocalOwner();
|
|
151
149
|
installBearerSessions(agentsDir(), localOwner);
|
|
152
150
|
watchPolicySnapshot();
|
|
@@ -183,7 +181,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
183
181
|
});
|
|
184
182
|
watchConnectorTools();
|
|
185
183
|
startCloudWatchPublisher();
|
|
186
|
-
watchFallbackUsage(() => effectiveSetup()
|
|
184
|
+
watchFallbackUsage(() => effectiveSetup());
|
|
187
185
|
announceLocalEndpoint();
|
|
188
186
|
tunnel?.start();
|
|
189
187
|
log.info(
|
|
@@ -13,17 +13,6 @@ import {
|
|
|
13
13
|
readClaudeSkill,
|
|
14
14
|
writeClaudeSkill,
|
|
15
15
|
} from './skills.js';
|
|
16
|
-
import {
|
|
17
|
-
answerClaudeLogin,
|
|
18
|
-
claudeAccount,
|
|
19
|
-
claudeInstalled,
|
|
20
|
-
claudeLoginView,
|
|
21
|
-
endClaudeLogin,
|
|
22
|
-
startClaudeLogin,
|
|
23
|
-
type LoginDeps,
|
|
24
|
-
type LoginTarget,
|
|
25
|
-
} from './login.js';
|
|
26
|
-
import { claudeLoginTarget, type ClaudeLoginDeps } from '../gateway/claude-logins.js';
|
|
27
16
|
import { setupAnswer, type SetupApiDeps } from './setup-api.js';
|
|
28
17
|
import {
|
|
29
18
|
autostartEnabled,
|
|
@@ -58,8 +47,6 @@ const PAGE_MAX = 500;
|
|
|
58
47
|
|
|
59
48
|
export interface ClaudeApiDeps extends SetupApiDeps {
|
|
60
49
|
dir?: () => string;
|
|
61
|
-
login?: LoginDeps;
|
|
62
|
-
claudeLogins?: ClaudeLoginDeps;
|
|
63
50
|
skillSource?: GitHubSkills;
|
|
64
51
|
}
|
|
65
52
|
|
|
@@ -133,8 +120,7 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
|
|
|
133
120
|
return createClaudeSkill(body.name, typeof body.text === 'string' ? body.text : undefined, dir);
|
|
134
121
|
}
|
|
135
122
|
|
|
136
|
-
const ADMIN_ONLY = /^\/api\/claude\/(
|
|
137
|
-
const LOGIN = 'login';
|
|
123
|
+
const ADMIN_ONLY = /^\/api\/claude\/(session|setup|skill-source)(\/|$)/;
|
|
138
124
|
const SESSION = 'session';
|
|
139
125
|
const SETUP = 'setup';
|
|
140
126
|
|
|
@@ -166,26 +152,6 @@ async function sessionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise
|
|
|
166
152
|
return sessionCommand(body, session);
|
|
167
153
|
}
|
|
168
154
|
|
|
169
|
-
function loginTarget(req: IncomingMessage, deps: ClaudeApiDeps): LoginTarget | null {
|
|
170
|
-
const asked = new URL(req.url ?? '', 'http://metro').searchParams.get('connection');
|
|
171
|
-
return asked === null ? null : claudeLoginTarget(asked, deps.claudeLogins);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps): Promise<unknown> {
|
|
175
|
-
const method = req.method ?? 'GET';
|
|
176
|
-
if (id === '') {
|
|
177
|
-
if (method === 'POST') return startClaudeLogin(deps.login, Date.now(), loginTarget(req, deps));
|
|
178
|
-
if (method === 'GET') return { available: claudeInstalled(), ...claudeAccount() };
|
|
179
|
-
throw new ApiError('method not allowed', 405);
|
|
180
|
-
}
|
|
181
|
-
if (method === 'GET') return claudeLoginView(id);
|
|
182
|
-
if (method === 'DELETE') return endClaudeLogin(id);
|
|
183
|
-
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
184
|
-
const body = await readJsonBody(req);
|
|
185
|
-
if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
|
|
186
|
-
return answerClaudeLogin(id, body.text);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
155
|
function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
|
|
190
156
|
const [head = '', item = ''] = rest;
|
|
191
157
|
if (rest.length !== 2) throw new ApiError('method not allowed', 405);
|
|
@@ -236,7 +202,6 @@ function fileAnswer(req: IncomingMessage, path: string, search: string, dir: str
|
|
|
236
202
|
function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
|
|
237
203
|
const dir = (deps.dir ?? claudeDir)();
|
|
238
204
|
const [head = '', item = ''] = parts(path);
|
|
239
|
-
if (head === LOGIN) return loginAnswer(req, item, deps);
|
|
240
205
|
if (head === 'skill-source' && parts(path).length === 1) return githubSkillsAnswer(req, deps.skillSource);
|
|
241
206
|
if (head === 'skills') return skillsAnswer(req, path, search, dir, sourceOf(deps));
|
|
242
207
|
const single = item === '' ? SINGLETONS[head] : undefined;
|
|
@@ -13,7 +13,6 @@ import { METRO_VERSION } from '@metro-labs/core/version';
|
|
|
13
13
|
import { agentsDir, listAgentFiles } from '../agents/files.js';
|
|
14
14
|
import { notReady, primaryRoute, type ModelSetup } from '../gateway/model-config.js';
|
|
15
15
|
import { effectiveSetup } from '../gateway/effective.js';
|
|
16
|
-
import { claudeAccount, claudeInstalled, forgetClaudeChecks } from './login.js';
|
|
17
16
|
import { inSessionScope } from './memory.js';
|
|
18
17
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
19
18
|
import { agentExtraEnv, agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
|
|
@@ -38,7 +37,6 @@ export interface SessionDeps {
|
|
|
38
37
|
metro?: string[];
|
|
39
38
|
home?: string;
|
|
40
39
|
agents?: string;
|
|
41
|
-
signedIn?: () => boolean;
|
|
42
40
|
model?: () => ModelSetup;
|
|
43
41
|
now?: () => number;
|
|
44
42
|
version?: string;
|
|
@@ -122,30 +120,23 @@ export function prepareRunnerStore(): Promise<void> {
|
|
|
122
120
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
123
121
|
said = (said + chunk.toString('utf8')).slice(-2000);
|
|
124
122
|
});
|
|
125
|
-
child.on('error',
|
|
126
|
-
forgetClaudeChecks();
|
|
127
|
-
reject(err);
|
|
128
|
-
});
|
|
123
|
+
child.on('error', reject);
|
|
129
124
|
child.on('close', (code) => {
|
|
130
|
-
forgetClaudeChecks();
|
|
131
125
|
if (code === 0) resolve();
|
|
132
126
|
else reject(new Error(`metro agent --prepare failed: ${said.trim() || `exit ${String(code)}`}`));
|
|
133
127
|
});
|
|
134
128
|
});
|
|
135
129
|
}
|
|
136
130
|
|
|
137
|
-
const
|
|
131
|
+
const NO_MODEL = "the organization routes this agent to no model yet: choose one on the organization's Models page";
|
|
138
132
|
|
|
139
133
|
function credentialReady(deps: SessionDeps): string | null {
|
|
140
|
-
const signedIn = deps.signedIn ?? loginNotRuledOut;
|
|
141
|
-
if (signedIn()) return null;
|
|
142
134
|
try {
|
|
143
|
-
const primary = primaryRoute((deps.model ?? (() => effectiveSetup(
|
|
144
|
-
|
|
135
|
+
const primary = primaryRoute((deps.model ?? (() => effectiveSetup()))());
|
|
136
|
+
return primary === null ? NO_MODEL : notReady(primary);
|
|
145
137
|
} catch (err) {
|
|
146
138
|
return `the model setup is not readable (${errMsg(err)})`;
|
|
147
139
|
}
|
|
148
|
-
return 'Claude Code is not signed in and the organization routes this agent to no model yet';
|
|
149
140
|
}
|
|
150
141
|
|
|
151
142
|
const agentUserMissing = (deps: SessionDeps): boolean =>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
-
import { isProvider,
|
|
2
|
+
import { isProvider, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
|
|
3
3
|
|
|
4
4
|
export interface DeliveredRoute {
|
|
5
5
|
primary: Fallback | null;
|
|
@@ -27,7 +27,7 @@ function refOf(raw: unknown): Fallback | null {
|
|
|
27
27
|
function routeOf(raw: unknown): DeliveredRoute | null {
|
|
28
28
|
if (!isRecord(raw)) return null;
|
|
29
29
|
const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
|
|
30
|
-
return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== ''
|
|
30
|
+
return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS) };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function accountOf(raw: unknown): Account {
|
|
@@ -44,16 +44,23 @@ function codexOf(raw: Record<string, unknown>): CodexAccess | null {
|
|
|
44
44
|
return { method: raw.method, accessToken, expiresAt: numberOf(raw.expiresAt), accountId: maybe(raw.accountId) };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
interface Credential {
|
|
48
|
+
apiKey: string;
|
|
49
|
+
claudeToken: string;
|
|
50
|
+
codex: CodexAccess | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function credentialOf(provider: Provider, raw: unknown): Credential {
|
|
54
|
+
if (!isRecord(raw)) return { apiKey: '', claudeToken: '', codex: null };
|
|
55
|
+
if (provider === 'codex') return { apiKey: '', claudeToken: '', codex: codexOf(raw) };
|
|
56
|
+
return { apiKey: text(raw.apiKey), claudeToken: provider === 'anthropic' ? text(raw.claudeToken) : '', codex: null };
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export function deliveredConnection(raw: unknown): Connection | null {
|
|
53
60
|
if (!isRecord(raw) || !isProvider(raw.provider)) return null;
|
|
54
61
|
const id = text(raw.id);
|
|
55
|
-
if (id === ''
|
|
56
|
-
const { apiKey, codex } = credentialOf(raw.provider, raw.credential);
|
|
62
|
+
if (id === '') return null;
|
|
63
|
+
const { apiKey, claudeToken, codex } = credentialOf(raw.provider, raw.credential);
|
|
57
64
|
return {
|
|
58
65
|
id,
|
|
59
66
|
provider: raw.provider,
|
|
@@ -64,9 +71,9 @@ export function deliveredConnection(raw: unknown): Connection | null {
|
|
|
64
71
|
zdr: raw.zdr === true,
|
|
65
72
|
revision: numberOf(raw.revision),
|
|
66
73
|
codex,
|
|
67
|
-
|
|
74
|
+
claudeToken,
|
|
68
75
|
account: accountOf(raw.account),
|
|
69
|
-
signedOut: codex === null && apiKey === '',
|
|
76
|
+
signedOut: codex === null && apiKey === '' && claudeToken === '',
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
79
|
|
|
@@ -1,26 +1,15 @@
|
|
|
1
1
|
import { log } from '@metro-labs/core/log';
|
|
2
|
-
import { agentsDir } from '../agents/files.js';
|
|
3
|
-
import { readClaudeLogins, type ClaudeLogin } from './claude-store.js';
|
|
4
2
|
import type { Delivered } from './delivered.js';
|
|
5
|
-
import { emptySetup,
|
|
3
|
+
import { emptySetup, primaryRoute, runnerModel, type ModelSetup } from './model-config.js';
|
|
6
4
|
import { deliveredSetup, type OrgModels } from './org-models.js';
|
|
7
5
|
|
|
8
|
-
function
|
|
9
|
-
if (id === SESSION_LOGIN) return sessionConnection();
|
|
10
|
-
if (!id.startsWith(LOCAL_PREFIX)) return null;
|
|
11
|
-
const login = id.slice(LOCAL_PREFIX.length);
|
|
12
|
-
return loginConnection(login, logins.find((l) => l.id === login));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function setupOf(delivered: Delivered | null, logins: ClaudeLogin[]): ModelSetup {
|
|
6
|
+
export function setupOf(delivered: Delivered | null): ModelSetup {
|
|
16
7
|
const route = delivered?.route ?? null;
|
|
17
8
|
if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
|
|
18
|
-
|
|
19
|
-
const locals = [...new Set(named)].flatMap((id) => localOf(id, logins) ?? []);
|
|
20
|
-
return { primary: route.primary, fallbacks: route.fallbacks, connections: [...delivered.connections, ...locals], source: delivered.source };
|
|
9
|
+
return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source };
|
|
21
10
|
}
|
|
22
11
|
|
|
23
|
-
export const effectiveSetup = (
|
|
12
|
+
export const effectiveSetup = (delivered: Delivered | null = deliveredSetup()): ModelSetup => setupOf(delivered);
|
|
24
13
|
|
|
25
14
|
export function modelViewText(setup: ModelSetup): string | null {
|
|
26
15
|
const primary = primaryRoute(setup);
|
|
@@ -55,7 +55,7 @@ export function holdOf(route: Route, now = Date.now()): Hold | null {
|
|
|
55
55
|
|
|
56
56
|
function fallbackModel(requested: string, conn: Connection, model: string): string {
|
|
57
57
|
const bare = bareModel(requested);
|
|
58
|
-
return
|
|
58
|
+
return conn.provider === 'anthropic' && isSmallModel(bare) ? bare : model;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function fallbackRoute(setup: ModelSetup, fallback: Fallback, requested: string): Route | null {
|
|
@@ -90,9 +90,10 @@ function coolUntil(headers: Headers, now: number): number {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export function noteRefused(route: Route, status: number, headers: Headers, next: Route | undefined, now = Date.now()): void {
|
|
93
|
-
const
|
|
94
|
-
cooling.set(keyOf(route), { until: coolUntil(headers, now), reason });
|
|
95
|
-
|
|
93
|
+
const signedOut = status === 401;
|
|
94
|
+
cooling.set(keyOf(route), { until: coolUntil(headers, now), reason: signedOut ? 'token refused, sign in again' : `answered ${String(status)}` });
|
|
95
|
+
const why = signedOut ? 'gateway: Anthropic refused the Claude subscription, so the request goes to the next fallback model' : 'gateway: the model hit its limit, so the request goes to the next fallback model';
|
|
96
|
+
log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, why);
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
|
|
@@ -31,6 +31,12 @@ const HOP_BY_HOP = new Set([
|
|
|
31
31
|
]);
|
|
32
32
|
const RESPONSE_DROP = new Set(['content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive']);
|
|
33
33
|
|
|
34
|
+
export class SubscriptionRefused extends GatewayError {
|
|
35
|
+
constructor(label: string) {
|
|
36
|
+
super(403, 'permission_error', `Anthropic refused the Claude subscription ${label}: sign in again on the Models page`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
export function sendError(res: ServerResponse, status: number, kind: string, message: string): void {
|
|
35
41
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
36
42
|
res.end(JSON.stringify({ type: 'error', error: { type: kind, message } }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
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 { agentIdForKey } from '../agents/keys.js';
|
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
addBeta,
|
|
14
14
|
anthropicHeaders,
|
|
15
|
-
forwardedHeaders,
|
|
16
15
|
GatewayError,
|
|
17
16
|
holdLimitRefusal,
|
|
17
|
+
SubscriptionRefused,
|
|
18
18
|
loginHeaders,
|
|
19
19
|
parseJson,
|
|
20
20
|
pipeResponse,
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
sendError,
|
|
23
23
|
upstreamMessage,
|
|
24
24
|
watchUpstream,
|
|
25
|
+
type Held,
|
|
26
|
+
type Watch,
|
|
27
|
+
type Refusal,
|
|
25
28
|
} from './forward.js';
|
|
26
29
|
import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
|
|
27
30
|
import {
|
|
@@ -36,7 +39,7 @@ import {
|
|
|
36
39
|
withEffort,
|
|
37
40
|
withThinkingFor,
|
|
38
41
|
} from './effort.js';
|
|
39
|
-
import { notReady, resolveRoute, routeLabel,
|
|
42
|
+
import { notReady, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
|
|
40
43
|
import { codexMessages, type CodexDeps } from './codex.js';
|
|
41
44
|
import { countTokens } from './subscription.js';
|
|
42
45
|
import { OPENROUTER_BASE } from './openrouter.js';
|
|
@@ -44,8 +47,6 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
44
47
|
import { forgetServed, noteServed } from './served.js';
|
|
45
48
|
import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
46
49
|
import { fitToolSearch } from './tool-search.js';
|
|
47
|
-
import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
|
|
48
|
-
import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
|
|
49
50
|
import { orgModels } from './org-models.js';
|
|
50
51
|
|
|
51
52
|
const GATEWAY_PREFIX = '/gateway';
|
|
@@ -53,6 +54,8 @@ const MESSAGES = '/v1/messages';
|
|
|
53
54
|
const COUNT = '/v1/messages/count_tokens';
|
|
54
55
|
const MODELS = '/v1/models';
|
|
55
56
|
const HELLO = '/api/hello';
|
|
57
|
+
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
58
|
+
const NO_ROUTE = "metro gateway: no model is set up for this agent yet: enroll it with its organization and choose a model on the organization's Models page";
|
|
56
59
|
|
|
57
60
|
export interface GatewayDeps {
|
|
58
61
|
setup: () => ModelSetup;
|
|
@@ -61,7 +64,6 @@ export interface GatewayDeps {
|
|
|
61
64
|
bedrockBase?: string;
|
|
62
65
|
openrouterBase?: string;
|
|
63
66
|
codex?: Partial<CodexDeps>;
|
|
64
|
-
claudeLogins?: ClaudeLoginDeps;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
const learned: Adaptations = freshAdaptations();
|
|
@@ -73,7 +75,6 @@ export function resetGatewayState(): void {
|
|
|
73
75
|
learned.fields.clear();
|
|
74
76
|
learned.dropBetas = false;
|
|
75
77
|
forgetLearnedThinking();
|
|
76
|
-
forgetClaudeTokens();
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
|
|
@@ -101,7 +102,9 @@ function modelsBody(setup: ModelSetup): Record<string, unknown> {
|
|
|
101
102
|
return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
const
|
|
105
|
+
const noRoute = (): never => {
|
|
106
|
+
throw new GatewayError(503, 'api_error', NO_ROUTE);
|
|
107
|
+
};
|
|
105
108
|
|
|
106
109
|
function requestedModel(body: Record<string, unknown>): string {
|
|
107
110
|
const model = body.model;
|
|
@@ -109,11 +112,6 @@ function requestedModel(body: Record<string, unknown>): string {
|
|
|
109
112
|
return model;
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
const standsInFor = (req: IncomingMessage): boolean => {
|
|
113
|
-
const metroKey = req.headers['x-metro-key'];
|
|
114
|
-
return typeof metroKey === 'string' && metroKey !== '' && req.headers.authorization === `Bearer ${metroKey}`;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
115
|
interface Payloads {
|
|
118
116
|
metro: Buffer;
|
|
119
117
|
asSent: Buffer | null;
|
|
@@ -148,25 +146,11 @@ async function refusedThinkingOff(upstream: Response, shaped: Record<string, unk
|
|
|
148
146
|
return true;
|
|
149
147
|
}
|
|
150
148
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
log.warn({ err: errMsg(err) }, 'gateway: could not list the models of the Claude Code login');
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
|
|
158
|
-
if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
|
|
159
|
-
if (conn.claude === null) return attempt(forwardedHeaders(req));
|
|
160
|
-
return withClaudeLogin(conn.claude, deps.claudeLogins ?? {}, (token) => {
|
|
161
|
-
listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
|
|
162
|
-
return attempt(loginHeaders(req, token));
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
|
|
167
|
-
if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
|
|
149
|
+
async function refusedSubscription(conn: Connection, upstream: Response, watch: Watch): Promise<void> {
|
|
150
|
+
if (conn.apiKey !== '' || upstream.status !== 401) return;
|
|
168
151
|
await upstream.body?.cancel();
|
|
169
|
-
|
|
152
|
+
watch.stop();
|
|
153
|
+
throw new SubscriptionRefused(conn.label);
|
|
170
154
|
}
|
|
171
155
|
|
|
172
156
|
async function toAnthropic(
|
|
@@ -179,17 +163,8 @@ async function toAnthropic(
|
|
|
179
163
|
deps: GatewayDeps,
|
|
180
164
|
): Promise<void> {
|
|
181
165
|
const conn = route.connection;
|
|
182
|
-
const
|
|
183
|
-
const url = `${anthropicBase}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
184
|
-
const passthrough = conn.apiKey === '' && conn.claude === null;
|
|
185
|
-
if (passthrough && standsInFor(req))
|
|
186
|
-
throw new GatewayError(
|
|
187
|
-
403,
|
|
188
|
-
'permission_error',
|
|
189
|
-
"Claude Code on this machine has no Anthropic login of its own; route this agent to a model on the organization's Models page, or sign in a Claude login on the Model page",
|
|
190
|
-
);
|
|
166
|
+
const url = `${deps.anthropicBase ?? ANTHROPIC_API}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
191
167
|
const watch = watchUpstream(res);
|
|
192
|
-
if (passthrough) listLoginModels(req.headers, anthropicBase);
|
|
193
168
|
const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
|
|
194
169
|
fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
|
|
195
170
|
const attempt = async (base: Record<string, string>): Promise<Response> => {
|
|
@@ -197,12 +172,11 @@ async function toAnthropic(
|
|
|
197
172
|
const first = await shape();
|
|
198
173
|
return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
|
|
199
174
|
};
|
|
200
|
-
const upstream = await
|
|
201
|
-
await
|
|
175
|
+
const upstream = await attempt(conn.apiKey !== '' ? anthropicHeaders(req, conn.apiKey) : loginHeaders(req, conn.claudeToken));
|
|
176
|
+
await refusedSubscription(conn, upstream, watch);
|
|
202
177
|
noteRefusal(conn.label, route.model, upstream);
|
|
203
178
|
noteUsageHeaders('anthropic', conn.id, upstream.headers);
|
|
204
|
-
|
|
205
|
-
await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
|
|
179
|
+
await pipeResponse(upstream, res, watch, { ownCredential: true, scanner: new UsageScanner(conn.id) });
|
|
206
180
|
}
|
|
207
181
|
|
|
208
182
|
async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
|
|
@@ -289,13 +263,23 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
|
|
|
289
263
|
await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
|
|
290
264
|
}
|
|
291
265
|
|
|
266
|
+
async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: GatewayDeps): Promise<Refusal | null> {
|
|
267
|
+
try {
|
|
268
|
+
await attempt(req, held.res, ask, route, deps);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (!(err instanceof SubscriptionRefused)) throw err;
|
|
271
|
+
return { status: 401, headers: new Headers() };
|
|
272
|
+
}
|
|
273
|
+
return held.refusal();
|
|
274
|
+
}
|
|
275
|
+
|
|
292
276
|
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
|
|
293
277
|
const setup = deps.setup();
|
|
294
278
|
const raw = await readBody(req);
|
|
295
279
|
const parsed = parseJson(raw);
|
|
296
280
|
const sent = fitToolSearch(req, parsed);
|
|
297
281
|
const requested = requestedModel(sent);
|
|
298
|
-
const primary = resolveRoute(requested, setup) ??
|
|
282
|
+
const primary = resolveRoute(requested, setup) ?? noRoute();
|
|
299
283
|
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
|
|
300
284
|
const routes = routesToTry(chainOf(primary, setup, requested));
|
|
301
285
|
for (const [at, route] of routes.entries()) {
|
|
@@ -305,9 +289,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
305
289
|
await attempt(req, res, ask, route, deps);
|
|
306
290
|
return;
|
|
307
291
|
}
|
|
308
|
-
const
|
|
309
|
-
await attempt(req, held.res, ask, route, deps);
|
|
310
|
-
const refusal = held.refusal();
|
|
292
|
+
const refusal = await heldAttempt(req, holdLimitRefusal(res), ask, route, deps);
|
|
311
293
|
if (refusal === null) return;
|
|
312
294
|
noteRefused(route, refusal.status, refusal.headers, next);
|
|
313
295
|
}
|
|
@@ -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');
|