@bitkyc08/opencodex 2.7.0 → 2.7.1
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-BUAMcKFd.css +1 -0
- package/gui/dist/assets/index-CtHGtaW8.js +34 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/codex/warmup.ts +1 -2
- package/src/reasoning-effort.ts +5 -0
- package/src/server/management-api.ts +26 -5
- package/src/server/request-decompress.ts +8 -2
- package/src/server/responses.ts +34 -11
- package/src/types.ts +6 -0
- package/src/vision/describe.ts +4 -0
- package/src/web-search/executor.ts +4 -0
- package/src/web-search/format-result.ts +11 -3
- package/src/web-search/index.ts +30 -1
- package/src/web-search/loop.ts +112 -61
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BGdxwydf.js +0 -34
- package/gui/dist/assets/index-DANCQ2Jt.css +0 -1
package/src/web-search/loop.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx
|
|
|
3
3
|
import { namespacedToolName } from "../types";
|
|
4
4
|
import { bridgeToResponsesSSE } from "../bridge";
|
|
5
5
|
import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
|
|
6
|
-
import { cancelBodyOnAbort } from "../lib/abort";
|
|
6
|
+
import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
|
|
7
7
|
import { fetchWithResetRetry } from "../lib/upstream-retry";
|
|
8
8
|
import { formatWebSearchResults } from "./format-result";
|
|
9
9
|
import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
|
|
@@ -170,6 +170,13 @@ export interface WebSearchLoopDeps {
|
|
|
170
170
|
recordSidecarOutcome?: SidecarOutcomeRecorder;
|
|
171
171
|
/** Per-iteration deadline for routed model calls (mirrors the normal path's connectTimeoutMs). */
|
|
172
172
|
connectTimeoutMs?: number;
|
|
173
|
+
/**
|
|
174
|
+
* Effective bridge stall deadline for this turn (seconds). Computed by planWebSearch
|
|
175
|
+
* (webSearchStallTimeoutSec) to cover the loop's bounded silent units — a non-streaming model
|
|
176
|
+
* iteration (connectTimeoutMs) or one sidecar search (settings.timeoutMs) — so a legitimately
|
|
177
|
+
* slow-but-bounded unit never trips the bridge's 90s default upstream_stall_timeout.
|
|
178
|
+
*/
|
|
179
|
+
stallTimeoutSec?: number;
|
|
173
180
|
/**
|
|
174
181
|
* 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter,
|
|
175
182
|
* or null when the pool is exhausted (same semantics as the normal routed path).
|
|
@@ -190,6 +197,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
190
197
|
if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
|
|
191
198
|
|
|
192
199
|
const messages: OcxMessage[] = [...parsed.context.messages];
|
|
200
|
+
const loopT0 = Date.now();
|
|
193
201
|
const allTools = parsed.context.tools ?? [];
|
|
194
202
|
// For the forced-answer pass we drop the synthetic web_search tool so the model MUST answer from the
|
|
195
203
|
// results already in `messages` (can't search again) — this guarantees a non-empty final answer.
|
|
@@ -214,11 +222,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
214
222
|
// Hard iteration bound (termination safety net); forceAnswer normally ends the loop sooner.
|
|
215
223
|
const HARD_CAP = maxSearches + 2;
|
|
216
224
|
|
|
217
|
-
// Run one model iteration: build the request, fetch it, parse to adapter events.
|
|
218
|
-
// scanned split. Throws `LoopError` on a hard
|
|
219
|
-
// turn it into a non-200 jsonError
|
|
220
|
-
//
|
|
221
|
-
|
|
225
|
+
// Run one model iteration: build the request, fetch it, parse to adapter events. RETURNS the
|
|
226
|
+
// scanned split (generator return value, not a yield). Throws `LoopError` on a hard
|
|
227
|
+
// provider/parse failure so the EAGER first call can turn it into a non-200 jsonError
|
|
228
|
+
// (preserving the status contract), while later iterations — already inside the 200 SSE —
|
|
229
|
+
// surface it as an in-stream error event. A generator so the 429 key-failover loop can YIELD a
|
|
230
|
+
// heartbeat between bounded retry fetches: the iteration-wide AbortSignal.timeout bounds the
|
|
231
|
+
// plain-fetch path, but adapters with their own fetchResponse timeout handling could otherwise
|
|
232
|
+
// chain silent retries past the bridge stall deadline.
|
|
233
|
+
const runIterationEvents = async function* (forceAnswer: boolean): AsyncGenerator<AdapterEvent, { calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean }> {
|
|
222
234
|
// On the forced-answer pass the synthetic web_search tool is gone, so the model MUST answer
|
|
223
235
|
// from the results already in `messages`. A weak model can still produce a thin answer that
|
|
224
236
|
// ignores what the search found, which reads to the user as "the search did nothing". Nudge it
|
|
@@ -233,61 +245,77 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
233
245
|
};
|
|
234
246
|
// Per-iteration deadline: routed calls elsewhere carry connectTimeoutMs; without it a hung
|
|
235
247
|
// upstream would stall the whole loop until the client gives up.
|
|
236
|
-
const
|
|
237
|
-
?
|
|
238
|
-
:
|
|
239
|
-
const
|
|
240
|
-
|
|
248
|
+
const iterationTimeout = deps.connectTimeoutMs
|
|
249
|
+
? signalWithTimeout(deps.connectTimeoutMs, signal)
|
|
250
|
+
: null;
|
|
251
|
+
const iterationSignal = iterationTimeout?.signal ?? signal;
|
|
252
|
+
try {
|
|
253
|
+
const fetchOnce = async (): Promise<Response> => {
|
|
254
|
+
const request = await adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
|
|
255
|
+
try {
|
|
256
|
+
return adapter.fetchResponse
|
|
257
|
+
? await adapter.fetchResponse(request, { abortSignal: iterationSignal, ...(deps.connectTimeoutMs ? { timeoutMs: deps.connectTimeoutMs } : {}) })
|
|
258
|
+
: await fetchWithResetRetry(
|
|
259
|
+
() => fetch(request.url, {
|
|
260
|
+
method: request.method,
|
|
261
|
+
headers: request.headers,
|
|
262
|
+
body: request.body,
|
|
263
|
+
signal: iterationSignal,
|
|
264
|
+
}),
|
|
265
|
+
{ abortSignal: iterationSignal, label: "web-search-loop" },
|
|
266
|
+
);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
if (!signal.aborted && iterationSignal.aborted) {
|
|
269
|
+
throw new LoopError(504, `Provider timeout after ${deps.connectTimeoutMs}ms during web-search`);
|
|
270
|
+
}
|
|
271
|
+
throw new LoopError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
let resp = await fetchOnce();
|
|
275
|
+
// 429 key-failover parity with the normal routed path: rotate pool keys until one responds
|
|
276
|
+
// or the pool is exhausted (deps.on429 returns null — cooldown map guarantees termination).
|
|
277
|
+
while (resp.status === 429 && deps.on429) {
|
|
278
|
+
const rotated = deps.on429(resp.headers.get("retry-after"));
|
|
279
|
+
if (!rotated?.parseResponse) break;
|
|
280
|
+
try { void resp.body?.cancel(); } catch { /* already consumed */ }
|
|
281
|
+
adapter = rotated;
|
|
282
|
+
// Stall-watchdog seam between bounded retry fetches (audit 011 B3).
|
|
283
|
+
yield { type: "heartbeat" };
|
|
284
|
+
resp = await fetchOnce();
|
|
285
|
+
}
|
|
286
|
+
if (!resp.ok) {
|
|
287
|
+
const t = await resp.text().catch(() => "");
|
|
288
|
+
throw new LoopError(resp.status, `Provider error ${resp.status}: ${t.slice(0, 400)}`);
|
|
289
|
+
}
|
|
290
|
+
// The fetch above carries `signal`; when the turn is superseded/cancelled, Bun aborts the
|
|
291
|
+
// response body stream. If parseResponse hasn't attached a reader yet, the body's pending read is
|
|
292
|
+
// orphaned off the awaited path and surfaces as `unhandledRejection: TypeError: null is not an
|
|
293
|
+
// object` (native-only stack). Proactively cancel the body on abort so WE settle it, and guard
|
|
294
|
+
// the drain so a mid-decode abort/stream error ends cleanly instead of throwing.
|
|
295
|
+
const detachBodyGuard = cancelBodyOnAbort(resp.body, signal);
|
|
296
|
+
let events: AdapterEvent[];
|
|
241
297
|
try {
|
|
242
|
-
|
|
243
|
-
? await adapter.fetchResponse(request, { abortSignal: iterationSignal, ...(deps.connectTimeoutMs ? { timeoutMs: deps.connectTimeoutMs } : {}) })
|
|
244
|
-
: await fetchWithResetRetry(
|
|
245
|
-
() => fetch(request.url, {
|
|
246
|
-
method: request.method,
|
|
247
|
-
headers: request.headers,
|
|
248
|
-
body: request.body,
|
|
249
|
-
signal: iterationSignal,
|
|
250
|
-
}),
|
|
251
|
-
{ abortSignal: iterationSignal, label: "web-search-loop" },
|
|
252
|
-
);
|
|
298
|
+
events = await adapter.parseResponse!(resp);
|
|
253
299
|
} catch (e) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
300
|
+
await resp.body?.cancel().catch(() => {});
|
|
301
|
+
if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
|
|
302
|
+
throw new LoopError(502, `Provider stream error: ${e instanceof Error ? e.message : String(e)}`);
|
|
303
|
+
} finally {
|
|
304
|
+
detachBodyGuard();
|
|
258
305
|
}
|
|
259
|
-
|
|
260
|
-
let resp = await fetchOnce();
|
|
261
|
-
// 429 key-failover parity with the normal routed path: rotate pool keys until one responds
|
|
262
|
-
// or the pool is exhausted (deps.on429 returns null — cooldown map guarantees termination).
|
|
263
|
-
while (resp.status === 429 && deps.on429) {
|
|
264
|
-
const rotated = deps.on429(resp.headers.get("retry-after"));
|
|
265
|
-
if (!rotated?.parseResponse) break;
|
|
266
|
-
try { void resp.body?.cancel(); } catch { /* already consumed */ }
|
|
267
|
-
adapter = rotated;
|
|
268
|
-
resp = await fetchOnce();
|
|
269
|
-
}
|
|
270
|
-
if (!resp.ok) {
|
|
271
|
-
const t = await resp.text().catch(() => "");
|
|
272
|
-
throw new LoopError(resp.status, `Provider error ${resp.status}: ${t.slice(0, 400)}`);
|
|
273
|
-
}
|
|
274
|
-
// The fetch above carries `signal`; when the turn is superseded/cancelled, Bun aborts the
|
|
275
|
-
// response body stream. If parseResponse hasn't attached a reader yet, the body's pending read is
|
|
276
|
-
// orphaned off the awaited path and surfaces as `unhandledRejection: TypeError: null is not an
|
|
277
|
-
// object` (native-only stack). Proactively cancel the body on abort so WE settle it, and guard
|
|
278
|
-
// the drain so a mid-decode abort/stream error ends cleanly instead of throwing.
|
|
279
|
-
const detachBodyGuard = cancelBodyOnAbort(resp.body, signal);
|
|
280
|
-
let events: AdapterEvent[];
|
|
281
|
-
try {
|
|
282
|
-
events = await adapter.parseResponse!(resp);
|
|
283
|
-
} catch (e) {
|
|
284
|
-
await resp.body?.cancel().catch(() => {});
|
|
285
|
-
if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
|
|
286
|
-
throw new LoopError(502, `Provider stream error: ${e instanceof Error ? e.message : String(e)}`);
|
|
306
|
+
return scanEventsForWebSearch(events);
|
|
287
307
|
} finally {
|
|
288
|
-
|
|
308
|
+
iterationTimeout?.cleanup();
|
|
289
309
|
}
|
|
290
|
-
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Drain an iteration OUTSIDE the bridge (eager first call): the stall deadline is not armed
|
|
313
|
+
// before bridgeToResponsesSSE exists, so seam heartbeats have nowhere to go — discard them.
|
|
314
|
+
const runIterationDrained = async (forceAnswer: boolean): Promise<{ calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean }> => {
|
|
315
|
+
const it = runIterationEvents(forceAnswer);
|
|
316
|
+
let r = await it.next();
|
|
317
|
+
while (!r.done) r = await it.next();
|
|
318
|
+
return r.value;
|
|
291
319
|
};
|
|
292
320
|
|
|
293
321
|
// Execute one model-requested web_search call. The call may batch several queries (native
|
|
@@ -306,6 +334,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
306
334
|
results.push({ query: "", outcome: { text: "", sources: [], error: "the model called web_search with an empty query" } });
|
|
307
335
|
}
|
|
308
336
|
for (const query of call.queries) {
|
|
337
|
+
// Stall-watchdog seam: batched queries run sequentially inside ONE begin/end cell, and
|
|
338
|
+
// placeholder outcomes (repeat/limit) emit no cell at all — without this, consecutive
|
|
339
|
+
// bounded units chain into one silent span past the stall deadline (audit 011 B1).
|
|
340
|
+
yield { type: "heartbeat" };
|
|
309
341
|
let outcome: SidecarOutcome;
|
|
310
342
|
if (failedQueries.has(normalizeQuery(query))) {
|
|
311
343
|
// Already failed this turn — don't spend another real search on it.
|
|
@@ -375,7 +407,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
375
407
|
// streaming starts (the status contract Codex relies on). Later iterations run live inside the SSE.
|
|
376
408
|
let first: { calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean };
|
|
377
409
|
try {
|
|
378
|
-
first = await
|
|
410
|
+
first = await runIterationDrained(false);
|
|
379
411
|
} catch (e) {
|
|
380
412
|
if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
|
|
381
413
|
if (e instanceof LoopError) return jsonError(e.status, e.message);
|
|
@@ -401,7 +433,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
401
433
|
// First loop turn reuses the eager result; subsequent turns run a fresh iteration here.
|
|
402
434
|
if (i > 0) {
|
|
403
435
|
try {
|
|
404
|
-
|
|
436
|
+
// Stall-watchdog seam before each in-stream iteration (placeholder search turns emit no
|
|
437
|
+
// cell, so consecutive non-streaming iterations would otherwise be one silent span).
|
|
438
|
+
yield { type: "heartbeat" };
|
|
439
|
+
// Delegate so seam heartbeats reach the bridge, the scanned split returns here, and an
|
|
440
|
+
// outer consumer close propagates into the iteration generator's cleanup.
|
|
441
|
+
split = yield* runIterationEvents(forceAnswer);
|
|
405
442
|
} catch (e) {
|
|
406
443
|
yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };
|
|
407
444
|
return;
|
|
@@ -411,7 +448,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
411
448
|
// tool call (e.g. shell/apply_patch) means this turn is terminal for Codex — finalize so those
|
|
412
449
|
// calls reach Codex. forceAnswer also finalizes.
|
|
413
450
|
const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceAnswer;
|
|
414
|
-
|
|
451
|
+
if (!shouldLoop) {
|
|
452
|
+
if (executedSearchCount > 0) {
|
|
453
|
+
const failedCount = failedQueries.size;
|
|
454
|
+
console.warn(
|
|
455
|
+
`[web-search-loop] done — ${executedSearchCount} search${executedSearchCount > 1 ? "es" : ""}`
|
|
456
|
+
+ (failedCount > 0 ? ` (${failedCount} failed)` : "")
|
|
457
|
+
+ `, ${i + 1} iteration${i > 0 ? "s" : ""}, ${Date.now() - loopT0}ms`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
415
460
|
yield* replay(split.passthrough);
|
|
416
461
|
return;
|
|
417
462
|
}
|
|
@@ -424,11 +469,17 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
424
469
|
}
|
|
425
470
|
|
|
426
471
|
const sse = bridgeToResponsesSSE(
|
|
427
|
-
produce(), parsed.modelId, toolNsMap, freeform, toolSearch,
|
|
428
|
-
|
|
472
|
+
produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
|
|
473
|
+
const elapsed = Date.now() - loopT0;
|
|
474
|
+
if (executedSearchCount > 0 || searchesExecuted > 0) {
|
|
475
|
+
console.warn(`[web-search-loop] cancelled — ${executedSearchCount} real searches, ${searchesExecuted - executedSearchCount} placeholders, ${elapsed}ms`);
|
|
476
|
+
}
|
|
477
|
+
internalAbort.abort("client closed responses stream");
|
|
478
|
+
}, undefined,
|
|
429
479
|
{
|
|
430
480
|
...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
431
481
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
482
|
+
...(deps.stallTimeoutSec !== undefined ? { stallTimeoutSec: deps.stallTimeoutSec } : {}),
|
|
432
483
|
},
|
|
433
484
|
);
|
|
434
485
|
return new Response(sse, { headers: SSE_HEADERS });
|
package/src/web-search/parse.ts
CHANGED
|
@@ -154,7 +154,10 @@ export async function parseSidecarSSE(response: Response): Promise<WebSearchResu
|
|
|
154
154
|
const handle = (payload: string): void => {
|
|
155
155
|
if (!payload || payload === "[DONE]") return;
|
|
156
156
|
let data: Record<string, unknown>;
|
|
157
|
-
try { data = JSON.parse(payload) as Record<string, unknown>; } catch {
|
|
157
|
+
try { data = JSON.parse(payload) as Record<string, unknown>; } catch {
|
|
158
|
+
console.warn(`[web-search-parse] malformed SSE JSON (${payload.length} chars): ${payload.slice(0, 120)}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
158
161
|
const type = data.type as string | undefined;
|
|
159
162
|
if (type === "response.output_text.delta" && typeof data.delta === "string") {
|
|
160
163
|
acc.deltaText += data.delta;
|