Skip to content

Commit d192ba9

Browse files
committed
fix: robust model fallback chain — stream probe both primary and fallback, try multiple candidates for deprecated models
1 parent 93ba60c commit d192ba9

1 file changed

Lines changed: 101 additions & 35 deletions

File tree

app/lib/.server/llm/stream-text.ts

Lines changed: 101 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)