@@ -726,38 +726,46 @@ ${fileList.map((f) => `- ${f}`).join('\n')}
726726
727727 let result : Awaited < ReturnType < typeof _streamText > > ;
728728
729- try {
730- result = await _streamText ( streamParams ) ;
731-
732- /*
733- * AI SDK v4 wraps certain provider-level HTTP errors (e.g. 404 for
734- * deprecated/removed models) into stream error events instead of
735- * rejecting the streamText() promise. Probe the first stream event
736- * so the fallback chain below can handle these errors.
737- */
729+ /*
730+ * AI SDK v4 wraps certain provider-level HTTP errors (e.g. 404 for
731+ * deprecated/removed models) into stream error events instead of
732+ * rejecting the streamText() promise. This helper probes the first
733+ * event of the stream and throws if it is an error, enabling the
734+ * fallback chain to catch deferred provider errors.
735+ */
736+ async function probeStreamForErrors (
737+ streamResult : Awaited < ReturnType < typeof _streamText > > ,
738+ ) : Promise < void > {
738739 // eslint-disable-next-line @typescript-eslint/no-explicit-any
739- const rawStream = result . fullStream as any ;
740+ const rawStream = streamResult . fullStream as any ;
740741
741- if ( typeof rawStream ?. tee === 'function' ) {
742- const [ probe , consumer ] = rawStream . tee ( ) ;
743- const reader = probe . getReader ( ) ;
744- const { done, value } = await reader . read ( ) ;
742+ if ( typeof rawStream ?. tee !== 'function' ) {
743+ return ;
744+ }
745745
746- reader . cancel ( ) ;
746+ const [ probe , consumer ] = rawStream . tee ( ) ;
747+ const reader = probe . getReader ( ) ;
748+ const { done, value } = await reader . read ( ) ;
747749
748- if ( ! done && value ?. type === 'error' ) {
749- consumer . cancel ( ) ;
750- throw value . error ?? new Error ( 'Stream returned an error event' ) ;
751- }
750+ reader . cancel ( ) ;
752751
753- // Replace fullStream with the untouched branch so downstream
754- // consumers (monitoring IIFE in api.chat.ts) can still iterate.
755- // mergeIntoDataStream() uses separate internal streams — unaffected.
756- Object . defineProperty ( result , 'fullStream' , {
757- value : consumer ,
758- configurable : true ,
759- } ) ;
752+ if ( ! done && value ?. type === 'error' ) {
753+ consumer . cancel ( ) ;
754+ throw value . error ?? new Error ( 'Stream returned an error event' ) ;
760755 }
756+
757+ // Replace fullStream with the untouched branch so downstream
758+ // consumers (monitoring IIFE in api.chat.ts) can still iterate.
759+ // mergeIntoDataStream() uses separate internal streams — unaffected.
760+ Object . defineProperty ( streamResult , 'fullStream' , {
761+ value : consumer ,
762+ configurable : true ,
763+ } ) ;
764+ }
765+
766+ try {
767+ result = await _streamText ( streamParams ) ;
768+ await probeStreamForErrors ( result ) ;
761769 } catch ( primaryError : unknown ) {
762770 const errorCategory = categorizeLLMError ( primaryError ) ;
763771 const primaryLabel = `${ provider . name } /${ modelDetails . name } ` ;
@@ -771,19 +779,76 @@ ${fileList.map((f) => `- ${f}`).join('\n')}
771779 throw primaryError ;
772780 }
773781
774- // Determine the fallback route — use explicit config or auto-select for model_not_found
782+ // Determine fallback candidates — use explicit config or auto-select for model_not_found
775783 let effectiveFallbackRoute = fallbackRoute ;
776784
777785 if ( ! effectiveFallbackRoute && errorCategory === 'model_not_found' ) {
778- // No explicit fallback configured, but the model was rejected by the provider.
779- // Auto-select the first available model from the same provider that differs from the failed one.
780- const availableModels = LLMManager . getInstance ( ) . getStaticModelListFromProvider ( provider ) ;
781- const altModel = availableModels . find ( ( m ) => m . name !== modelDetails . name ) ;
782-
783- if ( altModel ) {
784- effectiveFallbackRoute = { provider : provider . name , model : altModel . name } ;
785- logger . info ( `Auto-selected fallback model: ${ provider . name } /${ altModel . name } ` ) ;
786+ // No explicit fallback configured — build a list of candidate models to try.
787+ // Prefer dynamic/cached models (represent actually-available API models)
788+ // over static list (which may contain deprecated models).
789+ const llm = LLMManager . getInstance ( ) ;
790+ let candidateModels = llm . getModelList ( ) . filter (
791+ ( m ) => m . provider === provider . name && m . name !== modelDetails . name ,
792+ ) ;
793+
794+ // If the full model list has no alternatives for this provider, fall back to static list
795+ if ( candidateModels . length === 0 ) {
796+ candidateModels = llm . getStaticModelListFromProvider ( provider ) . filter (
797+ ( m ) => m . name !== modelDetails . name ,
798+ ) ;
786799 }
800+
801+ // Try each candidate until one succeeds with stream probing
802+ for ( const candidate of candidateModels ) {
803+ logger . info ( `Trying fallback candidate: ${ provider . name } /${ candidate . name } ` ) ;
804+
805+ try {
806+ const candidateModelDetails = await resolveModel ( {
807+ provider,
808+ currentModel : candidate . name ,
809+ apiKeys,
810+ providerSettings,
811+ serverEnv,
812+ logger,
813+ } ) ;
814+
815+ const candidateModelInstance = provider . getModelInstance ( {
816+ model : candidateModelDetails . name ,
817+ serverEnv,
818+ apiKeys,
819+ providerSettings,
820+ } ) ;
821+
822+ result = await _streamText ( {
823+ ...streamParams ,
824+ model : candidateModelInstance ,
825+ } ) ;
826+ await probeStreamForErrors ( result ) ;
827+
828+ logger . info (
829+ `Fallback succeeded: ${ provider . name } /${ candidateModelDetails . name } ` +
830+ `(primary ${ primaryLabel } failed with ${ errorCategory } )` ,
831+ ) ;
832+
833+ // Found a working model — break out of candidate loop and skip the
834+ // single-route fallback logic below
835+ if ( props . wsConnection ) {
836+ pipeStreamToWebSocket ( result , props . wsConnection ) ;
837+ }
838+
839+ return result ;
840+ } catch ( candidateError : unknown ) {
841+ logger . warn (
842+ `Fallback candidate ${ provider . name } /${ candidate . name } failed: ` +
843+ `${ candidateError instanceof Error ? candidateError . message : String ( candidateError ) } ` ,
844+ ) ;
845+ // Continue to next candidate
846+ }
847+ }
848+
849+ // All candidates exhausted — no effectiveFallbackRoute to try below
850+ logger . error ( `All ${ candidateModels . length } fallback candidates for ${ provider . name } failed` ) ;
851+ throw primaryError ;
787852 }
788853
789854 if ( ! effectiveFallbackRoute ) {
@@ -818,6 +883,7 @@ ${fileList.map((f) => `- ${f}`).join('\n')}
818883 ...streamParams ,
819884 model : fallbackModelInstance ,
820885 } ) ;
886+ await probeStreamForErrors ( result ) ;
821887
822888 logger . info (
823889 `Fallback succeeded: ${ effectiveFallbackRoute . provider } /${ fallbackModelDetails . name } ` +
0 commit comments