fix(providers): make invalid model errors retriable for fallback

Previously, 400 errors were classified as FailoverFormat, which is
non-retriable. This caused the fallback chain to abort immediately when
a provider returned 400 with messages like "gemini-3-flash is not a
valid model ID".

This is problematic because:
1. User has model_fallbacks configured: ["openrouter-free", "nemotron-4-340b", ...]
2. Primary model "gemini-flash" fails with 400 "invalid model ID"
3. System aborts instead of trying the next fallback model
4. User sees error even though working fallbacks are configured

Changes:
1. Add new FailoverModel reason for "invalid model" type errors
2. Add patterns to detect invalid model errors:
   - "not a valid model"
   - "invalid model"
   - "model ... is not available"
   - "model not found"
   - "model not supported"
   - etc.
3. Make FailoverModel retriable (unlike FailoverFormat)
4. Check message patterns BEFORE status codes
   - This allows 400 + "invalid model" to be FailoverModel
   - Instead of blindly treating all 400s as FailoverFormat

Now when gemini-flash fails with "invalid model ID":
1. Error is classified as FailoverModel (retriable)
2. Fallback chain tries openrouter-free
3. If that fails, tries nemotron-4-340b
4. Continues until success or all models exhausted
5. Only then shows aggregate error to user

This gives the user a much better experience - the system "just works"
by falling back to working models instead of failing immediately.

Fixes the issue where model_fallbacks were not being used when the
primary model returned a 400 invalid model error.
This commit is contained in:
Vishnuvardhan Reddy 2026-02-26 13:56:57 +00:00
parent cda030b5a6
commit f871eb4880
3 changed files with 80 additions and 11 deletions

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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
}