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
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@
"claude-system-env-auto.test.ts": "claude-integration",
"cleanup-orphaned-workflows.test.ts": "ci-workflows",
"clearable-deadline.test.ts": "lib",
"cli-account-pin-drain.test.ts": "cli",
"cli-account-pool-verbs.test.ts": "cli",
"cli-account.test.ts": "cli",
"cli-capabilities.test.ts": "cli",
Expand Down Expand Up @@ -513,6 +514,7 @@
"codex-models-cache-invalidate.test.ts": "codex-integration",
"codex-native-residue.test.ts": "codex-integration",
"codex-plan.test.ts": "codex-integration",
"codex-pin-drain-projection.test.ts": "codex-integration",
"codex-plugins-doctor.test.ts": "codex-integration",
"codex-pool-plan-exclusion.test.ts": "codex-integration",
"codex-pool-rotation.test.ts": "codex-integration",
Expand Down
31 changes: 27 additions & 4 deletions src/cli/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,36 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
if (res.status === 0) return proxyUnreachable(res.transportError);
if (res.status !== 200) return apiError(res.json, `failed to switch ${name}`, res.status);

if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, type: c.type, activeId }, null, 2));
else console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`);
// The route reports this only when routing would drop the pin it just recorded, so an
// absent field means the pin survives (#4521).
const pinDrainReason = typeof res.json.pinDrainReason === "string" ? res.json.pinDrainReason : undefined;
if (wantsJson) {
console.log(JSON.stringify({
ok: true,
provider: name,
type: c.type,
activeId,
...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}),
}, null, 2));
Comment on lines +329 to +336

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the added pin-drain response fields

This changes the documented ocx account use --json contract, but docs-site/src/content/docs/reference/cli/providers-accounts.md still promises only { ok, provider, type, activeId }, and the management API reference does not describe pinDrained or pinDrainReason. Update the user-facing references so clients can discover and correctly interpret these conditional fields.

AGENTS.md reference: src/AGENTS.md:L29-L29

Useful? React with 👍 / 👎.

} else {
console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`);
}
if (c.type === "codex") {
console.error("Takes effect immediately; running threads move on their next request, and in-flight requests keep the account they captured.");
const active = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
if (active.status === 200 && typeof active.json.autoSwitchThreshold === "number" && active.json.autoSwitchThreshold > 0) {
console.error(`Note: auto-switch (threshold ${active.json.autoSwitchThreshold}%) may override this pin.`);
const threshold = active.status === 200 && typeof active.json.autoSwitchThreshold === "number"
? active.json.autoSwitchThreshold
: undefined;
if (pinDrainReason !== undefined) {
// "may override" is the right caveat for a pin that is currently fine and could be
// overtaken later. It is the wrong sentence for one the next request will discard, and
// printing only that is what left the operator believing the account was pinned.
const because = pinDrainReason === "quota_threshold"
? `is at or above the auto-switch threshold${threshold !== undefined ? ` (${threshold}%)` : ""}`
: `cannot currently be selected (${pinDrainReason})`;
console.error(`Note: ${displayId(activeId)} ${because}, so routing releases this pin on its next request.`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify the warning for independent-quota requests

When the request following ocx account use targets the independent gpt-reserve quota scope, resolveCodexAccountForThreadDetailed deliberately skips releaseDrainedCodexAccountPin for that request, so this unconditional statement that routing releases the pin “on its next request” is false. Describe this as the next shared-quota request, or otherwise account for the model scope before promising when release occurs.

Useful? React with 👍 / 👎.

} else if (threshold !== undefined && threshold > 0) {
console.error(`Note: auto-switch (threshold ${threshold}%) may override this pin.`);
}
}
return 0;
Expand Down
19 changes: 17 additions & 2 deletions src/codex/auth-api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getAccountQuotaHistory, listAccountQuotas } from "../quota";
import { deleteCodexAccount } from "../account-lifecycle";
import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority";
import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation";
import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
Expand Down Expand Up @@ -235,7 +235,22 @@ export async function handleCodexAuthAPI(
else setCodexAccountPin(runtimeConfig, targetAccountId);
resetCodexRoutingForManualSelection(targetAccountId);
saveRuntimeConfig(config, runtimeConfig);
return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
// A pin this route accepts can still be dropped by the very next resolve, and saying
// nothing about that is what made the setting look ignored (#4521). The checks above
// refuse an account that cannot be selected at all; this reports the one remaining
// outcome they do not cover, from the same predicate routing releases on, so the two
// cannot drift. Absent means the pin survives — additive for existing clients.
// `appliesImmediately` is unchanged: it answers whether thread affinity was cleared,
// not whether the pin is durable.
const pinDrainReason = body.accountId == null
? undefined
: codexAccountPinDrainReason(runtimeConfig, targetAccountId);
return jsonResponse({
ok: true,
activeCodexAccountId: body.accountId,
appliesImmediately: true,
...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}),
});
}

if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
Expand Down
19 changes: 5 additions & 14 deletions src/codex/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { codexAccountLogLabel } from "./account-label";
import { isCodexAccountPaused } from "./account-pause";
import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority";
import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
import { markAccountNeedsReauth } from "./account-runtime-state";
import { codexAccountPinDrainReason } from "./routing/pin-drain";
import { POOL_KEY_CODEX, notePoolRotationFailure } from "./pool-rotation";
import { getAccountQuota, isRetiredCodexSparkModel } from "./quota";
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
Expand Down Expand Up @@ -202,6 +203,8 @@ export {
getEffectiveActiveCodexAccountId,
isEffectiveCodexAccountPinned,
} from "./routing/active-account";
export { codexAccountPinDrainReason } from "./routing/pin-drain";
export type { CodexPinDrainReason } from "./routing/pin-drain";
function hasConfiguredPoolAccount(
config: OcxConfig,
accountId: string,
Expand Down Expand Up @@ -462,19 +465,7 @@ function releaseDrainedCodexAccountPin(
): void {
const pinned = pinnedCodexAccountId(config);
if (pinned === undefined) return;
const knownUnavailable = isAccountNeedsReauth(pinned) || isCodexAccountPaused(config, pinned);
if (knownUnavailable) {
clearCodexAccountPin(config);
saveConfigPreservingClaudeCode(config);
return;
}
// Temporary drain deliberately forbids every native-main read. A pin on main
// cannot be classified by credential liveness or quota until the fenced profile
// is readable. Cached reauth and configured pause state were handled above.
if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return;
const drained = !isCodexAccountUsable(config, pinned, selectionOptions)
|| !hasCodexQuotaHeadroom(config, pinned, selectionOptions, now);
if (!drained) return;
if (codexAccountPinDrainReason(config, pinned, selectionOptions, now) === undefined) return;
clearCodexAccountPin(config);
saveConfigPreservingClaudeCode(config);
}
Expand Down
57 changes: 57 additions & 0 deletions src/codex/routing/pin-drain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { OcxConfig } from "../../types";
import { isCodexAccountPaused } from "../account-pause";
import { isAccountNeedsReauth } from "../account-runtime-state";
import type { CodexAccountUsabilityOptions } from "../account-usability";
import { isCodexAccountUsable } from "../account-usability";
import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
import { hasCodexQuotaHeadroom } from "./selection";

/** Why routing would drop a manual pin, or undefined when the pin survives. */
export type CodexPinDrainReason = "needs_reauth" | "paused" | "unusable" | "quota_threshold";

/**
* Would routing release a pin on this account the next time it resolves?
*
* This lives beside selection rather than inside `routing.ts` for the same reason
* {@link ./cache-affinity} does: it is a policy question asked by two callers that must answer
* identically. One is `releaseDrainedCodexAccountPin`, which acts on it. The other is
* `PUT /api/codex-auth/active`, which reports it.
*
* The two-step contradiction in #4521 is what happens when only the first exists. That route
* validates existence, pause and pending validation, answers 200, and says nothing about quota;
* the very next resolve runs this rule and drops the pin. The operator sees a setting accepted
* and then ignored. Reporting it from the same predicate, rather than from a second copy on the
* accepting surface, is what keeps the answer and the action from drifting -- a client-side
* re-derivation of the usage score has to track {@link ./cooldown-math} exactly, including the
* Free/Go plan windows and short-window freshness.
*
* Ordering is load-bearing. Cached reauth and configured pause are classified FIRST, because
* they hold for the main account even while its fenced native profile is unreadable; a
* selection-only caller then makes every later classification answer "no drain", so reading
* reauth after that guard would make a pin on a signed-out main look durable.
*
* This answers only whether the pin survives. It is not an admission check: the caller that
* acts on it releases a preference, and every involuntary release -- quota refusal, failover
* streak, cooldown, lost generation, affinity expiry -- is decided elsewhere and earlier.
*/
export function codexAccountPinDrainReason(
config: OcxConfig,
accountId: string,
selectionOptions?: Pick<
CodexAccountUsabilityOptions,
"nativeMainSelectionOnly" | "isMainAccountTokenLive"
>,
now: number = Date.now(),
): CodexPinDrainReason | undefined {
if (isAccountNeedsReauth(accountId)) return "needs_reauth";
if (isCodexAccountPaused(config, accountId)) return "paused";
// Temporary drain deliberately forbids every native-main read. A pin on main cannot be
// classified by credential liveness or quota until the fenced profile is readable. Cached
// reauth and configured pause state were handled above.
if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
return undefined;
}
if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable";
if (!hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)) return "quota_threshold";
return undefined;
}
16 changes: 16 additions & 0 deletions structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,22 @@ newer statement wins. Ordinary round-robin movement inside the capped tier does
Without that last rule a pin made before any order existed, which is just an ordinary account switch,
would outrank every order set afterwards for as long as the account kept headroom.

Whether a pin is about to be released is one predicate, `codexAccountPinDrainReason` in
`src/codex/routing/pin-drain.ts`, and the surface that accepts a pin evaluates it rather than
keeping a second copy. It lives beside selection rather than in `routing.ts` for the same reason
`routing/cache-affinity.ts` does: two callers ask it and must answer identically, one acting on
the answer and one reporting it.

`PUT /api/codex-auth/active` still accepts a pin on a drained account -- a usage reading is a
preference and can be stale, so refusing would turn a proactive threshold into a hard capacity limit
-- but it reports `pinDrained` with a `pinDrainReason` of `needs_reauth`, `paused`, `unusable` or
`quota_threshold` when the next resolve would drop what it just recorded. The fields are absent when
the pin survives, so a client that does not know them reads no drain. Without this the route answered
a bare 200 and the operator watched an accepted selection be ignored one request later, which is the
contradiction reported in #4521. Reauth and pause are classified before the native-main fence,
because a selection-only caller makes every later classification answer "no drain" and a pin on a
signed-out main would otherwise read as durable.

Only an actual selection pins. Clearing the active account states that no account is chosen, so it
releases the pin instead of recording one against the `__main__` fallback that the same handler uses
for its paused check. A pin no effective active account matches is invisible — `pinned` compares the
Expand Down
105 changes: 105 additions & 0 deletions tests/cli/cli-account-pin-drain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";
import { cmdAccount } from "../../src/cli/account";
import type { AccountDeps } from "../../src/cli/account-api";

/**
* #4521: `ocx account use` printed "auto-switch (threshold 80%) may override this pin"
* whether or not the pin was already spent, so the one case that needed a definite sentence
* got the same hedge as the healthy case. The route now reports the drain it evaluated.
*/
const BASE_URL = "http://127.0.0.1:10100";

function deps(putJson: Record<string, unknown>, thresholdJson: Record<string, unknown>): AccountDeps {
return {
baseUrl: BASE_URL,
loadConfigImpl: () => ({ providers: { openai: { adapter: "codex" } } }) as never,
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
if (path === "/api/codex-auth/active" && (init?.method ?? "GET") === "PUT") {
return new Response(JSON.stringify(putJson), { status: 200 });
}
if (path === "/api/codex-auth/active") {
return new Response(JSON.stringify(thresholdJson), { status: 200 });
}
return new Response("{}", { status: 404 });
}) as unknown as typeof fetch,
};
}

async function run(args: string[], accountDeps: AccountDeps): Promise<{ code: number; out: string; err: string }> {
const out: string[] = [];
const err: string[] = [];
const log = console.log;
const error = console.error;
console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); };
console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); };
try {
const code = await cmdAccount(args, accountDeps);
return { code, out: out.join("\n"), err: err.join("\n") };
} finally {
console.log = log;
console.error = error;
}
}

describe("account use pin-drain reporting", () => {
test("a reported quota drain is stated, not hedged", async () => {
const result = await run(["use", "openai", "pool_hot"], deps({
ok: true,
activeCodexAccountId: "pool_hot",
appliesImmediately: true,
pinDrained: true,
pinDrainReason: "quota_threshold",
}, { autoSwitchThreshold: 80 }));

expect(result.code).toBe(0);
expect(result.err).toContain("is at or above the auto-switch threshold (80%)");
expect(result.err).toContain("routing releases this pin on its next request");
expect(result.err).not.toContain("may override this pin");
});

test("a non-quota drain names its own reason", async () => {
const result = await run(["use", "openai", "pool_gone"], deps({
ok: true,
activeCodexAccountId: "pool_gone",
appliesImmediately: true,
pinDrained: true,
pinDrainReason: "needs_reauth",
}, { autoSwitchThreshold: 80 }));

expect(result.code).toBe(0);
expect(result.err).toContain("cannot currently be selected (needs_reauth)");
expect(result.err).not.toContain("auto-switch threshold");
});

test("no reported drain keeps the generic caveat", async () => {
const result = await run(["use", "openai", "pool_cool"], deps({
ok: true,
activeCodexAccountId: "pool_cool",
appliesImmediately: true,
}, { autoSwitchThreshold: 80 }));

expect(result.code).toBe(0);
expect(result.err).toContain("auto-switch (threshold 80%) may override this pin");
expect(result.err).not.toContain("routing releases this pin");
});

test("--json carries the two fields through", async () => {
const result = await run(["use", "openai", "pool_hot", "--json"], deps({
ok: true,
activeCodexAccountId: "pool_hot",
appliesImmediately: true,
pinDrained: true,
pinDrainReason: "quota_threshold",
}, { autoSwitchThreshold: 80 }));

expect(result.code).toBe(0);
expect(JSON.parse(result.out)).toMatchObject({
ok: true,
provider: "openai",
type: "codex",
pinDrained: true,
pinDrainReason: "quota_threshold",
});
});
});
Loading
Loading