SPB Git

spb/chat-spboucher Public

Private universal chat interface over the OpenRouter ecosystem — 400+ models, branching, streaming, usage tracking. Next.js 16 + SQLite, PWA, deployed on m4m64a at chat.spboucher.ai

TypeScript 78.8% CSS 15.1% JavaScript 4.9% Shell 1.2%
4.1 KB · 147 lines typescript
Raw Blame History
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45export type AIErrorCode =6  | "AUTHENTICATION_ERROR"7  | "RATE_LIMIT"8  | "MODEL_UNAVAILABLE"9  | "PROVIDER_UNAVAILABLE"10  | "CONTEXT_TOO_LARGE"11  | "INVALID_REQUEST"12  | "CONTENT_REJECTED"13  | "TIMEOUT"14  | "NETWORK_ERROR"15  | "UNKNOWN";1617export interface NormalizedAIError {18  code: AIErrorCode;19  message: string;20  retryable: boolean;21  modelId?: string;22  status?: number;23}2425export class AIError extends Error implements NormalizedAIError {26  code: AIErrorCode;27  retryable: boolean;28  modelId?: string;29  status?: number;3031  constructor(err: NormalizedAIError) {32    super(err.message);33    this.name = "AIError";34    this.code = err.code;35    this.retryable = err.retryable;36    this.modelId = err.modelId;37    this.status = err.status;38  }39}4041/** Map an HTTP status + upstream message to a normalized error. Never leaks internals. */42export function normalizeHttpError(status: number, upstreamMessage: string, modelId?: string): AIError {43  const msg = (upstreamMessage || "").slice(0, 500);44  switch (status) {45    case 401:46    case 403:47      return new AIError({48        code: "AUTHENTICATION_ERROR",49        message: "The gateway rejected the server credentials. Check the OpenRouter key.",50        retryable: false,51        modelId,52        status,53      });54    case 402:55      return new AIError({56        code: "AUTHENTICATION_ERROR",57        message: "OpenRouter account has insufficient credits.",58        retryable: false,59        modelId,60        status,61      });62    case 404:63      return new AIError({64        code: "MODEL_UNAVAILABLE",65        message: "This model is not available right now. Pick another model.",66        retryable: false,67        modelId,68        status,69      });70    case 408:71      return new AIError({ code: "TIMEOUT", message: "The model timed out. Try again.", retryable: true, modelId, status });72    case 413:73      return new AIError({74        code: "CONTEXT_TOO_LARGE",75        message: "The conversation exceeds this model's context window. Trim it or switch to a larger-context model.",76        retryable: false,77        modelId,78        status,79      });80    case 429:81      return new AIError({82        code: "RATE_LIMIT",83        message: "Rate limited upstream. Wait a moment and retry.",84        retryable: true,85        modelId,86        status,87      });88    case 502:89    case 503:90      return new AIError({91        code: "PROVIDER_UNAVAILABLE",92        message: "The upstream provider is unavailable. Retry, or switch models.",93        retryable: true,94        modelId,95        status,96      });97    default:98      if (status >= 500) {99        return new AIError({100          code: "PROVIDER_UNAVAILABLE",101          message: "Upstream failure. Retry, or switch models.",102          retryable: true,103          modelId,104          status,105        });106      }107      if (/moderation|flagged|content policy/i.test(msg)) {108        return new AIError({109          code: "CONTENT_REJECTED",110          message: "The provider rejected this content.",111          retryable: false,112          modelId,113          status,114        });115      }116      if (/context length|maximum context|too many tokens/i.test(msg)) {117        return new AIError({118          code: "CONTEXT_TOO_LARGE",119          message: "The conversation exceeds this model's context window. Trim it or switch to a larger-context model.",120          retryable: false,121          modelId,122          status,123        });124      }125      return new AIError({126        code: "INVALID_REQUEST",127        message: msg || "The request was rejected.",128        retryable: false,129        modelId,130        status,131      });132  }133}134135export function normalizeNetworkError(err: unknown, modelId?: string): AIError {136  if (err instanceof AIError) return err;137  if (err instanceof Error && err.name === "AbortError") {138    return new AIError({ code: "TIMEOUT", message: "The request was cancelled.", retryable: false, modelId });139  }140  return new AIError({141    code: "NETWORK_ERROR",142    message: "Could not reach the model gateway. Check connectivity and retry.",143    retryable: true,144    modelId,145  });146}147