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
11 changes: 11 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ to restore the default upstream WebSocket selection. This setting does not chang
client-facing `websockets` switch or the ChatGPT account used for the request. Native
mid-turn steering and injection need upstream WebSocket and are unavailable while it is off.

**Reasoning usage metadata (pending next release).** For HTTP/SSE clients on the canonical
ChatGPT route, the upstream WebSocket relay preserves an actual `x-reasoning-included`
connection-response header when the Bun runtime exposes it, including on a reused connection.
This tells Codex that the server's reported usage already includes earlier reasoning. Metadata
sent before the first Responses event takes precedence; later metadata remains in the stream
but cannot change headers already committed. The relay does not invent this signal or alter
usage numbers. A missing upstream signal and Codex's own accounting lifecycle remain separate
issues, so this change alone does not establish that early compaction is fixed. Client-facing
WebSocket connections have already completed their upgrade before the upstream response and
cannot receive the signal retroactively as an upgrade header.

Local provider pacing can also hold a request before it is dispatched at all. So a slow first
output has several possible contributors, and upstream queueing is only one of them. `ocx doctor`
classifies configuration and measures none of these: compare actual transport, pacing, network,
Expand Down
11 changes: 11 additions & 0 deletions docs-site/src/content/docs/ko/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,17 @@ Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들

전용 provider 모드의 `requires_openai_auth = true`는 Codex App/TUI의 계정 게이트 화면을 네이티브 Codex와 같은 조건으로 맞춥니다. opencodex는 `/v1/responses`도 WebSocket으로 제공합니다. 전용 provider는 `"websockets": true`일 때만 `supports_websockets = true`를 광고합니다. loopback에서는 Codex의 빌트인 provider가 먼저 WebSocket을 시도할 수 있으며, 비활성화된 proxy는 `426`을 반환해서 Codex가 HTTP/SSE로 fallback합니다.

**추론 사용량 표시 보존(다음 릴리스 반영 예정).** 정식 ChatGPT 경로를 HTTP/SSE로 받는
클라이언트에는, Bun 런타임이 제공하는 상류 WebSocket 연결 응답의 실제
`x-reasoning-included` 헤더를 전달합니다. 같은 연결을 재사용하는 요청에서도 유지합니다.
이 표시는 서버가 보고한 사용량에 이전 추론량이 이미 포함됐음을 Codex에 알립니다.
첫 Responses 이벤트 전에 받은 메타데이터가 우선하며, 이후 메타데이터는 본문에 남지만
이미 확정한 응답 헤더를 바꾸지는 못합니다. 없는 표시를 만들어 붙이거나 사용량 숫자를
수정하지 않습니다. 상류가 표시를 보내지 않는 경우와 Codex 자체의 사용량 상태 관리는
별도 문제이므로, 이 변경만으로 조기 압축 해결을 보장하지 않습니다. 클라이언트도
WebSocket으로 연결하면 상류 응답 전에 연결 승인이 끝나므로, 그 승인 헤더에 표시를
나중에 추가할 수는 없습니다.

네이티브 ChatGPT forward 요청의 로컬 재생 상태가 만료되었거나 없으면 opencodex는
upstream 요청 전에 `previous_response_not_found`를 반환합니다. Codex WebSocket 클라이언트는
일반 스트림 재시도 한도 안에서 다시 연결하고, 완료된 도구 호출과 결과를 포함한 현재 보유
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 @@ -1961,6 +1961,7 @@
"ws-steering-smoke.test.ts": "responses",
"ws-steering-stability.test.ts": "responses",
"ws-upstream-reuse.test.ts": "responses", "codex-ws-pool-contention.test.ts": "responses",
"codex-ws-handshake.test.ts": "responses", "codex-ws-handshake-validation.test.ts": "responses",
"ws-upstream-socks5.test.ts": "responses",
"ws-upstream.test.ts": "responses",
"xai-client.test.ts": "images",
Expand Down
3 changes: 3 additions & 0 deletions src/server/responses/codex-ws-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
return;
}
if (terminal || settledPreOpen || signal?.aborted) return;
// New sockets have just validated their upgrade; retained sockets carry the
// same connection signal into a fresh exchange, never its predecessor's frames.
metadata?.seed(session.upgradeHeaders());
sent = true;
try {
if (nativeControl) {
Expand Down
44 changes: 44 additions & 0 deletions src/server/responses/codex-ws-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,48 @@ export const CODEX_WS_METADATA_MAX_HEADERS = 128;
export const CODEX_WS_METADATA_MAX_VALUE_BYTES = 4096;

export type CodexWsQuotaObserver = (headers: Headers) => void;
const REASONING_INCLUDED_HEADER = "x-reasoning-included";
const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;

function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

/** Bun emits this payload before validating the upgrade; the session must wait for open. */
export function readCodexWsUpgradeHeaders(payload: unknown): Headers {
try {
if (!record(payload) || payload.statusCode !== 101 || !Array.isArray(payload.rawHeaders)
|| payload.rawHeaders.length % 2 !== 0 || payload.rawHeaders.length > CODEX_WS_METADATA_MAX_HEADERS * 2) return new Headers();
const headers = new Headers();
let bytes = 0;
let connectionNominated = false;
for (let i = 0; i < payload.rawHeaders.length; i += 2) {
const name = payload.rawHeaders[i];
const value = payload.rawHeaders[i + 1];
if (typeof name !== "string" || typeof value !== "string"
|| name.length + value.length > CODEX_WS_METADATA_MAX_BYTES - bytes || !HTTP_HEADER_TOKEN.test(name)
|| /[\r\n\0]/.test(value)) return new Headers();
bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
if (bytes > CODEX_WS_METADATA_MAX_BYTES) return new Headers();
const lower = name.toLowerCase();
if (lower === "connection" && value.split(",").some(token => token.trim().toLowerCase() === REASONING_INCLUDED_HEADER)) {
connectionNominated = true;
}
// Other allowlisted response headers (quota, request id, turn state, model)
// describe a response, not a reusable connection. Never retain them here.
if (lower === REASONING_INCLUDED_HEADER) {
if (Buffer.byteLength(value) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
headers.append(lower, value);
if (Buffer.byteLength(headers.get(lower)!) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
}
}
return connectionNominated ? new Headers() : headers;
} catch {
// Missing/unsupported or malformed optional metadata cannot authorize a retry.
return new Headers();
}
}

function finiteNonnegative(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
Expand Down Expand Up @@ -91,6 +128,13 @@ export class CodexWsMetadata {

snapshot(): Headers { return new Headers(this.headers); }

/** Seed only the connection-level inclusion signal; per-response frames still win. */
seed(headers: Headers): void {
if (this.ended || this.committed || this.headers.has(REASONING_INCLUDED_HEADER)) return;
const value = headers.get(REASONING_INCLUDED_HEADER);
if (value !== null) setMetadataHeader(this.headers, REASONING_INCLUDED_HEADER, value);
}

private publishQuota(headers: Headers): void {
// Observation is auxiliary bookkeeping; a consumer exception cannot turn
// a valid provider frame into a retryable transport failure.
Expand Down
22 changes: 21 additions & 1 deletion src/server/responses/codex-ws-session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readCodexWsUpgradeHeaders } from "./codex-ws-metadata";

export const MAX_CODEX_WS_SESSION_EXCHANGES = 32;

/** Owns one physical socket; request listeners belong to the exchange, not this object. */
Expand All @@ -8,10 +10,13 @@ export class CodexWsSession {
busy = false;
private owner?: (reason: Error) => void;
private readonly completedIds = new Set<string>();
private pendingUpgradeHeaders?: Headers;
private connectionHeaders?: Headers;

constructor(url: string, headers: Record<string, string>, readonly retainable = false,
private readonly changed: () => void = () => {}, proxy?: string) {
this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]);
this.socket.addEventListener("handshake", this.onHandshake);
this.socket.addEventListener("open", this.onOpen);
this.socket.addEventListener("message", this.onIdleMessage);
this.socket.addEventListener("close", this.onClose);
Expand All @@ -20,6 +25,7 @@ export class CodexWsSession {

get reused(): boolean { return this.completedIds.size > 0; }
hasCompleted(id: string): boolean { return this.completedIds.has(id); }
upgradeHeaders(): Headers { return new Headers(this.opened && !this.closed ? this.connectionHeaders : undefined); }

reserve(): boolean {
if (this.closed || this.busy || (this.opened && this.socket.readyState !== undefined && this.socket.readyState !== 1)) return false;
Expand Down Expand Up @@ -71,7 +77,18 @@ export class CodexWsSession {
}
}

private onOpen = (): void => { this.opened = true; };
private onHandshake = (event: Event): void => {
if (this.opened || this.closed) return;
this.pendingUpgradeHeaders = readCodexWsUpgradeHeaders((event as MessageEvent<unknown>).data);
};
private onOpen = (): void => {
if (this.closed) return;
// A 101 payload alone is not proof that Bun accepted the handshake.
this.connectionHeaders = this.pendingUpgradeHeaders;
this.pendingUpgradeHeaders = undefined;
this.socket.removeEventListener("handshake", this.onHandshake);
this.opened = true;
};
private onIdleMessage = (): void => {
if (!this.busy) this.dispose(new Error("codex websocket received unsolicited idle data"));
};
Expand All @@ -85,6 +102,9 @@ export class CodexWsSession {
// The active exchange's close listener retains pre-send fallback semantics.
};
private detach(): void {
this.pendingUpgradeHeaders = undefined;
this.connectionHeaders = undefined;
this.socket.removeEventListener("handshake", this.onHandshake);
this.socket.removeEventListener("open", this.onOpen);
this.socket.removeEventListener("message", this.onIdleMessage);
this.socket.removeEventListener("close", this.onClose);
Expand Down
12 changes: 6 additions & 6 deletions structure/transports/responses-wire-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,12 +464,12 @@ Lite identity still retires the previous socket, and subsequent eligible request
identity can reuse the new socket. Malformed native metadata retains HTTP fallback eligibility
without rewriting its body.

Canonical WS quota and response metadata preceding the first Responses event
are projected into bounded, allowlisted HTTP headers before the response is
committed. Later quota observations update only the captured serving account;
they cannot retroactively change HTTP headers already sent to the client.
Control frames remain bounded, and provider credential/cookie headers are not
forwarded. Once a WS create may have been sent, a missing prelude, overflow or
Canonical WS quota and response metadata preceding the first Responses event are projected into bounded, allowlisted HTTP headers before commitment.
`src/server/responses/codex-ws-session.ts` also retains the actual `x-reasoning-included` upgrade header when Bun exposes its handshake event, promoting the bounded candidate only after `open` validates the connection. No other upgrade header is retained: request ids, model, turn state, quota and credits are response-scoped.
`src/server/responses/codex-ws-metadata.ts` rejects malformed or oversized raw headers and connection-nominated inclusion headers without changing transport failure or replay policy. Each canonical exchange starts from that connection signal; its own prelude metadata wins, including an explicit `false` value, and no prior exchange's metadata carries over. No signal is inferred from a model, configuration or encrypted input, and usage numbers are unchanged.
Later metadata remains in the SSE body, while quota observations update only the captured serving account; neither changes committed HTTP headers. The pre-output hold freezes the same snapshot at its first Responses event. A client-facing WebSocket upgrade is already complete before upstream dispatch and cannot acquire this HTTP/SSE response header retroactively.
`tests/responses/codex-ws-handshake.test.ts` and `tests/responses/codex-ws-handshake-validation.test.ts` cover the native loopback, reuse, bounds and failed-upgrade boundaries. Control frames remain bounded, and provider credential/cookie headers are not forwarded.
Once a WS create may have been sent, a missing prelude, overflow or
disconnect settles as a non-replayable gateway status before the first Responses
event, or as an errored SSE body after it, rather than as a retryable fetch
failure, so HTTP fallback cannot duplicate that inference. The one exception is a
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Responses-compatible streaming output. Every Responses serializer projects unref
The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode.
`forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`.
Provider headers win case-insensitively; arbitrary names are rejected on load/write and ignored at runtime.
Canonical forward auth retains its separate fixed credential/metadata allowlist; absent caller identity is not invented.
Canonical forward auth retains its separate fixed credential/metadata allowlist; absent caller identity is not invented. Upstream WebSocket reasoning-inclusion headers follow the [native prelude and connection-signal contract](responses-wire-shapes.md#passthrough-sse-stream-shapes-314).

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
2 changes: 1 addition & 1 deletion structure/transports/streaming-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ returns the same downstream SSE contract. The canonical `openai` provider uses
upstream WebSocket by default; `providers.openai.upstreamWebsocket: false` sends
its streaming turns over HTTP/SSE instead. This explicit choice also makes
native mid-turn steering and injection unavailable on that provider. It does
not change the endpoint, credential, or downstream event format.
not change the endpoint, credential, or downstream event format. The [upstream connection-signal contract](responses-wire-shapes.md#passthrough-sse-stream-shapes-314) preserves an actual reasoning-inclusion header for HTTP/SSE clients; it cannot modify a client-facing WebSocket upgrade that already completed.
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
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 @@ -1328,6 +1328,7 @@
"ws-steering-stability.test.ts": "responses",
"ws-upstream-reuse.test.ts": "responses",
"codex-ws-pool-contention.test.ts": "responses",
"codex-ws-handshake.test.ts": "responses", "codex-ws-handshake-validation.test.ts": "responses",
"ws-upstream-socks5.test.ts": "responses",
"ws-upstream.test.ts": "responses",
"xai-client.test.ts": "images",
Expand Down
Loading
Loading