diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b2c490a2c1..a80eab897f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -224,6 +224,12 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that +served the first request. Changing the selected key, its reference or resolved value, authentication +mode, or base URL during search or provider pacing ends the turn with a bridge error before another +provider request is sent. Changing away and back also ends that continuation. Start a new turn to +use the new selection. Selection changes before the first provider send retain normal reselection. + Custom-model `reasoningEfforts` normally override discovered provider metadata. The bounded exception is an explicit Astra or Daybreak custom row on the canonical `openai` Codex-forward destination: its advertised list is intersected with that model's pinned native capabilities. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6d89ff13ae..deacff62bb 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -6149,6 +6149,8 @@ async function handleResponsesInner( isPassthrough: true, stream: parsed.stream === true, }); + // Capture the binding that actually served the first leg, after its permitted reselection. + const webSearchBridgeBinding = requestBindings.get(request); // The bridge wraps the RAW upstream body, so terminal repair below still owns the single // client-facing terminal — the bridge drops the terminal of every intercepted leg. const upstreamSseBody = webSearchBridgePlan @@ -6166,7 +6168,14 @@ async function handleResponsesInner( connectMs, true, providerFetch(route.provider, options.codexWsRuntimeIdentity, { - dispatchOverride: oauthDispatch(request), + // Pacing can outlive a manual selection change. A continuation must retain the + // first leg's key and appended search result, never rebuild from the original turn. + beforeDispatch: () => { + if (webSearchBridgeBinding?.kind !== "api-key" + || !providerApiKeySelectionIsCurrent(config, route.providerName, webSearchBridgeBinding.provider)) { + throw new Error("API key selection changed during a web-search continuation"); + } + }, providerName: route.providerName, modelId: route.modelId, }), diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index b5a20f3361..05a73e5f8a 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -3,6 +3,9 @@ The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +The server-owned key-auth Responses hosted-search bridge retains the serving adapter's account +binding as specified in [continuation binding contract](../runtime.md#hosted-search-continuation-binding). + ## Decision Runtime adapter construction has one authority: `src/adapters/registry.ts`. diff --git a/structure/catalog.md b/structure/catalog.md index 3aafe4e77a..032d7eee3b 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -218,6 +218,9 @@ real turn depends on it (`src/codex/warmup.ts`). ## Routed tool discovery and hosted search +For opted-in key-auth Responses providers, the declared hosted-search tool follows the +[continuation binding contract](runtime.md#hosted-search-continuation-binding). + All routed catalog rows advertise `supports_search_tool: true` together with `tool_mode: "code_mode_only"` — the pair is load-bearing. The field selects Codex's deferred tool-discovery surface; it does not describe the hosted web-search sidecar. Under code mode, diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 795aa19045..dda9d5e727 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -3,6 +3,9 @@ The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +The shared server's key-auth Responses hosted-search continuation policy is documented in +[continuation binding contract](../runtime.md#hosted-search-continuation-binding). + ## Connected Claude Desktop profiles Connected `ocx claude desktop apply` reads the hub's Desktop snapshot and writes the hub origin diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index e4c1bd9102..6244e59a0d 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -3,6 +3,9 @@ The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +For the shared Responses server's hosted-search continuation binding, see +[continuation binding contract](../runtime.md#hosted-search-continuation-binding). + ## Standalone Images Codex's local `image_gen.imagegen` tool makes a second Images request after the model calls it: diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 4e1ad253a5..7fb089c534 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -42,6 +42,9 @@ to gpt-live-1-codex; gpt-live-1 is an explicit alias. Dictation and Frameless ev separate. Coverage lives in `tests/server/audio-client.test.ts`, `tests/server/audio-dictation.test.ts` and `tests/server/live-call-bindings.test.ts`. +Requests entering the key-auth Responses hosted-search bridge follow its +[continuation binding contract](../runtime.md#hosted-search-continuation-binding). + ## Chat Completions inbound native path `POST /v1/chat/completions` sends eligible `openai-chat` routes directly to the provider's Chat diff --git a/structure/data-planes/search.md b/structure/data-planes/search.md index de35b28ea9..f95494537b 100644 --- a/structure/data-planes/search.md +++ b/structure/data-planes/search.md @@ -1,5 +1,8 @@ # Search Data Plane +The opt-in key-auth Responses hosted-search bridge follows the +[continuation binding contract](../runtime.md#hosted-search-continuation-binding). + ## 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/gui-and-management-api.md b/structure/gui-and-management-api.md index 7835ae62e9..d506a9c121 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,7 +1,7 @@ # GUI And Management API The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Changing the selected API key during an opted-in Responses hosted-search turn follows the [continuation binding contract](runtime.md#hosted-search-continuation-binding). ## Dashboard serving diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 74b20aff6e..8b2d95e9e4 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -5,6 +5,9 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior ## Public docs +The provider configuration reference documents the user-visible +[hosted-search continuation binding](../runtime.md#hosted-search-continuation-binding). + The public documentation site lives in `docs-site/` and is built with Astro + Starlight. English is served at the site root, with Korean under `/ko`, Simplified Chinese under `/zh-cn`, Traditional Chinese under `/zh-tw`, Russian under `/ru`, and Japanese under `/ja`. `docs-site/astro.config.mjs` is the locale source of truth. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 1e73334c7e..c565c935fd 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -44,6 +44,9 @@ PATH, so a launcher-backed job is never misreported as an older plist (#3464). ## Sidecars +The opt-in key-auth Responses hosted-search bridge follows the +[continuation binding contract](../runtime.md#hosted-search-continuation-binding). + Web search and vision sidecars run only when the main request needs that capability and a usable sidecar authority exists. Vision has two possible backends; web search's config union additionally admits `xai`, `gemini`, and `exa`. xAI is a live explicit-only backend through stored Grok OAuth; diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index aec74b8d54..e84e055e5c 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -3,6 +3,9 @@ The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +The opt-in key-auth Responses hosted-search bridge uses the shared +[continuation binding contract](../runtime.md#hosted-search-continuation-binding). + ## xAI Grok hardening (official Grok Build contract parity) Grounded in the open-sourced official client (xai-org/grok-build); unit + evidence: diff --git a/structure/runtime.md b/structure/runtime.md index fe99ed8b64..abe71a7040 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -206,6 +206,19 @@ Routed Responses continuations whose local replay state is missing resolve their The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. +### Hosted-search continuation binding + +The opt-in key-auth Responses hosted-search bridge in `src/server/responses/core.ts` captures the +request binding that served the first leg, after any permitted initial reselection. Before every +continuation dispatch, after provider pacing, that binding must remain an API-key selection matching +the configured entry, reference, revision, resolved key, authentication mode, and base URL; a +disabled or removed provider fails the same check. Drift produces the bridge's failed terminal +without another provider request, and an unchanged binding resends the built request with its +executed search result appended, never re-entering the initial reselection/rebuild path. Initial +dispatch keeps its normal reselection policy. `tests/web-search/web-search-passthrough-bridge.test.ts` +covers drift during search, while pacing, and before first-leg headers return, plus successful +first-dispatch reselection and result preservation. + ## 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/structure/subagents.md b/structure/subagents.md index 187007e7a5..b374256671 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -136,6 +136,9 @@ other fragment layouts and mixed readable content retain their documented residu ## Subagents +Parent and subagent requests using the key-auth Responses hosted-search bridge share the +[continuation binding contract](runtime.md#hosted-search-continuation-binding). + New non-OAuth provider registrations carry `initialModelSelection` with a unique registration identity. Until reliable live/static discovery completes, public catalogs and model candidates withhold those providers' models; the provider itself diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 65e1fcf8aa..425c4096c9 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -3,6 +3,9 @@ The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +The opt-in key-auth Responses hosted-search bridge has a separate +[continuation binding contract](../runtime.md#hosted-search-continuation-binding). + ## Transport inventory The sections above cover the transports with load-bearing invariants. The rest of the transport diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 232e01d46a..7e6b8f2c5c 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -9,7 +9,7 @@ Plaintext collaboration restoration treats a null namespace as absent, rejects n `/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to -Responses-compatible streaming output. +Responses-compatible streaming output. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../runtime.md#hosted-search-continuation-binding). ### Credential-bearing HTTP redirects diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index dc729e6e77..8d4ef3f073 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -3,6 +3,9 @@ The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +Key-auth hosted-search continuations validate account selection after pacing and report a failed +terminal on drift; see [continuation binding contract](../runtime.md#hosted-search-continuation-binding). + ## Heartbeat and stall deadline The HTTP/SSE bridge emits an SSE comment-line keep-alive (`: opencodex heartbeat`) during upstream diff --git a/tests/web-search/web-search-passthrough-bridge.test.ts b/tests/web-search/web-search-passthrough-bridge.test.ts index 9e7029bb53..655c16831b 100644 --- a/tests/web-search/web-search-passthrough-bridge.test.ts +++ b/tests/web-search/web-search-passthrough-bridge.test.ts @@ -21,6 +21,11 @@ import { import { mapOllamaSearchResponse } from "../../src/web-search/ollama-executor"; import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../../src/server/responses-undeclared-tool-guard"; import { handleResponses } from "../../src/server/responses"; +import { + resetProviderRequestPacingForTest, + setProviderRequestPacingRuntimeForTest, + waitForProviderRequestSlot, +} from "../../src/providers/request-pacing"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig, ProviderWebSearchBridgeConfig } from "../../src/types"; /** One SSE event block without its blank-line delimiter. */ @@ -572,9 +577,16 @@ describe("the reported turn, end to end through handleResponses", () => { async function post( ocxConfig: OcxConfig, legs: string[], - ): Promise<{ body: string; outbound: string[]; searches: number }> { + hooks: { onSearch?: () => void; onProviderResponse?: (leg: number) => void } = {}, + ): Promise<{ + body: string; + outbound: string[]; + destinations: Array<{ url: string; authorization: string | null }>; + searches: number; + }> { const savedFetch = globalThis.fetch; const outbound: string[] = []; + const destinations: Array<{ url: string; authorization: string | null }> = []; let searches = 0; let leg = 0; globalThis.fetch = (async (input: unknown, init?: RequestInit) => { @@ -583,13 +595,16 @@ describe("the reported turn, end to end through handleResponses", () => { : input instanceof URL ? input.href : (input as Request).url; if (url.includes("/api/web_search")) { searches += 1; + hooks.onSearch?.(); return new Response(JSON.stringify({ results: [{ title: "Releases", url: "https://example.test/rel", content: "opencodex 2.50.0" }], }), { headers: { "content-type": "application/json" } }); } outbound.push(String(init?.body ?? "")); + destinations.push({ url, authorization: new Headers(init?.headers).get("authorization") }); const text = legs[Math.min(leg, legs.length - 1)]!; leg += 1; + hooks.onProviderResponse?.(leg); return new Response(text, { headers: { "content-type": "text/event-stream" } }); }) as unknown as typeof fetch; try { @@ -598,7 +613,7 @@ describe("the reported turn, end to end through handleResponses", () => { headers: { "content-type": "application/json" }, body: clientRequest, }), ocxConfig, { model: "", provider: "" }); - return { body: await response.text(), outbound, searches }; + return { body: await response.text(), outbound, destinations, searches }; } finally { globalThis.fetch = savedFetch; } @@ -624,6 +639,10 @@ describe("the reported turn, end to end through handleResponses", () => { // The search result reached the SECOND upstream body as a native tool result. expect(result.outbound).toHaveLength(2); + expect(result.destinations).toEqual([ + { url: "https://ollama.com/v1/responses", authorization: "Bearer fixture-key" }, + { url: "https://ollama.com/v1/responses", authorization: "Bearer fixture-key" }, + ]); const continuation = JSON.parse(result.outbound[1]!) as { input: Record[] }; const output = continuation.input.find(item => item.type === "function_call_output"); expect(output).toBeDefined(); @@ -632,6 +651,149 @@ describe("the reported turn, end to end through handleResponses", () => { item.type === "function_call" && item.name === "web_search")).toBe(true); }); + const selectionChanges: Array<[string, (ocxConfig: OcxConfig) => void]> = [ + ["selection revision with an unchanged key", cfg => { + cfg.providers.fixture!.apiKeySelectionRevision = "selection-after"; + }], + ["key reference with the same resolved value", cfg => { + cfg.providers.fixture!.apiKey = "${OCX_BRIDGE_BINDING_ALTERNATE}"; + cfg.providers.fixture!.apiKeyPool![0]!.key = "${OCX_BRIDGE_BINDING_ALTERNATE}"; + }], + ["selected entry id", cfg => { + cfg.providers.fixture!.apiKeyPool![0]!.id = "entry-after"; + }], + ["resolved key behind an unchanged reference", () => { + process.env.OCX_BRIDGE_BINDING_KEY = "fixture-key-after"; + }], + ["authentication mode", cfg => { cfg.providers.fixture!.authMode = "forward"; }], + ["base URL", cfg => { cfg.providers.fixture!.baseUrl = "https://gateway.example/v1"; }], + ["provider disabled", cfg => { cfg.providers.fixture!.disabled = true; }], + ["provider removed", cfg => { delete cfg.providers.fixture; }], + ]; + + test.each(selectionChanges)("refuses the continuation when search changes the %s", async (_name, change) => { + const savedKey = process.env.OCX_BRIDGE_BINDING_KEY; + const savedAlternate = process.env.OCX_BRIDGE_BINDING_ALTERNATE; + process.env.OCX_BRIDGE_BINDING_KEY = "fixture-key"; + process.env.OCX_BRIDGE_BINDING_ALTERNATE = "fixture-key"; + const cfg = config(armed); + Object.assign(cfg.providers.fixture!, { + apiKey: "${OCX_BRIDGE_BINDING_KEY}", + apiKeySelectionRevision: "selection-before", + apiKeyPool: [{ id: "entry-before", key: "${OCX_BRIDGE_BINDING_KEY}" }], + }); + try { + const result = await post(cfg, [searchLeg(), answerLeg()], { onSearch: () => change(cfg) }); + expect(result.searches).toBe(1); + expect(result.outbound).toHaveLength(1); + expect(result.destinations).toEqual([ + { url: "https://ollama.com/v1/responses", authorization: "Bearer fixture-key" }, + ]); + const events = clientEvents(result.body); + expect(events.filter(event => event.type === "response.failed")).toHaveLength(1); + expect(events.filter(event => event.type === "response.completed")).toHaveLength(0); + expect(result.body).toContain(WEB_SEARCH_BRIDGE_ERROR_CODE); + expect(result.body).not.toContain("The current release is 2.50.0."); + } finally { + if (savedKey === undefined) delete process.env.OCX_BRIDGE_BINDING_KEY; + else process.env.OCX_BRIDGE_BINDING_KEY = savedKey; + if (savedAlternate === undefined) delete process.env.OCX_BRIDGE_BINDING_ALTERNATE; + else process.env.OCX_BRIDGE_BINDING_ALTERNATE = savedAlternate; + } + }); + + test("rechecks the continuation binding after its pacing wait", async () => { + const cfg = config(armed); + cfg.providers.fixture!.requestPacing = { enabled: true, minIntervalMs: 100 }; + let now = 0; + let searches = 0; + let waitsAfterSearch = 0; + resetProviderRequestPacingForTest(); + setProviderRequestPacingRuntimeForTest({ + now: () => now, + setTimer: (callback, delayMs) => { + queueMicrotask(() => { + if (searches > 0) { + waitsAfterSearch += 1; + cfg.providers.fixture!.apiKeySelectionRevision = "selection-during-pacing"; + } + now += delayMs; + callback(); + }); + return 1; + }, + clearTimer: () => {}, + enqueueMicrotask: queueMicrotask, + }); + try { + const result = await post(cfg, [searchLeg(), answerLeg()], { onSearch: () => { searches += 1; } }); + expect(result.searches).toBe(1); + expect(waitsAfterSearch).toBe(1); + expect(result.outbound).toHaveLength(1); + expect(result.body).toContain(WEB_SEARCH_BRIDGE_ERROR_CODE); + expect(clientEvents(result.body).filter(event => event.type === "response.completed")).toHaveLength(0); + } finally { + resetProviderRequestPacingForTest(); + } + }); + + test("keeps the dispatched binding if selection changes before first-leg headers return", async () => { + const cfg = config(armed); + const result = await post(cfg, [searchLeg(), answerLeg()], { + onProviderResponse: leg => { + if (leg === 1) cfg.providers.fixture!.apiKey = "fixture-key-after"; + }, + }); + expect(result.searches).toBe(1); + expect(result.outbound).toHaveLength(1); + expect(result.destinations[0]!.authorization).toBe("Bearer fixture-key"); + expect(result.body).toContain(WEB_SEARCH_BRIDGE_ERROR_CODE); + expect(clientEvents(result.body).filter(event => event.type === "response.completed")).toHaveLength(0); + }); + + test("allows initial dispatch reselection and binds search to the key that served it", async () => { + const cfg = config(armed); + cfg.providers.fixture!.requestPacing = { enabled: true, minIntervalMs: 100 }; + let now = 0; + let waits = 0; + resetProviderRequestPacingForTest(); + setProviderRequestPacingRuntimeForTest({ + now: () => now, + setTimer: (callback, delayMs) => { + queueMicrotask(() => { + waits += 1; + if (waits === 1) { + cfg.providers.fixture!.apiKey = "fixture-key-after"; + cfg.providers.fixture!.apiKeySelectionRevision = "selection-before-first-send"; + } + now += delayMs; + callback(); + }); + return 1; + }, + clearTimer: () => {}, + enqueueMicrotask: queueMicrotask, + }); + try { + // Occupy the first slot so the already-built request must wait before credential dispatch. + await waitForProviderRequestSlot("fixture", cfg.providers.fixture!, "glm-4.7"); + const result = await post(cfg, [searchLeg(), answerLeg()]); + expect(waits).toBe(2); + expect(result.searches).toBe(1); + expect(result.destinations).toEqual([ + { url: "https://ollama.com/v1/responses", authorization: "Bearer fixture-key-after" }, + { url: "https://ollama.com/v1/responses", authorization: "Bearer fixture-key-after" }, + ]); + const continuation = JSON.parse(result.outbound[1]!) as { input: Record[] }; + const output = continuation.input.find(item => item.type === "function_call_output"); + expect(String(output?.output)).toContain("opencodex 2.50.0"); + expect(result.body).toContain("The current release is 2.50.0."); + expect(result.body).not.toContain("response.failed"); + } finally { + resetProviderRequestPacingForTest(); + } + }); + test("an unrelated undeclared tool still fails closed through the bridged stream", async () => { const strayCall = { type: "function_call",