import { isAccountDeactivated, isCreditsExhausted, isDailyQuotaExhausted, isOAuthInvalidToken, } from "./accountFallback.ts"; import { getProviderCategory } from "../config/providerRegistry.ts"; export function isEmptyContentResponse(responseBody: unknown): boolean { if (!responseBody || typeof responseBody !== "object") return false; const body = responseBody as Record; if (Array.isArray(body.choices)) { const firstChoice = body.choices[0] as Record | undefined; if (!firstChoice) return true; const message = firstChoice.message as Record | undefined; const delta = firstChoice.delta as Record | undefined; const content = message?.content ?? delta?.content; const reasoningContent = message?.reasoning_content ?? delta?.reasoning_content; const hasToolCalls = (Array.isArray(message?.tool_calls) && (message.tool_calls as unknown[]).length > 0) || (Array.isArray(delta?.tool_calls) && (delta.tool_calls as unknown[]).length > 0); const hasContent = content !== null && content !== undefined && content !== ""; const hasReasoning = reasoningContent !== null && reasoningContent !== undefined && reasoningContent !== ""; return !hasContent && !hasReasoning && !hasToolCalls; } if (Array.isArray(body.content)) { return body.content.length === 0; } if (typeof body.text === "string") { return body.text.trim() === ""; } if ("content" in body) { const content = body.content; return content === null || content === undefined || content === ""; } return false; } export const PROVIDER_ERROR_TYPES = { RATE_LIMITED: "rate_limited", UNAUTHORIZED: "unauthorized", ACCOUNT_DEACTIVATED: "account_deactivated", FORBIDDEN: "forbidden", SERVER_ERROR: "server_error", QUOTA_EXHAUSTED: "quota_exhausted", PROJECT_ROUTE_ERROR: "project_route_error", CONTEXT_OVERFLOW: "context_overflow", OAUTH_INVALID_TOKEN: "oauth_invalid_token", EMPTY_CONTENT: "empty_content", }; export const CONTEXT_OVERFLOW_SIGNALS = [ "context overflow", "prompt too large", "context window", "maximum context", "exceeds context", "input too long", "token limit", "too many tokens", "context length", "exceed.*context", "messages exceed", ]; export const CONTEXT_OVERFLOW_REGEX = new RegExp(CONTEXT_OVERFLOW_SIGNALS.join("|"), "i"); export function isContextOverflow(errorText: string): boolean { return CONTEXT_OVERFLOW_REGEX.test(String(errorText || "")); } function responseBodyToString(responseBody: unknown): string { if (typeof responseBody === "string") return responseBody; if (responseBody !== null && typeof responseBody === "object") { try { return JSON.stringify(responseBody); } catch { return ""; } } return ""; } function shouldPreserveQuotaSignalsFor429(provider?: string | null): boolean { if (!provider) return true; return getProviderCategory(provider) === "oauth"; } export function classifyProviderError( statusCode: number, responseBody: unknown, provider?: string | null ): string | null { const bodyStr = responseBodyToString(responseBody); const creditsExhausted = isCreditsExhausted(bodyStr); const accountDeactivated = isAccountDeactivated(bodyStr); const oauthInvalid = isOAuthInvalidToken(bodyStr); const preserveQuota429 = shouldPreserveQuotaSignalsFor429(provider); if (creditsExhausted && [400, 402, 403].includes(statusCode)) { return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; } if (creditsExhausted && statusCode === 429 && preserveQuota429) { return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; } // API-key providers route 429 cooldowns through the resilience-aware fallback layer. // OAuth providers keep their existing quota semantics because some of them encode // longer quota windows as 429 responses. if (statusCode === 429) { if (preserveQuota429 && isDailyQuotaExhausted(bodyStr)) { return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; } return PROVIDER_ERROR_TYPES.RATE_LIMITED; } if (statusCode === 401) { if (oauthInvalid) { return PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN; } return accountDeactivated ? PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED : PROVIDER_ERROR_TYPES.UNAUTHORIZED; } if (statusCode === 402) return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; if (statusCode === 403 && accountDeactivated) { return PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED; } if (statusCode === 403) { if (bodyStr.includes("has not been used in project")) { return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR; } if (provider && getProviderCategory(provider) === "apikey") { return null; } return PROVIDER_ERROR_TYPES.FORBIDDEN; } if (statusCode >= 500) return PROVIDER_ERROR_TYPES.SERVER_ERROR; if (statusCode === 400 && isContextOverflow(bodyStr)) { return PROVIDER_ERROR_TYPES.CONTEXT_OVERFLOW; } return null; }