import type { OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; import { withUpstreamHttpVersion } from "../lib/upstream-http-version"; import { parseSidecarSSE, type WebSearchResult } from "./parse"; import type { CodexUpstreamOutcome } from "../codex/routing"; import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models"; export interface SidecarSettings { model: string; reasoning: string; timeoutMs: number; /** Effective Desktop authless compatibility does not grant auxiliary model use. */ reserveCompatibility?: boolean; /** * True when the routed (downstream) model is text-only. The search model CAN see images, so it's * told to verbalize any relevant image results and include their URLs — otherwise a non-vision model * would receive bare image links it cannot interpret (the image-web-search gap). */ describeImages?: boolean; } // Shared with the anthropic-backed executor (single source; audit F3). The instruction is // backend-agnostic — both the gpt-mini sidecar and a Claude sidecar answer the same way. export const BASE_INSTRUCTION = "You are a web-search assistant. Use the web_search tool to find current information for the " + "user's query, then reply with a concise, factual answer. End your reply with a `Sources:` " + "section listing each source you used on its own line as `- Title: URL` (one per line)."; export const IMAGE_INSTRUCTION = " The model that will read your answer is TEXT-ONLY and cannot see images: if the results include " + "relevant images, describe what they show in words and include their source URLs in your answer."; /** A search result, or an `error` string when the search couldn't run (surfaced as a tool result). */ export type SidecarOutcome = WebSearchResult & { error?: string }; export type SidecarOutcomeRecorder = (outcome: CodexUpstreamOutcome) => void; /** * Execute ONE web search via the gpt-mini sidecar through the ChatGPT forward backend — the only path * with a real server-side web_search. Reuses selected forwarded OAuth headers (the forward adapter * has no key of its own), replays the hosted web_search tool config verbatim, and runs the mini at * minimal reasoning. Never throws — returns `{error}` so the caller injects a graceful tool result. */ export async function runWebSearch( query: string, hostedTool: Record, forwardProvider: OcxProviderConfig, selectedForwardHeaders: Headers, settings: SidecarSettings, abortSignal?: AbortSignal, recordOutcome?: SidecarOutcomeRecorder, ): Promise { if (settings.reserveCompatibility && settings.model === NATIVE_RESERVE_MODEL) { return { text: "", sources: [], error: "Luna Reserve compatibility is only available as a conversation model, not a search helper. Choose another search helper model." }; } const headers: Record = { "Content-Type": "application/json" }; if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers); for (const h of FORWARD_HEADERS) { const v = selectedForwardHeaders.get(h); if (v) headers[h] = v; } const body = { model: settings.model, instructions: settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION, input: [{ type: "message", role: "user", content: [{ type: "input_text", text: query }] }], tools: [hostedTool], tool_choice: "auto", reasoning: { effort: settings.reasoning }, // NOTE: the ChatGPT (codex) backend rejects `max_output_tokens` ("Unsupported parameter") and // requires `store: false` — keep this body minimal. The shared SSE parser bounds raw response // bytes before format-result applies its smaller display clamp. store: false, stream: true, }; const url = `${forwardProvider.baseUrl}/responses`; const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); const sidecarExit = sidecarEnter("web-search"); const t0 = Date.now(); try { const res = await fetchWithResetRetry( // Recovery nests INSIDE the version helper: applyUpstreamRecoveryInit then always receives a // defined init, and withUpstreamHttpVersion spreads the result, so `protocol` and the // recovery fields (`connection: close` + Bun's transport-level `keepalive: false`) survive // together. The reverse order needs a `?? init` fallback to type-check at all. recovery => fetch(url, withUpstreamHttpVersion(url, applyUpstreamRecoveryInit({ method: "POST", headers, body: JSON.stringify(body), signal: linkedSignal.signal, // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization` // across origins but forwards nonstandard headers such as `chatgpt-account-id`, // `session_id`, and `x-codex-turn-metadata` to the redirect target. redirect: "manual", }, recovery), forwardProvider)), { abortSignal: linkedSignal.signal, label: "web-search-sidecar" }, ); // Attach the body guard before ANY branch reads it. The success path guarded itself below, // but the failure branch's `res.text()` runs first, so a cancel landing between fetch // resolution and reader attach orphaned the internal rejection (found investigating #1419). const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); if (!res.ok) { recordOutcome?.(res.status); const t = await res.text().catch(() => ""); detachBodyGuard(); console.warn(`[web-search] sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; } try { const parsed = await parseSidecarSSE(res); if (linkedSignal.signal.aborted) throw linkedSignal.signal.reason; recordOutcome?.(res.status); return parsed; } finally { detachBodyGuard(); } } catch (e) { const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; const callerAborted = abortSignal?.aborted === true && linkedSignal.signal.aborted && linkedSignal.signal.reason === abortSignal.reason && e === linkedSignal.signal.reason; recordOutcome?.(callerAborted ? "connect_neutral" : kind); console.warn(`[web-search] sidecar ${kind} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) }; } finally { sidecarExit(); linkedSignal.cleanup(); } }