@bitkyc08/opencodex 2.49.0 → 2.50.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.
Files changed (69) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  4. package/gui/dist/index.html +1 -1
  5. package/package.json +1 -1
  6. package/src/claude/inbound.ts +17 -5
  7. package/src/cli/account-api.ts +18 -3
  8. package/src/cli/account-auth.ts +8 -1
  9. package/src/cli/account-extended.ts +2 -1
  10. package/src/cli/account.ts +1 -0
  11. package/src/cli/capabilities.ts +15 -1
  12. package/src/cli/index.ts +5 -1
  13. package/src/cli/models-runtime.ts +8 -3
  14. package/src/cli/observe.ts +13 -3
  15. package/src/clients/config-export/zcode.ts +24 -0
  16. package/src/codex/account-runtime-state.ts +6 -1
  17. package/src/codex/account-store.ts +72 -9
  18. package/src/codex/account-usability.ts +3 -2
  19. package/src/codex/auth-api.ts +107 -23
  20. package/src/codex/auth-context.ts +21 -0
  21. package/src/codex/catalog/parsing.ts +23 -0
  22. package/src/codex/catalog/provider-fetch.ts +71 -2
  23. package/src/codex/catalog/sync.ts +14 -0
  24. package/src/codex/inject.ts +3 -2
  25. package/src/codex/quota-auto-refresh.ts +6 -1
  26. package/src/codex/quota.ts +54 -8
  27. package/src/combos/index.ts +2 -0
  28. package/src/combos/resolve.ts +52 -0
  29. package/src/config.ts +58 -0
  30. package/src/generated/compatibility-version.json +72 -60
  31. package/src/lib/errors.ts +8 -0
  32. package/src/lib/privacy.ts +25 -0
  33. package/src/oauth/health.ts +47 -12
  34. package/src/oauth/index.ts +46 -8
  35. package/src/oauth/token-guardian.ts +32 -6
  36. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  37. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  38. package/src/providers/quota.ts +15 -0
  39. package/src/providers/registry.ts +1 -1
  40. package/src/server/auth-cors.ts +6 -0
  41. package/src/server/chat-completions.ts +4 -4
  42. package/src/server/chat-native.ts +10 -1
  43. package/src/server/claude-messages.ts +5 -5
  44. package/src/server/images.ts +2 -2
  45. package/src/server/index.ts +25 -2
  46. package/src/server/management/logs-usage-routes.ts +4 -1
  47. package/src/server/management/model-rows.ts +16 -1
  48. package/src/server/management/oauth-account-routes.ts +6 -2
  49. package/src/server/management/provider-routes.ts +9 -2
  50. package/src/server/management/request-history-routes.ts +4 -2
  51. package/src/server/management/route-registry.ts +5 -4
  52. package/src/server/management/shared.ts +66 -3
  53. package/src/server/management-api.ts +1 -1
  54. package/src/server/request-decompress.ts +91 -3
  55. package/src/server/request-log.ts +10 -0
  56. package/src/server/responses/codex-ws-wire.ts +1 -1
  57. package/src/server/responses/compact.ts +8 -2
  58. package/src/server/responses/context-overflow.ts +11 -0
  59. package/src/server/responses/core.ts +144 -38
  60. package/src/server/responses/policy-fallback.ts +6 -2
  61. package/src/server/search.ts +2 -2
  62. package/src/service.ts +92 -7
  63. package/src/types/accounts.ts +18 -0
  64. package/src/types/config.ts +36 -0
  65. package/src/types/provider.ts +56 -0
  66. package/src/types.ts +4 -0
  67. package/src/web-search/ollama-executor.ts +127 -0
  68. package/src/web-search/passthrough-bridge.ts +761 -0
  69. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -0,0 +1,761 @@
1
+ /**
2
+ * Hosted-web-search bridge for the KEY-auth Responses passthrough (#3761).
3
+ *
4
+ * The Codex App always declares the hosted "{type:'web_search'}" tool. On the passthrough the
5
+ * proxy reads that declaration as "the destination executes search itself" and relays it
6
+ * unchanged, which is correct for the ChatGPT backend and for xAI. It is wrong for an
7
+ * OpenAI-shaped KEY gateway that does not run the hosted tool: Ollama Cloud GLM answers with a
8
+ * plain "{type:'function_call', name:'web_search'}", nothing on either side executes it, and the
9
+ * undeclared-tool guard ends the turn because a hosted declaration never authorizes a client
10
+ * function name.
11
+ *
12
+ * This module is the opt-in repair, armed only by "providers.<name>.webSearchBridge.enabled".
13
+ * It intercepts that one call out of the upstream stream, runs the configured search backend
14
+ * itself, feeds the call and its result back to the SAME upstream in a fresh POST, and shows
15
+ * Codex the hosted "web_search_call" cell it already understands. The offending function_call
16
+ * never reaches the client, and "web_search" is never added to the guard's allowed names --
17
+ * doing that would authorize a call nobody can execute rather than removing it.
18
+ *
19
+ * Deliberate boundaries of this first slice:
20
+ * - Streaming SSE turns only. A non-streaming turn stays on the existing path.
21
+ * - A leg that mixes the search call with any OTHER client tool call fails closed with an
22
+ * explicit error. Answering both would need the raw mixed-tool continuation contract the
23
+ * 2.47 track deferred (devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md),
24
+ * and silently half-doing it would drop the client's own tool call.
25
+ * - Continuation legs use a direct send rather than the core recovery ladder: the first leg
26
+ * still goes through it, and a KEY-auth destination has no OAuth refresh path to replay.
27
+ * The caller's outbound body ceiling is re-applied to every continuation body.
28
+ * - The client stream is renumbered (sequence_number and output_index) because events are both
29
+ * dropped and injected; a plain relay cannot preserve upstream numbering through that.
30
+ *
31
+ * The stream this module produces is ordinary Responses SSE and is handed back to the core relay,
32
+ * so the undeclared-tool guard, the provider payload rewrites, terminal-outcome recording, and the
33
+ * continuation cache all still apply to it. That is what keeps the guard's authority intact over
34
+ * every OTHER call an upstream emits: the bridge removes only the web_search call it executes.
35
+ */
36
+ import { nextSseBlock, sseDataPayload } from "../server/sse-payload-rewrite";
37
+ import { toolChoiceToolPredicate } from "../types";
38
+ import type { OcxParsedRequest, OcxProviderConfig, ProviderWebSearchBridgeBackend } from "../types";
39
+ import type { SidecarOutcome } from "./executor";
40
+ import { buildWebSearchTool, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
41
+ import { safeWebSearchSources } from "./sources";
42
+ import { runOllamaWebSearch } from "./ollama-executor";
43
+
44
+ /** Canonical Ollama Cloud origin. The only origin the "ollama" backend derives on its own. */
45
+ export const OLLAMA_CLOUD_ORIGIN = "https://ollama.com";
46
+ const OLLAMA_WEB_SEARCH_PATH = "/api/web_search";
47
+
48
+ const DEFAULT_BRIDGE_MAX_SEARCHES = 3;
49
+ const DEFAULT_BRIDGE_TIMEOUT_MS = 60_000;
50
+ /** Queries honored from one call's "queries" array; the rest are ignored rather than billed. */
51
+ const MAX_QUERIES_PER_CALL = 3;
52
+ /** Hard ceiling on retained client-visible items before the terminal snapshot rewrite is skipped. */
53
+ const MAX_RETAINED_OUTPUT_ITEMS = 500;
54
+ /** Refuse to buffer an unbounded partial SSE event from a misbehaving upstream. */
55
+ const MAX_SSE_BUFFER_CHARS = 8 * 1024 * 1024;
56
+
57
+ export const WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE = "web_search_bridge_mixed_tools";
58
+ export const WEB_SEARCH_BRIDGE_ERROR_CODE = "web_search_bridge_failed";
59
+
60
+ /** Item types whose calls the CLIENT has to execute; any of them alongside a search is mixed. */
61
+ const CLIENT_EXECUTED_ITEM_TYPES = new Set([
62
+ "function_call",
63
+ "custom_tool_call",
64
+ "local_shell_call",
65
+ "tool_search_call",
66
+ "computer_call",
67
+ ]);
68
+
69
+ export interface PassthroughWebSearchBridgePlan {
70
+ /** Resolved executor id. Only "ollama" has a shipped executor today. */
71
+ backend: ProviderWebSearchBridgeBackend;
72
+ /** Absolute search-API URL the executor posts to. */
73
+ endpoint: string;
74
+ /** Searches actually executed per turn before further calls are refused. */
75
+ maxSearches: number;
76
+ /** Per-search deadline in milliseconds. */
77
+ timeoutMs: number;
78
+ }
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return !!value && typeof value === "object" && !Array.isArray(value);
82
+ }
83
+
84
+ function originOf(value: string | undefined): string | undefined {
85
+ if (!value) return undefined;
86
+ try {
87
+ const url = new URL(value);
88
+ if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
89
+ return url.origin;
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolve the search endpoint for the "ollama" backend.
97
+ *
98
+ * An explicit "endpoint" is the operator's own authorization: they are naming the destination
99
+ * that receives this provider's API key. Without one, the origin must be canonical Ollama Cloud
100
+ * -- a renamed row pointing at an arbitrary host must not silently receive the key just because
101
+ * its adapter happens to be openai-responses.
102
+ */
103
+ export function resolveOllamaWebSearchEndpoint(
104
+ provider: OcxProviderConfig,
105
+ ): string | undefined {
106
+ const configured = provider.webSearchBridge?.endpoint;
107
+ if (configured !== undefined) {
108
+ return originOf(configured) === undefined ? undefined : configured;
109
+ }
110
+ return originOf(provider.baseUrl) === OLLAMA_CLOUD_ORIGIN
111
+ ? OLLAMA_CLOUD_ORIGIN + OLLAMA_WEB_SEARCH_PATH
112
+ : undefined;
113
+ }
114
+
115
+ /**
116
+ * Decide whether this passthrough turn may run the web-search bridge.
117
+ *
118
+ * Fails closed on every axis. In particular it never arms for "authMode: 'forward'": that is the
119
+ * ChatGPT backend speaking Codex's own protocol with the caller's own credential, and it executes
120
+ * hosted search upstream. A provider that runs hosted search itself (xAI) also stays on the
121
+ * existing relay, because arming here would replace a real provider-side search with ours.
122
+ *
123
+ * This is a NEW planner rather than a relaxation of "isPassthrough" in planWebSearch: the sidecar
124
+ * rewrites normalized messages, while this path must preserve the raw Responses conversation.
125
+ */
126
+ export function planPassthroughWebSearchBridge(
127
+ parsed: OcxParsedRequest,
128
+ provider: OcxProviderConfig,
129
+ options: { isPassthrough: boolean; stream: boolean },
130
+ ): PassthroughWebSearchBridgePlan | undefined {
131
+ if (!options.isPassthrough || !options.stream) return undefined;
132
+ if (!parsed._webSearch) return undefined;
133
+ // Never spend a forwarded ChatGPT credential on a proxy-run search, and never pre-empt a
134
+ // provider that executes the hosted tool itself.
135
+ if (provider.authMode !== "key") return undefined;
136
+ const bridge = provider.webSearchBridge;
137
+ if (!bridge || bridge.enabled !== true) return undefined;
138
+ // A tool_choice that excludes web search excludes the bridge too; the model may not search.
139
+ if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined;
140
+ // Explicit-only, and inert for every backend whose executor has not shipped.
141
+ if (bridge.backend !== "ollama") return undefined;
142
+ const endpoint = resolveOllamaWebSearchEndpoint(provider);
143
+ if (!endpoint) return undefined;
144
+ const maxSearches = Number.isInteger(bridge.maxSearches)
145
+ && bridge.maxSearches! >= 1
146
+ && bridge.maxSearches! <= 10
147
+ ? bridge.maxSearches!
148
+ : DEFAULT_BRIDGE_MAX_SEARCHES;
149
+ const timeoutMs = Number.isInteger(bridge.timeoutMs)
150
+ && bridge.timeoutMs! >= 1_000
151
+ && bridge.timeoutMs! <= 600_000
152
+ ? bridge.timeoutMs!
153
+ : DEFAULT_BRIDGE_TIMEOUT_MS;
154
+ return { backend: "ollama", endpoint, maxSearches, timeoutMs };
155
+ }
156
+
157
+ /** One intercepted search call, carried from the upstream stream into the next request body. */
158
+ export interface InterceptedSearchCall {
159
+ callId: string;
160
+ argumentsText: string;
161
+ /** Upstream item id of the call this bridge answered; replayed on the continuation item. */
162
+ sourceItemId?: string;
163
+ /** Client-facing hosted cell opened in place of the intercepted call. */
164
+ cellItemId: string;
165
+ cellOutputIndex: number;
166
+ /** Slot reserved in the terminal snapshot so the cell keeps its streamed position. */
167
+ retainedSlot?: number;
168
+ }
169
+
170
+ export type PassthroughWebSearchBridgeExecutor = (
171
+ queries: string[],
172
+ signal?: AbortSignal,
173
+ ) => Promise<SidecarOutcome>;
174
+
175
+ export interface PassthroughWebSearchBridgeStreamOptions {
176
+ plan: PassthroughWebSearchBridgePlan;
177
+ /** The already-open first upstream leg, obtained through the normal core send path. */
178
+ firstLeg: ReadableStream<Uint8Array>;
179
+ /** The exact outbound body that produced the first leg; continuation legs extend it. */
180
+ requestBody: string;
181
+ /** Sends one continuation leg and resolves with its response. */
182
+ send: (body: string) => Promise<Response>;
183
+ execute: PassthroughWebSearchBridgeExecutor;
184
+ /**
185
+ * Re-applies the caller's outbound body ceiling to a continuation body. Returns a refusal
186
+ * message when the extended body may not be sent, or undefined when it is admitted.
187
+ */
188
+ checkOutboundBody?: (body: string) => string | undefined;
189
+ signal?: AbortSignal;
190
+ }
191
+
192
+ function parseQueries(argumentsText: string): string[] {
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(argumentsText);
196
+ } catch {
197
+ // A non-JSON argument blob is still a search intent; treat the raw text as the query.
198
+ const trimmed = argumentsText.trim();
199
+ return trimmed.length > 0 ? [trimmed.slice(0, 1_000)] : [];
200
+ }
201
+ if (!isRecord(parsed)) return [];
202
+ const queries: string[] = [];
203
+ const push = (value: unknown): void => {
204
+ if (typeof value !== "string") return;
205
+ const trimmed = value.trim();
206
+ if (trimmed.length === 0 || queries.includes(trimmed)) return;
207
+ if (queries.length < MAX_QUERIES_PER_CALL) queries.push(trimmed.slice(0, 1_000));
208
+ };
209
+ push(parsed.query);
210
+ if (Array.isArray(parsed.queries)) for (const entry of parsed.queries) push(entry);
211
+ return queries;
212
+ }
213
+
214
+ function isWebSearchCallItem(item: unknown): boolean {
215
+ if (!isRecord(item)) return false;
216
+ if (item.type !== "function_call" && item.type !== "custom_tool_call") return false;
217
+ // A namespaced "ns__web_search" is a different tool identity that the client declared and
218
+ // executes itself; intercepting it would steal a call the client owns.
219
+ if (typeof item.namespace === "string") return false;
220
+ return item.name === WEB_SEARCH_TOOL_NAME;
221
+ }
222
+
223
+ function isClientExecutedItem(item: unknown): boolean {
224
+ return isRecord(item) && typeof item.type === "string" && CLIENT_EXECUTED_ITEM_TYPES.has(item.type);
225
+ }
226
+
227
+ /** Yield complete SSE event blocks. */
228
+ async function* readSseBlocks(
229
+ body: ReadableStream<Uint8Array>,
230
+ ): AsyncGenerator<{ block: string }> {
231
+ const reader = body.getReader();
232
+ const decoder = new TextDecoder();
233
+ let buffer = "";
234
+ const drain = function* (): Generator<{ block: string }> {
235
+ let next: ReturnType<typeof nextSseBlock>;
236
+ while ((next = nextSseBlock(buffer))) {
237
+ buffer = next.rest;
238
+ yield { block: next.block };
239
+ }
240
+ };
241
+ try {
242
+ for (;;) {
243
+ const { done, value } = await reader.read();
244
+ if (done) {
245
+ buffer += decoder.decode();
246
+ yield* drain();
247
+ if (buffer.length > 0) {
248
+ yield { block: buffer };
249
+ }
250
+ return;
251
+ }
252
+ buffer += decoder.decode(value, { stream: true });
253
+ if (buffer.length > MAX_SSE_BUFFER_CHARS) {
254
+ throw new Error("upstream SSE event exceeded the web-search bridge buffer bound");
255
+ }
256
+ yield* drain();
257
+ }
258
+ } finally {
259
+ reader.cancel().catch(() => {});
260
+ }
261
+ }
262
+ interface LegDecision {
263
+ kind: "end" | "continue" | "fail";
264
+ searches: InterceptedSearchCall[];
265
+ message?: string;
266
+ code?: string;
267
+ }
268
+
269
+ /** One client-executed call event held until the leg's fate is known. */
270
+ interface HeldCallEvent {
271
+ payload: Record<string, unknown>;
272
+ upstreamIndex?: number;
273
+ }
274
+
275
+ /**
276
+ * Stateful client-stream builder for one bridged turn.
277
+ *
278
+ * Owns the two numbering spaces the client sees. Upstream indices are per-leg and include items
279
+ * this bridge removes or injects, so every emitted event is remapped onto one monotonic client
280
+ * sequence. Client output_index is assigned at EMIT time, which is what keeps a held call's index
281
+ * consistent with the order the client actually receives.
282
+ */
283
+ class BridgeStreamState {
284
+ /** Client-facing sequence_number, rewritten on every emitted payload. */
285
+ private sequence = 0;
286
+ /** Next unused client output_index. */
287
+ private outputIndex = 0;
288
+ /** Client-visible finished items, used to rebuild the terminal snapshot after an injection. */
289
+ private readonly retainedItems: unknown[] = [];
290
+ private retainedItemsComplete = true;
291
+ private injected = false;
292
+
293
+ /** Per-leg upstream output_index -> client output_index. */
294
+ private indexMap = new Map<number, number>();
295
+ /** Upstream output_index -> the intercepted call that owns it, so parallel calls stay distinct. */
296
+ private suppressedSearches = new Map<number, InterceptedSearchCall>();
297
+ /** Item ids of intercepted calls, for events that carry item_id but no output_index. */
298
+ private suppressedItemIds = new Map<string, InterceptedSearchCall>();
299
+ private searches: InterceptedSearchCall[] = [];
300
+ /**
301
+ * Client-executed calls are withheld until the leg's fate is known. Emitting one and THEN
302
+ * failing the turn would let Codex start running a tool for a turn that never completes.
303
+ */
304
+ private heldCalls: HeldCallEvent[] = [];
305
+ private heldIndexes = new Set<number>();
306
+ private heldItemIds = new Set<string>();
307
+ private terminalPayload: Record<string, unknown> | undefined;
308
+
309
+ beginLeg(): void {
310
+ this.indexMap = new Map();
311
+ this.suppressedSearches = new Map();
312
+ this.suppressedItemIds = new Map();
313
+ this.searches = [];
314
+ this.heldCalls = [];
315
+ this.heldIndexes = new Set();
316
+ this.heldItemIds = new Set();
317
+ this.terminalPayload = undefined;
318
+ }
319
+
320
+ get sawClientExecutedCall(): boolean {
321
+ return this.heldCalls.length > 0;
322
+ }
323
+
324
+ private clientIndexFor(upstreamIndex: number): number {
325
+ const existing = this.indexMap.get(upstreamIndex);
326
+ if (existing !== undefined) return existing;
327
+ const assigned = this.outputIndex++;
328
+ this.indexMap.set(upstreamIndex, assigned);
329
+ return assigned;
330
+ }
331
+
332
+ /** Reserve a retained-snapshot slot so an item injected later keeps its streamed position. */
333
+ private reserveRetainedSlot(): number | undefined {
334
+ if (!this.retainedItemsComplete) return undefined;
335
+ if (this.retainedItems.length >= MAX_RETAINED_OUTPUT_ITEMS) {
336
+ this.retainedItemsComplete = false;
337
+ return undefined;
338
+ }
339
+ return this.retainedItems.push(undefined) - 1;
340
+ }
341
+
342
+ private retain(item: unknown, slot?: number): void {
343
+ if (!this.retainedItemsComplete) return;
344
+ if (slot !== undefined) {
345
+ this.retainedItems[slot] = item;
346
+ return;
347
+ }
348
+ if (this.retainedItems.length >= MAX_RETAINED_OUTPUT_ITEMS) {
349
+ this.retainedItemsComplete = false;
350
+ return;
351
+ }
352
+ this.retainedItems.push(item);
353
+ }
354
+
355
+ private render(type: string, data: Record<string, unknown>): string {
356
+ return "event: " + type + "\n"
357
+ + "data: " + JSON.stringify({ ...data, type, sequence_number: this.sequence++ });
358
+ }
359
+
360
+ failureFrames(code: string, message: string): string[] {
361
+ const failure = { type: "upstream_error", code, message };
362
+ return [
363
+ this.render("response.failed", {
364
+ response: { status: "failed", error: failure, last_error: failure },
365
+ }),
366
+ "data: [DONE]",
367
+ ];
368
+ }
369
+
370
+ searchEndFrames(call: InterceptedSearchCall, queries: string[], outcome: SidecarOutcome): string[] {
371
+ const sources = safeWebSearchSources(outcome.sources);
372
+ const first = queries[0] ?? "";
373
+ const item = {
374
+ type: "web_search_call",
375
+ id: call.cellItemId,
376
+ status: outcome.error ? "failed" : "completed",
377
+ action: { type: "search", query: first, queries: queries.length > 0 ? queries : [first] },
378
+ ...(sources.length > 0 ? { sources } : {}),
379
+ };
380
+ this.retain(item, call.retainedSlot);
381
+ return [this.render("response.output_item.done", { output_index: call.cellOutputIndex, item })];
382
+ }
383
+
384
+ /**
385
+ * Translate one upstream block into the blocks the client should receive now.
386
+ *
387
+ * Search-call events are replaced in place by the hosted cell's opening frame. Client-executed
388
+ * call events are withheld. The terminal is held: whether it ends the turn is only decided once
389
+ * the whole leg has been read.
390
+ */
391
+ consume(block: string, isFirstLeg: boolean): string[] {
392
+ const data = sseDataPayload(block);
393
+ if (data === null) return [block];
394
+ if (data === "[DONE]") return [];
395
+ let payload: unknown;
396
+ try {
397
+ payload = JSON.parse(data);
398
+ } catch {
399
+ return [block];
400
+ }
401
+ if (!isRecord(payload) || typeof payload.type !== "string") return [block];
402
+
403
+ // A continuation leg opens its own response lifecycle; the client already has one.
404
+ if ((payload.type === "response.created" || payload.type === "response.in_progress") && !isFirstLeg) {
405
+ return [];
406
+ }
407
+ if (payload.type === "response.completed"
408
+ || payload.type === "response.incomplete"
409
+ || payload.type === "response.failed") {
410
+ this.terminalPayload = payload;
411
+ return [];
412
+ }
413
+
414
+ const upstreamIndex = typeof payload.output_index === "number" ? payload.output_index : undefined;
415
+ const itemId = typeof payload.item_id === "string" ? payload.item_id : undefined;
416
+
417
+ if (payload.type === "response.output_item.added" && isRecord(payload.item)) {
418
+ const item = payload.item;
419
+ if (isWebSearchCallItem(item)) {
420
+ // Open the hosted cell exactly where the intercepted call stood, so a search that is not
421
+ // the last item of the turn keeps its position instead of being appended after it.
422
+ const cellOutputIndex = this.outputIndex++;
423
+ const intercepted: InterceptedSearchCall = {
424
+ callId: typeof item.call_id === "string" ? item.call_id : "",
425
+ sourceItemId: typeof item.id === "string" ? item.id : undefined,
426
+ argumentsText: typeof item.arguments === "string" ? item.arguments : "",
427
+ cellItemId: "ws_" + crypto.randomUUID(),
428
+ cellOutputIndex,
429
+ retainedSlot: this.reserveRetainedSlot(),
430
+ };
431
+ if (upstreamIndex !== undefined) this.suppressedSearches.set(upstreamIndex, intercepted);
432
+ if (intercepted.sourceItemId) this.suppressedItemIds.set(intercepted.sourceItemId, intercepted);
433
+ this.searches.push(intercepted);
434
+ this.injected = true;
435
+ return [this.render("response.output_item.added", {
436
+ output_index: cellOutputIndex,
437
+ item: { type: "web_search_call", id: intercepted.cellItemId, status: "in_progress" },
438
+ })];
439
+ }
440
+ if (isClientExecutedItem(item)) {
441
+ if (upstreamIndex !== undefined) this.heldIndexes.add(upstreamIndex);
442
+ if (typeof item.id === "string") this.heldItemIds.add(item.id);
443
+ this.heldCalls.push({ payload, ...(upstreamIndex === undefined ? {} : { upstreamIndex }) });
444
+ return [];
445
+ }
446
+ }
447
+
448
+ const pending = (upstreamIndex === undefined ? undefined : this.suppressedSearches.get(upstreamIndex))
449
+ ?? (itemId === undefined ? undefined : this.suppressedItemIds.get(itemId));
450
+ if (pending) {
451
+ // Argument deltas and the matching done frame belong to a call the client never sees;
452
+ // the done frame still carries the authoritative complete arguments.
453
+ if (payload.type === "response.output_item.done" && isRecord(payload.item)) {
454
+ const args = payload.item.arguments;
455
+ if (typeof args === "string" && args.length > 0) pending.argumentsText = args;
456
+ }
457
+ if (payload.type === "response.function_call_arguments.done" && typeof payload.arguments === "string") {
458
+ if (payload.arguments.length > 0) pending.argumentsText = payload.arguments;
459
+ }
460
+ return [];
461
+ }
462
+
463
+ if ((upstreamIndex !== undefined && this.heldIndexes.has(upstreamIndex))
464
+ || (itemId !== undefined && this.heldItemIds.has(itemId))) {
465
+ this.heldCalls.push({ payload, ...(upstreamIndex === undefined ? {} : { upstreamIndex }) });
466
+ return [];
467
+ }
468
+
469
+ const rewritten: Record<string, unknown> = { ...payload };
470
+ if (upstreamIndex !== undefined) rewritten.output_index = this.clientIndexFor(upstreamIndex);
471
+ if (payload.type === "response.output_item.done") this.retain(payload.item);
472
+ return [this.render(payload.type, rewritten)];
473
+ }
474
+
475
+ /** Release the withheld client tool calls once the turn is known to end here. */
476
+ flushHeldCalls(): string[] {
477
+ const blocks: string[] = [];
478
+ for (const held of this.heldCalls) {
479
+ const rewritten: Record<string, unknown> = { ...held.payload };
480
+ if (held.upstreamIndex !== undefined) {
481
+ rewritten.output_index = this.clientIndexFor(held.upstreamIndex);
482
+ }
483
+ if (held.payload.type === "response.output_item.done") this.retain(held.payload.item);
484
+ blocks.push(this.render(String(held.payload.type), rewritten));
485
+ }
486
+ this.heldCalls = [];
487
+ return blocks;
488
+ }
489
+
490
+ /** Decide what the leg's terminal means once the whole leg has been read. */
491
+ decide(remainingLegs: number): LegDecision {
492
+ if (this.searches.length === 0) return { kind: "end", searches: [] };
493
+ if (this.sawClientExecutedCall) {
494
+ return {
495
+ kind: "fail",
496
+ searches: this.searches,
497
+ code: WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE,
498
+ message: "routed provider requested web_search alongside another client tool in one turn; "
499
+ + "the web-search bridge cannot answer both without dropping the client's call",
500
+ };
501
+ }
502
+ const terminalType = this.terminalPayload?.type;
503
+ if (terminalType === "response.failed" || terminalType === "response.incomplete") {
504
+ return { kind: "end", searches: [] };
505
+ }
506
+ if (remainingLegs <= 0) {
507
+ return {
508
+ kind: "fail",
509
+ searches: this.searches,
510
+ code: WEB_SEARCH_BRIDGE_ERROR_CODE,
511
+ message: "web-search bridge exhausted its continuation budget for this turn",
512
+ };
513
+ }
514
+ return { kind: "continue", searches: this.searches };
515
+ }
516
+
517
+ /**
518
+ * Flush the held terminal. When searches were injected the snapshot is rebuilt from the items
519
+ * the client actually received, so response.output matches the streamed turn instead of
520
+ * showing only the final leg.
521
+ */
522
+ terminalFrames(): string[] {
523
+ const held = this.terminalPayload;
524
+ if (!held) return ["data: [DONE]"];
525
+ const payload: Record<string, unknown> = { ...held };
526
+ if (this.injected && this.retainedItemsComplete && isRecord(payload.response)) {
527
+ payload.response = {
528
+ ...payload.response,
529
+ output: this.retainedItems.filter(item => item !== undefined),
530
+ };
531
+ }
532
+ return [this.render(String(held.type), payload), "data: [DONE]"];
533
+ }
534
+ }
535
+
536
+ /** Append one executed search turn to the raw Responses body for the next leg. */
537
+ export function appendBridgeSearchTurn(
538
+ requestBody: string,
539
+ turns: readonly { call: InterceptedSearchCall; output: string }[],
540
+ ): string | undefined {
541
+ let parsed: unknown;
542
+ try {
543
+ parsed = JSON.parse(requestBody);
544
+ } catch {
545
+ return undefined;
546
+ }
547
+ if (!isRecord(parsed) || !Array.isArray(parsed.input)) return undefined;
548
+ const input = [...parsed.input];
549
+ for (const turn of turns) {
550
+ input.push({
551
+ type: "function_call",
552
+ ...(turn.call.sourceItemId ? { id: turn.call.sourceItemId } : {}),
553
+ call_id: turn.call.callId,
554
+ name: WEB_SEARCH_TOOL_NAME,
555
+ arguments: turn.call.argumentsText || "{}",
556
+ });
557
+ input.push({
558
+ type: "function_call_output",
559
+ call_id: turn.call.callId,
560
+ output: turn.output,
561
+ });
562
+ }
563
+ return JSON.stringify({ ...parsed, input, stream: true });
564
+ }
565
+
566
+ /**
567
+ * Bind the shipped executor for a plan. Each query in one call is a separate upstream search;
568
+ * their digests are merged so the model receives a single tool result for the call it made.
569
+ */
570
+ export function createOllamaBridgeExecutor(
571
+ plan: PassthroughWebSearchBridgePlan,
572
+ apiKey: string,
573
+ ): PassthroughWebSearchBridgeExecutor {
574
+ return async (queries, signal) => {
575
+ const texts: string[] = [];
576
+ const sources: SidecarOutcome["sources"] = [];
577
+ const errors: string[] = [];
578
+ for (const query of queries) {
579
+ if (signal?.aborted) break;
580
+ const outcome = await runOllamaWebSearch(query, apiKey, plan.endpoint, plan.timeoutMs, signal);
581
+ if (outcome.error) {
582
+ errors.push(outcome.error);
583
+ continue;
584
+ }
585
+ texts.push(queries.length > 1 ? "Results for \"" + query + "\":\n" + outcome.text : outcome.text);
586
+ for (const source of outcome.sources) {
587
+ if (!sources.some(existing => existing.url === source.url)) sources.push(source);
588
+ }
589
+ }
590
+ if (texts.length === 0) {
591
+ return { text: "", sources: [], error: errors[0] ?? "web search produced no results" };
592
+ }
593
+ return { text: texts.join("\n\n"), sources };
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Run one bridged turn as a client-facing SSE stream.
599
+ *
600
+ * The first leg is already open -- it came through the core send path with its full recovery,
601
+ * circuit, and body-size handling. Every later leg is a direct re-POST of the same outbound body
602
+ * extended with the executed search, which is exactly what a KEY-auth Responses continuation is.
603
+ */
604
+ async function* bridgeStreamBlocks(
605
+ options: PassthroughWebSearchBridgeStreamOptions,
606
+ aborted: () => boolean,
607
+ ): AsyncGenerator<string> {
608
+ const state = new BridgeStreamState();
609
+ let requestBody = options.requestBody;
610
+ let leg: ReadableStream<Uint8Array> = options.firstLeg;
611
+ let isFirstLeg = true;
612
+ let searchesExecuted = 0;
613
+ // One continuation leg per allowed search, plus one final leg for the answer itself.
614
+ let legsRemaining = options.plan.maxSearches + 1;
615
+
616
+ const emit = function* (blocks: readonly string[]): Generator<string> {
617
+ for (const block of blocks) yield block + "\n\n";
618
+ };
619
+
620
+ for (;;) {
621
+ state.beginLeg();
622
+ try {
623
+ for await (const { block } of readSseBlocks(leg)) {
624
+ yield* emit(state.consume(block, isFirstLeg));
625
+ if (aborted()) return;
626
+ }
627
+ } catch (error) {
628
+ const message = error instanceof Error ? error.message : String(error);
629
+ yield* emit(state.failureFrames(
630
+ WEB_SEARCH_BRIDGE_ERROR_CODE,
631
+ "web-search bridge upstream read failed: " + message,
632
+ ));
633
+ return;
634
+ }
635
+ isFirstLeg = false;
636
+ if (aborted()) return;
637
+
638
+ const decision = state.decide(legsRemaining);
639
+ if (decision.kind === "fail") {
640
+ // Close any cell this leg opened, or Codex keeps a "Searching the web" spinner running
641
+ // under a failed turn (the same reason src/bridge.ts closes a dangling search on teardown).
642
+ for (const call of decision.searches) {
643
+ yield* emit(state.searchEndFrames(call, [], {
644
+ text: "",
645
+ sources: [],
646
+ error: decision.message!,
647
+ }));
648
+ }
649
+ // The withheld client call is deliberately dropped: the turn is ending as failed, and
650
+ // releasing a tool call Codex would start executing is exactly what must not happen.
651
+ yield* emit(state.failureFrames(decision.code!, decision.message!));
652
+ return;
653
+ }
654
+ if (decision.kind === "end") {
655
+ yield* emit(state.flushHeldCalls());
656
+ yield* emit(state.terminalFrames());
657
+ return;
658
+ }
659
+
660
+ const turns: { call: InterceptedSearchCall; output: string }[] = [];
661
+ for (const call of decision.searches) {
662
+ const queries = parseQueries(call.argumentsText);
663
+ let outcome: SidecarOutcome;
664
+ if (aborted()) return;
665
+ if (searchesExecuted >= options.plan.maxSearches) {
666
+ outcome = {
667
+ text: "",
668
+ sources: [],
669
+ error: "no further web searches are available for this turn",
670
+ };
671
+ } else if (queries.length === 0) {
672
+ outcome = { text: "", sources: [], error: "web_search was called without a usable query" };
673
+ } else {
674
+ searchesExecuted += 1;
675
+ outcome = await options.execute(queries, options.signal);
676
+ }
677
+ yield* emit(state.searchEndFrames(call, queries, outcome));
678
+ turns.push({
679
+ call,
680
+ // The model needs a readable result either way; an executor error is reported as the
681
+ // tool result rather than as a turn failure, so it can still answer without the search.
682
+ output: outcome.error ? "Web search failed: " + outcome.error : outcome.text,
683
+ });
684
+ }
685
+
686
+ const nextBody = appendBridgeSearchTurn(requestBody, turns);
687
+ if (nextBody === undefined) {
688
+ yield* emit(state.failureFrames(
689
+ WEB_SEARCH_BRIDGE_ERROR_CODE,
690
+ "web-search bridge could not extend the outbound request body",
691
+ ));
692
+ return;
693
+ }
694
+ // The first leg was admitted by the caller's outbound ceiling; appending a search result can
695
+ // push the continuation past it, so re-check rather than sending an unbounded body.
696
+ const refusal = options.checkOutboundBody?.(nextBody);
697
+ if (refusal) {
698
+ yield* emit(state.failureFrames(WEB_SEARCH_BRIDGE_ERROR_CODE, refusal));
699
+ return;
700
+ }
701
+ requestBody = nextBody;
702
+ legsRemaining -= 1;
703
+ if (aborted()) return;
704
+
705
+ let next: Response;
706
+ try {
707
+ next = await options.send(requestBody);
708
+ } catch (error) {
709
+ const message = error instanceof Error ? error.message : String(error);
710
+ yield* emit(state.failureFrames(
711
+ WEB_SEARCH_BRIDGE_ERROR_CODE,
712
+ "web-search bridge continuation send failed: " + message,
713
+ ));
714
+ return;
715
+ }
716
+ if (!next.ok || !next.body || aborted()) {
717
+ next.body?.cancel().catch(() => {});
718
+ if (aborted()) return;
719
+ yield* emit(state.failureFrames(
720
+ WEB_SEARCH_BRIDGE_ERROR_CODE,
721
+ "web-search bridge continuation returned HTTP " + next.status,
722
+ ));
723
+ return;
724
+ }
725
+ leg = next.body;
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Build the client-facing SSE body for a bridged turn.
731
+ *
732
+ * Pull-driven so a slow client applies backpressure to the upstream leg instead of letting the
733
+ * proxy buffer the whole turn. Cancelling the client stream latches a local abort, so no further
734
+ * search is billed and no further continuation is sent once the consumer is gone.
735
+ */
736
+ export function createPassthroughWebSearchBridgeStream(
737
+ options: PassthroughWebSearchBridgeStreamOptions,
738
+ ): ReadableStream<Uint8Array> {
739
+ let cancelled = false;
740
+ const aborted = (): boolean => cancelled || options.signal?.aborted === true;
741
+ const iterator = bridgeStreamBlocks(options, aborted)[Symbol.asyncIterator]();
742
+ const encoder = new TextEncoder();
743
+ return new ReadableStream<Uint8Array>({
744
+ async pull(controller) {
745
+ try {
746
+ const next = await iterator.next();
747
+ if (next.done) {
748
+ controller.close();
749
+ return;
750
+ }
751
+ controller.enqueue(encoder.encode(next.value));
752
+ } catch (error) {
753
+ controller.error(error);
754
+ }
755
+ },
756
+ cancel(reason) {
757
+ cancelled = true;
758
+ void iterator.return?.(reason);
759
+ },
760
+ });
761
+ }