Skip to content

Commit 6842737

Browse files
committed
feat(codex): add exact account routing
1 parent f0867e8 commit 6842737

10 files changed

Lines changed: 528 additions & 68 deletions

src/codex/auth-context.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isCodexAccountGenerationLive,
66
} from "./account-store";
77
import { markAccountNeedsReauth } from "./account-runtime-state";
8+
import { isCodexAccountPaused } from "./account-pause";
89
import { isCodexAccountUsable } from "./account-usability";
910
import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
1011
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
@@ -33,6 +34,8 @@ export type CodexAuthContext =
3334
generation: number;
3435
accessToken: string;
3536
chatgptAccountId: string;
37+
/** Prevent pool selection, affinity, and active-account mutation for an exact selector. */
38+
fixedAccount?: boolean;
3639
/**
3740
* Set when this request was admitted through an active quota cooldown as
3841
* the account's single probe. Must be echoed into the upstream outcome so
@@ -51,6 +54,8 @@ export type CodexAuthContext =
5154
accountId: string;
5255
accessToken: string;
5356
chatgptAccountId: string;
57+
/** Prevent pool selection, affinity, and active-account mutation for an exact selector. */
58+
fixedAccount?: boolean;
5459
/** See `pool.probeLeaseId`. */
5560
probeLeaseId?: string;
5661
quotaScope?: CodexQuotaScope;
@@ -148,22 +153,31 @@ export function cooldownAccountLabel(accountId: string): string {
148153
* as HTTP. The bare "cooling down" string left users with no route but commenting out the
149154
* injected `openai_base_url` in config.toml.
150155
*/
151-
export function cooldownErrorMessage(err: CodexAccountCooldownError): string {
156+
export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
152157
const until = new Date(err.cooldownUntil).toISOString();
153158
const scope = err.quotaScope === "spark"
154159
? "Spark quota"
155160
: err.quotaScope === "shared"
156161
? "shared native quota"
157162
: null;
158-
return `Selected Codex account (${cooldownAccountLabel(err.accountId)})${scope ? ` ${scope} is` : " is"} cooling down until ${until}`
159-
+ ` (source: ${err.cooldownSource ?? "default"}).`
160-
+ ` Run 'ocx account list openai' to find the id, then`
161-
+ ` 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.`;
163+
const selected = accountSelector
164+
? `Selected Codex account selector (${accountSelector})`
165+
: `Selected Codex account (${cooldownAccountLabel(err.accountId)})`;
166+
const recovery = accountSelector
167+
? " This request is pinned to that selector and will not switch accounts; choose another account-qualified model or retry later."
168+
: " Run 'ocx account list openai' to find the id, then"
169+
+ " 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.";
170+
return `${selected}${scope ? ` ${scope} is` : " is"} cooling down until ${until}`
171+
+ ` (source: ${err.cooldownSource ?? "default"}).${recovery}`;
162172
}
163173

164174
/** HTTP form of {@link cooldownErrorMessage}, carrying Retry-After for well-behaved clients. */
165-
export function cooldownErrorResponse(err: CodexAccountCooldownError, now = Date.now()): Response {
166-
const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err));
175+
export function cooldownErrorResponse(
176+
err: CodexAccountCooldownError,
177+
now = Date.now(),
178+
accountSelector?: string,
179+
): Response {
180+
const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err, accountSelector));
167181
const headers = new Headers(res.headers);
168182
headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000))));
169183
return new Response(res.body, { status: res.status, headers });
@@ -185,6 +199,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
185199

186200
export interface ResolveCodexAuthContextOptions {
187201
excludeAccountId?: string;
202+
/** Resolve exactly this account without consulting or mutating Pool selection. */
203+
accountId?: string;
188204
/** Final native model selected for this request, used to select its quota group. */
189205
modelId?: string;
190206
}
@@ -195,14 +211,22 @@ export async function resolveCodexAuthContext(
195211
mode: CodexAccountMode,
196212
options: ResolveCodexAuthContextOptions = {},
197213
): Promise<CodexAuthContext> {
198-
if (mode === "direct") {
214+
const fixedAccountId = options.accountId;
215+
if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
216+
throw new Error("Codex auth context cannot select and exclude an account simultaneously");
217+
}
218+
// An explicit namespace binding is stronger than the provider's default mode. It must use the
219+
// selected stored credential even while the canonical OpenAI provider is globally Direct.
220+
if (mode === "direct" && fixedAccountId === undefined) {
199221
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
200222
return { kind: "main", accountId: null };
201223
}
202224
reconcileMainCodexAccountRuntimeState();
203225
const threadId = headers.get("x-codex-parent-thread-id");
204226
const quotaScope = codexQuotaScopeForModel(options.modelId);
205-
const resolution = options.excludeAccountId
227+
const resolution = fixedAccountId !== undefined
228+
? { status: "selected" as const, accountId: fixedAccountId }
229+
: options.excludeAccountId
206230
? (() => {
207231
const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!, Date.now(), quotaScope);
208232
return accountId
@@ -213,12 +237,16 @@ export async function resolveCodexAuthContext(
213237
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
214238
let accountId = resolution.status === "selected" ? resolution.accountId : null;
215239
if (!accountId) throw new CodexPoolAuthenticationError();
240+
if (fixedAccountId !== undefined
241+
&& (isCodexAccountPaused(config, accountId) || !isCodexAccountUsable(config, accountId))) {
242+
throw new CodexPoolAuthenticationError();
243+
}
216244
// Lazy prime: if the selected account has no quota yet, the pool is likely
217245
// unprimed (dashboard never opened, or startup prime was blocked). Kick a
218246
// best-effort prime so the NEXT routing decision has real scores. This never
219247
// blocks the current request, and the helper's single-flight guard collapses
220248
// repeated triggers into one pass.
221-
if (!getAccountQuota(accountId)) {
249+
if (fixedAccountId === undefined && !getAccountQuota(accountId)) {
222250
import("./auth-api")
223251
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
224252
.catch(() => {});
@@ -233,6 +261,11 @@ export async function resolveCodexAuthContext(
233261
let probeLeaseId: string | undefined;
234262
let probeQuotaScope: CodexQuotaScope | undefined;
235263
if (cooldownUntil) {
264+
// Exact bindings are not Pool recovery traffic. Fail closed instead of consuming the Pool's
265+
// one probe lease or selecting another account.
266+
if (fixedAccountId !== undefined) {
267+
throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
268+
}
236269
probeQuotaScope = cooldown?.quotaScope;
237270
probeLeaseId = probeQuotaScope
238271
? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
@@ -256,6 +289,7 @@ export async function resolveCodexAuthContext(
256289
accountId,
257290
accessToken: token.accessToken,
258291
chatgptAccountId: token.chatgptAccountId,
292+
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
259293
...(quotaScope ? { quotaScope } : {}),
260294
...(probeLeaseId ? { probeLeaseId } : {}),
261295
...(probeQuotaScope ? { probeQuotaScope } : {}),
@@ -270,6 +304,7 @@ export async function resolveCodexAuthContext(
270304
generation: token.generation,
271305
accessToken: token.accessToken,
272306
chatgptAccountId: token.chatgptAccountId,
307+
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
273308
...(quotaScope ? { quotaScope } : {}),
274309
...(probeLeaseId ? { probeLeaseId } : {}),
275310
...(probeQuotaScope ? { probeQuotaScope } : {}),

src/codex/routing.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ export type CodexUpstreamOutcomeMeta = {
171171
modelId?: string;
172172
/** When set, clears affinity for this thread immediately on transient failure. */
173173
threadId?: string | null;
174+
/** Suppress Pool selection and affinity mutations for an account-qualified request. */
175+
fixedAccount?: boolean;
174176
/**
175177
* Probe lease held by this request, when it was admitted through an active
176178
* quota cooldown. Only the outcome carrying the current lease may clear the
@@ -1276,7 +1278,7 @@ export function recordCodexUpstreamOutcome(
12761278
});
12771279
quotaScopedHealth.delete(accountId);
12781280
markAccountNeedsReauth(accountId);
1279-
clearThreadAccountMapForAccount(accountId);
1281+
if (!meta.fixedAccount) clearThreadAccountMapForAccount(accountId);
12801282
return;
12811283
}
12821284

@@ -1309,7 +1311,7 @@ export function recordCodexUpstreamOutcome(
13091311
// The shared native scope is the existing account-wide native behavior:
13101312
// threads must leave it and new requests should prefer an eligible account.
13111313
// Spark remains isolated so a same-account Terra/Luna combo fallback can run.
1312-
if (quotaScope === "shared") {
1314+
if (quotaScope === "shared" && !meta.fixedAccount) {
13131315
clearThreadAccountMapForAccount(accountId);
13141316
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
13151317
if (getEffectiveActiveCodexAccountId(config) === accountId) {
@@ -1354,17 +1356,19 @@ export function recordCodexUpstreamOutcome(
13541356
...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
13551357
}),
13561358
});
1357-
clearThreadAccountMapForAccount(accountId);
1358-
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1359-
const effectiveActive = getEffectiveActiveCodexAccountId(config);
1360-
if (effectiveActive === accountId) {
1361-
// Same-request 429 retry already picked via excludeAccountId — reuse it so
1362-
// round-robin does not advance the ring a second time.
1363-
const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1364-
? meta.promoteAccountId
1365-
: null;
1366-
const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now);
1367-
if (fallback) promoteActiveCodexAccount(config, fallback);
1359+
if (!meta.fixedAccount) {
1360+
clearThreadAccountMapForAccount(accountId);
1361+
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1362+
const effectiveActive = getEffectiveActiveCodexAccountId(config);
1363+
if (effectiveActive === accountId) {
1364+
// Same-request 429 retry already picked via excludeAccountId — reuse it so
1365+
// round-robin does not advance the ring a second time.
1366+
const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1367+
? meta.promoteAccountId
1368+
: null;
1369+
const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now);
1370+
if (fallback) promoteActiveCodexAccount(config, fallback);
1371+
}
13681372
}
13691373
return;
13701374
}
@@ -1409,15 +1413,17 @@ export function recordCodexUpstreamOutcome(
14091413
// thread is still pinned to the FAILING account — a late failure from account A
14101414
// must not delete a newer healthy binding to account B (race: T→A, A fails,
14111415
// T→B, late A failure must not delete B's mapping).
1412-
if (failoverReady && meta.threadId) {
1416+
if (!meta.fixedAccount && failoverReady && meta.threadId) {
14131417
deleteThreadAffinitiesForAccount(meta.threadId, accountId);
14141418
}
14151419
// Once the account is past the failover streak, clear every thread still pinned
14161420
// to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
1417-
if (shouldFailover(config, accountId, now)) {
1421+
if (!meta.fixedAccount && shouldFailover(config, accountId, now)) {
14181422
clearThreadAccountMapForAccount(accountId);
14191423
}
1420-
if (getEffectiveActiveCodexAccountId(config) === accountId) applyFailureFailover(config, accountId, now);
1424+
if (!meta.fixedAccount && getEffectiveActiveCodexAccountId(config) === accountId) {
1425+
applyFailureFailover(config, accountId, now);
1426+
}
14211427
}
14221428

14231429
export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {

src/router.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,26 @@ import { hasOwnProvider, resolveEnvValue } from "./config";
44
import { assertProviderDestinationAllowed } from "./lib/destination-policy";
55
import { redactSecretString, redactUrlForLog } from "./lib/redact";
66
import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry";
7-
import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
7+
import {
8+
isCanonicalOpenAiForwardProvider,
9+
LEGACY_CHATGPT_PROVIDER_ID,
10+
LEGACY_OPENAI_MULTI_PROVIDER_ID,
11+
OPENAI_API_PROVIDER_ID,
12+
OPENAI_CODEX_PROVIDER_ID,
13+
} from "./providers/openai-tiers";
814
import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec";
915
import { getStaleCached } from "./codex/model-cache";
16+
import { codexAccountNamespaceEntries } from "./codex/account-namespaces";
1017

1118
export interface RouteResult {
1219
providerName: string;
1320
provider: OcxProviderConfig;
1421
modelId: string;
1522
codexAccountMode?: CodexAccountMode;
23+
/** Exact account selected by an account-qualified native model. */
24+
codexAccountId?: string;
25+
/** Public namespace used by the account-qualified selector. */
26+
codexAccountNamespace?: string;
1627
combo?: ComboPick;
1728
}
1829

@@ -313,6 +324,31 @@ function routeResult(providerName: string, provider: OcxProviderConfig, modelId:
313324
}
314325

315326
function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult {
327+
const slash = modelId.indexOf("/");
328+
if (slash > 0) {
329+
const namespace = modelId.slice(0, slash);
330+
const binding = codexAccountNamespaceEntries(config)
331+
.find(([candidate]) => candidate === namespace);
332+
if (binding) {
333+
const nativeModelId = modelId.slice(slash + 1);
334+
if (!isBareOpenAiFamilyModel(nativeModelId)) {
335+
throw new Error(`Codex account namespace ${namespace} only supports native OpenAI model ids`);
336+
}
337+
const provider = config.providers[OPENAI_CODEX_PROVIDER_ID];
338+
if (!provider || provider.disabled === true || !isCanonicalOpenAiForwardProvider(provider)) {
339+
throw new NoEnabledOpenAiProviderError(nativeModelId);
340+
}
341+
return {
342+
...routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId),
343+
// Exact account injection uses the pool credential machinery even when the canonical
344+
// provider is globally Direct. The fixed id bypasses pool selection entirely.
345+
codexAccountMode: "pool",
346+
codexAccountId: binding[1],
347+
codexAccountNamespace: namespace,
348+
};
349+
}
350+
}
351+
316352
if (!bypassCombos && !preservesPhysicalComboProvider(config)) {
317353
const combo = tryPickComboModel(config, modelId);
318354
if (combo) {
@@ -328,7 +364,6 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo
328364
// Only triggers when the prefix matches a CONFIGURED provider, so genuine
329365
// slash-containing model ids (e.g. "anthropic/claude-...") fall through when
330366
// no such provider exists.
331-
const slash = modelId.indexOf("/");
332367
if (slash > 0) {
333368
const provName = modelId.slice(0, slash);
334369
if (provName === LEGACY_CHATGPT_PROVIDER_ID || provName === LEGACY_OPENAI_MULTI_PROVIDER_ID) {

src/server/responses/compact.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,9 @@ export async function handleResponsesCompact(
186186
const selectedModelId = route.modelId;
187187
logCtx.requestedModel = raw.model;
188188
logCtx.model = selectedModelId;
189-
logCtx.provider = route.providerName;
189+
logCtx.provider = route.codexAccountNamespace
190+
? `${route.providerName}-${route.codexAccountNamespace}`
191+
: route.providerName;
190192
logCtx.providerAdapter = route.provider.adapter;
191193
const virtual = resolveOpenAiCompactModel(route.providerName, selectedModelId);
192194
if (virtual) {
@@ -218,7 +220,10 @@ export async function handleResponsesCompact(
218220
const headers = new Headers({ "content-type": "application/json" });
219221
try {
220222
if (route.codexAccountMode) {
221-
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: selectedModelId });
223+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
224+
accountId: route.codexAccountId,
225+
modelId: selectedModelId,
226+
});
222227
const selected = headersForCodexAuthContext(req.headers, authCtx);
223228
compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
224229
for (const name of FORWARD_HEADERS) {
@@ -233,7 +238,7 @@ export async function handleResponsesCompact(
233238
}
234239
} catch (err) {
235240
if (err instanceof CodexAccountCooldownError) {
236-
return cooldownErrorResponse(err);
241+
return cooldownErrorResponse(err, Date.now(), route.codexAccountNamespace);
237242
}
238243
if (err instanceof CodexThreadAffinityExpiredError) {
239244
return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
@@ -264,6 +269,7 @@ export async function handleResponsesCompact(
264269
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
265270
...meta,
266271
threadId: compactThreadId,
272+
fixedAccount: authCtx.fixedAccount,
267273
modelId: selectedModelId,
268274
probeLeaseId: codexProbeLeaseId(authCtx),
269275
probeQuotaScope: codexProbeQuotaScope(authCtx),

0 commit comments

Comments
 (0)