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%
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