diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index a0f003006..96fa8675b 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -78,6 +78,17 @@ var ( substr("invalid request format"), } + invalidModelPatterns = []errorPattern{ + rxp(`not a valid model`), + rxp(`invalid model`), + rxp(`model .+ is not available`), + rxp(`model .+ does not exist`), + rxp(`unknown model`), + rxp(`model_not_found`), + substr("model not found"), + rxp(`model not supported`), + } + imageDimensionPatterns = []errorPattern{ rxp(`image dimensions exceed max`), } @@ -128,7 +139,20 @@ func ClassifyError(err error, provider, model string) *FailoverError { } } - // Try HTTP status code extraction first. + // Message pattern matching FIRST (priority over status codes). + // This allows 400 errors with specific messages to be classified correctly. + // For example, 400 + "not a valid model ID" should be FailoverModel (retriable) + // not FailoverFormat (non-retriable). + if reason := classifyByMessage(msg); reason != "" { + return &FailoverError{ + Reason: reason, + Provider: provider, + Model: model, + Wrapped: err, + } + } + + // Then try HTTP status code extraction (only if message didn't match). if status := extractHTTPStatus(msg); status > 0 { if reason := classifyByStatus(status); reason != "" { return &FailoverError{ @@ -141,16 +165,6 @@ func ClassifyError(err error, provider, model string) *FailoverError { } } - // Message pattern matching (priority order from OpenClaw). - if reason := classifyByMessage(msg); reason != "" { - return &FailoverError{ - Reason: reason, - Provider: provider, - Model: model, - Wrapped: err, - } - } - return nil } @@ -191,6 +205,9 @@ func classifyByMessage(msg string) FailoverReason { if matchesAny(msg, authPatterns) { return FailoverAuth } + if matchesAny(msg, invalidModelPatterns) { + return FailoverModel + } if matchesAny(msg, formatPatterns) { return FailoverFormat } diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 865aea57a..ebf574b90 100644 --- a/pkg/providers/error_classifier_test.go +++ b/pkg/providers/error_classifier_test.go @@ -335,3 +335,53 @@ func TestIsImageSizeError(t *testing.T) { t.Error("should not match normal error") } } + +func TestClassifyError_InvalidModelErrors(t *testing.T) { + tests := []struct { + name string + msg string + reason FailoverReason + retriable bool + }{ + { + name: "antigravity invalid model 400", + msg: "API request failed: Status: 400 Body: {\"error\":{\"message\":\"gemini-3-flash is not a valid model ID\",\"code\":400}}", + reason: FailoverModel, + retriable: true, + }, + { + name: "openrouter invalid model", + msg: "invalid model: gpt-5 does not exist", + reason: FailoverModel, + retriable: true, + }, + { + name: "model not available", + msg: "model claude-opus is not available", + reason: FailoverModel, + retriable: true, + }, + { + name: "model not found", + msg: "model not found: unknown-model-x", + reason: FailoverModel, + retriable: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := errors.New(tt.msg) + result := ClassifyError(err, "test-provider", "test-model") + if result == nil { + t.Fatalf("expected non-nil error, got nil") + } + if result.Reason != tt.reason { + t.Errorf("reason = %q, want %q", result.Reason, tt.reason) + } + if result.IsRetriable() != tt.retriable { + t.Errorf("IsRetriable() = %v, want %v", result.IsRetriable(), tt.retriable) + } + }) + } +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index f0c168bc6..ef9ded481 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -47,6 +47,7 @@ const ( FailoverTimeout FailoverReason = "timeout" FailoverFormat FailoverReason = "format" FailoverOverloaded FailoverReason = "overloaded" + FailoverModel FailoverReason = "model" FailoverUnknown FailoverReason = "unknown" ) @@ -70,6 +71,7 @@ func (e *FailoverError) Unwrap() error { // IsRetriable returns true if this error should trigger fallback to next candidate. // Non-retriable: Format errors (bad request structure, image dimension/size). +// Retriable: Auth, rate_limit, billing, timeout, model, overloaded. func (e *FailoverError) IsRetriable() bool { return e.Reason != FailoverFormat }