diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7041841a4d0..d9cca538c98 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -370,7 +370,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | | `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → exact official correction → jawcode catalog → expected-price overlay → model-level vendor fallback, and an explicit all-zero user entry means a known-zero estimate; delete that model entry to restore automatic pricing. All-zero catalog metadata still falls through. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | -| `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. Requests that ride the provider outbound wrapper — model discovery, connection tests, and other proxy-originated diagnostics such as the Ollama show probe — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. An experimental relay may negotiate earlier completed-tool delivery with `x-opencodex-tool-completion: block-stop-v1`; the embedding shell must supply authenticated durable turn membership, and the relay must acknowledge the same response header and implement exact call-bound completion markers. Leave it unset for other providers. Early delivery applies only to eligible structured calls; later errors or cancellation cannot retract a call already delivered. | +| `headers?` | `Record` | Extra upstream headers. For the classic OpenAI web-search sidecar, `x-openai-internal-codex-responses-lite` is removed case-insensitively after configured and selected headers are merged; other forwarded headers are preserved. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. Requests that ride the provider outbound wrapper — model discovery, connection tests, and other proxy-originated diagnostics such as the Ollama show probe — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. An experimental relay may negotiate earlier completed-tool delivery with `x-opencodex-tool-completion: block-stop-v1`; the embedding shell must supply authenticated durable turn membership, and the relay must acknowledge the same response header and implement exact call-bound completion markers. Leave it unset for other providers. Early delivery applies only to eligible structured calls; later errors or cancellation cannot retract a call already delivered. | | `forwardClientHeaders?` | `string[]` | `openai-responses` only: opt in to copying named inbound client metadata headers onto inference requests when the provider has not already set that header. Credential and transport-owned names such as `Authorization`, cookies, API-key headers, `Content-Type`, `Content-Length`, `Host`, and `x-oai-attestation` are rejected and also filtered at runtime. Header names are case-insensitive and may not repeat. `headers` remains authoritative; the existing caller `User-Agent` fallback still applies when neither surface sets one. Supported names: `originator`, `x-client-request-id`, `x-codex-app-version`, `user-agent`; all other names are refused. | | `supportsNativeCompactionTrigger?` | `boolean` | `openai-responses` only: declare support for native v2 `compaction_trigger` input items. Defaults off for noncanonical gateways; preserves native compaction rather than requesting a portable text summary. Independent of the v1 endpoint and blob replay capabilities. | | `supportsNativeCompactEndpoint?` | `boolean` | `openai-responses` only: declare support for native v1 `POST /responses/compact`. Defaults off for custom gateways. Key/local/forward routes use the provider's Responses URL plus `/compact` and its normal transport authentication; OAuth keeps routed compaction and its existing credential lifecycle. Does not enable v2 triggers or grant official OpenAI identity. | diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 6a1e11acf81..6e2a74e5ab2 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -1,5 +1,5 @@ import type { OcxProviderConfig } from "../types"; -import { mergeCodexSelectedHeaders } from "../codex/forward-transport-headers"; +import { CODEX_RESPONSES_LITE_HEADER, mergeCodexSelectedHeaders } from "../codex/forward-transport-headers"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; @@ -84,9 +84,11 @@ export async function runWebSearch( if (settings.reserveCompatibility && settings.model === NATIVE_RESERVE_MODEL) { return { text: "", sources: [], error: "Luna Reserve compatibility is only available as a conversation model, not a search helper. Choose another search helper model." }; } - const headers = Object.fromEntries(mergeCodexSelectedHeaders( + const mergedHeaders = mergeCodexSelectedHeaders( { "Content-Type": "application/json", ...forwardProvider.headers }, selectedForwardHeaders, - )); + ); + mergedHeaders.delete(CODEX_RESPONSES_LITE_HEADER); + const headers = Object.fromEntries(mergedHeaders); const body = { model: settings.model, instructions: settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 36068f40dbf..620766291c5 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -499,7 +499,7 @@ request-scoped OpenAI sidecar authority on completion, failure, and client cance cancellation releases immediately rather than waiting on an abandoned upstream read — so a recovery probe lease no search consumed is always returned. `tests/web-search/web-search-bridge-replay.test.ts` pins the restore and each of those refusals. -A forward OpenAI search sidecar retries a 429 only when the requested delay fits both its retry ceiling and the remaining overall sidecar deadline. A delay that cannot fit returns and records the original 429 so pool routing retains quota evidence. +A forward OpenAI search sidecar retries a 429 only when the requested delay fits both its retry ceiling and the remaining overall sidecar deadline. A delay that cannot fit returns and records the original 429 so pool routing retains quota evidence. The classic OpenAI search helper in `src/web-search/executor.ts` removes the Responses Lite marker from merged configured and selected headers before sending its hosted-search request, while retaining other forwarded headers. `tests/web-search/web-search-sidecar-429.test.ts` covers both header sources. One search makes at most three physical sends in total: connection-reset recovery and 429 replays draw from the same budget, and a budget spent with a 429 in hand ends with that 429 as the recorded outcome. OpenAI helpers recheck the resolved [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) immediately before every physical send, including those retries. An unchanged caller-owned Direct credential is not governed by stored-pool consent. An explicitly unfinished tool end is passed through and never executes. A leg whose diff --git a/tests/web-search/web-search-sidecar-429.test.ts b/tests/web-search/web-search-sidecar-429.test.ts index 56a96c788e4..4b1abae66cd 100644 --- a/tests/web-search/web-search-sidecar-429.test.ts +++ b/tests/web-search/web-search-sidecar-429.test.ts @@ -1,7 +1,7 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { runWebSearch as runOpenAiWebSearch } from "../../src/web-search/executor"; import { listOpenAiForwardSidecarCandidates } from "../../src/providers/openai-sidecar"; -import type { OcxConfig } from "../../src/types"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); @@ -174,3 +174,69 @@ describe("web-search sidecar 429 replays", () => { expect(recorded).toEqual(["connect_neutral"]); }); }); + +test("issue #8160 - OpenAI search helper strips responses-lite marker from merged headers", async () => { + const originalFetch = globalThis.fetch; + let capturedHeaders: Headers | undefined; + globalThis.fetch = (async (_url: any, init: any) => { + capturedHeaders = new Headers(init?.headers); + return new Response( + 'data: ' + JSON.stringify({ type: "response.completed", response: { status: "completed", output: [] } }) + '\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + + try { + await runOpenAiWebSearch( + "synthetic query", + { type: "web_search" }, + { + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + headers: { "x-openai-internal-codex-responses-lite": "true", "x-custom-test": "forwarded" }, + } as any, + new Headers({ + "x-openai-internal-codex-responses-lite": "true", + "chatgpt-account-id": "test-account", + }), + { model: "gpt-6.1-sol", reasoning: "low", timeoutMs: 1_000 }, + ); + + expect(capturedHeaders).toBeDefined(); + expect(capturedHeaders!.has("x-openai-internal-codex-responses-lite")).toBe(false); + expect(capturedHeaders!.get("chatgpt-account-id")).toBe("test-account"); + expect(capturedHeaders!.get("x-custom-test")).toBe("forwarded"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test.each([ + { name: "configured header only", configured: true, selected: false }, + { name: "selected header only", configured: false, selected: true }, +])("classic search strips the Responses Lite marker from $name", async ({ configured, selected }) => { + let sent: Headers | undefined; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + sent = new Headers(init?.headers); + return new Response('data: {"type":"response.completed","response":{"status":"completed","output":[]}}\n\n'); + }) as typeof fetch; + + await runOpenAiWebSearch( + "synthetic query", + { type: "web_search" }, + { + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + headers: { ...(configured ? { "X-OpenAI-Internal-Codex-Responses-Lite": "true" } : {}), "x-custom-test": "forwarded" }, + } as OcxProviderConfig, + new Headers({ ...(selected ? { "X-OpenAI-Internal-Codex-Responses-Lite": "true" } : {}), "chatgpt-account-id": "test-account" }), + { model: "gpt-6.1-sol", reasoning: "low", timeoutMs: 1_000 }, + ); + + expect(sent).toBeDefined(); + expect(sent!.has("x-openai-internal-codex-responses-lite")).toBe(false); + expect(sent!.get("chatgpt-account-id")).toBe("test-account"); + expect(sent!.get("x-custom-test")).toBe("forwarded"); +});