@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.
- package/gui/dist/assets/index-Barime1y.js +9 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/transport-retry.ts +5 -26
- package/src/adapters/google-http.ts +1 -16
- package/src/adapters/kiro-retry.ts +1 -25
- package/src/cli.ts +6 -0
- package/src/codex-catalog.ts +25 -2
- package/src/doctor.ts +153 -7
- package/src/oauth/index.ts +26 -1
- package/src/oauth/token-guardian.ts +200 -0
- package/src/providers/registry.ts +27 -1
- package/src/server.ts +113 -12
- package/src/types.ts +41 -0
- package/src/upstream-retry.ts +96 -0
- package/src/vision/describe.ts +10 -6
- package/src/web-search/executor.ts +10 -6
- package/src/web-search/loop.ts +10 -6
- package/gui/dist/assets/index-DbTEyo46.js +0 -9
|
@@ -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
|
-
{
|
|
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
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
|
567
|
-
|
|
568
|
-
|
|
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
|
|
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
|
+
}
|
package/src/vision/describe.ts
CHANGED
|
@@ -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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(() => "");
|
package/src/web-search/loop.ts
CHANGED
|
@@ -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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
}
|