@stage-labs/metro 0.1.0-beta.163 → 0.1.0-beta.165
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +19 -19
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +40 -38
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +16 -15
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +66 -238
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +202 -141
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +195 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +70 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/served.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +25 -23
- package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +19 -15
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.165",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.165",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -49,6 +49,7 @@ import { pipeline } from 'node:stream/promises';
|
|
|
49
49
|
import {
|
|
50
50
|
claudeDir,
|
|
51
51
|
deleteClaudeSession,
|
|
52
|
+
deleteMemoryFile,
|
|
52
53
|
listClaudeProjects,
|
|
53
54
|
listClaudeSessions,
|
|
54
55
|
listMemory,
|
|
@@ -259,6 +260,7 @@ function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
|
|
|
259
260
|
const [head = '', item = ''] = rest;
|
|
260
261
|
if (rest.length !== 2) throw new ApiError('method not allowed', 405);
|
|
261
262
|
if (head === 'skills') return { deleted: deleteClaudeSkill(decodeURIComponent(item), dir) };
|
|
263
|
+
if (head === 'memory') return { deleted: deleteMemoryFile(projectOf(query), decodeURIComponent(item), dir) };
|
|
262
264
|
if (head !== 'sessions') throw new ApiError('method not allowed', 405);
|
|
263
265
|
deleteClaudeSession(projectOf(query), item, dir);
|
|
264
266
|
return { deleted: item };
|
|
@@ -351,6 +351,14 @@ export function writeMemoryFile(project: string, name: string, text: string, dir
|
|
|
351
351
|
return { name: file, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
export function deleteMemoryFile(project: string, name: string, dir = claudeDir()): string {
|
|
355
|
+
const file = safeName(name, MEMORY_RE, 'memory file name');
|
|
356
|
+
const path = join(memoryDir(project, dir), file);
|
|
357
|
+
if (!existsSync(path)) throw new ApiError('no such memory file', 404);
|
|
358
|
+
rmSync(path);
|
|
359
|
+
return file;
|
|
360
|
+
}
|
|
361
|
+
|
|
354
362
|
export function readMemoryFile(project: string, name: string, dir = claudeDir()): string {
|
|
355
363
|
const path = join(memoryDir(project, dir), safeName(name, MEMORY_RE, 'memory file name'));
|
|
356
364
|
if (!existsSync(path)) throw new ApiError('no such memory file', 404);
|
|
@@ -7,7 +7,7 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
7
7
|
import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
8
8
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
9
9
|
import { agentsDir, listAgentFiles } from '../agents/files.js';
|
|
10
|
-
import { notReady, readModelConfig } from '../gateway/model-config.js';
|
|
10
|
+
import { notReady, readModelConfig, routedConnection } from '../gateway/model-config.js';
|
|
11
11
|
import { claudeDir, listClaudeProjects } from './files.js';
|
|
12
12
|
import { claudeAccount, claudeInstalled } from './login.js';
|
|
13
13
|
import { trustFolder } from './onboarding.js';
|
|
@@ -105,7 +105,8 @@ function credentialReady(deps: SessionDeps): string | null {
|
|
|
105
105
|
if (signedIn()) return null;
|
|
106
106
|
try {
|
|
107
107
|
const cfg = readModelConfig(deps.agents ?? agentsDir());
|
|
108
|
-
|
|
108
|
+
const conn = routedConnection(cfg);
|
|
109
|
+
if (conn !== null && conn.provider !== 'anthropic' && notReady(cfg) === null) return null;
|
|
109
110
|
} catch (err) {
|
|
110
111
|
return `the Model page is not readable (${errMsg(err)})`;
|
|
111
112
|
}
|
|
@@ -7,7 +7,7 @@ import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
|
7
7
|
import { agentsDir } from '../agents/files.js';
|
|
8
8
|
import { claudeDir } from './files.js';
|
|
9
9
|
import { stagedMarketplaceDir } from './plugin-install.js';
|
|
10
|
-
import { readModelConfig, type ModelConfig } from '../gateway/model-config.js';
|
|
10
|
+
import { readModelConfig, routedConnection, type ModelConfig } from '../gateway/model-config.js';
|
|
11
11
|
|
|
12
12
|
export const PRIVACY_ENV: Record<string, string> = {
|
|
13
13
|
DISABLE_TELEMETRY: '1',
|
|
@@ -222,9 +222,9 @@ const MODELS_KEY = 'availableModels';
|
|
|
222
222
|
const ENFORCE_KEY = 'enforceAvailableModels';
|
|
223
223
|
|
|
224
224
|
export function routeOf(cfg: ModelConfig): string | null {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return
|
|
225
|
+
const conn = routedConnection(cfg);
|
|
226
|
+
if (conn === null || conn.provider === 'anthropic' || conn.model === '') return null;
|
|
227
|
+
return `${conn.provider}:${conn.model}`;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
function withAvailableModels(settings: Record<string, unknown>, route: string | null, metroWrote: boolean): Record<string, unknown> {
|
|
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import { log } from '@metro-labs/core/log';
|
|
3
3
|
import { EventStreamDecoder, type EventStreamMessage } from './eventstream.js';
|
|
4
4
|
import { errorFrame, GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { Connection } from './model-config.js';
|
|
6
6
|
import { UsageScanner } from './usage.js';
|
|
7
7
|
|
|
8
8
|
const ANTHROPIC_VERSION = 'bedrock-2023-05-31';
|
|
@@ -18,7 +18,7 @@ export interface Adaptations {
|
|
|
18
18
|
export const freshAdaptations = (): Adaptations => ({ fields: new Set(), dropBetas: false });
|
|
19
19
|
|
|
20
20
|
export interface BedrockUpstream {
|
|
21
|
-
settings:
|
|
21
|
+
settings: Connection;
|
|
22
22
|
base: string;
|
|
23
23
|
learned: Adaptations;
|
|
24
24
|
watch: Watch;
|
|
@@ -253,7 +253,7 @@ export async function bedrockCount(
|
|
|
253
253
|
res.end(JSON.stringify({ input_tokens: counted ?? estimateTokens(rewritten.body) }));
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
export function assertBedrockReady(settings:
|
|
256
|
+
export function assertBedrockReady(settings: Connection): void {
|
|
257
257
|
if (settings.apiKey === '' || settings.region === '')
|
|
258
258
|
throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key and a region: add them on the Model page.');
|
|
259
259
|
}
|
|
@@ -7,7 +7,7 @@ import { refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
|
|
|
7
7
|
import { assembleMessage, CodexEventTranslator, parseEvent, SseParser } from './codex-stream.js';
|
|
8
8
|
import { ToolNames, toResponsesRequest } from './codex-translate.js';
|
|
9
9
|
import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
10
|
-
import type {
|
|
10
|
+
import type { Connection } from './model-config.js';
|
|
11
11
|
import { noteUsageHeaders, UsageScanner } from './usage.js';
|
|
12
12
|
|
|
13
13
|
export const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
|
|
@@ -20,7 +20,7 @@ export interface CodexDeps {
|
|
|
20
20
|
base?: string;
|
|
21
21
|
issuer?: string;
|
|
22
22
|
fetchImpl?: typeof fetch;
|
|
23
|
-
save: (tokens: CodexTokens) => void;
|
|
23
|
+
save: (id: string, tokens: CodexTokens) => void;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export interface CodexState {
|
|
@@ -57,13 +57,13 @@ function headersFor(tokens: CodexTokens, sessionId: string): Record<string, stri
|
|
|
57
57
|
|
|
58
58
|
const newerThan = (a: CodexTokens, b: CodexTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
|
|
59
59
|
|
|
60
|
-
function refreshed(tokens: CodexTokens, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
|
|
60
|
+
function refreshed(id: string, tokens: CodexTokens, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
|
|
61
61
|
const latest = state.latest;
|
|
62
62
|
if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
|
|
63
63
|
if (state.refreshing !== null) return state.refreshing;
|
|
64
64
|
const run = refreshTokens(tokens, deps.issuer, deps.fetchImpl)
|
|
65
65
|
.then((fresh) => {
|
|
66
|
-
deps.save(fresh);
|
|
66
|
+
deps.save(id, fresh);
|
|
67
67
|
state.latest = fresh;
|
|
68
68
|
return fresh;
|
|
69
69
|
})
|
|
@@ -77,10 +77,10 @@ function refreshed(tokens: CodexTokens, deps: CodexDeps, state: CodexState): Pro
|
|
|
77
77
|
return run;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
export function currentTokens(
|
|
81
|
-
const tokens =
|
|
80
|
+
export function currentTokens(conn: Connection, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
|
|
81
|
+
const tokens = conn.codex;
|
|
82
82
|
if (tokens === null) throw new GatewayError(400, 'invalid_request_error', 'Codex is not connected: sign in on the Model page.');
|
|
83
|
-
return tokensStale(tokens) ? refreshed(tokens, deps, state) : Promise.resolve(tokens);
|
|
83
|
+
return tokensStale(tokens) ? refreshed(conn.id, tokens, deps, state) : Promise.resolve(tokens);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function errorKind(status: number): string {
|
|
@@ -116,18 +116,18 @@ function send(call: Call, tokens: CodexTokens): Promise<Response> {
|
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
async function reach(call: Call,
|
|
120
|
-
let tokens = await currentTokens(
|
|
119
|
+
async function reach(call: Call, conn: Connection, state: CodexState): Promise<Response> {
|
|
120
|
+
let tokens = await currentTokens(conn, call.deps, state);
|
|
121
121
|
let upstream = await send(call, tokens);
|
|
122
122
|
if (upstream.status === 401) {
|
|
123
123
|
await upstream.body?.cancel();
|
|
124
|
-
tokens = await refreshed(tokens, call.deps, state);
|
|
124
|
+
tokens = await refreshed(conn.id, tokens, call.deps, state);
|
|
125
125
|
upstream = await send(call, tokens);
|
|
126
126
|
}
|
|
127
127
|
return upstream;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
async function relayStream(upstream: Response, res: ServerResponse, model: string, names: ToolNames, watch: Watch): Promise<void> {
|
|
130
|
+
async function relayStream(upstream: Response, res: ServerResponse, model: string, names: ToolNames, watch: Watch, key: string): Promise<void> {
|
|
131
131
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
132
132
|
const translator = new CodexEventTranslator(model, (name) => names.restore(name));
|
|
133
133
|
const body = upstream.body;
|
|
@@ -136,7 +136,7 @@ async function relayStream(upstream: Response, res: ServerResponse, model: strin
|
|
|
136
136
|
return;
|
|
137
137
|
}
|
|
138
138
|
const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
|
|
139
|
-
const scanner = new UsageScanner(
|
|
139
|
+
const scanner = new UsageScanner(key);
|
|
140
140
|
const emit = (frames: string): void => {
|
|
141
141
|
scanner.feed(frames);
|
|
142
142
|
res.write(frames);
|
|
@@ -166,7 +166,7 @@ async function relayStream(upstream: Response, res: ServerResponse, model: strin
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
async function relayWhole(upstream: Response, res: ServerResponse, model: string, names: ToolNames): Promise<void> {
|
|
169
|
+
async function relayWhole(upstream: Response, res: ServerResponse, model: string, names: ToolNames, key: string): Promise<void> {
|
|
170
170
|
const translator = new CodexEventTranslator(model, (name) => names.restore(name));
|
|
171
171
|
let frames = '';
|
|
172
172
|
const parser = new SseParser();
|
|
@@ -175,7 +175,7 @@ async function relayWhole(upstream: Response, res: ServerResponse, model: string
|
|
|
175
175
|
if (parsed !== null) frames += translator.push(parsed.event, parsed.data);
|
|
176
176
|
}
|
|
177
177
|
frames += translator.close();
|
|
178
|
-
const scanner = new UsageScanner(
|
|
178
|
+
const scanner = new UsageScanner(key);
|
|
179
179
|
scanner.feed(frames);
|
|
180
180
|
scanner.done();
|
|
181
181
|
const message = assembleMessage(frames);
|
|
@@ -189,21 +189,21 @@ export async function codexMessages(
|
|
|
189
189
|
res: ServerResponse,
|
|
190
190
|
body: Record<string, unknown>,
|
|
191
191
|
model: string,
|
|
192
|
-
|
|
192
|
+
conn: Connection,
|
|
193
193
|
deps: CodexDeps,
|
|
194
194
|
state: CodexState,
|
|
195
195
|
watch: Watch,
|
|
196
196
|
): Promise<void> {
|
|
197
197
|
const call: Call = { body, model, sessionId: sessionIdOf(req), watch, deps, names: new ToolNames() };
|
|
198
|
-
const upstream = await reach(call,
|
|
199
|
-
noteUsageHeaders('codex', upstream.headers);
|
|
198
|
+
const upstream = await reach(call, conn, state);
|
|
199
|
+
noteUsageHeaders('codex', conn.id, upstream.headers);
|
|
200
200
|
if (!upstream.ok) {
|
|
201
201
|
const text = await upstream.text();
|
|
202
202
|
sendError(res, providerStatus(upstream.status), errorKind(upstream.status), upstreamMessage(text, `Codex answered ${String(upstream.status)}`));
|
|
203
203
|
return;
|
|
204
204
|
}
|
|
205
|
-
if (body.stream === true) await relayStream(upstream, res, model, call.names, watch);
|
|
206
|
-
else await relayWhole(upstream, res, model, call.names);
|
|
205
|
+
if (body.stream === true) await relayStream(upstream, res, model, call.names, watch, conn.id);
|
|
206
|
+
else await relayWhole(upstream, res, model, call.names, conn.id);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
export function codexCount(res: ServerResponse, body: Record<string, unknown>): void {
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type Adaptations,
|
|
11
11
|
} from './bedrock.js';
|
|
12
12
|
import { anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
|
|
13
|
-
import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type ModelConfig, type Route } from './model-config.js';
|
|
13
|
+
import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
|
|
14
14
|
import { codexCount, codexMessages, freshCodexState } from './codex.js';
|
|
15
15
|
import { freshGeminiState, geminiCount, geminiMessages } from './gemini.js';
|
|
16
16
|
import type { GeminiDeps } from './gemini.js';
|
|
@@ -52,12 +52,12 @@ export function resetGatewayState(): void {
|
|
|
52
52
|
Object.assign(geminiState, freshGeminiState());
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
const saveCodexTokens = (tokens: CodexTokens): void => {
|
|
56
|
-
writeModelConfig(setCodexAuth(readModelConfig(), tokens));
|
|
55
|
+
const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
|
|
56
|
+
writeModelConfig(setCodexAuth(readModelConfig(), id, tokens));
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
const saveGeminiTokens = (tokens: GeminiTokens): void => {
|
|
60
|
-
writeModelConfig(setGeminiAuth(readModelConfig(), tokens));
|
|
59
|
+
const saveGeminiTokens = (id: string, tokens: GeminiTokens): void => {
|
|
60
|
+
writeModelConfig(setGeminiAuth(readModelConfig(), id, tokens));
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
const keyOf = (req: IncomingMessage): string => {
|
|
@@ -68,18 +68,16 @@ const keyOf = (req: IncomingMessage): string => {
|
|
|
68
68
|
const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
|
|
69
69
|
|
|
70
70
|
function modelsBody(cfg: ModelConfig): Record<string, unknown> {
|
|
71
|
-
const data
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (cfg.openrouter.model !== '')
|
|
75
|
-
data.push({ id: `openrouter:${cfg.openrouter.model}`, display_name: `OpenRouter · ${cfg.openrouter.model}`, description: 'Through metro, billed to OpenRouter' });
|
|
76
|
-
if (cfg.codex.model !== '')
|
|
77
|
-
data.push({ id: `codex:${cfg.codex.model}`, display_name: `Codex · ${cfg.codex.model}`, description: 'Through metro, on your ChatGPT subscription' });
|
|
78
|
-
if (cfg.gemini.model !== '')
|
|
79
|
-
data.push({ id: `gemini:${cfg.gemini.model}`, display_name: `Gemini · ${cfg.gemini.model}`, description: 'Through metro, on your Google account' });
|
|
71
|
+
const data = cfg.connections
|
|
72
|
+
.filter((c) => c.provider !== 'anthropic' && c.model !== '')
|
|
73
|
+
.map((c) => ({ id: `${c.provider}:${c.model}`, display_name: `${c.label} · ${c.model}`, description: 'Through metro' }));
|
|
80
74
|
return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
|
|
81
75
|
}
|
|
82
76
|
|
|
77
|
+
const PASSTHROUGH: Connection = { id: 'passthrough', provider: 'anthropic', label: 'Claude Code login', model: '', apiKey: '', region: '', zdr: false, codex: null, gemini: null };
|
|
78
|
+
|
|
79
|
+
const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
|
|
80
|
+
|
|
83
81
|
function requestedModel(body: Record<string, unknown>): string {
|
|
84
82
|
const model = body.model;
|
|
85
83
|
if (typeof model !== 'string' || model === '') throw new GatewayError(400, 'invalid_request_error', 'model is required');
|
|
@@ -99,10 +97,11 @@ async function toAnthropic(
|
|
|
99
97
|
route: Route,
|
|
100
98
|
deps: GatewayDeps,
|
|
101
99
|
): Promise<void> {
|
|
100
|
+
const conn = route.connection;
|
|
102
101
|
const explicit = typeof body.model === 'string' && body.model !== route.model;
|
|
103
102
|
const payload = explicit ? Buffer.from(JSON.stringify({ ...body, model: route.model })) : raw;
|
|
104
103
|
const url = `${deps.anthropicBase ?? ANTHROPIC_BASE}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
105
|
-
const key =
|
|
104
|
+
const key = conn.apiKey;
|
|
106
105
|
if (key === '' && standsInFor(req))
|
|
107
106
|
throw new GatewayError(
|
|
108
107
|
403,
|
|
@@ -117,9 +116,9 @@ async function toAnthropic(
|
|
|
117
116
|
signal: watch.signal,
|
|
118
117
|
redirect: 'manual',
|
|
119
118
|
});
|
|
120
|
-
noteRefusal(
|
|
121
|
-
noteUsageHeaders('anthropic', upstream.headers);
|
|
122
|
-
const scanner = new UsageScanner(
|
|
119
|
+
noteRefusal(conn.label, route.model, upstream);
|
|
120
|
+
noteUsageHeaders('anthropic', conn.id, upstream.headers);
|
|
121
|
+
const scanner = new UsageScanner(conn.id);
|
|
123
122
|
await pipeResponse(upstream, res, watch, key === '' ? { scanner } : { ownCredential: true, scanner });
|
|
124
123
|
}
|
|
125
124
|
|
|
@@ -131,12 +130,13 @@ async function toOpenRouter(
|
|
|
131
130
|
cfg: ModelConfig,
|
|
132
131
|
deps: GatewayDeps,
|
|
133
132
|
): Promise<void> {
|
|
134
|
-
const
|
|
133
|
+
const conn = route.connection;
|
|
134
|
+
const reason = notReady(cfg, conn);
|
|
135
135
|
if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
|
|
136
136
|
const headers: Record<string, string> = {
|
|
137
137
|
'content-type': 'application/json',
|
|
138
138
|
accept: req.headers.accept ?? 'application/json',
|
|
139
|
-
authorization: `Bearer ${
|
|
139
|
+
authorization: `Bearer ${conn.apiKey}`,
|
|
140
140
|
'anthropic-version': typeof req.headers['anthropic-version'] === 'string' ? req.headers['anthropic-version'] : '2023-06-01',
|
|
141
141
|
'http-referer': 'https://metro.box',
|
|
142
142
|
'x-title': 'metro',
|
|
@@ -147,12 +147,12 @@ async function toOpenRouter(
|
|
|
147
147
|
const upstream = await fetch(`${deps.openrouterBase ?? OPENROUTER_BASE}${MESSAGES}`, {
|
|
148
148
|
method: 'POST',
|
|
149
149
|
headers,
|
|
150
|
-
body: JSON.stringify(openrouterBody(body, route.model,
|
|
150
|
+
body: JSON.stringify(openrouterBody(body, route.model, conn.zdr)),
|
|
151
151
|
signal: watch.signal,
|
|
152
152
|
redirect: 'manual',
|
|
153
153
|
});
|
|
154
|
-
noteRefusal(
|
|
155
|
-
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(
|
|
154
|
+
noteRefusal(conn.label, route.model, upstream);
|
|
155
|
+
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(conn.id) });
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
const thinkingOff = (body: Record<string, unknown>): boolean => isRecord(body.thinking) && body.thinking.type === 'disabled';
|
|
@@ -165,41 +165,43 @@ export function openrouterBody(body: Record<string, unknown>, model: string, zdr
|
|
|
165
165
|
return { ...sent, provider: { ...provider, zdr: true } };
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
function noteRefusal(
|
|
169
|
-
if (!upstream.ok) log.warn({
|
|
168
|
+
function noteRefusal(connection: string, model: string, upstream: Response): void {
|
|
169
|
+
if (!upstream.ok) log.warn({ connection, model, status: upstream.status }, 'gateway: the provider refused the request');
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
async function toSubscription(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route,
|
|
173
|
-
|
|
172
|
+
async function toSubscription(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
|
|
173
|
+
const conn = route.connection;
|
|
174
|
+
if (conn.provider === 'gemini') {
|
|
174
175
|
if (path === COUNT) geminiCount(res, body);
|
|
175
|
-
else await geminiMessages(req, res, body, route.model,
|
|
176
|
+
else await geminiMessages(req, res, body, route.model, conn, { save: saveGeminiTokens, ...deps.gemini }, geminiState, watchUpstream(res));
|
|
176
177
|
return;
|
|
177
178
|
}
|
|
178
179
|
if (path === COUNT) codexCount(res, body);
|
|
179
|
-
else await codexMessages(req, res, body, route.model,
|
|
180
|
+
else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, codexState, watchUpstream(res));
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
|
|
183
184
|
const cfg = deps.config();
|
|
184
185
|
const raw = await readBody(req);
|
|
185
186
|
const body = parseJson(raw);
|
|
186
|
-
const route = resolveRoute(requestedModel(body), cfg);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
187
|
+
const route = resolveRoute(requestedModel(body), cfg) ?? passthrough(body);
|
|
188
|
+
const conn = route.connection;
|
|
189
|
+
log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
|
|
190
|
+
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
|
|
191
|
+
if (conn.provider === 'bedrock') {
|
|
192
|
+
assertBedrockReady(conn);
|
|
193
|
+
const up = { settings: conn, base: deps.bedrockBase ?? bedrockBase(conn.region), learned, watch: watchUpstream(res) };
|
|
192
194
|
if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
|
|
193
195
|
else await bedrockMessages(req, res, body, route.model, up);
|
|
194
196
|
return;
|
|
195
197
|
}
|
|
196
|
-
if (
|
|
198
|
+
if (conn.provider === 'openrouter') {
|
|
197
199
|
if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
|
|
198
200
|
await toOpenRouter(req, res, body, route, cfg, deps);
|
|
199
201
|
return;
|
|
200
202
|
}
|
|
201
|
-
if (
|
|
202
|
-
await toSubscription(req, res, path, body, route,
|
|
203
|
+
if (conn.provider === 'codex' || conn.provider === 'gemini') {
|
|
204
|
+
await toSubscription(req, res, path, body, route, deps);
|
|
203
205
|
return;
|
|
204
206
|
}
|
|
205
207
|
await toAnthropic(req, res, raw, body, route, deps);
|
|
@@ -8,7 +8,7 @@ import { toGeminiRequest } from './gemini-translate.js';
|
|
|
8
8
|
import { ToolNames } from './codex-translate.js';
|
|
9
9
|
import { assembleMessage, SseParser } from './codex-stream.js';
|
|
10
10
|
import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
11
|
-
import type {
|
|
11
|
+
import type { Connection } from './model-config.js';
|
|
12
12
|
import { UsageScanner } from './usage.js';
|
|
13
13
|
|
|
14
14
|
const PING_MS = 25_000;
|
|
@@ -18,7 +18,7 @@ export interface GeminiDeps {
|
|
|
18
18
|
base?: Bases;
|
|
19
19
|
tokenBase?: string;
|
|
20
20
|
fetchImpl?: typeof fetch;
|
|
21
|
-
save: (tokens: GeminiTokens) => void;
|
|
21
|
+
save: (id: string, tokens: GeminiTokens) => void;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface GeminiState {
|
|
@@ -30,13 +30,13 @@ export const freshGeminiState = (): GeminiState => ({ refreshing: null, latest:
|
|
|
30
30
|
|
|
31
31
|
const newerThan = (a: GeminiTokens, b: GeminiTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
|
|
32
32
|
|
|
33
|
-
function refreshed(tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
|
|
33
|
+
function refreshed(id: string, tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
|
|
34
34
|
const latest = state.latest;
|
|
35
35
|
if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
|
|
36
36
|
if (state.refreshing !== null) return state.refreshing;
|
|
37
37
|
const run = refreshTokens(tokens, deps.tokenBase, deps.fetchImpl)
|
|
38
38
|
.then((fresh) => {
|
|
39
|
-
deps.save(fresh);
|
|
39
|
+
deps.save(id, fresh);
|
|
40
40
|
state.latest = fresh;
|
|
41
41
|
return fresh;
|
|
42
42
|
})
|
|
@@ -50,10 +50,10 @@ function refreshed(tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState):
|
|
|
50
50
|
return run;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function currentGeminiTokens(
|
|
54
|
-
const tokens =
|
|
53
|
+
export function currentGeminiTokens(conn: Connection, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
|
|
54
|
+
const tokens = conn.gemini;
|
|
55
55
|
if (tokens === null) throw new GatewayError(400, 'invalid_request_error', 'Gemini is not connected: sign in with Google on the Model page.');
|
|
56
|
-
return tokensStale(tokens) ? refreshed(tokens, deps, state) : Promise.resolve(tokens);
|
|
56
|
+
return tokensStale(tokens) ? refreshed(conn.id, tokens, deps, state) : Promise.resolve(tokens);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function errorKind(status: number): string {
|
|
@@ -76,6 +76,7 @@ interface Call {
|
|
|
76
76
|
watch: Watch;
|
|
77
77
|
deps: GeminiDeps;
|
|
78
78
|
names: ToolNames;
|
|
79
|
+
conn: Connection;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
const headersFor = (token: string, stream: boolean): Record<string, string> => ({
|
|
@@ -140,12 +141,12 @@ export function refusalMessage(text: string, status: number): string {
|
|
|
140
141
|
return notes.length === 0 ? message : `${message} (${notes.join('; ')})`;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
async function reach(call: Call,
|
|
144
|
-
let tokens = await currentGeminiTokens(
|
|
144
|
+
async function reach(call: Call, conn: Connection, state: GeminiState, stream: boolean): Promise<Response> {
|
|
145
|
+
let tokens = await currentGeminiTokens(conn, call.deps, state);
|
|
145
146
|
let upstream = await send(call, tokens, stream);
|
|
146
147
|
if (upstream.status === 401) {
|
|
147
148
|
await upstream.body?.cancel();
|
|
148
|
-
tokens = await refreshed(tokens, call.deps, state);
|
|
149
|
+
tokens = await refreshed(conn.id, tokens, call.deps, state);
|
|
149
150
|
upstream = await send(call, tokens, stream);
|
|
150
151
|
}
|
|
151
152
|
return upstream;
|
|
@@ -210,7 +211,7 @@ async function relayStream(upstream: Response, res: ServerResponse, call: Call):
|
|
|
210
211
|
return;
|
|
211
212
|
}
|
|
212
213
|
const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
|
|
213
|
-
const scanner = new UsageScanner(
|
|
214
|
+
const scanner = new UsageScanner(call.conn.id);
|
|
214
215
|
const emit = (frames: string): void => {
|
|
215
216
|
if (frames === '') return;
|
|
216
217
|
scanner.feed(frames);
|
|
@@ -245,7 +246,7 @@ async function relayWhole(upstream: Response, res: ServerResponse, call: Call):
|
|
|
245
246
|
const translator = new GeminiStreamTranslator(call.model, (name) => call.names.restore(name));
|
|
246
247
|
const data = parseData(await upstream.text());
|
|
247
248
|
const frames = (data === null ? '' : translator.push(data)) + translator.close();
|
|
248
|
-
const scanner = new UsageScanner(
|
|
249
|
+
const scanner = new UsageScanner(call.conn.id);
|
|
249
250
|
scanner.feed(frames);
|
|
250
251
|
scanner.done();
|
|
251
252
|
const message = assembleMessage(frames);
|
|
@@ -259,14 +260,14 @@ export async function geminiMessages(
|
|
|
259
260
|
res: ServerResponse,
|
|
260
261
|
body: Record<string, unknown>,
|
|
261
262
|
model: string,
|
|
262
|
-
|
|
263
|
+
conn: Connection,
|
|
263
264
|
deps: GeminiDeps,
|
|
264
265
|
state: GeminiState,
|
|
265
266
|
watch: Watch,
|
|
266
267
|
): Promise<void> {
|
|
267
268
|
const stream = body.stream === true;
|
|
268
|
-
const call: Call = { body, model, promptId: promptIdOf(req), watch, deps, names: new ToolNames() };
|
|
269
|
-
const upstream = await reach(call,
|
|
269
|
+
const call: Call = { body, model, promptId: promptIdOf(req), watch, deps, names: new ToolNames(), conn };
|
|
270
|
+
const upstream = await reach(call, conn, state, stream);
|
|
270
271
|
if (!upstream.ok) {
|
|
271
272
|
const text = await upstream.text();
|
|
272
273
|
log.warn({ provider: 'gemini', model, status: upstream.status, body: text.slice(0, REFUSAL_LOG_MAX) }, 'gateway: the provider refused the request');
|