From 2547b1f21b70ff76751782dd2c243322b4bb3f04 Mon Sep 17 00:00:00 2001 From: MingYang Lu <121647131+2836048681@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:34:22 +0800 Subject: [PATCH 1/3] feat(responses): forward selected client headers --- .../src/content/docs/reference/adapters.md | 5 +- .../docs/reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 1 + src/adapters/openai-responses/passthrough.ts | 21 +++- src/config/provider-validation.ts | 3 + src/config/schema/config-schema.ts | 11 +++ src/lib/provider-client-headers.ts | 55 +++++++++++ src/providers/model-rename-fields.ts | 1 + src/server/auth-cors.ts | 4 + src/types/provider.ts | 2 + structure/config.md | 1 + structure/transports/responses.md | 6 +- tests/fixtures/test-layout-expected.json | 1 + ...i-responses-forward-client-headers.test.ts | 95 +++++++++++++++++++ 14 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 src/lib/provider-client-headers.ts create mode 100644 tests/responses/openai-responses-forward-client-headers.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1dae3494998..c4011aa2df0 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -137,8 +137,9 @@ 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. +if the caller sends none, OpenCodex does not invent one. Other caller metadata is forwarded only +when its name is explicitly listed in `forwardClientHeaders`; provider `headers` still win, and +credential or transport-owned names are refused at configuration and runtime boundaries. 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 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 33371d1104f..84201dc561f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -245,6 +245,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `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. | +| `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. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | | `vercelGatewayRouting?` | `VercelGatewayRouting` | Default Vercel AI Gateway `order`, `only`, and `sort` (`"cost"` \| `"ttft"` \| `"tps"`) preferences; valid only for canonical Vercel AI Gateway with `openai-chat`. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 402eede4dc3..d9ec9b19dcf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1240,6 +1240,7 @@ "openai-provider-option-startup.test.ts": "adapters/openai", "openai-provider-option-tooling.test.ts": "adapters/openai", "openai-provider-option.test.ts": "adapters/openai", + "openai-responses-forward-client-headers.test.ts": "responses", "openai-responses-passthrough.test.ts": "responses", "openai-responses-summary-none.test.ts": "responses", "responses-forward-output-cap.test.ts": "responses", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 5aa3474942a..118fb6c38d1 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -46,6 +46,7 @@ import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuation import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; import { observeOutbound } from "../../usage/cache-diagnostic"; +import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers"; import { normalizeMuseToolChoice } from "./muse-tool-choice"; /** @@ -94,6 +95,21 @@ function applyCallerUserAgentFallback( if (userAgent) headers["User-Agent"] = userAgent; } +/** Copy only explicitly opted-in caller metadata; provider-owned headers remain authoritative. */ +function applyConfiguredClientHeaderForwarding( + headers: Record, + incoming: IncomingMeta, + provider: OcxProviderConfig, +): void { + for (const rawName of provider.forwardClientHeaders ?? []) { + const name = normalizeForwardedClientHeaderName(rawName); + if (name === null) continue; + if (Object.keys(headers).some(existing => existing.toLowerCase() === name)) continue; + const value = incoming.headers.get(name); + if (value) headers[name] = value; + } +} + /** 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); @@ -252,8 +268,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): 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. + // client fingerprint. Additional caller metadata is opt-in; static provider headers remain + // authoritative in either auth mode. + applyConfiguredClientHeaderForwarding(headers, incoming, provider); applyCallerUserAgentFallback(headers, incoming); const forward = provider.authMode === "forward"; diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index d8ff2288509..e7dc0eb0a7e 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -6,6 +6,7 @@ import { } from "../providers/model-discovery-limits"; import { isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { encodeRoutedModelId } from "../providers/slug-codec"; +import { providerForwardClientHeadersConfigError } from "../lib/provider-client-headers"; import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, @@ -125,6 +126,8 @@ export function providerHeadersConfigError(headers: unknown): string | null { return null; } +export { providerForwardClientHeadersConfigError }; + /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */ export function apiKeyTransportConfigError( provider: Pick, diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 6febc06293f..eac84e77b27 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -43,6 +43,7 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerForwardClientHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, } from "../provider-validation"; import { @@ -478,6 +479,16 @@ export const configSchema = z.object({ message: headersError, }); } + const forwardClientHeadersError = providerForwardClientHeadersConfigError( + (provider as { forwardClientHeaders?: unknown }).forwardClientHeaders, + ); + if (forwardClientHeadersError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "forwardClientHeaders"], + message: forwardClientHeadersError, + }); + } const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts); if (modelCostsError) { ctx.addIssue({ diff --git a/src/lib/provider-client-headers.ts b/src/lib/provider-client-headers.ts new file mode 100644 index 00000000000..af7f59b1d36 --- /dev/null +++ b/src/lib/provider-client-headers.ts @@ -0,0 +1,55 @@ +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const BLOCKED_FORWARDED_CLIENT_HEADERS = new Set([ + "authorization", + "chatgpt-account-id", + "connection", + "content-length", + "content-type", + "cookie", + "expect", + "host", + "keep-alive", + "proxy-authorization", + "proxy-connection", + "set-cookie", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "x-oai-attestation", +]); + +const MAX_FORWARDED_CLIENT_HEADERS = 64; + +/** Normalize one opt-in caller header while refusing credential and transport-owned fields. */ +export function normalizeForwardedClientHeaderName(value: unknown): string | null { + if (typeof value !== "string") return null; + const name = value.trim().toLowerCase(); + if (!name || !HEADER_NAME_PATTERN.test(name) || BLOCKED_FORWARDED_CLIENT_HEADERS.has(name)) return null; + return name; +} + +/** Validate provider.forwardClientHeaders without ever admitting credential-bearing headers. */ +export function providerForwardClientHeadersConfigError(value: unknown): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) return "forwardClientHeaders must be an array of header names"; + if (value.length > MAX_FORWARDED_CLIENT_HEADERS) { + return `forwardClientHeaders must contain at most ${MAX_FORWARDED_CLIENT_HEADERS} names`; + } + const seen = new Set(); + for (const raw of value) { + if (typeof raw !== "string" || !raw.trim() || !HEADER_NAME_PATTERN.test(raw.trim())) { + return "forwardClientHeaders must use valid HTTP header names"; + } + const name = raw.trim().toLowerCase(); + if (BLOCKED_FORWARDED_CLIENT_HEADERS.has(name)) { + return `forwardClientHeaders must not include credential or transport-owned header "${raw.trim()}"`; + } + if (seen.has(name)) return `forwardClientHeaders must not repeat header "${raw.trim()}"`; + seen.add(name); + } + return null; +} diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index a12fbf88083..78cc8aedfe9 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -69,6 +69,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { autoReviewModel: "none", autoReviewModelOverrides: "record", headers: "none", + forwardClientHeaders: "none", openRouterRouting: "none", modelOpenRouterRouting: "record", vercelGatewayRouting: "none", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index e53662b844e..ba527c21fed 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -27,6 +27,7 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerForwardClientHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, upstreamHttpVersionConfigError, } from "../config/provider-validation"; @@ -794,6 +795,8 @@ export function providerManagementConfigError( } const headersError = providerHeadersConfigError(typed.headers); if (headersError) return `provider ${name} ${headersError}`; + const forwardClientHeadersError = providerForwardClientHeadersConfigError(raw.forwardClientHeaders); + if (forwardClientHeadersError) return `provider ${name} ${forwardClientHeadersError}`; const retryOn429Error = retryOn429PolicyConfigError(raw.retryOn429); if (retryOn429Error) { // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape @@ -1036,6 +1039,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { modelMaxOutputTokens: "editor", modelCosts: "editor", headers: "redacted", + forwardClientHeaders: "editor", openRouterRouting: "editor", modelOpenRouterRouting: "editor", vercelGatewayRouting: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index e2fda139323..185818aeabd 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -650,6 +650,8 @@ export interface OcxProviderConfig { */ autoReviewModelOverrides?: Record; headers?: Record; + /** Inbound client metadata headers to copy to this provider when the outbound field is otherwise unset. */ + forwardClientHeaders?: string[]; /** Default provider-routing preferences for models sent through the canonical OpenRouter API. */ openRouterRouting?: OpenRouterProviderRouting; /** Exact model-id overrides for `openRouterRouting`. Each matching entry replaces the default. */ diff --git a/structure/config.md b/structure/config.md index 2b92b202384..0f6a7fdce2f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -105,6 +105,7 @@ A schema-invalid top-level JSON value is repairable only when it is a non-array | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | | Canonical ChatGPT upstream transport | `providers.openai.upstreamWebsocket` | Omitted uses upstream WebSocket when eligible; explicit `false` selects HTTP/SSE without changing the canonical provider identity. `true` is rejected on the canonical row. This is independent of the client-facing `websockets` setting. | | Provider egress | `providers..proxy`, `providers..noProxy` | An absent `proxy` inherits global egress; `"direct"` or `null` forces direct egress; HTTP(S) and SOCKS5(H) URLs select a provider-owned proxy. `noProxy` uses NO_PROXY syntax and sends a matching destination direct across either a provider-owned or inherited global proxy. `src/lib/provider-egress.ts` owns parsing and request-local resolution. | +| Provider caller metadata | `providers..forwardClientHeaders` | Optional Responses-only allowlist of inbound metadata header names. Static provider `headers` remain authoritative; credential and transport-owned names are rejected on load/write and filtered again at runtime. | | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | | Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`gui-and-management-api.md`](gui-and-management-api.md). | diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 46ea7fa69c2..e1b3000801a 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -18,8 +18,10 @@ Responses-compatible streaming output. The routed provider name follows every ad 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. +when the caller omits it, the adapter invents no client identity. `forwardClientHeaders` can opt a +provider into additional caller metadata one name at a time, but an existing provider header wins +and credential or transport-owned names are rejected again at the adapter boundary. Canonical +forward auth keeps its separate fixed credential/metadata allowlist. 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4303c879bac..622d18e2167 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1251,6 +1251,7 @@ "openai-provider-option-startup.test.ts": "adapters/openai", "openai-provider-option-tooling.test.ts": "adapters/openai", "openai-provider-option.test.ts": "adapters/openai", + "openai-responses-forward-client-headers.test.ts": "responses", "openai-responses-passthrough.test.ts": "responses", "openai-responses-summary-none.test.ts": "responses", "responses-forward-output-cap.test.ts": "responses", diff --git a/tests/responses/openai-responses-forward-client-headers.test.ts b/tests/responses/openai-responses-forward-client-headers.test.ts new file mode 100644 index 00000000000..073665d322f --- /dev/null +++ b/tests/responses/openai-responses-forward-client-headers.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses/passthrough"; +import { providerForwardClientHeadersConfigError } from "../../src/config/provider-validation"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { OcxProviderConfig } from "../../src/types"; + +function buildHeaders(provider: OcxProviderConfig, incoming: Record): Record { + const budget = createTranslatorBudget(); + try { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "test-model", input: "hello", stream: true }, + }, { + headers: new Headers(incoming), + translatorBudget: budget, + providerName: "test", + }); + request.releaseBodyObservation?.(); + return Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name.toLowerCase(), value])); + } finally { + budget.dispose(); + } +} + +describe("openai-responses forwardClientHeaders", () => { + test("copies only selected caller metadata while provider headers remain authoritative", () => { + const headers = buildHeaders({ + adapter: "openai-responses", + baseUrl: "https://example.com/v1", + authMode: "key", + apiKey: "provider-key", + headers: { + "X-Client-Request-Id": "provider-owned", + "User-Agent": "provider-agent", + }, + forwardClientHeaders: [ + "originator", + "x-client-request-id", + "x-codex-app-version", + "x-custom-client-meta", + "user-agent", + ], + }, { + authorization: "Bearer caller-secret", + originator: "codex_cli_rs", + "user-agent": "caller-agent", + "x-client-request-id": "caller-request", + "x-codex-app-version": "0.159.3", + "x-custom-client-meta": "custom-meta", + "x-not-forwarded": "must-stay-local", + }); + + expect(headers.authorization).toBe("Bearer provider-key"); + expect(headers.originator).toBe("codex_cli_rs"); + expect(headers["x-client-request-id"]).toBe("provider-owned"); + expect(headers["x-codex-app-version"]).toBe("0.159.3"); + expect(headers["x-custom-client-meta"]).toBe("custom-meta"); + expect(headers["user-agent"]).toBe("provider-agent"); + expect(headers["x-not-forwarded"]).toBeUndefined(); + }); + + test("runtime refuses credential and transport headers even if validation is bypassed", () => { + const headers = buildHeaders({ + adapter: "openai-responses", + baseUrl: "https://example.com/v1", + authMode: "key", + apiKey: "provider-key", + forwardClientHeaders: ["authorization", "cookie", "content-type", "x-oai-attestation", "originator"], + }, { + authorization: "Bearer caller-secret", + cookie: "session=secret", + "content-type": "text/plain", + "x-oai-attestation": "attestation-secret", + originator: "codex_cli_rs", + }); + + expect(headers.authorization).toBe("Bearer provider-key"); + expect(headers.cookie).toBeUndefined(); + expect(headers["content-type"]).toBe("application/json"); + expect(headers["x-oai-attestation"]).toBeUndefined(); + expect(headers.originator).toBe("codex_cli_rs"); + }); + + test("validation rejects malformed, duplicate, credential, and transport-owned names", () => { + expect(providerForwardClientHeadersConfigError(["originator", "x-client-request-id"])).toBeNull(); + expect(providerForwardClientHeadersConfigError("originator")).toContain("array"); + expect(providerForwardClientHeadersConfigError(["bad header"])).toContain("valid HTTP header names"); + expect(providerForwardClientHeadersConfigError(["Originator", "originator"])).toContain("must not repeat"); + expect(providerForwardClientHeadersConfigError(["authorization"])).toContain("credential or transport-owned"); + expect(providerForwardClientHeadersConfigError(["content-length"])).toContain("credential or transport-owned"); + }); +}); From 9e69f154dd72f79f75a4f451acf7bbe858b5a9f2 Mon Sep 17 00:00:00 2001 From: MingYang Lu <121647131+2836048681@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:20:07 +0800 Subject: [PATCH 2/3] fix(responses): address client header review findings --- .../src/content/docs/reference/adapters.md | 6 ++-- src/lib/provider-client-headers.ts | 1 + src/server/auth-cors.ts | 3 ++ ...i-responses-forward-client-headers.test.ts | 35 ++++++++++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index c4011aa2df0..e4f4d22ec48 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -137,9 +137,9 @@ 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. Other caller metadata is forwarded only -when its name is explicitly listed in `forwardClientHeaders`; provider `headers` still win, and -credential or transport-owned names are refused at configuration and runtime boundaries. +if the caller sends none, OpenCodex does not invent one. Additional caller metadata can be selected +with `forwardClientHeaders`; provider `headers` win for this option, and credential or transport-owned +names are refused. Canonical ChatGPT forward auth retains its separate fixed header allowlist. 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 diff --git a/src/lib/provider-client-headers.ts b/src/lib/provider-client-headers.ts index af7f59b1d36..73bac4a84d9 100644 --- a/src/lib/provider-client-headers.ts +++ b/src/lib/provider-client-headers.ts @@ -18,6 +18,7 @@ const BLOCKED_FORWARDED_CLIENT_HEADERS = new Set([ "upgrade", "x-amz-security-token", "x-api-key", + "api-key", "x-goog-api-key", "x-oai-attestation", ]); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index ba527c21fed..c5c45bf6f7b 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -765,6 +765,9 @@ export function providerManagementConfigError( // validation and then rejected by the seed comparison, so canonical OpenAI could never // set OR clear it — the value was admitted and then refused in the same request. delete canonicalCandidate.annotateEmptyToolOutputs; + // forwardClientHeaders is an editor-managed request-metadata overlay. It is validated + // separately below and must not make an otherwise canonical OpenAI provider fail the seed check. + delete canonicalCandidate.forwardClientHeaders; // Canonical ChatGPT keeps WebSocket as the default, but an operator may // select the existing HTTP/SSE path without changing its auth or endpoint. if (raw.upstreamWebsocket !== undefined) { diff --git a/tests/responses/openai-responses-forward-client-headers.test.ts b/tests/responses/openai-responses-forward-client-headers.test.ts index 073665d322f..9ea5d5b0c01 100644 --- a/tests/responses/openai-responses-forward-client-headers.test.ts +++ b/tests/responses/openai-responses-forward-client-headers.test.ts @@ -2,6 +2,9 @@ import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses/passthrough"; import { providerForwardClientHeadersConfigError } from "../../src/config/provider-validation"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { providerManagementConfigError } from "../../src/server/auth-cors"; import type { OcxProviderConfig } from "../../src/types"; function buildHeaders(provider: OcxProviderConfig, incoming: Record): Record { @@ -68,12 +71,13 @@ describe("openai-responses forwardClientHeaders", () => { baseUrl: "https://example.com/v1", authMode: "key", apiKey: "provider-key", - forwardClientHeaders: ["authorization", "cookie", "content-type", "x-oai-attestation", "originator"], + forwardClientHeaders: ["authorization", "cookie", "content-type", "x-oai-attestation", "Api-Key", "originator"], }, { authorization: "Bearer caller-secret", cookie: "session=secret", "content-type": "text/plain", "x-oai-attestation": "attestation-secret", + "api-key": "caller-azure-secret", originator: "codex_cli_rs", }); @@ -81,15 +85,44 @@ describe("openai-responses forwardClientHeaders", () => { expect(headers.cookie).toBeUndefined(); expect(headers["content-type"]).toBe("application/json"); expect(headers["x-oai-attestation"]).toBeUndefined(); + expect(headers["api-key"]).toBeUndefined(); expect(headers.originator).toBe("codex_cli_rs"); }); + test("runtime blocks mixed-case api-key in forward auth mode", () => { + const headers = buildHeaders({ + adapter: "openai-responses", + baseUrl: "https://example.com/v1", + authMode: "forward", + forwardClientHeaders: ["Api-Key", "originator"], + }, { + "api-key": "caller-azure-secret", + originator: "codex_cli_rs", + }); + + expect(headers["api-key"]).toBeUndefined(); + expect(headers.originator).toBe("codex_cli_rs"); + }); + + test("canonical OpenAI provider accepts forwardClientHeaders as an operator overlay", () => { + const entry = getProviderRegistryEntry("openai"); + expect(entry).toBeDefined(); + const provider = providerConfigSeed(entry!); + + expect(providerManagementConfigError("openai", { + ...provider, + codexAccountMode: "direct", + forwardClientHeaders: ["originator", "x-client-request-id"], + })).toBeNull(); + }); + test("validation rejects malformed, duplicate, credential, and transport-owned names", () => { expect(providerForwardClientHeadersConfigError(["originator", "x-client-request-id"])).toBeNull(); expect(providerForwardClientHeadersConfigError("originator")).toContain("array"); expect(providerForwardClientHeadersConfigError(["bad header"])).toContain("valid HTTP header names"); expect(providerForwardClientHeadersConfigError(["Originator", "originator"])).toContain("must not repeat"); expect(providerForwardClientHeadersConfigError(["authorization"])).toContain("credential or transport-owned"); + expect(providerForwardClientHeadersConfigError(["Api-Key"])).toContain("credential or transport-owned"); expect(providerForwardClientHeadersConfigError(["content-length"])).toContain("credential or transport-owned"); }); }); From bb8eb250c6b228e3ab7bbcae0a16acc510103dbe Mon Sep 17 00:00:00 2001 From: MingYang Lu <121647131+2836048681@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:13:17 +0800 Subject: [PATCH 3/3] test(responses): cover configured forward-auth header --- .../responses/openai-responses-forward-client-headers.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/responses/openai-responses-forward-client-headers.test.ts b/tests/responses/openai-responses-forward-client-headers.test.ts index 9ea5d5b0c01..9469f487f75 100644 --- a/tests/responses/openai-responses-forward-client-headers.test.ts +++ b/tests/responses/openai-responses-forward-client-headers.test.ts @@ -94,14 +94,16 @@ describe("openai-responses forwardClientHeaders", () => { adapter: "openai-responses", baseUrl: "https://example.com/v1", authMode: "forward", - forwardClientHeaders: ["Api-Key", "originator"], + forwardClientHeaders: ["Api-Key", "originator", "x-custom-client-meta"], }, { "api-key": "caller-azure-secret", originator: "codex_cli_rs", + "x-custom-client-meta": "custom-meta", }); expect(headers["api-key"]).toBeUndefined(); expect(headers.originator).toBe("codex_cli_rs"); + expect(headers["x-custom-client-meta"]).toBe("custom-meta"); }); test("canonical OpenAI provider accepts forwardClientHeaders as an operator overlay", () => {