mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-04 19:51:18 +00:00
424 lines
14 KiB
Go
424 lines
14 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
|
)
|
|
|
|
func resetAntigravityCreditsRetryState() {
|
|
antigravityCreditsExhaustedByAuth = sync.Map{}
|
|
antigravityPreferCreditsByModel = sync.Map{}
|
|
}
|
|
|
|
func TestClassifyAntigravity429(t *testing.T) {
|
|
t.Run("quota exhausted", func(t *testing.T) {
|
|
body := []byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)
|
|
if got := classifyAntigravity429(body); got != antigravity429QuotaExhausted {
|
|
t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429QuotaExhausted)
|
|
}
|
|
})
|
|
|
|
t.Run("structured rate limit", func(t *testing.T) {
|
|
body := []byte(`{
|
|
"error": {
|
|
"status": "RESOURCE_EXHAUSTED",
|
|
"details": [
|
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "RATE_LIMIT_EXCEEDED"},
|
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.5s"}
|
|
]
|
|
}
|
|
}`)
|
|
if got := classifyAntigravity429(body); got != antigravity429RateLimited {
|
|
t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429RateLimited)
|
|
}
|
|
})
|
|
|
|
t.Run("structured quota exhausted", func(t *testing.T) {
|
|
body := []byte(`{
|
|
"error": {
|
|
"status": "RESOURCE_EXHAUSTED",
|
|
"details": [
|
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "QUOTA_EXHAUSTED"}
|
|
]
|
|
}
|
|
}`)
|
|
if got := classifyAntigravity429(body); got != antigravity429QuotaExhausted {
|
|
t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429QuotaExhausted)
|
|
}
|
|
})
|
|
|
|
t.Run("unknown", func(t *testing.T) {
|
|
body := []byte(`{"error":{"message":"too many requests"}}`)
|
|
if got := classifyAntigravity429(body); got != antigravity429Unknown {
|
|
t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429Unknown)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInjectEnabledCreditTypes(t *testing.T) {
|
|
body := []byte(`{"model":"gemini-2.5-flash","request":{}}`)
|
|
got := injectEnabledCreditTypes(body)
|
|
if got == nil {
|
|
t.Fatal("injectEnabledCreditTypes() returned nil")
|
|
}
|
|
if !strings.Contains(string(got), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("injectEnabledCreditTypes() = %s, want enabledCreditTypes", string(got))
|
|
}
|
|
|
|
if got := injectEnabledCreditTypes([]byte(`not json`)); got != nil {
|
|
t.Fatalf("injectEnabledCreditTypes() for invalid json = %s, want nil", string(got))
|
|
}
|
|
}
|
|
|
|
func TestShouldMarkAntigravityCreditsExhausted(t *testing.T) {
|
|
for _, body := range [][]byte{
|
|
[]byte(`{"error":{"message":"Insufficient GOOGLE_ONE_AI credits"}}`),
|
|
[]byte(`{"error":{"message":"minimumCreditAmountForUsage requirement not met"}}`),
|
|
[]byte(`{"error":{"message":"Resource has been exhausted"}}`),
|
|
} {
|
|
if !shouldMarkAntigravityCreditsExhausted(http.StatusForbidden, body, nil) {
|
|
t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body))
|
|
}
|
|
}
|
|
if shouldMarkAntigravityCreditsExhausted(http.StatusServiceUnavailable, []byte(`{"error":{"message":"credits exhausted"}}`), nil) {
|
|
t.Fatal("shouldMarkAntigravityCreditsExhausted() = true for 5xx, want false")
|
|
}
|
|
}
|
|
|
|
func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
requestBodies []string
|
|
)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = r.Body.Close()
|
|
|
|
mu.Lock()
|
|
requestBodies = append(requestBodies, string(body))
|
|
reqNum := len(requestBodies)
|
|
mu.Unlock()
|
|
|
|
if reqNum == 1 {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`))
|
|
return
|
|
}
|
|
|
|
if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("second request body missing enabledCreditTypes: %s", string(body))
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewAntigravityExecutor(&config.Config{
|
|
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
|
|
})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-credits-ok",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
},
|
|
Metadata: map[string]any{
|
|
"access_token": "token",
|
|
"project_id": "project-1",
|
|
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
}
|
|
|
|
resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "gemini-2.5-flash",
|
|
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatAntigravity,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if len(resp.Payload) == 0 {
|
|
t.Fatal("Execute() returned empty payload")
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(requestBodies) != 2 {
|
|
t.Fatalf("request count = %d, want 2", len(requestBodies))
|
|
}
|
|
}
|
|
|
|
func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
|
|
var requestCount int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestCount++
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewAntigravityExecutor(&config.Config{
|
|
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
|
|
})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-credits-exhausted",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
},
|
|
Metadata: map[string]any{
|
|
"access_token": "token",
|
|
"project_id": "project-1",
|
|
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
}
|
|
markAntigravityCreditsExhausted(auth, time.Now())
|
|
|
|
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "gemini-2.5-flash",
|
|
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatAntigravity,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Execute() error = nil, want 429")
|
|
}
|
|
sErr, ok := err.(statusErr)
|
|
if !ok {
|
|
t.Fatalf("Execute() error type = %T, want statusErr", err)
|
|
}
|
|
if got := sErr.StatusCode(); got != http.StatusTooManyRequests {
|
|
t.Fatalf("Execute() status code = %d, want %d", got, http.StatusTooManyRequests)
|
|
}
|
|
if requestCount != 1 {
|
|
t.Fatalf("request count = %d, want 1", requestCount)
|
|
}
|
|
}
|
|
|
|
func TestAntigravityExecute_PrefersCreditsAfterSuccessfulFallback(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
requestBodies []string
|
|
)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = r.Body.Close()
|
|
|
|
mu.Lock()
|
|
requestBodies = append(requestBodies, string(body))
|
|
reqNum := len(requestBodies)
|
|
mu.Unlock()
|
|
|
|
switch reqNum {
|
|
case 1:
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"QUOTA_EXHAUSTED"},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"10s"}]}}`))
|
|
case 2, 3:
|
|
if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("request %d body missing enabledCreditTypes: %s", reqNum, string(body))
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"OK"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`))
|
|
default:
|
|
t.Fatalf("unexpected request count %d", reqNum)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewAntigravityExecutor(&config.Config{
|
|
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
|
|
})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-prefer-credits",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
},
|
|
Metadata: map[string]any{
|
|
"access_token": "token",
|
|
"project_id": "project-1",
|
|
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
}
|
|
|
|
request := cliproxyexecutor.Request{
|
|
Model: "gemini-2.5-flash",
|
|
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatAntigravity}
|
|
|
|
if _, err := exec.Execute(context.Background(), auth, request, opts); err != nil {
|
|
t.Fatalf("first Execute() error = %v", err)
|
|
}
|
|
if _, err := exec.Execute(context.Background(), auth, request, opts); err != nil {
|
|
t.Fatalf("second Execute() error = %v", err)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(requestBodies) != 3 {
|
|
t.Fatalf("request count = %d, want 3", len(requestBodies))
|
|
}
|
|
if strings.Contains(requestBodies[0], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("first request unexpectedly used credits: %s", requestBodies[0])
|
|
}
|
|
if !strings.Contains(requestBodies[1], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("fallback request missing credits: %s", requestBodies[1])
|
|
}
|
|
if !strings.Contains(requestBodies[2], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("preferred request missing credits: %s", requestBodies[2])
|
|
}
|
|
}
|
|
|
|
func TestAntigravityExecute_PreservesBaseURLFallbackAfterCreditsRetryFailure(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
firstCount int
|
|
secondCount int
|
|
)
|
|
|
|
firstServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = r.Body.Close()
|
|
|
|
mu.Lock()
|
|
firstCount++
|
|
reqNum := firstCount
|
|
mu.Unlock()
|
|
|
|
switch reqNum {
|
|
case 1:
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"QUOTA_EXHAUSTED"}]}}`))
|
|
case 2:
|
|
if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("credits retry missing enabledCreditTypes: %s", string(body))
|
|
}
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"error":{"message":"permission denied"}}`))
|
|
default:
|
|
t.Fatalf("unexpected first server request count %d", reqNum)
|
|
}
|
|
}))
|
|
defer firstServer.Close()
|
|
|
|
secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
secondCount++
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`))
|
|
}))
|
|
defer secondServer.Close()
|
|
|
|
exec := NewAntigravityExecutor(&config.Config{
|
|
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
|
|
})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-baseurl-fallback",
|
|
Attributes: map[string]string{
|
|
"base_url": firstServer.URL,
|
|
},
|
|
Metadata: map[string]any{
|
|
"access_token": "token",
|
|
"project_id": "project-1",
|
|
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
}
|
|
|
|
originalOrder := antigravityBaseURLFallbackOrder
|
|
defer func() { antigravityBaseURLFallbackOrder = originalOrder }()
|
|
antigravityBaseURLFallbackOrder = func(auth *cliproxyauth.Auth) []string {
|
|
return []string{firstServer.URL, secondServer.URL}
|
|
}
|
|
|
|
resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "gemini-2.5-flash",
|
|
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatAntigravity,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if len(resp.Payload) == 0 {
|
|
t.Fatal("Execute() returned empty payload")
|
|
}
|
|
if firstCount != 2 {
|
|
t.Fatalf("first server request count = %d, want 2", firstCount)
|
|
}
|
|
if secondCount != 1 {
|
|
t.Fatalf("second server request count = %d, want 1", secondCount)
|
|
}
|
|
}
|
|
|
|
func TestAntigravityExecute_DoesNotDirectInjectCreditsWhenFlagDisabled(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
|
|
var requestBodies []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = r.Body.Close()
|
|
requestBodies = append(requestBodies, string(body))
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewAntigravityExecutor(&config.Config{
|
|
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: false},
|
|
})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-flag-disabled",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
},
|
|
Metadata: map[string]any{
|
|
"access_token": "token",
|
|
"project_id": "project-1",
|
|
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
}
|
|
markAntigravityPreferCredits(auth, "gemini-2.5-flash", time.Now(), nil)
|
|
|
|
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "gemini-2.5-flash",
|
|
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatAntigravity,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Execute() error = nil, want 429")
|
|
}
|
|
if len(requestBodies) != 1 {
|
|
t.Fatalf("request count = %d, want 1", len(requestBodies))
|
|
}
|
|
if strings.Contains(requestBodies[0], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("request unexpectedly used enabledCreditTypes with flag disabled: %s", requestBodies[0])
|
|
}
|
|
}
|