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 {