fix(auth): rotate when a ChatGPT account lacks the requested Codex model

A Codex request to a model the signed-in ChatGPT account is not entitled
to fails with "The '<model>' model is not supported when using Codex with
a ChatGPT account." That was classified as a plain provider error, so the
request failed outright even when a sibling account was signed in and
entitled to the model.

Classify that exact denial as an account-policy error, the same category
`cyber_policy` already uses, so the existing credential-rotation path can
reach an entitled account.

The match is deliberately narrow: it fires only for provider
`openai-codex`, only when the denied model in the message is the model that
was requested, and only for a bounded, non-null model identity. A denial
naming some other model does not trigger rotation, so an unrelated mention
cannot burn sibling credentials.
This commit is contained in:
Sunil Srivatsa
2026-08-16 12:49:16 -07:00
parent 644ad30d6e
commit f294d1b177
8 changed files with 320 additions and 18 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Codex requests failing outright when the signed-in ChatGPT account is not entitled to the requested model; the exact model denial is now classified as an account-policy error so credential rotation can reach an entitled sibling account
## [17.3.7] - 2026-08-17
### Changed
+56 -9
View File
@@ -1026,6 +1026,28 @@ function resolveOpenAICodexPlanRequirement(provider: string, modelId: string | u
return "none";
}
const MODEL_ACCOUNT_POLICY_BLOCK_SCOPE_PREFIX = "model-policy:";
function modelAccountPolicyBlockScope(provider: string, modelId: string | undefined): string | undefined {
if (provider !== "openai-codex" || typeof modelId !== "string") return undefined;
const separator = modelId.lastIndexOf("/");
const bareModelId = (separator === -1 ? modelId : modelId.slice(separator + 1)).trim().toLowerCase();
if (!bareModelId || bareModelId.includes("\0")) return undefined;
return `${MODEL_ACCOUNT_POLICY_BLOCK_SCOPE_PREFIX}${bareModelId}`;
}
function credentialBlockScopesForRequest(
provider: string,
strategy: CredentialRankingStrategy | undefined,
rankingContext: CredentialRankingContext,
blockScope: string | undefined,
): readonly string[] {
const scopes = strategy?.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
const modelPolicyScope = modelAccountPolicyBlockScope(provider, rankingContext.modelId);
if (!modelPolicyScope || scopes.includes(modelPolicyScope)) return scopes;
return [...scopes, modelPolicyScope];
}
function getUsagePlanType(report: UsageReport | null): string | undefined {
const metadata = report?.metadata;
if (!metadata) return undefined;
@@ -2169,7 +2191,7 @@ export class AuthStorage {
const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
const blockScope = strategy.blockScope?.(rankingContext);
const blockScopes = strategy.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
const blockScopes = credentialBlockScopesForRequest(provider, strategy, rankingContext, blockScope);
const candidates = await this.#rankApiKeySelections({
providerKey,
provider,
@@ -3911,7 +3933,7 @@ export class AuthStorage {
const planRequirement = resolveOpenAICodexPlanRequirement(provider, options.modelId);
const planEligibilityByCredential = new Map<number, boolean | undefined>();
const blockScope = strategy.blockScope?.(rankingContext);
const blockScopes = strategy.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
const blockScopes = credentialBlockScopesForRequest(provider, strategy, rankingContext, blockScope);
const reserveFraction = Number.isFinite(options.reserveFraction)
? Math.max(0, Math.min(1, options.reserveFraction))
: 0;
@@ -4378,17 +4400,24 @@ export class AuthStorage {
provider: string,
credentialType: AuthCredential["type"],
modelId: string | undefined,
blockScopeOverride?: string,
): CredentialBlockRouting {
const providerKey = this.#getProviderTypeKey(provider, credentialType);
const strategy = this.#rankingStrategyResolver?.(provider);
const rankingContext: CredentialRankingContext = { modelId };
const blockScope = strategy?.blockScope?.(rankingContext);
const defaultBlockScope = strategy?.blockScope?.(rankingContext);
const blockScope = blockScopeOverride ?? defaultBlockScope;
const requestBlockScopes = credentialBlockScopesForRequest(provider, strategy, rankingContext, defaultBlockScope);
const siblingBlockScopes =
blockScopeOverride && !requestBlockScopes.includes(blockScopeOverride)
? [...requestBlockScopes, blockScopeOverride]
: requestBlockScopes;
return {
providerKey,
strategy,
rankingContext,
blockScope,
siblingBlockScopes: strategy?.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []),
siblingBlockScopes,
};
}
@@ -4755,7 +4784,7 @@ export class AuthStorage {
const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
const blockScope = strategy?.blockScope?.(rankingContext);
// Reads honour every scope that applies; the scalar above is for args that persist.
const blockScopes = strategy?.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
const blockScopes = credentialBlockScopesForRequest(provider, strategy, rankingContext, blockScope);
const planRequirement = resolveOpenAICodexPlanRequirement(provider, options?.modelId);
const hasPlanRequirement = planRequirement !== "none";
const checkUsage = strategy !== undefined && (credentials.length > 1 || hasPlanRequirement);
@@ -6205,8 +6234,10 @@ export class AuthStorage {
* - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
* (temporary block via its own backoff — default plus server usage-report
* reset; sticky left intact so the next resolve re-ranks around the block).
* - account-scoped policy denial → temporarily block that account without
* marking its credential suspect, then rotate through eligible siblings.
* - exact Codex model-entitlement denial → temporarily block only that
* requested model after provider/model identity matches, then rotate.
* - other account-scoped policy denial → temporarily block that account
* without marking its credential suspect, then rotate through siblings.
* - otherwise (hard 401 / auth failure) → mark the credential suspect (or
* reload when no broker hook is wired) and block it, then drop matching
* sticky state.
@@ -6243,8 +6274,24 @@ export class AuthStorage {
});
if (!sessionCredential) return false;
if (AIError.isAccountPolicyError(error)) {
const routing = this.#credentialBlockRouting(provider, sessionCredential.type, options?.modelId);
const deniedModel = AIError.codexChatGPTAccountPolicyModel(error);
const exactCodexModelPolicy =
deniedModel !== undefined && AIError.isCodexChatGPTAccountPolicyError(error, provider, options?.modelId);
// The exact sentence is provider-controlled input. A non-Codex provider,
// absent request model, or mismatched model must not turn it into either a
// global block or a hard-auth invalidation.
if (deniedModel !== undefined && !exactCodexModelPolicy) return false;
if (exactCodexModelPolicy || AIError.isAccountPolicyError(error)) {
const modelPolicyScope = exactCodexModelPolicy
? modelAccountPolicyBlockScope(provider, options?.modelId)
: undefined;
if (exactCodexModelPolicy && modelPolicyScope === undefined) return false;
const routing = this.#credentialBlockRouting(
provider,
sessionCredential.type,
options?.modelId,
modelPolicyScope,
);
return this.#blockCredentialForRotation(
provider,
sessionCredential.type,
+4
View File
@@ -10,6 +10,8 @@ export interface FinalizeOptions {
api?: Api;
/** Provider id; forwarded to the message formatter for copilot rewrites. */
provider?: string;
/** Requested model id; paired with provider for model-entitlement classification. */
model?: string;
/** Caller signal, for providers that don't run an abort tracker. */
signal?: AbortSignal;
/** Abort tracker, preferred over `signal`: distinguishes caller vs. local aborts. */
@@ -55,6 +57,8 @@ export async function finalize(error: unknown, opts: FinalizeOptions = {}): Prom
const id = classifyMessage({
api: opts.api,
provider: opts.provider,
model: opts.model,
errorId: classify(error, opts.api),
errorMessage: message,
errorStatus: currentStatus,
+76 -3
View File
@@ -115,6 +115,36 @@ const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reaso
const EMPTY_RESPONSE_PATTERN = /\bthought-only response without final output\b/i;
const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
const ACCOUNT_POLICY_PATTERN = /\bcyber_policy\b|trusted access for cyber/i;
const CODEX_CHATGPT_ACCOUNT_MODEL_POLICY_PATTERN =
/\bThe ['"]([^'"\r\n]+)['"] model is not supported when using Codex with a ChatGPT account\./i;
const CODEX_CHATGPT_ACCOUNT_MODEL_MAX_LENGTH = 256;
function normalizeCodexChatGPTAccountPolicyModel(modelId: string | undefined): string | undefined {
if (typeof modelId !== "string") return undefined;
const separator = modelId.lastIndexOf("/");
const bareModelId = (separator === -1 ? modelId : modelId.slice(separator + 1)).trim().toLowerCase();
if (!bareModelId || bareModelId.length > CODEX_CHATGPT_ACCOUNT_MODEL_MAX_LENGTH || bareModelId.includes("\0")) {
return undefined;
}
return bareModelId;
}
function codexChatGPTAccountPolicyModelFromText(text: string): string | undefined {
const modelId = CODEX_CHATGPT_ACCOUNT_MODEL_POLICY_PATTERN.exec(text)?.[1]?.trim();
return normalizeCodexChatGPTAccountPolicyModel(modelId) === undefined ? undefined : modelId;
}
function isCodexChatGPTAccountPolicyText(
text: string,
provider: string | undefined,
modelId: string | undefined,
): boolean {
if (provider !== "openai-codex") return false;
const deniedModel = codexChatGPTAccountPolicyModelFromText(text);
const deniedIdentity = normalizeCodexChatGPTAccountPolicyModel(deniedModel);
const requestedIdentity = normalizeCodexChatGPTAccountPolicyModel(modelId);
return deniedIdentity !== undefined && deniedIdentity === requestedIdentity;
}
const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
/**
@@ -344,7 +374,13 @@ function matchesOverflowText(text: string): boolean {
return OVERFLOW_PATTERNS.some(p => p.test(text)) || OVERFLOW_NO_BODY_PATTERN.test(text);
}
function classifyText(errorMessage: string | undefined, errorStatus: number | undefined, api?: Api): number {
function classifyText(
errorMessage: string | undefined,
errorStatus: number | undefined,
api?: Api,
provider?: string,
modelId?: string,
): number {
let kinds = 0;
if (errorMessage) {
if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
@@ -352,7 +388,12 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
if (EMPTY_RESPONSE_PATTERN.test(errorMessage)) kinds |= Flag.EmptyResponse | Flag.Transient;
if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
if (ACCOUNT_POLICY_PATTERN.test(errorMessage)) kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
if (
ACCOUNT_POLICY_PATTERN.test(errorMessage) ||
isCodexChatGPTAccountPolicyText(errorMessage, provider, modelId)
) {
kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
}
if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined);
@@ -503,6 +544,36 @@ export function isAccountPolicyError(error: unknown, api?: Api): boolean {
return is(classify(error, api), Flag.AccountPolicy);
}
/**
* Model id from Codex's exact ChatGPT-account entitlement denial. Generic
* unsupported-model invalid requests deliberately do not match.
*/
export function codexChatGPTAccountPolicyModel(error: unknown, depth = 0): string | undefined {
if (depth > 6) return undefined;
if (typeof error === "string") return codexChatGPTAccountPolicyModelFromText(error);
if (!error || typeof error !== "object") return undefined;
const errorMessage =
"errorMessage" in error && typeof error.errorMessage === "string" ? error.errorMessage : undefined;
const message = "message" in error && typeof error.message === "string" ? error.message : undefined;
const direct =
(errorMessage ? codexChatGPTAccountPolicyModelFromText(errorMessage) : undefined) ??
(message ? codexChatGPTAccountPolicyModelFromText(message) : undefined);
if (direct !== undefined) return direct;
return "cause" in error ? codexChatGPTAccountPolicyModel(error.cause, depth + 1) : undefined;
}
/** Whether the exact Codex entitlement denial applies to this provider and requested model. */
export function isCodexChatGPTAccountPolicyError(
error: unknown,
provider: string,
modelId: string | undefined,
): boolean {
const deniedModel = codexChatGPTAccountPolicyModel(error);
const deniedIdentity = normalizeCodexChatGPTAccountPolicyModel(deniedModel);
const requestedIdentity = normalizeCodexChatGPTAccountPolicyModel(modelId);
return provider === "openai-codex" && deniedIdentity !== undefined && deniedIdentity === requestedIdentity;
}
/**
* Strict-tool rejection: grammar too large, schema too complex, or structured
* outputs unsupported by the model/endpoint.
@@ -556,13 +627,15 @@ export function isCopilotTransientModelError(error: unknown): boolean {
export function classifyMessage(message: {
api?: Api;
provider?: string;
model?: string;
errorId?: number;
errorMessage?: string;
errorStatus?: number;
}): number {
const existingId = message.errorId;
const currentStatus = message.errorStatus ?? statusFromId(existingId);
const textId = classifyText(message.errorMessage, currentStatus, message.api);
const textId = classifyText(message.errorMessage, currentStatus, message.api, message.provider, message.model);
let kinds = ((existingId ?? 0) | textId) & KIND_MASK;
if (message.errorMessage && LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(message.errorMessage)) {
@@ -1975,6 +1975,8 @@ async function handleCodexStreamFailure(context: CodexStreamFailureContext, erro
}
const result = await AIError.finalize(error, {
api: context.model.api,
provider: context.model.provider,
model: context.model.id,
signal: context.options?.signal,
rawRequestDump: context.requestContext.rawRequestDump,
});
+31 -6
View File
@@ -1085,12 +1085,18 @@ function extractStatusFromAssistantError(message: AssistantMessage): number | un
return AIError.status({ message: message.errorMessage });
}
function isRetryableUpstreamError(error: unknown, status: number | undefined, message: string | undefined): boolean {
function isRetryableUpstreamError(
model: Model<Api>,
error: unknown,
status: number | undefined,
message: string | undefined,
): boolean {
// 401 means the credential is bad; 403 is its valid-token twin (access
// denied by plan, model policy, or org restriction — a sibling account may
// not share it). Explicit account-scoped policy errors such as Codex
// `cyber_policy` are likewise rotatable: another account may carry the
// required approval. Usage-limit phrasing (Codex's
// `cyber_policy` are likewise rotatable. The exact ChatGPT-account model
// denial is rotatable only when its provider and requested model match.
// Usage-limit phrasing (Codex's
// "You have hit your ChatGPT usage limit", Anthropic's "usage_limit_reached",
// Google's "resource_exhausted", OpenAI's "insufficient_quota") and 429s
// without transient rate-limit wording mean this account is parked but a
@@ -1100,6 +1106,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
// credential block. Transient 429s ("Too many requests", per-minute caps)
// classify as RATE_LIMIT_EXCEEDED in `parseRateLimitReason` and stay in the
// provider's own backoff layer instead of burning siblings.
if (AIError.isCodexChatGPTAccountPolicyError(error, model.provider, model.id)) return true;
if (AIError.isAccountPolicyError(error)) return true;
if (AIError.isUsageLimit(error)) return true;
if (isInvalidatedOAuthTokenError(error)) return true;
@@ -1117,6 +1124,17 @@ function createAssistantAuthError(message: AssistantMessage): Error {
return typeof message.errorId === "number" ? AIError.attach(error, message.errorId) : error;
}
function contextualizeAuthRetryError(model: Model<Api>, error: unknown): unknown {
if (
!error ||
typeof error !== "object" ||
!AIError.isCodexChatGPTAccountPolicyError(error, model.provider, model.id)
) {
return error;
}
return AIError.attach(error, AIError.create(AIError.Flag.AccountPolicy | AIError.Flag.ContentBlocked));
}
function emitBufferedEvents(stream: AssistantMessageEventStream, events: AssistantMessageEvent[]): void {
for (const event of events) {
stream.push(event);
@@ -1438,7 +1456,8 @@ function streamSimpleRequest<TApi extends Api>(
};
try {
const inner = streamSimpleRequest(model, context, { ...requestOptions, apiKey });
const attemptOptions = { ...requestOptions, apiKey };
const inner = streamSimpleRequest(model, context, attemptOptions);
for await (const event of inner) {
if (!emittedReplayUnsafeEvent && event.type === "start") {
bufferedEvents.push(event);
@@ -1448,12 +1467,17 @@ function streamSimpleRequest<TApi extends Api>(
!emittedReplayUnsafeEvent &&
event.type === "error" &&
isRetryableUpstreamError(
model,
event.error,
extractStatusFromAssistantError(event.error),
event.error.errorMessage,
)
) {
return { error: createAssistantAuthError(event.error), bufferedEvents, terminalEvent: event };
return {
error: contextualizeAuthRetryError(model, createAssistantAuthError(event.error)),
bufferedEvents,
terminalEvent: event,
};
}
flushBuffered();
emittedReplayUnsafeEvent = true;
@@ -1466,12 +1490,13 @@ function streamSimpleRequest<TApi extends Api>(
if (
!emittedReplayUnsafeEvent &&
isRetryableUpstreamError(
model,
error,
AIError.status(error),
error instanceof Error ? error.message : undefined,
)
) {
return { error, bufferedEvents };
return { error: contextualizeAuthRetryError(model, error), bufferedEvents };
}
flushBuffered();
outer.fail(error);
@@ -5,13 +5,19 @@ import * as path from "node:path";
import { withAuth } from "@oh-my-pi/pi-ai";
import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage";
import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth";
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth";
import type { OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types";
import type { CredentialRankingStrategy, UsageProvider } from "@oh-my-pi/pi-ai/usage";
import { removeWithRetries } from "../../utils/src/temp";
const PROVIDER = "unit-rotate-oauth";
const SOURCE = "auth-storage-force-refresh-rotate-test";
const CODEX_PROVIDER = "openai-codex";
const DAYBREAK_MODEL = "gpt-daybreak-blue-latest";
const CODEX_CHATGPT_MODEL_DENIAL =
"The 'gpt-daybreak-blue-latest' model is not supported when using Codex with a ChatGPT account. (code=invalid_request_error)";
function farExpiry(): number {
return Date.now() + 60 * 60_000;
}
@@ -527,6 +533,86 @@ describe("AuthStorage forceRefresh + rotateSessionCredential", () => {
expect(await authStorage.getApiKey(PROVIDER, "cyber-policy")).not.toBe(first);
});
test("Codex ChatGPT model denial blocks only that model and rotates to a sibling", async () => {
if (!store) throw new Error("test setup failed");
const codexStorage = new AuthStorage(store, { usageProviderResolver: () => undefined });
vi.spyOn(oauthUtils, "getOAuthApiKey").mockImplementation(async (_provider, credentials) => {
const credential = credentials[CODEX_PROVIDER] as OAuthCredentials | undefined;
if (!credential) return null;
return { apiKey: credential.access, newCredentials: credential };
});
await codexStorage.set(CODEX_PROVIDER, [
{
type: "oauth",
access: "daybreak-denied",
refresh: "ref-A",
expires: farExpiry(),
accountId: "account-A",
},
{
type: "oauth",
access: "daybreak-sibling",
refresh: "ref-B",
expires: farExpiry(),
accountId: "account-B",
},
]);
const sessionId = "daybreak-model-policy";
const first = await codexStorage.getApiKey(CODEX_PROVIDER, sessionId, { modelId: DAYBREAK_MODEL });
expect(first).toBe("daybreak-denied");
const denial = new ProviderHttpError(CODEX_CHATGPT_MODEL_DENIAL, 400, {
code: "invalid_request_error",
});
expect(
await codexStorage.rotateSessionCredential(CODEX_PROVIDER, sessionId, {
error: denial,
apiKey: first,
}),
).toBe(false);
expect(
await codexStorage.rotateSessionCredential(CODEX_PROVIDER, sessionId, {
error: denial,
modelId: "gpt-5.3-codex",
apiKey: first,
}),
).toBe(false);
expect(await codexStorage.getApiKey(CODEX_PROVIDER, sessionId, { modelId: DAYBREAK_MODEL })).toBe(first);
const usageLimitSpy = vi.spyOn(codexStorage, "markUsageLimitReached");
const rotated = await codexStorage.rotateSessionCredential(CODEX_PROVIDER, sessionId, {
error: denial,
modelId: DAYBREAK_MODEL,
apiKey: first,
});
expect(rotated).toBe(true);
expect(usageLimitSpy).not.toHaveBeenCalled();
expect(await codexStorage.getApiKey(CODEX_PROVIDER, sessionId, { modelId: DAYBREAK_MODEL })).toBe(
"daybreak-sibling",
);
const deniedRow = store
.listAuthCredentials(CODEX_PROVIDER)
.find(row => row.credential.type === "oauth" && row.credential.access === "daybreak-denied");
if (!deniedRow) throw new Error("denied credential row missing");
const modelBlock = store.getCredentialBlock?.(
deniedRow.id,
`${CODEX_PROVIDER}:oauth`,
"model-policy:gpt-daybreak-blue-latest",
);
expect(typeof modelBlock).toBe("number");
expect(store.getCredentialBlock?.(deniedRow.id, `${CODEX_PROVIDER}:oauth`, "chat")).toBeUndefined();
expect(store.getCredentialBlock?.(deniedRow.id, `${CODEX_PROVIDER}:oauth`, "")).toBeUndefined();
const otherModelStorage = new AuthStorage(store, { usageProviderResolver: () => undefined });
await otherModelStorage.reload();
expect(
await otherModelStorage.getApiKey(CODEX_PROVIDER, "other-codex-model", {
modelId: "gpt-5.3-codex",
}),
).toBe("daybreak-denied");
});
test("rotateSessionCredential treats structured usage codes as quota blocks despite generic messages", async () => {
if (!authStorage) throw new Error("test setup failed");
registerProvider();
+61
View File
@@ -88,6 +88,67 @@ describe("error-id classification", () => {
expect(AIError.retriable(id)).toBe(false);
});
it("classifies only the matching Codex ChatGPT-account model entitlement denial as account policy", () => {
const errorMessage =
"The 'gpt-daybreak-blue-latest' model is not supported when using Codex with a ChatGPT account. (code=invalid_request_error)";
const denial = message({
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-daybreak-blue-latest",
errorStatus: 400,
errorMessage,
});
const denialId = AIError.classifyMessage(denial);
expect(AIError.is(denialId, AIError.Flag.AccountPolicy)).toBe(true);
expect(AIError.is(denialId, AIError.Flag.ContentBlocked)).toBe(true);
expect(AIError.retriable(denialId)).toBe(false);
expect(AIError.codexChatGPTAccountPolicyModel(denial)).toBe("gpt-daybreak-blue-latest");
expect(AIError.isCodexChatGPTAccountPolicyError(denial, denial.provider, denial.model)).toBe(true);
for (const mismatch of [
message({
api: "openai-codex-responses",
provider: "openrouter",
model: "gpt-daybreak-blue-latest",
errorStatus: 400,
errorMessage,
}),
message({
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.3-codex",
errorStatus: 400,
errorMessage,
}),
]) {
const mismatchId = AIError.classifyMessage(mismatch);
expect(AIError.is(mismatchId, AIError.Flag.AccountPolicy)).toBe(false);
expect(AIError.isCodexChatGPTAccountPolicyError(mismatch, mismatch.provider, mismatch.model)).toBe(false);
}
const genericUnsupported = message({
api: "openai-codex-responses",
provider: "openai-codex",
model: "some-unsupported-model",
errorStatus: 400,
errorMessage: "The 'some-unsupported-model' model is not supported. (code=invalid_request_error)",
});
const genericId = AIError.classifyMessage(genericUnsupported);
expect(AIError.is(genericId, AIError.Flag.AccountPolicy)).toBe(false);
expect(AIError.codexChatGPTAccountPolicyModel(genericUnsupported)).toBeUndefined();
const oversizedModel = "m".repeat(257);
const oversized = message({
api: "openai-codex-responses",
provider: "openai-codex",
model: oversizedModel,
errorStatus: 400,
errorMessage: `The '${oversizedModel}' model is not supported when using Codex with a ChatGPT account.`,
});
expect(AIError.codexChatGPTAccountPolicyModel(oversized)).toBeUndefined();
expect(AIError.is(AIError.classifyMessage(oversized), AIError.Flag.AccountPolicy)).toBe(false);
});
it("keeps raw status fallback unclassified", () => {
const id = 503;
expect(AIError.is(id, AIError.Flag.Class)).toBe(false);