302 lines
11 KiB
Go
302 lines
11 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
)
|
|
|
|
type compactTestStatusError struct {
|
|
code int
|
|
msg string
|
|
}
|
|
|
|
func (e compactTestStatusError) Error() string { return e.msg }
|
|
func (e compactTestStatusError) StatusCode() int { return e.code }
|
|
|
|
type compactTestExecutor struct {
|
|
calls int
|
|
compactErr error
|
|
normalErr error
|
|
responseBody []byte
|
|
}
|
|
|
|
func (e *compactTestExecutor) Identifier() string { return "compact-test-provider" }
|
|
|
|
func (e *compactTestExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
e.calls++
|
|
if opts.Alt == "responses/compact" {
|
|
if e.compactErr != nil {
|
|
return cliproxyexecutor.Response{}, e.compactErr
|
|
}
|
|
} else {
|
|
if e.normalErr != nil {
|
|
return cliproxyexecutor.Response{}, e.normalErr
|
|
}
|
|
}
|
|
payload := e.responseBody
|
|
if len(payload) == 0 {
|
|
payload = []byte(`{"status":"ok"}`)
|
|
}
|
|
return cliproxyexecutor.Response{Payload: payload}, nil
|
|
}
|
|
|
|
func (e *compactTestExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
|
|
return nil, errors.New("stream not supported")
|
|
}
|
|
|
|
func (e *compactTestExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) {
|
|
return auth, nil
|
|
}
|
|
|
|
func (e *compactTestExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
return cliproxyexecutor.Response{}, errors.New("not supported")
|
|
}
|
|
|
|
func (e *compactTestExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {
|
|
return nil, errors.New("not supported")
|
|
}
|
|
|
|
func TestManager_ResponsesCompact_TransientFailure_AvailabilityNeutral(t *testing.T) {
|
|
executor := &compactTestExecutor{
|
|
compactErr: compactTestStatusError{code: http.StatusInternalServerError, msg: "upstream compact 500"},
|
|
}
|
|
m := NewManager(nil, nil, nil)
|
|
m.RegisterExecutor(executor)
|
|
|
|
model := "gpt-5.6-sol"
|
|
auth1 := &Auth{ID: "auth1", Provider: executor.Identifier(), Status: StatusActive}
|
|
auth2 := &Auth{ID: "auth2", Provider: executor.Identifier(), Status: StatusActive}
|
|
if _, err := m.Register(context.Background(), auth1); err != nil {
|
|
t.Fatalf("Register auth1: %v", err)
|
|
}
|
|
if _, err := m.Register(context.Background(), auth2); err != nil {
|
|
t.Fatalf("Register auth2: %v", err)
|
|
}
|
|
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: model}})
|
|
registry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: model}})
|
|
t.Cleanup(func() {
|
|
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
|
|
registry.GetGlobalRegistry().UnregisterClient(auth2.ID)
|
|
})
|
|
|
|
req := cliproxyexecutor.Request{Model: model, Payload: []byte(`{"input":"hello"}`)}
|
|
opts := cliproxyexecutor.Options{Alt: "responses/compact"}
|
|
|
|
start := time.Now()
|
|
_, errExec := m.Execute(context.Background(), []string{executor.Identifier()}, req, opts)
|
|
elapsed := time.Since(start)
|
|
|
|
if errExec == nil {
|
|
t.Fatal("Execute expected error, got nil")
|
|
}
|
|
if elapsed > 2*time.Second {
|
|
t.Fatalf("Execute took %v, should not pause for cooldown wait", elapsed)
|
|
}
|
|
if executor.calls != 2 {
|
|
t.Fatalf("executor.calls = %d, want 2 (fallback across candidate auths)", executor.calls)
|
|
}
|
|
|
|
// Verify model states are not unavailable
|
|
for _, id := range []string{"auth1", "auth2"} {
|
|
a, ok := m.GetByID(id)
|
|
if !ok {
|
|
t.Fatalf("auth %s not found", id)
|
|
}
|
|
if state, exists := a.ModelStates[model]; exists && state != nil {
|
|
if state.Unavailable {
|
|
t.Fatalf("auth %s marked unavailable after compact failure", id)
|
|
}
|
|
if !state.NextRetryAfter.IsZero() && state.NextRetryAfter.After(time.Now()) {
|
|
t.Fatalf("auth %s has NextRetryAfter set in future: %v", id, state.NextRetryAfter)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normal request succeeds immediately
|
|
normalReq := cliproxyexecutor.Request{Model: model, Payload: []byte(`{"input":"hello"}`)}
|
|
normalOpts := cliproxyexecutor.Options{}
|
|
resp, errNormal := m.Execute(context.Background(), []string{executor.Identifier()}, normalReq, normalOpts)
|
|
if errNormal != nil {
|
|
t.Fatalf("normal Execute failed: %v", errNormal)
|
|
}
|
|
if string(resp.Payload) != `{"status":"ok"}` {
|
|
t.Fatalf("normal Execute payload = %s", string(resp.Payload))
|
|
}
|
|
}
|
|
|
|
func TestManager_ResponsesCompact_RequestFault_StopsFallback(t *testing.T) {
|
|
executor := &compactTestExecutor{
|
|
compactErr: compactTestStatusError{code: http.StatusNotFound, msg: "404 endpoint not found"},
|
|
}
|
|
m := NewManager(nil, nil, nil)
|
|
m.RegisterExecutor(executor)
|
|
|
|
model := "gpt-5.6-sol"
|
|
auth1 := &Auth{ID: "auth1", Provider: executor.Identifier(), Status: StatusActive}
|
|
auth2 := &Auth{ID: "auth2", Provider: executor.Identifier(), Status: StatusActive}
|
|
if _, err := m.Register(context.Background(), auth1); err != nil {
|
|
t.Fatalf("Register auth1: %v", err)
|
|
}
|
|
if _, err := m.Register(context.Background(), auth2); err != nil {
|
|
t.Fatalf("Register auth2: %v", err)
|
|
}
|
|
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: model}})
|
|
registry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: model}})
|
|
t.Cleanup(func() {
|
|
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
|
|
registry.GetGlobalRegistry().UnregisterClient(auth2.ID)
|
|
})
|
|
|
|
req := cliproxyexecutor.Request{Model: model, Payload: []byte(`{"input":"hello"}`)}
|
|
opts := cliproxyexecutor.Options{Alt: "responses/compact"}
|
|
|
|
_, errExec := m.Execute(context.Background(), []string{executor.Identifier()}, req, opts)
|
|
if errExec == nil {
|
|
t.Fatal("Execute expected error, got nil")
|
|
}
|
|
if executor.calls != 1 {
|
|
t.Fatalf("executor.calls = %d, want 1 (fallback stopped on request fault)", executor.calls)
|
|
}
|
|
|
|
// Verify model states are not unavailable
|
|
for _, id := range []string{"auth1", "auth2"} {
|
|
a, ok := m.GetByID(id)
|
|
if !ok {
|
|
t.Fatalf("auth %s not found", id)
|
|
}
|
|
if state, exists := a.ModelStates[model]; exists && state != nil {
|
|
if state.Unavailable {
|
|
t.Fatalf("auth %s marked unavailable after compact 404 fault", id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestManager_ResponsesCompact_Unauthorized_CoolsCredential(t *testing.T) {
|
|
executor := &compactTestExecutor{
|
|
compactErr: compactTestStatusError{code: http.StatusUnauthorized, msg: "401 unauthorized"},
|
|
}
|
|
m := NewManager(nil, nil, nil)
|
|
m.RegisterExecutor(executor)
|
|
|
|
model := "gpt-5.6-sol"
|
|
auth1 := &Auth{ID: "auth1", Provider: executor.Identifier(), Status: StatusActive}
|
|
if _, err := m.Register(context.Background(), auth1); err != nil {
|
|
t.Fatalf("Register auth1: %v", err)
|
|
}
|
|
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: model}})
|
|
t.Cleanup(func() {
|
|
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
|
|
})
|
|
|
|
req := cliproxyexecutor.Request{Model: model, Payload: []byte(`{"input":"hello"}`)}
|
|
opts := cliproxyexecutor.Options{Alt: "responses/compact"}
|
|
|
|
_, errExec := m.Execute(context.Background(), []string{executor.Identifier()}, req, opts)
|
|
if errExec == nil {
|
|
t.Fatal("Execute expected error, got nil")
|
|
}
|
|
|
|
a, ok := m.GetByID("auth1")
|
|
if !ok {
|
|
t.Fatal("auth1 not found")
|
|
}
|
|
state, exists := a.ModelStates[model]
|
|
if !exists || state == nil {
|
|
t.Fatal("auth1 model state should be recorded for 401 unauthorized")
|
|
}
|
|
if !state.Unavailable {
|
|
t.Fatal("auth1 model state should be unavailable after 401 unauthorized")
|
|
}
|
|
if state.NextRetryAfter.IsZero() || !state.NextRetryAfter.After(time.Now()) {
|
|
t.Fatalf("auth1 NextRetryAfter not set in future for 401 unauthorized: %v", state.NextRetryAfter)
|
|
}
|
|
}
|
|
|
|
func TestManager_ResponsesCompact_Forbidden_CoolsCredential(t *testing.T) {
|
|
executor := &compactTestExecutor{
|
|
compactErr: compactTestStatusError{code: http.StatusForbidden, msg: "403 forbidden"},
|
|
}
|
|
m := NewManager(nil, nil, nil)
|
|
m.RegisterExecutor(executor)
|
|
|
|
model := "gpt-5.6-sol"
|
|
auth1 := &Auth{ID: "auth1", Provider: executor.Identifier(), Status: StatusActive}
|
|
if _, err := m.Register(context.Background(), auth1); err != nil {
|
|
t.Fatalf("Register auth1: %v", err)
|
|
}
|
|
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: model}})
|
|
t.Cleanup(func() {
|
|
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
|
|
})
|
|
|
|
req := cliproxyexecutor.Request{Model: model, Payload: []byte(`{"input":"hello"}`)}
|
|
opts := cliproxyexecutor.Options{Alt: "responses/compact"}
|
|
|
|
_, errExec := m.Execute(context.Background(), []string{executor.Identifier()}, req, opts)
|
|
if errExec == nil {
|
|
t.Fatal("Execute expected error, got nil")
|
|
}
|
|
|
|
a, ok := m.GetByID("auth1")
|
|
if !ok {
|
|
t.Fatal("auth1 not found")
|
|
}
|
|
state, exists := a.ModelStates[model]
|
|
if !exists || state == nil {
|
|
t.Fatal("auth1 model state should be recorded for 403 forbidden")
|
|
}
|
|
if !state.Unavailable {
|
|
t.Fatal("auth1 model state should be unavailable after 403 forbidden")
|
|
}
|
|
if state.NextRetryAfter.IsZero() || !state.NextRetryAfter.After(time.Now()) {
|
|
t.Fatalf("auth1 NextRetryAfter not set in future for 403 forbidden: %v", state.NextRetryAfter)
|
|
}
|
|
}
|
|
|
|
func TestManager_ResponsesCompact_Quota429_CoolsCredential(t *testing.T) {
|
|
executor := &compactTestExecutor{
|
|
compactErr: compactTestStatusError{code: http.StatusTooManyRequests, msg: `{"error":{"type":"usage_limit_reached","message":"quota exceeded"}}`},
|
|
}
|
|
m := NewManager(nil, nil, nil)
|
|
m.RegisterExecutor(executor)
|
|
|
|
model := "gpt-5.6-sol"
|
|
auth1 := &Auth{ID: "auth1", Provider: executor.Identifier(), Status: StatusActive}
|
|
if _, err := m.Register(context.Background(), auth1); err != nil {
|
|
t.Fatalf("Register auth1: %v", err)
|
|
}
|
|
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: model}})
|
|
t.Cleanup(func() {
|
|
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
|
|
})
|
|
|
|
req := cliproxyexecutor.Request{Model: model, Payload: []byte(`{"input":"hello"}`)}
|
|
opts := cliproxyexecutor.Options{Alt: "responses/compact"}
|
|
|
|
_, errExec := m.Execute(context.Background(), []string{executor.Identifier()}, req, opts)
|
|
if errExec == nil {
|
|
t.Fatal("Execute expected error, got nil")
|
|
}
|
|
|
|
a, ok := m.GetByID("auth1")
|
|
if !ok {
|
|
t.Fatal("auth1 not found")
|
|
}
|
|
state, exists := a.ModelStates[model]
|
|
if !exists || state == nil {
|
|
t.Fatal("auth1 model state should be recorded for 429 quota")
|
|
}
|
|
if !state.Quota.Exceeded {
|
|
t.Fatal("auth1 quota should be marked exceeded after 429 quota")
|
|
}
|
|
if state.NextRetryAfter.IsZero() || !state.NextRetryAfter.After(time.Now()) {
|
|
t.Fatalf("auth1 NextRetryAfter not set in future for 429 quota: %v", state.NextRetryAfter)
|
|
}
|
|
}
|