Skip to content
Closed
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
11 changes: 8 additions & 3 deletions src/codex/account-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
import { clearAccountQuota } from "./quota";
import { forgetManualPreference } from "./routing/active-account";
import { deleteAllHealthForAccount } from "./routing/health-store";
import { forgetCodexFailureWindow } from "./routing/failure-window";
import { clearThreadAccountMapForAccount } from "./routing/thread-affinity";

import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff";
Expand Down Expand Up @@ -49,11 +50,15 @@ export function purgeCodexAccountRuntimeState(accountId: string): void {
clearAccountNeedsReauth(accountId);
clearAccountQuota(accountId);
clearThreadAccountMapForAccount(accountId);
// Deliberately not via `./routing`: the account-delete path only needs the health rows and
// the manual preference gone, and importing the router here closed a cycle through
// routing/health-store and routing/active-account.
// Deliberately not via `./routing`: the account-delete path only needs the health rows
// and their sibling routing state gone, and importing the router barrel here closed a
// cycle through routing/health-store and routing/active-account. The leaf imports below
// carry every per-account forget that barrel helper used to perform — including the
// failure window, whose degrade and pin-warning state must not leak into a replacement
// account or a new physical login behind __main__.
deleteAllHealthForAccount(accountId);
forgetManualPreference(accountId);
forgetCodexFailureWindow(accountId);
clearCodexPoolRefreshFailure(accountId);
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
clearMainAccountInfoCache();
Expand Down
35 changes: 27 additions & 8 deletions src/codex/account-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,25 +136,37 @@ export function peekCodexAccountRecordStore(): Record<string, CodexAccountCreden

/** Read the Codex account record store from disk with hardened permissions, and hand it to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
function loadCodexAccountRecordStore(): CodexAccountStore {
const result = loadCodexAccountRecordStoreResult();
return result.status === "ok" ? result.records : {};
}

type CodexAccountRecordStoreResult = { status: "ok"; records: CodexAccountStore } | { status: "failed" };

/** Retain read/parse failure separately for consumers that must preserve existing evidence. */
function loadCodexAccountRecordStoreResult(): CodexAccountRecordStoreResult {
const path = codexAccountsPath();
hardenConfigDir();
hardenExistingSecret(path);
if (!existsSync(path)) { notifyCodexStoreLoaded({}); return {}; }
const normalized: CodexAccountStore = {};
try {
const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
if (!isObject(raw)) throw new Error("invalid Codex account store shape");
for (const [id, value] of Object.entries(raw)) {
const record = normalizeRecord(value);
if (record) normalized[id] = record;
}
} catch {
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
notifyCodexStoreLoaded({});
return { status: "ok", records: {} };
}
backupInvalidConfig(path);
notifyCodexStoreLoaded({});
return {};
return { status: "failed" };
}
// Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
notifyCodexStoreLoaded(normalized);
return normalized;
return { status: "ok", records: normalized };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Atomically write the Codex account store, then hand it to the opt-in decision-quota slot so active advisory evidence tracks the persisted credentials. */
Expand Down Expand Up @@ -400,6 +412,10 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
return !!record?.credential && record.deletedAt == null && record.generation === generation;
}

/** Callable for existing liveness consumers; stateful consumers can distinguish unknown snapshots. */
export type CodexAccountGenerationLiveCheck = ((id: string, generation: number) => boolean)
& { readonly snapshotStatus: "ok" | "failed" };

/**
* The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
*
Expand All @@ -409,12 +425,15 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
* several accounts in one synchronous pass on the request path, so it opens one checker and
* closes over the snapshot instead.
*/
export function beginCodexAccountGenerationLiveCheck(): (id: string, generation: number) => boolean {
const snapshot = loadCodexAccountRecordSnapshot();
return (id, generation) => {
export function beginCodexAccountGenerationLiveCheck(): CodexAccountGenerationLiveCheck {
let result: CodexAccountRecordStoreResult;
try { result = loadCodexAccountRecordStoreResult(); }
catch { result = { status: "failed" }; }
const snapshot = result.status === "ok" ? result.records : {};
return Object.assign((id: string, generation: number) => {
const record = snapshot[id];
return !!record?.credential && record.deletedAt == null && record.generation === generation;
};
}, { snapshotStatus: result.status });
}

export function saveCodexAccountCredentialIfGeneration(
Expand Down
4 changes: 4 additions & 0 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ export type CodexAuthContext =
writerGeneration: number;
/** Captured before async credential work; never reconstructed after the upstream response. */
mainQuotaWriter?: MainQuotaWriter;
/** Immutable credential proof captured while materializing this physical attempt's headers. */
mainQuotaDispatch?: MainQuotaDispatch;
reserveAuthorization?: MainReserveAuthorization;
accessToken: string;
chatgptAccountId: string;
Expand Down Expand Up @@ -1766,6 +1768,8 @@ export function materializeCodexUpstreamAuth(
if (ctx.kind === "main-pool") {
ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter);
assertMainAccountPolicy(options.config);
ctx.mainQuotaDispatch = ctx.mainQuotaWriter && isMainQuotaWriterLive(ctx.mainQuotaWriter)
? selectedMainQuotaDispatch(selected) : undefined;
}
assertMaterializedReserve(selected, ctx, options);
return selected;
Expand Down
6 changes: 3 additions & 3 deletions src/codex/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
failureWindowSteersNewThreads,
forgetCodexFailureWindow,
forgetCodexFailureWindows,
noteCodexFailureWindowSample,
noteCodexFailureWindowOutcome,
settleCodexSuccessStreak,
} from "./routing/failure-window";

Expand Down Expand Up @@ -1305,7 +1305,7 @@ export function recordCodexUpstreamOutcome(
*/
dropSpentCredentialFailure(accountId);
if (outcomeClass === "success") {
noteCodexFailureWindowSample(config, accountId, false, now);
noteCodexFailureWindowOutcome(config, accountId, false, now, meta);
// The operator's one-shot is spent by a dispatch that actually worked, and only by that.
// A failed lookup leaves it unspent so the intent survives the failure.
consumeManualPreference(accountId, codexPoolKeyForScope(quotaScope));
Expand Down Expand Up @@ -1555,7 +1555,7 @@ export function recordCodexUpstreamOutcome(
// A transient failure concludes an owning probe; an unrelated 5xx must not
// consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
noteCodexFailureWindowSample(config, accountId, true, now);
noteCodexFailureWindowOutcome(config, accountId, true, now, meta);
const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
// Soft avoid + affinity clears are part of failover. When threshold is 0, leave
Expand Down
5 changes: 5 additions & 0 deletions src/codex/routing/cooldown-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isThirtyDayOnlyCodexPlan } from "../plan";
import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types";
import type { CodexQuotaScope } from "./health-store";
import type { TransientProbeGrant } from "./thread-affinity";
import type { MainQuotaDispatch } from "../main-account-cache";

export { TERMINAL_SHORT_WINDOW_FRESHNESS_MS } from "../quota-types";

Expand Down Expand Up @@ -99,6 +100,10 @@ export type CodexUpstreamOutcomeMeta = {
* means the caller cannot supply lineage and the historical unfenced handling stands.
*/
credentialGeneration?: number;
/** Main credential proof captured before dispatch; null means no owned proof was available. */
mainFailureWindowDispatch?: MainQuotaDispatch | null;
/** Pool sample provenance retained by a terminal callback even if its context is reused. */
failureWindowCredentialGeneration?: number;
};

export function computeCodexUsageScore(quota: {
Expand Down
70 changes: 61 additions & 9 deletions src/codex/routing/failure-window.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { pinnedCodexAccountId } from "../account-priority";
import type { OcxConfig } from "../../types";
import { beginCodexAccountGenerationLiveCheck, isCodexAccountGenerationLive } from "../account-store";
import { isMainQuotaDispatchLive } from "../main-account-cache";
import type { CodexAuthContext } from "../auth-context";
import type { CodexUpstreamOutcomeMeta } from "./cooldown-math";
import {
deleteAccountHealth,
setAccountHealth,
Expand All @@ -14,12 +18,31 @@ export const CODEX_SLIDING_FAILURE_RECOVER_RATIO = 0.1;
export const CODEX_SLIDING_FAILURE_RECOVER_SUSTAIN_MS = 30_000;
const MAX_SAMPLES = 512;

type Sample = { at: number; failed: boolean };
type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number };
type Sample = { at: number; failed: boolean; credentialGeneration?: number };
type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number; credentialGeneration?: number };

const windows = new Map<string, WindowState>();
const pinnedDegradeWarned = new Set<string>();

/** Read only dispatch-time provenance retained by auth materialization, never recapture at arrival. */
export function codexFailureWindowOutcomeMeta(
context: CodexAuthContext,
): Pick<CodexUpstreamOutcomeMeta, "failureWindowCredentialGeneration" | "mainFailureWindowDispatch"> {
if (context.kind === "pool") return { failureWindowCredentialGeneration: context.generation };
return context.kind === "main-pool" ? { mainFailureWindowDispatch: context.mainQuotaDispatch ?? null } : {};
}

/** Fence window samples only; other health and recovery-lease semantics stay with their owners. */
export function noteCodexFailureWindowOutcome(
config: OcxConfig, accountId: string, failed: boolean, now: number, meta: CodexUpstreamOutcomeMeta,
): void {
if (meta.mainFailureWindowDispatch !== undefined
&& (!meta.mainFailureWindowDispatch || !isMainQuotaDispatchLive(meta.mainFailureWindowDispatch))) return;
const generation = meta.failureWindowCredentialGeneration ?? meta.credentialGeneration;
if (generation !== undefined && !isCodexAccountGenerationLive(accountId, generation)) return;
appendSample(config, accountId, failed, now, generation);
}

export function codexFailureWindowEnabled(config: OcxConfig): boolean {
return config.codexFailureWindow !== false;
}
Expand All @@ -33,11 +56,27 @@ function stateFor(accountId: string): WindowState {
return state;
}

function recompute(state: WindowState, now: number): void {
const cutoff = now - CODEX_SLIDING_FAILURE_WINDOW_MS;
if (state.samples.length > 0 && state.samples[0]!.at <= cutoff) {
state.samples = state.samples.filter(sample => sample.at > cutoff);
function recompute(
accountId: string, state: WindowState, now: number,
generationLive?: ReturnType<typeof beginCodexAccountGenerationLiveCheck>,
): void {
// One store snapshot per insertion/read, never one file load per retained sample. The
// unlocked insertion precheck can race another process; provenance must survive it.
const tagged = state.credentialGeneration !== undefined
|| state.samples.some(sample => sample.credentialGeneration !== undefined);
const live = generationLive ?? (tagged ? beginCodexAccountGenerationLiveCheck() : undefined);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Unknown evidence is not a credential removal: preserve samples and all derived state.
if (live?.snapshotStatus === "failed") return;
if (state.credentialGeneration !== undefined && !live?.(accountId, state.credentialGeneration)) {
state.degraded = false;
state.healthySince = undefined;
state.credentialGeneration = undefined;
pinnedDegradeWarned.delete(accountId);
}
const cutoff = now - CODEX_SLIDING_FAILURE_WINDOW_MS;
state.samples = state.samples.filter(sample => sample.at > cutoff
&& (sample.credentialGeneration === undefined || live?.(accountId, sample.credentialGeneration)));
state.credentialGeneration ??= state.samples.find(sample => sample.credentialGeneration !== undefined)?.credentialGeneration;
const samples = state.samples.length;
const failed = state.samples.reduce((count, sample) => count + (sample.failed ? 1 : 0), 0);
const degraded = samples >= CODEX_SLIDING_FAILURE_MIN_SAMPLES
Expand Down Expand Up @@ -71,19 +110,32 @@ export function noteCodexFailureWindowSample(
accountId: string,
failed: boolean,
now: number,
): void {
appendSample(config, accountId, failed, now);
}

function appendSample(
config: OcxConfig, accountId: string, failed: boolean, now: number, credentialGeneration?: number,
): void {
if (!codexFailureWindowEnabled(config)) return;
const existing = windows.get(accountId);
const tagged = credentialGeneration !== undefined || existing?.credentialGeneration !== undefined
|| existing?.samples.some(sample => sample.credentialGeneration !== undefined);
const live = tagged ? beginCodexAccountGenerationLiveCheck() : undefined;
if (live?.snapshotStatus === "failed") return;
// A stale arrival is not an ownership transition and must not delete a live replacement's state.
if (credentialGeneration !== undefined && !live?.(accountId, credentialGeneration)) return;
const state = stateFor(accountId);
state.samples.push({ at: now, failed });
state.samples.push({ at: now, failed, ...(credentialGeneration === undefined ? {} : { credentialGeneration }) });
if (state.samples.length > MAX_SAMPLES) state.samples.splice(0, state.samples.length - MAX_SAMPLES);
recompute(state, now);
recompute(accountId, state, now, live);
if (!state.degraded) pinnedDegradeWarned.delete(accountId);
}

export function isCodexFailureWindowDegraded(accountId: string, now = Date.now()): boolean {
const state = windows.get(accountId);
if (!state) return false;
recompute(state, now);
recompute(accountId, state, now);
if (!state.degraded) pinnedDegradeWarned.delete(accountId);
return state.degraded;
}
Expand Down
5 changes: 5 additions & 0 deletions src/providers/openai-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type CodexAuthPolicyConfig,
} from "../codex/auth-context";
import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome } from "../codex/routing";
import { codexFailureWindowOutcomeMeta } from "../codex/routing/failure-window";
import { inspectChatGptDomainClaim } from "../oauth/chatgpt";
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "../server/auth-cors";
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
Expand Down Expand Up @@ -205,6 +206,7 @@ export async function resolveFirstUsableOpenAiSidecar(
releaseCodexAuthContextProbeLease(authContext);
throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
}
const windowMeta = codexFailureWindowOutcomeMeta(authContext);
return {
...candidate,
authContext,
Expand All @@ -220,6 +222,7 @@ export async function resolveFirstUsableOpenAiSidecar(
probeLeaseId: authContext.probeLeaseId,
probeQuotaScope: authContext.probeQuotaScope,
writerGeneration: authContext.writerGeneration,
...windowMeta,
// 401/403 here is evidence about this exact stored credential; without the generation a
// replacement inherits the quarantine (#2892 gap 4).
...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
Expand Down Expand Up @@ -256,6 +259,7 @@ export async function resolveFirstUsableOpenAiSidecar(
releaseCodexAuthContextProbeLease(authContext);
continue;
}
const windowMeta = codexFailureWindowOutcomeMeta(authContext);
return {
...candidate,
authContext,
Expand All @@ -271,6 +275,7 @@ export async function resolveFirstUsableOpenAiSidecar(
threadId: authContext.affinityKey,
probeLeaseId: authContext.probeLeaseId,
writerGeneration: authContext.writerGeneration,
...windowMeta,
// Same fence as the exact-account recorder above (#2892 gap 4).
...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
},
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
recordCodexUpstreamOutcome,
type CodexUpstreamOutcome,
} from "../../codex/routing";
import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
import {
applyAccountChangeConversationStateScrub,
conversationStateBindingFromAuth,
Expand Down Expand Up @@ -1062,6 +1063,7 @@ export async function handleResponsesCompact(
if ((outcome === 401 || outcome === 403) && upstream && wasPermanentAccountRefusal(upstream)) return;
recordCodexUpstreamOutcome(config, ctx.accountId, outcome, {
...meta,
...codexFailureWindowOutcomeMeta(ctx),
threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined,
fixedAccount: ctx.fixedAccount,
modelId: selectedModelId,
Expand Down
Loading
Loading