Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions devlog/_plan/260914_l7_web_search_bridge/000_plan.md
Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
@@ -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.

16 changes: 7 additions & 9 deletions src/server/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
Expand Down
Loading
Loading