Skip to content

Commit 75c9445

Browse files
muhnehhCopilot
authored andcommitted
feat(api): classify openai-compatible provider failures (Gitlawb#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>
1 parent 0bb7673 commit 75c9445

9 files changed

Lines changed: 1117 additions & 18 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { APIError } from '@anthropic-ai/sdk'
2+
import { expect, test } from 'bun:test'
3+
4+
import { getAssistantMessageFromError } from './errors.js'
5+
6+
function getFirstText(message: ReturnType<typeof getAssistantMessageFromError>): string {
7+
const first = message.message.content[0]
8+
if (!first || typeof first !== 'object' || !('text' in first)) {
9+
return ''
10+
}
11+
return typeof first.text === 'string' ? first.text : ''
12+
}
13+
14+
test('maps endpoint_not_found category markers to actionable setup guidance', () => {
15+
const error = APIError.generate(
16+
404,
17+
undefined,
18+
'OpenAI API error 404: Not Found [openai_category=endpoint_not_found] Hint: Confirm OPENAI_BASE_URL includes /v1.',
19+
new Headers(),
20+
)
21+
22+
const message = getAssistantMessageFromError(error, 'qwen2.5-coder:7b')
23+
const text = getFirstText(message)
24+
25+
expect(message.isApiErrorMessage).toBe(true)
26+
expect(text).toContain('Provider endpoint was not found')
27+
expect(text).toContain('OPENAI_BASE_URL')
28+
expect(text).toContain('/v1')
29+
})
30+
31+
test('maps tool_call_incompatible category markers to model/tool guidance', () => {
32+
const error = APIError.generate(
33+
400,
34+
undefined,
35+
'OpenAI API error 400: tool_calls are not supported [openai_category=tool_call_incompatible]',
36+
new Headers(),
37+
)
38+
39+
const message = getAssistantMessageFromError(error, 'qwen2.5-coder:7b')
40+
const text = getFirstText(message)
41+
42+
expect(text).toContain('rejected tool-calling payloads')
43+
expect(text).toContain('/model')
44+
})

src/services/api/errors.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,110 @@ import {
5050
} from '../claudeAiLimits.js'
5151
import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command
5252
import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js'
53+
import {
54+
extractOpenAICategoryMarker,
55+
type OpenAICompatibilityFailureCategory,
56+
} from './openaiErrorClassification.js'
5357

5458
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
5559

60+
function stripOpenAICompatibilityMetadata(message: string): string {
61+
return message
62+
.replace(/\s*\[openai_category=[a-z_]+\]\s*/g, ' ')
63+
.replace(/\s{2,}/g, ' ')
64+
.trim()
65+
}
66+
67+
function mapOpenAICompatibilityFailureToAssistantMessage(options: {
68+
category: OpenAICompatibilityFailureCategory
69+
model: string
70+
rawMessage: string
71+
}): AssistantMessage {
72+
const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model'
73+
const compactHint = getIsNonInteractiveSession()
74+
? 'Reduce prompt size or start a new session.'
75+
: 'Run /compact or start a new session with /new.'
76+
77+
switch (options.category) {
78+
case 'localhost_resolution_failed':
79+
case 'connection_refused':
80+
return createAssistantAPIErrorMessage({
81+
content:
82+
'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.',
83+
error: 'unknown',
84+
})
85+
86+
case 'endpoint_not_found':
87+
return createAssistantAPIErrorMessage({
88+
content:
89+
'Provider endpoint was not found. Confirm OPENAI_BASE_URL targets an OpenAI-compatible /v1 endpoint (for Ollama: http://127.0.0.1:11434/v1).',
90+
error: 'invalid_request',
91+
})
92+
93+
case 'model_not_found':
94+
return createAssistantAPIErrorMessage({
95+
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).`,
96+
error: 'invalid_request',
97+
})
98+
99+
case 'auth_invalid':
100+
return createAssistantAPIErrorMessage({
101+
content: `${API_ERROR_MESSAGE_PREFIX}: Authentication failed for your OpenAI-compatible provider. Verify OPENAI_API_KEY and endpoint-specific auth requirements.`,
102+
error: 'authentication_failed',
103+
})
104+
105+
case 'rate_limited':
106+
return createAssistantAPIErrorMessage({
107+
content: `${API_ERROR_MESSAGE_PREFIX}: Provider rate limit reached. Retry in a few seconds.`,
108+
error: 'rate_limit',
109+
})
110+
111+
case 'request_timeout':
112+
return createAssistantAPIErrorMessage({
113+
content: `${API_ERROR_MESSAGE_PREFIX}: Provider request timed out. Local models may be loading or overloaded; retry shortly or increase API_TIMEOUT_MS.`,
114+
error: 'unknown',
115+
})
116+
117+
case 'context_overflow':
118+
return createAssistantAPIErrorMessage({
119+
content: `The conversation exceeded the provider context limit. ${compactHint}`,
120+
error: 'invalid_request',
121+
})
122+
123+
case 'tool_call_incompatible':
124+
return createAssistantAPIErrorMessage({
125+
content: `The selected provider/model rejected tool-calling payloads. Try ${switchCmd} to pick a tool-capable model or continue without tools.`,
126+
error: 'invalid_request',
127+
})
128+
129+
case 'malformed_provider_response':
130+
return createAssistantAPIErrorMessage({
131+
content: `${API_ERROR_MESSAGE_PREFIX}: Provider returned a malformed response. Confirm endpoint compatibility and check local proxy/network middleware.`,
132+
error: 'unknown',
133+
errorDetails: stripOpenAICompatibilityMetadata(options.rawMessage),
134+
})
135+
136+
case 'provider_unavailable':
137+
return createAssistantAPIErrorMessage({
138+
content: `${API_ERROR_MESSAGE_PREFIX}: Provider is temporarily unavailable. Retry in a moment.`,
139+
error: 'unknown',
140+
})
141+
142+
case 'network_error':
143+
case 'unknown':
144+
return createAssistantAPIErrorMessage({
145+
content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`,
146+
error: 'unknown',
147+
})
148+
149+
default:
150+
return createAssistantAPIErrorMessage({
151+
content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`,
152+
error: 'unknown',
153+
})
154+
}
155+
}
156+
56157
export function startsWithApiErrorPrefix(text: string): boolean {
57158
return (
58159
text.startsWith(API_ERROR_MESSAGE_PREFIX) ||
@@ -457,6 +558,19 @@ export function getAssistantMessageFromError(
457558
})
458559
}
459560

561+
// OpenAI-compatible transport and HTTP failures include structured category
562+
// markers from openaiShim.ts for actionable end-user remediation.
563+
if (error instanceof APIError) {
564+
const openaiCategory = extractOpenAICategoryMarker(error.message)
565+
if (openaiCategory) {
566+
return mapOpenAICompatibilityFailureToAssistantMessage({
567+
category: openaiCategory,
568+
model,
569+
rawMessage: error.message,
570+
})
571+
}
572+
}
573+
460574
// Check for emergency capacity off switch for Opus PAYG users
461575
if (
462576
error instanceof Error &&
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { expect, test } from 'bun:test'
2+
3+
import {
4+
buildOpenAICompatibilityErrorMessage,
5+
classifyOpenAIHttpFailure,
6+
classifyOpenAINetworkFailure,
7+
extractOpenAICategoryMarker,
8+
formatOpenAICategoryMarker,
9+
} from './openaiErrorClassification.js'
10+
11+
test('classifies localhost ECONNREFUSED as connection_refused', () => {
12+
const error = Object.assign(new TypeError('fetch failed'), {
13+
code: 'ECONNREFUSED',
14+
})
15+
16+
const failure = classifyOpenAINetworkFailure(error, {
17+
url: 'http://localhost:11434/v1/chat/completions',
18+
})
19+
20+
expect(failure.category).toBe('connection_refused')
21+
expect(failure.retryable).toBe(true)
22+
expect(failure.code).toBe('ECONNREFUSED')
23+
expect(failure.hint).toContain('local server is running')
24+
})
25+
26+
test('classifies localhost ENOTFOUND as localhost_resolution_failed', () => {
27+
const error = Object.assign(new TypeError('getaddrinfo ENOTFOUND localhost'), {
28+
code: 'ENOTFOUND',
29+
})
30+
31+
const failure = classifyOpenAINetworkFailure(error, {
32+
url: 'http://localhost:11434/v1/chat/completions',
33+
})
34+
35+
expect(failure.category).toBe('localhost_resolution_failed')
36+
expect(failure.retryable).toBe(true)
37+
expect(failure.code).toBe('ENOTFOUND')
38+
expect(failure.hint).toContain('127.0.0.1')
39+
})
40+
41+
test('classifies model-not-found 404 responses', () => {
42+
const failure = classifyOpenAIHttpFailure({
43+
status: 404,
44+
body: 'The model qwen2.5-coder:7b was not found',
45+
})
46+
47+
expect(failure.category).toBe('model_not_found')
48+
expect(failure.retryable).toBe(false)
49+
})
50+
51+
test('classifies generic 404 responses as endpoint_not_found', () => {
52+
const failure = classifyOpenAIHttpFailure({
53+
status: 404,
54+
body: 'Not Found',
55+
})
56+
57+
expect(failure.category).toBe('endpoint_not_found')
58+
expect(failure.hint).toContain('/v1')
59+
})
60+
61+
test('classifies context-overflow responses', () => {
62+
const failure = classifyOpenAIHttpFailure({
63+
status: 500,
64+
body: 'request too large: maximum context length exceeded',
65+
})
66+
67+
expect(failure.category).toBe('context_overflow')
68+
expect(failure.retryable).toBe(false)
69+
})
70+
71+
test('classifies tool compatibility failures', () => {
72+
const failure = classifyOpenAIHttpFailure({
73+
status: 400,
74+
body: 'tool_calls are not supported by this model',
75+
})
76+
77+
expect(failure.category).toBe('tool_call_incompatible')
78+
})
79+
80+
test('embeds and extracts category markers in formatted messages', () => {
81+
const marker = formatOpenAICategoryMarker('endpoint_not_found')
82+
expect(marker).toBe('[openai_category=endpoint_not_found]')
83+
84+
const formatted = buildOpenAICompatibilityErrorMessage('OpenAI API error 404: Not Found', {
85+
category: 'endpoint_not_found',
86+
hint: 'Confirm OPENAI_BASE_URL includes /v1.',
87+
})
88+
89+
expect(formatted).toContain('[openai_category=endpoint_not_found]')
90+
expect(formatted).toContain('Hint: Confirm OPENAI_BASE_URL includes /v1.')
91+
expect(extractOpenAICategoryMarker(formatted)).toBe('endpoint_not_found')
92+
})
93+
94+
test('ignores unknown category markers during extraction', () => {
95+
const malformed = 'OpenAI API error 500 [openai_category=totally_fake_category]'
96+
expect(extractOpenAICategoryMarker(malformed)).toBeUndefined()
97+
})

0 commit comments

Comments
 (0)