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
43 changes: 41 additions & 2 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHmac, randomBytes } from "node:crypto";
import {
CodexCredentialGenerationConflictError,
CodexCredentialRefreshLockTimeoutError,
Expand Down Expand Up @@ -38,6 +39,38 @@ import { getAccountQuota } from "./quota";
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
import { FORWARD_HEADERS } from "../adapters/openai-responses";
import { captureConfigGeneration } from "../lib/state-store-sweeper";
import { retainedUtf8Bytes } from "../lib/admission";

const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
const CODEX_APP_AFFINITY_KEY = randomBytes(32);

function boundedCodexAffinityComponent(value: string | null): string | undefined {
const normalized = value?.trim();
if (!normalized) return undefined;
if (retainedUtf8Bytes(normalized) > CODEX_AFFINITY_COMPONENT_MAX_BYTES) return undefined;
return normalized;
}

/**
* Preserve Codex's parent-thread affinity when present. Desktop App requests can omit that
* header while retaining a stable session/thread pair, so derive an opaque process-local key
* only from the complete bounded pair. Raw identifiers and durable hashes never enter Pool state.
*/
export function codexPoolAffinityKey(headers: Headers): string | undefined {
const parentThreadId = boundedCodexAffinityComponent(headers.get("x-codex-parent-thread-id"));
if (parentThreadId) return parentThreadId;

const sessionId = boundedCodexAffinityComponent(headers.get("session-id"));
const threadId = boundedCodexAffinityComponent(headers.get("thread-id"));
if (!sessionId || !threadId) return undefined;

return `app:${createHmac("sha256", CODEX_APP_AFFINITY_KEY)
.update("opencodex-app-pool-affinity-v1\0")
.update(sessionId)
.update("\0")
.update(threadId)
.digest("base64url")}`;
}

export type CodexAuthContext =
| { kind: "main"; accountId: null }
Expand All @@ -50,6 +83,8 @@ export type CodexAuthContext =
chatgptAccountId: string;
/** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
fixedAccount?: boolean;
/** Pool binding key; the Desktop fallback is an opaque process-local HMAC. */
affinityKey?: string;
/**
* Set when this request was admitted through an active quota cooldown as
* the account's single probe. Must be echoed into the upstream outcome so
Expand All @@ -71,6 +106,8 @@ export type CodexAuthContext =
chatgptAccountId: string;
/** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
fixedAccount?: boolean;
/** See `pool.affinityKey`. */
affinityKey?: string;
/** See `pool.probeLeaseId`. */
probeLeaseId?: string;
quotaScope?: CodexQuotaScope;
Expand Down Expand Up @@ -343,6 +380,7 @@ export async function resolveCodexAuthContext(
}
return { kind: "main", accountId: null };
}
const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined;
const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config)
: undefined;
Expand All @@ -369,7 +407,6 @@ export async function resolveCodexAuthContext(
// routing inspect it. Selectors arriving after the fence skip reconciliation
// and may still route to non-main pool accounts without touching switch state.
if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
const threadId = headers.get("x-codex-parent-thread-id");
const resolution = fixedAccountId !== undefined
? { status: "selected" as const, accountId: fixedAccountId }
: options.excludeAccountId
Expand All @@ -385,7 +422,7 @@ export async function resolveCodexAuthContext(
? { status: "selected" as const, accountId: selected }
: { status: "none" as const };
})()
: resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope, selectionOptions);
: resolveCodexAccountForThreadDetailed(affinityKey ?? null, config, Date.now(), quotaScope, selectionOptions);
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
const selected = resolution.status === "selected" ? resolution.accountId : null;
if (!selected) {
Expand Down Expand Up @@ -500,6 +537,7 @@ export async function resolveCodexAuthContext(
accessToken: token.accessToken,
chatgptAccountId: token.chatgptAccountId,
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
...(affinityKey ? { affinityKey } : {}),
...(quotaScope ? { quotaScope } : {}),
...(probeLeaseId ? { probeLeaseId } : {}),
...(probeQuotaScope ? { probeQuotaScope } : {}),
Expand All @@ -516,6 +554,7 @@ export async function resolveCodexAuthContext(
accessToken: token.accessToken,
chatgptAccountId: token.chatgptAccountId,
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
...(affinityKey ? { affinityKey } : {}),
...(quotaScope ? { quotaScope } : {}),
...(probeLeaseId ? { probeLeaseId } : {}),
...(probeQuotaScope ? { probeQuotaScope } : {}),
Expand Down
1 change: 1 addition & 0 deletions src/providers/openai-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export async function resolveFirstUsableOpenAiSidecar(
authContext.accountId,
outcome,
{
threadId: authContext.affinityKey,
probeLeaseId: authContext.probeLeaseId,
writerGeneration: authContext.writerGeneration,
},
Expand Down
3 changes: 1 addition & 2 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,6 @@ export async function handleResponsesCompact(
}
compactHostAdmissionLease = null;
};
const compactThreadId = req.headers.get("x-codex-parent-thread-id");
const connectMs = config.connectTimeoutMs ?? 200_000;
// Takes its context explicitly: the alternate-account flow below records a rejection
// against A while promoting B, then records B's own outcome. A closure over a single
Expand All @@ -478,7 +477,7 @@ export async function handleResponsesCompact(
if (!usesCodexForwardPoolAuth(ctx, route.provider)) return;
recordCodexUpstreamOutcome(config, ctx.accountId, outcome, {
...meta,
threadId: compactThreadId,
threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined,
fixedAccount: ctx.fixedAccount,
modelId: selectedModelId,
probeLeaseId: codexProbeLeaseId(ctx),
Expand Down
20 changes: 9 additions & 11 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenA
import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue";
import {
applyCodexAuthContextToProvider,
codexPoolAffinityKey,
CodexAccountCooldownError,
codexMainProfileDrainingResponse,
cooldownErrorResponse,
Expand Down Expand Up @@ -328,11 +329,10 @@ export function adapterNeedsForcedContinuation(name: string): boolean {
export function sidecarOutcomeRecorder(
config: OcxConfig,
authCtx: CodexAuthContext,
threadId?: string | null,
): ((outcome: CodexUpstreamOutcome) => void) | undefined {
return authCtx.kind === "pool" || authCtx.kind === "main-pool"
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
threadId,
threadId: authCtx.affinityKey,
fixedAccount: authCtx.fixedAccount,
probeLeaseId: authCtx.probeLeaseId,
probeQuotaScope: authCtx.probeQuotaScope,
Expand Down Expand Up @@ -946,7 +946,7 @@ async function retryCodexPoolOnAlternateAccount(
const recordFirstOutcome = (): void => {
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
...quotaMeta,
threadId: req.headers.get("x-codex-parent-thread-id"),
threadId: firstAuthCtx.affinityKey,
modelId: route.modelId,
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
Expand Down Expand Up @@ -1081,7 +1081,6 @@ export function codexForwardTerminalOutcomeRecorder(
provider: OcxProviderConfig,
modelId?: string,
logCtx?: RequestLogContext,
threadId?: string | null,
): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
return (status, httpStatusOverride) => {
Expand All @@ -1090,7 +1089,7 @@ export function codexForwardTerminalOutcomeRecorder(
// request. Don't penalize account health; record success to clear any
// prior soft-avoid so a healthy account isn't stuck avoided.
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
threadId,
threadId: authCtx.affinityKey,
fixedAccount: authCtx.fixedAccount,
modelId,
probeLeaseId: codexProbeLeaseId(authCtx),
Expand All @@ -1112,7 +1111,7 @@ export function codexForwardTerminalOutcomeRecorder(
? 200
: (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
threadId,
threadId: authCtx.affinityKey,
fixedAccount: authCtx.fixedAccount,
modelId,
probeLeaseId: codexProbeLeaseId(authCtx),
Expand Down Expand Up @@ -2287,6 +2286,7 @@ async function handleResponsesInner(
let subagentFallbackPreviewAccountId: string | null | undefined;
let subagentQuotaFailureModel = parsed.modelId;
const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null;

try {
if (
Expand All @@ -2302,9 +2302,8 @@ async function handleResponsesInner(
// Preview the preferred Codex account without acquiring a probe lease or refreshing
// tokens — auth is resolved only after the final route is selected.
if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) {
const threadId = req.headers.get("x-codex-parent-thread-id");
const previewAccountId = previewCodexAccountForRequest(
threadId,
poolAffinityKey,
config,
Date.now(),
undefined,
Expand Down Expand Up @@ -3022,7 +3021,7 @@ async function handleResponsesInner(
}
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
threadId: req.headers.get("x-codex-parent-thread-id"),
threadId: authCtx.affinityKey,
fixedAccount: authCtx.fixedAccount,
modelId: route.modelId,
probeLeaseId: codexProbeLeaseId(authCtx),
Expand Down Expand Up @@ -3405,7 +3404,6 @@ async function handleResponsesInner(
route.provider,
route.modelId,
logCtx,
req.headers.get("x-codex-parent-thread-id"),
);
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
// Capture quota from upstream response for multi-account tracking
Expand Down Expand Up @@ -3446,7 +3444,7 @@ async function handleResponsesInner(
)) {
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
...quotaMeta,
threadId: req.headers.get("x-codex-parent-thread-id"),
threadId: authCtx.affinityKey,
fixedAccount: authCtx.fixedAccount,
modelId: route.modelId,
probeLeaseId: codexProbeLeaseId(authCtx),
Expand Down
28 changes: 28 additions & 0 deletions structure/08_openai-provider-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,34 @@ engine. Direct short-circuits that engine before pool state is read or mutated a
current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API
provider may not fall through to Codex-login credentials.

Pool affinity preserves the existing `x-codex-parent-thread-id` supplied by ordinary Codex clients.
The parent id is trimmed and bounded under the same 512-byte component limit as the Desktop
fallback. When Codex Desktop omits it or sends an unusable value, the complete bounded `session-id`
plus `thread-id` pair is mapped to an opaque HMAC under a random process-local key. Missing or
oversized components remain unbound, raw identifiers and durable hashes are never stored, and
account-qualified selectors skip both lookup and mutation. Selection, subagent fallback preview,
and terminal outcome accounting carry the same key so route planning cannot preview one account
and authenticate another, and a transient failure clears the binding that actually selected the
account.

[Decision Log]
- 목적과 의도: Keep Desktop reconnects on the account selected for the App task without persisting
or exposing its session and thread identifiers.
- 기존 구현 및 제약 조건: Pool affinity used only `x-codex-parent-thread-id`; Desktop requests can
omit it while stable `session-id` and `thread-id` headers remain available. Exact account
selectors must stay outside automatic Pool affinity.
- 검토한 주요 대안: Leave reconnects unbound, persist a plain hash, bind from either header alone,
delete App turn metadata, or derive one process-local key from the complete pair.
- 선택한 방식: Preserve the parent-thread key when present; otherwise HMAC the two bounded headers
under a random per-process key and carry that opaque value through selection, subagent preview,
and outcome handling.
- 다른 대안 대신 이 방식을 선택한 이유: A complete pair avoids weak partial identities, a
process-local HMAC prevents durable correlation or dictionary recovery, and no upstream metadata
needs to be mutated before the first-403 cause is proven.
- 장점, 단점 및 영향: Reconnects stop rotating among Pool accounts and failure accounting clears
the correct binding. Affinity intentionally resets on process restart, and requests missing either
component retain the prior unbound behavior.

An explicit `Retry-After` or an unclassified quota 429 is account-wide. A reset-derived native-model
429 is advisory and remains within its confirmed quota group: `gpt-5.3-codex-spark` is separate from
the shared native group (including GPT-5.6 Terra/Luna). This allows a same-account combo to test an
Expand Down
Loading
Loading