diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 688db5a3d..fec8a851d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -7,6 +7,8 @@ - Fixed thinking effort selections being ignored for local Qwen 3.8+ models on llama.cpp and vLLM: the Qwen chat-completions dialects only toggled `enable_thinking`, so the chat template always reasoned at its `xhigh` default no matter which level was selected. The encoder now routes the requested effort onto the template's `reasoning_effort` kwarg (`chat_template_kwargs` for both Qwen dialects, plus the top-level field newer llama.cpp builds map natively). - Fixed OpenAI Completions, Amazon Bedrock, and Cursor providers ignoring `onPayload` replacement payloads. The hook now transforms the actual request body sent upstream on these providers, matching the Anthropic/Gemini/OpenAI Responses replacement contract. `devin-agent` still does not fire the hook (its payload is a protobuf object). +- 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 diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index e9f4f4417..9504a63df 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -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(); 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, diff --git a/packages/ai/src/error/finalize.ts b/packages/ai/src/error/finalize.ts index bfecce6b7..413dcaafe 100644 --- a/packages/ai/src/error/finalize.ts +++ b/packages/ai/src/error/finalize.ts @@ -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, diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 5a786e2d6..b9f9ee289 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -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)) { diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index dfead1a75..723a280ba 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -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, }); diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index c75175e7c..206de1fbc 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -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, + 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, 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( }; 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( !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( 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); diff --git a/packages/ai/test/auth-storage-force-refresh-rotate.test.ts b/packages/ai/test/auth-storage-force-refresh-rotate.test.ts index 8d79f504d..379ecd2d4 100644 --- a/packages/ai/test/auth-storage-force-refresh-rotate.test.ts +++ b/packages/ai/test/auth-storage-force-refresh-rotate.test.ts @@ -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(); diff --git a/packages/ai/test/error-id.test.ts b/packages/ai/test/error-id.test.ts index 8823684e7..75e2b8ff8 100644 --- a/packages/ai/test/error-id.test.ts +++ b/packages/ai/test/error-id.test.ts @@ -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);