Skip to content

Commit 1c06176

Browse files
Merge pull request #109 from JimStenstrom/fix/ollama-json-error-handling-87
Fix/ollama json error handling 87
2 parents 39c567a + 4a4413d commit 1c06176

File tree

4 files changed

+326
-3
lines changed

4 files changed

+326
-3
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import test from 'ava';
2+
import {parseAPIError} from './ai-sdk-client.js';
3+
4+
// Tests for parseAPIError function
5+
// Now using the actual exported function instead of a duplicated copy
6+
7+
test('parseAPIError - handles Ollama unmarshal error from issue #87', t => {
8+
const error = new Error(
9+
"RetryError [AI_RetryError]: Failed after 3 attempts. Last error: unmarshal: invalid character '{' after top-level value",
10+
);
11+
12+
const result = parseAPIError(error);
13+
14+
t.true(result.includes('Ollama server error'));
15+
t.true(result.includes('malformed JSON'));
16+
t.true(result.includes('Restart Ollama'));
17+
t.true(result.includes('Re-pull the model'));
18+
t.true(result.includes('Check Ollama logs'));
19+
t.true(result.includes('Try a different model'));
20+
t.true(result.includes('Original error:'));
21+
});
22+
23+
test('parseAPIError - handles unmarshal error without retry wrapper', t => {
24+
const error = new Error("unmarshal: invalid character '{' after top-level value");
25+
26+
const result = parseAPIError(error);
27+
28+
t.true(result.includes('Ollama server error'));
29+
t.true(result.includes('malformed JSON'));
30+
});
31+
32+
test('parseAPIError - handles 500 error with invalid character (status code takes precedence)', t => {
33+
// This test verifies that HTTP status codes are parsed FIRST,
34+
// so a 500 error with "invalid character" in the message is treated
35+
// as a server error, not an Ollama-specific error
36+
const error = new Error(
37+
"500 Internal Server Error: invalid character 'x' after top-level value",
38+
);
39+
40+
const result = parseAPIError(error);
41+
42+
// Status code parsing takes precedence over Ollama-specific pattern matching
43+
t.is(result, "Server error: invalid character 'x' after top-level value");
44+
});
45+
46+
test('parseAPIError - handles 500 error without JSON parsing issue', t => {
47+
const error = new Error('500 Internal Server Error: database connection failed');
48+
49+
const result = parseAPIError(error);
50+
51+
t.is(result, 'Server error: database connection failed');
52+
});
53+
54+
test('parseAPIError - handles 404 error', t => {
55+
const error = new Error('404 Not Found: model not available');
56+
57+
const result = parseAPIError(error);
58+
59+
t.is(
60+
result,
61+
'Model not found: The requested model may not exist or is unavailable',
62+
);
63+
});
64+
65+
test('parseAPIError - handles connection refused', t => {
66+
const error = new Error('ECONNREFUSED: Connection refused');
67+
68+
const result = parseAPIError(error);
69+
70+
t.is(result, 'Connection failed: Unable to reach the model server');
71+
});
72+
73+
test('parseAPIError - handles timeout error', t => {
74+
const error = new Error('Request timeout: ETIMEDOUT');
75+
76+
const result = parseAPIError(error);
77+
78+
t.is(result, 'Request timed out: The model took too long to respond');
79+
});
80+
81+
test('parseAPIError - handles non-Error objects', t => {
82+
const result = parseAPIError('string error');
83+
84+
t.is(result, 'An unknown error occurred while communicating with the model');
85+
});
86+
87+
test('parseAPIError - handles context length errors', t => {
88+
const error = new Error(
89+
'context length exceeded',
90+
);
91+
92+
const result = parseAPIError(error);
93+
94+
// Use exact assertion instead of OR condition
95+
t.is(result, 'Context too large: Please reduce the conversation length or message size');
96+
});
97+
98+
test('parseAPIError - handles too many tokens errors', t => {
99+
const error = new Error(
100+
'too many tokens in the request',
101+
);
102+
103+
const result = parseAPIError(error);
104+
105+
t.is(result, 'Context too large: Please reduce the conversation length or message size');
106+
});
107+
108+
test('parseAPIError - handles 400 with context length in message', t => {
109+
const error = new Error(
110+
'400 Bad Request: context length exceeded',
111+
);
112+
113+
const result = parseAPIError(error);
114+
115+
// The 400 status code pattern matches first, so we get the full message
116+
t.is(result, 'Bad request: context length exceeded');
117+
});
118+
119+
test('parseAPIError - handles 401 authentication error', t => {
120+
const error = new Error('401 Unauthorized: Invalid API key');
121+
122+
const result = parseAPIError(error);
123+
124+
t.is(result, 'Authentication failed: Invalid API key or credentials');
125+
});
126+
127+
test('parseAPIError - handles 403 forbidden error', t => {
128+
const error = new Error('403 Forbidden: Access denied');
129+
130+
const result = parseAPIError(error);
131+
132+
t.is(result, 'Access forbidden: Check your API permissions');
133+
});
134+
135+
test('parseAPIError - handles 429 rate limit error', t => {
136+
const error = new Error('429 Too Many Requests: Rate limit exceeded');
137+
138+
const result = parseAPIError(error);
139+
140+
t.is(result, 'Rate limit exceeded: Too many requests. Please wait and try again');
141+
});
142+
143+
test('parseAPIError - handles 502 bad gateway error', t => {
144+
const error = new Error('502 Bad Gateway: upstream error');
145+
146+
const result = parseAPIError(error);
147+
148+
t.is(result, 'Server error: upstream error');
149+
});
150+
151+
test('parseAPIError - handles 503 service unavailable error', t => {
152+
const error = new Error('503 Service Unavailable: server overloaded');
153+
154+
const result = parseAPIError(error);
155+
156+
t.is(result, 'Server error: server overloaded');
157+
});
158+
159+
test('parseAPIError - handles reduce tokens message', t => {
160+
const error = new Error('Please reduce the number of tokens in your request');
161+
162+
const result = parseAPIError(error);
163+
164+
t.is(result, 'Too many tokens: Please shorten your message or clear conversation history');
165+
});
166+
167+
test('parseAPIError - cleans up unknown errors', t => {
168+
const error = new Error('Error: Something unexpected happened\nWith more details');
169+
170+
const result = parseAPIError(error);
171+
172+
// Should strip "Error: " prefix and only return first line
173+
t.is(result, 'Something unexpected happened');
174+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import test from 'ava';
2+
import {AISDKClient} from './ai-sdk-client.js';
3+
import type {AIProviderConfig} from './types/config.js';
4+
5+
// Tests for maxRetries configuration
6+
// Now tests actual AISDKClient instantiation and behavior
7+
8+
test('AISDKClient - maxRetries defaults to 2 when not specified', t => {
9+
const config: AIProviderConfig = {
10+
name: 'TestProvider',
11+
type: 'openai-compatible',
12+
models: ['test-model'],
13+
config: {
14+
baseURL: 'http://localhost:11434/v1',
15+
apiKey: 'test-key',
16+
},
17+
};
18+
19+
const client = new AISDKClient(config);
20+
21+
// Verify the client's internal maxRetries is set to default of 2
22+
t.is(client.getMaxRetries(), 2);
23+
});
24+
25+
test('AISDKClient - maxRetries respects custom value', t => {
26+
const config: AIProviderConfig = {
27+
name: 'TestProvider',
28+
type: 'openai-compatible',
29+
models: ['test-model'],
30+
maxRetries: 5,
31+
config: {
32+
baseURL: 'http://localhost:11434/v1',
33+
apiKey: 'test-key',
34+
},
35+
};
36+
37+
const client = new AISDKClient(config);
38+
39+
// Verify the client uses the custom maxRetries value
40+
t.is(client.getMaxRetries(), 5);
41+
});
42+
43+
test('AISDKClient - maxRetries can be set to 0 to disable retries', t => {
44+
// Important: This test verifies that 0 is treated as a valid value,
45+
// not as falsy (which would incorrectly default to 2)
46+
const config: AIProviderConfig = {
47+
name: 'TestProvider',
48+
type: 'openai-compatible',
49+
models: ['test-model'],
50+
maxRetries: 0,
51+
config: {
52+
baseURL: 'http://localhost:11434/v1',
53+
apiKey: 'test-key',
54+
},
55+
};
56+
57+
const client = new AISDKClient(config);
58+
59+
// Verify that 0 is respected (nullish coalescing handles this correctly)
60+
t.is(client.getMaxRetries(), 0);
61+
});
62+
63+
test('AISDKClient - maxRetries handles value of 1', t => {
64+
const config: AIProviderConfig = {
65+
name: 'TestProvider',
66+
type: 'openai-compatible',
67+
models: ['test-model'],
68+
maxRetries: 1,
69+
config: {
70+
baseURL: 'http://localhost:11434/v1',
71+
apiKey: 'test-key',
72+
},
73+
};
74+
75+
const client = new AISDKClient(config);
76+
77+
t.is(client.getMaxRetries(), 1);
78+
});
79+
80+
test('AIProviderConfig type - includes maxRetries in interface', t => {
81+
// Compile-time test that maxRetries is part of the interface
82+
const config: AIProviderConfig = {
83+
name: 'TestProvider',
84+
type: 'openai-compatible',
85+
models: ['test-model'],
86+
maxRetries: 3,
87+
config: {
88+
baseURL: 'http://localhost:11434/v1',
89+
},
90+
};
91+
92+
// TypeScript should not complain about maxRetries property
93+
t.is(typeof config.maxRetries, 'number');
94+
t.true('maxRetries' in config);
95+
});
96+
97+
test('AISDKClient - undefined maxRetries uses default', t => {
98+
// Explicitly set to undefined to test fallback behavior
99+
const config: AIProviderConfig = {
100+
name: 'TestProvider',
101+
type: 'openai-compatible',
102+
models: ['test-model'],
103+
maxRetries: undefined,
104+
config: {
105+
baseURL: 'http://localhost:11434/v1',
106+
apiKey: 'test-key',
107+
},
108+
};
109+
110+
const client = new AISDKClient(config);
111+
112+
// Verify undefined falls back to default of 2
113+
t.is(client.getMaxRetries(), 2);
114+
});

source/ai-sdk-client.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@ import {XMLToolCallParser} from '@/tool-calling/xml-parser';
1515
import {getModelContextLimit} from '@/models/index.js';
1616

1717
/**
18-
* Parses API errors into user-friendly messages
18+
* Parses API errors into user-friendly messages.
19+
* Exported for testing purposes.
1920
*/
20-
function parseAPIError(error: unknown): string {
21+
export function parseAPIError(error: unknown): string {
2122
if (!(error instanceof Error)) {
2223
return 'An unknown error occurred while communicating with the model';
2324
}
2425

2526
const errorMessage = error.message;
2627

27-
// Extract status code and clean message from common error patterns
28+
// Extract status code and clean message from common error patterns FIRST
29+
// This ensures HTTP status codes are properly parsed before falling through
30+
// to more generic pattern matching (like Ollama-specific errors)
2831
const statusMatch = errorMessage.match(
2932
/(?:Error: )?(\d{3})\s+(?:\d{3}\s+)?(?:Bad Request|[^:]+):\s*(.+)/i,
3033
);
@@ -52,6 +55,26 @@ function parseAPIError(error: unknown): string {
5255
}
5356
}
5457

58+
// Handle Ollama-specific unmarshal/JSON parsing errors
59+
// This runs AFTER status code parsing to avoid misclassifying HTTP errors
60+
// that happen to contain JSON parsing error text in their message
61+
if (
62+
errorMessage.includes('unmarshal') ||
63+
(errorMessage.includes('invalid character') &&
64+
errorMessage.includes('after top-level value'))
65+
) {
66+
return (
67+
'Ollama server error: The model returned malformed JSON. ' +
68+
'This usually indicates an issue with the Ollama server or model. ' +
69+
'Try:\n' +
70+
' 1. Restart Ollama: systemctl restart ollama (Linux) or restart the Ollama app\n' +
71+
' 2. Re-pull the model: ollama pull <model-name>\n' +
72+
' 3. Check Ollama logs for more details\n' +
73+
' 4. Try a different model to see if the issue is model-specific\n' +
74+
`Original error: ${errorMessage}`
75+
);
76+
}
77+
5578
// Handle timeout errors
5679
if (errorMessage.includes('timeout') || errorMessage.includes('ETIMEDOUT')) {
5780
return 'Request timed out: The model took too long to respond';
@@ -138,12 +161,15 @@ export class AISDKClient implements LLMClient {
138161
private providerConfig: AIProviderConfig;
139162
private undiciAgent: Agent;
140163
private cachedContextSize: number;
164+
private maxRetries: number;
141165

142166
constructor(providerConfig: AIProviderConfig) {
143167
this.providerConfig = providerConfig;
144168
this.availableModels = providerConfig.models;
145169
this.currentModel = providerConfig.models[0] || '';
146170
this.cachedContextSize = 0;
171+
// Default to 2 retries (same as AI SDK default), or use configured value
172+
this.maxRetries = providerConfig.maxRetries ?? 2;
147173

148174
const {requestTimeout, socketTimeout, connectionPool} = this.providerConfig;
149175
const resolvedSocketTimeout =
@@ -233,6 +259,10 @@ export class AISDKClient implements LLMClient {
233259
return this.cachedContextSize;
234260
}
235261

262+
getMaxRetries(): number {
263+
return this.maxRetries;
264+
}
265+
236266
getAvailableModels(): Promise<string[]> {
237267
return Promise.resolve(this.availableModels);
238268
}
@@ -263,6 +293,7 @@ export class AISDKClient implements LLMClient {
263293
messages: modelMessages,
264294
tools: aiTools,
265295
abortSignal: signal,
296+
maxRetries: this.maxRetries,
266297
});
267298

268299
// Extract tool calls from result
@@ -384,6 +415,7 @@ export class AISDKClient implements LLMClient {
384415
messages: modelMessages,
385416
tools: aiTools,
386417
abortSignal: signal,
418+
maxRetries: this.maxRetries,
387419
});
388420

389421
// Stream tokens

0 commit comments

Comments
 (0)