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:
nehan
2026-04-17 14:01:40 +04:00
committed by GitHub
parent eed77e6579
commit 80a00acc2c
9 changed files with 1117 additions and 18 deletions

View 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')
})

View File

@@ -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 &&

View 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()
})

View 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,
}
}

View 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@')
})

View File

@@ -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')
})

View File

@@ -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<string, string> = {
'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<void> {
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,
)
}

View 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)
})

View File

@@ -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<string>()
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 =