@bitkyc08/opencodex 0.2.2 → 1.9.0

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/src/server.ts CHANGED
@@ -6,7 +6,17 @@ import { createGoogleAdapter } from "./adapters/google";
6
6
  import { createOpenAIChatAdapter } from "./adapters/openai-chat";
7
7
  import { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
8
8
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
9
- import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig } from "./config";
9
+ import {
10
+ buildWarmupCompletionFrames,
11
+ buildWsErrorFrame,
12
+ selectForwardHeaders,
13
+ sendJsonFrame,
14
+ sendResponseToWebSocket,
15
+ sendTextFrame,
16
+ type WsData,
17
+ } from "./ws-bridge";
18
+ import type { ServerWebSocket } from "bun";
19
+ import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig, websocketsEnabled } from "./config";
10
20
  import { parseRequest } from "./responses/parser";
11
21
  import { routeModel } from "./router";
12
22
  import { namespacedToolName } from "./types";
@@ -19,6 +29,7 @@ import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "./web-searc
19
29
  import { describeImagesInPlace, planVisionSidecar } from "./vision";
20
30
  import { removeCredential } from "./oauth/store";
21
31
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
32
+ import { deriveProviderPresets } from "./providers/derive";
22
33
  import type { OcxConfig, OcxProviderConfig } from "./types";
23
34
 
24
35
  const VERSION = "0.0.1";
@@ -67,7 +78,7 @@ function serveGuiFile(pathname: string): Response | null {
67
78
  });
68
79
  }
69
80
 
70
- function resolveAdapter(providerConfig: OcxProviderConfig) {
81
+ export function resolveAdapter(providerConfig: OcxProviderConfig) {
71
82
  switch (providerConfig.adapter) {
72
83
  case "openai-chat":
73
84
  return createOpenAIChatAdapter(providerConfig);
@@ -77,6 +88,7 @@ function resolveAdapter(providerConfig: OcxProviderConfig) {
77
88
  return createResponsesPassthroughAdapter(providerConfig);
78
89
  case "google":
79
90
  return createGoogleAdapter(providerConfig);
91
+ case "azure":
80
92
  case "azure-openai":
81
93
  return createAzureAdapter(providerConfig);
82
94
  default:
@@ -84,7 +96,12 @@ function resolveAdapter(providerConfig: OcxProviderConfig) {
84
96
  }
85
97
  }
86
98
 
87
- async function handleResponses(req: Request, config: OcxConfig, logCtx: { model: string; provider: string }): Promise<Response> {
99
+ async function handleResponses(
100
+ req: Request,
101
+ config: OcxConfig,
102
+ logCtx: { model: string; provider: string },
103
+ options: { forceEmptyResponseId?: boolean; abortSignal?: AbortSignal } = {},
104
+ ): Promise<Response> {
88
105
  let body: unknown;
89
106
  try {
90
107
  body = await req.json();
@@ -133,24 +150,30 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
133
150
  // with text BEFORE the main call, so the text-only model can reason about it.
134
151
  const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, req.headers);
135
152
  if (visionPlan) {
136
- await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings);
153
+ await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings, options.abortSignal);
137
154
  }
138
155
 
139
156
  const adapter = resolveAdapter(route.provider);
140
157
 
141
158
  if ("passthrough" in adapter && adapter.passthrough) {
142
159
  const request = adapter.buildRequest(parsed, { headers: req.headers });
160
+ // Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
161
+ // consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
162
+ // whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
163
+ const upstream = new AbortController();
164
+ linkAbortSignal(upstream, options.abortSignal);
143
165
  let upstreamResponse: Response;
144
166
  try {
145
167
  upstreamResponse = await fetch(request.url, {
146
168
  method: request.method,
147
169
  headers: request.headers,
148
170
  body: request.body,
171
+ signal: upstream.signal,
149
172
  });
150
173
  } catch (err) {
151
174
  return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
152
175
  }
153
- return new Response(upstreamResponse.body, {
176
+ return new Response(relayWithAbort(upstreamResponse.body, upstream), {
154
177
  status: upstreamResponse.status,
155
178
  headers: sanitizePassthroughHeaders(upstreamResponse.headers),
156
179
  });
@@ -169,17 +192,23 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
169
192
  incomingHeaders: req.headers,
170
193
  settings: wsPlan.settings,
171
194
  maxSearches: wsPlan.maxSearches,
195
+ abortSignal: options.abortSignal,
172
196
  });
173
197
  }
174
198
 
175
199
  const request = adapter.buildRequest(parsed, { headers: req.headers });
176
200
 
201
+ // Abort the upstream fetch if the client (Codex) disconnects mid-stream, so a cancelled turn does
202
+ // not leak the upstream connection or keep draining tokens. The bridge's cancel() fires upstream.abort() (RC2).
203
+ const upstream = new AbortController();
204
+ linkAbortSignal(upstream, options.abortSignal);
177
205
  let upstreamResponse: Response;
178
206
  try {
179
207
  upstreamResponse = await fetch(request.url, {
180
208
  method: request.method,
181
209
  headers: request.headers,
182
210
  body: request.body,
211
+ signal: upstream.signal,
183
212
  });
184
213
  } catch (err) {
185
214
  return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
@@ -202,7 +231,16 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
202
231
  if (t.freeform) freeformToolNames.add(t.name);
203
232
  if (t.toolSearch) toolSearchToolNames.add(t.name);
204
233
  }
205
- const sseStream = bridgeToResponsesSSE(eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames);
234
+ const sseStream = bridgeToResponsesSSE(
235
+ eventStream,
236
+ parsed.modelId,
237
+ toolNsMap,
238
+ freeformToolNames,
239
+ toolSearchToolNames,
240
+ () => upstream.abort(),
241
+ 2_000,
242
+ options.forceEmptyResponseId ? { responseId: "" } : undefined,
243
+ );
206
244
  return new Response(sseStream, {
207
245
  headers: {
208
246
  "Content-Type": "text/event-stream",
@@ -224,6 +262,15 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
224
262
  return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
225
263
  }
226
264
 
265
+ export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): void {
266
+ if (!signal) return;
267
+ if (signal.aborted) {
268
+ upstream.abort(signal.reason);
269
+ return;
270
+ }
271
+ signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
272
+ }
273
+
227
274
  const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
228
275
  const MAX_LOG_SIZE = 200;
229
276
 
@@ -232,6 +279,39 @@ function addRequestLog(entry: typeof requestLog[number]) {
232
279
  if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
233
280
  }
234
281
 
282
+ /**
283
+ * Relay an upstream body verbatim while wiring client-cancel -> upstream.abort(). A body returned
284
+ * directly from fetch does NOT propagate the consumer's cancel to a signalled fetch, so a client
285
+ * disconnect would leak the upstream connection. Pumping through this stream (whose cancel() aborts
286
+ * the upstream) fixes the leak with zero byte changes — passthrough fidelity is preserved (RC2).
287
+ */
288
+ export function relayWithAbort(
289
+ body: ReadableStream<Uint8Array> | null,
290
+ upstream: AbortController,
291
+ ): ReadableStream<Uint8Array> | null {
292
+ if (!body) return null;
293
+ const reader = body.getReader();
294
+ return new ReadableStream<Uint8Array>({
295
+ async pull(controller) {
296
+ try {
297
+ const { done, value } = await reader.read();
298
+ if (done) {
299
+ controller.close();
300
+ return;
301
+ }
302
+ controller.enqueue(value);
303
+ } catch (err) {
304
+ try { controller.error(err); } catch { /* already torn down */ }
305
+ }
306
+ },
307
+ cancel(reason) {
308
+ // Client disconnected: abort the upstream fetch and release the reader so we do not leak it.
309
+ upstream.abort(reason);
310
+ reader.cancel(reason).catch(() => {});
311
+ },
312
+ });
313
+ }
314
+
235
315
  /**
236
316
  * Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
237
317
  * (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
@@ -239,7 +319,18 @@ function addRequestLog(entry: typeof requestLog[number]) {
239
319
  * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim.
240
320
  */
241
321
  export function sanitizePassthroughHeaders(upstream: Headers): Headers {
242
- const DROP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
322
+ const DROP = new Set([
323
+ "content-encoding",
324
+ "content-length",
325
+ "transfer-encoding",
326
+ "connection",
327
+ "keep-alive",
328
+ "proxy-authenticate",
329
+ "proxy-authorization",
330
+ "te",
331
+ "trailer",
332
+ "upgrade",
333
+ ]);
243
334
  const out = new Headers();
244
335
  upstream.forEach((value, key) => {
245
336
  if (!DROP.has(key.toLowerCase())) out.set(key, value);
@@ -361,6 +452,12 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
361
452
  return jsonResponse({ providers: listKeyLoginProviders() });
362
453
  }
363
454
 
455
+ // Complete GUI picker presets, derived from the canonical provider registry. The GUI is a
456
+ // standalone Vite package, so it consumes this runtime view instead of importing repo-root src.
457
+ if (url.pathname === "/api/provider-presets" && req.method === "GET") {
458
+ return jsonResponse({ providers: deriveProviderPresets() });
459
+ }
460
+
364
461
  // Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
365
462
  // first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
366
463
  if (url.pathname === "/api/subagent-models" && req.method === "GET") {
@@ -452,7 +549,7 @@ export function startServer(port?: number) {
452
549
  }
453
550
  const listenPort = port ?? config.port ?? 10100;
454
551
 
455
- const server = Bun.serve({
552
+ const server = Bun.serve<WsData>({
456
553
  port: listenPort,
457
554
  async fetch(req) {
458
555
  const url = new URL(req.url);
@@ -461,6 +558,13 @@ export function startServer(port?: number) {
461
558
  return new Response(null, { status: 204, headers: corsHeaders() });
462
559
  }
463
560
 
561
+ // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
562
+ // handshake-time only, so capture inbound headers and thread them into the pipeline.
563
+ if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
564
+ if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
565
+ return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
566
+ }
567
+
464
568
  if (url.pathname === "/healthz" && req.method === "GET") {
465
569
  return jsonResponse({ status: "ok", version: VERSION, uptime: process.uptime() });
466
570
  }
@@ -481,7 +585,7 @@ export function startServer(port?: number) {
481
585
  // Codex client → Codex catalog shape: native gpt + namespaced routed models,
482
586
  // cloned from a native template so required fields (base_instructions, etc.) are present.
483
587
  // Pass the subagent picks so featured models lead by priority (matches the on-disk file).
484
- return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels) });
588
+ return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) });
485
589
  }
486
590
  // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
487
591
  const data = [
@@ -510,6 +614,75 @@ export function startServer(port?: number) {
510
614
 
511
615
  return formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`);
512
616
  },
617
+ websocket: {
618
+ // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
619
+ // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
620
+ // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
621
+ message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
622
+ let frame: Record<string, unknown>;
623
+ try {
624
+ frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
625
+ } catch {
626
+ return; // text-only contract; ignore unparseable frames
627
+ }
628
+ if (frame.type === "response.processed") return; // ack — no-op
629
+ if (frame.type !== "response.create") return;
630
+
631
+ ws.data.cancel?.();
632
+ const turnId = (ws.data.turnId ?? 0) + 1;
633
+ ws.data.turnId = turnId;
634
+ const isCurrent = () => ws.data.turnId === turnId;
635
+ const turnAbort = new AbortController();
636
+ const cancelTurn = () => {
637
+ turnAbort.abort("websocket turn superseded or closed");
638
+ };
639
+ ws.data.cancel = cancelTurn;
640
+
641
+ if (frame.generate === false) {
642
+ for (const payload of buildWarmupCompletionFrames(frame)) {
643
+ if (!isCurrent()) return;
644
+ sendTextFrame(ws, payload);
645
+ }
646
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
647
+ return;
648
+ }
649
+
650
+ const payload: Record<string, unknown> = { ...frame };
651
+ delete payload.type;
652
+ void (async () => {
653
+ const logCtx = { model: "unknown", provider: "unknown" };
654
+ const fwd = new Headers({ "content-type": "application/json" });
655
+ ws.data.headers?.forEach((value, key) => fwd.set(key, value));
656
+ const req = new Request("http://localhost/v1/responses", {
657
+ method: "POST",
658
+ headers: fwd,
659
+ body: JSON.stringify({ ...payload, stream: true }),
660
+ });
661
+ try {
662
+ const response = await handleResponses(req, config, logCtx, {
663
+ forceEmptyResponseId: true,
664
+ abortSignal: turnAbort.signal,
665
+ });
666
+ await sendResponseToWebSocket(ws, response, isCurrent);
667
+ } catch (err) {
668
+ if (!isCurrent()) return;
669
+ try {
670
+ sendJsonFrame(ws, buildWsErrorFrame(502, {
671
+ type: "proxy_error",
672
+ message: err instanceof Error ? err.message : String(err),
673
+ }));
674
+ } catch {
675
+ /* socket already gone or send dropped */
676
+ }
677
+ } finally {
678
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
679
+ }
680
+ })();
681
+ },
682
+ close(ws: ServerWebSocket<WsData>) {
683
+ ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
684
+ },
685
+ },
513
686
  });
514
687
 
515
688
  console.log(`🚀 opencodex proxy running on http://localhost:${listenPort}`);
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export interface OcxParsedRequest {
2
2
  modelId: string;
3
+ previousResponseId?: string;
3
4
  context: OcxContext;
4
5
  stream: boolean;
5
6
  options: OcxRequestOptions;
@@ -149,6 +150,7 @@ export interface OcxRequestOptions {
149
150
  export type AdapterEvent =
150
151
  | { type: "text_delta"; text: string }
151
152
  | { type: "thinking_delta"; thinking: string }
153
+ | { type: "reasoning_raw_delta"; text: string }
152
154
  | { type: "tool_call_start"; id: string; name: string }
153
155
  | { type: "tool_call_delta"; arguments: string }
154
156
  | { type: "tool_call_end" }
@@ -158,6 +160,8 @@ export type AdapterEvent =
158
160
  export interface OcxUsage {
159
161
  inputTokens: number;
160
162
  outputTokens: number;
163
+ cachedInputTokens?: number;
164
+ reasoningOutputTokens?: number;
161
165
  }
162
166
 
163
167
  export interface OcxConfig {
@@ -171,6 +175,8 @@ export interface OcxConfig {
171
175
  subagentModels?: string[];
172
176
  /** Routed model ids ("<provider>/<model>") hidden from Codex (excluded from the catalog + /v1/models). */
173
177
  disabledModels?: string[];
178
+ /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt into WS. */
179
+ websockets?: boolean;
174
180
  /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */
175
181
  modelCacheTtlMs?: number;
176
182
  /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */
@@ -1,5 +1,6 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
+ import { signalWithTimeout } from "../abort";
3
4
  import { parseSidecarSSE } from "../web-search/parse";
4
5
 
5
6
  export interface VisionSettings {
@@ -48,6 +49,7 @@ export async function describeImage(
48
49
  forwardProvider: OcxProviderConfig,
49
50
  incomingHeaders: Headers,
50
51
  settings: VisionSettings,
52
+ abortSignal?: AbortSignal,
51
53
  ): Promise<DescribeOutcome> {
52
54
  const invalid = validateImageUrl(imageUrl);
53
55
  if (invalid) return { text: "", error: invalid };
@@ -76,12 +78,13 @@ export async function describeImage(
76
78
  store: false,
77
79
  stream: true,
78
80
  };
81
+ const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
79
82
  try {
80
83
  const res = await fetch(`${forwardProvider.baseUrl}/responses`, {
81
84
  method: "POST",
82
85
  headers,
83
86
  body: JSON.stringify(body),
84
- signal: AbortSignal.timeout(settings.timeoutMs),
87
+ signal: linkedSignal.signal,
85
88
  });
86
89
  if (!res.ok) {
87
90
  const t = await res.text().catch(() => "");
@@ -94,5 +97,7 @@ export async function describeImage(
94
97
  return { text: parsed.text };
95
98
  } catch (e) {
96
99
  return { text: "", error: e instanceof Error ? e.message : String(e) };
100
+ } finally {
101
+ linkedSignal.cleanup();
97
102
  }
98
103
  }
@@ -107,6 +107,7 @@ export async function describeImagesInPlace(
107
107
  forwardProvider: OcxProviderConfig,
108
108
  incomingHeaders: Headers,
109
109
  settings: VisionSettings,
110
+ abortSignal?: AbortSignal,
110
111
  ): Promise<void> {
111
112
  // 1. Gather every image part across messages, each with its own message's text as context.
112
113
  const jobs: ImageJob[] = [];
@@ -129,7 +130,7 @@ export async function describeImagesInPlace(
129
130
 
130
131
  // 2. Describe all images with bounded concurrency (order preserved).
131
132
  const outcomes = await runBounded(jobs, VISION_CONCURRENCY, j =>
132
- describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, incomingHeaders, settings));
133
+ describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, incomingHeaders, settings, abortSignal));
133
134
 
134
135
  // 3. Rebuild each message, replacing image parts with their descriptions in order.
135
136
  let oi = 0;
@@ -1,5 +1,6 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
+ import { signalWithTimeout } from "../abort";
3
4
  import { parseSidecarSSE, type WebSearchResult } from "./parse";
4
5
 
5
6
  export interface SidecarSettings {
@@ -36,6 +37,7 @@ export async function runWebSearch(
36
37
  forwardProvider: OcxProviderConfig,
37
38
  incomingHeaders: Headers,
38
39
  settings: SidecarSettings,
40
+ abortSignal?: AbortSignal,
39
41
  ): Promise<SidecarOutcome> {
40
42
  const headers: Record<string, string> = { "Content-Type": "application/json" };
41
43
  if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers);
@@ -57,12 +59,13 @@ export async function runWebSearch(
57
59
  stream: true,
58
60
  };
59
61
  const url = `${forwardProvider.baseUrl}/responses`;
62
+ const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
60
63
  try {
61
64
  const res = await fetch(url, {
62
65
  method: "POST",
63
66
  headers,
64
67
  body: JSON.stringify(body),
65
- signal: AbortSignal.timeout(settings.timeoutMs),
68
+ signal: linkedSignal.signal,
66
69
  });
67
70
  if (!res.ok) {
68
71
  const t = await res.text().catch(() => "");
@@ -71,5 +74,7 @@ export async function runWebSearch(
71
74
  return await parseSidecarSSE(res);
72
75
  } catch (e) {
73
76
  return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) };
77
+ } finally {
78
+ linkedSignal.cleanup();
74
79
  }
75
80
  }
@@ -95,6 +95,7 @@ export interface WebSearchLoopDeps {
95
95
  incomingHeaders: Headers;
96
96
  settings: SidecarSettings;
97
97
  maxSearches: number;
98
+ abortSignal?: AbortSignal;
98
99
  }
99
100
 
100
101
  /**
@@ -104,7 +105,7 @@ export interface WebSearchLoopDeps {
104
105
  * streamed Responses SSE. web_search calls are executed internally and never relayed to Codex.
105
106
  */
106
107
  export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Response> {
107
- const { parsed, adapter, incomingHeaders, forwardProvider, hostedTool, settings, maxSearches } = deps;
108
+ const { parsed, adapter, incomingHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal } = deps;
108
109
  if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
109
110
 
110
111
  const messages: OcxMessage[] = [...parsed.context.messages];
@@ -129,7 +130,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
129
130
  const request = adapter.buildRequest(iterParsed, { headers: incomingHeaders });
130
131
  let resp: Response;
131
132
  try {
132
- resp = await fetch(request.url, { method: request.method, headers: request.headers, body: request.body });
133
+ resp = await fetch(request.url, {
134
+ method: request.method,
135
+ headers: request.headers,
136
+ body: request.body,
137
+ signal: abortSignal,
138
+ });
133
139
  } catch (e) {
134
140
  return jsonError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
135
141
  }
@@ -159,7 +165,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
159
165
  outcome = { text: "", sources: [], error: "the model called web_search with an empty query" };
160
166
  searchesExecuted++;
161
167
  } else {
162
- outcome = await runWebSearch(call.query, hostedTool, forwardProvider, incomingHeaders, settings);
168
+ outcome = await runWebSearch(call.query, hostedTool, forwardProvider, incomingHeaders, settings, abortSignal);
163
169
  searchesExecuted++;
164
170
  if (outcome.error) failedQueries.add(normalizeQuery(call.query));
165
171
  }