From d0d77182ee8efdd568a056c5e4177591429c22bb Mon Sep 17 00:00:00 2001 From: sususu Date: Mon, 10 Aug 2026 14:17:15 +0800 Subject: [PATCH] fix(auth): rotate DeepSeek authentication failures --- internal/clienterror/client_error.go | 22 +++++++ internal/clienterror/client_error_test.go | 6 ++ sdk/cliproxy/auth/conductor_overrides_test.go | 64 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/internal/clienterror/client_error.go b/internal/clienterror/client_error.go index 78565428d..be498edf9 100644 --- a/internal/clienterror/client_error.go +++ b/internal/clienterror/client_error.go @@ -86,6 +86,12 @@ func IsRequestFault(status int, err error) bool { if status == http.StatusPaymentRequired { return false } + // DeepSeek reports an invalid API key as 401 with the authentication_error + // type alongside the same generic code. Preserve that credential failure + // classification without weakening generic request-fault handling. + if status == http.StatusUnauthorized && hasAuthenticationErrorBody(err) { + return false + } if hasRequestFaultBody(err) { return true } @@ -118,6 +124,22 @@ func IsItemNotPersisted(message string) bool { strings.Contains(lower, "items are not persisted when `store` is set to false") } +func hasAuthenticationErrorBody(err error) bool { + if err == nil { + return false + } + body := strings.TrimSpace(err.Error()) + if body == "" || !json.Valid([]byte(body)) { + return false + } + for _, path := range []string{"error.type", "type", "response.error.type", "body.error.type"} { + if errType := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String())); errType == "authentication_error" { + return true + } + } + return false +} + func hasRequestFaultBody(err error) bool { if err == nil { return false diff --git a/internal/clienterror/client_error_test.go b/internal/clienterror/client_error_test.go index 7086230b6..fd4e88e27 100644 --- a/internal/clienterror/client_error_test.go +++ b/internal/clienterror/client_error_test.go @@ -191,6 +191,12 @@ func TestIsRequestFault(t *testing.T) { }, {name: "plain not found", status: http.StatusNotFound, err: errors.New("model not found")}, {name: "unauthorized", status: http.StatusUnauthorized, err: errors.New("invalid token")}, + { + name: "deepseek authentication failure is credential failure", + status: http.StatusUnauthorized, + err: errors.New(`{"error":{"code":"invalid_request_error","message":"Authentication Fails, Your api key: ****heck is invalid","param":null,"type":"authentication_error"}}`), + want: false, + }, { name: "deepseek insufficient balance is payment failure", status: http.StatusPaymentRequired, diff --git a/sdk/cliproxy/auth/conductor_overrides_test.go b/sdk/cliproxy/auth/conductor_overrides_test.go index 96996ee53..b7236b4c5 100644 --- a/sdk/cliproxy/auth/conductor_overrides_test.go +++ b/sdk/cliproxy/auth/conductor_overrides_test.go @@ -1447,6 +1447,70 @@ func TestManager_DeepSeekInsufficientBalanceRotatesCredentialAndRebindsSession(t } } +func TestManager_DeepSeekAuthenticationFailureRotatesCredential(t *testing.T) { + m := NewManager(nil, nil, nil) + m.SetRetryConfig(2, 30*time.Second, 0) + + const provider = "openai-compatibility" + const model = "deepseek-v4-pro" + + executor := &authFallbackExecutor{ + id: provider, + executeErrors: map[string]error{ + "aa-invalid-key": &Error{ + HTTPStatus: http.StatusUnauthorized, + Message: `{"error":{"code":"invalid_request_error","message":"Authentication Fails, Your api key: ****heck is invalid","param":null,"type":"authentication_error"}}`, + }, + }, + } + m.RegisterExecutor(executor) + + invalidAuth := &Auth{ID: "aa-invalid-key", Provider: provider} + availableAuth := &Auth{ID: "bb-valid-key", Provider: provider} + + reg := registry.GetGlobalRegistry() + models := []*registry.ModelInfo{{ID: model}} + reg.RegisterClient(invalidAuth.ID, provider, models) + reg.RegisterClient(availableAuth.ID, provider, models) + t.Cleanup(func() { + reg.UnregisterClient(invalidAuth.ID) + reg.UnregisterClient(availableAuth.ID) + }) + + if _, errRegister := m.Register(context.Background(), invalidAuth); errRegister != nil { + t.Fatalf("register invalid auth: %v", errRegister) + } + if _, errRegister := m.Register(context.Background(), availableAuth); errRegister != nil { + t.Fatalf("register available auth: %v", errRegister) + } + + resp, errExecute := m.Execute( + context.Background(), + []string{provider}, + cliproxyexecutor.Request{Model: model}, + cliproxyexecutor.Options{}, + ) + if errExecute != nil { + t.Fatalf("expected fallback to the next credential, got error: %v", errExecute) + } + if got := string(resp.Payload); got != availableAuth.ID { + t.Fatalf("served by %q, want %q", got, availableAuth.ID) + } + wantCalls := []string{invalidAuth.ID, availableAuth.ID} + if calls := executor.ExecuteCalls(); !slices.Equal(calls, wantCalls) { + t.Fatalf("credential calls = %v, want %v", calls, wantCalls) + } + + updatedInvalid, ok := m.GetByID(invalidAuth.ID) + if !ok || updatedInvalid == nil { + t.Fatal("expected invalid auth to remain registered") + } + state := updatedInvalid.ModelStates[model] + if state == nil || !state.Unavailable || state.NextRetryAfter.IsZero() { + t.Fatalf("invalid auth model state = %#v, want active cooldown", state) + } +} + // TestManager_UnknownUpstreamErrorRotatesAndPenalizesModelOnly pins the upstream // 500 "status":"UNKNOWN" contract. It is an upstream internal failure, not a // request fault, so the request must fall through to the next credential. The