mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix(auth): rotate DeepSeek authentication failures
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user