Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 33 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,39 @@ The proxy listens on port `10100` by default and serves `POST /v1/responses`,
`POST /v1/responses/compact`, `POST /v1/images/generations`, `POST /v1/images/edits`,
`GET /v1/models`, `GET /healthz`, and the `/api/*` management surface.

### Experimental context management (Codex 0.153+)

For an eligible ChatGPT account, enable the experimental feature in Codex's own
`config.toml` (merge this into an existing `[features]` table):

```toml
[features]
context_management.experimental_mode = true
```

On the default built-in loopback integration, the next `ocx sync` or proxy start changes the
managed root `openai_base_url` to `http://127.0.0.1:10100/backend-api/codex`. Codex checks this
backend path before enabling its `new_context`, history, and notes tools. Start a new Codex
session after synchronization. The feature remains opt-in; user-owned base URLs and remote
custom-provider injection are not rewritten. No context-window or compaction-limit override
is needed or added.

The backend prefix aliases the existing data-plane routes, including Responses WebSocket
upgrades. The original `/v1` routes and the realtime sideband override remain available. The
proxy also relays the ten native `alpha/history/v2/*` and `alpha/notes/v2/*` POST endpoints
through the configured ChatGPT forward provider. Encrypted arguments, tool-output policy
headers, response bodies, and upstream error statuses are preserved. These private endpoints
are not implemented by other model providers or the OpenAI API-key route.
Comment thread
y2ambition-ai marked this conversation as resolved.
Outdated

Pool/Direct account selection is unchanged. History requests use their root
`context.session_id` to recover the same local account-affinity lane as the root model request;
this does not migrate server-side history between accounts. An automatic account change or
a proxy restart can therefore affect continuity. Notes writes are not automatically retried,
and history traffic does not consume or settle a model quota-recovery probe.

To disable the feature, remove the experimental key (or set it to `false`), run `ocx sync`,
and start a new Codex session. The managed root base returns to `/v1`.

### Built-in image generation (`image_gen`)

Codex's built-in `image_gen` tool does not go through `/v1/responses` — the codex-rs extension
Expand Down
6 changes: 5 additions & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"domains": {
"providers": {
"match": [
"^(?:aside|auto|azure|baseten|chutes|cline|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-"
"^(?:aside|auto|azure|baseten|chutes|cline|command|commandcode|context(?!-compat|-history)|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-"
],
"children": {
"cursor": [
Expand All @@ -33,11 +33,13 @@
},
"codex-integration": {
"match": [
"^context-compat\\.test\\.ts$",
"^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-"
]
},
"server": {
"match": [
"^context-history\\.test\\.ts$",
"^(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-"
]
},
Expand Down Expand Up @@ -493,6 +495,8 @@
"consume-for-inspection-cancel.test.ts": "server",
"container-bootstrap.test.ts": "service",
"context-cap-unknown-window.test.ts": "providers",
"context-compat.test.ts": "codex-integration",
"context-history.test.ts": "server",
"continuation-dedup.test.ts": "responses",
"core-lab-boundary.test.ts": "lab",
"cost-cap-unknown-evidence.test.ts": "usage",
Expand Down
36 changes: 36 additions & 0 deletions src/codex/context-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** Backend path and opt-in config compatibility for native Codex history/notes. */
export const CONTEXT_BACKEND_PREFIX = "/backend-api/codex";

const CONTEXT_ENDPOINTS = new Set([
"alpha/history/v2/list_windows", "alpha/history/v2/list_items",
"alpha/history/v2/read_item", "alpha/history/v2/search_contents",
"alpha/notes/v2/thread_hint", "alpha/notes/v2/list_files_by_prefix",
"alpha/notes/v2/read_file", "alpha/notes/v2/search_contents",
"alpha/notes/v2/append_to_file", "alpha/notes/v2/write_file",
]);

export function contextEndpoint(path: string): string | undefined {
const endpoint = path.startsWith("/v1/") ? path.slice(4) : "";
return CONTEXT_ENDPOINTS.has(endpoint) ? endpoint : undefined;
}

/** Alias only the data-plane prefix. Existing auth/origin and route gates still run. */
export function codexCompatibleUrl(rawUrl: string): URL {
const url = new URL(rawUrl);
if (url.pathname === CONTEXT_BACKEND_PREFIX || url.pathname.startsWith(CONTEXT_BACKEND_PREFIX + "/")) {
url.pathname = "/v1" + url.pathname.slice(CONTEXT_BACKEND_PREFIX.length);
}
return url;
}

/** Change only marker-managed built-in routing, and only with an explicit context opt-in. */
export function contextCompatibleBaseLine(content: string, line: string): string {
const parsed = Bun.TOML.parse(content) as {features?: {context_management?: {experimental_mode?: boolean}}};
Comment thread
y2ambition-ai marked this conversation as resolved.
Outdated
if (parsed.features?.context_management?.experimental_mode !== true) return line;
const match = /^openai_base_url = "([^"]+)"$/.exec(line);
if (!match) return line;
const url = new URL(match[1]);
if (url.pathname !== "/v1" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) return line;
url.pathname = CONTEXT_BACKEND_PREFIX;
return `openai_base_url = "${url.href}"`;
}
5 changes: 3 additions & 2 deletions src/codex/inject.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { contextCompatibleBaseLine } from "./context-compat";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import {
atomicWriteFile,
Expand Down Expand Up @@ -364,7 +365,7 @@ export function setRootOpenaiBaseUrl(
const lines = content.split("\n");
const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
const rootEnd = firstTable === -1 ? lines.length : firstTable;
const key = buildOpenaiBaseUrlLine(portOrTarget, hostname);
const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLine(portOrTarget, hostname));

for (let i = 0; i < rootEnd; i++) {
if (!isRootOpenaiBaseUrlLine(lines[i])) continue;
Expand Down Expand Up @@ -399,7 +400,7 @@ function setRootOpenaiBaseUrlForTarget(
const lines = content.split("\n");
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
const rootEnd = firstTable === -1 ? lines.length : firstTable;
const key = buildOpenaiBaseUrlLineForTarget(target);
const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLineForTarget(target));
for (let index = 0; index < rootEnd; index += 1) {
if (!isRootOpenaiBaseUrlLine(lines[index])) continue;
const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER);
Expand Down
114 changes: 114 additions & 0 deletions src/server/context-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/** Native history/notes JSON relay. No interpretation of encrypted tool arguments or retries. */
import { formatErrorResponse } from "../bridge";
import {
CodexAccountCooldownError, CodexAuthContextError, CodexMainProfileDrainingError, CodexDirectAuthenticationError,
CodexPoolAuthenticationError, CodexThreadAffinityExpiredError,
codexMainProfileDrainingResponse, cooldownErrorResponse,
headersForCodexAuthContext, isCodexAuthContextUsable, resolveCodexAuthContext, releaseCodexAuthContextProbeLease,
} from "../codex/auth-context";
import { contextEndpoint } from "../codex/context-compat";
import { formatCodexProviderForLog } from "../codex/routing";
import { listOpenAiForwardSidecarCandidates } from "../providers/openai-sidecar";
import { signalWithTimeout } from "../lib/abort";
import { readBoundedResponseBytes } from "../lib/bounded-body";
import type { AdmissionLease } from "../lib/admission";
import type { OcxConfig } from "../types";
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
import { readJsonRequestBody } from "./request-decompress";
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
import { codexAccountSelectionForTurn } from "./lifecycle";
import type { RequestLogContext } from "./request-log";

const PROTOCOL_HEADERS = ["x-openai-encrypted-tool-arguments", "x-openai-tool-output-truncation-policy"];
const RESPONSE_HEADERS = ["content-type", "retry-after", "x-request-id", "openai-processing-ms", ...PROTOCOL_HEADERS];
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;

export function contextSelectionHeaders(headers: Headers, sessionId: string): Headers {
const result = new Headers(headers);
// Codex history tools carry root session_id in JSON, unlike Responses' HTTP headers.
// Root model requests use (session-id=root, thread-id=root); don't fabricate a parent key.
if (!result.has("x-codex-parent-thread-id") && !result.has("session-id") && !result.has("thread-id")) {
result.set("session-id", sessionId);
result.set("thread-id", sessionId);
}
return result;
}

export async function handleContextHistory(
req: Request, config: OcxConfig, logCtx: RequestLogContext,
endpoint: string, turnAdmissionLease?: AdmissionLease,
): Promise<Response> {
if (!contextEndpoint("/v1/" + endpoint) || req.method !== "POST") {
return formatErrorResponse(404, "not_found", "Unknown context endpoint");
}
try { validateForwardAdmissionCredential(req.headers, config); }
Comment thread
y2ambition-ai marked this conversation as resolved.
Outdated
catch (err) {
if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
throw err;
}
let body: unknown;
try { body = await readJsonRequestBody(req); }
catch (err) { return decodeRequestErrorResponse(err, "context_history"); }
const sessionId = (body as {context?: {session_id?: unknown}} | null)?.context?.session_id;
if (typeof sessionId !== "string" || !/^[A-Za-z0-9._:-]{1,512}$/.test(sessionId)) {
return formatErrorResponse(400, "invalid_request_error", "context.session_id must be a bounded nonempty string");
}
const candidate = listOpenAiForwardSidecarCandidates(config)[0];
if (!candidate) return formatErrorResponse(400, "invalid_request_error", "History and notes require the native ChatGPT forward provider");
let authContext: Awaited<ReturnType<typeof resolveCodexAuthContext>>;
const headers = new Headers(candidate.provider.headers);
try {
authContext = await resolveCodexAuthContext(contextSelectionHeaders(req.headers, sessionId), config, candidate.accountMode, {
// Non-Spark context tools share the ordinary model quota/affinity scope, not legacy.
modelId: "context_history",
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
});
if (authContext.kind !== "main" && authContext.probeLeaseId) {
// History traffic must not occupy or settle the model's quota-recovery probe.
releaseCodexAuthContextProbeLease(authContext);
return formatErrorResponse(503, "upstream_error", "Model quota recovery is pending; retry context operation later");
}
if (!isCodexAuthContextUsable(authContext, config)) throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
logCtx.provider = formatCodexProviderForLog(candidate.providerName, codexLogAccountId(authContext), config);
// Materialization rechecks the current account policy after async selection.
// Synthetic lane IDs are local selection metadata, never upstream headers.
for (const [key, value] of headersForCodexAuthContext(req.headers, authContext, config, "context_history")) {
headers.set(key, value);
}
} catch (err) {
if (err instanceof CodexAccountCooldownError) return cooldownErrorResponse(err);
if (err instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse();
if (err instanceof CodexThreadAffinityExpiredError) return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
if (err instanceof CodexAuthContextError || err instanceof CodexPoolAuthenticationError || err instanceof CodexDirectAuthenticationError) {
return formatErrorResponse(401, "authentication_error", "Selected Codex account is unavailable or needs reauthentication");
}
throw err;
}
headers.set("content-type", "application/json");
for (const key of PROTOCOL_HEADERS) {
const value = req.headers.get(key); if (value !== null) headers.set(key, value);
}
const deadline = signalWithTimeout(35_000, req.signal);
let response: Response | undefined;
try {
response = await fetch(`${candidate.provider.baseUrl}/${endpoint}`, {
method: "POST", headers, body: JSON.stringify(body), signal: deadline.signal, redirect: "manual",
});
const result = await readBoundedResponseBytes(response, {maxBytes: MAX_RESPONSE_BYTES, signal: deadline.signal});
if (result.oversized) return formatErrorResponse(502, "upstream_error", "Context response exceeded 16 MiB");
const outputHeaders = new Headers();
for (const key of RESPONSE_HEADERS) {
const value = response.headers.get(key); if (value !== null) outputHeaders.set(key, value);
}
// A context 403 is not evidence that the model credential is invalid. Don't mutate pool
// health/quota or retry writes; preserve the real upstream result for the caller.
return new Response([204,205,304].includes(response.status) ? null : result.bytes, {status:response.status, headers:outputHeaders});
} catch {
if (req.signal.aborted) return formatErrorResponse(499, "client_closed_request", "Context request canceled by client");
if (deadline.signal.aborted) return formatErrorResponse(504, "upstream_error", "Context upstream timed out");
return formatErrorResponse(502, "upstream_error", "Context upstream connection failed");
} finally {
deadline.cleanup();
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => undefined);
}
}
34 changes: 31 additions & 3 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ import {
import { handleImages } from "./images";
import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
import { handleSearch } from "./search";
import { handleContextHistory } from "./context-history";
import { codexCompatibleUrl, contextEndpoint } from "../codex/context-compat";
import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api";
import {
createManagementSessionControl,
Expand Down Expand Up @@ -820,6 +822,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
}
if (path === "/v1/responses/compact") return req.method === "POST";
if (path === "/v1/alpha/search") return req.method === "POST";
if (contextEndpoint(path)) return req.method === "POST";
if (path === "/v1/images/generations" || path === "/v1/images/edits") {
return req.method === "POST";
}
Expand Down Expand Up @@ -1047,7 +1050,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
// The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
// else. Rejecting here, before any handler runs, is what keeps the surface from growing
// silently when a route is added below.
if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(new URL(req.url), req)) {
if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(codexCompatibleUrl(req.url), req)) {
return withCors(
formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
req,
Expand All @@ -1056,7 +1059,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
}
// Tailscale Serve terminates only on this separately bound loopback socket. Reject before
// dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run.
if (ingress === "hub-management" && !managementIngressRouteAllowed(new URL(req.url), req)) {
if (ingress === "hub-management" && !managementIngressRouteAllowed(codexCompatibleUrl(req.url), req)) {
return withCors(
formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
req,
Expand All @@ -1069,7 +1072,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
// same code path a plain loopback bind has always taken — Host-header check included.
// Routing, provider selection and response bodies keep using `config`.
const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config;
const url = new URL(req.url);
const url = codexCompatibleUrl(req.url);
markActivity(`${req.method} ${url.pathname}`);

// Readiness is exact-GET on the literal /readyz path. Compare the DECODED
Expand Down Expand Up @@ -1825,6 +1828,31 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
}), req, policy);
}

if (contextEndpoint(url.pathname) !== undefined && req.method === "POST") {
disableResponsesRequestTimeout(req, requestServer);
if (isDraining()) {
return drainingResponse(req, policy);
}
const admission = resolveApiAuth(req, policy);
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
if (!isAllowedRequestOrigin(req, policy)) {
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
}
const start = Date.now();
const requestId = nextRequestLogId(start);
const logCtx: RequestLogContext = {
model: "context_history",
provider: "unknown",
...admissionFields(admission),
};
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
const response = await handleContextHistory(req, config, logCtx, contextEndpoint(url.pathname)!, turnAdmissionLease);
addFinalRequestLog(requestId, start, logCtx, response.status,
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
return withCors(response, req, policy);
});
}

if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
disableResponsesRequestTimeout(req, requestServer);
if (isDraining()) {
Expand Down
3 changes: 2 additions & 1 deletion src/server/live.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { codexCompatibleUrl } from "../codex/context-compat";
/**
* /v1/live and /v1/realtime/calls relay (issue #371).
*
Expand Down Expand Up @@ -646,7 +647,7 @@ export async function handleLive(
// Frameless API-shape call-create posts to `{base}/live` without the AVAS
// query (openai/codex RealtimeCallClient, realtime_call.rs); only the
// realtime/calls inbound shape keeps the legacy keyed AVAS endpoint.
url = new URL(req.url).pathname === "/v1/live"
url = codexCompatibleUrl(req.url).pathname === "/v1/live"
? forwardLiveUrl(relay.providerBaseUrl, /* usesBackendShape */ false)
: keyedLiveUrl(relay.providerBaseUrl);
}
Expand Down
Loading
Loading