From 8f01e46f2e20c8b4782635f52af4f279ef056254 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 14:41:43 +0900 Subject: [PATCH 1/2] fix(web-search): end a mixed-tool bridge leg instead of failing it, and serve alpha/search from a configured sidecar Two web-search gaps, both reported against a deployment with no ChatGPT forward provider. The hosted-search bridge failed closed whenever one upstream leg carried both an intercepted web_search call and a tool call the client has to run: the search cell closed as failed, the client's call was dropped, and the turn died after five reconnects. Such a leg now ends the turn on the leg. The intercepted searches run, their hosted cells complete, the held client calls are released with their call_id and streamed order intact, 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 rather than the gateway, and no tool output is fabricated for a call the bridge cannot execute. A leg whose upstream terminal already ended the turn runs no search at all and closes any cell it opened, so a dead turn is never billed and never leaves a spinner behind. This covers the remainder of issue 4429 on the client-facing side only. The destination still never receives the executed search result: the caller replays the hosted web_search_call cell, which carries the query and its sources but no result text, so the destination's own function_call and function_call_output pair is not reconstructed. Repairing that needs the outbound body rewritten before the first leg is dispatched, which lives outside this module. POST /v1/alpha/search returned 400 whenever no ChatGPT forward candidate existed, before considering any configured backend, so an API-key-only deployment could not use built-in web search at all. When and only when that candidate list is empty, an explicitly configured webSearchSidecar backend of anthropic, xai, gemini, or exa now serves the request with that backend's own credential and answers the shape the client reads. The verbatim relay is untouched while a forward provider exists. An unset or openai backend, a sidecar disabled by enabled:false, and a named backend whose credential is missing all keep the 400 rather than borrowing another paid backend, and a backend that fails answers with its own diagnostic instead of asking for ChatGPT auth. No new configuration field: the fallback reads the webSearchSidecar block that already exists. --- .../260914_l7_web_search_bridge/000_plan.md | 21 ++ .../010_mixed_tool_continuation.md | 39 +++ .../020_alpha_search_fallback.md | 32 ++ src/server/search.ts | 16 +- src/web-search/alpha-search.ts | 276 ++++++++++++++++++ src/web-search/passthrough-bridge.ts | 73 ++++- structure/data-planes/search.md | 12 + structure/runtime.md | 11 +- structure/transports/inventory.md | 2 +- tests/server/server-search.test.ts | 210 ++++++++++++- .../web-search-passthrough-bridge.test.ts | 257 +++++++++++++--- 11 files changed, 878 insertions(+), 71 deletions(-) create mode 100644 devlog/_plan/260914_l7_web_search_bridge/000_plan.md create mode 100644 devlog/_plan/260914_l7_web_search_bridge/010_mixed_tool_continuation.md create mode 100644 devlog/_plan/260914_l7_web_search_bridge/020_alpha_search_fallback.md create mode 100644 src/web-search/alpha-search.ts 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..ac4c0686d1 --- /dev/null +++ b/src/web-search/alpha-search.ts @@ -0,0 +1,276 @@ +/** + * 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 }; + +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): ResolvedAlphaSearchSidecar | undefined { + 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 undefined; + const backend = resolveSidecarBackend(sidecar?.backend); + if (backend === "openai") return undefined; + switch (backend) { + case "anthropic": { + const found = findAnthropicSidecarProvider(config); + return found ? { backend, providerName: found.providerName, provider: found.provider } : undefined; + } + case "xai": { + const found = findXaiSidecarProvider(config); + return found ? { backend, providerName: found.providerName, provider: found.provider } : undefined; + } + case "gemini": { + const found = findGeminiSidecarProvider(config); + return found ? { backend, providerName: found.providerName, provider: found.provider } : undefined; + } + case "exa": { + const apiKey = sidecar?.exaApiKey; + return typeof apiKey === "string" && apiKey.length > 0 ? { backend, apiKey } : undefined; + } + } +} + +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."; + +/** + * 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 resolved = resolveAlphaSearchSidecar(config); + if (!resolved) { + return formatErrorResponse(400, "invalid_request_error", NO_FORWARD_PROVIDER_MESSAGE); + } + 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..d2c23558bb 100644 --- a/structure/data-planes/search.md +++ b/structure/data-planes/search.md @@ -3,6 +3,18 @@ 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, a sidecar +disabled by `enabled: false`, and a named backend whose credential is missing all keep the +existing 400 rather than borrowing another paid backend. A backend that fails answers with its own +diagnostic instead of the ChatGPT-auth message. 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..5bc05c3b99 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,162 @@ 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 } }; + expect(json.error.message).toContain("ChatGPT forward provider"); + expect(json.error.message).toContain("webSearchSidecar"); + 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 () => { From 20684cfa853e975cc2ce94055a61d8e9db132fce Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 14:45:32 +0900 Subject: [PATCH 2/2] fix(web-search): name the missing credential instead of asking for ChatGPT auth A deployment that already chose a web-search backend was still told to configure a ChatGPT forward provider when that backend's credential was absent, which is the exact answer the feature request asked this path to stop giving. Resolution now distinguishes a deployment that named no backend from one whose named backend cannot authenticate: the first keeps the ChatGPT-auth message, the second is refused with a message naming that backend and the credential it could not find, and neither reaches another paid backend. --- src/web-search/alpha-search.ts | 66 ++++++++++++++++++++++++++---- structure/data-planes/search.md | 11 ++--- tests/server/server-search.test.ts | 8 +++- 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/src/web-search/alpha-search.ts b/src/web-search/alpha-search.ts index ac4c0686d1..3bdd64853a 100644 --- a/src/web-search/alpha-search.ts +++ b/src/web-search/alpha-search.ts @@ -63,6 +63,20 @@ type ResolvedAlphaSearchSidecar = | { 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); } @@ -73,30 +87,38 @@ function isRecord(value: unknown): value is Record { * 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): ResolvedAlphaSearchSidecar | undefined { +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 undefined; + if (sidecar?.enabled === false) return { status: "unconfigured" }; const backend = resolveSidecarBackend(sidecar?.backend); - if (backend === "openai") return undefined; + if (backend === "openai") return { status: "unconfigured" }; switch (backend) { case "anthropic": { const found = findAnthropicSidecarProvider(config); - return found ? { backend, providerName: found.providerName, provider: found.provider } : undefined; + return found + ? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } } + : { status: "missing-credential", backend }; } case "xai": { const found = findXaiSidecarProvider(config); - return found ? { backend, providerName: found.providerName, provider: found.provider } : undefined; + return found + ? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } } + : { status: "missing-credential", backend }; } case "gemini": { const found = findGeminiSidecarProvider(config); - return found ? { backend, providerName: found.providerName, provider: found.provider } : undefined; + 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 ? { backend, apiKey } : undefined; + return typeof apiKey === "string" && apiKey.length > 0 + ? { status: "ready", sidecar: { backend, apiKey } } + : { status: "missing-credential", backend }; } } } @@ -201,6 +223,25 @@ const NO_FORWARD_PROVIDER_MESSAGE = + "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 @@ -212,10 +253,17 @@ export async function handleAlphaSearchSidecarFallback( signal?: AbortSignal, logCtx?: { provider: string }, ): Promise { - const resolved = resolveAlphaSearchSidecar(config); - if (!resolved) { + 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); diff --git a/structure/data-planes/search.md b/structure/data-planes/search.md index d2c23558bb..6c63ab85d0 100644 --- a/structure/data-planes/search.md +++ b/structure/data-planes/search.md @@ -9,11 +9,12 @@ The opt-in key-auth Responses hosted-search bridge follows the 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, a sidecar -disabled by `enabled: false`, and a named backend whose credential is missing all keep the -existing 400 rather than borrowing another paid backend. A backend that fails answers with its own -diagnostic instead of the ChatGPT-auth message. The fallback never runs while a forward candidate -exists, so the verbatim relay stays the path for a ChatGPT deployment. +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 diff --git a/tests/server/server-search.test.ts b/tests/server/server-search.test.ts index 5bc05c3b99..721ca15559 100644 --- a/tests/server/server-search.test.ts +++ b/tests/server/server-search.test.ts @@ -577,8 +577,12 @@ test("a webSearchSidecar backend with no credential does not fall through to ano ); 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"); + // 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); });