diff --git a/devlog/_plan/260914_l7_web_search_bridge/000_plan.md b/devlog/_plan/260914_l7_web_search_bridge/000_plan.md new file mode 100644 index 0000000000..5fe3c99b2c --- /dev/null +++ b/devlog/_plan/260914_l7_web_search_bridge/000_plan.md @@ -0,0 +1,21 @@ +# L7 — web-search bridge: mixed-tool continuation and a search fallback + +Lane R2-L7. Two issues, one PR against `dev`, branch `codex/260914-l7-web-search-bridge`. + +## Units + +- 010 — mixed-tool continuation (residual of issue 4429). +- 020 — /v1/alpha/search without a ChatGPT forward provider (issue 2730). + +## Write scope + +`src/web-search/*`, `src/server/search.ts`, and their tests. No new config-schema +field: another lane owns `src/config.ts` and `src/types/config.ts` this round. +`src/server/responses/core.ts` is deliberately untouched — see 010 for what that +costs and why the remainder is recorded rather than reached for. + +## Verification posture + +This worktree has no `node_modules`, so nothing local runs: no suite, no +typecheck, no focused file. Hosted CI at the exact final head is the only proof. + diff --git a/devlog/_plan/260914_l7_web_search_bridge/010_mixed_tool_continuation.md b/devlog/_plan/260914_l7_web_search_bridge/010_mixed_tool_continuation.md new file mode 100644 index 0000000000..6dc6d55583 --- /dev/null +++ b/devlog/_plan/260914_l7_web_search_bridge/010_mixed_tool_continuation.md @@ -0,0 +1,39 @@ +# 010 — mixed-tool continuation + +## What already landed + +PR 4515 armed the non-Ollama bridge backends and said plainly that it does not +close the issue. The remainder is one branch in `BridgeStreamState.decide()`: +a leg carrying both an intercepted `web_search` call and a client-executed call +returns `kind: "fail"` with `web_search_bridge_mixed_tools`. The stream then +closes the hosted cell as failed and drops the held client call, so Codex App +reconnects five times and the turn dies. + +## The shape of the fix + +A mixed leg ends the turn on that leg instead of failing it: + +1. Execute the intercepted search exactly as the non-mixed path does — same + budget accounting, same query parsing, same completed `web_search_call` cell. +2. Flush the held client call so Codex runs it, with its `call_id`, item id, and + streamed order intact. +3. Emit the leg's own terminal. + +No continuation leg is sent upstream. That is the whole point: the client's tool +call is unanswered, so the conversation has to go back to the client, not to the +gateway. + +## What this does not fix + +The upstream gateway never sees the search result. Codex replays the hosted +`web_search_call` cell on the next turn, which carries the query and sources but +no result text, and the gateway's own `function_call` / `function_call_output` +pair is not reconstructed. Making it whole needs an inbound rewrite applied to +the outbound body **before** the first leg is dispatched, and the only place that +can happen is `src/server/responses/core.ts`, which is outside this lane's write +scope. The turn now survives and the model can re-search on the following turn; +the replay remains open. + +Pre-existing and unchanged: a hosted `web_search_call` item synthesized by the +bridge already reaches the gateway on later turns in the non-mixed path too. + diff --git a/devlog/_plan/260914_l7_web_search_bridge/020_alpha_search_fallback.md b/devlog/_plan/260914_l7_web_search_bridge/020_alpha_search_fallback.md new file mode 100644 index 0000000000..a01bcecac5 --- /dev/null +++ b/devlog/_plan/260914_l7_web_search_bridge/020_alpha_search_fallback.md @@ -0,0 +1,32 @@ +# 020 — /v1/alpha/search without ChatGPT forward auth + +## Today + +`handleSearch` calls `listOpenAiForwardSidecarCandidates(config)` and returns 400 +when the list is empty, before considering any configured web-search backend. An +API-key-only deployment therefore cannot use Codex's built-in search at all. + +## Response shape + +The relay is verbatim today, so the proxy never had to know the schema. The +fallback does. Two independent sources agree: this repository's own fixture in +`tests/server/server-search.test.ts` asserts `{ encrypted_output, output }`, and an +external reimplementation records `{ "encrypted_output": null, "output": "...", +"results": [] }` with `output` carrying the text the client reads. The endpoint is +an internal alpha route with no published wire spec, so the fallback is written to +degrade rather than to be authoritative. + +## The fix + +When and only when no forward candidate exists, resolve an explicitly configured +`webSearchSidecar.backend` (anthropic, xai, gemini, exa) whose credential is +present, run the query through the executor that backend already ships, and adapt +the outcome to `{ encrypted_output: null, output, results }`. + +- The verbatim ChatGPT relay is untouched whenever a forward provider exists. +- An unset or `openai` backend cannot serve this path — `openai` *is* the ChatGPT + forward path — so that case keeps a 400 and says what to configure. +- A backend that fails returns its own diagnostic rather than the ChatGPT-auth + message, which is what the issue asks for. +- No new config field. The fallback reads `webSearchSidecar`, which already exists. + diff --git a/src/server/search.ts b/src/server/search.ts index 681c44254d..e66a51e24b 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -4,9 +4,11 @@ * codex-rs's built-in search client executes CLIENT-SIDE: it POSTs `alpha/search` against the * configured base_url with the same ChatGPT bearer auth used for model requests. Under Design B * injection base_url is this proxy, so the request otherwise dies on the /v1/* JSON-404 guard. - * The endpoint is private to the ChatGPT Codex backend, so routed providers and OpenAI API-key - * providers cannot serve it. Relay the JSON request and response verbatim through the configured - * ChatGPT forward provider. + * The endpoint is private to the ChatGPT Codex backend, so the honest answer while a forward + * provider is configured is to copy bytes. When none is, a configured web-search sidecar + * (anthropic / xai / gemini / exa) can still answer — see src/web-search/alpha-search.ts. + * That fallback never runs while a forward candidate exists, and never borrows a different + * paid backend than the one the operator named. */ import { formatErrorResponse } from "../bridge"; import { @@ -34,6 +36,7 @@ import { type ExactOpenAiSidecarAccount, } from "../providers/openai-sidecar"; import { routeModel } from "../router"; +import { handleAlphaSearchSidecarFallback } from "../web-search/alpha-search"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; import type { RequestLogContext } from "./request-log"; @@ -105,12 +108,7 @@ export async function handleSearch( } const candidates = listOpenAiForwardSidecarCandidates(config); if (candidates.length === 0) { - return formatErrorResponse( - 400, - "invalid_request_error", - "Built-in web search needs a ChatGPT forward provider, but none is configured in opencodex. " - + "Routed and OpenAI API-key providers cannot serve /v1/alpha/search.", - ); + return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx); } let upstream: Awaited>; diff --git a/src/web-search/alpha-search.ts b/src/web-search/alpha-search.ts new file mode 100644 index 0000000000..3bdd64853a --- /dev/null +++ b/src/web-search/alpha-search.ts @@ -0,0 +1,324 @@ +/** + * Serve Codex's built-in `/v1/alpha/search` when no ChatGPT forward provider exists. + * + * The ChatGPT relay in src/server/search.ts is byte-identical on purpose: the client talks an + * unpublished alpha envelope, and the only honest answer while a forward provider is configured + * is to copy bytes. That leaves API-key-only and routed-provider deployments with a 400 even + * when they already paid for a web-search sidecar. This module is the empty-candidates branch + * of that handler — it never runs when a forward provider is present, and it never borrows a + * different paid backend than the one the operator named. + * + * Do not import `./index.ts` from here. The barrel is still evaluating when search.ts loads, + * and pulling it in recreates the cycle sidecar-providers.ts exists to avoid. + */ +import { formatErrorResponse } from "../bridge"; +import { redactSecretString } from "../lib/redact"; +import { sidecarEnter } from "../lib/sidecar-tracker"; +import type { OcxConfig, OcxProviderConfig, OcxWebSearchSidecarConfig } from "../types"; +import { runAnthropicWebSearch } from "./anthropic-executor"; +import { runExaWebSearch } from "./exa-executor"; +import type { SidecarOutcome, SidecarSettings } from "./executor"; +import { runGeminiWebSearch } from "./gemini-executor"; +import { + findAnthropicSidecarProvider, + findGeminiSidecarProvider, + findXaiSidecarProvider, + resolveSidecarBackend, + xaiSearchOptionsFromConfig, +} from "./sidecar-providers"; +import { safeWebSearchSources } from "./sources"; +import { runXaiWebSearch } from "./xai-executor"; + +/** + * Same total-search budget the ChatGPT relay uses in src/server/search.ts. The sidecar loop's + * 60s default is a different contract (a helper turn beside a routed model); alpha/search is + * the whole request, so it keeps the relay's 200s ceiling unless config.search.timeoutMs says + * otherwise. + */ +const SEARCH_UPSTREAM_TIMEOUT_MS = 200_000; +/** Queries honored from one alpha/search body; the rest are ignored rather than billed. */ +const MAX_QUERIES_PER_CALL = 3; +const MAX_QUERY_CHARS = 1_000; +const DEFAULT_REASONING = "low"; + +/** + * Search model each sidecar backend runs when the operator did not name one for THIS backend. + * Copied from the passthrough bridge's table on purpose: sending a ChatGPT slug to Anthropic + * is the failure that table exists to prevent, and alpha/search would reproduce it if it + * trusted `webSearchSidecar.model` unconditionally. + */ +const DEFAULT_BACKEND_MODELS = { + anthropic: "claude-sonnet-5", + xai: "grok-4.6", + gemini: "gemini-3.8-flash", + // Exa ignores model; the placeholder only satisfies SidecarSettings. + exa: "gpt-5.6-luna", +} as const; + +export type AlphaSearchSidecarBackend = keyof typeof DEFAULT_BACKEND_MODELS; + +type ResolvedAlphaSearchSidecar = + | { backend: "anthropic"; providerName: string; provider: OcxProviderConfig } + | { backend: "xai"; providerName: string; provider: OcxProviderConfig } + | { backend: "gemini"; providerName: string; provider: OcxProviderConfig } + | { backend: "exa"; apiKey: string }; + +/** + * Why this path cannot serve the request, kept distinct from "nobody asked for it". + * + * The two refusals read identically to the operator but mean opposite things: `unconfigured` is + * a deployment that never named a backend, while `missing-credential` is one that named a + * backend the proxy cannot authenticate. Answering both with the ChatGPT-auth sentence is the + * behaviour the feature request called out — it tells an operator who already chose Exa to go + * set up ChatGPT OAuth, which is the one thing they were trying to avoid. + */ +export type AlphaSearchSidecarResolution = + | { status: "ready"; sidecar: ResolvedAlphaSearchSidecar } + | { status: "unconfigured" } + | { status: "missing-credential"; backend: AlphaSearchSidecarBackend }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * Only the operator's explicit sidecar backend can serve this path, and only with THAT + * backend's own credential. `openai` is the ChatGPT forward path, which is absent by the + * time we are here; auto-selecting a different paid backend from leftover keys is how an + * anthropic-named config would silently spend Exa. + */ +export function resolveAlphaSearchSidecar(config: OcxConfig): AlphaSearchSidecarResolution { + const sidecar = config.webSearchSidecar; + // The master switch is the operator saying this sidecar may not run. planWebSearch honors it the + // same way, and ignoring it here would make `enabled: false` mean "off for the routed loop, on + // for alpha/search" — the one reading under which a disabled backend still spends money. + if (sidecar?.enabled === false) return { status: "unconfigured" }; + const backend = resolveSidecarBackend(sidecar?.backend); + if (backend === "openai") return { status: "unconfigured" }; + switch (backend) { + case "anthropic": { + const found = findAnthropicSidecarProvider(config); + return found + ? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } } + : { status: "missing-credential", backend }; + } + case "xai": { + const found = findXaiSidecarProvider(config); + return found + ? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } } + : { status: "missing-credential", backend }; + } + case "gemini": { + const found = findGeminiSidecarProvider(config); + return found + ? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } } + : { status: "missing-credential", backend }; + } + case "exa": { + const apiKey = sidecar?.exaApiKey; + return typeof apiKey === "string" && apiKey.length > 0 + ? { status: "ready", sidecar: { backend, apiKey } } + : { status: "missing-credential", backend }; + } + } +} + +function pushQuery(queries: string[], value: unknown): void { + if (typeof value !== "string") return; + const trimmed = value.trim(); + if (trimmed.length === 0 || queries.includes(trimmed)) return; + if (queries.length < MAX_QUERIES_PER_CALL) queries.push(trimmed.slice(0, MAX_QUERY_CHARS)); +} + +/** + * Codex's live-search client sends a Responses-shaped envelope whose primary operation is + * `commands.search_query: [{ q }]`. Top-level `query` / `q` / `search_query` strings are the + * fallback for tests and any thinner client; they are consulted only when the envelope form + * produced nothing usable, so a present-but-empty `search_query` array cannot hide a + * top-level query the operator actually sent. + */ +export function extractAlphaSearchQueries(body: unknown): string[] { + const queries: string[] = []; + if (!isRecord(body)) return queries; + const searchQuery = isRecord(body.commands) ? body.commands.search_query : undefined; + if (Array.isArray(searchQuery)) { + for (const entry of searchQuery) { + if (isRecord(entry)) pushQuery(queries, entry.q); + } + } + if (queries.length === 0) { + pushQuery(queries, body.query); + if (queries.length === 0) pushQuery(queries, body.q); + if (queries.length === 0) pushQuery(queries, body.search_query); + } + return queries; +} + +function modelForAlphaSearchBackend( + backend: AlphaSearchSidecarBackend, + sidecar: Pick | undefined, +): string { + const backendDefault = DEFAULT_BACKEND_MODELS[backend]; + if (resolveSidecarBackend(sidecar?.backend) !== backend) return backendDefault; + return sidecar?.model ?? backendDefault; +} + +function sidecarSettingsForAlphaSearch( + backend: AlphaSearchSidecarBackend, + config: OcxConfig, +): SidecarSettings { + const sidecar = config.webSearchSidecar; + return { + model: modelForAlphaSearchBackend(backend, sidecar), + reasoning: sidecar?.reasoning ?? DEFAULT_REASONING, + timeoutMs: config.search?.timeoutMs ?? SEARCH_UPSTREAM_TIMEOUT_MS, + }; +} + +async function runAlphaSearchQuery( + query: string, + resolved: ResolvedAlphaSearchSidecar, + settings: SidecarSettings, + config: OcxConfig, + signal?: AbortSignal, +): Promise { + switch (resolved.backend) { + case "anthropic": + return runAnthropicWebSearch(query, resolved.providerName, resolved.provider, settings, signal); + case "xai": + return runXaiWebSearch( + query, + resolved.providerName, + resolved.provider, + settings, + xaiSearchOptionsFromConfig(config.webSearchSidecar ?? {}), + signal, + ); + case "gemini": + return runGeminiWebSearch(query, resolved.providerName, resolved.provider, settings, signal); + case "exa": + return runExaWebSearch(query, resolved.apiKey, settings, signal); + } +} + +function formatAlphaSearchBody(text: string, sources: SidecarOutcome["sources"]): { + encrypted_output: null; + output: string; + results: Array<{ title: string; url: string }>; +} { + // Title falls back to the URL so the client always sees both fields; unsafe URLs are + // dropped entirely rather than echoed into `results`. + return { + encrypted_output: null, + output: text, + results: safeWebSearchSources(sources).map(source => ({ + url: source.url, + title: source.title ?? source.url, + })), + }; +} + +const NO_FORWARD_PROVIDER_MESSAGE = + "Built-in web search needs a ChatGPT forward provider, but none is configured in opencodex. " + + "Routed and OpenAI API-key providers cannot serve /v1/alpha/search. " + + "Configure webSearchSidecar.backend (anthropic, xai, gemini, or exa) with that backend's credential instead."; + +/** + * What a named backend is missing, said in the operator's own terms. + * + * An operator who already chose a backend does not need to be told to configure ChatGPT auth — + * that answer is what the request asked this path to stop giving. They need to know which + * credential the backend they named could not find. + */ +function missingCredentialMessage(backend: AlphaSearchSidecarBackend): string { + const detail: Record = { + anthropic: "no usable stored Anthropic OAuth account was found", + xai: "no usable stored Grok OAuth account was found", + gemini: "no usable stored Antigravity OAuth account with a discovered project was found", + exa: "webSearchSidecar.exaApiKey is not set", + }; + return "Built-in web search is configured to use the " + backend + " backend, but " + + detail[backend] + ". Restore that backend's credential, or choose another " + + "webSearchSidecar.backend. This request was not sent to any other backend."; +} + +/** + * Run the named sidecar backend against an alpha/search body. Callers must already know there + * is no ChatGPT forward candidate — this function does not re-check that, so a mis-call would + * spend the sidecar even when the relay could have copied bytes. + */ +export async function handleAlphaSearchSidecarFallback( + body: unknown, + config: OcxConfig, + signal?: AbortSignal, + logCtx?: { provider: string }, +): Promise { + const resolution = resolveAlphaSearchSidecar(config); + if (resolution.status === "missing-credential") { + // Never the ChatGPT-auth sentence here: the operator already named a backend, so the honest + // answer names what that backend is missing. + if (logCtx) logCtx.provider = resolution.backend; + return formatErrorResponse(400, "invalid_request_error", missingCredentialMessage(resolution.backend)); + } + if (resolution.status !== "ready") { + return formatErrorResponse(400, "invalid_request_error", NO_FORWARD_PROVIDER_MESSAGE); + } + const resolved = resolution.sidecar; + if (logCtx) logCtx.provider = resolved.backend; + + const queries = extractAlphaSearchQueries(body); + if (queries.length === 0) { + return formatErrorResponse( + 400, + "invalid_request_error", + "Built-in web search request is missing a usable query (commands.search_query, query, q, or search_query).", + ); + } + + const settings = sidecarSettingsForAlphaSearch(resolved.backend, config); + const sidecarExit = sidecarEnter("search"); + try { + const texts: string[] = []; + const sources: SidecarOutcome["sources"] = []; + const errors: string[] = []; + for (const query of queries) { + if (signal?.aborted) break; + const outcome = await runAlphaSearchQuery(query, resolved, settings, config, signal); + if (outcome.error) { + errors.push(outcome.error); + continue; + } + texts.push(queries.length > 1 ? `Results for "${query}":\n${outcome.text}` : outcome.text); + for (const source of outcome.sources) { + if (!sources.some(existing => existing.url === source.url)) sources.push(source); + } + } + if (signal?.aborted) { + return formatErrorResponse(499, "client_closed_request", "search request canceled by client"); + } + if (texts.length === 0) { + const detail = redactSecretString(errors[0] ?? "web search produced no results"); + return formatErrorResponse( + 502, + "upstream_error", + resolved.backend + " web search failed: " + detail, + ); + } + return new Response(JSON.stringify(formatAlphaSearchBody(texts.join("\n\n"), sources)), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (err) { + if (signal?.aborted) { + return formatErrorResponse(499, "client_closed_request", "search request canceled by client"); + } + const detail = redactSecretString(err instanceof Error ? err.message : String(err)); + return formatErrorResponse( + 502, + "upstream_error", + resolved.backend + " web search failed: " + detail, + ); + } finally { + sidecarExit(); + } +} diff --git a/src/web-search/passthrough-bridge.ts b/src/web-search/passthrough-bridge.ts index 8850b7e2ac..10a443494b 100644 --- a/src/web-search/passthrough-bridge.ts +++ b/src/web-search/passthrough-bridge.ts @@ -18,10 +18,19 @@ * * Deliberate boundaries of this first slice: * - Streaming SSE turns only. A non-streaming turn stays on the existing path. - * - A leg that mixes the search call with any OTHER client tool call fails closed with an - * explicit error. Answering both would need the raw mixed-tool continuation contract the - * 2.47 track deferred (devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md), - * and silently half-doing it would drop the client's own tool call. + * - A leg that mixes the search call with a client-executed tool call ends the turn ON that + * leg: the intercepted searches still run proxy-side so the hosted cell completes, the + * held client calls are released for Codex to run, and the leg's own terminal closes the + * turn. No continuation is sent upstream, because the client's call is unanswered and the + * conversation owes the client a turn, not the gateway. When the leg's terminal already + * ended the turn (response.failed / response.incomplete) the searches are not run at all: + * the opened cells close unanswered and that terminal is relayed, because billing a search + * for a dead turn buys nothing. What is still not fixed: the + * gateway never receives the executed search result -- Codex replays the hosted + * web_search_call cell (query and sources, no result text) on the next turn and the + * gateway's own function_call/function_call_output pair is not reconstructed. Making it + * whole needs the outbound body rewritten before the first leg is dispatched, which lives + * in src/server/responses/core.ts and is out of this module's scope. * - Assistant text is never treated as a search instruction. The bridge intercepts structured * function_call / custom_tool_call items named web_search, not XML-like prose. * - Non-Ollama backends reuse the sidecar executors and those executors' own credentials. @@ -120,6 +129,11 @@ const MAX_RETAINED_OUTPUT_ITEMS = 500; /** Refuse to buffer an unbounded partial SSE event from a misbehaving upstream. */ const MAX_SSE_BUFFER_CHARS = 8 * 1024 * 1024; +/** + * Retained for importers that pinned the first slice's contract: a leg mixing the search with + * a client-executed call used to fail with this code. Such legs now end the turn on the leg + * instead of failing, so nothing emits it any more. + */ export const WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE = "web_search_bridge_mixed_tools"; export const WEB_SEARCH_BRIDGE_ERROR_CODE = "web_search_bridge_failed"; @@ -426,7 +440,7 @@ async function* readSseBlocks( } } interface LegDecision { - kind: "end" | "continue" | "fail"; + kind: "end" | "endAfterSearch" | "endWithoutSearch" | "continue" | "fail"; searches: InterceptedSearchCall[]; message?: string; code?: string; @@ -656,18 +670,18 @@ class BridgeStreamState { /** Decide what the leg's terminal means once the whole leg has been read. */ decide(remainingLegs: number): LegDecision { if (this.searches.length === 0) return { kind: "end", searches: [] }; - if (this.sawClientExecutedCall) { - return { - kind: "fail", - searches: this.searches, - code: WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE, - message: "routed provider requested web_search alongside another client tool in one turn; " - + "the web-search bridge cannot answer both without dropping the client's call", - }; - } const terminalType = this.terminalPayload?.type; if (terminalType === "response.failed" || terminalType === "response.incomplete") { - return { kind: "end", searches: [] }; + // The upstream terminal already ended this leg, so running the intercepted searches now + // would bill a search for a dead turn. The opened cells are closed unanswered instead. + return { kind: "endWithoutSearch", searches: this.searches }; + } + if (this.sawClientExecutedCall) { + // The client's own call is unanswered, so this leg cannot continue upstream: the + // conversation owes the client a turn, not the gateway. The intercepted searches still + // run so the hosted cell completes rather than dangling, then the held calls go back to + // the client and the leg's own terminal ends the turn. + return { kind: "endAfterSearch", searches: this.searches }; } if (remainingLegs <= 0) { return { @@ -984,6 +998,24 @@ async function* bridgeStreamBlocks( return; } + if (decision.kind === "endWithoutSearch") { + // The upstream terminal already ended this leg, so billing a search now would pay for a + // dead turn. The opened cells still have to close -- an in_progress web_search_call left + // under a finished turn is the same dangling "Searching the web" spinner the failure path + // above closes for. This also tightens the pre-existing non-mixed failed-leg path, which + // used to drop the searches and leave the cell open. + for (const call of decision.searches) { + yield* emit(state.searchEndFrames(call, [], { + text: "", + sources: [], + error: "the upstream turn ended before the web search could run", + })); + } + yield* emit(state.flushHeldCalls()); + yield* emit(state.terminalFrames()); + return; + } + const turns: { call: InterceptedSearchCall; output: string }[] = []; for (const call of decision.searches) { const queries = parseQueries(call.argumentsText); @@ -1010,6 +1042,17 @@ async function* bridgeStreamBlocks( }); } + if (decision.kind === "endAfterSearch") { + // A mixed leg ends here rather than continuing upstream: the client's own call is + // unanswered, so the conversation owes the CLIENT a turn, not the gateway. The searches + // completed their hosted cells above; now the held calls go back for Codex to run and + // the leg's terminal closes the turn. No continuation is sent and no function_call_output + // is fabricated for a call the bridge cannot execute. + yield* emit(state.flushHeldCalls()); + yield* emit(state.terminalFrames()); + return; + } + const nextBody = appendBridgeSearchTurn(requestBody, turns); if (nextBody === undefined) { yield* emit(state.failureFrames( diff --git a/structure/data-planes/search.md b/structure/data-planes/search.md index f95494537b..6c63ab85d0 100644 --- a/structure/data-planes/search.md +++ b/structure/data-planes/search.md @@ -3,6 +3,19 @@ The opt-in key-auth Responses hosted-search bridge follows the [continuation binding contract](../runtime.md#hosted-search-continuation-binding). +## Serving the relay without ChatGPT auth + +`POST /v1/alpha/search` relays verbatim through a configured ChatGPT forward provider. When no +forward candidate exists, an explicitly configured `webSearchSidecar.backend` of `anthropic`, +`xai`, `gemini`, or `exa` serves the request instead, spending only that backend's own +credential: `src/web-search/alpha-search.ts` runs the query through that backend's executor and +answers `{ encrypted_output: null, output, results }`. An unset or `openai` backend and a sidecar +disabled by `enabled: false` keep the ChatGPT-auth 400. A named backend whose credential is +missing is refused as well, but the message names that backend and the credential it could not +find instead of asking for ChatGPT auth, and the request reaches no other backend. A backend that +fails answers with its own diagnostic. The fallback never runs while a forward candidate exists, +so the verbatim relay stays the path for a ChatGPT deployment. + ## Standalone Search and exact account selectors `POST /v1/alpha/search` retains the selected model in its request body. When that value is an diff --git a/structure/runtime.md b/structure/runtime.md index aa977c51df..e4a50ba3f8 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -251,8 +251,15 @@ first-dispatch reselection and result preservation. on the planned search endpoint. `openai`, `anthropic`, `xai`, `gemini`, and `exa` reuse the matching sidecar executor and that executor's own credential; a missing credential leaves the bridge disarmed rather than falling through to another paid search. A leg that mixes an intercepted -`web_search` call with another client-executed tool still fails closed. Assistant text is not -treated as a search instruction. +`web_search` call with another client-executed tool ends the turn on that leg: the intercepted +searches run, their hosted cells complete, the held client calls are released for the caller to +execute, and the leg's own terminal closes the turn with no continuation sent upstream. The +destination therefore never receives the executed search result — the caller replays the hosted +`web_search_call` cell, which carries the query and sources but no result text, so the +destination's own `function_call`/`function_call_output` pair is not reconstructed. A leg whose +upstream terminal is `response.failed` or `response.incomplete` runs no search at all and closes +any cell it opened rather than leaving it in progress. Assistant text is not treated as a search +instruction. The bridge backend and the global `webSearchSidecar` block are configured independently, so the sidecar's `model` applies to a bridge search only when `resolveSidecarBackend(webSearchSidecar.backend)` diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b98bf57473..8c04f7b633 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -24,7 +24,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | | Chat Completions inbound | `src/server/chat-completions.ts`, `src/server/chat-native.ts`, `src/chat/`, `src/adapters/openai-chat.ts` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. The native handler owns pin/cap normalization; the adapter wire builder removes effort only for explicit empty declarations or no-reasoning models, preserving unknown raw declarations. On the response side, the upstream `service_tier` echo relays on every delivery shape (`src/chat/outbound.ts` projections, `src/server/chat-native-sse.ts` chunks); an upstream without the field gets no injected key. | -| Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | +| Hosted search relay | `src/server/search.ts` | Verbatim ChatGPT relay, or an explicitly configured web-search sidecar backend when no forward provider exists; distinct from the web-search sidecar loop below. | | Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | | API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A configured `apiKeyPoolStrategy` plus a cooling committed key rotates before the first send (`selectProactiveApiKeyTransport`); a 429 still rotates after the send and records a cooldown. `provider.apiKey` keeps mirroring the active entry so routing stays single-key. The pick is inert without a strategy or while the committed key is healthy. | diff --git a/tests/server/server-search.test.ts b/tests/server/server-search.test.ts index fffd663279..721ca15559 100644 --- a/tests/server/server-search.test.ts +++ b/tests/server/server-search.test.ts @@ -1,7 +1,9 @@ /** * /v1/alpha/search relay: codex-rs's built-in web search client POSTs this path against the * injected base_url, so the proxy must relay it to the ChatGPT forward provider instead of the - * /v1/* JSON-404 guard. + * /v1/* JSON-404 guard. When no forward provider exists, a named web-search sidecar can still + * answer; that fallback must not run while a forward candidate is configured, and must not + * spend a different paid backend than the one the operator named. */ import { afterEach, beforeEach, expect, test } from "bun:test"; import { existsSync, mkdirSync} from "node:fs"; @@ -97,6 +99,61 @@ function fakeSearchUpstream(captured: CapturedRequest[], status = 200, payload?: return upstream; } +interface CapturedExaRequest { + url: string; + headers: Headers; + body: unknown; +} + +function fakeExaUpstream( + captured: CapturedExaRequest[], + status = 200, + payload?: unknown, +): void { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "api.exa.ai") { + captured.push({ + url: requestUrl, + headers: new Headers(init?.headers), + body: typeof init?.body === "string" ? JSON.parse(init.body) : null, + }); + return Promise.resolve(Response.json( + payload ?? { + results: [{ + title: "OpenAI news", + url: "https://openai.com/news", + text: "Latest OpenAI news.", + }], + }, + { status }, + )); + } + return originalFetch(input, init); + }) as typeof fetch; +} + +function routedConfig(overrides: Partial = {}): OcxConfig { + return { + port: 0, + defaultProvider: "groq", + openaiProviderTierVersion: 2, + providers: { + groq: { adapter: "openai-chat", baseUrl: "https://api.groq.example/v1", apiKey: "gsk-x" }, + }, + ...overrides, + } as OcxConfig; +} + +function alphaSearchRequest(body: unknown, headers: Record = {}): Request { + return new Request("http://127.0.0.1/v1/alpha/search", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + function forwardConfig(_baseUrl = ""): OcxConfig { return { port: 0, @@ -421,11 +478,166 @@ test("returns an honest 400 when no ChatGPT forward provider is configured", asy const json = await response.json() as { error: { message: string } }; expect(json.error.message).toContain("ChatGPT forward provider"); expect(json.error.message).toContain("/v1/alpha/search"); + expect(json.error.message).toContain("webSearchSidecar"); } finally { await server.stop(true); } }); +test("falls back to a configured exa sidecar when no ChatGPT forward provider exists", async () => { + const captured: CapturedExaRequest[] = []; + fakeExaUpstream(captured); + const response = await handleSearch( + alphaSearchRequest({ + id: "search-session", + model: "gpt-test", + commands: { search_query: [{ q: "OpenAI news" }] }, + }), + routedConfig({ webSearchSidecar: { backend: "exa", exaApiKey: "exa-test-key" } }), + { model: "", provider: "" }, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + const json = await response.json() as { + encrypted_output: null; + output: string; + results: Array<{ title: string; url: string }>; + }; + expect(json.encrypted_output).toBeNull(); + expect(json.output).toContain("OpenAI news"); + expect(json.results).toEqual([{ title: "OpenAI news", url: "https://openai.com/news" }]); + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe("https://api.exa.ai/search"); + expect(captured[0].headers.get("x-api-key")).toBe("exa-test-key"); + expect(captured[0].body).toMatchObject({ query: "OpenAI news" }); +}); + +test("a ChatGPT forward provider still wins over a configured web-search sidecar", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeSearchUpstream(captured); + const inner = globalThis.fetch; + let exaHits = 0; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (new URL(requestUrl).hostname === "api.exa.ai") { + exaHits += 1; + return Promise.resolve(Response.json({ results: [] })); + } + return inner(input, init); + }) as typeof fetch; + + try { + const response = await handleSearch( + alphaSearchRequest({ + id: "search-session", + model: "gpt-test", + commands: { search_query: [{ q: "OpenAI news" }] }, + }, { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + }), + { + ...forwardConfig(), + webSearchSidecar: { backend: "exa", exaApiKey: "exa-must-not-run" }, + } as OcxConfig, + { model: "", provider: "" }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ encrypted_output: "ciphertext", output: "search result" }); + expect(captured).toHaveLength(1); + expect(captured[0].path).toBe("/alpha/search"); + expect(exaHits).toBe(0); + } finally { + await upstream.stop(true); + } +}); + +test("an openai webSearchSidecar backend cannot serve alpha/search without ChatGPT forward auth", async () => { + const captured: CapturedExaRequest[] = []; + fakeExaUpstream(captured); + const response = await handleSearch( + alphaSearchRequest({ commands: { search_query: [{ q: "OpenAI news" }] } }), + routedConfig({ webSearchSidecar: { backend: "openai", exaApiKey: "exa-must-not-run" } }), + { model: "", provider: "" }, + ); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("ChatGPT forward provider"); + expect(json.error.message).toContain("webSearchSidecar"); + expect(captured).toHaveLength(0); +}); + +test("a webSearchSidecar backend with no credential does not fall through to another paid backend", async () => { + const captured: CapturedExaRequest[] = []; + fakeExaUpstream(captured); + const response = await handleSearch( + alphaSearchRequest({ commands: { search_query: [{ q: "OpenAI news" }] } }), + routedConfig({ webSearchSidecar: { backend: "anthropic", exaApiKey: "exa-must-not-run" } }), + { model: "", provider: "" }, + ); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + // The operator already chose anthropic, so the refusal names what anthropic is missing rather + // than telling them to go configure the ChatGPT auth they were trying to avoid. + expect(json.error.message).toContain("anthropic"); + expect(json.error.message).toContain("Anthropic OAuth"); + expect(json.error.message).not.toContain("ChatGPT forward provider"); + expect(json.error.message).toContain("not sent to any other backend"); + expect(captured).toHaveLength(0); +}); + +test("a disabled web-search sidecar cannot serve alpha/search either", async () => { + const captured: CapturedExaRequest[] = []; + fakeExaUpstream(captured); + const response = await handleSearch( + alphaSearchRequest({ commands: { search_query: [{ q: "OpenAI news" }] } }), + routedConfig({ webSearchSidecar: { enabled: false, backend: "exa", exaApiKey: "exa-must-not-run" } }), + { model: "", provider: "" }, + ); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("ChatGPT forward provider"); + expect(captured).toHaveLength(0); +}); + +test("an alpha/search sidecar failure names the backend instead of asking for ChatGPT auth", async () => { + const key = "exa-secret-key-123"; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (new URL(requestUrl).hostname === "api.exa.ai") { + return new Response(`invalid key ${key} rejected`, { status: 502 }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const response = await handleSearch( + alphaSearchRequest({ commands: { search_query: [{ q: "OpenAI news" }] } }), + routedConfig({ webSearchSidecar: { backend: "exa", exaApiKey: key } }), + { model: "", provider: "" }, + ); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("exa"); + expect(json.error.message).toContain("502"); + expect(json.error.message).not.toContain("ChatGPT"); + expect(json.error.message).not.toContain(key); +}); + +test("an eligible sidecar still 400s when the search body has no query", async () => { + const captured: CapturedExaRequest[] = []; + fakeExaUpstream(captured); + const response = await handleSearch( + alphaSearchRequest({ id: "search-session", model: "gpt-test" }), + routedConfig({ webSearchSidecar: { backend: "exa", exaApiKey: "exa-test-key" } }), + { model: "", provider: "" }, + ); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message.toLowerCase()).toContain("query"); + expect(json.error.message).not.toContain("ChatGPT"); + expect(captured).toHaveLength(0); +}); + test("relays search upstream error status and body verbatim", async () => { const captured: CapturedRequest[] = []; const upstream = fakeSearchUpstream(captured, 403, { diff --git a/tests/web-search/web-search-passthrough-bridge.test.ts b/tests/web-search/web-search-passthrough-bridge.test.ts index 1af8a45955..730da7540a 100644 --- a/tests/web-search/web-search-passthrough-bridge.test.ts +++ b/tests/web-search/web-search-passthrough-bridge.test.ts @@ -711,19 +711,25 @@ describe("the bridged client stream", () => { expect(body.trimEnd().endsWith("data: [DONE]")).toBe(true); }); - test("a search mixed with another client tool call fails closed instead of dropping it", async () => { - let sends = 0; + test("a search mixed with another client tool call ends the turn on that leg", async () => { + const sent: string[] = []; + const executed: string[][] = []; const clientCall = { type: "function_call", id: "fc_2", call_id: "call_2", name: "exec", - arguments: "{}", + arguments: "{\"cmd\":\"ls\"}", }; const mixedLeg = sseBody( frame("response.output_item.added", { output_index: 0, item: { ...searchCall, arguments: "" } }), frame("response.output_item.done", { output_index: 0, item: searchCall }), frame("response.output_item.added", { output_index: 1, item: { ...clientCall, arguments: "" } }), + frame("response.function_call_arguments.done", { + output_index: 1, + item_id: "fc_2", + arguments: clientCall.arguments, + }), frame("response.output_item.done", { output_index: 1, item: clientCall }), frame("response.completed", { response: { id: "resp_1", status: "completed", output: [searchCall, clientCall] }, @@ -734,28 +740,165 @@ describe("the bridged client stream", () => { plan, firstLeg: streamFromText(mixedLeg), requestBody: initialBody, - send: async () => { - sends += 1; + send: async (body) => { + sent.push(body); return new Response(null, { status: 500 }); }, - execute: async () => ({ text: "unused", sources: [] }), + execute: async (queries) => { + executed.push(queries); + return { text: "opencodex 2.50.0 shipped", sources: [{ url: "https://example.test/rel", title: "Releases" }] }; + }, }); const body = await new Response(stream).text(); - expect(sends).toBe(0); - // The client tool call is withheld and dropped: releasing it under a failed turn would let - // Codex start running exec for a turn that never completes. - expect(body).not.toContain("\"name\":\"exec\""); - const failed = clientEvents(body).find(event => event.type === "response.failed"); - expect(failed).toBeDefined(); - const error = (failed!.response as { error: Record }).error; - expect(error.code).toBe(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); - expect(String(error.message)).toContain("another client tool"); - // The opened hosted cell is closed as failed rather than left spinning. - const cell = clientEvents(body).find(event => + const events = clientEvents(body); + + // The client's own call is unanswered, so the conversation owes the client a turn, not the + // gateway: the search still runs, then the leg ends with no continuation POST upstream. + expect(sent).toEqual([]); + expect(executed).toEqual([["opencodex release"]]); + expect(body).not.toContain("response.failed"); + expect(body).not.toContain(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); + + // The hosted cell completes with its real queries and sources, exactly as on a pure leg. + const cellDone = events.find(event => event.type === "response.output_item.done" && (event.item as Record).type === "web_search_call"); - expect((cell!.item as Record).status).toBe("failed"); + expect(cellDone).toBeDefined(); + const cellItem = cellDone!.item as Record; + expect(cellItem.status).toBe("completed"); + expect(cellItem.action).toEqual({ + type: "search", + query: "opencodex release", + queries: ["opencodex release"], + }); + expect(cellItem.sources).toEqual([{ url: "https://example.test/rel", title: "Releases" }]); + + // The held client call is released with its own item id, call_id, and arguments intact. + const execDone = events.find(event => + event.type === "response.output_item.done" + && (event.item as Record).type === "function_call"); + expect(execDone).toBeDefined(); + expect(execDone!.item as Record).toMatchObject({ + id: "fc_2", + call_id: "call_2", + name: "exec", + arguments: clientCall.arguments, + }); + + // One terminal, and its snapshot carries both items in the order upstream emitted them. + const completed = events.filter(event => event.type === "response.completed"); + expect(completed).toHaveLength(1); + const output = (completed[0]!.response as { output: Record[] }).output; + expect(output.map(item => item.type)).toEqual(["web_search_call", "function_call"]); + expect(output[1]).toMatchObject({ call_id: "call_2", name: "exec" }); + }); + + test("a mixed leg where the client call streams first keeps the streamed order in the snapshot", async () => { + const sent: string[] = []; + const clientCall = { + type: "function_call", + id: "fc_0", + call_id: "call_0", + name: "exec", + arguments: "{}", + }; + const mixedLeg = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...clientCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 0, item: clientCall }), + frame("response.output_item.added", { output_index: 1, item: { ...searchCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 1, item: searchCall }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [clientCall, searchCall] }, + }), + ); + + const stream = createPassthroughWebSearchBridgeStream({ + plan, + firstLeg: streamFromText(mixedLeg), + requestBody: initialBody, + send: async (body) => { + sent.push(body); + return new Response(null, { status: 500 }); + }, + execute: async () => ({ text: "a result", sources: [] }), + }); + + const events = clientEvents(await new Response(stream).text()); + expect(sent).toEqual([]); + + // The held call reaches the client AFTER the hosted cell, because it is only released once + // the leg is known to end here; output_index follows that streamed order with no gap. + const added = events.filter(event => event.type === "response.output_item.added"); + expect(added.map(event => (event.item as Record).type)) + .toEqual(["web_search_call", "function_call"]); + expect(added.map(event => event.output_index)).toEqual([0, 1]); + + // The retained snapshot follows the same streamed order -- it exists so response.output + // matches the turn the client received, so a divergence here would contradict the stream. + const completed = events.find(event => event.type === "response.completed"); + const output = (completed!.response as { output: Record[] }).output; + expect(output.map(item => item.type)).toEqual(["web_search_call", "function_call"]); + expect(output[1]).toMatchObject({ call_id: "call_0", name: "exec" }); + }); + + test("a mixed leg whose upstream terminal already ended runs no search and closes the cell", async () => { + const sent: string[] = []; + let executes = 0; + const clientCall = { + type: "function_call", + id: "fc_3", + call_id: "call_3", + name: "exec", + arguments: "{}", + }; + const mixedLeg = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...searchCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 0, item: searchCall }), + frame("response.output_item.added", { output_index: 1, item: { ...clientCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 1, item: clientCall }), + frame("response.incomplete", { + response: { id: "resp_1", status: "incomplete", output: [searchCall, clientCall] }, + }), + ); + + const stream = createPassthroughWebSearchBridgeStream({ + plan, + firstLeg: streamFromText(mixedLeg), + requestBody: initialBody, + send: async (body) => { + sent.push(body); + return new Response(null, { status: 500 }); + }, + execute: async () => { + executes += 1; + return { text: "unused", sources: [] }; + }, + }); + + const body = await new Response(stream).text(); + const events = clientEvents(body); + + // The upstream terminal already ended the turn, so no search is billed and nothing is + // sent back upstream. + expect(executes).toBe(0); + expect(sent).toEqual([]); + + // The opened hosted cell still closes -- as failed, not left in_progress under a finished + // turn -- and the held client call is released rather than dropped. + const cellDone = events.find(event => + event.type === "response.output_item.done" + && (event.item as Record).type === "web_search_call"); + expect((cellDone!.item as Record).status).toBe("failed"); + const execDone = events.find(event => + event.type === "response.output_item.done" + && (event.item as Record).type === "function_call"); + expect(execDone!.item as Record).toMatchObject({ call_id: "call_3", name: "exec" }); + + // The upstream terminal is relayed as it stood: incomplete, not a bridge failure. + const incomplete = events.filter(event => event.type === "response.incomplete"); + expect(incomplete).toHaveLength(1); + expect(body).not.toContain("response.failed"); }); test("already-hosted web_search_call items pass through without a proxy search", async () => { @@ -797,9 +940,9 @@ describe("the bridged client stream", () => { expect(body).not.toContain("response.failed"); }); - test("probe B mixed hosted cells plus exec plus web_search still fail closed", async () => { - let sends = 0; - let executes = 0; + test("probe B mixed hosted cells plus exec plus web_search ends the turn on that leg", async () => { + const sent: string[] = []; + const executed: string[][] = []; const hosted = { type: "web_search_call", id: "ws_hosted", @@ -828,22 +971,42 @@ describe("the bridged client stream", () => { plan, firstLeg: streamFromText(probeB), requestBody: initialBody, - send: async () => { - sends += 1; + send: async (body) => { + sent.push(body); return new Response(null, { status: 500 }); }, - execute: async () => { - executes += 1; - return { text: "unused", sources: [] }; + execute: async (queries) => { + executed.push(queries); + return { text: "a result", sources: [] }; }, }); const body = await new Response(stream).text(); - expect(sends).toBe(0); - expect(executes).toBe(0); - expect(body).not.toContain("\"name\":\"exec\""); - const failed = clientEvents(body).find(event => event.type === "response.failed"); - expect((failed!.response as { error: Record }).error.code) - .toBe(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); + const events = clientEvents(body); + // Only the intercepted call is executed proxy-side; the already-hosted cell is upstream's + // own item and passes through, and the leg still ends without a continuation. + expect(sent).toEqual([]); + expect(executed).toEqual([["opencodex release"]]); + expect(body).not.toContain("response.failed"); + expect(body).not.toContain(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); + // The held exec call is released for Codex to run with its identity intact. + const execDone = events.find(event => + event.type === "response.output_item.done" + && (event.item as Record).type === "function_call"); + expect(execDone).toBeDefined(); + expect(execDone!.item as Record).toMatchObject({ + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"cmd\":\"python fetch.py\"}", + }); + // The snapshot follows the streamed order: the hosted cell, the new cell, then the + // released client call. + const completed = events.find(event => event.type === "response.completed"); + const output = (completed!.response as { output: Record[] }).output; + expect(output.map(item => item.type)) + .toEqual(["web_search_call", "web_search_call", "function_call"]); + expect(output[0]).toMatchObject({ id: "ws_hosted" }); + expect(output[2]).toMatchObject({ call_id: "call_exec", name: "exec" }); }); test("DeepSeek-style XML assistant text is not dispatched as a search", async () => { @@ -1433,7 +1596,7 @@ describe("the reported turn, end to end through handleResponses", () => { expect(result.destinations.every(destination => destination.authorization === "Bearer fixture-key")).toBe(true); }); - test("an exa-backed mixed exec/search turn still fails closed", async () => { + test("an exa-backed mixed exec/search turn ends the turn on that leg", async () => { const cfg = { port: 0, defaultProvider: "fixture", @@ -1448,26 +1611,34 @@ describe("the reported turn, end to end through handleResponses", () => { }, webSearchSidecar: { exaApiKey: "exa-canary" }, } as unknown as OcxConfig; - const execCall = { + // The client call uses the one function name the request declares ("wait"); anything else + // would trip the undeclared-tool guard for a reason unrelated to the bridge. + const waitCall = { type: "function_call", - id: "fc_exec", - call_id: "call_exec", - name: "exec", + id: "fc_wait", + call_id: "call_wait", + name: "wait", arguments: "{}", }; const mixedLeg = sseBody( frame("response.output_item.added", { output_index: 0, item: { ...searchCall, arguments: "" } }), frame("response.output_item.done", { output_index: 0, item: searchCall }), - frame("response.output_item.added", { output_index: 1, item: { ...execCall, arguments: "" } }), - frame("response.output_item.done", { output_index: 1, item: execCall }), + frame("response.output_item.added", { output_index: 1, item: { ...waitCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 1, item: waitCall }), frame("response.completed", { - response: { id: "resp_1", status: "completed", output: [searchCall, execCall] }, + response: { id: "resp_1", status: "completed", output: [searchCall, waitCall] }, }), ); const result = await post(cfg, [mixedLeg]); - expect(result.searches).toBe(0); - expect(result.body).toContain(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); - expect(result.body).not.toContain("\"name\":\"exec\""); + // The exa search still ran proxy-side, the leg ended the turn, and no continuation POST + // went back to the gateway: the client's call is answered by the client, not upstream. + expect(result.searches).toBe(1); + expect(result.outbound).toHaveLength(1); + expect(result.body).not.toContain(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); + expect(result.body).not.toContain("response.failed"); + expect(result.body).toContain("\"type\":\"web_search_call\""); + expect(result.body).toContain("\"name\":\"wait\""); + expect(result.body).toContain("call_wait"); }); test("exa without a key stays disarmed on a non-ollama gateway", async () => {