@stage-labs/metro 0.1.0-beta.164 → 0.1.0-beta.166

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.
Files changed (22) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/orchestrator.md +6 -0
  4. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +2 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +8 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -2
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +28 -13
  8. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +3 -3
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +19 -19
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +70 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +9 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +83 -51
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +3 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +35 -18
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +66 -238
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +202 -141
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +195 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +70 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
  20. package/runtime/node_modules/@metro-labs/daemon/src/gateway/served.ts +1 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +25 -23
  22. package/runtime/runtime.json +1 -1
@@ -9,8 +9,9 @@ import {
9
9
  freshAdaptations,
10
10
  type Adaptations,
11
11
  } from './bedrock.js';
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';
12
+ import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
13
+ import { BINDING_BETA, cappedEffort, effortToApply, plannedEffort, withBlockBinding, withEffort } from './effort.js';
14
+ import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
14
15
  import { codexCount, codexMessages, freshCodexState } from './codex.js';
15
16
  import { freshGeminiState, geminiCount, geminiMessages } from './gemini.js';
16
17
  import type { GeminiDeps } from './gemini.js';
@@ -52,12 +53,12 @@ export function resetGatewayState(): void {
52
53
  Object.assign(geminiState, freshGeminiState());
53
54
  }
54
55
 
55
- const saveCodexTokens = (tokens: CodexTokens): void => {
56
- writeModelConfig(setCodexAuth(readModelConfig(), tokens));
56
+ const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
57
+ writeModelConfig(setCodexAuth(readModelConfig(), id, tokens));
57
58
  };
58
59
 
59
- const saveGeminiTokens = (tokens: GeminiTokens): void => {
60
- writeModelConfig(setGeminiAuth(readModelConfig(), tokens));
60
+ const saveGeminiTokens = (id: string, tokens: GeminiTokens): void => {
61
+ writeModelConfig(setGeminiAuth(readModelConfig(), id, tokens));
61
62
  };
62
63
 
63
64
  const keyOf = (req: IncomingMessage): string => {
@@ -68,18 +69,16 @@ const keyOf = (req: IncomingMessage): string => {
68
69
  const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
69
70
 
70
71
  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' });
72
+ const data = cfg.connections
73
+ .filter((c) => c.provider !== 'anthropic' && c.model !== '')
74
+ .map((c) => ({ id: `${c.provider}:${c.model}`, display_name: `${c.label} · ${c.model}`, description: 'Through metro' }));
80
75
  return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
81
76
  }
82
77
 
78
+ const PASSTHROUGH: Connection = { id: 'passthrough', provider: 'anthropic', label: 'Claude Code login', model: '', apiKey: '', region: '', zdr: false, codex: null, gemini: null };
79
+
80
+ const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
81
+
83
82
  function requestedModel(body: Record<string, unknown>): string {
84
83
  const model = body.model;
85
84
  if (typeof model !== 'string' || model === '') throw new GatewayError(400, 'invalid_request_error', 'model is required');
@@ -91,18 +90,43 @@ const standsInFor = (req: IncomingMessage): boolean => {
91
90
  return typeof metroKey === 'string' && metroKey !== '' && req.headers.authorization === `Bearer ${metroKey}`;
92
91
  };
93
92
 
93
+ interface Payloads {
94
+ metro: Buffer;
95
+ asSent: Buffer | null;
96
+ bound: boolean;
97
+ }
98
+
99
+ function anthropicPayloads(raw: Buffer, sent: Record<string, unknown>, shaped: Record<string, unknown>, model: string): Payloads {
100
+ const rewrite = typeof sent.model === 'string' && sent.model !== model;
101
+ const asSent = rewrite ? Buffer.from(JSON.stringify({ ...sent, model })) : raw;
102
+ const bound = withBlockBinding(shaped);
103
+ if (bound === sent) return { metro: asSent, asSent: null, bound: false };
104
+ return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bound !== shaped };
105
+ }
106
+
107
+ type Send = (payload: Buffer, headers: Record<string, string>) => Promise<Response>;
108
+
109
+ async function asAnthropicWants(send: Send, base: Record<string, string>, payloads: Payloads, connection: string, model: string): Promise<Response> {
110
+ const { metro, asSent, bound } = payloads;
111
+ const upstream = await send(metro, bound ? addBeta(base, BINDING_BETA) : base);
112
+ if (upstream.status !== 400 || asSent === null) return upstream;
113
+ await upstream.body?.cancel();
114
+ log.warn({ connection, model }, 'gateway: Anthropic refused the request metro shaped, so it was sent again as Claude Code wrote it');
115
+ return send(asSent, base);
116
+ }
117
+
94
118
  async function toAnthropic(
95
119
  req: IncomingMessage,
96
120
  res: ServerResponse,
97
121
  raw: Buffer,
98
- body: Record<string, unknown>,
122
+ sent: Record<string, unknown>,
123
+ shaped: Record<string, unknown>,
99
124
  route: Route,
100
125
  deps: GatewayDeps,
101
126
  ): Promise<void> {
102
- const explicit = typeof body.model === 'string' && body.model !== route.model;
103
- const payload = explicit ? Buffer.from(JSON.stringify({ ...body, model: route.model })) : raw;
127
+ const conn = route.connection;
104
128
  const url = `${deps.anthropicBase ?? ANTHROPIC_BASE}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
105
- const key = deps.config().anthropic.apiKey;
129
+ const key = conn.apiKey;
106
130
  if (key === '' && standsInFor(req))
107
131
  throw new GatewayError(
108
132
  403,
@@ -110,16 +134,13 @@ async function toAnthropic(
110
134
  'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
111
135
  );
112
136
  const watch = watchUpstream(res);
113
- const upstream = await fetch(url, {
114
- method: 'POST',
115
- headers: key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key),
116
- body: new Uint8Array(payload),
117
- signal: watch.signal,
118
- redirect: 'manual',
119
- });
120
- noteRefusal('anthropic', route.model, upstream);
121
- noteUsageHeaders('anthropic', upstream.headers);
122
- const scanner = new UsageScanner('anthropic');
137
+ const base = key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key);
138
+ const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
139
+ fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
140
+ const upstream = await asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
141
+ noteRefusal(conn.label, route.model, upstream);
142
+ noteUsageHeaders('anthropic', conn.id, upstream.headers);
143
+ const scanner = new UsageScanner(conn.id);
123
144
  await pipeResponse(upstream, res, watch, key === '' ? { scanner } : { ownCredential: true, scanner });
124
145
  }
125
146
 
@@ -131,12 +152,13 @@ async function toOpenRouter(
131
152
  cfg: ModelConfig,
132
153
  deps: GatewayDeps,
133
154
  ): Promise<void> {
134
- const reason = notReady(cfg, 'openrouter');
155
+ const conn = route.connection;
156
+ const reason = notReady(cfg, conn);
135
157
  if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
136
158
  const headers: Record<string, string> = {
137
159
  'content-type': 'application/json',
138
160
  accept: req.headers.accept ?? 'application/json',
139
- authorization: `Bearer ${cfg.openrouter.apiKey}`,
161
+ authorization: `Bearer ${conn.apiKey}`,
140
162
  'anthropic-version': typeof req.headers['anthropic-version'] === 'string' ? req.headers['anthropic-version'] : '2023-06-01',
141
163
  'http-referer': 'https://metro.box',
142
164
  'x-title': 'metro',
@@ -147,12 +169,12 @@ async function toOpenRouter(
147
169
  const upstream = await fetch(`${deps.openrouterBase ?? OPENROUTER_BASE}${MESSAGES}`, {
148
170
  method: 'POST',
149
171
  headers,
150
- body: JSON.stringify(openrouterBody(body, route.model, cfg.openrouter.zdr)),
172
+ body: JSON.stringify(openrouterBody(body, route.model, conn.zdr)),
151
173
  signal: watch.signal,
152
174
  redirect: 'manual',
153
175
  });
154
- noteRefusal('openrouter', route.model, upstream);
155
- await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner('openrouter') });
176
+ noteRefusal(conn.label, route.model, upstream);
177
+ await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(conn.id) });
156
178
  }
157
179
 
158
180
  const thinkingOff = (body: Record<string, unknown>): boolean => isRecord(body.thinking) && body.thinking.type === 'disabled';
@@ -160,49 +182,59 @@ const thinkingOff = (body: Record<string, unknown>): boolean => isRecord(body.th
160
182
  export function openrouterBody(body: Record<string, unknown>, model: string, zdr: boolean): Record<string, unknown> {
161
183
  const sent: Record<string, unknown> = { ...body, model };
162
184
  if (thinkingOff(body)) delete sent.thinking;
185
+ const effort = effortToApply(body);
186
+ if (effort !== null) sent.reasoning = { ...(isRecord(body.reasoning) ? body.reasoning : {}), effort: cappedEffort(effort) };
163
187
  if (!zdr) return sent;
164
188
  const provider = isRecord(body.provider) ? body.provider : {};
165
189
  return { ...sent, provider: { ...provider, zdr: true } };
166
190
  }
167
191
 
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');
192
+ function noteRefusal(connection: string, model: string, upstream: Response): void {
193
+ if (!upstream.ok) log.warn({ connection, model, status: upstream.status }, 'gateway: the provider refused the request');
170
194
  }
171
195
 
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') {
196
+ async function toSubscription(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
197
+ const conn = route.connection;
198
+ if (conn.provider === 'gemini') {
174
199
  if (path === COUNT) geminiCount(res, body);
175
- else await geminiMessages(req, res, body, route.model, cfg, { save: saveGeminiTokens, ...deps.gemini }, geminiState, watchUpstream(res));
200
+ else await geminiMessages(req, res, body, route.model, conn, { save: saveGeminiTokens, ...deps.gemini }, geminiState, watchUpstream(res));
176
201
  return;
177
202
  }
178
203
  if (path === COUNT) codexCount(res, body);
179
- else await codexMessages(req, res, body, route.model, cfg, { save: saveCodexTokens, ...deps.codex }, codexState, watchUpstream(res));
204
+ else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, codexState, watchUpstream(res));
205
+ }
206
+
207
+ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
208
+ const effort = plannedEffort(req, sent);
209
+ return effort === null ? sent : withEffort(sent, effort);
180
210
  }
181
211
 
182
212
  async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
183
213
  const cfg = deps.config();
184
214
  const raw = await readBody(req);
185
- 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) };
215
+ const sent = parseJson(raw);
216
+ const body = shapedFor(req, sent);
217
+ const route = resolveRoute(requestedModel(body), cfg) ?? passthrough(body);
218
+ const conn = route.connection;
219
+ log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
220
+ if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
221
+ if (conn.provider === 'bedrock') {
222
+ assertBedrockReady(conn);
223
+ const up = { settings: conn, base: deps.bedrockBase ?? bedrockBase(conn.region), learned, watch: watchUpstream(res) };
192
224
  if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
193
225
  else await bedrockMessages(req, res, body, route.model, up);
194
226
  return;
195
227
  }
196
- if (route.provider === 'openrouter') {
228
+ if (conn.provider === 'openrouter') {
197
229
  if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
198
230
  await toOpenRouter(req, res, body, route, cfg, deps);
199
231
  return;
200
232
  }
201
- if (route.provider === 'codex' || route.provider === 'gemini') {
202
- await toSubscription(req, res, path, body, route, cfg, deps);
233
+ if (conn.provider === 'codex' || conn.provider === 'gemini') {
234
+ await toSubscription(req, res, path, body, route, deps);
203
235
  return;
204
236
  }
205
- await toAnthropic(req, res, raw, body, route, deps);
237
+ await toAnthropic(req, res, raw, sent, body, route, deps);
206
238
  }
207
239
 
208
240
  function failed(res: ServerResponse, err: unknown): void {
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { ToolNames } from './codex-translate.js';
4
4
  import { CLIENT_NAME, requestId, SYSTEM_PREFIX } from './gemini-client.js';
5
+ import { cappedEffort, effortToApply } from './effort.js';
5
6
 
6
7
  type Item = Record<string, unknown>;
7
8
 
@@ -190,6 +191,8 @@ function generationConfig(body: Item): Item {
190
191
  if (typeof body.temperature === 'number') out.temperature = body.temperature;
191
192
  if (typeof body.top_p === 'number') out.topP = body.top_p;
192
193
  if (isRecord(body.thinking) && body.thinking.type === 'enabled') out.thinkingConfig = { includeThoughts: true };
194
+ const effort = effortToApply(body);
195
+ if (effort !== null) out.thinkingLevel = cappedEffort(effort);
193
196
  return out;
194
197
  }
195
198
 
@@ -8,8 +8,9 @@ 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
+ import { effortToApply, withoutEffort } from './effort.js';
13
14
 
14
15
  const PING_MS = 25_000;
15
16
  const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
@@ -18,7 +19,7 @@ export interface GeminiDeps {
18
19
  base?: Bases;
19
20
  tokenBase?: string;
20
21
  fetchImpl?: typeof fetch;
21
- save: (tokens: GeminiTokens) => void;
22
+ save: (id: string, tokens: GeminiTokens) => void;
22
23
  }
23
24
 
24
25
  export interface GeminiState {
@@ -30,13 +31,13 @@ export const freshGeminiState = (): GeminiState => ({ refreshing: null, latest:
30
31
 
31
32
  const newerThan = (a: GeminiTokens, b: GeminiTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
32
33
 
33
- function refreshed(tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
34
+ function refreshed(id: string, tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
34
35
  const latest = state.latest;
35
36
  if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
36
37
  if (state.refreshing !== null) return state.refreshing;
37
38
  const run = refreshTokens(tokens, deps.tokenBase, deps.fetchImpl)
38
39
  .then((fresh) => {
39
- deps.save(fresh);
40
+ deps.save(id, fresh);
40
41
  state.latest = fresh;
41
42
  return fresh;
42
43
  })
@@ -50,10 +51,10 @@ function refreshed(tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState):
50
51
  return run;
51
52
  }
52
53
 
53
- export function currentGeminiTokens(cfg: ModelConfig, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
54
- const tokens = cfg.gemini.auth;
54
+ export function currentGeminiTokens(conn: Connection, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
55
+ const tokens = conn.gemini;
55
56
  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);
57
+ return tokensStale(tokens) ? refreshed(conn.id, tokens, deps, state) : Promise.resolve(tokens);
57
58
  }
58
59
 
59
60
  function errorKind(status: number): string {
@@ -76,6 +77,7 @@ interface Call {
76
77
  watch: Watch;
77
78
  deps: GeminiDeps;
78
79
  names: ToolNames;
80
+ conn: Connection;
79
81
  }
80
82
 
81
83
  const headersFor = (token: string, stream: boolean): Record<string, string> => ({
@@ -140,12 +142,12 @@ export function refusalMessage(text: string, status: number): string {
140
142
  return notes.length === 0 ? message : `${message} (${notes.join('; ')})`;
141
143
  }
142
144
 
143
- async function reach(call: Call, cfg: ModelConfig, state: GeminiState, stream: boolean): Promise<Response> {
144
- let tokens = await currentGeminiTokens(cfg, call.deps, state);
145
+ async function reach(call: Call, conn: Connection, state: GeminiState, stream: boolean): Promise<Response> {
146
+ let tokens = await currentGeminiTokens(conn, call.deps, state);
145
147
  let upstream = await send(call, tokens, stream);
146
148
  if (upstream.status === 401) {
147
149
  await upstream.body?.cancel();
148
- tokens = await refreshed(tokens, call.deps, state);
150
+ tokens = await refreshed(conn.id, tokens, call.deps, state);
149
151
  upstream = await send(call, tokens, stream);
150
152
  }
151
153
  return upstream;
@@ -210,7 +212,7 @@ async function relayStream(upstream: Response, res: ServerResponse, call: Call):
210
212
  return;
211
213
  }
212
214
  const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
213
- const scanner = new UsageScanner('gemini');
215
+ const scanner = new UsageScanner(call.conn.id);
214
216
  const emit = (frames: string): void => {
215
217
  if (frames === '') return;
216
218
  scanner.feed(frames);
@@ -245,7 +247,7 @@ async function relayWhole(upstream: Response, res: ServerResponse, call: Call):
245
247
  const translator = new GeminiStreamTranslator(call.model, (name) => call.names.restore(name));
246
248
  const data = parseData(await upstream.text());
247
249
  const frames = (data === null ? '' : translator.push(data)) + translator.close();
248
- const scanner = new UsageScanner('gemini');
250
+ const scanner = new UsageScanner(call.conn.id);
249
251
  scanner.feed(frames);
250
252
  scanner.done();
251
253
  const message = assembleMessage(frames);
@@ -254,23 +256,38 @@ async function relayWhole(upstream: Response, res: ServerResponse, call: Call):
254
256
  res.end(JSON.stringify(message));
255
257
  }
256
258
 
259
+ const NAMES_THINKING = /thinking|thought/i;
260
+
261
+ function refuse(res: ServerResponse, model: string, status: number, text: string): void {
262
+ log.warn({ provider: 'gemini', model, status, body: text.slice(0, REFUSAL_LOG_MAX) }, 'gateway: the provider refused the request');
263
+ sendError(res, providerStatus(status), errorKind(status), refusalMessage(text, status));
264
+ }
265
+
257
266
  export async function geminiMessages(
258
267
  req: IncomingMessage,
259
268
  res: ServerResponse,
260
269
  body: Record<string, unknown>,
261
270
  model: string,
262
- cfg: ModelConfig,
271
+ conn: Connection,
263
272
  deps: GeminiDeps,
264
273
  state: GeminiState,
265
274
  watch: Watch,
266
275
  ): Promise<void> {
267
276
  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);
270
- if (!upstream.ok) {
277
+ let call: Call = { body, model, promptId: promptIdOf(req), watch, deps, names: new ToolNames(), conn };
278
+ let upstream = await reach(call, conn, state, stream);
279
+ if (upstream.status === 400 && effortToApply(body) !== null) {
271
280
  const text = await upstream.text();
272
- log.warn({ provider: 'gemini', model, status: upstream.status, body: text.slice(0, REFUSAL_LOG_MAX) }, 'gateway: the provider refused the request');
273
- sendError(res, providerStatus(upstream.status), errorKind(upstream.status), refusalMessage(text, upstream.status));
281
+ if (!NAMES_THINKING.test(text)) {
282
+ refuse(res, model, 400, text);
283
+ return;
284
+ }
285
+ log.warn({ provider: 'gemini', model }, 'gateway: Gemini refused the thinking level, so metro asked again without it');
286
+ call = { ...call, body: withoutEffort(body) };
287
+ upstream = await reach(call, conn, state, stream);
288
+ }
289
+ if (!upstream.ok) {
290
+ refuse(res, model, upstream.status, await upstream.text());
274
291
  return;
275
292
  }
276
293
  if (stream) await relayStream(upstream, res, call);