diff --git a/devlog/_fin/260913_model_availability_errors/000_summary.md b/devlog/_fin/260913_model_availability_errors/000_summary.md new file mode 100644 index 0000000000..8499219ec2 --- /dev/null +++ b/devlog/_fin/260913_model_availability_errors/000_summary.md @@ -0,0 +1,22 @@ +# Model availability error classification + +## Problem + +Account-gated native model selection inherited `CodexPoolAuthenticationError`, so every local +compatibility or capacity failure became HTTP 401 `invalid_api_key`. A healthy pool account that +did not support the selected model therefore looked like a broken credential. + +## Change + +- Added typed `unsupported` and `temporarily_unavailable` model-availability reasons. +- Mapped unsupported selections to 400 `invalid_request_error`. +- Mapped temporarily unavailable model-capable pools to 429 `rate_limit_error` with code + `rate_limit_exceeded`. +- Reused the mapping on Responses, Images, Live, and Search surfaces. +- Preserved existing 401 behavior for actual pool credential failures. + +## Verification + +- Focused mapping tests cover 400, 429, and unchanged 401 behavior. +- The existing auth-context regression suite covers account-gated detours, exact selection, + cooldowns, affinity, and reauthentication behavior. diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 45ec0666d6..b3ca477259 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -116,6 +116,11 @@ Use `subagentModels` to choose and order the leading models that Codex also adve choice can expand into multiple selector-qualified catalog rows, so configured choices and advertised rows are not necessarily one-to-one. +If no configured account supports an account-gated native model, the request fails as an invalid +model choice. If supporting accounts exist but are temporarily exhausted or unavailable, it fails +as a retryable rate limit. These states are never reported as an invalid API key; choose another +available model or wait for the capable account's quota window to reopen. + Use `modelPickerOrder` for display-only ordering of routed `/` rows beyond that featured block: diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 12a1dd6e17..32d124b7fd 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -204,6 +204,19 @@ export class CodexPoolAuthenticationError extends Error { } } +export type CodexModelAvailabilityReason = "unsupported" | "temporarily_unavailable"; + +/** A model/account compatibility failure is not a credential failure. */ +export class CodexModelAvailabilityError extends CodexPoolAuthenticationError { + reason: CodexModelAvailabilityReason; + + constructor(reason: CodexModelAvailabilityReason, message: string) { + super(message); + this.name = "CodexModelAvailabilityError"; + this.reason = reason; + } +} + class CodexAccountValidationPendingError extends CodexPoolAuthenticationError { constructor() { super("Codex account validation is pending; refresh quota after recovery to validate it"); @@ -703,7 +716,10 @@ export async function resolveCodexAuthContext( options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel )(headers, options.modelId); if (!entitled) { - throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); + throw new CodexModelAvailabilityError( + "unsupported", + "The selected ChatGPT account does not support this model", + ); } } if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); @@ -737,7 +753,10 @@ export async function resolveCodexAuthContext( options.modelId, )?.has(MAIN_CODEX_ACCOUNT_ID) === true; if (!entitled) { - throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); + throw new CodexModelAvailabilityError( + "unsupported", + "The selected ChatGPT account does not support this model", + ); } } assertMainAccountPolicy(policy); @@ -864,11 +883,13 @@ export async function resolveCodexAuthContext( return await resolveCallerOwnedMainContext(); } if (fixedAccountId !== undefined) { - throw new CodexPoolAuthenticationError( - modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId) - ? "Selected Codex account does not support this model" - : "Selected Codex account is unavailable", - ); + if (modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)) { + throw new CodexModelAvailabilityError( + "unsupported", + "Selected Codex account does not support this model", + ); + } + throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } // Recovery or a turn drain deliberately makes physical main unobservable. // If no healthy pool route is available, report the temporary fence rather @@ -884,13 +905,16 @@ export async function resolveCodexAuthContext( && (!modelEligibleAccountIds || modelEligibleAccountIds.has(MAIN_CODEX_ACCOUNT_ID))) { assertMainAccountPolicy(policy); } - throw new CodexPoolAuthenticationError( - modelEligibleAccountIds === undefined - ? undefined - : entitledAccountIds?.size === 0 && !mainModelGrantUnobserved - ? "No eligible Codex account supports this model" - : "Codex accounts that support this model are currently unavailable", - ); + if (modelEligibleAccountIds !== undefined) { + const unsupported = entitledAccountIds?.size === 0 && !mainModelGrantUnobserved; + throw new CodexModelAvailabilityError( + unsupported ? "unsupported" : "temporarily_unavailable", + unsupported + ? "No eligible Codex account supports this model" + : "Codex accounts that support this model are currently unavailable", + ); + } + throw new CodexPoolAuthenticationError(); } accountId = selected; if (accountId === MAIN_CODEX_ACCOUNT_ID) assertMainAccountPolicy(policy); @@ -909,7 +933,8 @@ export async function resolveCodexAuthContext( // Model entitlement is different: sending the request would spend a turn on an account whose // authenticated roster already denied the model. Reassert this boundary after every selector. if (modelEligibleAccountIds && !modelEligibleAccountIds.has(accountId)) { - throw new CodexPoolAuthenticationError( + throw new CodexModelAvailabilityError( + "unsupported", fixedAccountId !== undefined ? "Selected Codex account does not support this model" : "No eligible Codex account supports this model", diff --git a/src/server/images.ts b/src/server/images.ts index 25ff02f3d4..cf9c4d516f 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -19,6 +19,7 @@ import { cooldownErrorResponse, CodexAuthContextError, CodexMainProfileDrainingError, + CodexModelAvailabilityError, CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, } from "../codex/auth-context"; @@ -49,6 +50,7 @@ import { findXaiProvider, resolveXaiImageAuthToken } from "../images/plan"; import { callXaiImages } from "../images/xai-client"; import type { AdmissionLease } from "../lib/admission"; import { codexAccountSelectionForTurn } from "./lifecycle"; +import { codexModelAvailabilityErrorResponse } from "./responses/codex-auth-error"; export type ImagesEndpoint = "generations" | "edits"; @@ -674,6 +676,8 @@ export async function handleImages( const safeAccountLabel = formatCodexProviderForLog("openai", err.accountId, config); console.error(`[images] Pool account ${safeAccountLabel} token failed; reauthentication required`); forwardAuthError = formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"); + } else if (err instanceof CodexModelAvailabilityError) { + forwardAuthError = codexModelAvailabilityErrorResponse(err); } else if (err instanceof CodexPoolAuthenticationError) { forwardAuthError = formatErrorResponse(401, "authentication_error", err.message); } else { diff --git a/src/server/live.ts b/src/server/live.ts index 15322f225f..eaca5f14ca 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -35,6 +35,7 @@ import { cooldownErrorResponse, CodexAuthContextError, CodexMainProfileDrainingError, + CodexModelAvailabilityError, CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, } from "../codex/auth-context"; @@ -48,6 +49,7 @@ import type { RequestLogContext } from "./request-log"; import { codexLogAccountId } from "./responses"; import type { AdmissionLease } from "../lib/admission"; import { codexAccountSelectionForTurn } from "./lifecycle"; +import { codexModelAvailabilityErrorResponse } from "./responses/codex-auth-error"; /** Voice call create can wait on SDP negotiation; bound a hung upstream. */ const LIVE_UPSTREAM_TIMEOUT_MS = 120_000; @@ -565,6 +567,8 @@ export async function resolveLiveRelay( "authentication_error", "Selected Codex account needs reauthentication", ); + } else if (err instanceof CodexModelAvailabilityError) { + forwardAuthError = codexModelAvailabilityErrorResponse(err); } else if (err instanceof CodexPoolAuthenticationError) { forwardAuthError = formatErrorResponse(401, "authentication_error", err.message); } else { diff --git a/src/server/responses/codex-auth-error.ts b/src/server/responses/codex-auth-error.ts index 8cddd70d6c..12ab9a40fa 100644 --- a/src/server/responses/codex-auth-error.ts +++ b/src/server/responses/codex-auth-error.ts @@ -7,6 +7,7 @@ import { CodexDirectAuthenticationError, CodexMainProfileDrainingError, CodexMainSubstitutionUnavailableError, + CodexModelAvailabilityError, CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, } from "../../codex/auth-context"; @@ -22,6 +23,13 @@ export interface CodexAuthContextErrorResponseOptions { now: number; } +export function codexModelAvailabilityErrorResponse(error: CodexModelAvailabilityError): Response { + if (error.reason === "temporarily_unavailable") { + return formatErrorResponse(429, "rate_limit_error", error.message); + } + return formatErrorResponse(400, "invalid_request_error", error.message); +} + export function nativeMainRefreshFailureResponse(error: unknown): Response { if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth") { return formatErrorResponse(401, "authentication_error", "Codex main account needs reauthentication"); @@ -86,6 +94,9 @@ export function mapCodexAuthContextErrorToResponse( "Selected Codex account needs reauthentication", ); } + if (error instanceof CodexModelAvailabilityError) { + return codexModelAvailabilityErrorResponse(error); + } if (error instanceof CodexPoolAuthenticationError || error instanceof CodexDirectAuthenticationError) { return formatErrorResponse(401, "authentication_error", error.message); } diff --git a/src/server/search.ts b/src/server/search.ts index 808bcd86c6..681c44254d 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -15,6 +15,7 @@ import { cooldownErrorResponse, CodexAuthContextError, CodexMainProfileDrainingError, + CodexModelAvailabilityError, CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, } from "../codex/auth-context"; @@ -39,6 +40,7 @@ import type { RequestLogContext } from "./request-log"; import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; import type { AdmissionLease } from "../lib/admission"; import { codexAccountSelectionForTurn } from "./lifecycle"; +import { codexModelAvailabilityErrorResponse } from "./responses/codex-auth-error"; /** * Default TOTAL deadline for one search relay. alpha/search is non-streaming JSON — response @@ -143,6 +145,7 @@ export async function handleSearch( console.error(`[search] Pool account ${safeAccountLabel} token failed; reauthentication required`); return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"); } + if (err instanceof CodexModelAvailabilityError) return codexModelAvailabilityErrorResponse(err); if (err instanceof CodexPoolAuthenticationError) return formatErrorResponse(401, "authentication_error", err.message); throw err; } diff --git a/structure/runtime.md b/structure/runtime.md index 35b1e1f31a..1feddc26a2 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -43,8 +43,11 @@ there. Feature code is grouped by responsibility: `src/server/` is split by responsibility: `index.ts` owns the listener and route ordering; `responses.ts` owns Responses handling and compaction; `images.ts` owns the standalone Images relay; -`responses/codex-auth-error.ts` owns the shared Responses/compact Codex auth-context HTTP mapping, -while account selection, credential materialization, logging, and transport stay in their existing handlers; +`responses/codex-auth-error.ts` owns the shared Responses/compact Codex auth-context HTTP mapping. +Model entitlement denial is a 400 request error and temporary exhaustion of every model-capable +account is a retryable 429; neither is reported as an invalid API key. Images, Live, and Search +reuse that model-availability mapping while retaining their existing credential handling. Account +selection, credential materialization, logging, and transport stay in their existing handlers; `management-api.ts` owns `/api/*`; `lifecycle.ts`, `request-log.ts`, `relay.ts` (incl. the shared `createSseInspector` SSE inspection factory), `relay-eager.ts` (#314 gated eager bounded passthrough relay), `memory-watchdog.ts` diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 96ef06d3c0..41ca530e79 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -519,7 +519,7 @@ policies; kiro imports the shared abort/sleep helpers from this module. ## Same-provider combo quota fallback -For a failover combo with multiple models on the same Codex-login OpenAI provider, a pre-stream +Native account-gated model selection maps no grant to 400, temporary capable-account exhaustion to 429, and actual credential failures to 401; Images, Live, and Search reuse this distinction. For a failover combo with multiple models on the same Codex-login OpenAI provider, a pre-stream 429/402 carrying only `x-codex-*-reset-at` may advance to the later model on the same account. The failed physical combo target still enters its normal target cooldown. An explicit `Retry-After` remains an account-wide instruction and blocks the later target; a quota response with neither an diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index bca4e30808..c23a1cda2c 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -10,6 +10,7 @@ import { CodexAuthContextError, CodexDirectAuthenticationError, CodexMainProfileDrainingError, + CodexModelAvailabilityError, CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, codexMainProfileDrainingResponse, @@ -676,9 +677,17 @@ describe("Codex auth context", () => { }); await expect(resolve(["gpt-daybreak-blue-latest"])) - .rejects.toThrow("Codex accounts that support this model are currently unavailable"); + .rejects.toMatchObject({ + name: "CodexModelAvailabilityError", + reason: "temporarily_unavailable", + message: "Codex accounts that support this model are currently unavailable", + } satisfies Partial); await expect(resolve(["gpt-5.6-sol"])) - .rejects.toThrow("No eligible Codex account supports this model"); + .rejects.toMatchObject({ + name: "CodexModelAvailabilityError", + reason: "unsupported", + message: "No eligible Codex account supports this model", + } satisfies Partial); const mainExcludedSnapshot: CodexModelEntitlementSnapshot = { modelsByAccount: new Map(), @@ -697,7 +706,10 @@ describe("Codex auth context", () => { expect(options?.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)).toBeTrue(); return mainExcludedSnapshot; }, - })).rejects.toThrow("Codex accounts that support this model are currently unavailable"); + })).rejects.toMatchObject({ + reason: "temporarily_unavailable", + message: "Codex accounts that support this model are currently unavailable", + } satisfies Partial); }); test("auth resolution preserves per-model detours without replacing ordinary affinity", async () => { @@ -784,7 +796,10 @@ describe("Codex auth context", () => { accountId: "pool-a", modelId: "gpt-daybreak-blue-latest", resolveCodexModelEntitlements: async () => entitlementSnapshot, - })).rejects.toThrow("Selected Codex account does not support this model"); + })).rejects.toMatchObject({ + reason: "unsupported", + message: "Selected Codex account does not support this model", + } satisfies Partial); }); test("ordinary native models do not pay the entitlement discovery path", async () => { diff --git a/tests/codex-integration/codex-model-availability-error.test.ts b/tests/codex-integration/codex-model-availability-error.test.ts new file mode 100644 index 0000000000..131899ac31 --- /dev/null +++ b/tests/codex-integration/codex-model-availability-error.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { + CodexModelAvailabilityError, + CodexPoolAuthenticationError, +} from "../../src/codex/auth-context"; +import { + codexModelAvailabilityErrorResponse, + mapCodexAuthContextErrorToResponse, +} from "../../src/server/responses/codex-auth-error"; + +describe("Codex model availability HTTP errors", () => { + test("unsupported model is a request error, not invalid_api_key", async () => { + const error = new CodexModelAvailabilityError( + "unsupported", + "No eligible Codex account supports this model", + ); + expect(error).toBeInstanceOf(CodexPoolAuthenticationError); + + const response = mapCodexAuthContextErrorToResponse(error, { now: Date.now() }); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: { + type: "invalid_request_error", + code: "invalid_request_error", + message: "No eligible Codex account supports this model", + }, + }); + }); + + test("temporarily unavailable model is retryable quota capacity", async () => { + const response = codexModelAvailabilityErrorResponse(new CodexModelAvailabilityError( + "temporarily_unavailable", + "Codex accounts that support this model are currently unavailable", + )); + expect(response.status).toBe(429); + expect(response.headers.has("retry-after")).toBeFalse(); + expect(await response.json()).toEqual({ + error: { + type: "rate_limit_error", + code: "rate_limit_exceeded", + message: "Codex accounts that support this model are currently unavailable", + }, + }); + }); + + test("ordinary pool credential failures retain authentication semantics", async () => { + const response = mapCodexAuthContextErrorToResponse( + new CodexPoolAuthenticationError(), + { now: Date.now() }, + ); + expect(response?.status).toBe(401); + expect(await response?.json()).toMatchObject({ + error: { type: "authentication_error", code: "invalid_api_key" }, + }); + }); +});