Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR
| `codexAccountPriorityFailback?` | `boolean` | `false` | Opt-in return of ongoing Codex Pool tasks to a usable higher-priority account under the `quota` strategy. Requests trigger a background quota refresh at most once every five minutes, including failed refresh attempts. When recent quota observations show headroom, the next request may rebind even if its current account is below the threshold. This explicit preference can discard a warm prompt cache; it does not change default cache affinity. No requests means no polling. Manual pins, model access, cooldowns, pauses and reauthentication exclusions still apply; in-flight requests keep their captured account. Requires the current source account's effective threshold to be positive (an override of 0 disables it; a positive override works with global 0); exact account routes and Direct mode are unchanged. |
| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for placing new/unbound work. `quota` can re-evaluate unbound tasks on their next request once usage crosses this threshold. Bound tasks keep their account past the threshold by default (`pool.cacheAffinity`); they leave only when that account is exhausted or otherwise cannot serve, and then only for an account with genuine quota headroom and strictly lower usage. Set `pool.cacheAffinity: false` to re-evaluate bound tasks at this threshold, still only onto such a destination. `fill-first` uses it only as the drain point for unbound assignment; `round-robin` uses it only when placing new/unbound work: it skips accounts at or over their effective threshold while any eligible account is still under it, and never moves a bound task for it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. |
| `codexAccountAutoSwitchThresholds?` | `Record<string, number>` | — | Per-account overrides for `autoSwitchThreshold`: account id → integer from `0` to `100`. An absent entry inherits the global value; `0` disables usage-driven switching only when that account is the source. `quota` uses the current source account's effective threshold for bound-task re-evaluation and unbound switching. Fill-first drain and selection-order tier headroom are evaluated against each account's own effective threshold; main-account pins and subagent fallback also use the relevant account's effective threshold. Supports the main `__main__` account. A malformed map is ignored with a warning. Managed by each account card on the Codex Auth page; disabling an override removes its entry. Enabling the override copies the current global threshold into a fixed account value. The override, including `0`, takes precedence over later global changes. Disabling it sends `threshold: null`, removes the entry, and restores inheritance of the current global threshold and future global changes. |
| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request. Bound tasks follow `pool.cacheAffinity` (on by default): they stay until the account is exhausted (known usage at 100%) or otherwise cannot serve, and then may rebind only to an account with genuine quota headroom and strictly lower usage. Set the flag `false` to proactively rebind a bound task at the threshold, still only onto such a destination. `round-robin` distributes unbound requests evenly across eligible accounts still under their effective `autoSwitchThreshold`, and across all eligible accounts when every one is at or over it; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. |
| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request. Bound tasks follow `pool.cacheAffinity` (on by default): they stay until the account is exhausted (known usage at 100%) or otherwise cannot serve, and then may rebind only to an account with genuine quota headroom and strictly lower usage. Set the flag `false` to proactively rebind a bound task at the threshold, still only onto such a destination. `round-robin` distributes unbound requests evenly across eligible accounts still under their effective `autoSwitchThreshold`, and across all eligible accounts when every one is at or over it; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. Under every strategy, a Claude Code turn that reaches a Codex account route through `/v1/messages` is bound by its Claude session: a caller `session_id`/`session-id` alias if present, else the metadata session, else `x-claude-code-session-id`; the binding is keyed to the admitted credential (or to local loopback use) with a server-held secret, so callers cannot share, guess or reuse another credential's binding. The binding is used only to choose the account; the headers sent upstream are unchanged. Spawn turns and turns that already carry `x-codex-parent-thread-id`, `thread-id` or a complete session/thread pair keep their own identity. A bound session normally stays on one account; the affinity and failure-recovery rules above can still move it. |
| `pool.cacheAffinity?` | `boolean` | `true` | Cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. On by default; omitting the key or setting `true` keeps a bound task on its account until that account genuinely cannot serve. Only an explicit `false` restores threshold-based rebinding of bound tasks. A live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — and then only to an account with genuine quota headroom and strictly lower usage. Under either setting, an account with unknown usage is never chosen as a destination for a bound task, so when every account sits above the threshold the task stays put. Affinity is a reordering, not a pin. |
| `pool.credentialGroups?` | `Array<{ id: string; credentials: string[]; note?: string }>` | `[]` | Accepted and validated, but not yet consumed by routing: declaring a group changes no routing decision until a consuming layer lands. Operator-declared quota domains: groups of credentials that demonstrably share one upstream usage limit. Members of one group count once toward available capacity, and a quota refusal inside a group is not answered by rotating to another member — the limit is the same, so the move would pay a cold prefix for zero new capacity. Declared groups speak only to quota; sharing a limit says nothing about prompt-cache compatibility, which is classified separately. Each member is written provider-qualified as `"<provider>:<credential-id>"`, because a credential id means something only inside its provider; the provider segment accepts the usual aliases (`chatgpt:` and `codex:` both mean OpenAI). Group ids must be unique, `credentials` must be non-empty, and a credential may appear in at most one group — an ambiguous declaration is rejected on write and dropped with a warning on load rather than resolved by whichever group is listed first, since that would merge two unrelated quota domains. A malformed list costs only the grouping: `pool.kernel` and `pool.cacheAffinity` are preserved. Absent or empty means no declared grouping, so an unconfigured install behaves exactly as before. |
| `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. |
Expand Down
4 changes: 3 additions & 1 deletion scripts/test-layout/layout-server-inbound.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"config-concurrent-inbound-body.test.ts": "config",
"inbound-body-admission.test.ts": "server",
"inbound-body-budget.test.ts": "server"
"inbound-body-budget.test.ts": "server",
"claude-bridge-pool-session.test.ts": "claude-integration",
"claude-bridge-pool-dispatch.test.ts": "claude-integration"
}
59 changes: 59 additions & 0 deletions src/server/claude-bridge-pool-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Private pool identity for translated Claude turns; replay headers remain unchanged. */
import { createHmac, randomBytes } from "node:crypto";
import { codexPoolAffinityKey } from "../codex/auth-context";
import { contextPrincipalIdOf, type DataPlaneAdmission } from "./auth-cors";
import { safeCallerSessionId } from "./caller-session-identity";
import { isThreadSpawnRequest } from "./effort-policy";
import type { RouteResult } from "../router";
import type { HandleResponsesOptions } from "./responses/core-options";

/**
* Process-local, like the lineage key: a synthesized identity never leaves the process (replay and
* upstream headers stay uncompleted), so no caller can name it, not even as a complete
* session-id + thread-id pair, which reaches the pool unscoped.
*/
const BRIDGE_POOL_KEY = randomBytes(32);

export function resolveClaudeBridgePoolSession(args: {
ingress: Headers; replay: Headers; metadataSessionId?: string; admission?: DataPlaneAdmission;
}): string | undefined {
const { ingress, replay, metadataSessionId, admission } = args;
if (!admission || isThreadSpawnRequest(ingress) || isThreadSpawnRequest(replay)) return undefined;
if (ingress.has("x-codex-parent-thread-id") || ingress.has("thread-id")) return undefined;
if (codexPoolAffinityKey(replay) !== undefined) return undefined;
let value: string | undefined;
if (ingress.has("session_id") || ingress.has("session-id")) {
const first = ingress.get("session_id");
const second = ingress.get("session-id");
if (first && second && first !== second) return undefined;
value = safeCallerSessionId(first || second);
} else {
value = safeCallerSessionId(metadataSessionId ?? ingress.get("x-claude-code-session-id"));
}
if (!value) return undefined;
// Every identity this bridge names, caller aliases included, is bound to its admission scope:
// a shared or guessed value must never select another principal's conversation key.
let scope: unknown = "loopback";
if (admission.kind !== "loopback") {
const principal = contextPrincipalIdOf(admission);
if (!principal) return undefined;
scope = ["principal", principal];
}
return createHmac("sha256", BRIDGE_POOL_KEY)
.update(JSON.stringify(["opencodex-claude-bridge-pool-v1", scope, value])).digest("hex");
}

export function completeClaudeBridgePoolIdentity(headers: Headers, options: Pick<HandleResponsesOptions, "claudeBridgePoolSession">): Headers {
const session = options.claudeBridgePoolSession;
if (!session || headers.get("x-codex-parent-thread-id") || headers.get("thread-id")
|| codexPoolAffinityKey(headers) !== undefined) return headers;
const completed = new Headers(headers);
completed.set("session-id", session);
completed.set("thread-id", session);
return completed;
}

export function applyClaudeBridgePoolSession(headers: Headers, route: Pick<RouteResult, "codexAccountMode">,
options: Pick<HandleResponsesOptions, "claudeBridgePoolSession">): Headers {
return route.codexAccountMode !== undefined ? completeClaudeBridgePoolIdentity(headers, options) : headers;
}
5 changes: 5 additions & 0 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CodexInstallationIdUnavailableError } from "../codex/installation-id";
* unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
*/
import { FORWARD_HEADERS } from "../adapters/openai-responses";
import { resolveClaudeBridgePoolSession } from "./claude-bridge-pool-session";
import {
admissionModelDeniedResponse,
AdmissionModelDeniedError,
Expand Down Expand Up @@ -1176,6 +1177,9 @@ async function handleClaudeMessagesWithBudget(
?? normalizeLogConversationId(req.headers.get("x-opencode-session"))
?? metadataGoLane
?? getOrAllocateRequestSessionLane(req);
const claudeBridgePoolSession = resolveClaudeBridgePoolSession({
ingress: req.headers, replay: headers, metadataSessionId: claudeNativeSessionId, admission: logIds?.admission,
});
let internalReq: Request;
try {
// The UTF-16 JSON string and the Request's UTF-8 body coexist until dispatch.
Expand Down Expand Up @@ -1239,6 +1243,7 @@ async function handleClaudeMessagesWithBudget(
? { tools: anthropicBody.tools, messages: anthropicBody.messages, tool_choice: anthropicBody.tool_choice } : undefined,
claudeGoAffinity: { sessionLane: claudeGoSessionLane },
claudeNativeSessionId,
...(claudeBridgePoolSession ? { claudeBridgePoolSession } : {}),
stripClaudeMainAuthForNoncanonicalForward: true,
...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}),
// Claude's internal stored-main enrichment is not an original caller credential.
Expand Down
23 changes: 20 additions & 3 deletions src/server/responses/core-auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { applyClaudeBridgePoolSession } from "../claude-bridge-pool-session";
import type { OcxProviderConfig, OcxConfig } from "../../types";
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
import type { CodexAuthContext } from "../../codex/auth-context";
Expand Down Expand Up @@ -111,7 +112,7 @@ function validatedCallerDirectHeaders(
return captureCallerDirectAuth(headers, config) ? headers : undefined;
}

export function codexRouteCredentialDomainHeaders(
function codexRouteCredentialDomainHeadersFor(
req: Request,
route: RouteResult,
options: HandleResponsesOptions,
Expand Down Expand Up @@ -156,6 +157,20 @@ export function codexRouteCredentialDomainHeaders(
}


/**
* Credential-domain headers for Codex account SELECTION (previews, recovery, final resolution).
* A Claude bridge pool identity is completed here only; upstream headers are always materialized
* from the uncompleted form, so the synthesized pair never reaches the wire.
*/
export function codexRouteCredentialDomainHeaders(
req: Request, route: RouteResult, options: HandleResponsesOptions,
credentialDomainWasRewritten: boolean, config: OcxConfig,
): Headers {
return applyClaudeBridgePoolSession(
codexRouteCredentialDomainHeadersFor(req, route, options, credentialDomainWasRewritten, config), route, options,
);
}

/**
* Does this route substitute OUR stored main credential, and does the caller own the credential
* this request will authenticate with?
Expand Down Expand Up @@ -210,7 +225,7 @@ export async function resolveResponsesCodexAuth(
// routed credential with it either, because that would be a silent credential swap.
if (hasAgentAssertionAuthorization(req.headers)) return { ok: false, response: agentAssertionUnsupportedResponse() };
try {
let authInputHeaders = codexRouteCredentialDomainHeaders(
let authInputHeaders = codexRouteCredentialDomainHeadersFor(
req,
route,
options,
Expand Down Expand Up @@ -270,7 +285,9 @@ export async function resolveResponsesCodexAuth(
validateForwardAdmissionCredential(authInputHeaders, config);
}
if (route.codexAccountMode) {
authCtx = await resolveCodexAuthContext(authInputHeaders, config, route.codexAccountMode, {
// Only selection sees the Claude bridge pool identity; materialization below keeps the
// uncompleted headers, so the wire matches the original request.
authCtx = await resolveCodexAuthContext(applyClaudeBridgePoolSession(authInputHeaders, route, options), config, route.codexAccountMode, {
admission: options.admission,
codexAuthPolicy: options.codexAuthPolicy,
accountId: route.codexAccountId,
Expand Down
7 changes: 6 additions & 1 deletion src/server/responses/core-codex-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { formatErrorResponse } from "../../bridge";
import { readBoundedResponseBody } from "../../lib/bounded-body";
import { upstreamErrorMessageFromPayload, isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
import { isNonReplayableResponse, isTransientUpstreamStatus, SendBudgetExhaustedError } from "../../lib/upstream-retry";
import { applyClaudeBridgePoolSession } from "../claude-bridge-pool-session";
import {
codexWsPreOutputRefusalHold,
codexWsPreOutputRefusalOutcomeRecorded,
Expand Down Expand Up @@ -490,6 +491,8 @@ export interface CodexPoolAccountRetryArgs {
abortSignal?: AbortSignal;
onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
deferCodexResetDerivedCooldown?: boolean;
/** Private Claude bridge pool identity; alternate selection completes it like the first pick. */
claudeBridgePoolSession?: string;
// Narrowed subset of HandleResponsesOptions: the retry rebuilds the adapter, so it
// needs the inbound scope or the retry could land on a different wire than the
// first attempt.
Expand Down Expand Up @@ -767,7 +770,9 @@ export async function retryCodexPoolOnAlternateAccount(
}
try {
retryAuthCtx ??= await resolveCodexAuthContext(
callerAuthHeaders,
// Alternate selection keeps the initial Claude bridge pool identity; the retry's upstream
// headers are still built from uncompleted callerAuthHeaders below.
applyClaudeBridgePoolSession(callerAuthHeaders, { codexAccountMode: "pool" }, options),
config,
"pool",
{
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/core-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ export interface HandleResponsesOptions {
claudeGoAffinity?: { sessionLane?: string };
/** Validated Claude metadata identity; projected only into final canonical attempt headers. */
claudeNativeSessionId?: string;
/** Private Claude bridge pool identity (validated, principal-scoped when derived); completes session-id/thread-id only on Codex account-selection headers. */
claudeBridgePoolSession?: string;
/** Original live policy owner; separate from caller-specific routing/sidecar snapshots. */
codexAuthPolicy?: CodexAuthPolicyConfig;
turnAdmissionLease?: AdmissionLease;
Expand Down
3 changes: 2 additions & 1 deletion src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { completeClaudeBridgePoolIdentity } from "../claude-bridge-pool-session";
import type { HandleResponsesOptions, ResponsesRequestContext, ResponsesAdmissionState, ResponsesDispatchers } from "./core-options";
import type { PolicyRequestEvidence } from "../../routing/evaluator";
import type { RequestLogContext } from "../request-log";
Expand Down Expand Up @@ -952,7 +953,7 @@ export async function prepareResponsesRequest(
let subagentFallbackModelEligibleAccountIdsForModel: SubagentModelEligibleAccountIds | undefined;
let subagentQuotaFailureModel = parsed.modelId;
const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null;
const poolAffinityKey = codexPoolAffinityKey(completeClaudeBridgePoolIdentity(req.headers, options)) ?? null;
// Preview has to see the same lineage resolve does. Without it, a child's first turn is
// previewed as a cold pick and resolved onto the family account, and the subagent fallback
// then decides model eligibility against an account the request will never use.
Expand Down
Loading
Loading