refactor: enhance error handling and normalization across AI engines

This update introduces a centralized error handling mechanism for various AI engines, improving the consistency and clarity of error messages. The new `normalizeEngineError` function standardizes error responses, allowing for better user feedback and recovery suggestions. Additionally, specific error classes for insufficient credits, rate limits, and service availability have been implemented, along with user-friendly formatting for error messages. This refactor aims to enhance the overall user experience when interacting with the AI services.
This commit is contained in:
di-sukharev
2026-01-17 23:34:49 +03:00
parent 8b0ee25923
commit 5b241ed2d0
16 changed files with 1081 additions and 412 deletions

View File

@@ -0,0 +1,205 @@
import axios from 'axios';
import {
AuthenticationError,
InsufficientCreditsError,
ModelNotFoundError,
RateLimitError,
ServiceUnavailableError
} from './errors';
/**
* Extracts HTTP status code from various error types
*/
function getStatusCode(error: unknown): number | null {
// Direct status property (common in API SDKs)
if (typeof (error as any)?.status === 'number') {
return (error as any).status;
}
// Axios-style errors
if (axios.isAxiosError(error)) {
return error.response?.status ?? null;
}
// Response object with status
if (typeof (error as any)?.response?.status === 'number') {
return (error as any).response.status;
}
return null;
}
/**
* Extracts retry-after value from error headers (for rate limiting)
*/
function getRetryAfter(error: unknown): number | undefined {
const headers = (error as any)?.response?.headers;
if (headers) {
const retryAfter = headers['retry-after'] || headers['Retry-After'];
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) {
return seconds;
}
}
}
return undefined;
}
/**
* Extracts the error message from various error structures
*/
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
// API error response structures
const apiError = (error as any)?.response?.data?.error;
if (apiError) {
if (typeof apiError === 'string') {
return apiError;
}
if (apiError.message) {
return apiError.message;
}
}
// Direct error data
const errorData = (error as any)?.error;
if (errorData) {
if (typeof errorData === 'string') {
return errorData;
}
if (errorData.message) {
return errorData.message;
}
}
// Fallback
if (typeof error === 'string') {
return error;
}
return 'An unknown error occurred';
}
/**
* Checks if the error message indicates a model not found error
*/
function isModelNotFoundMessage(message: string): boolean {
const lowerMessage = message.toLowerCase();
return (
(lowerMessage.includes('model') &&
(lowerMessage.includes('not found') ||
lowerMessage.includes('does not exist') ||
lowerMessage.includes('invalid') ||
lowerMessage.includes('pull'))) ||
lowerMessage.includes('does_not_exist')
);
}
/**
* Checks if the error message indicates insufficient credits
*/
function isInsufficientCreditsMessage(message: string): boolean {
const lowerMessage = message.toLowerCase();
return (
lowerMessage.includes('insufficient') ||
lowerMessage.includes('credit') ||
lowerMessage.includes('quota') ||
lowerMessage.includes('balance too low') ||
lowerMessage.includes('billing') ||
lowerMessage.includes('payment required') ||
lowerMessage.includes('exceeded')
);
}
/**
* Normalizes raw API errors into typed error classes.
* This provides consistent error handling across all engine implementations.
*
* @param error - The raw error from the API call
* @param provider - The AI provider name (e.g., 'openai', 'anthropic')
* @param model - The model being used
* @returns A typed Error instance
*/
export function normalizeEngineError(
error: unknown,
provider: string,
model: string
): Error {
// If it's already one of our custom errors, return as-is
if (
error instanceof ModelNotFoundError ||
error instanceof AuthenticationError ||
error instanceof InsufficientCreditsError ||
error instanceof RateLimitError ||
error instanceof ServiceUnavailableError
) {
return error;
}
const statusCode = getStatusCode(error);
const message = extractErrorMessage(error);
// Handle based on HTTP status codes
switch (statusCode) {
case 401:
return new AuthenticationError(provider, message);
case 402:
return new InsufficientCreditsError(provider, message);
case 404:
// Could be model not found or endpoint not found
if (isModelNotFoundMessage(message)) {
return new ModelNotFoundError(model, provider, 404);
}
// Return generic error for other 404s
return error instanceof Error ? error : new Error(message);
case 429:
const retryAfter = getRetryAfter(error);
return new RateLimitError(provider, retryAfter, message);
case 500:
case 502:
case 503:
case 504:
return new ServiceUnavailableError(provider, statusCode, message);
}
// Handle based on error message content
if (isModelNotFoundMessage(message)) {
return new ModelNotFoundError(model, provider, 404);
}
if (isInsufficientCreditsMessage(message)) {
return new InsufficientCreditsError(provider, message);
}
// Check for rate limit patterns in message
const lowerMessage = message.toLowerCase();
if (
lowerMessage.includes('rate limit') ||
lowerMessage.includes('rate_limit') ||
lowerMessage.includes('too many requests')
) {
return new RateLimitError(provider, undefined, message);
}
// Check for auth patterns in message
if (
lowerMessage.includes('unauthorized') ||
lowerMessage.includes('api key') ||
lowerMessage.includes('apikey') ||
lowerMessage.includes('authentication') ||
lowerMessage.includes('invalid_api_key')
) {
return new AuthenticationError(provider, message);
}
// Return original error or wrap in Error if needed
return error instanceof Error ? error : new Error(message);
}

View File

@@ -1,5 +1,71 @@
import chalk from 'chalk';
import { MODEL_LIST, OCO_AI_PROVIDER_ENUM } from '../commands/config';
// Provider billing/help URLs for common errors
export const PROVIDER_BILLING_URLS: Record<string, string | null> = {
[OCO_AI_PROVIDER_ENUM.ANTHROPIC]: 'https://console.anthropic.com/settings/plans',
[OCO_AI_PROVIDER_ENUM.OPENAI]: 'https://platform.openai.com/settings/organization/billing',
[OCO_AI_PROVIDER_ENUM.GEMINI]: 'https://aistudio.google.com/app/plan',
[OCO_AI_PROVIDER_ENUM.GROQ]: 'https://console.groq.com/settings/billing',
[OCO_AI_PROVIDER_ENUM.MISTRAL]: 'https://console.mistral.ai/billing/',
[OCO_AI_PROVIDER_ENUM.DEEPSEEK]: 'https://platform.deepseek.com/usage',
[OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'https://openrouter.ai/credits',
[OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'https://aimlapi.com/app/billing',
[OCO_AI_PROVIDER_ENUM.AZURE]: 'https://portal.azure.com/#view/Microsoft_Azure_CostManagement',
[OCO_AI_PROVIDER_ENUM.OLLAMA]: null,
[OCO_AI_PROVIDER_ENUM.MLX]: null,
[OCO_AI_PROVIDER_ENUM.FLOWISE]: null,
[OCO_AI_PROVIDER_ENUM.TEST]: null
};
// Error type for insufficient credits/quota
export class InsufficientCreditsError extends Error {
public readonly provider: string;
constructor(provider: string, message?: string) {
super(message || `Insufficient credits or quota for provider '${provider}'`);
this.name = 'InsufficientCreditsError';
this.provider = provider;
}
}
// Error type for rate limiting (429 errors)
export class RateLimitError extends Error {
public readonly provider: string;
public readonly retryAfter?: number;
constructor(provider: string, retryAfter?: number, message?: string) {
super(message || `Rate limit exceeded for provider '${provider}'`);
this.name = 'RateLimitError';
this.provider = provider;
this.retryAfter = retryAfter;
}
}
// Error type for service unavailable (5xx errors)
export class ServiceUnavailableError extends Error {
public readonly provider: string;
public readonly statusCode: number;
constructor(provider: string, statusCode: number = 503, message?: string) {
super(message || `Service unavailable for provider '${provider}'`);
this.name = 'ServiceUnavailableError';
this.provider = provider;
this.statusCode = statusCode;
}
}
// Error type for authentication failures
export class AuthenticationError extends Error {
public readonly provider: string;
constructor(provider: string, message?: string) {
super(message || `Authentication failed for provider '${provider}'`);
this.name = 'AuthenticationError';
this.provider = provider;
}
}
export class ModelNotFoundError extends Error {
public readonly modelName: string;
public readonly provider: string;
@@ -164,3 +230,242 @@ export function formatErrorWithRecovery(
return message;
}
// Detect insufficient credits/quota errors from various providers
export function isInsufficientCreditsError(error: unknown): boolean {
if (error instanceof InsufficientCreditsError) {
return true;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
// Common patterns for insufficient credits/quota
if (
message.includes('insufficient') ||
message.includes('credit') ||
message.includes('quota') ||
message.includes('balance') ||
message.includes('billing') ||
message.includes('payment') ||
message.includes('exceeded') ||
message.includes('limit reached') ||
message.includes('no remaining')
) {
return true;
}
// Check for 402 Payment Required status
if ('status' in (error as any) && (error as any).status === 402) {
return true;
}
if ('response' in (error as any)) {
const response = (error as any).response;
if (response?.status === 402) {
return true;
}
}
}
return false;
}
// Detect rate limit errors (429)
export function isRateLimitError(error: unknown): boolean {
if (error instanceof RateLimitError) {
return true;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
// Common patterns for rate limiting
if (
message.includes('rate limit') ||
message.includes('rate_limit') ||
message.includes('too many requests') ||
message.includes('throttle')
) {
return true;
}
// Check for 429 status
if ('status' in (error as any) && (error as any).status === 429) {
return true;
}
if ('response' in (error as any)) {
const response = (error as any).response;
if (response?.status === 429) {
return true;
}
}
}
return false;
}
// Detect service unavailable errors (5xx)
export function isServiceUnavailableError(error: unknown): boolean {
if (error instanceof ServiceUnavailableError) {
return true;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
// Common patterns for service unavailable
if (
message.includes('service unavailable') ||
message.includes('server error') ||
message.includes('internal error') ||
message.includes('temporarily unavailable') ||
message.includes('overloaded')
) {
return true;
}
// Check for 5xx status
const status = (error as any).status || (error as any).response?.status;
if (status && status >= 500 && status < 600) {
return true;
}
}
return false;
}
// User-friendly formatted error structure
export interface FormattedError {
title: string;
message: string;
helpUrl: string | null;
suggestion: string | null;
}
// Format an error into a user-friendly structure
export function formatUserFriendlyError(error: unknown, provider: string): FormattedError {
const billingUrl = PROVIDER_BILLING_URLS[provider] || null;
// Handle our custom error types first
if (error instanceof InsufficientCreditsError) {
return {
title: 'Insufficient Credits',
message: `Your ${provider} account has insufficient credits or quota.`,
helpUrl: billingUrl,
suggestion: 'Add credits to your account to continue using the service.'
};
}
if (error instanceof RateLimitError) {
const retryMsg = error.retryAfter
? `Please wait ${error.retryAfter} seconds before retrying.`
: 'Please wait a moment before retrying.';
return {
title: 'Rate Limit Exceeded',
message: `You've made too many requests to ${provider}.`,
helpUrl: billingUrl,
suggestion: retryMsg
};
}
if (error instanceof ServiceUnavailableError) {
return {
title: 'Service Unavailable',
message: `The ${provider} service is temporarily unavailable.`,
helpUrl: null,
suggestion: 'Please try again in a few moments.'
};
}
if (error instanceof AuthenticationError) {
return {
title: 'Authentication Failed',
message: `Your ${provider} API key is invalid or expired.`,
helpUrl: billingUrl,
suggestion: 'Run `oco setup` to configure a valid API key.'
};
}
if (error instanceof ModelNotFoundError) {
return {
title: 'Model Not Found',
message: `The model '${error.modelName}' is not available for ${provider}.`,
helpUrl: null,
suggestion: 'Run `oco setup` to select a valid model.'
};
}
// Detect error type from raw errors
if (isInsufficientCreditsError(error)) {
return {
title: 'Insufficient Credits',
message: `Your ${provider} account has insufficient credits or quota.`,
helpUrl: billingUrl,
suggestion: 'Add credits to your account to continue using the service.'
};
}
if (isRateLimitError(error)) {
return {
title: 'Rate Limit Exceeded',
message: `You've made too many requests to ${provider}.`,
helpUrl: billingUrl,
suggestion: 'Please wait a moment before retrying.'
};
}
if (isServiceUnavailableError(error)) {
return {
title: 'Service Unavailable',
message: `The ${provider} service is temporarily unavailable.`,
helpUrl: null,
suggestion: 'Please try again in a few moments.'
};
}
if (isApiKeyError(error)) {
return {
title: 'Authentication Failed',
message: `Your ${provider} API key is invalid or expired.`,
helpUrl: billingUrl,
suggestion: 'Run `oco setup` to configure a valid API key.'
};
}
if (isModelNotFoundError(error)) {
const model = (error as any).modelName || (error as any).model || 'unknown';
return {
title: 'Model Not Found',
message: `The model '${model}' is not available for ${provider}.`,
helpUrl: null,
suggestion: 'Run `oco setup` to select a valid model.'
};
}
// Default: generic error
const errorMessage = error instanceof Error ? error.message : String(error);
return {
title: 'Error',
message: errorMessage,
helpUrl: null,
suggestion: 'Run `oco setup` to reconfigure or check your settings.'
};
}
// Print a formatted error as a chalk-styled string
export function printFormattedError(formatted: FormattedError): string {
let output = `\n${chalk.red('✖')} ${chalk.bold.red(formatted.title)}\n`;
output += ` ${formatted.message}\n`;
if (formatted.helpUrl) {
output += `\n ${chalk.cyan('Help:')} ${chalk.underline(formatted.helpUrl)}\n`;
}
if (formatted.suggestion) {
output += `\n ${chalk.yellow('Suggestion:')} ${formatted.suggestion}\n`;
}
return output;
}