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:
parent
cda030b5a6
commit
f871eb4880
3 changed files with 80 additions and 11 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue