diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 715555f847..2b82254895 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -123,6 +123,12 @@ body and response, with narrow compatibility rewrites for routed gateways. `forward` uses configured static headers without relaying caller authorization; `key` uses the configured provider key. +The adapter preserves the incoming client's `User-Agent` as a fallback in both auth modes because +some Responses-compatible providers use the Codex client fingerprint for compatibility behavior. +An explicitly configured provider `User-Agent` remains authoritative regardless of header casing; +if the caller sends none, OpenCodex does not invent one. No other caller header is widened by this +exception. + Adapter selection does not select the upstream transport. Eligible requests can use the [upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses Bun's diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 4cde7b8d3c..7bdfd123b6 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -64,6 +64,16 @@ export const FORWARD_HEADERS = [ CODEX_RESPONSES_LITE_HEADER, ]; +/** Preserve the caller fingerprint unless the provider explicitly owns that header. */ +function applyCallerUserAgentFallback( + headers: Record, + incoming: IncomingMeta, +): void { + if (Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) return; + const userAgent = incoming.headers.get("user-agent"); + if (userAgent) headers["User-Agent"] = userAgent; +} + /** Replace every `input_image` part under a routed-compaction body with a short marker. */ function stripInputImagesDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripInputImagesDeep); @@ -221,6 +231,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); } + // Some Responses-compatible gateways select their Codex compatibility path from the real + // client fingerprint. This is a single non-credential fallback, not broader caller-header + // forwarding. Static provider headers remain authoritative in either auth mode. + applyCallerUserAgentFallback(headers, incoming); const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 4a6a664cad..639278c8d0 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -11,6 +11,11 @@ Plaintext collaboration restoration treats a null namespace as absent, rejects n provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to 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). +The `openai-responses` adapter preserves the incoming `User-Agent` as a non-credential fallback in +both key and forward modes. A configured provider header with that name wins case-insensitively; +when the caller omits it, the adapter invents no client identity. This does not widen the canonical +forward credential/metadata allowlist or copy any other caller header. + Retired Codex Spark has no model-specific tool or Responses Lite override; general Lite handling and namespace scrubbing remain shared compatibility behavior. Codex quota/reset evidence follows the [shared/Reserve policy](../providers/openai-tiers.md#public-provider-contract), including suppression of retired model-derived evidence before shared recovery. diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index a9dc25ed4a..d6f21f87e8 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -160,6 +160,78 @@ describe("Codex metadata integrity", () => { expect(sync.headers.session_id).toBe("sess-real-2"); expect(sync.headers["thread-id"]).toBe("thread-real-2"); }); + + test("Responses preserves caller User-Agent as a fallback in key and forward modes", async () => { + for (const provider of [ + { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "test-key", + }, + { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + ] satisfies OcxProviderConfig[]) { + const request = await createResponsesPassthroughAdapter(provider).buildRequest(minimalParsed(), { + headers: new Headers({ "User-Agent": "codex_cli_rs/0.154.0" }), + }); + expect(new Headers(request.headers).get("user-agent")).toBe("codex_cli_rs/0.154.0"); + } + }); + + test("configured User-Agent wins case-insensitively and a missing caller value stays absent", async () => { + const configured = await createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + headers: { "uSeR-aGeNt": "operator-agent/1" }, + }).buildRequest(minimalParsed(), { + headers: new Headers({ "User-Agent": "caller-agent/1" }), + }); + expect(new Headers(configured.headers).get("user-agent")).toBe("operator-agent/1"); + expect(Object.keys(configured.headers).filter(name => name.toLowerCase() === "user-agent")) + .toHaveLength(1); + + const absent = await createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + }).buildRequest(minimalParsed(), { headers: new Headers() }); + expect(new Headers(absent.headers).has("user-agent")).toBe(false); + }); + + test("the preserved User-Agent is the value received by the HTTP upstream", async () => { + let resolveObserved!: (value: string | null) => void; + const observed = new Promise(resolve => { resolveObserved = resolve; }); + const upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + resolveObserved(request.headers.get("user-agent")); + return Response.json({ id: "response-fixture", output: [] }); + }, + }); + try { + const built = await createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + authMode: "key", + }).buildRequest(minimalParsed(), { + headers: new Headers({ "User-Agent": "codex_cli_rs/receiver-proof" }), + }); + await fetch(built.url, { + method: built.method, + headers: built.headers, + body: built.body, + }); + expect(await observed).toBe("codex_cli_rs/receiver-proof"); + } finally { + upstream.stop(true); + } + }); }); describe("Codex request transport metadata", () => {