SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
6.7 KB · 95 lines typescript
Raw Blame History
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