diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 71a79b1b67..7dd58c6b91 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,3 +1,4 @@ +import { createHmac, randomBytes } from "node:crypto"; import { CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, @@ -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 } @@ -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 @@ -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; @@ -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; @@ -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 @@ -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) { @@ -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 } : {}), @@ -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 } : {}), diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index e1ffc397fb..892b08462b 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -169,6 +169,7 @@ export async function resolveFirstUsableOpenAiSidecar( authContext.accountId, outcome, { + threadId: authContext.affinityKey, probeLeaseId: authContext.probeLeaseId, writerGeneration: authContext.writerGeneration, }, diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index adc9415ec6..4273dae40b 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -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 @@ -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), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fca48dd90c..1623263b0d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -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, @@ -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, @@ -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), @@ -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) => { @@ -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), @@ -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), @@ -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 ( @@ -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, @@ -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), @@ -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 @@ -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), diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 21cef4e954..0fd70b3b58 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -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 diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index b4e9f77d86..5a3372c137 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -497,6 +497,187 @@ describe("Codex auth context", () => { .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); + test("Desktop session and thread headers derive one opaque reconnect affinity", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + const headers = new Headers({ + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(first).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(first.kind).toBe("pool"); + if (first.kind !== "pool") throw new Error("expected pool context"); + expect(first.affinityKey?.startsWith("app:")).toBe(true); + expect(first.affinityKey?.includes("desktop-session-private")).toBe(false); + expect(first.affinityKey?.includes("desktop-thread-private")).toBe(false); + + cfg.activeCodexAccountId = "pool-b"; + const reconnect = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(reconnect).toMatchObject({ + kind: "pool", + accountId: "pool-a", + affinityKey: first.affinityKey, + }); + }); + + test("the canonical parent-thread affinity stays authoritative over Desktop fallback headers", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + const headers = new Headers({ + "x-codex-parent-thread-id": " canonical-parent-thread ", + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const resolved = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(resolved).toMatchObject({ + kind: "pool", + accountId: "pool-a", + affinityKey: "canonical-parent-thread", + }); + }); + + test("an oversized parent-thread id falls back to the bounded Desktop pair", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + const headers = new Headers({ + "x-codex-parent-thread-id": "p".repeat(513), + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const resolved = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(resolved).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(resolved.kind).toBe("pool"); + if (resolved.kind !== "pool") throw new Error("expected pool context"); + expect(resolved.affinityKey?.startsWith("app:")).toBe(true); + expect(resolved.affinityKey).not.toContain("desktop-session-private"); + expect(resolved.affinityKey).not.toContain("desktop-thread-private"); + }); + + test("incomplete or oversized Desktop affinity headers remain unbound", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + + for (const headers of [ + new Headers({ "session-id": "session-only" }), + new Headers({ "thread-id": "thread-only" }), + new Headers({ "session-id": "s".repeat(513), "thread-id": "bounded-thread" }), + ]) { + clearThreadAccountMap(); + cfg.activeCodexAccountId = "pool-a"; + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(first).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(first.kind === "pool" ? first.affinityKey : undefined).toBeUndefined(); + + cfg.activeCodexAccountId = "pool-b"; + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + } + }); + + test("exact account selection does not create Desktop Pool affinity", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.activeCodexAccountId = "pool-b"; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + const headers = new Headers({ + "session-id": "exact-desktop-session", + "thread-id": "exact-desktop-thread", + }); + + const exact = await resolveCodexAuthContext(headers, cfg, "pool", { accountId: "pool-a" }); + expect(exact).toMatchObject({ kind: "pool", accountId: "pool-a", fixedAccount: true }); + expect(exact.kind === "pool" ? exact.affinityKey : undefined).toBeUndefined(); + + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + + test("late transient failure cannot delete a newer Desktop affinity binding", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.upstreamFailoverThreshold = 3; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + const headers = new Headers({ + "session-id": "failure-desktop-session", + "thread-id": "failure-desktop-thread", + }); + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + if (first.kind !== "pool") throw new Error("expected pool context"); + expect(first.accountId).toBe("pool-a"); + + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(cfg, "pool-a", 500, { + now: 1_800_000_000_000 + attempt, + threadId: first.affinityKey, + }); + } + const rebound = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(rebound).toMatchObject({ kind: "pool", accountId: "pool-b" }); + + recordCodexUpstreamOutcome(cfg, "pool-a", 500, { + now: 1_800_000_000_100, + threadId: first.affinityKey, + }); + clearCodexUpstreamHealth(); + cfg.activeCodexAccountId = "pool-a"; + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + test("selection order never bypasses an exact account selector", async () => { // Regression: `codexAccountPriorities` narrows the pool to the highest tier, but it // is an ordering boundary over the pool path only. A request that names an account diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 79744f588a..aa2f6c4736 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -27,7 +27,7 @@ import { resetSubagentModelFallbackStateForTests, setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; -import type { CodexAuthContext } from "../src/codex/auth-context"; +import { resolveCodexAuthContext, type CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; import { isEagerRelaySseResponse } from "../src/server/relay"; import type { OcxConfig } from "../src/types"; @@ -670,6 +670,56 @@ describe("subagent fallback final-route normalization", () => { }); describe("native fallback account preview", () => { + test("Desktop fallback affinity drives the subagent preview and final native account", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + defaultProvider: "xai", + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + subagentModelFallback: ["gpt-5.6-terra"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const desktopHeaders = { + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }; + const bound = await resolveCodexAuthContext(new Headers(desktopHeaders), cfg, "pool", { + modelId: "gpt-5.6-sol", + }); + expect(bound).toMatchObject({ kind: "pool", accountId: "pool-a" }); + if (bound.kind !== "pool") throw new Error("expected pool context"); + cfg.activeCodexAccountId = "pool-b"; + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, "shared")).toBe("pool-a"); + + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("xai/grok-4.5", "429", cfg); + noteSubagentModelFailure("grok-4.5", "429", cfg); + + let finalAuth: CodexAuthContext | undefined; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "xai/grok-4.5", input: readableAgentInput(), stream: false }, + { onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; } }, + { model: "", provider: "" }, + desktopHeaders, + ); + + expect(response.status).toBe(200); + expect(capture.urls.some((url) => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); + expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(capture.auths.some((auth) => auth?.includes("pool-a_token"))).toBe(true); + }); + test("uses healthier pool account B when active A is above threshold", async () => { const now = 1_800_000_000_000; Date.now = () => now;