TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1/**2 * Turns a provider error (`PolyProviderErrorShape` on the wire, `StoredMessageError` on a saved3 * message, or a `ClientApiError`) into user-facing copy and the actions that make sense for it.4 * Pure — safe for unit tests and for both client and server bundles.5 */6export interface ChatErrorLike {7 code?: string | null;8 message?: string | null;9 provider?: string | null;10 status?: number | null;11 retryable?: boolean | null;12 retryAfterMs?: number | null;13 providerCode?: string | null;14 detail?: string | null;15 requestId?: string | null;16}1718export interface HumanizedError {19 /** Short headline, e.g. "OpenAI rate limit reached." */20 title: string;21 /** One sentence of guidance. `{retry}` is already substituted. */22 description: string;23 /** Whether a plain "Retry" is sensible. */24 canRetry: boolean;25 /** Whether "Switch model" is the most helpful action. */26 suggestSwitch: boolean;27 /** Whether the fix lives in Settings → Providers (invalid key, no credit). */28 suggestProviders: boolean;29 /** Countdown to show next to Retry (ms), when the provider told us to wait. */30 retryAfterMs: number | null;31 details: { code: string; providerCode: string | null; status: number | null; requestId: string | null; raw: string | null; provider: string | null };32}3334const KNOWN: Record<string, { title: (p: string) => string; description: (p: string) => string; canRetry: boolean; suggestSwitch: boolean; suggestProviders?: boolean }> = {35 RATE_LIMITED: { title: (p) => `${p} rate limit reached.`, description: () => "Your key is sending requests faster than the provider allows. {retry}", canRetry: true, suggestSwitch: true },36 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 },37 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 },38 PROVIDER_UNAVAILABLE: { title: (p) => `${p} is having an outage.`, description: () => "The provider returned a server error. {retry}", canRetry: true, suggestSwitch: true },39 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 },40 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 },41 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 },42 REQUEST_TIMEOUT: { title: (p) => `${p} timed out.`, description: () => "The provider took too long to answer. {retry}", canRetry: true, suggestSwitch: true },43 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 },44 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 },45 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 },46 CANCELLED: { title: () => "Generation stopped.", description: () => "You stopped the answer. Retry to generate it again.", canRetry: true, suggestSwitch: false },47 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 },48 UNKNOWN_PROVIDER_ERROR: { title: (p) => `${p} returned an unexpected error.`, description: () => "{retry} If it keeps failing, try another model.", canRetry: true, suggestSwitch: true },49};5051/** "Retry in 18 seconds." / "Retry in a moment." */52export function retryPhrase(retryAfterMs: number | null | undefined, capitalize = true): string {53 const s = retryAfterMs && retryAfterMs > 0 ? Math.ceil(retryAfterMs / 1000) : null;54 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"}.`;55 return capitalize ? base.charAt(0).toUpperCase() + base.slice(1) : base;56}5758export function humanizeChatError(err: ChatErrorLike | null | undefined, opts: { providerName?: string; requestId?: string | null } = {}): HumanizedError {59 const code = (err?.code ?? "UNKNOWN_PROVIDER_ERROR").toUpperCase();60 const provider = opts.providerName ?? prettyProvider(err?.provider) ?? "The provider";61 const spec = KNOWN[code] ?? KNOWN.UNKNOWN_PROVIDER_ERROR;62 const retryAfterMs = typeof err?.retryAfterMs === "number" && err.retryAfterMs > 0 ? Math.min(err.retryAfterMs, 10 * 60_000) : null;63 const description = spec.description(provider).replace("{retry}", retryPhrase(retryAfterMs)).replace("{retryLower}", retryPhrase(retryAfterMs, false));64 return {65 title: spec.title(provider),66 description,67 canRetry: spec.canRetry || err?.retryable === true,68 suggestSwitch: spec.suggestSwitch,69 suggestProviders: Boolean(spec.suggestProviders),70 retryAfterMs,71 details: {72 code,73 providerCode: err?.providerCode ?? null,74 status: typeof err?.status === "number" ? err.status : null,75 requestId: opts.requestId ?? err?.requestId ?? null,76 raw: err?.detail ?? (err?.message && !KNOWN[code] ? err.message : null) ?? null,77 provider: err?.provider ?? null,78 },79 };80}8182const PROVIDER_NAMES: Record<string, string> = { openai: "OpenAI", anthropic: "Anthropic", gemini: "Google Gemini", xai: "xAI", mistral: "Mistral", deepseek: "DeepSeek", kimi: "Kimi", openrouter: "OpenRouter", cerebras: "Cerebras" };8384function prettyProvider(p: string | null | undefined): string | null {85 if (!p) return null;86 return PROVIDER_NAMES[p] ?? p.charAt(0).toUpperCase() + p.slice(1);87}8889/** Seconds left for a live countdown; null when no wait was requested or it has elapsed. */90export function secondsLeft(retryAfterMs: number | null, since: number, now: number): number | null {91 if (!retryAfterMs) return null;92 const left = Math.ceil((since + retryAfterMs - now) / 1000);93 return left > 0 ? left : null;94}95