@@ -77,6 +77,10 @@ const sandboxToolPolicyAuditMessages = new WeakSet<AssistantMessage>();
7777export const GENERIC_ASSISTANT_ERROR_TEXT = "LLM request failed." ;
7878const PROVIDER_SCHEMA_REJECTION_USER_TEXT =
7979 "LLM request failed: provider rejected the request schema or tool payload." ;
80+ const MODEL_NOT_FOUND_USER_TEXT =
81+ "The selected model was not found by the provider. Check the model id or choose a different model." ;
82+ const MAX_FAILOVER_DETAIL_CANDIDATES = 12 ;
83+ const MAX_FAILOVER_DETAIL_CHARS = 1_000 ;
8084
8185/** Detect provider errors that require reasoning to stay enabled. */
8286export function isReasoningConstraintErrorMessage ( raw : string ) : boolean {
@@ -276,6 +280,7 @@ export type FailoverSignal = {
276280 errorType ?: string ;
277281 message ?: string ;
278282 provider ?: string ;
283+ details ?: readonly string [ ] ;
279284} ;
280285
281286export type FailoverClassification =
@@ -287,6 +292,87 @@ export type FailoverClassification =
287292 kind : "context_overflow" ;
288293 } ;
289294
295+ // Provider SDKs often keep semantic error fields outside Error.message.
296+ // These bounded candidates feed classification only; user-facing copy still
297+ // comes from the normal sanitized formatter path.
298+ function normalizeFailoverDetailString ( value : string | undefined ) : string | undefined {
299+ const trimmed = value ?. trim ( ) ;
300+ if ( ! trimmed ) {
301+ return undefined ;
302+ }
303+ return trimmed . length > MAX_FAILOVER_DETAIL_CHARS
304+ ? trimmed . slice ( 0 , MAX_FAILOVER_DETAIL_CHARS )
305+ : trimmed ;
306+ }
307+
308+ function appendFailoverDetailCandidate ( candidates : string [ ] , value : unknown ) : void {
309+ const normalized =
310+ typeof value === "string" || typeof value === "number" || typeof value === "boolean"
311+ ? normalizeFailoverDetailString ( String ( value ) )
312+ : undefined ;
313+ if ( ! normalized || candidates . includes ( normalized ) ) {
314+ return ;
315+ }
316+ candidates . push ( normalized ) ;
317+ }
318+
319+ function collectFailoverDetailCandidates (
320+ value : unknown ,
321+ candidates : string [ ] ,
322+ seen : Set < object > ,
323+ ) : void {
324+ if (
325+ candidates . length >= MAX_FAILOVER_DETAIL_CANDIDATES ||
326+ value === undefined ||
327+ value === null
328+ ) {
329+ return ;
330+ }
331+ if ( typeof value === "string" ) {
332+ appendFailoverDetailCandidate ( candidates , value ) ;
333+ const trimmed = value . trim ( ) ;
334+ if ( ! trimmed . startsWith ( "{" ) || ! trimmed . endsWith ( "}" ) ) {
335+ return ;
336+ }
337+ try {
338+ collectFailoverDetailCandidates ( JSON . parse ( trimmed ) as unknown , candidates , seen ) ;
339+ } catch {
340+ // Non-JSON detail strings are still useful as direct classifier candidates.
341+ }
342+ return ;
343+ }
344+ if ( typeof value === "number" || typeof value === "boolean" ) {
345+ appendFailoverDetailCandidate ( candidates , value ) ;
346+ return ;
347+ }
348+ if ( ! value || typeof value !== "object" || Array . isArray ( value ) ) {
349+ return ;
350+ }
351+ if ( seen . has ( value ) ) {
352+ return ;
353+ }
354+ seen . add ( value ) ;
355+ const record = value as Record < string , unknown > ;
356+ for ( const key of [ "message" , "param" , "code" , "type" , "error" , "detail" , "body" ] ) {
357+ collectFailoverDetailCandidates ( record [ key ] , candidates , seen ) ;
358+ if ( candidates . length >= MAX_FAILOVER_DETAIL_CANDIDATES ) {
359+ return ;
360+ }
361+ }
362+ }
363+
364+ export function extractFailoverSignalDetails ( ...values : unknown [ ] ) : string [ ] | undefined {
365+ const candidates : string [ ] = [ ] ;
366+ const seen = new Set < object > ( ) ;
367+ for ( const value of values ) {
368+ collectFailoverDetailCandidates ( value , candidates , seen ) ;
369+ if ( candidates . length >= MAX_FAILOVER_DETAIL_CANDIDATES ) {
370+ break ;
371+ }
372+ }
373+ return candidates . length > 0 ? candidates : undefined ;
374+ }
375+
290376export type ProviderRuntimeFailureKind =
291377 | "auth_scope"
292378 | "auth_refresh"
@@ -302,6 +388,7 @@ export type ProviderRuntimeFailureKind =
302388 | "rate_limit"
303389 | "dns"
304390 | "timeout"
391+ | "model_not_found"
305392 | "schema"
306393 | "sandbox_blocked"
307394 | "replay_invalid"
@@ -1009,6 +1096,49 @@ function classifyFailoverClassificationFromMessage(
10091096 return null ;
10101097}
10111098
1099+ function classificationReason (
1100+ classification : FailoverClassification | null ,
1101+ ) : FailoverReason | undefined {
1102+ return classification ?. kind === "reason" ? classification . reason : undefined ;
1103+ }
1104+
1105+ function classifyFailoverDetailCandidates (
1106+ details : readonly string [ ] | undefined ,
1107+ provider : string | undefined ,
1108+ includeProviderPluginHooks : boolean ,
1109+ ) : FailoverClassification | null {
1110+ for ( const detail of details ?? [ ] ) {
1111+ const classification = classifyFailoverClassificationFromMessage ( detail , provider , {
1112+ includeProviderPluginHooks,
1113+ } ) ;
1114+ if ( classification ) {
1115+ return classification ;
1116+ }
1117+ }
1118+ return null ;
1119+ }
1120+
1121+ function mergeMessageAndDetailClassification (
1122+ messageClassification : FailoverClassification | null ,
1123+ detailClassification : FailoverClassification | null ,
1124+ ) : FailoverClassification | null {
1125+ if ( ! messageClassification ) {
1126+ return detailClassification ;
1127+ }
1128+ if ( ! detailClassification ) {
1129+ return messageClassification ;
1130+ }
1131+ if ( messageClassification . kind === "context_overflow" ) {
1132+ return messageClassification ;
1133+ }
1134+ if ( detailClassification . kind === "context_overflow" ) {
1135+ return detailClassification ;
1136+ }
1137+ return classificationReason ( messageClassification ) === "format"
1138+ ? detailClassification
1139+ : messageClassification ;
1140+ }
1141+
10121142export function classifyFailoverSignal ( signal : FailoverSignal ) : FailoverClassification | null {
10131143 const inferredStatus = inferSignalStatus ( signal ) ;
10141144 const explicitStatus =
@@ -1029,6 +1159,11 @@ export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassifi
10291159 includeProviderPluginHooks : ! hasStructuredProviderSignal ,
10301160 } )
10311161 : null ;
1162+ const detailClassification = classifyFailoverDetailCandidates (
1163+ signal . details ,
1164+ signal . provider ,
1165+ ! hasStructuredProviderSignal ,
1166+ ) ;
10321167 const providerPluginReason =
10331168 hasStructuredProviderSignal &&
10341169 signal . provider &&
@@ -1043,7 +1178,7 @@ export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassifi
10431178 : null ;
10441179 const effectiveMessageClassification = providerPluginReason
10451180 ? toReasonClassification ( providerPluginReason )
1046- : messageClassification ;
1181+ : mergeMessageAndDetailClassification ( messageClassification , detailClassification ) ;
10471182 const codeReason = classifyFailoverReasonFromCode ( signal . code ) ;
10481183 if ( codeReason === "auth_permanent" ) {
10491184 return toReasonClassification ( codeReason ) ;
@@ -1110,6 +1245,12 @@ export function classifyProviderRuntimeFailureKind(
11101245 if ( failoverClassification ?. kind === "reason" && failoverClassification . reason === "rate_limit" ) {
11111246 return "rate_limit" ;
11121247 }
1248+ if (
1249+ failoverClassification ?. kind === "reason" &&
1250+ failoverClassification . reason === "model_not_found"
1251+ ) {
1252+ return "model_not_found" ;
1253+ }
11131254 if ( message && isDnsTransportErrorMessage ( message ) ) {
11141255 return "dns" ;
11151256 }
@@ -1155,6 +1296,32 @@ export function classifyProviderRuntimeFailureKind(
11551296 return "unclassified" ;
11561297}
11571298
1299+ function buildAssistantFailoverSignal (
1300+ msg : AssistantMessage ,
1301+ opts ?: { provider ?: string } ,
1302+ ) : FailoverSignal {
1303+ return {
1304+ status : extractLeadingHttpStatus ( msg . errorMessage ?. trim ( ) ?? "" ) ?. code ,
1305+ code : msg . errorCode ,
1306+ errorType : msg . errorType ,
1307+ message : msg . errorMessage ?. trim ( ) || undefined ,
1308+ provider : opts ?. provider ?? msg . provider ,
1309+ details : extractFailoverSignalDetails ( msg . errorBody ) ,
1310+ } ;
1311+ }
1312+
1313+ export function classifyAssistantFailoverReason (
1314+ msg : AssistantMessage | undefined ,
1315+ opts ?: { provider ?: string } ,
1316+ ) : FailoverReason | null {
1317+ if ( ! msg || msg . stopReason !== "error" ) {
1318+ return null ;
1319+ }
1320+ return failoverReasonFromClassification (
1321+ classifyFailoverSignal ( buildAssistantFailoverSignal ( msg , opts ) ) ,
1322+ ) ;
1323+ }
1324+
11581325export function formatAssistantErrorText (
11591326 msg : AssistantMessage ,
11601327 opts ?: { cfg ?: OpenClawConfig ; sessionKey ?: string ; provider ?: string ; model ?: string } ,
@@ -1169,9 +1336,8 @@ export function formatAssistantErrorText(
11691336 }
11701337
11711338 const providerRuntimeFailureKind = classifyProviderRuntimeFailureKind ( {
1172- status : extractLeadingHttpStatus ( raw ) ?. code ,
1339+ ... buildAssistantFailoverSignal ( msg , { provider : opts ?. provider } ) ,
11731340 message : raw ,
1174- provider : opts ?. provider ?? msg . provider ,
11751341 } ) ;
11761342
11771343 const unknownTool =
@@ -1264,6 +1430,10 @@ export function formatAssistantErrorText(
12641430 return "LLM request failed: proxy or tunnel configuration blocked the provider request." ;
12651431 }
12661432
1433+ if ( providerRuntimeFailureKind === "model_not_found" ) {
1434+ return MODEL_NOT_FOUND_USER_TEXT ;
1435+ }
1436+
12671437 if ( isContextOverflowError ( raw ) ) {
12681438 return (
12691439 "Context overflow: prompt too large for the model. " +
@@ -1580,8 +1750,5 @@ export function isFailoverErrorMessage(raw: string, opts?: { provider?: string }
15801750}
15811751
15821752export function isFailoverAssistantError ( msg : AssistantMessage | undefined ) : boolean {
1583- if ( ! msg || msg . stopReason !== "error" ) {
1584- return false ;
1585- }
1586- return isFailoverErrorMessage ( msg . errorMessage ?? "" , { provider : msg . provider } ) ;
1753+ return classifyAssistantFailoverReason ( msg ) !== null ;
15871754}
0 commit comments