diff --git a/src/services/api/errors.openaiCompatibility.test.ts b/src/services/api/errors.openaiCompatibility.test.ts index e79f7b59..566df93f 100644 --- a/src/services/api/errors.openaiCompatibility.test.ts +++ b/src/services/api/errors.openaiCompatibility.test.ts @@ -28,6 +28,38 @@ test('maps endpoint_not_found category markers to actionable setup guidance', () expect(text).toContain('/v1') }) +test('endpoint_not_found from a remote host shows the actual host, not Ollama (issue #926)', () => { + const error = APIError.generate( + 404, + undefined, + 'OpenAI API error 404: Not Found [openai_category=endpoint_not_found,host=integrate.api.nvidia.com] Hint: Endpoint at integrate.api.nvidia.com returned 404.', + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'moonshotai/kimi-k2.5-thinking') + const text = getFirstText(message) + + expect(text).toContain('integrate.api.nvidia.com') + expect(text).toContain('moonshotai/kimi-k2.5-thinking') + expect(text).not.toContain('Ollama') + expect(text).not.toContain('11434') +}) + +test('endpoint_not_found without a host falls back to the Ollama-aware message', () => { + const error = APIError.generate( + 404, + undefined, + 'OpenAI API error 404: Not Found [openai_category=endpoint_not_found] Hint: Confirm OPENAI_BASE_URL includes /v1.', + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'qwen2.5-coder:7b') + const text = getFirstText(message) + + expect(text).toContain('Provider endpoint was not found') + expect(text).toContain('Ollama') +}) + test('maps tool_call_incompatible category markers to model/tool guidance', () => { const error = APIError.generate( 400, diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index 09fd497a..b94edea6 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -51,7 +51,9 @@ import { import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js' import { + extractOpenAICategoryHost, extractOpenAICategoryMarker, + isLocalhostLikeHost, type OpenAICompatibilityFailureCategory, } from './openaiErrorClassification.js' @@ -68,25 +70,29 @@ function mapOpenAICompatibilityFailureToAssistantMessage(options: { category: OpenAICompatibilityFailureCategory model: string rawMessage: string + host?: string }): AssistantMessage { const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model' const compactHint = getIsNonInteractiveSession() ? 'Reduce prompt size or start a new session.' : 'Run /compact or start a new session with /new.' + const isLocalhost = options.host === undefined || isLocalhostLikeHost(options.host) switch (options.category) { case 'localhost_resolution_failed': case 'connection_refused': return createAssistantAPIErrorMessage({ - content: - 'Could not connect to the local OpenAI-compatible provider. Ensure the local server is running, then use OPENAI_BASE_URL=http://127.0.0.1:11434/v1 for Ollama.', + content: isLocalhost + ? 'Could not connect to the local OpenAI-compatible provider. Ensure the local server is running, then use OPENAI_BASE_URL=http://127.0.0.1:11434/v1 for Ollama.' + : `Could not connect to the provider at ${options.host}. Verify OPENAI_BASE_URL is correct and that the host is reachable.`, error: 'unknown', }) case 'endpoint_not_found': return createAssistantAPIErrorMessage({ - content: - 'Provider endpoint was not found. Confirm OPENAI_BASE_URL targets an OpenAI-compatible /v1 endpoint (for Ollama: http://127.0.0.1:11434/v1).', + content: isLocalhost + ? 'Provider endpoint was not found. Confirm OPENAI_BASE_URL targets an OpenAI-compatible /v1 endpoint (for Ollama: http://127.0.0.1:11434/v1).' + : `Provider endpoint at ${options.host} returned 404. Verify OPENAI_BASE_URL is correct and that the selected model (${options.model}) is supported by this provider.`, error: 'invalid_request', }) @@ -567,6 +573,7 @@ export function getAssistantMessageFromError( category: openaiCategory, model, rawMessage: error.message, + host: extractOpenAICategoryHost(error.message), }) } } diff --git a/src/services/api/openaiErrorClassification.test.ts b/src/services/api/openaiErrorClassification.test.ts index ddcc1870..601a7963 100644 --- a/src/services/api/openaiErrorClassification.test.ts +++ b/src/services/api/openaiErrorClassification.test.ts @@ -4,8 +4,10 @@ import { buildOpenAICompatibilityErrorMessage, classifyOpenAIHttpFailure, classifyOpenAINetworkFailure, + extractOpenAICategoryHost, extractOpenAICategoryMarker, formatOpenAICategoryMarker, + isLocalhostLikeHost, } from './openaiErrorClassification.js' test('classifies localhost ECONNREFUSED as connection_refused', () => { @@ -95,3 +97,58 @@ test('ignores unknown category markers during extraction', () => { const malformed = 'OpenAI API error 500 [openai_category=totally_fake_category]' expect(extractOpenAICategoryMarker(malformed)).toBeUndefined() }) + +test('endpoint_not_found 404 from a remote host gets a host-aware hint (issue #926)', () => { + const failure = classifyOpenAIHttpFailure({ + status: 404, + body: 'Not Found', + url: 'https://integrate.api.nvidia.com/v1/chat/completions', + }) + + expect(failure.category).toBe('endpoint_not_found') + expect(failure.requestUrl).toBe('https://integrate.api.nvidia.com/v1/chat/completions') + expect(failure.hint).toContain('integrate.api.nvidia.com') + expect(failure.hint).not.toContain('local providers') +}) + +test('endpoint_not_found 404 from localhost keeps the Ollama-flavored hint', () => { + const failure = classifyOpenAIHttpFailure({ + status: 404, + body: 'Not Found', + url: 'http://127.0.0.1:11434/v1/chat/completions', + }) + + expect(failure.category).toBe('endpoint_not_found') + expect(failure.hint).toContain('local providers') +}) + +test('marker round-trip preserves host segment', () => { + const formatted = buildOpenAICompatibilityErrorMessage( + 'OpenAI API error 404: Not Found', + { + category: 'endpoint_not_found', + hint: 'Endpoint at integrate.api.nvidia.com returned 404.', + requestUrl: 'https://integrate.api.nvidia.com/v1/chat/completions', + }, + ) + + expect(formatted).toContain('[openai_category=endpoint_not_found,host=integrate.api.nvidia.com]') + expect(extractOpenAICategoryMarker(formatted)).toBe('endpoint_not_found') + expect(extractOpenAICategoryHost(formatted)).toBe('integrate.api.nvidia.com') +}) + +test('marker without host stays backward-compatible', () => { + const marker = formatOpenAICategoryMarker('endpoint_not_found') + expect(marker).toBe('[openai_category=endpoint_not_found]') + expect(extractOpenAICategoryMarker(marker)).toBe('endpoint_not_found') + expect(extractOpenAICategoryHost(marker)).toBeUndefined() +}) + +test('isLocalhostLikeHost matches loopback variants', () => { + expect(isLocalhostLikeHost('localhost')).toBe(true) + expect(isLocalhostLikeHost('127.0.0.1')).toBe(true) + expect(isLocalhostLikeHost('127.0.0.5')).toBe(true) + expect(isLocalhostLikeHost('::1')).toBe(true) + expect(isLocalhostLikeHost('integrate.api.nvidia.com')).toBe(false) + expect(isLocalhostLikeHost(undefined)).toBe(false) +}) diff --git a/src/services/api/openaiErrorClassification.ts b/src/services/api/openaiErrorClassification.ts index a18a672a..8442a4d7 100644 --- a/src/services/api/openaiErrorClassification.ts +++ b/src/services/api/openaiErrorClassification.ts @@ -21,6 +21,7 @@ export type OpenAICompatibilityFailure = { hint?: string code?: string status?: number + requestUrl?: string } const OPENAI_CATEGORY_MARKER_PREFIX = '[openai_category=' @@ -96,6 +97,11 @@ function isLocalhostLikeHostname(hostname: string | null): boolean { return /^127\./.test(hostname) } +export function isLocalhostLikeHost(host: string | null | undefined): boolean { + if (!host) return false + return isLocalhostLikeHostname(host.toLowerCase()) +} + function isContextOverflowMessage(body: string): boolean { const lower = body.toLowerCase() return ( @@ -149,14 +155,18 @@ function isModelNotFoundMessage(body: string): boolean { export function formatOpenAICategoryMarker( category: OpenAICompatibilityFailureCategory, + host?: string, ): string { + if (host && /^[A-Za-z0-9.\-:]+$/.test(host)) { + return `${OPENAI_CATEGORY_MARKER_PREFIX}${category},host=${host}]` + } return `${OPENAI_CATEGORY_MARKER_PREFIX}${category}]` } export function extractOpenAICategoryMarker( message: string, ): OpenAICompatibilityFailureCategory | undefined { - const match = message.match(/\[openai_category=([a-z_]+)]/) + const match = message.match(/\[openai_category=([a-z_]+)(?:,host=[^\]]+)?]/) const category = match?.[1] if (!category || !isOpenAICompatibilityFailureCategory(category)) { @@ -166,11 +176,17 @@ export function extractOpenAICategoryMarker( return category } +export function extractOpenAICategoryHost(message: string): string | undefined { + const match = message.match(/\[openai_category=[a-z_]+,host=([A-Za-z0-9.\-:]+)]/) + return match?.[1] +} + export function buildOpenAICompatibilityErrorMessage( baseMessage: string, - failure: Pick, + failure: Pick, ): string { - const marker = formatOpenAICategoryMarker(failure.category) + const host = failure.requestUrl ? getHostname(failure.requestUrl) ?? undefined : undefined + const marker = formatOpenAICategoryMarker(failure.category, host) const hint = failure.hint ? ` Hint: ${failure.hint}` : '' return `${baseMessage} ${marker}${hint}` } @@ -247,8 +263,11 @@ export function classifyOpenAINetworkFailure( export function classifyOpenAIHttpFailure(options: { status: number body: string + url?: string }): OpenAICompatibilityFailure { const body = options.body ?? '' + const hostname = options.url ? getHostname(options.url) : null + const isLocalHost = isLocalhostLikeHostname(hostname) if (options.status === 401 || options.status === 403) { return { @@ -284,13 +303,17 @@ export function classifyOpenAIHttpFailure(options: { } if (options.status === 404) { + const isRemote = hostname !== null && !isLocalHost 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.', + requestUrl: options.url, + hint: isRemote + ? `Endpoint at ${hostname} returned 404. Verify OPENAI_BASE_URL is correct and the requested model is supported by this provider.` + : 'Endpoint was not found. Confirm OPENAI_BASE_URL includes /v1 for OpenAI-compatible local providers.', } } diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index d04c5132..6452638b 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1935,7 +1935,9 @@ class OpenAIShimMessages { classifyOpenAIHttpFailure({ status, body: errorBody, + url: requestUrl, }) + const failureWithUrl = { ...failure, requestUrl: failure.requestUrl ?? requestUrl } const redactedUrl = redactUrlForDiagnostics(requestUrl) logForDebugging( @@ -1948,7 +1950,7 @@ class OpenAIShimMessages { parsedBody, buildOpenAICompatibilityErrorMessage( `OpenAI API error ${status}: ${errorBody}${rateHint}`, - failure, + failureWithUrl, ), responseHeaders, )