Merge PR #8756: fix(auth): rotate when a ChatGPT account lacks the requested Codex model (@alphastorm)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user