diff --git a/src/services/api/errors.openaiCompatibility.test.ts b/src/services/api/errors.openaiCompatibility.test.ts new file mode 100644 index 00000000..e79f7b59 --- /dev/null +++ b/src/services/api/errors.openaiCompatibility.test.ts @@ -0,0 +1,44 @@ +import { APIError } from '@anthropic-ai/sdk' +import { expect, test } from 'bun:test' + +import { getAssistantMessageFromError } from './errors.js' + +function getFirstText(message: ReturnType): string { + const first = message.message.content[0] + if (!first || typeof first !== 'object' || !('text' in first)) { + return '' + } + return typeof first.text === 'string' ? first.text : '' +} + +test('maps endpoint_not_found category markers to actionable setup guidance', () => { + 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(message.isApiErrorMessage).toBe(true) + expect(text).toContain('Provider endpoint was not found') + expect(text).toContain('OPENAI_BASE_URL') + expect(text).toContain('/v1') +}) + +test('maps tool_call_incompatible category markers to model/tool guidance', () => { + const error = APIError.generate( + 400, + undefined, + 'OpenAI API error 400: tool_calls are not supported [openai_category=tool_call_incompatible]', + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'qwen2.5-coder:7b') + const text = getFirstText(message) + + expect(text).toContain('rejected tool-calling payloads') + expect(text).toContain('/model') +}) diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index eb8fc1ed..09249c53 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -50,9 +50,110 @@ import { } from '../claudeAiLimits.js' import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js' +import { + extractOpenAICategoryMarker, + type OpenAICompatibilityFailureCategory, +} from './openaiErrorClassification.js' export const API_ERROR_MESSAGE_PREFIX = 'API Error' +function stripOpenAICompatibilityMetadata(message: string): string { + return message + .replace(/\s*\[openai_category=[a-z_]+\]\s*/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim() +} + +function mapOpenAICompatibilityFailureToAssistantMessage(options: { + category: OpenAICompatibilityFailureCategory + model: string + rawMessage: 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.' + + 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.', + 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).', + error: 'invalid_request', + }) + + case 'model_not_found': + return createAssistantAPIErrorMessage({ + content: `The selected model (${options.model}) is not available on this provider. Run ${switchCmd} to choose another model, or verify installed local models (for Ollama: ollama list).`, + error: 'invalid_request', + }) + + case 'auth_invalid': + return createAssistantAPIErrorMessage({ + content: `${API_ERROR_MESSAGE_PREFIX}: Authentication failed for your OpenAI-compatible provider. Verify OPENAI_API_KEY and endpoint-specific auth requirements.`, + error: 'authentication_failed', + }) + + case 'rate_limited': + return createAssistantAPIErrorMessage({ + content: `${API_ERROR_MESSAGE_PREFIX}: Provider rate limit reached. Retry in a few seconds.`, + error: 'rate_limit', + }) + + case 'request_timeout': + return createAssistantAPIErrorMessage({ + content: `${API_ERROR_MESSAGE_PREFIX}: Provider request timed out. Local models may be loading or overloaded; retry shortly or increase API_TIMEOUT_MS.`, + error: 'unknown', + }) + + case 'context_overflow': + return createAssistantAPIErrorMessage({ + content: `The conversation exceeded the provider context limit. ${compactHint}`, + error: 'invalid_request', + }) + + case 'tool_call_incompatible': + return createAssistantAPIErrorMessage({ + content: `The selected provider/model rejected tool-calling payloads. Try ${switchCmd} to pick a tool-capable model or continue without tools.`, + error: 'invalid_request', + }) + + case 'malformed_provider_response': + return createAssistantAPIErrorMessage({ + content: `${API_ERROR_MESSAGE_PREFIX}: Provider returned a malformed response. Confirm endpoint compatibility and check local proxy/network middleware.`, + error: 'unknown', + errorDetails: stripOpenAICompatibilityMetadata(options.rawMessage), + }) + + case 'provider_unavailable': + return createAssistantAPIErrorMessage({ + content: `${API_ERROR_MESSAGE_PREFIX}: Provider is temporarily unavailable. Retry in a moment.`, + error: 'unknown', + }) + + case 'network_error': + case 'unknown': + return createAssistantAPIErrorMessage({ + content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`, + error: 'unknown', + }) + + default: + return createAssistantAPIErrorMessage({ + content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`, + error: 'unknown', + }) + } +} + export function startsWithApiErrorPrefix(text: string): boolean { return ( text.startsWith(API_ERROR_MESSAGE_PREFIX) || @@ -457,6 +558,19 @@ export function getAssistantMessageFromError( }) } + // OpenAI-compatible transport and HTTP failures include structured category + // markers from openaiShim.ts for actionable end-user remediation. + if (error instanceof APIError) { + const openaiCategory = extractOpenAICategoryMarker(error.message) + if (openaiCategory) { + return mapOpenAICompatibilityFailureToAssistantMessage({ + category: openaiCategory, + model, + rawMessage: error.message, + }) + } + } + // Check for emergency capacity off switch for Opus PAYG users if ( error instanceof Error && diff --git a/src/services/api/openaiErrorClassification.test.ts b/src/services/api/openaiErrorClassification.test.ts new file mode 100644 index 00000000..ddcc1870 --- /dev/null +++ b/src/services/api/openaiErrorClassification.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'bun:test' + +import { + buildOpenAICompatibilityErrorMessage, + classifyOpenAIHttpFailure, + classifyOpenAINetworkFailure, + extractOpenAICategoryMarker, + formatOpenAICategoryMarker, +} from './openaiErrorClassification.js' + +test('classifies localhost ECONNREFUSED as connection_refused', () => { + const error = Object.assign(new TypeError('fetch failed'), { + code: 'ECONNREFUSED', + }) + + const failure = classifyOpenAINetworkFailure(error, { + url: 'http://localhost:11434/v1/chat/completions', + }) + + expect(failure.category).toBe('connection_refused') + expect(failure.retryable).toBe(true) + expect(failure.code).toBe('ECONNREFUSED') + expect(failure.hint).toContain('local server is running') +}) + +test('classifies localhost ENOTFOUND as localhost_resolution_failed', () => { + const error = Object.assign(new TypeError('getaddrinfo ENOTFOUND localhost'), { + code: 'ENOTFOUND', + }) + + const failure = classifyOpenAINetworkFailure(error, { + url: 'http://localhost:11434/v1/chat/completions', + }) + + expect(failure.category).toBe('localhost_resolution_failed') + expect(failure.retryable).toBe(true) + expect(failure.code).toBe('ENOTFOUND') + expect(failure.hint).toContain('127.0.0.1') +}) + +test('classifies model-not-found 404 responses', () => { + const failure = classifyOpenAIHttpFailure({ + status: 404, + body: 'The model qwen2.5-coder:7b was not found', + }) + + expect(failure.category).toBe('model_not_found') + expect(failure.retryable).toBe(false) +}) + +test('classifies generic 404 responses as endpoint_not_found', () => { + const failure = classifyOpenAIHttpFailure({ + status: 404, + body: 'Not Found', + }) + + expect(failure.category).toBe('endpoint_not_found') + expect(failure.hint).toContain('/v1') +}) + +test('classifies context-overflow responses', () => { + const failure = classifyOpenAIHttpFailure({ + status: 500, + body: 'request too large: maximum context length exceeded', + }) + + expect(failure.category).toBe('context_overflow') + expect(failure.retryable).toBe(false) +}) + +test('classifies tool compatibility failures', () => { + const failure = classifyOpenAIHttpFailure({ + status: 400, + body: 'tool_calls are not supported by this model', + }) + + expect(failure.category).toBe('tool_call_incompatible') +}) + +test('embeds and extracts category markers in formatted messages', () => { + const marker = formatOpenAICategoryMarker('endpoint_not_found') + expect(marker).toBe('[openai_category=endpoint_not_found]') + + const formatted = buildOpenAICompatibilityErrorMessage('OpenAI API error 404: Not Found', { + category: 'endpoint_not_found', + hint: 'Confirm OPENAI_BASE_URL includes /v1.', + }) + + expect(formatted).toContain('[openai_category=endpoint_not_found]') + expect(formatted).toContain('Hint: Confirm OPENAI_BASE_URL includes /v1.') + expect(extractOpenAICategoryMarker(formatted)).toBe('endpoint_not_found') +}) + +test('ignores unknown category markers during extraction', () => { + const malformed = 'OpenAI API error 500 [openai_category=totally_fake_category]' + expect(extractOpenAICategoryMarker(malformed)).toBeUndefined() +}) diff --git a/src/services/api/openaiErrorClassification.ts b/src/services/api/openaiErrorClassification.ts new file mode 100644 index 00000000..3f86cf96 --- /dev/null +++ b/src/services/api/openaiErrorClassification.ts @@ -0,0 +1,355 @@ +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 >= 200 && options.status < 300 && isMalformedProviderResponse(body)) || + (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, + } +} diff --git a/src/services/api/openaiShim.diagnostics.test.ts b/src/services/api/openaiShim.diagnostics.test.ts new file mode 100644 index 00000000..9fbcfafd --- /dev/null +++ b/src/services/api/openaiShim.diagnostics.test.ts @@ -0,0 +1,119 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +const originalFetch = globalThis.fetch +const originalEnv = { + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_MODEL: process.env.OPENAI_MODEL, +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + +afterEach(() => { + globalThis.fetch = originalFetch + restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) + restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) + restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) + mock.restore() +}) + +test('logs classified transport diagnostics with category and code', async () => { + const debugSpy = mock(() => {}) + mock.module('../../utils/debug.js', () => ({ + logForDebugging: debugSpy, + })) + + const nonce = `${Date.now()}-${Math.random()}` + const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`) + + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_API_KEY = 'ollama' + + const transportError = Object.assign(new TypeError('fetch failed'), { + code: 'ECONNREFUSED', + }) + + globalThis.fetch = mock(async () => { + throw transportError + }) as typeof globalThis.fetch + + const client = createOpenAIShimClient({}) as { + beta: { + messages: { + create: (params: Record) => Promise + } + } + } + + await expect( + client.beta.messages.create({ + model: 'qwen2.5-coder:7b', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }), + ).rejects.toThrow('openai_category=connection_refused') + + const transportLog = debugSpy.mock.calls.find(call => + typeof call?.[0] === 'string' && call[0].includes('transport failure'), + ) + + expect(transportLog).toBeDefined() + expect(String(transportLog?.[0])).toContain('category=connection_refused') + expect(String(transportLog?.[0])).toContain('code=ECONNREFUSED') + expect(transportLog?.[1]).toEqual({ level: 'warn' }) +}) + +test('redacts credentials in transport diagnostic URL logs', async () => { + const debugSpy = mock(() => {}) + mock.module('../../utils/debug.js', () => ({ + logForDebugging: debugSpy, + })) + + const nonce = `${Date.now()}-${Math.random()}` + const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`) + + process.env.OPENAI_BASE_URL = 'http://user:supersecret@localhost:11434/v1' + process.env.OPENAI_API_KEY = 'supersecret' + + const transportError = Object.assign(new TypeError('fetch failed'), { + code: 'ECONNREFUSED', + }) + + globalThis.fetch = mock(async () => { + throw transportError + }) as typeof globalThis.fetch + + const client = createOpenAIShimClient({}) as { + beta: { + messages: { + create: (params: Record) => Promise + } + } + } + + await expect( + client.beta.messages.create({ + model: 'qwen2.5-coder:7b', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }), + ).rejects.toThrow('openai_category=connection_refused') + + const transportLog = debugSpy.mock.calls.find(call => + typeof call?.[0] === 'string' && call[0].includes('transport failure'), + ) + + expect(transportLog).toBeDefined() + const logLine = String(transportLog?.[0]) + expect(logLine).toContain('url=http://redacted:redacted@localhost:11434/v1/chat/completions') + expect(logLine).not.toContain('user:supersecret') + expect(logLine).not.toContain('supersecret@') +}) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index b0e31f97..5824967b 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2775,3 +2775,84 @@ test('streaming: strips leaked reasoning preamble when split across multiple con expect(textDeltas).toEqual(['Hey! How can I help you today?']) }) + +test('classifies localhost transport failures with actionable category marker', async () => { + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + + const transportError = Object.assign(new TypeError('fetch failed'), { + code: 'ECONNREFUSED', + }) + + globalThis.fetch = (async () => { + throw transportError + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'qwen2.5-coder:7b', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }), + ).rejects.toThrow('openai_category=connection_refused') + + await expect( + client.beta.messages.create({ + model: 'qwen2.5-coder:7b', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }), + ).rejects.toThrow('local server is running') +}) + +test('propagates AbortError without wrapping it as transport failure', async () => { + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + + const abortError = new DOMException('The operation was aborted.', 'AbortError') + globalThis.fetch = (async () => { + throw abortError + }) as FetchType + + const controller = new AbortController() + controller.abort() + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await expect( + client.beta.messages.create( + { + model: 'qwen2.5-coder:7b', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }, + { signal: controller.signal }, + ), + ).rejects.toBe(abortError) +}) + +test('classifies chat-completions endpoint 404 failures with endpoint_not_found marker', async () => { + process.env.OPENAI_BASE_URL = 'http://localhost:11434' + + globalThis.fetch = (async () => + new Response('Not Found', { + status: 404, + headers: { + 'Content-Type': 'text/plain', + }, + })) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'qwen2.5-coder:7b', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }), + ).rejects.toThrow('openai_category=endpoint_not_found') +}) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index b33acb38..e2bb665f 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -54,6 +54,11 @@ import { resolveProviderRequest, getGithubEndpointType, } from './providerConfig.js' +import { + buildOpenAICompatibilityErrorMessage, + classifyOpenAIHttpFailure, + classifyOpenAINetworkFailure, +} from './openaiErrorClassification.js' import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js' import { redactSecretValueForDisplay } from '../../utils/providerProfile.js' import { @@ -83,6 +88,19 @@ const COPILOT_HEADERS: Record = { 'Copilot-Integration-Id': 'vscode-chat', } +const SENSITIVE_URL_QUERY_PARAM_NAMES = [ + 'api_key', + 'key', + 'token', + 'access_token', + 'refresh_token', + 'signature', + 'sig', + 'secret', + 'password', + 'authorization', +] + function isGithubModelsMode(): boolean { return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) } @@ -132,6 +150,34 @@ function formatRetryAfterHint(response: Response): string { return ra ? ` (Retry-After: ${ra})` : '' } +function shouldRedactUrlQueryParam(name: string): boolean { + const lower = name.toLowerCase() + return SENSITIVE_URL_QUERY_PARAM_NAMES.some(token => lower.includes(token)) +} + +function redactUrlForDiagnostics(url: string): string { + try { + const parsed = new URL(url) + if (parsed.username) { + parsed.username = 'redacted' + } + if (parsed.password) { + parsed.password = 'redacted' + } + + for (const key of parsed.searchParams.keys()) { + if (shouldRedactUrlQueryParam(key)) { + parsed.searchParams.set(key, 'redacted') + } + } + + const serialized = parsed.toString() + return redactSecretValueForDisplay(serialized, process.env as SecretValueSource) ?? serialized + } catch { + return redactSecretValueForDisplay(url, process.env as SecretValueSource) ?? url + } +} + function sleepMs(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } @@ -1430,12 +1476,97 @@ class OpenAIShimMessages { } const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1 + + const throwClassifiedTransportError = ( + error: unknown, + requestUrl: string, + ): never => { + if (options?.signal?.aborted) { + throw error + } + + const failure = classifyOpenAINetworkFailure(error, { + url: requestUrl, + }) + const redactedUrl = redactUrlForDiagnostics(requestUrl) + const safeMessage = + redactSecretValueForDisplay( + failure.message, + process.env as SecretValueSource, + ) || 'Request failed' + + logForDebugging( + `[OpenAIShim] transport failure category=${failure.category} retryable=${failure.retryable} code=${failure.code ?? 'unknown'} method=POST url=${redactedUrl} model=${request.resolvedModel} message=${safeMessage}`, + { level: 'warn' }, + ) + + throw APIError.generate( + 503, + undefined, + buildOpenAICompatibilityErrorMessage( + `OpenAI API transport error: ${safeMessage}${failure.code ? ` (code=${failure.code})` : ''}`, + failure, + ), + new Headers(), + ) + } + + const throwClassifiedHttpError = ( + status: number, + errorBody: string, + parsedBody: object | undefined, + responseHeaders: Headers, + requestUrl: string, + rateHint = '', + ): never => { + const failure = classifyOpenAIHttpFailure({ + status, + body: errorBody, + }) + const redactedUrl = redactUrlForDiagnostics(requestUrl) + + logForDebugging( + `[OpenAIShim] request failed category=${failure.category} retryable=${failure.retryable} status=${status} method=POST url=${redactedUrl} model=${request.resolvedModel}`, + { level: 'warn' }, + ) + + throw APIError.generate( + status, + parsedBody, + buildOpenAICompatibilityErrorMessage( + `OpenAI API error ${status}: ${errorBody}${rateHint}`, + failure, + ), + responseHeaders, + ) + } + let response: Response | undefined for (let attempt = 0; attempt < maxAttempts; attempt++) { - response = await fetchWithProxyRetry(chatCompletionsUrl, fetchInit) + try { + response = await fetchWithProxyRetry(chatCompletionsUrl, fetchInit) + } catch (error) { + const isAbortError = + fetchInit.signal?.aborted === true || + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError') || + (typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError') + + if (isAbortError) { + throw error + } + + throwClassifiedTransportError(error, chatCompletionsUrl) + } + if (response.ok) { return response } + if ( isGithub && response.status === 429 && @@ -1505,34 +1636,43 @@ class OpenAIShimMessages { } } - const responsesResponse = await fetchWithProxyRetry(responsesUrl, { - method: 'POST', - headers, - body: JSON.stringify(responsesBody), - signal: options?.signal, - }) + let responsesResponse: Response + try { + responsesResponse = await fetchWithProxyRetry(responsesUrl, { + method: 'POST', + headers, + body: JSON.stringify(responsesBody), + signal: options?.signal, + }) + } catch (error) { + throwClassifiedTransportError(error, responsesUrl) + } + if (responsesResponse.ok) { return responsesResponse } const responsesErrorBody = await responsesResponse.text().catch(() => 'unknown error') let responsesErrorResponse: object | undefined try { responsesErrorResponse = JSON.parse(responsesErrorBody) } catch { /* raw text */ } - throw APIError.generate( + throwClassifiedHttpError( responsesResponse.status, + responsesErrorBody, responsesErrorResponse, - `OpenAI API error ${responsesResponse.status}: ${responsesErrorBody}`, responsesResponse.headers, + responsesUrl, ) } } let errorResponse: object | undefined try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ } - throw APIError.generate( + throwClassifiedHttpError( response.status, + errorBody, errorResponse, - `OpenAI API error ${response.status}: ${errorBody}${rateHint}`, response.headers as unknown as Headers, + chatCompletionsUrl, + rateHint, ) } diff --git a/src/services/api/providerConfig.envDiagnostics.test.ts b/src/services/api/providerConfig.envDiagnostics.test.ts new file mode 100644 index 00000000..c6d2dc19 --- /dev/null +++ b/src/services/api/providerConfig.envDiagnostics.test.ts @@ -0,0 +1,107 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +const originalEnv = { + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, + CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL, + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_MODEL: process.env.OPENAI_MODEL, + OPENAI_API_BASE: process.env.OPENAI_API_BASE, + MISTRAL_BASE_URL: process.env.MISTRAL_BASE_URL, + MISTRAL_MODEL: process.env.MISTRAL_MODEL, +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + +afterEach(() => { + restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) + restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL) + restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) + restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) + restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE) + restoreEnv('MISTRAL_BASE_URL', originalEnv.MISTRAL_BASE_URL) + restoreEnv('MISTRAL_MODEL', originalEnv.MISTRAL_MODEL) + mock.restore() +}) + +test('logs a warning when OPENAI_BASE_URL is literal undefined', async () => { + const debugSpy = mock(() => {}) + mock.module('../../utils/debug.js', () => ({ + logForDebugging: debugSpy, + })) + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'undefined' + process.env.OPENAI_MODEL = 'gpt-4o' + delete process.env.OPENAI_API_BASE + + const nonce = `${Date.now()}-${Math.random()}` + const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`) + + const resolved = resolveProviderRequest() + + expect(resolved.baseUrl).toBe('https://api.openai.com/v1') + + const warningCall = debugSpy.mock.calls.find(call => + typeof call?.[0] === 'string' && + call[0].includes('OPENAI_BASE_URL') && + call[0].includes('"undefined"'), + ) + + expect(warningCall).toBeDefined() + expect(warningCall?.[1]).toEqual({ level: 'warn' }) +}) + +test('does not warn for OPENAI_API_BASE when OPENAI_BASE_URL is active', async () => { + const debugSpy = mock(() => {}) + mock.module('../../utils/debug.js', () => ({ + logForDebugging: debugSpy, + })) + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + delete process.env.CLAUDE_CODE_USE_MISTRAL + process.env.OPENAI_BASE_URL = 'http://127.0.0.1:11434/v1' + process.env.OPENAI_MODEL = 'qwen2.5-coder:7b' + process.env.OPENAI_API_BASE = 'undefined' + + const nonce = `${Date.now()}-${Math.random()}` + const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`) + + const resolved = resolveProviderRequest() + + expect(resolved.baseUrl).toBe('http://127.0.0.1:11434/v1') + + const aliasWarning = debugSpy.mock.calls.find(call => + typeof call?.[0] === 'string' && + call[0].includes('OPENAI_API_BASE') && + call[0].includes('"undefined"'), + ) + + expect(aliasWarning).toBeUndefined() +}) + +test('uses OPENAI_API_BASE as fallback in mistral mode when MISTRAL_BASE_URL is unset', async () => { + const debugSpy = mock(() => {}) + mock.module('../../utils/debug.js', () => ({ + logForDebugging: debugSpy, + })) + + delete process.env.CLAUDE_CODE_USE_OPENAI + process.env.CLAUDE_CODE_USE_MISTRAL = '1' + delete process.env.MISTRAL_BASE_URL + process.env.MISTRAL_MODEL = 'mistral-medium-latest' + process.env.OPENAI_API_BASE = 'http://127.0.0.1:11434/v1' + + const nonce = `${Date.now()}-${Math.random()}` + const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`) + + const resolved = resolveProviderRequest() + + expect(resolved.baseUrl).toBe('http://127.0.0.1:11434/v1') + expect(debugSpy.mock.calls).toHaveLength(0) +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 68c7e8fe..03ccdbce 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -8,6 +8,7 @@ import { readCodexCredentials, type CodexCredentialBlob, } from '../../utils/codexCredentials.js' +import { logForDebugging } from '../../utils/debug.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { asTrimmedString, @@ -19,6 +20,7 @@ export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex' export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1' /** Default GitHub Copilot API model when user selects copilot / github:copilot */ export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o' +const warnedUndefinedEnvNames = new Set() const CODEX_ALIAS_MODELS: Record< string, @@ -129,7 +131,33 @@ function isPrivateIpv6Address(hostname: string): boolean { function asEnvUrl(value: string | undefined): string | undefined { if (!value) return undefined const trimmed = value.trim() - if (!trimmed || trimmed === 'undefined') return undefined + if (!trimmed) return undefined + if (trimmed === 'undefined') { + return undefined + } + return trimmed +} + +function asNamedEnvUrl( + value: string | undefined, + envName: string, +): string | undefined { + if (!value) return undefined + + const trimmed = value.trim() + if (!trimmed) return undefined + + if (trimmed === 'undefined') { + if (!warnedUndefinedEnvNames.has(envName)) { + warnedUndefinedEnvNames.add(envName) + logForDebugging( + `[provider-config] Environment variable ${envName} is the literal string "undefined"; ignoring it.`, + { level: 'warn' }, + ) + } + return undefined + } + return trimmed } @@ -362,14 +390,28 @@ export function resolveProviderRequest(options?: { (isGithubMode ? 'github:copilot' : 'gpt-4o') const descriptor = parseModelDescriptor(requestedModel) const explicitBaseUrl = asEnvUrl(options?.baseUrl) + + const normalizedMistralEnvBaseUrl = asNamedEnvUrl( + process.env.MISTRAL_BASE_URL, + 'MISTRAL_BASE_URL', + ) + + const primaryEnvBaseUrl = isMistralMode + ? normalizedMistralEnvBaseUrl + : asNamedEnvUrl(process.env.OPENAI_BASE_URL, 'OPENAI_BASE_URL') + + const fallbackEnvBaseUrl = isMistralMode + ? (primaryEnvBaseUrl === undefined + ? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_MISTRAL_BASE_URL + : undefined) + : (primaryEnvBaseUrl === undefined + ? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') + : undefined) + const envBaseUrlRaw = explicitBaseUrl ?? - asEnvUrl( - isMistralMode - ? (process.env.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL) - : process.env.OPENAI_BASE_URL - ) ?? - asEnvUrl(process.env.OPENAI_API_BASE) + primaryEnvBaseUrl ?? + fallbackEnvBaseUrl const isCodexModelForGithub = isGithubMode && isCodexAlias(requestedModel) const envBaseUrl =