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
46 changes: 46 additions & 0 deletions devlog/_plan/260913_model_availability_errors/000_summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Model availability error classification

Carried from #4460 (AgenticLab-SH) as the tip of lane B in the contributor carry
train. This unit stays in `_plan` until the carry lands on `dev`; a `_fin` record
describes work already visible in public git history, which this is not yet.

## 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.

## Catch-order audit

`CodexModelAvailabilityError` extends `CodexPoolAuthenticationError`, so any `catch` that
tests the parent first would fold the new 400 and 429 back into 401. Every such site was
audited during the carry:

- `src/server/responses/codex-auth-error.ts`, `images.ts`, `live.ts` and `search.ts` test the
subclass before the parent. That order is the contract.
- `src/server/context-history.ts` folds the parent straight to 401 and was left unchanged. It
resolves with `modelId: "context_history"`, and every `CodexModelAvailabilityError` throw
site is gated on `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` membership — directly, or through
`modelEligibleAccountIds`, which is only populated for a gated model. The subclass therefore
cannot reach that catch. `tests/codex-integration/codex-model-availability-error.test.ts`
pins the membership that keeps this true.
- `src/server/responses/encrypted-payload.ts` and `collaboration.ts` import the parent but
never branch on it.

Whether the context-history surface should adopt the same mapping outright is a maintainer
decision recorded on #4460, not a defect in this carry.
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
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@
"codex-management-convergence.test.ts": "codex-integration",
"codex-metadata-integrity.test.ts": "codex-integration",
"codex-model-entitlements.test.ts": "codex-integration",
"codex-model-availability-error.test.ts": "codex-integration",
"codex-models-cache-invalidate.test.ts": "codex-integration",
"codex-native-residue.test.ts": "codex-integration",
"codex-plan.test.ts": "codex-integration",
Expand Down
19 changes: 13 additions & 6 deletions src/adapters/run-turn-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ export const PREFLIGHT_HEARTBEAT_RETAIN_LIMIT = 16;
export const COALESCE_MAX_CHUNK_LENGTH = 64 * 1024;

export interface AdapterEventQueue {
push(event: AdapterEvent): void;
/**
* Returns true when the event was merged into the buffered tail instead of
* becoming its own retained item. A caller that charges a memory budget for
* what the queue holds needs that distinction: a merged delta costs only its
* appended payload, while a new item costs a whole serialized event.
*/
push(event: AdapterEvent): boolean;
close(): void;
stream(): AsyncIterable<AdapterEvent>;
collect(): Promise<AdapterEvent[]>;
Expand Down Expand Up @@ -98,21 +104,22 @@ export function createAdapterEventQueue(opts?: {
return false;
};

const push = (event: AdapterEvent): void => {
if (closed) return;
const push = (event: AdapterEvent): boolean => {
if (closed) return false;
const reader = readers.shift();
if (reader) {
reader({ done: false, value: event });
return;
return false;
}
if (coalesceIntoTail(event)) return;
if (coalesceIntoTail(event)) return true;
if (queued.length >= maxBacklog) {
opts?.onBacklogExceeded?.();
queued.push({ type: "error", message: "consumer stalled: adapter event backlog exceeded — turn aborted" });
close();
return;
return false;
}
queued.push(event);
return false;
};

const close = (): void => {
Expand Down
4 changes: 3 additions & 1 deletion src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1293,7 +1293,9 @@ export function bridgeToResponsesSSE(
if (isTruncatedStopReason(event.stopReason)) failCurrentToolCall();
else closeCurrentToolCall();
}
if (currentWebSearch) closeCurrentWebSearch("completed", []);
// A search still in flight when upstream truncates never returned results, so it
// takes the same "failed" status as the error/incomplete terminals below.
if (currentWebSearch) closeCurrentWebSearch(isTruncatedStopReason(event.stopReason) ? "failed" : "completed", []);
releasePendingWebSources();
// Redacted-only turns (or hidden thinking without a trailing signature event) still
// need their envelope-only reasoning item so the blocks replay next turn.
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",
Comment on lines +909 to +911

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 Preserve unknown entitlement states when classifying availability

When every model-roster lookup is unconfirmed—for example, because /models timed out—or credential snapshots return null, entitledAccountIds is empty even though no account has actually denied the model. This condition therefore labels the result unsupported, causing the new mapper to return a non-retryable 400; in the missing/failed-credential case it also violates the intended unchanged 401 authentication behavior. Classify unsupported only when the relevant account rosters are confirmed denials, and allow unknown or credential-failure states to retain their transient/authentication response.

Useful? React with 👍 / 👎.

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
Loading
Loading