Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
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);
Expand Down Expand Up @@ -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<string> | undefined;
Expand Down
5 changes: 5 additions & 0 deletions structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions tests/codex-integration/codex-metadata-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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", () => {
Expand Down
Loading