From 4e79b562fe41cc4c98ad1045e2684b68190b626b Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 04:01:41 +0900 Subject: [PATCH] fix(web-search): bind the passthrough bridge search model to its backend sidecarSettingsForBridge read the model out of the global config.webSearchSidecar block without checking which backend that block was configured for, and src/server/responses/core.ts hands the block over whole. A global {backend: "openai", model: "gpt-5.6-luna"} therefore reached runAnthropicWebSearch whenever a provider set webSearchBridge.backend to "anthropic", and Anthropic rejects the model, so the bridge search failed. Same shape for xai and gemini. The global model now applies only when resolveSidecarBackend(sidecar.backend) equals the bridge backend; otherwise the bridge runs that backend's own default. An unset global backend still resolves to "openai", so an unset-backend model reaches an openai bridge and no other. Only the model is gated: reasoning is a generic effort level, and xSearch is xai-only with no per-backend default and no webSearchBridge equivalent, so gating it would make an openai sidecar plus an xai bridge plus x_search inexpressible. No credential crosses a backend, before or after this change. resolvePassthroughWebSearchBridgeAuth switches on the bridge backend and consults only that backend's credential locator. This is a model and settings defect. resolveSidecarBackend and WebSearchBackendId move from src/web-search/index.ts to src/web-search/sidecar-providers.ts, which exists precisely so the bridge can resolve a backend without value-importing the barrel; index.ts re-exports both, so every existing consumer is unchanged. --- .../docs/reference/configuration/providers.md | 2 +- src/web-search/index.ts | 22 +----- src/web-search/passthrough-bridge.ts | 49 +++++++++++-- src/web-search/sidecar-providers.ts | 22 ++++++ structure/runtime.md | 15 ++++ .../web-search-passthrough-bridge.test.ts | 73 ++++++++++++++++++- 6 files changed, 156 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 75f7ea9cf1..a1f69c8a4c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -201,7 +201,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | -| `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | +| `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). The search model comes from `webSearchSidecar.model` only when `webSearchSidecar.backend` resolves to the same backend this bridge names; otherwise the bridge runs that backend's own default, because a model chosen for one vendor is rejected by another. An unset `webSearchSidecar.backend` resolves to `openai`, so an unset-backend model reaches an `openai` bridge and no other. There is no per-provider bridge model override. Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Key-auth `openai-chat` providers only. Opt-in retry for pre-stream transient upstream statuses (500, 502, 503, 504, 520, 521, 522): absent means off, object presence enables it unless `enabled: false`. Covers the initial Responses request, the terminal-guard continuation, and native `/v1/chat/completions`. `attempts` is the TOTAL number of upstream sends allowed for one request including the first (1..10, default 3) — it is one budget shared with connection-reset recovery, so `3` means at most three real requests reach the provider. Waits use a fixed 400 ms exponential backoff capped at 5 s and honor `Retry-After`. Separate from `retryOn429`, which handles rate limiting; mid-stream failures are never replayed. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | diff --git a/src/web-search/index.ts b/src/web-search/index.ts index e700e48a15..3506ef0b4f 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -15,8 +15,10 @@ import { findAnthropicSidecarProvider, findGeminiSidecarProvider, findXaiSidecarProvider, + resolveSidecarBackend, xaiSearchOptionsFromConfig, type AnthropicSidecarProvider, + type WebSearchBackendId, } from "./sidecar-providers"; export { runWithWebSearch } from "./loop"; @@ -29,8 +31,10 @@ export { findAnthropicSidecarProvider, findGeminiSidecarProvider, findXaiSidecarProvider, + resolveSidecarBackend, xaiSearchOptionsFromConfig, type AnthropicSidecarProvider, + type WebSearchBackendId, }; const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna"; @@ -98,24 +102,6 @@ export function webSearchStallTimeoutSec( return Math.min(Number.MAX_VALUE, Math.ceil(largestUnitSec) + STALL_MARGIN_SEC); } -/** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */ -export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa"; - -/** - * Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The - * anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting - * it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna) - * to the Anthropic API. - * The 2188 follow-up ids (xai/gemini/exa) resolve to themselves the same explicit-only way; their - * planWebSearch arms stay fail-closed until each executor layer lands. - */ -export function resolveSidecarBackend( - explicit: WebSearchBackendId | undefined, -): WebSearchBackendId { - if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa") return explicit; - return "openai"; -} - export interface SidecarPlan { /** Which executor runs the search. Anthropic does not require a forward provider. */ backend: WebSearchBackendId; diff --git a/src/web-search/passthrough-bridge.ts b/src/web-search/passthrough-bridge.ts index 37fff58767..c4e9804e46 100644 --- a/src/web-search/passthrough-bridge.ts +++ b/src/web-search/passthrough-bridge.ts @@ -61,6 +61,7 @@ import { findAnthropicSidecarProvider, findGeminiSidecarProvider, findXaiSidecarProvider, + resolveSidecarBackend, xaiSearchOptionsFromConfig, } from "./sidecar-providers"; @@ -677,7 +678,7 @@ export interface PassthroughWebSearchBridgeExecutorContext { auth?: PassthroughWebSearchBridgeAuth; hostedTool?: Record; describeImages?: boolean; - sidecar?: Pick; + sidecar?: Pick; } const DEFAULT_OPENAI_BRIDGE_MODEL = "gpt-5.6-luna"; @@ -686,18 +687,52 @@ const DEFAULT_XAI_BRIDGE_MODEL = "grok-4.6"; const DEFAULT_GEMINI_BRIDGE_MODEL = "gemini-3.8-flash"; const DEFAULT_BRIDGE_REASONING = "low"; -function sidecarSettingsForBridge( +/** + * Search model each bridge backend runs when the global sidecar block was configured for a + * DIFFERENT backend (see modelForBridgeBackend). Exhaustive over the backend union on purpose: + * a seventh backend must decide its own default here rather than fall through to a ChatGPT model. + * The `ollama` and `exa` rows are inert — runOllamaWebSearch takes no model argument and + * runExaWebSearch reads only settings.timeoutMs — and must stay that way. + */ +const DEFAULT_BRIDGE_MODELS: Record = { + ollama: DEFAULT_OPENAI_BRIDGE_MODEL, + openai: DEFAULT_OPENAI_BRIDGE_MODEL, + anthropic: DEFAULT_ANTHROPIC_BRIDGE_MODEL, + xai: DEFAULT_XAI_BRIDGE_MODEL, + gemini: DEFAULT_GEMINI_BRIDGE_MODEL, + exa: DEFAULT_OPENAI_BRIDGE_MODEL, +}; + +/** + * `sidecar` is the GLOBAL `config.webSearchSidecar` block, which carries the model chosen for + * ITS backend. The bridge backend is the per-provider `webSearchBridge.backend` and the two are + * configured independently, so the operator's model only means anything here when they agree: + * a global {backend:"openai", model:"gpt-5.6-luna"} otherwise reaches runAnthropicWebSearch and + * Anthropic rejects the model. On a mismatch the bridge falls back to the backend's own default. + * The same reasoning already pins the backend first in planWebSearch. + * + * Only the model is gated. `reasoning` is a generic effort level, and `xSearch` is xai-only with + * no per-backend default and no `webSearchBridge.xSearch` equivalent, so gating it would make an + * openai sidecar plus an xai bridge plus x_search impossible to express at all. + */ +function modelForBridgeBackend( + backend: ProviderWebSearchBridgeBackend, + sidecar: Pick, +): string { + const backendDefault = DEFAULT_BRIDGE_MODELS[backend]; + if (resolveSidecarBackend(sidecar.backend) !== backend) return backendDefault; + return sidecar.model ?? backendDefault; +} + +/** The settings a bridge executor will run with. Exported for tests; the executor closes over it. */ +export function sidecarSettingsForBridge( backend: ProviderWebSearchBridgeBackend, plan: PassthroughWebSearchBridgePlan, context: PassthroughWebSearchBridgeExecutorContext, ): SidecarSettings { const sidecar = context.sidecar ?? {}; - const model = backend === "anthropic" ? sidecar.model ?? DEFAULT_ANTHROPIC_BRIDGE_MODEL - : backend === "xai" ? sidecar.model ?? DEFAULT_XAI_BRIDGE_MODEL - : backend === "gemini" ? sidecar.model ?? DEFAULT_GEMINI_BRIDGE_MODEL - : sidecar.model ?? DEFAULT_OPENAI_BRIDGE_MODEL; return { - model, + model: modelForBridgeBackend(backend, sidecar), reasoning: sidecar.reasoning ?? DEFAULT_BRIDGE_REASONING, timeoutMs: plan.timeoutMs, describeImages: context.describeImages === true, diff --git a/src/web-search/sidecar-providers.ts b/src/web-search/sidecar-providers.ts index 9a718f6163..c98cd28a15 100644 --- a/src/web-search/sidecar-providers.ts +++ b/src/web-search/sidecar-providers.ts @@ -9,6 +9,28 @@ import { resolveSidecarAuth } from "../sidecar/auth"; import { getAccountSet } from "../oauth/store"; import type { XaiSearchOptions } from "./xai-executor"; +/** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */ +export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa"; + +/** + * Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The + * anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting + * it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna) + * to the Anthropic API. + * The 2188 follow-up ids (xai/gemini/exa) resolve to themselves the same explicit-only way; their + * planWebSearch arms stay fail-closed until each executor layer lands. + * + * Lives here rather than in `index.ts` for the reason at the top of this file: the passthrough + * bridge has to answer "which backend was this global sidecar block configured for?" without + * value-importing the barrel. + */ +export function resolveSidecarBackend( + explicit: WebSearchBackendId | undefined, +): WebSearchBackendId { + if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa") return explicit; + return "openai"; +} + /** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */ export interface AnthropicSidecarProvider { providerName: string; diff --git a/structure/runtime.md b/structure/runtime.md index a09d007d7b..1857614819 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -246,6 +246,21 @@ disarmed rather than falling through to another paid search. A leg that mixes an `web_search` call with another client-executed tool still fails closed. 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)` +equals that bridge backend; otherwise the bridge runs the backend's own default. An unset global +backend resolves to `openai`, so an unset-backend model reaches an `openai` bridge and no other. +There is no per-provider `webSearchBridge.model`, so a mismatched backend gets the default rather +than a vendor-specific override. This is a model and settings rule, not a credential one: +`resolvePassthroughWebSearchBridgeAuth` switches on the bridge backend and consults only that +backend's credential locator, so no key crosses backends. `reasoning` and `xSearch` are not gated — +`reasoning` is a generic effort level and `xSearch` is xai-only with no per-backend default and no +`webSearchBridge` equivalent. `resolveSidecarBackend` lives in `src/web-search/sidecar-providers.ts` +rather than the `src/web-search/index.ts` barrel so the bridge can answer this question without a +value import of the barrel; the barrel re-exports it. +`tests/web-search/web-search-passthrough-bridge.test.ts` covers the mismatch and matching cases for +anthropic, xai, and gemini, plus the unset-backend default. + ## Remote Hub hardening ownership `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. diff --git a/tests/web-search/web-search-passthrough-bridge.test.ts b/tests/web-search/web-search-passthrough-bridge.test.ts index 3bfe28ce2d..c7d0b8b98f 100644 --- a/tests/web-search/web-search-passthrough-bridge.test.ts +++ b/tests/web-search/web-search-passthrough-bridge.test.ts @@ -16,6 +16,7 @@ import { resolveOllamaWebSearchEndpoint, resolvePassthroughWebSearchBridgeAuth, shouldResolveOpenAiPassthroughWebSearchBridge, + sidecarSettingsForBridge, WEB_SEARCH_BRIDGE_ERROR_CODE, WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE, type PassthroughWebSearchBridgePlan, @@ -28,7 +29,7 @@ import { setProviderRequestPacingRuntimeForTest, waitForProviderRequestSlot, } from "../../src/providers/request-pacing"; -import type { OcxConfig, OcxParsedRequest, OcxProviderConfig, ProviderWebSearchBridgeConfig } from "../../src/types"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig, ProviderWebSearchBridgeBackend, ProviderWebSearchBridgeConfig } from "../../src/types"; /** One SSE event block without its blank-line delimiter. */ function frame(type: string, payload: Record): string { @@ -755,6 +756,76 @@ describe("bridge helpers", () => { }); }); +// The global webSearchSidecar block carries the model chosen for ITS backend, while the bridge +// backend is per-provider and configured independently. Without the agreement check a global +// { backend: "openai", model: "gpt-5.6-luna" } would reach runAnthropicWebSearch on an anthropic +// bridge, and Anthropic rejects the model. +describe("sidecarSettingsForBridge backend/model agreement", () => { + function bridgePlan(backend: ProviderWebSearchBridgeBackend): PassthroughWebSearchBridgePlan { + return { backend, maxSearches: 3, timeoutMs: 60_000 }; + } + + test("a global sidecar model configured for another backend does not reach this bridge", () => { + const sidecar = { backend: "openai", model: "gpt-5.6-luna" } as const; + expect(sidecarSettingsForBridge("anthropic", bridgePlan("anthropic"), { sidecar }).model) + .toBe("claude-sonnet-5"); + expect(sidecarSettingsForBridge("xai", bridgePlan("xai"), { sidecar }).model) + .toBe("grok-4.6"); + expect(sidecarSettingsForBridge("gemini", bridgePlan("gemini"), { sidecar }).model) + .toBe("gemini-3.8-flash"); + }); + + test("a global sidecar model configured for the same backend is kept as the operator override", () => { + expect(sidecarSettingsForBridge("anthropic", bridgePlan("anthropic"), { + sidecar: { backend: "anthropic", model: "claude-opus-4-6" }, + }).model).toBe("claude-opus-4-6"); + expect(sidecarSettingsForBridge("xai", bridgePlan("xai"), { + sidecar: { backend: "xai", model: "grok-4.6-fast" }, + }).model).toBe("grok-4.6-fast"); + expect(sidecarSettingsForBridge("gemini", bridgePlan("gemini"), { + sidecar: { backend: "gemini", model: "gemini-3.8-pro" }, + }).model).toBe("gemini-3.8-pro"); + }); + + test("an unset global sidecar backend resolves to openai and matches only an openai bridge", () => { + const sidecar = { model: "gpt-5.6-terra" } as const; + expect(sidecarSettingsForBridge("openai", bridgePlan("openai"), { sidecar }).model) + .toBe("gpt-5.6-terra"); + expect(sidecarSettingsForBridge("anthropic", bridgePlan("anthropic"), { sidecar }).model) + .toBe("claude-sonnet-5"); + }); + + test("an explicit openai sidecar backend keeps its model on an openai bridge", () => { + const sidecar = { backend: "openai", model: "gpt-5.6-terra" } as const; + expect(sidecarSettingsForBridge("openai", bridgePlan("openai"), { sidecar }).model) + .toBe("gpt-5.6-terra"); + }); + + test("a missing global sidecar block still yields a model for the ollama bridge", () => { + // createOllamaBridgeExecutor passes no sidecar; the ollama arm is inert anyway since + // runOllamaWebSearch takes no model argument. + const settings = sidecarSettingsForBridge("ollama", bridgePlan("ollama"), {}); + expect(typeof settings.model).toBe("string"); + expect(settings.model.length).toBeGreaterThan(0); + }); + + test("reasoning, timeout, and describeImages still come from the sidecar block, the plan, and the context", () => { + const settings = sidecarSettingsForBridge("xai", bridgePlan("xai"), { + describeImages: true, + sidecar: { backend: "xai", model: "grok-4.6-fast", reasoning: "high" }, + }); + expect(settings.reasoning).toBe("high"); + expect(settings.timeoutMs).toBe(60_000); + expect(settings.describeImages).toBe(true); + + const unset = sidecarSettingsForBridge("xai", bridgePlan("xai"), { + sidecar: { backend: "xai" }, + }); + expect(unset.reasoning).toBe("low"); + expect(unset.describeImages).toBe(false); + }); +}); + describe("the reported turn, end to end through handleResponses", () => { function config(bridge?: ProviderWebSearchBridgeConfig): OcxConfig { return {