mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-15 10:52:03 +00:00
fix(auth): honor disable-cooling and enrich no-auth errors
This commit is contained in:
@@ -6,6 +6,7 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -492,6 +493,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
||||
opts.Metadata = reqMeta
|
||||
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
err = enrichAuthSelectionError(err, providers, normalizedModel)
|
||||
status := http.StatusInternalServerError
|
||||
if se, ok := err.(interface{ StatusCode() int }); ok && se != nil {
|
||||
if code := se.StatusCode(); code > 0 {
|
||||
@@ -538,6 +540,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
||||
opts.Metadata = reqMeta
|
||||
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
err = enrichAuthSelectionError(err, providers, normalizedModel)
|
||||
status := http.StatusInternalServerError
|
||||
if se, ok := err.(interface{ StatusCode() int }); ok && se != nil {
|
||||
if code := se.StatusCode(); code > 0 {
|
||||
@@ -588,6 +591,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
||||
opts.Metadata = reqMeta
|
||||
streamResult, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
err = enrichAuthSelectionError(err, providers, normalizedModel)
|
||||
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||
status := http.StatusInternalServerError
|
||||
if se, ok := err.(interface{ StatusCode() int }); ok && se != nil {
|
||||
@@ -697,7 +701,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
||||
chunks = retryResult.Chunks
|
||||
continue outer
|
||||
}
|
||||
streamErr = retryErr
|
||||
streamErr = enrichAuthSelectionError(retryErr, providers, normalizedModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,6 +844,54 @@ func replaceHeader(dst http.Header, src http.Header) {
|
||||
}
|
||||
}
|
||||
|
||||
func enrichAuthSelectionError(err error, providers []string, model string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var authErr *coreauth.Error
|
||||
if !errors.As(err, &authErr) || authErr == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(authErr.Code)
|
||||
if code != "auth_not_found" && code != "auth_unavailable" {
|
||||
return err
|
||||
}
|
||||
|
||||
providerText := strings.Join(providers, ",")
|
||||
if providerText == "" {
|
||||
providerText = "unknown"
|
||||
}
|
||||
modelText := strings.TrimSpace(model)
|
||||
if modelText == "" {
|
||||
modelText = "unknown"
|
||||
}
|
||||
|
||||
baseMessage := strings.TrimSpace(authErr.Message)
|
||||
if baseMessage == "" {
|
||||
baseMessage = "no auth available"
|
||||
}
|
||||
detail := fmt.Sprintf("%s (providers=%s, model=%s)", baseMessage, providerText, modelText)
|
||||
|
||||
// Clarify the most common alias confusion between Anthropic route names and internal provider keys.
|
||||
if strings.Contains(","+providerText+",", ",claude,") {
|
||||
detail += "; check Claude auth/key session and cooldown state via /v0/management/auth-files"
|
||||
}
|
||||
|
||||
status := authErr.HTTPStatus
|
||||
if status <= 0 {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
return &coreauth.Error{
|
||||
Code: authErr.Code,
|
||||
Message: detail,
|
||||
Retryable: authErr.Retryable,
|
||||
HTTPStatus: status,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
|
||||
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
)
|
||||
|
||||
@@ -66,3 +68,46 @@ func TestWriteErrorResponse_AddonHeadersEnabled(t *testing.T) {
|
||||
t.Fatalf("X-Request-Id = %#v, want %#v", got, []string{"new-1", "new-2"})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthSelectionError_DefaultsTo503WithContext(t *testing.T) {
|
||||
in := &coreauth.Error{Code: "auth_not_found", Message: "no auth available"}
|
||||
out := enrichAuthSelectionError(in, []string{"claude"}, "claude-sonnet-4-6")
|
||||
|
||||
var got *coreauth.Error
|
||||
if !errors.As(out, &got) || got == nil {
|
||||
t.Fatalf("expected coreauth.Error, got %T", out)
|
||||
}
|
||||
if got.StatusCode() != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", got.StatusCode(), http.StatusServiceUnavailable)
|
||||
}
|
||||
if !strings.Contains(got.Message, "providers=claude") {
|
||||
t.Fatalf("message missing provider context: %q", got.Message)
|
||||
}
|
||||
if !strings.Contains(got.Message, "model=claude-sonnet-4-6") {
|
||||
t.Fatalf("message missing model context: %q", got.Message)
|
||||
}
|
||||
if !strings.Contains(got.Message, "/v0/management/auth-files") {
|
||||
t.Fatalf("message missing management hint: %q", got.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthSelectionError_PreservesExplicitStatus(t *testing.T) {
|
||||
in := &coreauth.Error{Code: "auth_unavailable", Message: "no auth available", HTTPStatus: http.StatusTooManyRequests}
|
||||
out := enrichAuthSelectionError(in, []string{"gemini"}, "gemini-2.5-pro")
|
||||
|
||||
var got *coreauth.Error
|
||||
if !errors.As(out, &got) || got == nil {
|
||||
t.Fatalf("expected coreauth.Error, got %T", out)
|
||||
}
|
||||
if got.StatusCode() != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want %d", got.StatusCode(), http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthSelectionError_IgnoresOtherErrors(t *testing.T) {
|
||||
in := errors.New("boom")
|
||||
out := enrichAuthSelectionError(in, []string{"claude"}, "claude-sonnet-4-6")
|
||||
if out != in {
|
||||
t.Fatalf("expected original error to be returned unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
@@ -463,6 +466,76 @@ func TestExecuteStreamWithAuthManager_DoesNotRetryAfterFirstByte(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStreamWithAuthManager_EnrichesBootstrapRetryAuthUnavailableError(t *testing.T) {
|
||||
executor := &failOnceStreamExecutor{}
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
manager.RegisterExecutor(executor)
|
||||
|
||||
auth1 := &coreauth.Auth{
|
||||
ID: "auth1",
|
||||
Provider: "codex",
|
||||
Status: coreauth.StatusActive,
|
||||
Metadata: map[string]any{"email": "test1@example.com"},
|
||||
}
|
||||
if _, err := manager.Register(context.Background(), auth1); err != nil {
|
||||
t.Fatalf("manager.Register(auth1): %v", err)
|
||||
}
|
||||
|
||||
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: "test-model"}})
|
||||
t.Cleanup(func() {
|
||||
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
|
||||
})
|
||||
|
||||
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{
|
||||
Streaming: sdkconfig.StreamingConfig{
|
||||
BootstrapRetries: 1,
|
||||
},
|
||||
}, manager)
|
||||
dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai", "test-model", []byte(`{"model":"test-model"}`), "")
|
||||
if dataChan == nil || errChan == nil {
|
||||
t.Fatalf("expected non-nil channels")
|
||||
}
|
||||
|
||||
var got []byte
|
||||
for chunk := range dataChan {
|
||||
got = append(got, chunk...)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty payload, got %q", string(got))
|
||||
}
|
||||
|
||||
var gotErr *interfaces.ErrorMessage
|
||||
for msg := range errChan {
|
||||
if msg != nil {
|
||||
gotErr = msg
|
||||
}
|
||||
}
|
||||
if gotErr == nil {
|
||||
t.Fatalf("expected terminal error")
|
||||
}
|
||||
if gotErr.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", gotErr.StatusCode, http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
var authErr *coreauth.Error
|
||||
if !errors.As(gotErr.Error, &authErr) || authErr == nil {
|
||||
t.Fatalf("expected coreauth.Error, got %T", gotErr.Error)
|
||||
}
|
||||
if authErr.Code != "auth_unavailable" {
|
||||
t.Fatalf("code = %q, want %q", authErr.Code, "auth_unavailable")
|
||||
}
|
||||
if !strings.Contains(authErr.Message, "providers=codex") {
|
||||
t.Fatalf("message missing provider context: %q", authErr.Message)
|
||||
}
|
||||
if !strings.Contains(authErr.Message, "model=test-model") {
|
||||
t.Fatalf("message missing model context: %q", authErr.Message)
|
||||
}
|
||||
|
||||
if executor.Calls() != 1 {
|
||||
t.Fatalf("expected exactly one upstream call before retry path selection failure, got %d", executor.Calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStreamWithAuthManager_PinnedAuthKeepsSameUpstream(t *testing.T) {
|
||||
executor := &authAwareStreamExecutor{}
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
|
||||
Reference in New Issue
Block a user