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
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,12 @@ A provider is included when opencodex has a matching wire adapter, **not** based
(AI Studio, Vertex, and Antigravity/Cloud Code Assist modes), `azure` / `azure-openai`, `kiro`, and
`cursor`. A proprietary API without one of these implementations, such as native Amazon Bedrock,
is not supported directly.

Provider configuration selects the adapter; upstream transport selection is separate. Eligible
Responses traffic can use WSS with [explicit proxy routing](/reference/proxy-formats/#json-and-sse-output).
Invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE, which uses Bun's HTTP
proxy rules rather than the WSS-specific `ALL_PROXY` fallback.

**GitHub Copilot** is an OAuth provider (`ocx login github-copilot`) that exchanges a GitHub
device-flow login for a short-lived Copilot API token — not a pasted API key. **GitLab Duo** remains
a key/subscription-token gateway on its OpenAI-compatible endpoint. **Cloudflare AI
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ 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.

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
HTTP proxy rules and does not inherit the WSS-specific `ALL_PROXY` fallback.

Noncanonical Responses gateways receive Codex's client-executed `tool_search` declaration as a
collision-safe public function tool. Matching request history and JSON/SSE function calls are
translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward
Expand Down
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ the raw JSON frame and its SSE envelope at 4 MiB, and closes the upstream when i
would overflow. That overflow emits a terminal downstream `response.failed` event followed by
`[DONE]`.

The upstream WebSocket checks `NO_PROXY`/`no_proxy` first. Otherwise it uses the first non-empty
`HTTPS_PROXY`, `https_proxy`, `ALL_PROXY`, or `all_proxy` value; `HTTP_PROXY` alone does not proxy a
WSS connection. HTTP and HTTPS proxy URLs are passed to Bun. If the selected value is invalid or
uses an unsupported protocol, opencodex skips the WebSocket attempt and uses HTTP/SSE instead of
dialing the upstream directly.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

These rules belong to the upstream WebSocket transport, independently of the selected provider
adapter. HTTP fetch-based Responses requests, including SSE fallback, use Bun's HTTP proxy rules
and do not use `ALL_PROXY`. `config.proxy` fills missing `HTTP_PROXY`/`HTTPS_PROXY` values; the
resulting scheme-specific value also takes precedence over an existing `ALL_PROXY` for WebSocket.
For an HTTPS upstream that requires a proxy, set `HTTPS_PROXY` or `config.proxy`; `HTTP_PROXY`
alone leaves both WSS and its HTTPS fallback without a scheme-matched proxy.

Every terminal Responses usage object includes both detail objects, even when the provider did not
report those details:

Expand Down
11 changes: 6 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3738,11 +3738,12 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements
}

/**
* Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound
* provider call through the proxy — no per-callsite changes (verified: Bun honors these plus
* NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the
* CLI's own health checks and running-proxy API calls stay direct. Call once per process entry
* that makes outbound provider requests (server start, catalog sync).
* Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports
* such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY
* variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS.
* localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and
* running-proxy API calls stay direct. Call once per process entry that makes outbound provider
Comment thread
S0RYUASUKA marked this conversation as resolved.
* requests (server start, catalog sync).
*/
export function applyProxyEnv(config: OcxConfig): void {
applyProxyEnvWith(config);
Expand Down
47 changes: 2 additions & 45 deletions src/lib/provider-outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
resolvePublicAddresses,
} from "./destination-policy";
import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
import { effectiveProxyFor, outboundProxyConfigured } from "./proxy-env";
import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env";
import { publicProviderBaseUrl } from "./provider-url";

type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">;
Expand Down Expand Up @@ -37,10 +37,6 @@ function pickPinnedAddress(addresses: Array<{ address: string; family: number }>
return addresses.find(address => address.family === 4) ?? addresses[0]!;
}

function configuredProxyFor(): boolean {
return outboundProxyConfigured();
}

/**
* Registry-owned fake-IP transparency exception (Clash/Surge/Mihomo TUN mode).
*
Expand Down Expand Up @@ -76,45 +72,6 @@ function transparentFakeIpException(
return isCanonicalUrl(name, url);
}

function normalizeProxyHostname(hostname: string): string {
const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
return normalized.startsWith("[") && normalized.endsWith("]")
? normalized.slice(1, -1)
: normalized;
}

function noProxyMatches(url: URL): boolean {
const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
const hostname = normalizeProxyHostname(url.hostname);
const port = url.port || (url.protocol === "https:" ? "443" : "80");
for (const rawEntry of raw.split(",")) {
let entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === "*") return true;
entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!;

let entryHost = entry;
let entryPort = "";
const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry);
if (bracketed) {
entryHost = bracketed[1]!;
entryPort = bracketed[2] ?? "";
} else if ((entry.match(/:/g)?.length ?? 0) === 1) {
const separator = entry.lastIndexOf(":");
const possiblePort = entry.slice(separator + 1);
if (/^\d+$/.test(possiblePort)) {
entryHost = entry.slice(0, separator);
entryPort = possiblePort;
}
}
if (entryPort && entryPort !== port) continue;
entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
if (!entryHost) continue;
if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true;
}
return false;
}

let proxyBoundaryWarned = false;
let proxyDnsDegradationWarned = false;

Expand Down Expand Up @@ -181,7 +138,7 @@ async function providerOutboundRequest(
return provider.fetch(url, { ...init, method, redirect: "manual" });
}
const parsed = postUrl ?? new URL(url);
const proxyConfigured = configuredProxyFor();
const proxyConfigured = outboundProxyConfigured();
// Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport
// below reason about the same value. `null` here means "no proxy fetch would actually use",
// even if some other proxy variable is set.
Expand Down
67 changes: 67 additions & 0 deletions src/lib/proxy-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,73 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const;

export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number];
export type ProxyEnvMap = Record<string, string | undefined>;
export type ProxyRoute =
| { kind: "direct" }
| { kind: "proxy"; proxy: string }
| { kind: "fallback" };

export function normalizeProxyHostname(hostname: string): string {
const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
return normalized.startsWith("[") && normalized.endsWith("]")
? normalized.slice(1, -1)
: normalized;
}

export function noProxyMatches(
url: URL,
env: ProxyEnvMap = process.env,
): boolean {
const raw = env.NO_PROXY ?? env.no_proxy ?? "";
const hostname = normalizeProxyHostname(url.hostname);
const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80");
for (const rawEntry of raw.split(",")) {
let entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === "*") return true;
entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!;

let entryHost = entry;
let entryPort = "";
const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry);
if (bracketed) {
entryHost = bracketed[1]!;
entryPort = bracketed[2] ?? "";
} else if ((entry.match(/:/g)?.length ?? 0) === 1) {
const separator = entry.lastIndexOf(":");
const possiblePort = entry.slice(separator + 1);
if (/^\d+$/.test(possiblePort)) {
entryHost = entry.slice(0, separator);
entryPort = possiblePort;
}
}
if (entryPort && entryPort !== port) continue;
entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true;
}
return false;
}

export function resolveProxyRoute(
url: URL,
env: ProxyEnvMap = process.env,
): ProxyRoute {
if (noProxyMatches(url, env)) return { kind: "direct" };
const key = url.protocol === "https:" || url.protocol === "wss:"
? "HTTPS_PROXY"
: "HTTP_PROXY";
const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"]
.map(candidate => env[candidate]?.trim())
.find(Boolean);
if (!proxy) return { kind: "direct" };
try {
const protocol = new URL(proxy).protocol;
return protocol === "http:" || protocol === "https:"
? { kind: "proxy", proxy }
Comment thread
S0RYUASUKA marked this conversation as resolved.
: { kind: "fallback" };
} catch {
return { kind: "fallback" };
}
}

export function proxyEnvPresent(
key: ProxyEnvKey,
Expand Down
8 changes: 4 additions & 4 deletions src/server/responses/codex-ws-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function digest(input: unknown): string {
}

/** Identity comes from the selected outgoing request, never a model label or caller hint. */
export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string): CodexWsReuseIdentity | null {
export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string, proxy?: string): CodexWsReuseIdentity | null {
if (url !== CODEX_RESPONSES_HTTP_URL) return null;
let body: unknown;
try { body = JSON.parse(frameText); } catch { return null; }
Expand All @@ -52,7 +52,7 @@ export function codexWsReuseIdentity(url: string, headers: Record<string, string
const scope = digest([url, account, thread, turn]);
const lite = metadata.ws_request_header_x_openai_internal_codex_responses_lite;
if (lite !== undefined && lite !== "true" && lite !== "false") return null;
return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable]) };
return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable, proxy ?? null]) };
}

interface Entry { identity: CodexWsReuseIdentity; session: CodexWsSession; createdAt: number; idleAt: number; retired: boolean }
Expand All @@ -75,7 +75,7 @@ export class CodexWsPool {
this.maxAgeMs = options.maxAgeMs ?? CODEX_WS_POOL_MAX_AGE_MS;
}

acquire(identity: CodexWsReuseIdentity, url: string, headers: Record<string, string>): CodexWsSession | null {
acquire(identity: CodexWsReuseIdentity, url: string, headers: Record<string, string>, proxy?: string): CodexWsSession | null {
this.sweep();
for (const entry of this.entries.values()) {
if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue;
Expand All @@ -94,7 +94,7 @@ export class CodexWsPool {
this.remove(oldest);
}
const createdAt = this.now();
const session = new CodexWsSession(url, headers, true, () => this.changed(entry));
const session = new CodexWsSession(url, headers, true, () => this.changed(entry), proxy);
const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false };
session.reserve();
this.entries.set(identity.key, entry);
Expand Down
4 changes: 2 additions & 2 deletions src/server/responses/codex-ws-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export class CodexWsSession {
private readonly completedIds = new Set<string>();

constructor(url: string, headers: Record<string, string>, readonly retainable = false,
private readonly changed: () => void = () => {}) {
this.socket = new WebSocket(url, { headers } as unknown as string[]);
private readonly changed: () => void = () => {}, proxy?: string) {
this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]);
this.socket.addEventListener("open", this.onOpen);
this.socket.addEventListener("message", this.onIdleMessage);
this.socket.addEventListener("close", this.onClose);
Expand Down
11 changes: 8 additions & 3 deletions src/server/responses/ws-upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// (passthrough relay, adapter parsers, usage sniffing) is unchanged.

import { compareBunVersions } from "../../lib/bun-stream-caps";
import { resolveProxyRoute } from "../../lib/proxy-env";
import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request";
import { codexWsExchange } from "./codex-ws-exchange";
Expand Down Expand Up @@ -150,6 +151,10 @@ export function codexWsUpstreamFetch(
return sseFallback(url, init);
}

const wsUrl = wsUpstreamUrlFor(url);
const proxyRoute = resolveProxyRoute(new URL(wsUrl));
if (proxyRoute.kind === "fallback") return sseFallback(url, init);
const proxy = proxyRoute.kind === "proxy" ? proxyRoute.proxy : undefined;
// A genuine caller `originator` is already in these headers via the forward
// set. Never fabricate one here: pool/forward traffic must not impersonate
// Codex CLI, per the metadata-integrity contract. (The backend's fast lane
Expand All @@ -164,9 +169,9 @@ export function codexWsUpstreamFetch(
}
let session: CodexWsSession;
try {
const identity = codexWsReuseIdentity(url, headers, frameText);
session = (identity ? codexWsPool.acquire(identity, wsUpstreamUrlFor(url), headers) : null)
?? new CodexWsSession(wsUpstreamUrlFor(url), headers);
const identity = codexWsReuseIdentity(url, headers, frameText, proxy);
session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null)
?? new CodexWsSession(wsUrl, headers, false, undefined, proxy);
if (!session.busy && !session.reserve()) {
session.dispose();
return sseFallback(url, init);
Expand Down
8 changes: 6 additions & 2 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ These are transport-fidelity guarantees, not a provider-billing guarantee.

Eligible complete-input creates can retain a canonical upstream socket within
one selected account, credential, thread and turn. Model/tier and immutable
handshake headers must also match. Turn-state and turn-metadata headers are
handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are
projected into their same-name per-frame metadata slots; explicit body values win.
The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and
retires a socket after five minutes or 32 successful exchanges (after active work
Expand Down Expand Up @@ -644,7 +644,11 @@ the upgrade with 426 so Codex falls back to HTTP cleanly.

That setting controls the client-facing upgrade only. The transparent upstream
ChatGPT WS optimization described above is selected independently and still
returns the same downstream SSE contract.
returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the
first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not
route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the
existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket
egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not consult ALL_PROXY.

The endpoint handles `response.create`, ignores `response.processed`, supports warmup
`generate: false`, and feeds the same request pipeline as HTTP/SSE.
Expand Down
29 changes: 27 additions & 2 deletions tests/responses/ws-upstream-reuse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-reque

const URL = "https://chatgpt.com/backend-api/codex/responses";
const realWebSocket = globalThis.WebSocket;
const proxyEnvKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"];
let savedProxyEnv: Record<string, string | undefined>;
let sequence = 0;

class Socket extends EventTarget {
static all: Socket[] = [];
static onSend: (socket: Socket, frame: Record<string, unknown>) => void = (socket) => socket.complete();
readyState = 0;
frames: Record<string, unknown>[] = [];
constructor(readonly url: string) {
constructor(readonly url: string, readonly options?: { proxy?: string }) {
super();
Socket.all.push(this);
queueMicrotask(() => { if (this.readyState === 0) { this.readyState = 1; this.dispatchEvent(new Event("open")); } });
Expand Down Expand Up @@ -58,7 +60,11 @@ function bodyWith(fields: Record<string, unknown>) {
options.body = JSON.stringify({ ...JSON.parse(options.body as string), ...fields });
return options;
}
beforeEach(() => { globalThis.WebSocket = Socket as unknown as typeof WebSocket; });
beforeEach(() => {
globalThis.WebSocket = Socket as unknown as typeof WebSocket;
savedProxyEnv = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]]));
for (const key of proxyEnvKeys) delete process.env[key];
});

afterEach(() => {
runOptionalShutdownHooks();
Expand All @@ -67,6 +73,25 @@ afterEach(() => {
Socket.onSend = socket => socket.complete();
sequence = 0;
globalThis.WebSocket = realWebSocket;
for (const key of proxyEnvKeys) delete process.env[key];
for (const key of proxyEnvKeys) {
if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key];
}
});

test("proxy changes and NO_PROXY retire the old route while unchanged routes reuse", async () => {
for (const proxy of ["http://proxy-a.example:8080", "http://proxy-b.example:8080"]) {
process.env.HTTPS_PROXY = proxy;
await drain();
await drain();
}
process.env.NO_PROXY = "chatgpt.com:443";
await drain();
await drain();
expect(Socket.all.map(socket => socket.options?.proxy))
.toEqual(["http://proxy-a.example:8080", "http://proxy-b.example:8080", undefined]);
expect(Socket.all.map(socket => socket.frames.length)).toEqual([2, 2, 2]);
expect(Socket.all.map(socket => socket.readyState)).toEqual([3, 3, 1]);
});

test("same account/thread/turn reuses one socket without trimming either HTTP input", async () => {
Expand Down
Loading
Loading