diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index db476c212..74336e37f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1941,23 +1941,41 @@ turnLoop: return al.abortTurn(ts) } - errMsg := strings.ToLower(err.Error()) - isTimeoutError := errors.Is(err, context.DeadlineExceeded) || - strings.Contains(errMsg, "deadline exceeded") || - strings.Contains(errMsg, "client.timeout") || - strings.Contains(errMsg, "timed out") || - strings.Contains(errMsg, "timeout exceeded") + // Extract typed FailoverReason from the error chain. + // FallbackChain already classifies errors; the outer retry loop + // only handles specific recoverable scenarios. + var failErr *providers.FailoverError + failReason := providers.FailoverUnknown + if errors.As(err, &failErr) { + failReason = failErr.Reason + } else if errors.Is(err, context.DeadlineExceeded) { + failReason = providers.FailoverTimeout + } else { + // Single-candidate path: classify raw error via ClassifyError + // to maintain backward compatibility with provider error messages. + errMsg := strings.ToLower(err.Error()) + if strings.Contains(errMsg, "deadline exceeded") || + strings.Contains(errMsg, "client.timeout") || + strings.Contains(errMsg, "timed out") || + strings.Contains(errMsg, "timeout exceeded") { + failReason = providers.FailoverTimeout + } else if strings.Contains(errMsg, "context_length_exceeded") || + strings.Contains(errMsg, "context window") || + strings.Contains(errMsg, "context_window") || + strings.Contains(errMsg, "maximum context length") || + strings.Contains(errMsg, "token limit") || + strings.Contains(errMsg, "too many tokens") || + strings.Contains(errMsg, "max_tokens") || + strings.Contains(errMsg, "invalidparameter") || + strings.Contains(errMsg, "prompt is too long") || + strings.Contains(errMsg, "request too large") { + failReason = providers.FailoverContextOverflow + } + } - isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || - strings.Contains(errMsg, "context window") || - strings.Contains(errMsg, "context_window") || - strings.Contains(errMsg, "maximum context length") || - strings.Contains(errMsg, "token limit") || - strings.Contains(errMsg, "too many tokens") || - strings.Contains(errMsg, "max_tokens") || - strings.Contains(errMsg, "invalidparameter") || - strings.Contains(errMsg, "prompt is too long") || - strings.Contains(errMsg, "request too large")) + isTimeoutError := failReason == providers.FailoverTimeout + + isContextError := failReason == providers.FailoverContextOverflow if isTimeoutError && retry < maxRetries { backoff := time.Duration(retry+1) * 5 * time.Second diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index e7691aa93..4a14b268c 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -102,6 +102,16 @@ var ( rxp(`image exceeds.*mb`), } + modelNotFoundPatterns = []errorPattern{ + rxp(`model[_ ]?not[_ ]?found`), + rxp(`does not exist.*model`), + rxp(`model.*does not exist`), + rxp(`invalid model`), + rxp(`model.*not available`), + rxp(`model.*not supported`), + rxp(`unknown model`), + } + // Transient HTTP status codes that map to timeout (server-side failures). transientStatusCodes = map[int]bool{ 500: true, 502: true, 503: true, @@ -147,6 +157,21 @@ func ClassifyError(err error, provider, model string) *FailoverError { // Try HTTP status code extraction first. if status := extractHTTPStatus(msg); status > 0 { if reason := classifyByStatus(status); reason != "" { + // For transient status codes (5xx), the message body may contain a + // more specific, non-transient error (e.g. zhipu returns 503 with + // "model_not_found"). Check message patterns and prefer them when + // they indicate a concrete, non-transient failure. + if isTransientStatus(status) { + if msgReason := classifyByMessage(msg); msgReason != "" && msgReason != FailoverTimeout { + return &FailoverError{ + Reason: msgReason, + Provider: provider, + Model: model, + Status: status, + Wrapped: err, + } + } + } return &FailoverError{ Reason: reason, Provider: provider, @@ -192,6 +217,9 @@ func classifyByStatus(status int) FailoverReason { // classifyByMessage matches error messages against patterns. // Priority order matters (from OpenClaw classifyFailoverReason). func classifyByMessage(msg string) FailoverReason { + if matchesAny(msg, modelNotFoundPatterns) { + return FailoverFormat // model_not_found is a configuration error, not retriable + } if matchesAny(msg, rateLimitPatterns) { return FailoverRateLimit } @@ -263,3 +291,9 @@ func parseDigits(s string) int { } return n } + +// isTransientStatus returns true for 5xx status codes that represent +// server-side transient failures (should be retried via fallback). +func isTransientStatus(status int) bool { + return transientStatusCodes[status] +} diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 549ec7837..94922f17b 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -302,3 +302,14 @@ func (e *FallbackExhaustedError) Error() string { } return sb.String() } + +// Unwrap returns the last non-skipped attempt's error for errors.Is/As traversal. +// This allows errors.As(err, &FailoverError{}) to work through FallbackExhaustedError. +func (e *FallbackExhaustedError) Unwrap() error { + for i := len(e.Attempts) - 1; i >= 0; i-- { + if !e.Attempts[i].Skipped && e.Attempts[i].Error != nil { + return e.Attempts[i].Error + } + } + return nil +}