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
5 changes: 3 additions & 2 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity
| `modelMaxOutputTokens?` | `Record<string, number>` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. |
| `modelCosts?` | `Record<string, Cost4>` | 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<string, string>` | 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<string, OpenRouterProviderRouting>` | 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`. |
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 @@ -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",
Expand Down
21 changes: 19 additions & 2 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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<string, string>,
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);
Expand Down Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions src/config/provider-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
Expand Down
11 changes: 11 additions & 0 deletions src/config/schema/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
positiveIntegerRecordConfigError,
providerBaseUrlConfigError,
providerHeadersConfigError,
providerForwardClientHeadersConfigError,
reasoningSummaryDeliveryRecordConfigError,
} from "../provider-validation";
import {
Expand Down Expand Up @@ -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({
Expand Down
56 changes: 56 additions & 0 deletions src/lib/provider-client-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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",
"api-key",
"x-goog-api-key",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"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<string>();
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;
}
1 change: 1 addition & 0 deletions src/providers/model-rename-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = {
autoReviewModel: "none",
autoReviewModelOverrides: "record",
headers: "none",
forwardClientHeaders: "none",
openRouterRouting: "none",
modelOpenRouterRouting: "record",
vercelGatewayRouting: "none",
Expand Down
7 changes: 7 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
positiveIntegerRecordConfigError,
providerBaseUrlConfigError,
providerHeadersConfigError,
providerForwardClientHeadersConfigError,
reasoningSummaryDeliveryRecordConfigError,
upstreamHttpVersionConfigError,
} from "../config/provider-validation";
Expand Down Expand Up @@ -764,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) {
Expand Down Expand Up @@ -794,6 +798,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}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const retryOn429Error = retryOn429PolicyConfigError(raw.retryOn429);
if (retryOn429Error) {
// The provider name is caller-controlled and can be token-shaped; redact and JSON-escape
Expand Down Expand Up @@ -1036,6 +1042,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
modelMaxOutputTokens: "editor",
modelCosts: "editor",
headers: "redacted",
forwardClientHeaders: "editor",
openRouterRouting: "editor",
modelOpenRouterRouting: "editor",
vercelGatewayRouting: "editor",
Expand Down
2 changes: 2 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,8 @@ export interface OcxProviderConfig {
*/
autoReviewModelOverrides?: Record<string, string>;
headers?: Record<string, string>;
/** 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. */
Expand Down
1 change: 1 addition & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.proxy`, `providers.<name>.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.<name>.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). |

Expand Down
6 changes: 4 additions & 2 deletions structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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",
Expand Down
Loading
Loading