Skip to content

Commit 354fdb1

Browse files
vraj00222claude
andcommitted
fix(sdk): broaden connection-error patterns; add smoke test for issue #678
Bun's fetch surfaces ECONNREFUSED as code='ConnectionRefused' with message "Unable to connect. Is the computer able to access the url?". Neither matched the original error-wrap regex. Now check both the raw message and the error.code property across Bun/Node patterns. Adds scripts/smoke-test-custom-provider.ts which exercises end-to-end against a live Ollama: • isCustomProvider returns true; model streams from localhost:11434 • trailing-slash baseUrl tolerated • unreachable endpoint produces the friendly wrapped error • CODEBUFF_BASE_URL env-var fallback works when agent has no baseUrl Verified locally against llama3.1:8b on Ollama 0.20.5 — all 4 smoke tests passed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a7294eb commit 354fdb1

2 files changed

Lines changed: 224 additions & 1 deletion

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Smoke test for issue #678 — local OpenAI-compatible provider support.
4+
*
5+
* Verifies the new code paths end-to-end against a running Ollama instance:
6+
* 1. getModelForRequest(customProvider: {...}) returns isCustomProvider: true
7+
* 2. The returned model successfully streams from http://localhost:11434/v1
8+
* 3. When Ollama is unreachable, the friendly error wrapping fires
9+
* 4. The env-var fallback works (CODEBUFF_BASE_URL)
10+
*
11+
* Run: bun scripts/smoke-test-custom-provider.ts
12+
*/
13+
14+
import { streamText } from 'ai'
15+
16+
import { getModelForRequest } from '../sdk/src/impl/model-provider'
17+
18+
const MODEL = 'llama3.1:8b'
19+
const BASE_URL = 'http://localhost:11434/v1'
20+
21+
function header(s: string) {
22+
console.log(`\n${'='.repeat(60)}\n${s}\n${'='.repeat(60)}`)
23+
}
24+
25+
async function streamAndCollect(model: any, prompt: string): Promise<string> {
26+
const response = streamText({
27+
model,
28+
messages: [{ role: 'user', content: prompt }],
29+
maxRetries: 1,
30+
})
31+
let out = ''
32+
for await (const chunk of response.fullStream) {
33+
if (chunk.type === 'text-delta') {
34+
out += (chunk as any).text ?? ''
35+
process.stdout.write((chunk as any).text ?? '')
36+
}
37+
if (chunk.type === 'error') {
38+
throw chunk.error
39+
}
40+
}
41+
return out
42+
}
43+
44+
async function test1_directHappyPath() {
45+
header('Test 1: customProvider returns isCustomProvider + actually streams')
46+
const result = await getModelForRequest({
47+
apiKey: 'cb-not-used-for-this-path',
48+
model: MODEL,
49+
customProvider: { baseUrl: BASE_URL, apiKey: 'ollama' },
50+
})
51+
52+
console.log(`isCustomProvider: ${result.isCustomProvider} (expect: true)`)
53+
console.log(`isChatGptOAuth: ${result.isChatGptOAuth} (expect: false)`)
54+
console.log(`modelId: ${(result.model as any).modelId} (expect: ${MODEL})`)
55+
if (!result.isCustomProvider) throw new Error('FAIL: isCustomProvider !== true')
56+
57+
console.log('\nStreaming "Reply in exactly 4 words":')
58+
const out = await streamAndCollect(
59+
result.model,
60+
'Reply in exactly 4 words.',
61+
)
62+
console.log('\n')
63+
if (!out.trim()) throw new Error('FAIL: empty response from Ollama')
64+
console.log(`✅ Got ${out.length} chars from ${MODEL} via ${BASE_URL}`)
65+
}
66+
67+
async function test2_trailingSlashTolerated() {
68+
header('Test 2: trailing slash on baseUrl is tolerated')
69+
const result = await getModelForRequest({
70+
apiKey: 'cb-not-used',
71+
model: MODEL,
72+
customProvider: { baseUrl: 'http://localhost:11434/v1///', apiKey: 'ollama' },
73+
})
74+
console.log('Streaming with baseUrl=http://localhost:11434/v1///:')
75+
const out = await streamAndCollect(result.model, 'Say "ok" only.')
76+
if (!out.trim()) throw new Error('FAIL: empty response')
77+
console.log('\n✅ Trailing slashes trimmed correctly')
78+
}
79+
80+
async function test3_unreachableEndpointFriendlyError() {
81+
header(
82+
'Test 3: unreachable endpoint produces a friendly error via promptAiSdkStream',
83+
)
84+
// We test this via the full promptAiSdkStream path because that's where the
85+
// error wrapping lives.
86+
const { promptAiSdkStream } = await import('../sdk/src/impl/llm')
87+
88+
const messages = [
89+
{
90+
role: 'user' as const,
91+
content: 'Hi',
92+
},
93+
]
94+
95+
const collected: string[] = []
96+
let caughtError: Error | null = null
97+
try {
98+
const gen = promptAiSdkStream({
99+
apiKey: 'cb-not-used',
100+
runId: 'smoke-run-' + Date.now(),
101+
messages: messages as any,
102+
clientSessionId: 'smoke',
103+
fingerprintId: 'smoke',
104+
model: MODEL as any,
105+
userId: undefined,
106+
userInputId: 'smoke-input',
107+
// Point at a port that is NOT serving anything
108+
agentProviderOptions: { baseUrl: 'http://127.0.0.1:1/v1' } as any,
109+
sendAction: () => {},
110+
logger: {
111+
info: () => {},
112+
warn: () => {},
113+
error: () => {},
114+
debug: () => {},
115+
} as any,
116+
trackEvent: () => {},
117+
signal: new AbortController().signal,
118+
})
119+
for await (const chunk of gen) {
120+
collected.push(JSON.stringify(chunk))
121+
}
122+
} catch (e) {
123+
caughtError = e as Error
124+
}
125+
126+
if (!caughtError) {
127+
throw new Error('FAIL: expected an error, got none. Chunks: ' + collected.join('\n'))
128+
}
129+
console.log('Got error message:\n')
130+
console.log(caughtError.message)
131+
console.log()
132+
if (!caughtError.message.includes('Cannot reach LLM provider')) {
133+
throw new Error(
134+
'FAIL: error message does not contain "Cannot reach LLM provider"',
135+
)
136+
}
137+
if (!caughtError.message.includes('http://127.0.0.1:1/v1')) {
138+
throw new Error('FAIL: error message does not contain the configured URL')
139+
}
140+
console.log('✅ Friendly error wrapping confirmed (URL + troubleshooting included)')
141+
}
142+
143+
async function test4_envVarFallback() {
144+
header('Test 4: env-var fallback (CODEBUFF_BASE_URL) is read when agent has no baseUrl')
145+
process.env.CODEBUFF_BASE_URL = BASE_URL
146+
process.env.CODEBUFF_PROVIDER_API_KEY = 'ollama'
147+
try {
148+
const { promptAiSdkStream } = await import('../sdk/src/impl/llm')
149+
150+
const collected: string[] = []
151+
let textChunks = 0
152+
const gen = promptAiSdkStream({
153+
apiKey: 'cb-not-used',
154+
runId: 'smoke-run-env-' + Date.now(),
155+
messages: [{ role: 'user', content: 'Reply with the word OK only.' }] as any,
156+
clientSessionId: 'smoke',
157+
fingerprintId: 'smoke',
158+
model: MODEL as any,
159+
userId: undefined,
160+
userInputId: 'smoke-input',
161+
// No agentProviderOptions and no clientCustomProvider — only env should apply.
162+
sendAction: () => {},
163+
logger: {
164+
info: () => {},
165+
warn: () => {},
166+
error: () => {},
167+
debug: () => {},
168+
} as any,
169+
trackEvent: () => {},
170+
signal: new AbortController().signal,
171+
})
172+
for await (const chunk of gen) {
173+
if (chunk.type === 'text') textChunks++
174+
collected.push(JSON.stringify(chunk))
175+
}
176+
if (textChunks === 0) {
177+
throw new Error('FAIL: no text chunks via env-var fallback. Got: ' + collected.join('\n'))
178+
}
179+
console.log(`✅ Got ${textChunks} text chunk(s) via env-var fallback`)
180+
} finally {
181+
delete process.env.CODEBUFF_BASE_URL
182+
delete process.env.CODEBUFF_PROVIDER_API_KEY
183+
}
184+
}
185+
186+
async function main() {
187+
console.log(`Smoke target: ${MODEL} @ ${BASE_URL}`)
188+
await test1_directHappyPath()
189+
await test2_trailingSlashTolerated()
190+
await test3_unreachableEndpointFriendlyError()
191+
await test4_envVarFallback()
192+
console.log('\n' + '='.repeat(60))
193+
console.log('All smoke tests passed ✅')
194+
console.log('='.repeat(60))
195+
}
196+
197+
main().catch((err) => {
198+
console.error('\n❌ Smoke test FAILED:')
199+
console.error(err.message ?? err)
200+
if (err.stack) console.error(err.stack)
201+
process.exit(1)
202+
})

sdk/src/impl/llm.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,23 @@ function buildCustomProviderError(args: {
143143
baseUrl: string
144144
model: string
145145
rawMessage: string
146+
rawCode?: string
146147
}): string {
147148
const lower = args.rawMessage.toLowerCase()
149+
const codeLower = (args.rawCode ?? '').toLowerCase()
148150
const isConnectionError =
149151
lower.includes('econnrefused') ||
152+
lower.includes('connectionrefused') ||
153+
lower.includes('connection refused') ||
154+
lower.includes('unable to connect') ||
150155
lower.includes('fetch failed') ||
151156
lower.includes('etimedout') ||
152157
lower.includes('enotfound') ||
153-
lower.includes('socket hang up')
158+
lower.includes('socket hang up') ||
159+
codeLower === 'connectionrefused' ||
160+
codeLower === 'econnrefused' ||
161+
codeLower === 'enotfound' ||
162+
codeLower === 'etimedout'
154163
const isModelNotFound =
155164
lower.includes('model not found') ||
156165
lower.includes('does not exist') ||
@@ -545,11 +554,16 @@ export async function* promptAiSdkStream(
545554
yield* response.fullStream
546555
} catch (e) {
547556
const rawMessage = e instanceof Error ? e.message : String(e)
557+
const rawCode =
558+
e && typeof e === 'object' && 'code' in e
559+
? String((e as { code?: unknown }).code ?? '')
560+
: undefined
548561
throw new Error(
549562
buildCustomProviderError({
550563
baseUrl: resolvedBaseUrl,
551564
model: params.model,
552565
rawMessage,
566+
rawCode,
553567
}),
554568
)
555569
}
@@ -704,11 +718,18 @@ export async function* promptAiSdkStream(
704718
// For custom-provider failures, rewrap with a friendly, actionable message
705719
// before throwing so users see "is Ollama running?" not raw "fetch failed".
706720
if (isCustomProvider && resolvedBaseUrl) {
721+
const rawCode =
722+
chunkValue.error &&
723+
typeof chunkValue.error === 'object' &&
724+
'code' in chunkValue.error
725+
? String((chunkValue.error as { code?: unknown }).code ?? '')
726+
: undefined
707727
throw new Error(
708728
buildCustomProviderError({
709729
baseUrl: resolvedBaseUrl,
710730
model: params.model,
711731
rawMessage: errorMessage,
732+
rawCode,
712733
}),
713734
)
714735
}

0 commit comments

Comments
 (0)