// Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: chat.spboucher.ai export type AIErrorCode = | "AUTHENTICATION_ERROR" | "RATE_LIMIT" | "MODEL_UNAVAILABLE" | "PROVIDER_UNAVAILABLE" | "CONTEXT_TOO_LARGE" | "INVALID_REQUEST" | "CONTENT_REJECTED" | "TIMEOUT" | "NETWORK_ERROR" | "UNKNOWN"; export interface NormalizedAIError { code: AIErrorCode; message: string; retryable: boolean; modelId?: string; status?: number; } export class AIError extends Error implements NormalizedAIError { code: AIErrorCode; retryable: boolean; modelId?: string; status?: number; constructor(err: NormalizedAIError) { super(err.message); this.name = "AIError"; this.code = err.code; this.retryable = err.retryable; this.modelId = err.modelId; this.status = err.status; } } /** Map an HTTP status + upstream message to a normalized error. Never leaks internals. */ export function normalizeHttpError(status: number, upstreamMessage: string, modelId?: string): AIError { const msg = (upstreamMessage || "").slice(0, 500); switch (status) { case 401: case 403: return new AIError({ code: "AUTHENTICATION_ERROR", message: "The gateway rejected the server credentials. Check the OpenRouter key.", retryable: false, modelId, status, }); case 402: return new AIError({ code: "AUTHENTICATION_ERROR", message: "OpenRouter account has insufficient credits.", retryable: false, modelId, status, }); case 404: return new AIError({ code: "MODEL_UNAVAILABLE", message: "This model is not available right now. Pick another model.", retryable: false, modelId, status, }); case 408: return new AIError({ code: "TIMEOUT", message: "The model timed out. Try again.", retryable: true, modelId, status }); case 413: return new AIError({ code: "CONTEXT_TOO_LARGE", message: "The conversation exceeds this model's context window. Trim it or switch to a larger-context model.", retryable: false, modelId, status, }); case 429: return new AIError({ code: "RATE_LIMIT", message: "Rate limited upstream. Wait a moment and retry.", retryable: true, modelId, status, }); case 502: case 503: return new AIError({ code: "PROVIDER_UNAVAILABLE", message: "The upstream provider is unavailable. Retry, or switch models.", retryable: true, modelId, status, }); default: if (status >= 500) { return new AIError({ code: "PROVIDER_UNAVAILABLE", message: "Upstream failure. Retry, or switch models.", retryable: true, modelId, status, }); } if (/moderation|flagged|content policy/i.test(msg)) { return new AIError({ code: "CONTENT_REJECTED", message: "The provider rejected this content.", retryable: false, modelId, status, }); } if (/context length|maximum context|too many tokens/i.test(msg)) { return new AIError({ code: "CONTEXT_TOO_LARGE", message: "The conversation exceeds this model's context window. Trim it or switch to a larger-context model.", retryable: false, modelId, status, }); } return new AIError({ code: "INVALID_REQUEST", message: msg || "The request was rejected.", retryable: false, modelId, status, }); } } export function normalizeNetworkError(err: unknown, modelId?: string): AIError { if (err instanceof AIError) return err; if (err instanceof Error && err.name === "AbortError") { return new AIError({ code: "TIMEOUT", message: "The request was cancelled.", retryable: false, modelId }); } return new AIError({ code: "NETWORK_ERROR", message: "Could not reach the model gateway. Check connectivity and retry.", retryable: true, modelId, }); }