mirror of
https://github.com/di-sukharev/opencommit.git
synced 2026-04-20 03:02:51 -04:00
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:
205
src/utils/engineErrorHandler.ts
Normal file
205
src/utils/engineErrorHandler.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user