feat(api): classify openai-compatible provider failures (#708)
* feat(api): classify openai-compatible provider failures * Update src/services/api/providerConfig.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/services/api/errors.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(api): harden openai-compatible diagnostics and env fallback * Update src/services/api/openaiShim.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/services/api/openaiShim.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/services/api/errors.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/services/api/errors.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix openaiShim duplicate requests and diagnostics * remove unused url from http failure classifier * dedupe env diagnostic warnings * Remove hardcoded URLs from OpenAI error tests Removed hardcoded URLs from network failure classification tests. * Update providerConfig.envDiagnostics.test.ts * fix(openai-shim): return successful responses and restore localhost classifier tests * Update src/services/api/openaiShim.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/services/api/openaiShim.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/services/api/openaiShim.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
44
src/services/api/errors.openaiCompatibility.test.ts
Normal file
44
src/services/api/errors.openaiCompatibility.test.ts
Normal file
@@ -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<typeof getAssistantMessageFromError>): 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')
|
||||||
|
})
|
||||||
@@ -50,9 +50,110 @@ import {
|
|||||||
} from '../claudeAiLimits.js'
|
} from '../claudeAiLimits.js'
|
||||||
import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command
|
import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command
|
||||||
import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js'
|
import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js'
|
||||||
|
import {
|
||||||
|
extractOpenAICategoryMarker,
|
||||||
|
type OpenAICompatibilityFailureCategory,
|
||||||
|
} from './openaiErrorClassification.js'
|
||||||
|
|
||||||
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
|
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 {
|
export function startsWithApiErrorPrefix(text: string): boolean {
|
||||||
return (
|
return (
|
||||||
text.startsWith(API_ERROR_MESSAGE_PREFIX) ||
|
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
|
// Check for emergency capacity off switch for Opus PAYG users
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
|
|||||||
97
src/services/api/openaiErrorClassification.test.ts
Normal file
97
src/services/api/openaiErrorClassification.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
355
src/services/api/openaiErrorClassification.ts
Normal file
355
src/services/api/openaiErrorClassification.ts
Normal file
@@ -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<OpenAICompatibilityFailureCategory> =
|
||||||
|
new Set<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',
|
||||||
|
])
|
||||||
|
|
||||||
|
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('<!doctype html') ||
|
||||||
|
lower.includes('<html') ||
|
||||||
|
lower.includes('invalid json') ||
|
||||||
|
lower.includes('malformed') ||
|
||||||
|
lower.includes('unexpected token') ||
|
||||||
|
lower.includes('cannot parse') ||
|
||||||
|
lower.includes('not valid json')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelNotFoundMessage(body: string): boolean {
|
||||||
|
const lower = body.toLowerCase()
|
||||||
|
return (
|
||||||
|
lower.includes('model') &&
|
||||||
|
(
|
||||||
|
lower.includes('not found') ||
|
||||||
|
lower.includes('does not exist') ||
|
||||||
|
lower.includes('unknown model') ||
|
||||||
|
lower.includes('unavailable model')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOpenAICategoryMarker(
|
||||||
|
category: OpenAICompatibilityFailureCategory,
|
||||||
|
): string {
|
||||||
|
return `${OPENAI_CATEGORY_MARKER_PREFIX}${category}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOpenAICategoryMarker(
|
||||||
|
message: string,
|
||||||
|
): OpenAICompatibilityFailureCategory | undefined {
|
||||||
|
const match = message.match(/\[openai_category=([a-z_]+)]/)
|
||||||
|
const category = match?.[1]
|
||||||
|
|
||||||
|
if (!category || !isOpenAICompatibilityFailureCategory(category)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpenAICompatibilityErrorMessage(
|
||||||
|
baseMessage: string,
|
||||||
|
failure: Pick<OpenAICompatibilityFailure, 'category' | 'hint'>,
|
||||||
|
): 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/services/api/openaiShim.diagnostics.test.ts
Normal file
119
src/services/api/openaiShim.diagnostics.test.ts
Normal file
@@ -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<string, unknown>) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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@')
|
||||||
|
})
|
||||||
@@ -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?'])
|
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')
|
||||||
|
})
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ import {
|
|||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
getGithubEndpointType,
|
getGithubEndpointType,
|
||||||
} from './providerConfig.js'
|
} from './providerConfig.js'
|
||||||
|
import {
|
||||||
|
buildOpenAICompatibilityErrorMessage,
|
||||||
|
classifyOpenAIHttpFailure,
|
||||||
|
classifyOpenAINetworkFailure,
|
||||||
|
} from './openaiErrorClassification.js'
|
||||||
import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
|
import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
|
||||||
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +88,19 @@ const COPILOT_HEADERS: Record<string, string> = {
|
|||||||
'Copilot-Integration-Id': 'vscode-chat',
|
'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 {
|
function isGithubModelsMode(): boolean {
|
||||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
}
|
}
|
||||||
@@ -132,6 +150,34 @@ function formatRetryAfterHint(response: Response): string {
|
|||||||
return ra ? ` (Retry-After: ${ra})` : ''
|
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<void> {
|
function sleepMs(ms: number): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
@@ -1430,12 +1476,97 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
|
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
|
let response: Response | undefined
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
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) {
|
if (response.ok) {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isGithub &&
|
isGithub &&
|
||||||
response.status === 429 &&
|
response.status === 429 &&
|
||||||
@@ -1505,34 +1636,43 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesResponse = await fetchWithProxyRetry(responsesUrl, {
|
let responsesResponse: Response
|
||||||
method: 'POST',
|
try {
|
||||||
headers,
|
responsesResponse = await fetchWithProxyRetry(responsesUrl, {
|
||||||
body: JSON.stringify(responsesBody),
|
method: 'POST',
|
||||||
signal: options?.signal,
|
headers,
|
||||||
})
|
body: JSON.stringify(responsesBody),
|
||||||
|
signal: options?.signal,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
throwClassifiedTransportError(error, responsesUrl)
|
||||||
|
}
|
||||||
|
|
||||||
if (responsesResponse.ok) {
|
if (responsesResponse.ok) {
|
||||||
return responsesResponse
|
return responsesResponse
|
||||||
}
|
}
|
||||||
const responsesErrorBody = await responsesResponse.text().catch(() => 'unknown error')
|
const responsesErrorBody = await responsesResponse.text().catch(() => 'unknown error')
|
||||||
let responsesErrorResponse: object | undefined
|
let responsesErrorResponse: object | undefined
|
||||||
try { responsesErrorResponse = JSON.parse(responsesErrorBody) } catch { /* raw text */ }
|
try { responsesErrorResponse = JSON.parse(responsesErrorBody) } catch { /* raw text */ }
|
||||||
throw APIError.generate(
|
throwClassifiedHttpError(
|
||||||
responsesResponse.status,
|
responsesResponse.status,
|
||||||
|
responsesErrorBody,
|
||||||
responsesErrorResponse,
|
responsesErrorResponse,
|
||||||
`OpenAI API error ${responsesResponse.status}: ${responsesErrorBody}`,
|
|
||||||
responsesResponse.headers,
|
responsesResponse.headers,
|
||||||
|
responsesUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let errorResponse: object | undefined
|
let errorResponse: object | undefined
|
||||||
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
|
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
|
||||||
throw APIError.generate(
|
throwClassifiedHttpError(
|
||||||
response.status,
|
response.status,
|
||||||
|
errorBody,
|
||||||
errorResponse,
|
errorResponse,
|
||||||
`OpenAI API error ${response.status}: ${errorBody}${rateHint}`,
|
|
||||||
response.headers as unknown as Headers,
|
response.headers as unknown as Headers,
|
||||||
|
chatCompletionsUrl,
|
||||||
|
rateHint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
src/services/api/providerConfig.envDiagnostics.test.ts
Normal file
107
src/services/api/providerConfig.envDiagnostics.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
readCodexCredentials,
|
readCodexCredentials,
|
||||||
type CodexCredentialBlob,
|
type CodexCredentialBlob,
|
||||||
} from '../../utils/codexCredentials.js'
|
} from '../../utils/codexCredentials.js'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||||
import {
|
import {
|
||||||
asTrimmedString,
|
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'
|
export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1'
|
||||||
/** Default GitHub Copilot API model when user selects copilot / github:copilot */
|
/** Default GitHub Copilot API model when user selects copilot / github:copilot */
|
||||||
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o'
|
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o'
|
||||||
|
const warnedUndefinedEnvNames = new Set<string>()
|
||||||
|
|
||||||
const CODEX_ALIAS_MODELS: Record<
|
const CODEX_ALIAS_MODELS: Record<
|
||||||
string,
|
string,
|
||||||
@@ -129,7 +131,33 @@ function isPrivateIpv6Address(hostname: string): boolean {
|
|||||||
function asEnvUrl(value: string | undefined): string | undefined {
|
function asEnvUrl(value: string | undefined): string | undefined {
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
const trimmed = value.trim()
|
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
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,14 +390,28 @@ export function resolveProviderRequest(options?: {
|
|||||||
(isGithubMode ? 'github:copilot' : 'gpt-4o')
|
(isGithubMode ? 'github:copilot' : 'gpt-4o')
|
||||||
const descriptor = parseModelDescriptor(requestedModel)
|
const descriptor = parseModelDescriptor(requestedModel)
|
||||||
const explicitBaseUrl = asEnvUrl(options?.baseUrl)
|
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 =
|
const envBaseUrlRaw =
|
||||||
explicitBaseUrl ??
|
explicitBaseUrl ??
|
||||||
asEnvUrl(
|
primaryEnvBaseUrl ??
|
||||||
isMistralMode
|
fallbackEnvBaseUrl
|
||||||
? (process.env.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL)
|
|
||||||
: process.env.OPENAI_BASE_URL
|
|
||||||
) ??
|
|
||||||
asEnvUrl(process.env.OPENAI_API_BASE)
|
|
||||||
|
|
||||||
const isCodexModelForGithub = isGithubMode && isCodexAlias(requestedModel)
|
const isCodexModelForGithub = isGithubMode && isCodexAlias(requestedModel)
|
||||||
const envBaseUrl =
|
const envBaseUrl =
|
||||||
|
|||||||
Reference in New Issue
Block a user