Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string> | undefined;
let routedCustomToolRepairNames: Set<string> | undefined;
Expand Down
2 changes: 2 additions & 0 deletions src/vision/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export async function describeImage(
const headers: Record<string, string> = { "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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/web-search/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export async function runWebSearch(
const headers: Record<string, string> = { "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;
}
Expand Down
3 changes: 3 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 3 additions & 0 deletions structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 3 additions & 0 deletions structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
4 changes: 4 additions & 0 deletions structure/ops/docs-and-release.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 3 additions & 0 deletions structure/overview.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 3 additions & 0 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 3 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 3 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 3 additions & 0 deletions structure/transports/byte-accounting.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 3 additions & 0 deletions structure/transports/inventory.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
14 changes: 14 additions & 0 deletions structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
147 changes: 147 additions & 0 deletions tests/responses/responses-user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> = [];
// 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<string | null> = [];
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);
}
});
}
}
});
Loading