@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
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/dist/route.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { agentsDir } from './local.js';
|
|
4
|
-
const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini']);
|
|
4
|
+
const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini', 'routeway']);
|
|
5
5
|
const ANTHROPIC = 'anthropic';
|
|
6
6
|
const text = (value) => (typeof value === 'string' ? value : '');
|
|
7
7
|
function routedIn(cfg) {
|
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,16 +39,15 @@ 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';
|
|
46
|
+
import { refusedWithPage, ROUTEWAY_BASE, routewayHeaders } from './routeway.js';
|
|
43
47
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
44
48
|
import { forgetServed, noteServed } from './served.js';
|
|
45
49
|
import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
46
50
|
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
51
|
import { orgModels } from './org-models.js';
|
|
50
52
|
|
|
51
53
|
const GATEWAY_PREFIX = '/gateway';
|
|
@@ -53,6 +55,8 @@ const MESSAGES = '/v1/messages';
|
|
|
53
55
|
const COUNT = '/v1/messages/count_tokens';
|
|
54
56
|
const MODELS = '/v1/models';
|
|
55
57
|
const HELLO = '/api/hello';
|
|
58
|
+
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
59
|
+
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
60
|
|
|
57
61
|
export interface GatewayDeps {
|
|
58
62
|
setup: () => ModelSetup;
|
|
@@ -60,8 +64,8 @@ export interface GatewayDeps {
|
|
|
60
64
|
anthropicBase?: string;
|
|
61
65
|
bedrockBase?: string;
|
|
62
66
|
openrouterBase?: string;
|
|
67
|
+
routewayBase?: string;
|
|
63
68
|
codex?: Partial<CodexDeps>;
|
|
64
|
-
claudeLogins?: ClaudeLoginDeps;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
const learned: Adaptations = freshAdaptations();
|
|
@@ -73,7 +77,6 @@ export function resetGatewayState(): void {
|
|
|
73
77
|
learned.fields.clear();
|
|
74
78
|
learned.dropBetas = false;
|
|
75
79
|
forgetLearnedThinking();
|
|
76
|
-
forgetClaudeTokens();
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
|
|
@@ -101,7 +104,9 @@ function modelsBody(setup: ModelSetup): Record<string, unknown> {
|
|
|
101
104
|
return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
const
|
|
107
|
+
const noRoute = (): never => {
|
|
108
|
+
throw new GatewayError(503, 'api_error', NO_ROUTE);
|
|
109
|
+
};
|
|
105
110
|
|
|
106
111
|
function requestedModel(body: Record<string, unknown>): string {
|
|
107
112
|
const model = body.model;
|
|
@@ -109,24 +114,27 @@ function requestedModel(body: Record<string, unknown>): string {
|
|
|
109
114
|
return model;
|
|
110
115
|
}
|
|
111
116
|
|
|
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
117
|
interface Payloads {
|
|
118
118
|
metro: Buffer;
|
|
119
119
|
asSent: Buffer | null;
|
|
120
120
|
bound: boolean;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
interface Shaped {
|
|
124
|
+
raw: Buffer;
|
|
125
|
+
sent: Record<string, unknown>;
|
|
126
|
+
shaped: Record<string, unknown>;
|
|
127
|
+
bind: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function anthropicPayloads({ raw, sent, shaped, bind }: Shaped, model: string): Payloads {
|
|
124
131
|
const runnable = withThinkingFor(sent, model);
|
|
125
132
|
const rewrite = runnable !== sent || (typeof sent.model === 'string' && sent.model !== model);
|
|
126
133
|
const asSent = rewrite ? Buffer.from(JSON.stringify({ ...runnable, model })) : raw;
|
|
127
|
-
const
|
|
134
|
+
const fitted = withThinkingFor(shaped, model);
|
|
135
|
+
const bound = bind ? withBlockBinding(fitted) : fitted;
|
|
128
136
|
if (bound === sent) return { metro: asSent, asSent: null, bound: false };
|
|
129
|
-
return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bound !== shaped };
|
|
137
|
+
return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bind && bound !== shaped };
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
type Send = (payload: Buffer, headers: Record<string, string>) => Promise<Response>;
|
|
@@ -148,61 +156,43 @@ async function refusedThinkingOff(upstream: Response, shaped: Record<string, unk
|
|
|
148
156
|
return true;
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
async function refusedSubscription(conn: Connection, upstream: Response, watch: Watch): Promise<void> {
|
|
160
|
+
if (conn.apiKey !== '' || upstream.status !== 401) return;
|
|
161
|
+
await upstream.body?.cancel();
|
|
162
|
+
watch.stop();
|
|
163
|
+
throw new SubscriptionRefused(conn.label);
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return
|
|
161
|
-
listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
|
|
162
|
-
return attempt(loginHeaders(req, token));
|
|
163
|
-
});
|
|
166
|
+
async function shapedAttempt(send: Send, base: Record<string, string>, body: Shaped, route: Route): Promise<Response> {
|
|
167
|
+
const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(body, route.model), route.connection.label, route.model);
|
|
168
|
+
const first = await shape();
|
|
169
|
+
return (await refusedThinkingOff(first, body.shaped, route.model)) ? shape() : first;
|
|
164
170
|
}
|
|
165
171
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
172
|
+
const sender = (url: string, watch: Watch): Send => (payload, headers) =>
|
|
173
|
+
fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
|
|
174
|
+
|
|
175
|
+
const upstreamPath = (req: IncomingMessage): string => (req.url ?? '').slice(GATEWAY_PREFIX.length);
|
|
171
176
|
|
|
172
|
-
async function toAnthropic(
|
|
173
|
-
req: IncomingMessage,
|
|
174
|
-
res: ServerResponse,
|
|
175
|
-
raw: Buffer,
|
|
176
|
-
sent: Record<string, unknown>,
|
|
177
|
-
shaped: Record<string, unknown>,
|
|
178
|
-
route: Route,
|
|
179
|
-
deps: GatewayDeps,
|
|
180
|
-
): Promise<void> {
|
|
177
|
+
async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
|
|
181
178
|
const conn = route.connection;
|
|
182
|
-
const anthropicBase = deps.anthropicBase ?? ANTHROPIC_API;
|
|
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
|
-
);
|
|
191
179
|
const watch = watchUpstream(res);
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
const attempt = async (base: Record<string, string>): Promise<Response> => {
|
|
196
|
-
const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
|
|
197
|
-
const first = await shape();
|
|
198
|
-
return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
|
|
199
|
-
};
|
|
200
|
-
const upstream = await sendAnthropic(req, conn, anthropicBase, attempt, deps);
|
|
201
|
-
await refusedLogin(conn, upstream);
|
|
180
|
+
const send = sender(`${deps.anthropicBase ?? ANTHROPIC_API}${upstreamPath(req)}`, watch);
|
|
181
|
+
const upstream = await shapedAttempt(send, conn.apiKey !== '' ? anthropicHeaders(req, conn.apiKey) : loginHeaders(req, conn.claudeToken), body, route);
|
|
182
|
+
await refusedSubscription(conn, upstream, watch);
|
|
202
183
|
noteRefusal(conn.label, route.model, upstream);
|
|
203
184
|
noteUsageHeaders('anthropic', conn.id, upstream.headers);
|
|
204
|
-
|
|
205
|
-
|
|
185
|
+
await pipeResponse(upstream, res, watch, { ownCredential: true, scanner: new UsageScanner(conn.id) });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
|
|
189
|
+
const conn = route.connection;
|
|
190
|
+
const watch = watchUpstream(res);
|
|
191
|
+
const send = sender(`${deps.routewayBase ?? ROUTEWAY_BASE}${upstreamPath(req)}`, watch);
|
|
192
|
+
const upstream = await shapedAttempt(send, routewayHeaders(req, conn.apiKey), { ...body, bind: false }, route);
|
|
193
|
+
noteRefusal(conn.label, route.model, upstream);
|
|
194
|
+
await refusedWithPage(upstream, watch);
|
|
195
|
+
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(conn.id) });
|
|
206
196
|
}
|
|
207
197
|
|
|
208
198
|
async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
|
|
@@ -286,7 +276,18 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
|
|
|
286
276
|
await toCodex(req, res, path, body, route, deps);
|
|
287
277
|
return;
|
|
288
278
|
}
|
|
289
|
-
|
|
279
|
+
const shaped: Shaped = { raw: bytesOf(ask.raw, ask.parsed, ask.sent), sent: ask.sent, shaped: body, bind: true };
|
|
280
|
+
await (conn.provider === 'routeway' ? toRouteway : toAnthropic)(req, res, shaped, route, deps);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: GatewayDeps): Promise<Refusal | null> {
|
|
284
|
+
try {
|
|
285
|
+
await attempt(req, held.res, ask, route, deps);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (!(err instanceof SubscriptionRefused)) throw err;
|
|
288
|
+
return { status: 401, headers: new Headers() };
|
|
289
|
+
}
|
|
290
|
+
return held.refusal();
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
|
|
@@ -295,7 +296,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
295
296
|
const parsed = parseJson(raw);
|
|
296
297
|
const sent = fitToolSearch(req, parsed);
|
|
297
298
|
const requested = requestedModel(sent);
|
|
298
|
-
const primary = resolveRoute(requested, setup) ??
|
|
299
|
+
const primary = resolveRoute(requested, setup) ?? noRoute();
|
|
299
300
|
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
|
|
300
301
|
const routes = routesToTry(chainOf(primary, setup, requested));
|
|
301
302
|
for (const [at, route] of routes.entries()) {
|
|
@@ -305,9 +306,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
305
306
|
await attempt(req, res, ask, route, deps);
|
|
306
307
|
return;
|
|
307
308
|
}
|
|
308
|
-
const
|
|
309
|
-
await attempt(req, held.res, ask, route, deps);
|
|
310
|
-
const refusal = held.refusal();
|
|
309
|
+
const refusal = await heldAttempt(req, holdLimitRefusal(res), ask, route, deps);
|
|
311
310
|
if (refusal === null) return;
|
|
312
311
|
noteRefused(route, refusal.status, refusal.headers, next);
|
|
313
312
|
}
|