Skip to content
Closed
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
22 changes: 22 additions & 0 deletions devlog/_fin/260913_model_availability_errors/000_summary.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/model-ordering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<provider>/<model>` rows beyond that
featured block:

Expand Down
55 changes: 40 additions & 15 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/server/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
cooldownErrorResponse,
CodexAuthContextError,
CodexMainProfileDrainingError,
CodexModelAvailabilityError,
CodexPoolAuthenticationError,
CodexThreadAffinityExpiredError,
} from "../codex/auth-context";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/server/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
cooldownErrorResponse,
CodexAuthContextError,
CodexMainProfileDrainingError,
CodexModelAvailabilityError,
CodexPoolAuthenticationError,
CodexThreadAffinityExpiredError,
} from "../codex/auth-context";
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions src/server/responses/codex-auth-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CodexDirectAuthenticationError,
CodexMainProfileDrainingError,
CodexMainSubstitutionUnavailableError,
CodexModelAvailabilityError,
CodexPoolAuthenticationError,
CodexThreadAffinityExpiredError,
} from "../../codex/auth-context";
Expand All @@ -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");
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 3 additions & 0 deletions src/server/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
cooldownErrorResponse,
CodexAuthContextError,
CodexMainProfileDrainingError,
CodexModelAvailabilityError,
CodexPoolAuthenticationError,
CodexThreadAffinityExpiredError,
} from "../codex/auth-context";
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 5 additions & 2 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions tests/codex-integration/codex-auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
CodexAuthContextError,
CodexDirectAuthenticationError,
CodexMainProfileDrainingError,
CodexModelAvailabilityError,
CodexPoolAuthenticationError,
CodexThreadAffinityExpiredError,
codexMainProfileDrainingResponse,
Expand Down Expand Up @@ -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<CodexModelAvailabilityError>);
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<CodexModelAvailabilityError>);

const mainExcludedSnapshot: CodexModelEntitlementSnapshot = {
modelsByAccount: new Map(),
Expand All @@ -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<CodexModelAvailabilityError>);
});

test("auth resolution preserves per-model detours without replacing ordinary affinity", async () => {
Expand Down Expand Up @@ -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<CodexModelAvailabilityError>);
});

test("ordinary native models do not pay the entitlement discovery path", async () => {
Expand Down
56 changes: 56 additions & 0 deletions tests/codex-integration/codex-model-availability-error.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
});
});
Loading