diff --git a/internal/accounts/classify.go b/internal/accounts/classify.go index 30c957c..ac45c06 100644 --- a/internal/accounts/classify.go +++ b/internal/accounts/classify.go @@ -16,6 +16,7 @@ const ( KindUnavailable = "unavailable" KindInvalidRequest = "invalid_request" KindModelNotAvailable = "model_not_available" + KindCanceled = "canceled" ) const maxRetryAfter = 10 * time.Minute diff --git a/internal/accounts/pool.go b/internal/accounts/pool.go index 050db84..84ba264 100644 --- a/internal/accounts/pool.go +++ b/internal/accounts/pool.go @@ -160,8 +160,29 @@ func CanonicalModelID(model string) string { return key } +// NormalizeModelName converts display-name formats sent by external clients +// into the canonical model ID used for routing. It currently strips a leading +// "Provider: " segment (single-word provider, no spaces) before lowercasing +// and folding separators so names like "DeepSeek: DeepSeek V4.1 Flash" become +// "deepseek-v4.1-flash". +func NormalizeModelName(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return model + } + parts := strings.SplitN(model, ":", 2) + if len(parts) == 2 { + provider := strings.TrimSpace(parts[0]) + displayName := strings.TrimSpace(parts[1]) + if !strings.Contains(provider, " ") { + model = displayName + } + } + return CanonicalModelID(model) +} + func routeModel(model string) string { - id := CanonicalModelID(model) + id := NormalizeModelName(model) if id == "" || id == "auto" { return "" } diff --git a/internal/accounts/pool_test.go b/internal/accounts/pool_test.go index a6210a0..921b994 100644 --- a/internal/accounts/pool_test.go +++ b/internal/accounts/pool_test.go @@ -569,3 +569,17 @@ func TestQuotaCooledEmptyCatalogSurfacesQuotaHint(t *testing.T) { t.Fatal("quota-cooled empty catalog must not count as a live candidate") } } + +func TestNormalizeModelNameStripsProviderPrefix(t *testing.T) { + for input, want := range map[string]string{ + "DeepSeek: DeepSeek V4.1 Flash": "deepseek-v4.1-flash", + "DeepSeek_V4.1_Flash": "deepseek-v4.1-flash", + "workbuddy/deepseek-v4.1-flash": "deepseek-v4.1-flash", + "MiniMax-M3": "minimax-m3", + "Qwen3.7-Plus": "qwen3.7-plus", + } { + if got := NormalizeModelName(input); got != want { + t.Fatalf("NormalizeModelName(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/api/chat.go b/internal/api/chat.go index 052d4f1..bcf1e15 100644 --- a/internal/api/chat.go +++ b/internal/api/chat.go @@ -912,7 +912,7 @@ func classifyAPIError(err error) accounts.Classified { } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return accounts.Classified{ - Kind: accounts.KindUnavailable, Status: 499, Failover: false, + Kind: accounts.KindCanceled, Status: 499, Failover: false, Code: "request_canceled", Message: err.Error(), } } diff --git a/internal/executor/chat.go b/internal/executor/chat.go index 7f47666..59c25e1 100644 --- a/internal/executor/chat.go +++ b/internal/executor/chat.go @@ -965,6 +965,12 @@ func (e ChatExecutor) classifyInProcessError(err error) accounts.Classified { } var providerErr *providers.Error if !errors.As(err, &providerErr) || providerErr == nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return accounts.Classified{ + Kind: accounts.KindCanceled, Status: 499, Failover: false, + Code: "request_canceled", Message: err.Error(), + } + } return accounts.Classify(0, err.Error(), "", accounts.KindUnavailable, "") } message := strings.TrimSpace(providerErr.Message)