export type OpenAICompatibilityFailureCategory = | 'connection_refused' | 'localhost_resolution_failed' | 'request_timeout' | 'network_error' | 'auth_invalid' | 'rate_limited' | 'model_not_found' | 'endpoint_not_found' | 'context_overflow' | 'tool_call_incompatible' | 'malformed_provider_response' | 'provider_unavailable' | 'unknown' export type OpenAICompatibilityFailure = { source: 'network' | 'http' category: OpenAICompatibilityFailureCategory retryable: boolean message: string hint?: string code?: string status?: number } const OPENAI_CATEGORY_MARKER_PREFIX = '[openai_category=' const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']) const OPENAI_COMPATIBILITY_FAILURE_CATEGORIES: ReadonlySet = new Set([ 'connection_refused', 'localhost_resolution_failed', 'request_timeout', 'network_error', 'auth_invalid', 'rate_limited', 'model_not_found', 'endpoint_not_found', 'context_overflow', 'tool_call_incompatible', 'malformed_provider_response', 'provider_unavailable', 'unknown', ]) function isOpenAICompatibilityFailureCategory( value: string, ): value is OpenAICompatibilityFailureCategory { return OPENAI_COMPATIBILITY_FAILURE_CATEGORIES.has( value as OpenAICompatibilityFailureCategory, ) } function getErrorCode(error: unknown): string | undefined { let current: unknown = error const maxDepth = 5 for (let depth = 0; depth < maxDepth; depth++) { if ( current && typeof current === 'object' && 'code' in current && typeof (current as { code?: unknown }).code === 'string' ) { return (current as { code: string }).code } if ( current && typeof current === 'object' && 'cause' in current && (current as { cause?: unknown }).cause !== current ) { current = (current as { cause?: unknown }).cause continue } break } return undefined } function getHostname(url: string): string | null { try { return new URL(url).hostname.toLowerCase() } catch { return null } } function isLocalhostLikeHostname(hostname: string | null): boolean { if (!hostname) return false if (LOCALHOST_HOSTNAMES.has(hostname)) return true return /^127\./.test(hostname) } function isContextOverflowMessage(body: string): boolean { const lower = body.toLowerCase() return ( lower.includes('too many tokens') || lower.includes('request too large') || lower.includes('context length') || lower.includes('maximum context') || lower.includes('input length') || lower.includes('payload too large') || lower.includes('prompt is too long') ) } function isToolCompatibilityMessage(body: string): boolean { const lower = body.toLowerCase() return ( lower.includes('tool_calls') || lower.includes('tool_call') || lower.includes('tool_use') || lower.includes('tool_result') || lower.includes('function calling') || lower.includes('function call') ) } function isMalformedProviderResponse(body: string): boolean { const lower = body.toLowerCase() return ( lower.includes(', ): string { const marker = formatOpenAICategoryMarker(failure.category) const hint = failure.hint ? ` Hint: ${failure.hint}` : '' return `${baseMessage} ${marker}${hint}` } export function classifyOpenAINetworkFailure( error: unknown, options: { url: string }, ): OpenAICompatibilityFailure { const message = error instanceof Error ? error.message : String(error) const lowerMessage = message.toLowerCase() const code = getErrorCode(error) const hostname = getHostname(options.url) const isLocalHost = isLocalhostLikeHostname(hostname) if ( code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || lowerMessage.includes('timeout') || lowerMessage.includes('timed out') || lowerMessage.includes('aborterror') ) { return { source: 'network', category: 'request_timeout', retryable: true, message, code, hint: 'The provider took too long to respond. Check local model load time or increase API timeout.', } } if ( isLocalHost && ( code === 'ENOTFOUND' || code === 'EAI_AGAIN' || lowerMessage.includes('getaddrinfo') || (code === undefined && lowerMessage.includes('fetch failed')) ) ) { return { source: 'network', category: 'localhost_resolution_failed', retryable: true, message, code, hint: 'Localhost failed for this request. Retry with 127.0.0.1 and confirm Ollama is serving on the configured port.', } } if (code === 'ECONNREFUSED') { return { source: 'network', category: 'connection_refused', retryable: true, message, code, hint: isLocalHost ? 'Connection to the local provider was refused. Ensure the local server is running and listening on the configured port.' : 'Connection was refused by the provider endpoint. Ensure the server is running and the port is correct.', } } return { source: 'network', category: 'network_error', retryable: true, message, code, hint: 'Network transport failed before a provider response was received.', } } export function classifyOpenAIHttpFailure(options: { status: number body: string }): OpenAICompatibilityFailure { const body = options.body ?? '' if (options.status === 401 || options.status === 403) { return { source: 'http', category: 'auth_invalid', retryable: false, status: options.status, message: body, hint: 'Authentication failed. Verify API key, token source, and endpoint-specific auth headers.', } } if (options.status === 429) { return { source: 'http', category: 'rate_limited', retryable: true, status: options.status, message: body, hint: 'Provider rate-limited the request. Retry after backoff.', } } if (options.status === 404 && isModelNotFoundMessage(body)) { return { source: 'http', category: 'model_not_found', retryable: false, status: options.status, message: body, hint: 'The selected model is not installed or not available on this endpoint.', } } if (options.status === 404) { return { source: 'http', category: 'endpoint_not_found', retryable: false, status: options.status, message: body, hint: 'Endpoint was not found. Confirm OPENAI_BASE_URL includes /v1 for OpenAI-compatible local providers.', } } if ( options.status === 413 || ((options.status === 400 || options.status >= 500) && isContextOverflowMessage(body)) ) { return { source: 'http', category: 'context_overflow', retryable: false, status: options.status, message: body, hint: 'Prompt context exceeded model/server limits. Reduce context or increase provider context length.', } } if (options.status === 400 && isToolCompatibilityMessage(body)) { return { source: 'http', category: 'tool_call_incompatible', retryable: false, status: options.status, message: body, hint: 'Provider/model rejected tool-calling payload. Retry without tools or use a tool-capable model.', } } if (options.status >= 400 && isMalformedProviderResponse(body)) { return { source: 'http', category: 'malformed_provider_response', retryable: false, status: options.status, message: body, hint: 'Provider returned malformed or non-JSON response where JSON was expected.', } } if (options.status >= 500) { return { source: 'http', category: 'provider_unavailable', retryable: true, status: options.status, message: body, hint: 'Provider reported a server-side failure. Retry after a short delay.', } } return { source: 'http', category: 'unknown', retryable: false, status: options.status, message: body, } }