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
8 changes: 4 additions & 4 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,7 @@ export type CodexAuthContext =
transientProbe?: TransientProbeGrant;
}
| {
// Main Codex account participating in rotation: token injected from ~/.codex/auth.json
// (Option A). Distinct from "main" (request-owned passthrough or Direct mode).
// Main account rotating in the Pool (Option A), token from ~/.codex/auth.json; never request-owned "main".
kind: "main-pool";
accountId: string;
writerGeneration: number;
Expand All @@ -336,14 +335,14 @@ export type CodexAuthContext =
chatgptAccountId: string;
/** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
fixedAccount?: boolean;
/** See `pool.affinityKey`. */
affinityKey?: string;
affinityKey?: string; // See `pool.affinityKey`.
/** See `pool.probeLeaseId`. */
probeLeaseId?: string;
quotaScope?: CodexQuotaScope;
probeQuotaScope?: CodexQuotaScope;
/** See `pool.transientProbe`. */
transientProbe?: TransientProbeGrant;
affinityDecision?: CodexAffinityDecision;
};

/** Probe lease carried by this context, when it holds one. */
Expand Down Expand Up @@ -1733,6 +1732,7 @@ async function resolveCodexAuthContextUnannotated(
...(quotaScope ? { quotaScope } : {}),
...(probeLeaseId ? { probeLeaseId } : {}),
...(probeQuotaScope ? { probeQuotaScope } : {}),
...(affinityDecision ? { affinityDecision } : {}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
...(transientProbe ? { transientProbe } : {}),
};
}
Expand Down
13 changes: 13 additions & 0 deletions src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../lib/errors";
import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing";
import type { CodexAuthContext } from "../codex/auth-context";
import { readCodexCatalogPath } from "../codex/catalog";
import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types";
import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
Expand Down Expand Up @@ -1263,6 +1264,18 @@ export function noteAffinityMove(
(logCtx.affinityMoveReasons ??= []).push(reason);
}

/**
* Copy the selection's own affinity decision onto the request log. Both Pool-served kinds carry
* one: a stored Pool account and the main login rotating as `main-pool` (#4546, #7799). A move
* then reads as an event with its cause instead of something inferred from account labels.
*/
export function noteCodexSelectionAffinity(logCtx: RequestLogContext, authCtx: CodexAuthContext): void {
const decision = authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.affinityDecision : undefined;
if (!decision) return;
logCtx.affinity = decision.move;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
logCtx.affinityReason = decision.reason;
}

/**
* The affinity scope a released binding belonged to: one thread, one model lane.
*
Expand Down
7 changes: 2 additions & 5 deletions src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import { anthropicSessionKeyFromParts } from "../../oauth/anthropic-routing";
import { configuredAnthropicInstance } from "../../providers/anthropic-instance";
import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../responses/turn-termination";
import { observeCacheDiagnosticInbound, rebindCacheDiagnosticBody, requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
import { noteCodexSelectionAffinity, observeCacheDiagnosticInbound, rebindCacheDiagnosticBody, requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
import type { RouteResult } from "../../router";
import {
captureRouteStaticPolicy,
Expand Down Expand Up @@ -1585,10 +1585,7 @@ export async function prepareResponsesRequest(
// A move is the expensive event: it discards the prefix warmed on the previous account. Record
// it as an event with its cause, so the operator reads it off one line instead of inferring it
// from account labels across many (#4546).
if (admissionState.authCtx.kind === "pool" && admissionState.authCtx.affinityDecision) {
logCtx.affinity = admissionState.authCtx.affinityDecision.move;
logCtx.affinityReason = admissionState.authCtx.affinityDecision.reason;
}
noteCodexSelectionAffinity(logCtx, admissionState.authCtx);
{
const binding = conversationStateBindingFromAuth(admissionState.authCtx, poolAffinityKey);
if (binding) {
Expand Down
26 changes: 26 additions & 0 deletions tests/codex-integration/codex-auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,32 @@ describe("Codex auth context", () => {
});
});

test("main-pool selection carries the same affinity decision as a stored-pool selection (#7799)", async () => {
const cfg = config();
cfg.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID;
writeFileSync(join(testDir, "auth.json"), JSON.stringify({
tokens: { access_token: "main-token", account_id: "main-account" },
}));
// Thread and session ids give the request a bindable conversation identity.
const headers = { "thread-id": "affinity-7799", "session-id": "affinity-7799" };
const resolve = () => resolveCodexAuthContext(new Headers(headers), cfg, "pool", {
isMainAccountTokenLive: () => true,
getMainAccountToken: () => ({ accessToken: "main-token", chatgptAccountId: "main-account" }),
primeCodexPoolQuotas: async () => {},
});

await expect(resolve()).resolves.toMatchObject({
kind: "main-pool",
accountId: MAIN_CODEX_ACCOUNT_ID,
affinityDecision: { move: "new_bind", reason: "healthy" },
});
await expect(resolve()).resolves.toMatchObject({
kind: "main-pool",
accountId: MAIN_CODEX_ACCOUNT_ID,
affinityDecision: { move: "reused", reason: "healthy" },
});
});

test("account-gated routing distinguishes an unavailable grant from no grant", async () => {
const cfg = config();
saveCodexAccountCredential("pool-a", {
Expand Down
2 changes: 2 additions & 0 deletions tests/codex-integration/codex-main-rotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ describe("main account rotation (Option A)", () => {
identityKey: expect.stringMatching(/^[a-f0-9]{64}$/),
identityGeneration: expect.any(Number),
},
affinityDecision: { move: "new_bind", reason: "healthy" },
});
expect(isCodexAuthContextUsable(ctx, config)).toBe(true);
const headers = headersForCodexAuthContext(new Headers(), ctx);
Expand Down Expand Up @@ -202,6 +203,7 @@ describe("main account rotation (Option A)", () => {
identityKey: expect.stringMatching(/^[a-f0-9]{64}$/),
identityGeneration: expect.any(Number),
},
affinityDecision: { move: "new_bind", reason: "healthy" },
});
expect(isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID)).toBe(false);
expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false);
Expand Down
29 changes: 29 additions & 0 deletions tests/server/spend-instrumentation-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
finishRequestAttempt,
noteAffinityMove,
noteAttemptSend,
noteCodexSelectionAffinity,
noteNoAccountAffinityReason,
recordNoAccountAffinityFailure,
requestLogEntryFromPersistedUsage,
Expand Down Expand Up @@ -201,3 +202,31 @@ describe("no-account failures explain themselves", () => {
clearNoAccountAffinityReasonsForTests();
});
});

describe("Pool selections report their affinity decision on the request log (#7799)", () => {
const decision = { move: "rebound", reason: "quota_refusal" } as const;
const base = { accountId: "acct", writerGeneration: 0, accessToken: "token", chatgptAccountId: "chatgpt" };

test("a main-pool selection reports the same decision fields as a stored-pool selection", () => {
const mainPool: RequestLogContext = { provider: "openai", model: "gpt-5.6-sol" };
noteCodexSelectionAffinity(mainPool, { kind: "main-pool", ...base, affinityDecision: decision });
expect(mainPool.affinity).toBe("rebound");
expect(mainPool.affinityReason).toBe("quota_refusal");

const pool: RequestLogContext = { provider: "openai", model: "gpt-5.6-sol" };
noteCodexSelectionAffinity(pool, { kind: "pool", ...base, generation: 1, affinityDecision: decision });
expect({ affinity: pool.affinity, affinityReason: pool.affinityReason })
.toEqual({ affinity: mainPool.affinity, affinityReason: mainPool.affinityReason });
});

test("a selection without a decision, or a request-owned main context, leaves the fields unset", () => {
const noDecision: RequestLogContext = { provider: "openai", model: "gpt-5.6-sol" };
noteCodexSelectionAffinity(noDecision, { kind: "main-pool", ...base });
expect(noDecision.affinity).toBeUndefined();
expect(noDecision.affinityReason).toBeUndefined();

const requestOwned: RequestLogContext = { provider: "openai", model: "gpt-5.6-sol" };
noteCodexSelectionAffinity(requestOwned, { kind: "main", accountId: null });
expect(requestOwned.affinity).toBeUndefined();
});
});
Loading