diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9a0b822f52..90d6146e95 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -33,6 +33,17 @@ Bearer authentication; this does not change the inference URL or imply different quota consumption between adapters. Existing BigModel CN monitor selection remains separate. Full request URLs such as `/api/v1/responses` are not provider base URLs. +## Responses client identity + +For `openai-responses` providers, OpenCodex preserves the incoming client's `User-Agent` +in both API-key and forward modes. This lets upstream services select client-specific +compatibility handling instead of seeing the Bun runtime's default identity. + +An explicit `User-Agent` in the provider's `headers` takes precedence, regardless of +header-name casing. Responses-based search and vision helpers use the same precedence. +When the incoming request has no User-Agent and no override is configured, OpenCodex +does not invent a Codex identity; the transport keeps its normal default behavior. + ## Provider-related top-level fields | Field | Type | Default | Meaning | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 885d2abcd4..9bc986e908 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1200,6 +1200,7 @@ "responses-tool-search-repair.test.ts": "responses", "responses-undeclared-tool-guard.test.ts": "responses", "responses-usage-passthrough.test.ts": "responses", + "responses-user-agent.test.ts": "responses", "restore-completes-shared-teardown.test.ts": "cli", "retry-after-429.test.ts": "server", "route-decision-trace.test.ts": "server", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 4cde7b8d3c..dfe6c1a281 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -41,9 +41,11 @@ import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuation import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; -// Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode. -// Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call. +// Caller headers retained through auth materialization and internal request bridges. +// Forward executors relay this set; an explicit provider User-Agent takes precedence. export const FORWARD_HEADERS = [ + // Keep client identity through auth materialization and internal HTTP/WS bridges. + "user-agent", "authorization", "chatgpt-account-id", "openai-beta", @@ -195,6 +197,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } if (mayForwardCallerCredentials) { for (const h of FORWARD_HEADERS) { + // User-Agent uses the provider-first fallback shared with API-key mode below. + if (h === "user-agent") continue; const v = incoming?.headers.get(h); if (v) { if (h === CODEX_RESPONSES_LITE_HEADER) { @@ -222,6 +226,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (provider.headers) Object.assign(headers, provider.headers); } + // Some Responses providers select Codex compatibility by User-Agent. Preserve the + // caller identity unless the provider explicitly overrides it, regardless of casing. + const callerUserAgent = incoming.headers.get("user-agent"); + if (callerUserAgent !== null && !Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) { + headers["User-Agent"] = callerUserAgent; + } + const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; let routedCustomToolRepairNames: Set | undefined; diff --git a/src/vision/describe.ts b/src/vision/describe.ts index 83c51afaeb..1715a77d0f 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -70,6 +70,8 @@ export async function describeImage( const headers: Record = { "Content-Type": "application/json" }; if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers); for (const h of FORWARD_HEADERS) { + // Explicit provider identity wins over the caller's fallback, including mixed casing. + if (h === "user-agent" && Object.keys(headers).some(name => name.toLowerCase() === h)) continue; const v = selectedForwardHeaders.get(h); if (v) headers[h] = v; } diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 489fa8f399..7655972a5e 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -58,6 +58,8 @@ export async function runWebSearch( const headers: Record = { "Content-Type": "application/json" }; if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers); for (const h of FORWARD_HEADERS) { + // Explicit provider identity wins over the caller's fallback, including mixed casing. + if (h === "user-agent" && Object.keys(headers).some(name => name.toLowerCase() === h)) continue; const v = selectedForwardHeaders.get(h); if (v) headers[h] = v; } diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 83e8f4f466..1b28faf31e 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,5 +1,8 @@ # Adapter Registry Authority +Responses-derived adapters inherit the +[client User-Agent forwarding contract](../transports/responses.md#client-user-agent-forwarding). + Request-local adapter bindings are separate from registry authority in the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 33ca8c76b0..15cf2a0277 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,8 @@ # Inbound Compatibility Surfaces +Internal request bridges retain User-Agent for the +[Responses client identity contract](../transports/responses.md#client-user-agent-forwarding). + Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 4628ca5e50..a0d0a9f711 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,8 @@ # GUI And Management API +Configured provider headers take precedence over caller identity under the +[Responses User-Agent contract](transports/responses.md#client-user-agent-forwarding). + The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 0c0ffe38d0..909748c910 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,5 +1,9 @@ # Docs And Release +The provider configuration reference documents the +[Responses User-Agent contract](../transports/responses.md#client-user-agent-forwarding), +with regression coverage registered in the Responses test domain. + 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. diff --git a/structure/overview.md b/structure/overview.md index 0151115adc..9dc9e8c6d5 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -1,5 +1,8 @@ # Overview +Responses and its search/vision helpers preserve client identity according to the +[User-Agent forwarding contract](transports/responses.md#client-user-agent-forwarding). + 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. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 5ee17ed807..d0634975d1 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -1,5 +1,8 @@ # Chat Provider Compatibility +Requests translated onto the Responses adapter follow its +[client User-Agent forwarding contract](../transports/responses.md#client-user-agent-forwarding). + 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. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 5ae38028d8..cf359a3199 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -1,5 +1,8 @@ # Cursor Provider +The [client User-Agent forwarding contract](../transports/responses.md#client-user-agent-forwarding) +belongs to Responses-based sends; Cursor's direct transport retains its own identity policy. + 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. diff --git a/structure/runtime.md b/structure/runtime.md index bd9ebbd561..d9955d80c0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,5 +1,8 @@ # Runtime +Responses adapters and auxiliary executors follow the +[client User-Agent forwarding contract](transports/responses.md#client-user-agent-forwarding). + Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 7f01dee197..caaa05699a 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,5 +1,8 @@ # Byte Accounting +Adapter request headers follow the [client User-Agent forwarding contract](responses.md#client-user-agent-forwarding); +this header-only policy does not allocate or reserialize request bodies. + Responses body-reader limits and lifetime handling follow the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ca80373a8e..9894f6a4f0 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,5 +1,8 @@ # Transport Inventory +Responses and its auxiliary HTTP sends follow the +[client User-Agent forwarding contract](responses.md#client-user-agent-forwarding). + The existing Responses transport is divided by responsibility in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 4a6a664cad..601838629c 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -15,6 +15,20 @@ Retired Codex Spark has no model-specific tool or Responses Lite override; gener 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. +### Client User-Agent forwarding + +`src/adapters/openai-responses/passthrough.ts` includes `user-agent` in the shared +forward-header allowlist so auth materialization and internal HTTP/WebSocket bridges +retain caller identity. The Responses adapter uses that identity in both API-key and +forward modes only when the provider has no explicit User-Agent header, matched +case-insensitively. A caller with no User-Agent does not acquire a fabricated Codex identity. +Credential selection and the other allowlisted fields keep their existing precedence. + +The Responses-based search and vision executors apply the same provider-first User-Agent +precedence when merging the shared allowlist. `tests/responses/responses-user-agent.test.ts` +covers adapter modes, internal WebSocket header selection, absent UA, mixed-case overrides, +and the headers received over real loopback HTTP by Responses and sidecar upstreams. + ### Credential-bearing HTTP redirects Credential/body-bearing HTTP sends use `redirect: "manual"` at the final executor boundary, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d2eb5d244b..4dc5493360 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1028,6 +1028,7 @@ "responses-tool-search-repair.test.ts": "responses", "responses-undeclared-tool-guard.test.ts": "responses", "responses-usage-passthrough.test.ts": "responses", + "responses-user-agent.test.ts": "responses", "restore-completes-shared-teardown.test.ts": "cli", "retry-after-429.test.ts": "server", "route-decision-trace.test.ts": "server", diff --git a/tests/responses/responses-user-agent.test.ts b/tests/responses/responses-user-agent.test.ts new file mode 100644 index 0000000000..0244e04e7d --- /dev/null +++ b/tests/responses/responses-user-agent.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { parseRequest } from "../../src/responses/parser"; +import { handleResponses } from "../../src/server/responses"; +import { selectForwardHeaders } from "../../src/server/ws-bridge"; +import { describeImage } from "../../src/vision/describe"; +import { runWebSearch } from "../../src/web-search/executor"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const callerUserAgent = "codex_cli_rs/0.153.0 (Windows 11; x86_64)"; +const providerUserAgent = "provider-client/1.0"; +const routes: Array<[string, OcxProviderConfig]> = [ + ["API key", { adapter: "openai-responses", baseUrl: "https://fixture.test/v1", authMode: "key", apiKey: "fixture-key" }], + ["canonical forward", { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }], + ["custom forward", { adapter: "openai-responses", baseUrl: "https://fixture.test/v1", authMode: "forward" }], +]; + +/** Exercise the production adapter with the shared test budget lifecycle. */ +function buildRequest(provider: OcxProviderConfig, incoming: Headers) { + return withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)).buildRequest( + parseRequest({ model: "fixture-model", input: "ping", stream: false }), + { headers: incoming }, + ); +} + +describe("Responses upstream User-Agent", () => { + test("WebSocket bridge retains the caller UA without forwarding unrelated headers", () => { + const selected = selectForwardHeaders(new Headers({ + "User-Agent": callerUserAgent, "x-unrelated": "private", cookie: "fixture=value", + })); + expect(selected.get("user-agent")).toBe(callerUserAgent); + expect(selected.has("x-unrelated")).toBe(false); + expect(selected.has("cookie")).toBe(false); + }); + + for (const [name, provider] of routes) { + test(`${name}: preserves the caller identity without forwarding unrelated headers`, () => { + const incoming = new Headers({ "User-Agent": callerUserAgent, cookie: "fixture=value", "x-unrelated": "private" }); + const result = new Headers(buildRequest(provider, incoming).headers); + expect(result.get("user-agent")).toBe(callerUserAgent); + expect(result.has("cookie")).toBe(false); + expect(result.has("x-unrelated")).toBe(false); + expect(incoming.get("user-agent")).toBe(callerUserAgent); + }); + + // Header casing must not produce two identities when the transport normalizes names. + for (const headerName of ["User-Agent", "user-agent", "USER-AGENT"]) { + test(`${name}: explicit ${headerName} wins without a duplicate`, () => { + const configured = { [headerName]: providerUserAgent, "x-provider-option": "enabled" }; + const request = buildRequest({ ...provider, headers: configured }, new Headers({ "user-agent": callerUserAgent })); + const result = new Headers(request.headers); + expect(result.get("user-agent")).toBe(providerUserAgent); + expect(Object.keys(request.headers).filter(key => key.toLowerCase() === "user-agent")).toHaveLength(1); + expect(result.get("x-provider-option")).toBe("enabled"); + expect(configured).toEqual({ [headerName]: providerUserAgent, "x-provider-option": "enabled" }); + }); + } + + test(`${name}: missing caller UA does not fabricate a client identity`, () => { + expect(new Headers(buildRequest(provider, new Headers()).headers).has("user-agent")).toBe(false); + }); + + test(`${name}: an explicitly empty provider UA still takes precedence`, () => { + const result = new Headers(buildRequest({ ...provider, headers: { "User-Agent": "" } }, + new Headers({ "user-agent": callerUserAgent })).headers); + expect(result.get("user-agent")).toBe(""); + }); + } + + for (const override of [false, true]) { + test(`HTTP upstream receives ${override ? "the configured override" : "the original Codex UA"}`, async () => { + const expected = override ? providerUserAgent : callerUserAgent; + const received: Array = []; + // Model a UA-gated upstream on loopback, without real provider credentials. + const upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + await request.text(); + const ua = request.headers.get("user-agent"); + received.push(ua); + if (ua !== expected) return Response.json({ error: { message: "client compatibility was not selected" } }, { status: 400 }); + return Response.json({ id: "resp_fixture", object: "response", status: "completed", output: [ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, + ] }); + }, + }); + try { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { fixture: { + adapter: "openai-responses", baseUrl: `${upstream.url.origin}/v1`, + authMode: "key", apiKey: "fixture-key", allowPrivateNetwork: true, + ...(override ? { headers: { "USER-AGENT": providerUserAgent } } : {}), + } }, + } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "user-agent": callerUserAgent }, + body: JSON.stringify({ model: "fixture/model", input: "ping", stream: false }), + signal: AbortSignal.timeout(5000), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ok"); + expect(received).toEqual([expected]); + } finally { + await upstream.stop(true); + } + }); + } + + for (const kind of ["search", "vision"] as const) { + for (const headerName of [undefined, "User-Agent", "user-agent", "USER-AGENT"]) { + test(`${kind} sidecar preserves ${headerName ?? "caller UA"} on the wire`, async () => { + const received: Array = []; + const upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + await request.text(); + received.push(request.headers.get("user-agent")); + return new Response('data: {"type":"response.output_text.delta","delta":"ok"}\n\n' + + 'data: {"type":"response.completed","response":{"status":"completed"}}\n\n', + { headers: { "content-type": "text/event-stream" } }); + }, + }); + try { + const provider: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: upstream.url.origin, authMode: "forward", + ...(headerName ? { headers: { [headerName]: providerUserAgent } } : {}), + }; + const incoming = new Headers({ "user-agent": callerUserAgent }); + const settings = { model: "fixture-model", reasoning: "low" as const, timeoutMs: 5000 }; + const result = kind === "search" + ? await runWebSearch("ping", { type: "web_search" }, provider, incoming, settings) + : await describeImage("data:image/png;base64,AA==", "low", "ping", provider, incoming, settings); + expect(result.error).toBeUndefined(); + expect(received).toEqual([headerName ? providerUserAgent : callerUserAgent]); + } finally { + await upstream.stop(true); + } + }); + } + } +});