-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(codex): report the pin routing will release instead of a bare accepted 200 (#4521) #4970
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)); | ||
| } 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.`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the request following Useful? React with 👍 / 👎. |
||
| } else if (threshold !== undefined && threshold > 0) { | ||
| console.error(`Note: auto-switch (threshold ${threshold}%) may override this pin.`); | ||
| } | ||
| } | ||
| return 0; | ||
|
|
||
| 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; | ||
| } |
| 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", | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This changes the documented
ocx account use --jsoncontract, butdocs-site/src/content/docs/reference/cli/providers-accounts.mdstill promises only{ ok, provider, type, activeId }, and the management API reference does not describepinDrainedorpinDrainReason. 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 👍 / 👎.