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: 1 addition & 1 deletion docs/reference/error-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ The default-on localhost Responses proxy returns JSON error payloads with a stab
| `codex_pinned_account_unavailable` | `503` | A manual pin is set (via `codex-multi-auth switch`) but the pinned account is rate-limited, cooling down, disabled, or blocked by policy. Run `codex-multi-auth status` for details, or `codex-multi-auth unpin` to allow rotation |
| `codex_runtime_rotation_proxy_error` | `500` | Proxy failed before forwarding the request |

Pool exhaustion includes a `reason`, `retry_after_ms`, and a hint to run `codex-multi-auth rotation status`. Pinned-account-unavailable responses include a `pinnedAccountIndex` field identifying the pinned account.
Pool exhaustion includes a `reason`, `retry_after_ms`, and a hint to run `codex-multi-auth rotation status`. Pinned-account-unavailable responses include a `pinnedAccountIndex` field identifying the pinned account, a structured `reason` field carrying the runtime skip reason (for example `rate-limited`, `cooling-down:auth-failure`, `circuit-open`, `disabled`, `workspace-disabled`, `policy-blocked`, `missing`, `already-attempted`) or `null` when no reason was recorded, and an `account_skip_reasons` map keyed by account index that mirrors the pool-exhausted response shape. The human-readable `message` appends the same reason in parentheses when present (see issue #486).

---

Expand Down
61 changes: 54 additions & 7 deletions lib/runtime-rotation-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,47 @@ function writePoolExhausted(params: {
});
}

/**
* Build the JSON `error` body for a pinned-account 503 response. Extracted so
* the null-reason desync path (`reason: null`, no parenthetical in `message`)
* can be unit-tested without standing up a full proxy. The shape mirrors
* `writePoolExhausted` so consumers can handle both 503 codes uniformly. See
* issue #486.
*/
export interface PinnedUnavailableErrorBody {
message: string;
code: "codex_pinned_account_unavailable";
pinnedAccountIndex: number | null;
reason: string | null;
account_skip_reasons: Record<string, string>;
}

export function buildPinnedUnavailableErrorBody(
pinnedIndex: number | null | undefined,
accountSkipReasons: ReadonlyMap<number, string>,
): PinnedUnavailableErrorBody {
const normalizedPinnedIndex =
typeof pinnedIndex === "number" ? pinnedIndex : null;
const skipReason =
normalizedPinnedIndex !== null
? accountSkipReasons.get(normalizedPinnedIndex) ?? null
: null;
const reasonSuffix = skipReason ? ` (${skipReason})` : "";
const displayIndex = (normalizedPinnedIndex ?? 0) + 1;
return {
message: `Pinned account ${displayIndex} is currently unavailable${reasonSuffix}; run \`codex-multi-auth status\` for details, or \`codex-multi-auth unpin\` to allow rotation.`,
code: "codex_pinned_account_unavailable",
pinnedAccountIndex: normalizedPinnedIndex,
reason: skipReason,
account_skip_reasons: Object.fromEntries(
[...accountSkipReasons.entries()].map(([index, reason]) => [
String(index),
reason,
]),
),
};
}

async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
Expand Down Expand Up @@ -1738,19 +1779,25 @@ export async function startRuntimeRotationProxy(
// When a manual pin is set and the pinned account is unavailable, do
// NOT silently fall through to rotation. Hard-fail with a 503 so the
// user is informed the pin cannot be honored. See issue #474.
//
// Surface the runtime skip reason in both the human-readable message
// and a structured `reason` field, mirroring `writePoolExhausted`. A
// null reason indicates a forecast/runtime state desync (the pinned
// account was selected but no skip reason was recorded) — see #486.
if (isPinned) {
const errorBody = buildPinnedUnavailableErrorBody(
pinnedIndex,
accountSkipReasons,
);
if (errorBody.reason === null) {
status.lastError = `pinned-503 missing skip reason (pinnedIndex=${pinnedIndex})`;
}
await usageRecorder?.record({
outcome: "failure",
statusCode: HTTP_STATUS.SERVICE_UNAVAILABLE,
errorCode: "codex_pinned_account_unavailable",
});
writeJson(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
error: {
message: `Pinned account ${(pinnedIndex ?? 0) + 1} is currently unavailable; run \`codex-multi-auth status\` for details, or \`codex-multi-auth unpin\` to allow rotation.`,
code: "codex_pinned_account_unavailable",
pinnedAccountIndex: pinnedIndex,
},
});
writeJson(res, HTTP_STATUS.SERVICE_UNAVAILABLE, { error: errorBody });
return;
}

Expand Down
155 changes: 155 additions & 0 deletions test/issue-474-pin-end-to-end.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,163 @@ describe("issue #474 — end-to-end pin honored over real HTTP proxy", () => {
expect(thirdResult.bodyText).toContain(
"codex_pinned_account_unavailable",
);
// Issue #486: the 503 body must surface the runtime skip reason so
// users can diagnose why the pin cannot be honored without scraping
// `codex-multi-auth status` logs out-of-band.
const thirdBody = JSON.parse(thirdResult.bodyText) as {
error: {
code: string;
pinnedAccountIndex: number | null;
reason: string | null;
account_skip_reasons: Record<string, string>;
message: string;
};
};
expect(thirdBody.error.reason).toBe("disabled");
expect(thirdBody.error.message).toContain("(disabled)");
expect(thirdBody.error.pinnedAccountIndex).toBe(otherAccountIndex);
expect(
thirdBody.error.account_skip_reasons[String(otherAccountIndex)],
).toBe("disabled");
// No additional upstream call — the proxy refused before issuing one.
expect(upstreamCalls).toHaveLength(2);
},
);

it(
"surfaces 'rate-limited' skip reason in pinned 503 body (issue #486)",
async () => {
const storagePath = makeTmpStoragePath();
const now = Date.now();
const initialStorage = createStorage(now);
const pinnedIndex = 1;
writeStorageFile(storagePath, {
...initialStorage,
pinnedAccountIndex: pinnedIndex,
affinityGeneration: 1,
});
setStoragePathDirect(storagePath);

const accountManager = new AccountManager(undefined, initialStorage);
openManagers.push(accountManager);

const pinned = accountManager.getAccountByIndex(pinnedIndex);
expect(pinned).not.toBeNull();
if (!pinned) throw new Error("setup failed");
// Match the family the proxy will resolve from `model: "gpt-5-codex"`.
// `getModelFamily("gpt-5-codex")` returns "gpt-5-codex", not "codex",
// so the rate-limit must be keyed under that family for the runtime
// skip-reason check to detect it.
accountManager.markRateLimitedWithReason(
pinned,
60_000,
"gpt-5-codex",
"quota",
);

const upstreamCalls: number[] = [];
const fetchImpl: typeof fetch = async (_input, init) => {
const headers = new Headers(init?.headers);
const auth = headers.get("authorization") ?? "";
const token = auth.replace(/^Bearer\s+/i, "");
const index = initialStorage.accounts.findIndex(
(a) => a.accessToken === token,
);
upstreamCalls.push(index);
return new Response(JSON.stringify({ ok: true, account: index }), {
status: HTTP_STATUS.OK,
headers: { "content-type": "application/json" },
});
};

const proxy = await startRuntimeRotationProxy({
accountManager,
fetchImpl,
upstreamBaseUrl: "https://example.test/backend-api",
clientApiKey: CLIENT_API_KEY,
});
openServers.push(proxy);

const result = await postViaHttp(
proxy,
{ model: "gpt-5-codex", stream: false },
"/v1/responses",
);
expect(result.status).toBe(HTTP_STATUS.SERVICE_UNAVAILABLE);
const body = JSON.parse(result.bodyText) as {
error: {
code: string;
reason: string | null;
account_skip_reasons: Record<string, string>;
message: string;
};
};
expect(body.error.code).toBe("codex_pinned_account_unavailable");
expect(body.error.reason).toBe("rate-limited");
expect(body.error.message).toContain("(rate-limited)");
expect(body.error.account_skip_reasons[String(pinnedIndex)]).toBe(
"rate-limited",
);
expect(upstreamCalls).toHaveLength(0);
},
);

it(
"surfaces a cooling-down skip reason in pinned 503 body (issue #486)",
async () => {
const storagePath = makeTmpStoragePath();
const now = Date.now();
const initialStorage = createStorage(now);
const pinnedIndex = 0;
writeStorageFile(storagePath, {
...initialStorage,
pinnedAccountIndex: pinnedIndex,
affinityGeneration: 1,
});
setStoragePathDirect(storagePath);

const accountManager = new AccountManager(undefined, initialStorage);
openManagers.push(accountManager);

const pinned = accountManager.getAccountByIndex(pinnedIndex);
if (!pinned) throw new Error("setup failed");
accountManager.markAccountCoolingDown(pinned, 60_000, "auth-failure");

const upstreamCalls: number[] = [];
const fetchImpl: typeof fetch = async () => {
upstreamCalls.push(-1);
return new Response("{}", { status: HTTP_STATUS.OK });
};

const proxy = await startRuntimeRotationProxy({
accountManager,
fetchImpl,
upstreamBaseUrl: "https://example.test/backend-api",
clientApiKey: CLIENT_API_KEY,
});
openServers.push(proxy);

const result = await postViaHttp(
proxy,
{ model: "gpt-5-codex", stream: false },
"/v1/responses",
);
expect(result.status).toBe(HTTP_STATUS.SERVICE_UNAVAILABLE);
const body = JSON.parse(result.bodyText) as {
error: {
code: string;
reason: string | null;
account_skip_reasons: Record<string, string>;
message: string;
};
};
expect(body.error.code).toBe("codex_pinned_account_unavailable");
expect(body.error.reason).toBe("cooling-down:auth-failure");
expect(body.error.message).toContain("(cooling-down:auth-failure)");
expect(body.error.account_skip_reasons[String(pinnedIndex)]).toBe(
"cooling-down:auth-failure",
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(upstreamCalls).toHaveLength(0);
},
);
Comment on lines +305 to +440

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

add an e2e case for reason: null pinned 503 payloads.

the pr objective includes explicit null when no skip reason is recorded, but this block only covers concrete reasons. please add one 503 test that verifies error.reason === null (and matching account_skip_reasons shape) so the desync path stays locked.

Based on learnings: "Runtime rotation proxy error code 'codex_pinned_account_unavailable' (503) ... must include ... structured reason field ... or null ..."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/issue-474-pin-end-to-end.test.ts` around lines 305 - 440, Add a new e2e
test modeled after the two existing pinned-503 tests that sets up a storage with
pinnedAccountIndex (use
makeTmpStoragePath/createStorage/writeStorageFile/setStoragePathDirect and new
AccountManager) but do NOT call accountManager.markRateLimitedWithReason or
markAccountCoolingDown on the pinned account; start the proxy via
startRuntimeRotationProxy with a fetchImpl that would not be called, POST via
postViaHttp to "/v1/responses" for model "gpt-5-codex" and assert the response
is 503 and that body.error.reason === null and
body.error.account_skip_reasons[String(pinnedIndex)] === null (and verify
upstreamCalls has length 0), mirroring the assertions style used in the other
two tests.

});
Loading