@stage-labs/metro 0.1.0-beta.164 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.164",
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.164",
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
- if (cfg.provider !== 'anthropic' && notReady(cfg) === null) return null;
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
- if (cfg.provider === 'anthropic') return null;
226
- const model = cfg[cfg.provider].model;
227
- return model === '' ? null : `${cfg.provider}:${model}`;
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 { BedrockSettings } from './model-config.js';
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: BedrockSettings;
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: BedrockSettings): void {
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 { ModelConfig } from './model-config.js';
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(cfg: ModelConfig, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
81
- const tokens = cfg.codex.auth;
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, cfg: ModelConfig, state: CodexState): Promise<Response> {
120
- let tokens = await currentTokens(cfg, call.deps, state);
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('codex');
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('codex');
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
- cfg: ModelConfig,
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, cfg, state);
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: Record<string, string>[] = [];
72
- if (cfg.bedrock.model !== '')
73
- data.push({ id: `bedrock:${cfg.bedrock.model}`, display_name: `Bedrock · ${cfg.bedrock.model}`, description: 'Through metro, billed to Amazon Bedrock' });
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 = deps.config().anthropic.apiKey;
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('anthropic', route.model, upstream);
121
- noteUsageHeaders('anthropic', upstream.headers);
122
- const scanner = new UsageScanner('anthropic');
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 reason = notReady(cfg, 'openrouter');
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 ${cfg.openrouter.apiKey}`,
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, cfg.openrouter.zdr)),
150
+ body: JSON.stringify(openrouterBody(body, route.model, conn.zdr)),
151
151
  signal: watch.signal,
152
152
  redirect: 'manual',
153
153
  });
154
- noteRefusal('openrouter', route.model, upstream);
155
- await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner('openrouter') });
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(provider: string, model: string, upstream: Response): void {
169
- if (!upstream.ok) log.warn({ provider, model, status: upstream.status }, 'gateway: the provider refused the request');
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, cfg: ModelConfig, deps: GatewayDeps): Promise<void> {
173
- if (route.provider === 'gemini') {
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, cfg, { save: saveGeminiTokens, ...deps.gemini }, geminiState, watchUpstream(res));
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, cfg, { save: saveCodexTokens, ...deps.codex }, codexState, watchUpstream(res));
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
- log.info({ route: routeLabel(route), path }, 'gateway: routing');
188
- if (path === MESSAGES) noteServed({ provider: route.provider, model: route.model, at: new Date().toISOString() });
189
- if (route.provider === 'bedrock') {
190
- assertBedrockReady(cfg.bedrock);
191
- const up = { settings: cfg.bedrock, base: deps.bedrockBase ?? bedrockBase(cfg.bedrock.region), learned, watch: watchUpstream(res) };
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 (route.provider === 'openrouter') {
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 (route.provider === 'codex' || route.provider === 'gemini') {
202
- await toSubscription(req, res, path, body, route, cfg, deps);
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 { ModelConfig } from './model-config.js';
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(cfg: ModelConfig, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
54
- const tokens = cfg.gemini.auth;
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, cfg: ModelConfig, state: GeminiState, stream: boolean): Promise<Response> {
144
- let tokens = await currentGeminiTokens(cfg, call.deps, state);
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('gemini');
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('gemini');
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
- cfg: ModelConfig,
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, cfg, state, stream);
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');