@bitkyc08/opencodex 2.6.18 → 2.6.19

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.
@@ -73,6 +73,15 @@ const ZAI_GLM_52_REASONING_MAP: Record<string, string> = {
73
73
  xhigh: "max",
74
74
  max: "max",
75
75
  };
76
+ const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
77
+ const DEEPSEEK_THINKING_EFFORTS = ["high", "xhigh"];
78
+ const DEEPSEEK_THINKING_REASONING_MAP: Record<string, string> = {
79
+ low: "high",
80
+ medium: "high",
81
+ high: "high",
82
+ xhigh: "max",
83
+ max: "max",
84
+ };
76
85
  const KIMI_THINKING_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", "kimi-k2-0905-preview"];
77
86
  const KIMI_LOCKED_PARAMETER_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
78
87
  const NEURALWATT_REASONING_HISTORY_MODELS = [
@@ -297,7 +306,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
297
306
  { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", featured: true, note: "Local — key usually blank", reasoningEffortMap: OLLAMA_REASONING_MAP },
298
307
  { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
299
308
  { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", featured: true, note: "Local — no key needed" },
300
- { id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.deepseek.com/api_keys", models: ["deepseek-chat", "deepseek-reasoner"], defaultModel: "deepseek-chat" },
309
+ {
310
+ id: "deepseek",
311
+ label: "DeepSeek",
312
+ baseUrl: "https://api.deepseek.com",
313
+ adapter: "openai-chat",
314
+ authKind: "key",
315
+ dashboardUrl: "https://platform.deepseek.com/api_keys",
316
+ models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS],
317
+ defaultModel: "deepseek-chat",
318
+ /* [Decision Log]
319
+ - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content.
320
+ - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata.
321
+ - 선택 근거: DeepSeek V4 thinking mode requires history replay, while older DeepSeek reasoner has different compatibility rules. A model-scoped registry flag fixes built-in and stale saved configs without broad provider regressions.
322
+ */
323
+ modelReasoningEfforts: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
324
+ modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
325
+ preserveReasoningContentModels: DEEPSEEK_THINKING_MODELS,
326
+ },
301
327
  { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "llama-3.3-70b" },
302
328
  { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" },
303
329
  { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },
package/src/server.ts CHANGED
@@ -84,6 +84,7 @@ import {
84
84
  } from "./codex-routing";
85
85
  import { registerCodexWebSocket, unregisterCodexWebSocket, updateCodexWebSocketAuthContext } from "./codex-websocket-registry";
86
86
  import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile } from "./server/gui-static";
87
+ import { fetchWithResetRetry } from "./upstream-retry";
87
88
  export { resolveGuiFilePath, rootFallbackPayload } from "./server/gui-static";
88
89
  import { resolveAdapter, resolveWireProtocolOverride } from "./server/adapter-resolve";
89
90
  export { resolveAdapter } from "./server/adapter-resolve";
@@ -368,11 +369,14 @@ async function handleResponses(
368
369
  const connectMs = config.connectTimeoutMs ?? 100_000;
369
370
  let upstreamResponse: Response;
370
371
  try {
371
- upstreamResponse = await fetchWithHeaderTimeout(request.url, {
372
- method: request.method,
373
- headers: request.headers,
374
- body: request.body,
375
- }, upstream.signal, connectMs);
372
+ upstreamResponse = await fetchWithResetRetry(
373
+ () => fetchWithHeaderTimeout(request.url, {
374
+ method: request.method,
375
+ headers: request.headers,
376
+ body: request.body,
377
+ }, upstream.signal, connectMs),
378
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
379
+ );
376
380
  } catch (err) {
377
381
  upstream.abort();
378
382
  const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
@@ -453,7 +457,13 @@ async function handleResponses(
453
457
  consumeForResponseLogMetadata(inspectBody, logCtx, turnAc.signal, () => unregisterTurn(turnAc));
454
458
  }
455
459
  if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
456
- return markNativePassthroughSseResponse(new Response(nativeBody, {
460
+ // win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
461
+ // relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
462
+ // mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
463
+ const clientBody = process.platform === "win32"
464
+ ? nativeBody
465
+ : relaySseWithFailedTail(nativeBody, upstream);
466
+ return markNativePassthroughSseResponse(new Response(clientBody, {
457
467
  status: upstreamResponse.status,
458
468
  headers,
459
469
  }));
@@ -563,9 +573,12 @@ async function handleResponses(
563
573
  try {
564
574
  upstreamResponse = adapter.fetchResponse
565
575
  ? await adapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs })
566
- : await fetchWithHeaderTimeout(request.url, {
567
- method: request.method, headers: request.headers, body: request.body,
568
- }, upstream.signal, connectMs);
576
+ : await fetchWithResetRetry(
577
+ () => fetchWithHeaderTimeout(request.url, {
578
+ method: request.method, headers: request.headers, body: request.body,
579
+ }, upstream.signal, connectMs),
580
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
581
+ );
569
582
  } catch (err) {
570
583
  cleanupUpstreamAbort();
571
584
  upstream.abort();
@@ -643,6 +656,15 @@ export function disableResponsesRequestTimeout(req: Request, server: Pick<Server
643
656
  }
644
657
  }
645
658
 
659
+ /** Host-only label for retry logs — never leaks path/query/credentials. */
660
+ function safeHostLabel(url: string): string {
661
+ try {
662
+ return new URL(url).host;
663
+ } catch {
664
+ return "upstream";
665
+ }
666
+ }
667
+
646
668
  async function fetchWithHeaderTimeout(
647
669
  url: string,
648
670
  init: Omit<RequestInit, "signal">,
@@ -966,6 +988,54 @@ export function relayWithAbort(
966
988
  });
967
989
  }
968
990
 
991
+ /**
992
+ * Relay a passthrough SSE body like relayWithAbort, but convert a MID-STREAM failure (upstream
993
+ * reset after headers) into a clean terminal: any partial block is closed off, then a synthetic
994
+ * `response.failed` event and `data: [DONE]` are emitted and the stream closes. Without this the
995
+ * client sees a raw socket teardown with no terminal SSE event. Deliberately NOT a resend: the
996
+ * upstream already committed the request (duplicate-completion risk — same policy as cursor's
997
+ * committed=non-replayable transport retry).
998
+ */
999
+ export function relaySseWithFailedTail(
1000
+ body: ReadableStream<Uint8Array>,
1001
+ upstream: AbortController,
1002
+ ): ReadableStream<Uint8Array> {
1003
+ const reader = body.getReader();
1004
+ const encoder = new TextEncoder();
1005
+ return new ReadableStream<Uint8Array>({
1006
+ async pull(controller) {
1007
+ try {
1008
+ const { done, value } = await reader.read();
1009
+ if (done) {
1010
+ controller.close();
1011
+ return;
1012
+ }
1013
+ controller.enqueue(value);
1014
+ } catch (err) {
1015
+ const failure = {
1016
+ type: "upstream_error",
1017
+ code: "upstream_reset",
1018
+ message: `Upstream stream terminated unexpectedly: ${err instanceof Error ? err.message : String(err)}`,
1019
+ };
1020
+ const payload = JSON.stringify({
1021
+ type: "response.failed",
1022
+ response: { status: "failed", error: failure, last_error: failure },
1023
+ });
1024
+ try {
1025
+ // Leading blank line terminates a partial SSE block so the failed frame parses cleanly.
1026
+ controller.enqueue(encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\ndata: [DONE]\n\n`));
1027
+ controller.close();
1028
+ } catch { /* client already torn down */ }
1029
+ upstream.abort();
1030
+ }
1031
+ },
1032
+ cancel(reason) {
1033
+ upstream.abort(reason);
1034
+ reader.cancel(reason).catch(() => {});
1035
+ },
1036
+ });
1037
+ }
1038
+
969
1039
  function nextSseBlock(buffer: string): { block: string; rest: string } | null {
970
1040
  const match = buffer.match(/\r?\n\r?\n/);
971
1041
  if (!match || match.index === undefined) return null;
@@ -1911,6 +1981,38 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
1911
1981
  return jsonResponse({ ok: true, applied: chosen });
1912
1982
  }
1913
1983
 
1984
+ // Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list,
1985
+ // only those ids ship to Codex's catalog / /v1/models. GET returns the CURRENT selection plus the
1986
+ // FULL available set per provider (unfiltered — the picker needs everything to choose from).
1987
+ if (url.pathname === "/api/selected-models" && req.method === "GET") {
1988
+ const models = await fetchAllModels(config);
1989
+ const available: Record<string, string[]> = {};
1990
+ for (const m of models) (available[m.provider] ??= []).push(m.id);
1991
+ const selected: Record<string, string[]> = {};
1992
+ for (const [name, prov] of Object.entries(config.providers)) {
1993
+ if (Array.isArray(prov.selectedModels) && prov.selectedModels.length > 0) selected[name] = [...prov.selectedModels];
1994
+ }
1995
+ return jsonResponse({ selected, available });
1996
+ }
1997
+ if (url.pathname === "/api/selected-models" && req.method === "PUT") {
1998
+ let body: { provider?: unknown; models?: unknown };
1999
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
2000
+ const provider = typeof body.provider === "string" ? body.provider : "";
2001
+ if (!provider || !hasOwnProvider(config.providers, provider)) {
2002
+ return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
2003
+ }
2004
+ const models = Array.isArray(body.models)
2005
+ ? [...new Set(body.models.filter((m): m is string => typeof m === "string"))]
2006
+ : [];
2007
+ // Empty list clears the allowlist (provider reverts to exposing all models).
2008
+ if (models.length > 0) config.providers[provider].selectedModels = models;
2009
+ else delete config.providers[provider].selectedModels;
2010
+ const { saveConfig: save } = await import("./config");
2011
+ save(config);
2012
+ await refreshCodexCatalogBestEffort();
2013
+ return jsonResponse({ ok: true, provider, selected: models });
2014
+ }
2015
+
1914
2016
  // OAuth login (xai now; anthropic/kimi in cycle 2). Starts the flow and returns the auth URL;
1915
2017
  // the provider's loopback callback server (inside this process) captures the redirect in the
1916
2018
  // background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
@@ -2071,10 +2173,9 @@ export function startServer(port?: number) {
2071
2173
  return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
2072
2174
  }
2073
2175
  const goModels = await fetchAllModels(config);
2074
- const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents } = await import("./codex-catalog");
2176
+ const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels } = await import("./codex-catalog");
2075
2177
  const nativeSlugs = nativeOpenAiSlugs();
2076
- const disabledSet = new Set(config.disabledModels ?? []);
2077
- const goEnabled = goModels.filter(m => !disabledSet.has(`${m.provider}/${m.id}`));
2178
+ const goEnabled = filterCatalogVisibleModels(goModels, config);
2078
2179
  const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
2079
2180
  if (url.searchParams.has("client_version")) {
2080
2181
  // Codex client → Codex catalog shape: native gpt + namespaced routed models,
package/src/types.ts CHANGED
@@ -274,6 +274,33 @@ export interface OcxConfig {
274
274
  autoSwitchThreshold?: number;
275
275
  /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */
276
276
  upstreamFailoverThreshold?: number;
277
+ /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */
278
+ tokenGuardian?: OcxTokenGuardianConfig;
279
+ }
280
+
281
+ /**
282
+ * Per-provider proactive-refresh policy. The guardian only ever touches a provider whose EFFECTIVE
283
+ * policy is "proactive"; "lazy-only" keeps today's on-demand refresh, "disabled" forbids the
284
+ * guardian entirely (used for providers whose ToS actively enforces against non-official-client
285
+ * token traffic, e.g. Anthropic subscription OAuth). See devlog 260703_oauth-multi-account-refresh-and-tos.
286
+ */
287
+ export type RefreshPolicy = "proactive" | "lazy-only" | "disabled";
288
+
289
+ export interface OcxTokenGuardianConfig {
290
+ /** Global kill-switch. Default false — the guardian does nothing unless explicitly enabled. */
291
+ enabled?: boolean;
292
+ /** Seconds between refresh sweeps. Default 21600 (6h). Min 60. */
293
+ tickSeconds?: number;
294
+ /** Random 0..jitterSeconds added before each sweep to de-synchronize. Default 300. */
295
+ jitterSeconds?: number;
296
+ /** Max concurrent refreshes per sweep. Default 3. Min 1. */
297
+ concurrency?: number;
298
+ /** Extra lead (seconds) beyond one tick when deciding a token is "expiring soon". Default 900. */
299
+ leadSeconds?: number;
300
+ /** First backoff (seconds) after a permanent refresh failure. Default 300. */
301
+ failureBackoffBaseSeconds?: number;
302
+ /** Backoff ceiling (seconds). Default 3600. */
303
+ failureBackoffMaxSeconds?: number;
277
304
  }
278
305
 
279
306
  export interface OcxVisionSidecarConfig {
@@ -312,6 +339,14 @@ export interface OcxProviderConfig {
312
339
  * or too flaky for startup/catalog sync.
313
340
  */
314
341
  liveModels?: boolean;
342
+ /**
343
+ * Per-provider catalog allowlist. When non-empty, ONLY these model ids are emitted to Codex's
344
+ * catalog and `/v1/models` — live discovery still runs, this just narrows what ships (so a proxy
345
+ * exposing thousands of models, or an aggregator like OpenRouter, doesn't bloat the catalog).
346
+ * Empty/undefined = expose all. The admin `/api/models` list is unaffected (it always shows the
347
+ * full set so the user can pick). See devlog issue_052_provider-model-allowlist.
348
+ */
349
+ selectedModels?: string[];
315
350
  /** Provider-wide Codex-visible context-window cap for routed catalog entries. */
316
351
  contextWindow?: number;
317
352
  /** Model-specific Codex-visible context-window caps. Values cap live metadata, never raise it. */
@@ -326,6 +361,12 @@ export interface OcxProviderConfig {
326
361
  * Only the openai-responses adapter implements "forward"; openai-chat uses its own key/token.
327
362
  */
328
363
  authMode?: "key" | "forward" | "oauth";
364
+ /**
365
+ * Override the guardian's proactive-refresh policy for this provider. When unset, the provider's
366
+ * built-in risk-tiered default applies (see OAUTH_PROVIDERS in src/oauth/index.ts). Set "proactive"
367
+ * to opt this provider into background refresh; "disabled"/"lazy-only" to forbid/limit it.
368
+ */
369
+ refreshPolicy?: RefreshPolicy;
329
370
  /**
330
371
  * Provider-wide Codex-visible reasoning tiers for routed models. Use only Codex-supported labels
331
372
  * here (`low`, `medium`, `high`, `xhigh`); translate to provider-specific wire values with
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Retry guard for upstream fetches that die on stale pooled keep-alive sockets.
3
+ *
4
+ * chatgpt.com (Cloudflare) closes idle keep-alive connections server-side; Bun's fetch pool
5
+ * reuses the half-closed socket and the request write fails with ECONNRESET before any
6
+ * response bytes arrive. Retrying on a fresh connection is safe for our replayable
7
+ * (string-body) upstream requests, because fetch() rejects only before response headers —
8
+ * a caught error here means no response was ever received.
9
+ *
10
+ * Deliberately narrow: timeouts, aborts, ECONNREFUSED/DNS/TLS failures, and HTTP error
11
+ * statuses (returned as Response, never thrown) are NOT retried. Mid-stream SSE resets are
12
+ * out of scope — the response has already resolved by then.
13
+ *
14
+ * MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports
15
+ * the shared abort helpers from here).
16
+ */
17
+
18
+ // 1 initial + 2 retries: the pool may hold more than one stale socket.
19
+ const RESET_RETRY_MAX_ATTEMPTS = 3;
20
+ const RESET_RETRY_BASE_DELAY_MS = 150;
21
+ const RESET_RETRY_MAX_DELAY_MS = 1_000;
22
+
23
+ export function abortError(signal?: AbortSignal): unknown {
24
+ return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
25
+ }
26
+
27
+ export async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
28
+ if (ms <= 0) return;
29
+ if (signal?.aborted) throw abortError(signal);
30
+ await new Promise<void>((resolve, reject) => {
31
+ let timer: ReturnType<typeof setTimeout>;
32
+ const cleanup = () => {
33
+ clearTimeout(timer);
34
+ signal?.removeEventListener("abort", onAbort);
35
+ };
36
+ const onAbort = () => {
37
+ cleanup();
38
+ reject(abortError(signal));
39
+ };
40
+ timer = setTimeout(() => {
41
+ cleanup();
42
+ resolve();
43
+ }, ms);
44
+ signal?.addEventListener("abort", onAbort, { once: true });
45
+ });
46
+ }
47
+
48
+ export function isConnectionResetError(err: unknown): boolean {
49
+ if (!(err instanceof Error)) return false;
50
+ // Aborts and timeouts are caller decisions / honest failures — never retryable.
51
+ if (err.name === "AbortError" || err.name === "TimeoutError") return false;
52
+ const code = (err as { code?: unknown }).code;
53
+ if (code === "ECONNRESET" || code === "EPIPE") return true;
54
+ const msg = err.message.toLowerCase();
55
+ return msg.includes("socket connection was closed unexpectedly")
56
+ || msg.includes("connection reset by peer");
57
+ }
58
+
59
+ function retryDelayMs(attempt: number): number {
60
+ const exp = Math.min(RESET_RETRY_BASE_DELAY_MS * (2 ** attempt), RESET_RETRY_MAX_DELAY_MS);
61
+ return Math.floor(exp * (0.8 + Math.random() * 0.4));
62
+ }
63
+
64
+ export interface ResetRetryOptions {
65
+ abortSignal?: AbortSignal;
66
+ /** Short host/path label for the retry warn log (no secrets/query strings). */
67
+ label?: string;
68
+ attempts?: number;
69
+ }
70
+
71
+ /**
72
+ * Run `doFetch`, retrying only connection-reset-shaped rejections (see
73
+ * isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe
74
+ * (string body); every retry is logged so persistent resets stay visible.
75
+ */
76
+ export async function fetchWithResetRetry(
77
+ doFetch: () => Promise<Response>,
78
+ opts: ResetRetryOptions = {},
79
+ ): Promise<Response> {
80
+ const attempts = Math.max(1, opts.attempts ?? RESET_RETRY_MAX_ATTEMPTS);
81
+ let lastError: unknown;
82
+ for (let attempt = 0; attempt < attempts; attempt++) {
83
+ if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
84
+ try {
85
+ return await doFetch();
86
+ } catch (err) {
87
+ if (opts.abortSignal?.aborted || !isConnectionResetError(err) || attempt === attempts - 1) throw err;
88
+ lastError = err;
89
+ console.warn(
90
+ `[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
91
+ );
92
+ await sleepWithAbort(retryDelayMs(attempt), opts.abortSignal);
93
+ }
94
+ }
95
+ throw lastError ?? new Error("upstream fetch failed");
96
+ }
@@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
3
  import { signalWithTimeout, cancelBodyOnAbort } from "../abort";
4
4
  import { sidecarEnter } from "../sidecar-tracker";
5
+ import { fetchWithResetRetry } from "../upstream-retry";
5
6
  import { parseSidecarSSE } from "../web-search/parse";
6
7
  import type { SidecarOutcomeRecorder } from "../web-search/executor";
7
8
 
@@ -84,12 +85,15 @@ export async function describeImage(
84
85
  const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
85
86
  const sidecarExit = sidecarEnter("vision");
86
87
  try {
87
- const res = await fetch(`${forwardProvider.baseUrl}/responses`, {
88
- method: "POST",
89
- headers,
90
- body: JSON.stringify(body),
91
- signal: linkedSignal.signal,
92
- });
88
+ const res = await fetchWithResetRetry(
89
+ () => fetch(`${forwardProvider.baseUrl}/responses`, {
90
+ method: "POST",
91
+ headers,
92
+ body: JSON.stringify(body),
93
+ signal: linkedSignal.signal,
94
+ }),
95
+ { abortSignal: linkedSignal.signal, label: "vision-sidecar" },
96
+ );
93
97
  recordOutcome?.(res.status);
94
98
  if (!res.ok) {
95
99
  const t = await res.text().catch(() => "");
@@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
3
  import { signalWithTimeout, cancelBodyOnAbort } from "../abort";
4
4
  import { sidecarEnter } from "../sidecar-tracker";
5
+ import { fetchWithResetRetry } from "../upstream-retry";
5
6
  import { parseSidecarSSE, type WebSearchResult } from "./parse";
6
7
  import type { CodexUpstreamOutcome } from "../codex-routing";
7
8
 
@@ -67,12 +68,15 @@ export async function runWebSearch(
67
68
  const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
68
69
  const sidecarExit = sidecarEnter("web-search");
69
70
  try {
70
- const res = await fetch(url, {
71
- method: "POST",
72
- headers,
73
- body: JSON.stringify(body),
74
- signal: linkedSignal.signal,
75
- });
71
+ const res = await fetchWithResetRetry(
72
+ () => fetch(url, {
73
+ method: "POST",
74
+ headers,
75
+ body: JSON.stringify(body),
76
+ signal: linkedSignal.signal,
77
+ }),
78
+ { abortSignal: linkedSignal.signal, label: "web-search-sidecar" },
79
+ );
76
80
  recordOutcome?.(res.status);
77
81
  if (!res.ok) {
78
82
  const t = await res.text().catch(() => "");
@@ -4,6 +4,7 @@ import { namespacedToolName } from "../types";
4
4
  import { bridgeToResponsesSSE } from "../bridge";
5
5
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
6
6
  import { cancelBodyOnAbort } from "../abort";
7
+ import { fetchWithResetRetry } from "../upstream-retry";
7
8
  import { formatWebSearchResults } from "./format-result";
8
9
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
9
10
 
@@ -200,12 +201,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
200
201
  try {
201
202
  resp = adapter.fetchResponse
202
203
  ? await adapter.fetchResponse(request, { abortSignal: signal })
203
- : await fetch(request.url, {
204
- method: request.method,
205
- headers: request.headers,
206
- body: request.body,
207
- signal,
208
- });
204
+ : await fetchWithResetRetry(
205
+ () => fetch(request.url, {
206
+ method: request.method,
207
+ headers: request.headers,
208
+ body: request.body,
209
+ signal,
210
+ }),
211
+ { abortSignal: signal, label: "web-search-loop" },
212
+ );
209
213
  } catch (e) {
210
214
  throw new LoopError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
211
215
  }