Skip to content
Draft
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
57 changes: 57 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,63 @@ 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 built-in `openai` provider with the `openai-responses` adapter and canonical
ChatGPT forward destination. Direct uses the current caller/main login; Pool selects a Codex
account. `openai-apikey` uses its configured API key, and custom or noncanonical Responses
providers are not candidates for this context relay and receive no Codex-account credentials
from it. These private endpoints are not implemented by other model providers or the OpenAI
API-key route.

Caller headers are restricted to the shared Codex forward allowlist: `authorization`,
`chatgpt-account-id`, and approved OpenAI beta, originator, session, and Codex protocol metadata.
The context relay additionally forwards `x-openai-encrypted-tool-arguments` and
`x-openai-tool-output-truncation-policy`; arbitrary caller headers such as cookies are not
forwarded. A proxy data-plane key presented as a bearer is replaced with the selected Codex
credential (the stored main login in Direct); missing credentials fail before forwarding.
Proxy admission credentials never go upstream. Encrypted arguments, response bodies, and
upstream error statuses are preserved.

A successful ChatGPT model response records the root session's actual serving account in a
bounded, process-local ownership registry. History and notes use that recorded owner, including
an explicitly selected account, even when the current active account changes. Stored-account token
refresh may continue for the same physical account; a replaced account identity is rejected.
Direct caller-owned sessions keep the caller credential and cannot be taken over by a proxy bearer.
This does not migrate server-side history between accounts.

Unknown, expired, evicted, conflicting, or restart-lost ownership returns HTTP 409 before account
selection or upstream I/O. The relay does not guess from the current active account. Existing
sessions should save a checkpoint or other durable summary before enabling the feature or resetting
context. Enabling it does not backfill earlier history or notes, and a new ownership observation
does not prove that older backend content exists. After a restart, establish ownership with a
successful model request before using context tools; start a new session when ownership conflicts.

Existing model affinity, cooldown, and retry rules are unchanged. Context requests are not
automatically retried, including notes writes; ChatGPT forward requests do not use same-key 429
replay. 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
55 changes: 55 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 @@ -40,6 +40,61 @@ loopback `openai_base_url` 형태에서만 쓰이고, 그 키와 함께 제거

프록시는 기본적으로 포트 `10100`에서 듣고 `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations`, `POST /v1/images/edits`, `GET /v1/models`, `GET /healthz`, 그리고 `/api/*` 관리 표면을 제공합니다.

### 실험적 컨텍스트 관리 (Codex 0.153+)

지원되는 ChatGPT 계정에서는 Codex의 `config.toml`에 다음 설정을 추가합니다.
기존 `[features]` 테이블이 있으면 그 안에 병합하세요.

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

기본 내장 loopback 통합에서는 다음 `ocx sync` 또는 프록시 시작 시 관리되는 루트
`openai_base_url`이 `http://127.0.0.1:10100/backend-api/codex`로 바뀝니다. Codex는 이 경로를
확인한 뒤 `new_context`, history, notes 도구를 활성화합니다. 동기화 후 새 Codex 세션을
시작하세요. 이 기능은 명시적으로 켜야 하며, 사용자 소유 URL과 원격 사용자 지정 provider
주입은 변경하지 않습니다. 컨텍스트 창이나 압축 한도를 덮어쓰지 않습니다.

이 backend 접두사는 Responses WebSocket 업그레이드를 포함한 기존 데이터 경로의 별칭입니다.
원래 `/v1` 경로와 realtime sideband 오버라이드도 유지됩니다. 열 개의 네이티브
`alpha/history/v2/*`, `alpha/notes/v2/*` POST 엔드포인트는 `openai-responses` 어댑터와 정식
ChatGPT forward 목적지를 사용하는 내장 `openai` provider로만 전달합니다. Direct는 현재
호출자/메인 로그인을, Pool은 선택된 Codex 계정을 사용합니다. `openai-apikey`는 설정된 API
키를 사용하는 별도 경로이며 이 비공개 엔드포인트를 지원하지 않습니다. 사용자 지정 또는
비정식 Responses provider는 이 컨텍스트 relay의 후보가 아니며 Codex 계정 자격 증명을
전달받지 않습니다.

호출자 헤더는 공통 Codex forward 허용 목록으로 제한됩니다. `authorization`,
`chatgpt-account-id`, 승인된 OpenAI beta, originator, session 및 Codex 프로토콜 메타데이터와
추가 헤더 `x-openai-encrypted-tool-arguments`, `x-openai-tool-output-truncation-policy`만
전달합니다. 쿠키 등 임의 헤더는 전달하지 않습니다. 프록시 데이터 키를 bearer로 사용하면
선택된 Codex 자격 증명으로 교체하며, Direct에서는 저장된 메인 로그인을 사용합니다.
자격 증명이 없으면 전달 전에 실패합니다. 프록시 인증 자격 증명은 upstream으로 보내지
않습니다. 암호화된 인수, 응답 본문, upstream 오류 상태는 보존합니다.

성공한 ChatGPT 모델 응답은 루트 세션을 실제로 처리한 계정을 크기가 제한된 프로세스 로컬
소유권 레지스트리에 기록합니다. History와 notes는 현재 활성 계정이 바뀌어도 명시적으로
선택된 계정을 포함해 기록된 소유 계정을 사용합니다. 저장된 계정의 토큰은 같은 실제 계정에
한해 갱신할 수 있으며, 다른 실제 계정으로 교체되면 거부합니다. Direct 호출자 소유 세션은
호출자 자격 증명을 유지하며 프록시 bearer로 인계할 수 없습니다. 서버 측 history를 계정 간에
이동하는 기능은 아닙니다.

소유권이 없거나 만료, 제거, 충돌 또는 재시작으로 소실된 경우 계정 선택이나 upstream 요청
전에 HTTP 409를 반환합니다. 현재 활성 계정으로 추측하지 않습니다. 기존 세션에서는 기능을
켜거나 컨텍스트를 초기화하기 전에 체크포인트 또는 지속적으로 보관할 요약을 먼저 저장하세요.
기능을 켜도 이전 history나 notes를 소급해서 채우지 않으며, 소유권이 새로 확인되어도 이전
backend 콘텐츠가 존재한다는 뜻은 아닙니다. 재시작 후에는 성공한 모델 요청으로 소유권을
확립한 뒤 컨텍스트 도구를 사용하고, 소유권이 충돌하면 새 세션을 시작하세요.

기존 모델 affinity, cooldown 및 재시도 규칙은 변경하지 않습니다. Notes 쓰기를 포함한
컨텍스트 요청은 자동 재시도하지 않으며, ChatGPT forward 요청에 같은 키를 사용한 429
재시도를 추가하지 않습니다. History 트래픽은 모델의 quota-recovery probe를 점유하거나
완료 처리하지 않습니다.

끄려면 실험 설정을 삭제하거나 `false`로 바꾼 뒤 `ocx sync`를 실행하고 새 세션을 시작하세요.
관리되는 루트 URL은 `/v1`로 돌아갑니다.

### 내장 이미지 생성 (`image_gen`)

Codex의 내장 `image_gen` 도구는 `/v1/responses`를 거치지 않습니다. codex-rs 확장은 채팅과 같은 ChatGPT bearer 인증을 사용해서 `{base_url}/images/generations`를 직접 POST하며, 참조 이미지가 붙어 있으면 `/images/edits`를 POST합니다. 주입된 `base_url`이 opencodex를 가리키므로, 프록시가 이 호출을 OpenAI upstream으로 전달합니다.
Expand Down
8 changes: 7 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 @@ -378,6 +380,7 @@
"codex-cli-update-zero-effect.test.ts": "codex-integration",
"codex-composed-acceptance.test.ts": "codex-integration",
"codex-config-generation.test.ts": "codex-integration",
"codex-context-owner.test.ts": "codex-integration",
"codex-convergence-account-selectors.test.ts": "codex-integration",
"codex-convergence-contract.test.ts": "codex-integration",
"codex-cooldown-recovery.test.ts": "codex-integration",
Expand Down Expand Up @@ -493,6 +496,9 @@
"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-ownership.test.ts": "server",
"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
42 changes: 42 additions & 0 deletions src/codex/context-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/** 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 {
let parsed: {features?: {context_management?: {experimental_mode?: boolean}}};
try {
parsed = Bun.TOML.parse(content) as typeof parsed;
} catch {
// Injection tolerates incomplete user config; malformed TOML is not an opt-in.
return line;
}
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}"`;
}
141 changes: 141 additions & 0 deletions src/codex/context-owner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { createHmac, randomBytes } from "node:crypto";
import type { CodexAuthContext } from "./auth-context";
import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";

export type ContextSessionOwner = Readonly<
| { kind: "stored"; accountId: string; physicalIdentity: string; ambiguous: boolean }
| { kind: "caller"; physicalIdentity?: string; callerCredentialIdentity: string; ambiguous: boolean }
>;

const TTL_MS = 24 * 60 * 60_000;
const MAX_ENTRIES = 2048;
const MAX_BYTES = 1024 * 1024;
const salt = randomBytes(32);
type Entry = { owner: ContextSessionOwner; destination: string; touchedAt: number; bytes: number };
const owners = new Map<string, Entry>();
let totalBytes = 0;

function digest(domain: string, value: string): string {
return createHmac("sha256", salt).update(domain).update("\0").update(value).digest("hex");
}

function validId(value: string | null | undefined): value is string {
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,512}$/.test(value);
}

function destinationIdentity(destination: string): string | undefined {
if (!destination || destination.length > 4096) return undefined;
try {
const url = new URL(destination);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) return undefined;
return digest("destination", url.href.replace(/\/+$/, ""));
} catch { return undefined; }
}

function physicalIdentity(headers: Headers): string | undefined {
const account = headers.get("chatgpt-account-id");
return validId(account) ? digest("physical-account", account) : undefined;
}

function callerCredentialIdentity(headers: Headers): string | undefined {
const authorization = headers.get("authorization");
if (!authorization || authorization.length > 32_768 || !/^Bearer [^\s]+$/i.test(authorization)) return undefined;
const account = headers.get("chatgpt-account-id");
if (account !== null && !validId(account)) return undefined;
return digest("caller-credential", JSON.stringify([authorization.slice(7), account]));
}

function remove(key: string): void {
const prior = owners.get(key);
if (!prior) return;
owners.delete(key);
totalBytes -= prior.bytes;
}

function sweep(now: number): void {
for (const [key, entry] of owners) {
if (now < entry.touchedAt || now - entry.touchedAt >= TTL_MS) remove(key);
}
}

/** Called only after a model attempt was accepted by its actual destination. */
export function recordContextSessionOwner(
inboundHeaders: Headers, destination: string, auth: CodexAuthContext,
outboundHeaders: Headers, substituteMainCredential: boolean, now = Date.now(),
): void {
if (!Number.isFinite(now)) return;
// A malformed explicit parent must not fall back to an unrelated local session.
const root = inboundHeaders.get("x-codex-parent-thread-id") ?? inboundHeaders.get("session-id");
if (!validId(root)) return;
const destinationKey = destinationIdentity(destination);
const credential = callerCredentialIdentity(outboundHeaders);
if (!destinationKey || !credential) return;
const physical = physicalIdentity(outboundHeaders);
let owner: ContextSessionOwner;
if (auth.kind !== "main" || substituteMainCredential) {
if (!physical) return;
if (auth.kind !== "main" && (!validId(auth.accountId)
|| auth.chatgptAccountId !== outboundHeaders.get("chatgpt-account-id"))) return;
// Direct proxy-bearer substitution has no token snapshot in its `main` context;
// its accepted outbound identity is still evidence for the stored main slot.
owner = { kind: "stored", accountId: auth.kind === "main" ? MAIN_CODEX_ACCOUNT_ID : auth.accountId,
physicalIdentity: physical, ambiguous: false };
} else {
owner = { kind: "caller", ...(physical ? { physicalIdentity: physical } : {}),
callerCredentialIdentity: credential, ambiguous: false };
}
sweep(now);
const key = digest("root-session", root);
const prior = owners.get(key);
if (prior) {
const samePhysical = prior.owner.physicalIdentity !== undefined && physical !== undefined
? prior.owner.physicalIdentity === physical
: prior.owner.kind === "caller" && owner.kind === "caller"
&& prior.owner.physicalIdentity === undefined && owner.physicalIdentity === undefined
&& prior.owner.callerCredentialIdentity === owner.callerCredentialIdentity;
if (prior.owner.ambiguous || prior.destination !== destinationKey
|| prior.owner.kind !== owner.kind || !samePhysical) {
// Once two accepted attempts prove conflicting ownership, no later write can
// silently choose which account contains this session's history.
owner = { ...prior.owner, ambiguous: true };
}
}
const ownerDestination = prior?.destination ?? destinationKey;
const bytes = Buffer.byteLength(JSON.stringify([key, ownerDestination, owner]), "utf8");
remove(key);
owners.set(key, { owner: Object.freeze(owner), destination: ownerDestination, touchedAt: now, bytes });
totalBytes += bytes;
while (owners.size > MAX_ENTRIES || totalBytes > MAX_BYTES) {
const oldest = owners.keys().next().value;
if (oldest === undefined) break;
remove(oldest);
}
}

/** Missing/expired/evicted ownership is unknown; never infer it from active routing. */
export function getContextSessionOwner(
sessionId: string, destination: string, now = Date.now(),
): ContextSessionOwner | undefined {
if (!validId(sessionId) || !Number.isFinite(now)) return undefined;
const destinationKey = destinationIdentity(destination);
if (!destinationKey) return undefined;
sweep(now);
const key = digest("root-session", sessionId);
const entry = owners.get(key);
if (!entry || entry.destination !== destinationKey) return undefined;
owners.delete(key);
entry.touchedAt = now;
owners.set(key, entry);
return entry.owner;
}

/** Compare only already-materialized headers; this function never reads credentials. */
export function contextSessionOwnerMatches(owner: ContextSessionOwner, headers: Headers): boolean {
if (owner.ambiguous) return false;
if (owner.kind === "stored") return owner.physicalIdentity === physicalIdentity(headers);
return owner.callerCredentialIdentity === callerCredentialIdentity(headers);
}

export function clearContextSessionOwnersForTests(): void {
owners.clear(); totalBytes = 0;
}
Loading
Loading