@@ -83,26 +83,190 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
8383 stream_options : { include_usage : true } ,
8484 }
8585
86- const stream = await this . client . chat . completions . create ( params )
86+ const stream = await this . retryApiCall ( ( ) => this . client . chat . completions . create ( params ) , "streaming request" )
8787
88- for await ( const chunk of stream ) {
89- const delta = chunk . choices [ 0 ] ?. delta
88+ try {
89+ for await ( const chunk of stream ) {
90+ try {
91+ const delta = chunk . choices [ 0 ] ?. delta
92+
93+ if ( delta ?. content ) {
94+ yield {
95+ type : "text" ,
96+ text : delta . content ,
97+ }
98+ }
9099
91- if ( delta ?. content ) {
92- yield {
93- type : "text" ,
94- text : delta . content ,
100+ if ( chunk . usage ) {
101+ yield {
102+ type : "usage" ,
103+ inputTokens : chunk . usage . prompt_tokens || 0 ,
104+ outputTokens : chunk . usage . completion_tokens || 0 ,
105+ }
106+ }
107+ } catch ( error ) {
108+ // Handle streaming chunk processing errors
109+ this . handleStreamingError ( error )
95110 }
96111 }
112+ } catch ( error ) {
113+ // Handle streaming errors that occur after initial connection
114+ this . handleStreamingError ( error )
115+ }
116+ }
97117
98- if ( chunk . usage ) {
99- yield {
100- type : "usage" ,
101- inputTokens : chunk . usage . prompt_tokens || 0 ,
102- outputTokens : chunk . usage . completion_tokens || 0 ,
103- }
118+ /**
119+ * Handle streaming-specific errors that occur during chunk processing
120+ */
121+ private handleStreamingError ( error : unknown ) : never {
122+ if ( error instanceof Error ) {
123+ const message = error . message . toLowerCase ( )
124+
125+ if ( message . includes ( "premature close" ) || message . includes ( "connection closed" ) ) {
126+ throw new Error (
127+ `${ this . providerName } connection was closed unexpectedly. This may be due to:\n` +
128+ `• Network connectivity issues\n` +
129+ `• Server overload or maintenance\n` +
130+ `• Request timeout\n\n` +
131+ `Please try again in a moment. If the issue persists, check your network connection or try a different model.` ,
132+ )
133+ }
134+
135+ if ( message . includes ( "invalid response body" ) || message . includes ( "unexpected token" ) ) {
136+ throw new Error (
137+ `${ this . providerName } returned an invalid response. This may be due to:\n` +
138+ `• Server-side processing errors\n` +
139+ `• Temporary service disruption\n` +
140+ `• Model compatibility issues\n\n` +
141+ `Please try again with a different model or contact support if the issue persists.` ,
142+ )
143+ }
144+
145+ throw new Error ( `${ this . providerName } streaming error: ${ error . message } ` )
146+ }
147+
148+ throw new Error ( `${ this . providerName } encountered an unexpected streaming error` )
149+ }
150+
151+ /**
152+ * Handle API request errors with detailed, user-friendly messages
153+ */
154+ private handleApiError ( error : unknown ) : never {
155+ if ( error instanceof Error ) {
156+ const message = error . message . toLowerCase ( )
157+
158+ // Handle specific connection errors
159+ if ( message . includes ( "econnreset" ) || message . includes ( "connection reset" ) ) {
160+ throw new Error (
161+ `Connection to ${ this . providerName } was reset. This usually indicates:\n` +
162+ `• Network connectivity issues\n` +
163+ `• Server overload\n` +
164+ `• Firewall or proxy interference\n\n` +
165+ `Please check your network connection and try again.` ,
166+ )
167+ }
168+
169+ if ( message . includes ( "econnrefused" ) || message . includes ( "connection refused" ) ) {
170+ throw new Error (
171+ `Cannot connect to ${ this . providerName } server. This may be due to:\n` +
172+ `• Incorrect API endpoint URL\n` +
173+ `• Server maintenance or downtime\n` +
174+ `• Network firewall blocking the connection\n\n` +
175+ `Please verify your API configuration and try again later.` ,
176+ )
177+ }
178+
179+ if ( message . includes ( "etimedout" ) || message . includes ( "timeout" ) ) {
180+ throw new Error (
181+ `Request to ${ this . providerName } timed out. This may be due to:\n` +
182+ `• Slow network connection\n` +
183+ `• Server overload\n` +
184+ `• Large request processing time\n\n` +
185+ `Please try again with a shorter prompt or check your network connection.` ,
186+ )
187+ }
188+
189+ if ( message . includes ( "enotfound" ) || message . includes ( "not found" ) ) {
190+ throw new Error (
191+ `Cannot resolve ${ this . providerName } server address. This may be due to:\n` +
192+ `• Incorrect API endpoint URL\n` +
193+ `• DNS resolution issues\n` +
194+ `• Network connectivity problems\n\n` +
195+ `Please verify your API configuration and network connection.` ,
196+ )
197+ }
198+
199+ // Handle premature close and invalid response body errors
200+ if ( message . includes ( "premature close" ) ) {
201+ throw new Error (
202+ `${ this . providerName } connection closed unexpectedly. This may be due to:\n` +
203+ `• Network connectivity issues\n` +
204+ `• Server overload or maintenance\n` +
205+ `• Request timeout\n\n` +
206+ `Please try again in a moment. If the issue persists, check your network connection.` ,
207+ )
208+ }
209+
210+ if ( message . includes ( "invalid response body" ) ) {
211+ throw new Error (
212+ `${ this . providerName } returned an invalid response. This may be due to:\n` +
213+ `• Server-side processing errors\n` +
214+ `• Temporary service disruption\n` +
215+ `• Model compatibility issues\n\n` +
216+ `Please try again with a different model or contact support if the issue persists.` ,
217+ )
104218 }
105219 }
220+
221+ // Handle OpenAI SDK errors
222+ if ( error && typeof error === "object" && "status" in error ) {
223+ const status = ( error as any ) . status
224+ const errorMessage = ( error as any ) . message || "Unknown error"
225+
226+ switch ( status ) {
227+ case 401 :
228+ throw new Error (
229+ `${ this . providerName } authentication failed. Please check your API key and ensure it's valid and has the necessary permissions.` ,
230+ )
231+ case 403 :
232+ throw new Error (
233+ `${ this . providerName } access forbidden. This may be due to:\n` +
234+ `• Invalid or expired API key\n` +
235+ `• Insufficient permissions for the requested model\n` +
236+ `• Account limitations or restrictions\n\n` +
237+ `Please verify your API key and account status.` ,
238+ )
239+ case 404 :
240+ throw new Error (
241+ `${ this . providerName } model or endpoint not found. Please verify:\n` +
242+ `• The model name is correct and available\n` +
243+ `• Your API endpoint URL is properly configured\n` +
244+ `• Your account has access to the requested model` ,
245+ )
246+ case 429 :
247+ throw new Error (
248+ `${ this . providerName } rate limit exceeded. Please:\n` +
249+ `• Wait a moment before trying again\n` +
250+ `• Consider upgrading your API plan for higher limits\n` +
251+ `• Reduce the frequency of your requests` ,
252+ )
253+ case 500 :
254+ case 502 :
255+ case 503 :
256+ throw new Error (
257+ `${ this . providerName } server error (${ status } ). This is a temporary issue on their end. Please try again in a few moments.` ,
258+ )
259+ default :
260+ throw new Error ( `${ this . providerName } API error (${ status } ): ${ errorMessage } ` )
261+ }
262+ }
263+
264+ // Fallback for unknown errors
265+ if ( error instanceof Error ) {
266+ throw new Error ( `${ this . providerName } error: ${ error . message } ` )
267+ }
268+
269+ throw new Error ( `${ this . providerName } encountered an unexpected error` )
106270 }
107271
108272 async completePrompt ( prompt : string ) : Promise < string > {
@@ -116,12 +280,81 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
116280
117281 return response . choices [ 0 ] ?. message . content || ""
118282 } catch ( error ) {
119- if ( error instanceof Error ) {
120- throw new Error ( `${ this . providerName } completion error: ${ error . message } ` )
283+ // Format error message to match expected test format
284+ const errorMessage = error instanceof Error ? error . message : "Unknown error"
285+ throw new Error ( `${ this . providerName } completion error: ${ errorMessage } ` )
286+ }
287+ }
288+
289+ /**
290+ * Retry API calls with exponential backoff for transient failures
291+ */
292+ private async retryApiCall < T > (
293+ apiCall : ( ) => Promise < T > ,
294+ operationType : string ,
295+ maxRetries : number = 3 ,
296+ ) : Promise < T > {
297+ let lastError : unknown
298+
299+ for ( let attempt = 1 ; attempt <= maxRetries ; attempt ++ ) {
300+ try {
301+ return await apiCall ( )
302+ } catch ( error ) {
303+ lastError = error
304+
305+ // Don't retry on certain types of errors
306+ if ( this . shouldNotRetry ( error ) ) {
307+ throw error // Throw original error to preserve test expectations
308+ }
309+
310+ // If this is the last attempt, throw the original error
311+ if ( attempt === maxRetries ) {
312+ throw error // Throw original error to preserve test expectations
313+ }
314+
315+ // Calculate delay with exponential backoff and jitter
316+ const baseDelay = Math . pow ( 2 , attempt - 1 ) * 1000 // 1s, 2s, 4s
317+ const jitter = Math . random ( ) * 1000 // Add up to 1s of jitter
318+ const delay = baseDelay + jitter
319+
320+ console . warn (
321+ `${ this . providerName } ${ operationType } failed (attempt ${ attempt } /${ maxRetries } ). ` +
322+ `Retrying in ${ Math . round ( delay ) } ms...` ,
323+ )
324+
325+ await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) )
121326 }
327+ }
122328
123- throw error
329+ // This should never be reached, but TypeScript needs it
330+ throw lastError
331+ }
332+
333+ /**
334+ * Determine if an error should not be retried
335+ */
336+ private shouldNotRetry ( error : unknown ) : boolean {
337+ if ( error && typeof error === "object" && "status" in error ) {
338+ const status = ( error as any ) . status
339+ // Don't retry on client errors (4xx) except for 429 (rate limit)
340+ if ( status >= 400 && status < 500 && status !== 429 ) {
341+ return true
342+ }
343+ }
344+
345+ if ( error instanceof Error ) {
346+ const message = error . message . toLowerCase ( )
347+ // Don't retry on authentication or authorization errors
348+ if (
349+ message . includes ( "unauthorized" ) ||
350+ message . includes ( "forbidden" ) ||
351+ message . includes ( "invalid api key" )
352+ ) {
353+ return true
354+ }
124355 }
356+
357+ return false
125358 }
126359
127360 override getModel ( ) {
0 commit comments