/** * Turns a provider error (`PolyProviderErrorShape` on the wire, `StoredMessageError` on a saved * message, or a `ClientApiError`) into user-facing copy and the actions that make sense for it. * Pure — safe for unit tests and for both client and server bundles. */ export interface ChatErrorLike { code?: string | null; message?: string | null; provider?: string | null; status?: number | null; retryable?: boolean | null; retryAfterMs?: number | null; providerCode?: string | null; detail?: string | null; requestId?: string | null; } export interface HumanizedError { /** Short headline, e.g. "OpenAI rate limit reached." */ title: string; /** One sentence of guidance. `{retry}` is already substituted. */ description: string; /** Whether a plain "Retry" is sensible. */ canRetry: boolean; /** Whether "Switch model" is the most helpful action. */ suggestSwitch: boolean; /** Whether the fix lives in Settings → Providers (invalid key, no credit). */ suggestProviders: boolean; /** Countdown to show next to Retry (ms), when the provider told us to wait. */ retryAfterMs: number | null; details: { code: string; providerCode: string | null; status: number | null; requestId: string | null; raw: string | null; provider: string | null }; } const KNOWN: Record string; description: (p: string) => string; canRetry: boolean; suggestSwitch: boolean; suggestProviders?: boolean }> = { RATE_LIMITED: { title: (p) => `${p} rate limit reached.`, description: () => "Your key is sending requests faster than the provider allows. {retry}", canRetry: true, suggestSwitch: true }, INVALID_API_KEY: { title: (p) => `${p} rejected your API key.`, description: () => "Check the key in Settings → Providers, then try again.", canRetry: false, suggestSwitch: true, suggestProviders: true }, PERMISSION_DENIED: { title: (p) => `${p} denied access to this model.`, description: () => "This key isn't allowed to use that model or feature. Pick another model or check your provider account.", canRetry: false, suggestSwitch: true, suggestProviders: true }, PROVIDER_UNAVAILABLE: { title: (p) => `${p} is having an outage.`, description: () => "The provider returned a server error. {retry}", canRetry: true, suggestSwitch: true }, CONTEXT_TOO_LONG: { title: () => "Context window exceeded.", description: () => "This conversation is longer than the model can read. Start a new chat, summarize the context or switch to a larger-context model.", canRetry: false, suggestSwitch: true }, INVALID_PARAMETER: { title: () => "A setting isn't supported by this model.", description: () => "Reset the model configuration (temperature, response format, tools…) and try again.", canRetry: true, suggestSwitch: false }, INSUFFICIENT_CREDITS: { title: (p) => `${p} quota exhausted.`, description: () => "Your provider account has no remaining credit or quota. Top up, or switch to a model from another provider.", canRetry: false, suggestSwitch: true, suggestProviders: true }, REQUEST_TIMEOUT: { title: (p) => `${p} timed out.`, description: () => "The provider took too long to answer. {retry}", canRetry: true, suggestSwitch: true }, NETWORK_ERROR: { title: (p) => `Couldn't reach ${p}.`, description: () => "PolyLLM couldn't connect to the provider. Check your connection, then {retryLower}", canRetry: true, suggestSwitch: false }, MODEL_NOT_FOUND: { title: () => "Model unavailable.", description: () => "This model isn't available for your account or was renamed. Refresh the model list or pick another one.", canRetry: false, suggestSwitch: true }, CONTENT_REJECTED: { title: (p) => `${p} declined this request.`, description: () => "The provider's content policy blocked the answer. Rephrase, or try another model.", canRetry: false, suggestSwitch: true }, CANCELLED: { title: () => "Generation stopped.", description: () => "You stopped the answer. Retry to generate it again.", canRetry: true, suggestSwitch: false }, NO_PROVIDER_KEY: { title: (p) => `No API key for ${p}.`, description: () => "Add a key in Settings → Providers to use this model.", canRetry: false, suggestSwitch: true, suggestProviders: true }, UNKNOWN_PROVIDER_ERROR: { title: (p) => `${p} returned an unexpected error.`, description: () => "{retry} If it keeps failing, try another model.", canRetry: true, suggestSwitch: true }, }; /** "Retry in 18 seconds." / "Retry in a moment." */ export function retryPhrase(retryAfterMs: number | null | undefined, capitalize = true): string { const s = retryAfterMs && retryAfterMs > 0 ? Math.ceil(retryAfterMs / 1000) : null; const base = s === null ? "retry in a moment." : s === 1 ? "retry in 1 second." : s < 60 ? `retry in ${s} seconds.` : `retry in about ${Math.round(s / 60)} minute${Math.round(s / 60) === 1 ? "" : "s"}.`; return capitalize ? base.charAt(0).toUpperCase() + base.slice(1) : base; } export function humanizeChatError(err: ChatErrorLike | null | undefined, opts: { providerName?: string; requestId?: string | null } = {}): HumanizedError { const code = (err?.code ?? "UNKNOWN_PROVIDER_ERROR").toUpperCase(); const provider = opts.providerName ?? prettyProvider(err?.provider) ?? "The provider"; const spec = KNOWN[code] ?? KNOWN.UNKNOWN_PROVIDER_ERROR; const retryAfterMs = typeof err?.retryAfterMs === "number" && err.retryAfterMs > 0 ? Math.min(err.retryAfterMs, 10 * 60_000) : null; const description = spec.description(provider).replace("{retry}", retryPhrase(retryAfterMs)).replace("{retryLower}", retryPhrase(retryAfterMs, false)); return { title: spec.title(provider), description, canRetry: spec.canRetry || err?.retryable === true, suggestSwitch: spec.suggestSwitch, suggestProviders: Boolean(spec.suggestProviders), retryAfterMs, details: { code, providerCode: err?.providerCode ?? null, status: typeof err?.status === "number" ? err.status : null, requestId: opts.requestId ?? err?.requestId ?? null, raw: err?.detail ?? (err?.message && !KNOWN[code] ? err.message : null) ?? null, provider: err?.provider ?? null, }, }; } const PROVIDER_NAMES: Record = { openai: "OpenAI", anthropic: "Anthropic", gemini: "Google Gemini", xai: "xAI", mistral: "Mistral", deepseek: "DeepSeek", kimi: "Kimi", openrouter: "OpenRouter", cerebras: "Cerebras" }; function prettyProvider(p: string | null | undefined): string | null { if (!p) return null; return PROVIDER_NAMES[p] ?? p.charAt(0).toUpperCase() + p.slice(1); } /** Seconds left for a live countdown; null when no wait was requested or it has elapsed. */ export function secondsLeft(retryAfterMs: number | null, since: number, now: number): number | null { if (!retryAfterMs) return null; const left = Math.ceil((since + retryAfterMs - now) / 1000); return left > 0 ? left : null; }