@@ -23,6 +23,10 @@ import {
2323 markChatGptOAuthRateLimited ,
2424} from './model-provider'
2525import { refreshChatGptOAuthToken } from '../credentials'
26+ import {
27+ getCustomProviderApiKeyFromEnv ,
28+ getCustomProviderBaseUrlFromEnv ,
29+ } from '../env'
2630import { getErrorStatusCode } from '../error-utils'
2731
2832import type { ModelRequestParams } from './model-provider'
@@ -130,6 +134,54 @@ type OpenRouterUsageAccounting = {
130134 }
131135}
132136
137+ /**
138+ * Wrap raw errors from a custom OpenAI-compatible endpoint in a friendly,
139+ * actionable message. Distinguishes connection failures (provider down,
140+ * wrong URL) from model-not-found errors.
141+ */
142+ function buildCustomProviderError ( args : {
143+ baseUrl : string
144+ model : string
145+ rawMessage : string
146+ } ) : string {
147+ const lower = args . rawMessage . toLowerCase ( )
148+ const isConnectionError =
149+ lower . includes ( 'econnrefused' ) ||
150+ lower . includes ( 'fetch failed' ) ||
151+ lower . includes ( 'etimedout' ) ||
152+ lower . includes ( 'enotfound' ) ||
153+ lower . includes ( 'socket hang up' )
154+ const isModelNotFound =
155+ lower . includes ( 'model not found' ) ||
156+ lower . includes ( 'does not exist' ) ||
157+ ( lower . includes ( '404' ) && lower . includes ( args . model . toLowerCase ( ) ) )
158+
159+ if ( isConnectionError ) {
160+ return [
161+ `Cannot reach LLM provider at ${ args . baseUrl } .` ,
162+ `` ,
163+ `Check:` ,
164+ ` • Is the provider running? (e.g. \`ollama serve\` or LM Studio's Local Server)` ,
165+ ` • Is the URL correct? Currently configured: ${ args . baseUrl } ` ,
166+ ` • Is the model '${ args . model } ' loaded? (e.g. \`ollama list\`)` ,
167+ `` ,
168+ `Original error: ${ args . rawMessage } ` ,
169+ ] . join ( '\n' )
170+ }
171+ if ( isModelNotFound ) {
172+ return [
173+ `Model '${ args . model } ' not found at ${ args . baseUrl } .` ,
174+ `` ,
175+ `Check:` ,
176+ ` • Pull the model first: \`ollama pull ${ args . model } \`` ,
177+ ` • Verify the exact tag with \`ollama list\`` ,
178+ `` ,
179+ `Original error: ${ args . rawMessage } ` ,
180+ ] . join ( '\n' )
181+ }
182+ return args . rawMessage
183+ }
184+
133185/**
134186 * Check if an error is an OAuth rate limit error that should trigger fallback.
135187 */
@@ -303,13 +355,34 @@ export async function* promptAiSdkStream(
303355 return promptAborted ( 'User cancelled input' )
304356 }
305357
358+ // Resolve custom-provider precedence: agent > client option > env.
359+ // apiKey is paired with whichever URL "wins" to avoid mixing sources.
360+ const agentBaseUrl = params . agentProviderOptions ?. baseUrl
361+ const agentApiKey = params . agentProviderOptions ?. apiKey
362+ const clientBaseUrl = params . clientCustomProvider ?. baseUrl
363+ const clientApiKey = params . clientCustomProvider ?. apiKey
364+ const envBaseUrl = getCustomProviderBaseUrlFromEnv ( )
365+ const envApiKey = getCustomProviderApiKeyFromEnv ( )
366+
367+ const resolvedBaseUrl = agentBaseUrl ?? clientBaseUrl ?? envBaseUrl
368+ const resolvedApiKey = agentBaseUrl
369+ ? agentApiKey
370+ : clientBaseUrl
371+ ? clientApiKey
372+ : envBaseUrl
373+ ? envApiKey
374+ : undefined
375+
306376 const modelParams : ModelRequestParams = {
307377 apiKey : params . apiKey ,
308378 model : params . model ,
309379 skipChatGptOAuth : params . skipChatGptOAuth ,
310380 costMode : params . costMode ,
381+ ...( resolvedBaseUrl
382+ ? { customProvider : { baseUrl : resolvedBaseUrl , apiKey : resolvedApiKey } }
383+ : { } ) ,
311384 }
312- const { model : aiSDKModel , isChatGptOAuth } =
385+ const { model : aiSDKModel , isChatGptOAuth, isCustomProvider } =
313386 await getModelForRequest ( modelParams )
314387
315388 if ( isChatGptOAuth ) {
@@ -329,9 +402,14 @@ export async function* promptAiSdkStream(
329402 prompt : undefined ,
330403 model : aiSDKModel ,
331404 messages : convertCbToModelMessages ( params ) ,
332- ...( isChatGptOAuth && { maxRetries : 0 } ) ,
333- // For ChatGPT OAuth direct, don't send codebuff metadata/provider options to OpenAI
334- ...( isChatGptOAuth
405+ // ChatGPT OAuth: no retries (we fall back to Codebuff on first failure).
406+ // Custom provider: one retry to handle brief model-load stalls without
407+ // dragging out errors when the provider is actually down.
408+ ...( isChatGptOAuth ? { maxRetries : 0 } : { } ) ,
409+ ...( isCustomProvider ? { maxRetries : 1 } : { } ) ,
410+ // Direct routes (ChatGPT OAuth, custom provider): skip codebuff_metadata
411+ // and OpenRouter routing keys — neither belongs in those request bodies.
412+ ...( isChatGptOAuth || isCustomProvider
335413 ? { }
336414 : {
337415 providerOptions : getProviderOptions ( {
@@ -458,7 +536,27 @@ export async function* promptAiSdkStream(
458536 // Track if we've yielded any content - if so, we can't safely fall back
459537 let hasYieldedContent = false
460538
461- for await ( const chunkValue of response . fullStream ) {
539+ // For custom-provider streams, a connection refusal at request init throws
540+ // from the iterator before any error chunk is emitted. Rewrap into a
541+ // friendly message so users see "is Ollama running?" not raw "fetch failed".
542+ const stream = isCustomProvider && resolvedBaseUrl
543+ ? ( async function * ( ) {
544+ try {
545+ yield * response . fullStream
546+ } catch ( e ) {
547+ const rawMessage = e instanceof Error ? e . message : String ( e )
548+ throw new Error (
549+ buildCustomProviderError ( {
550+ baseUrl : resolvedBaseUrl ,
551+ model : params . model ,
552+ rawMessage,
553+ } ) ,
554+ )
555+ }
556+ } ) ( )
557+ : response . fullStream
558+
559+ for await ( const chunkValue of stream ) {
462560 if ( chunkValue . type !== 'text-delta' ) {
463561 const flushed = stopSequenceHandler . flush ( )
464562 if ( flushed ) {
@@ -603,6 +701,18 @@ export async function* promptAiSdkStream(
603701 'Error in AI SDK stream' ,
604702 )
605703
704+ // For custom-provider failures, rewrap with a friendly, actionable message
705+ // before throwing so users see "is Ollama running?" not raw "fetch failed".
706+ if ( isCustomProvider && resolvedBaseUrl ) {
707+ throw new Error (
708+ buildCustomProviderError ( {
709+ baseUrl : resolvedBaseUrl ,
710+ model : params . model ,
711+ rawMessage : errorMessage ,
712+ } ) ,
713+ )
714+ }
715+
606716 // For all other errors, throw them -- they are fatal.
607717 throw chunkValue . error
608718 }
0 commit comments