mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-22 00:52:51 +08:00
- Updated Antigravity Credits fallback to handle KV store unavailability as a service error. - Enhanced signature caching mechanisms with request-time KV access and sliding expiration. - Added and improved tests for KV client interactions, including error handling and expiration behaviors. - Introduced `CacheSignatureBestEffort` for non-critical signature caching and clarified function flows with required context. - Ensured consistent error reporting for missing or unavailable KV stores in various scenarios. - Replaced direct `homekv` calls with injectable KV client interfaces for `antigravity` and `codex_reasoning_replay` modules. - Improved error reporting and handling for KV operations, including `KVGet`, `KVSet`, `KVDel`, and `KVExpire`. - Introduced dedicated fake KV clients for expanded and granular test coverage. - Added new unit tests to validate KV client behaviors and error scenarios, ensuring robustness and sliding expiration functionality.
735 lines
25 KiB
Go
735 lines
25 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
)
|
|
|
|
func resetAntigravityCreditsRetryState() {
|
|
antigravityCreditsFailureByAuth = sync.Map{}
|
|
antigravityShortCooldownByAuth = sync.Map{}
|
|
antigravityCreditsBalanceByAuth = sync.Map{}
|
|
antigravityCreditsHintRefreshByID = sync.Map{}
|
|
}
|
|
|
|
type fakeAntigravityKVClient struct {
|
|
values map[string][]byte
|
|
getErr error
|
|
setErr error
|
|
setNXErr error
|
|
delErr error
|
|
setNXResult bool
|
|
getCount int
|
|
setCount int
|
|
setNXCount int
|
|
delCount int
|
|
lastSetTTL time.Duration
|
|
lastSetNXTTL time.Duration
|
|
lastSetNXKey string
|
|
lastSetKey string
|
|
}
|
|
|
|
func newFakeAntigravityKVClient() *fakeAntigravityKVClient {
|
|
return &fakeAntigravityKVClient{
|
|
values: make(map[string][]byte),
|
|
setNXResult: true,
|
|
}
|
|
}
|
|
|
|
func (c *fakeAntigravityKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) {
|
|
c.getCount++
|
|
if c.getErr != nil {
|
|
return nil, false, c.getErr
|
|
}
|
|
value, ok := c.values[key]
|
|
if !ok {
|
|
return nil, false, nil
|
|
}
|
|
return append([]byte(nil), value...), true, nil
|
|
}
|
|
|
|
func (c *fakeAntigravityKVClient) KVSet(_ context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error) {
|
|
c.setCount++
|
|
c.lastSetKey = key
|
|
c.lastSetTTL = opts.EX
|
|
if c.setErr != nil {
|
|
return false, c.setErr
|
|
}
|
|
c.values[key] = append([]byte(nil), value...)
|
|
return true, nil
|
|
}
|
|
|
|
func (c *fakeAntigravityKVClient) KVSetNX(_ context.Context, key string, value []byte, ttl time.Duration) (bool, error) {
|
|
c.setNXCount++
|
|
c.lastSetNXKey = key
|
|
c.lastSetNXTTL = ttl
|
|
if c.setNXErr != nil {
|
|
return false, c.setNXErr
|
|
}
|
|
if _, ok := c.values[key]; ok {
|
|
return false, nil
|
|
}
|
|
if c.setNXResult {
|
|
c.values[key] = append([]byte(nil), value...)
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (c *fakeAntigravityKVClient) KVDel(_ context.Context, keys ...string) (int64, error) {
|
|
c.delCount++
|
|
if c.delErr != nil {
|
|
return 0, c.delErr
|
|
}
|
|
var deleted int64
|
|
for _, key := range keys {
|
|
if _, ok := c.values[key]; ok {
|
|
delete(c.values, key)
|
|
deleted++
|
|
}
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
func useFakeAntigravityKVClient(t *testing.T, client *fakeAntigravityKVClient, homeMode bool, errClient error) {
|
|
t.Helper()
|
|
previous := currentAntigravityKVClient
|
|
currentAntigravityKVClient = func() (antigravityKVClient, bool, error) {
|
|
return client, homeMode, errClient
|
|
}
|
|
t.Cleanup(func() {
|
|
currentAntigravityKVClient = previous
|
|
})
|
|
}
|
|
|
|
func mustAntigravityJSON(t *testing.T, value any) []byte {
|
|
t.Helper()
|
|
raw, errMarshal := json.Marshal(value)
|
|
if errMarshal != nil {
|
|
t.Fatalf("marshal value: %v", errMarshal)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
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("standard antigravity rate limit with ui message stays rate limited", func(t *testing.T) {
|
|
body := []byte(`{
|
|
"error": {
|
|
"code": 429,
|
|
"message": "You have exhausted your capacity on this model. Your quota will reset after 0s.",
|
|
"status": "RESOURCE_EXHAUSTED",
|
|
"details": [
|
|
{
|
|
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
|
"reason": "RATE_LIMIT_EXCEEDED",
|
|
"domain": "cloudcode-pa.googleapis.com",
|
|
"metadata": {
|
|
"model": "claude-opus-4-6-thinking",
|
|
"quotaResetDelay": "479.417207ms",
|
|
"quotaResetTimeStamp": "2026-04-20T09:19:49Z",
|
|
"uiMessage": "true"
|
|
}
|
|
},
|
|
{
|
|
"@type": "type.googleapis.com/google.rpc.RetryInfo",
|
|
"retryDelay": "0.479417207s"
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
if got := classifyAntigravity429(body); got != antigravity429RateLimited {
|
|
t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429RateLimited)
|
|
}
|
|
decision := decideAntigravity429(body)
|
|
if decision.kind != antigravity429DecisionInstantRetrySameAuth {
|
|
t.Fatalf("decideAntigravity429().kind = %q, want %q", decision.kind, antigravity429DecisionInstantRetrySameAuth)
|
|
}
|
|
if decision.retryAfter == nil {
|
|
t.Fatal("decideAntigravity429().retryAfter = nil")
|
|
}
|
|
})
|
|
|
|
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("unstructured 429 defaults to soft rate limit", func(t *testing.T) {
|
|
body := []byte(`{"error":{"message":"too many requests"}}`)
|
|
if got := classifyAntigravity429(body); got != antigravity429SoftRateLimit {
|
|
t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429SoftRateLimit)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAntigravityShouldRetryNoCapacity_Standard503(t *testing.T) {
|
|
body := []byte(`{
|
|
"error": {
|
|
"code": 503,
|
|
"message": "No capacity available for model gemini-3.1-flash-image on the server",
|
|
"status": "UNAVAILABLE",
|
|
"details": [
|
|
{
|
|
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
|
"reason": "MODEL_CAPACITY_EXHAUSTED",
|
|
"domain": "cloudcode-pa.googleapis.com",
|
|
"metadata": {
|
|
"model": "gemini-3.1-flash-image"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
if !antigravityShouldRetryNoCapacity(http.StatusServiceUnavailable, body) {
|
|
t.Fatal("antigravityShouldRetryNoCapacity() = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestInjectEnabledCreditTypes(t *testing.T) {
|
|
body := []byte(`{"model":"claude-sonnet-4-6","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 TestParseRetryDelay_HumanReadableDuration(t *testing.T) {
|
|
body := []byte(`{"error":{"message":"You have exhausted your capacity on this model. Your quota will reset after 1h43m56s."}}`)
|
|
retryAfter, err := parseRetryDelay(body)
|
|
if err != nil {
|
|
t.Fatalf("parseRetryDelay() error = %v", err)
|
|
}
|
|
if retryAfter == nil {
|
|
t.Fatal("parseRetryDelay() returned nil")
|
|
}
|
|
want := time.Hour + 43*time.Minute + 56*time.Second
|
|
if *retryAfter != want {
|
|
t.Fatalf("parseRetryDelay() = %v, want %v", *retryAfter, want)
|
|
}
|
|
}
|
|
|
|
func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
|
|
var requestCount int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestCount++
|
|
switch requestCount {
|
|
case 1:
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`))
|
|
case 2:
|
|
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", requestCount)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewAntigravityExecutor(&config.Config{RequestRetry: 1})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-transient-429",
|
|
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: "claude-sonnet-4-6",
|
|
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 requestCount != 2 {
|
|
t.Fatalf("request count = %d, want 2", requestCount)
|
|
}
|
|
}
|
|
|
|
func TestAntigravityExecute_CreditsInjectedWhenConductorRequests(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()
|
|
if r.URL.Path == "/v1internal:loadCodeAssist" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`))
|
|
return
|
|
}
|
|
requestBodies = append(requestBodies, string(body))
|
|
|
|
if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
|
|
t.Fatalf("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-conductor",
|
|
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),
|
|
},
|
|
}
|
|
|
|
// Simulate conductor setting credits requested flag in context
|
|
ctx := cliproxyauth.WithAntigravityCredits(context.Background())
|
|
|
|
resp, err := exec.Execute(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "claude-sonnet-4-6",
|
|
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 len(requestBodies) != 1 {
|
|
t.Fatalf("request count = %d, want 1", len(requestBodies))
|
|
}
|
|
}
|
|
|
|
func TestAntigravityExecute_NoCreditsWithoutConductorFlag(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()
|
|
if r.URL.Path == "/v1internal:loadCodeAssist" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`))
|
|
return
|
|
}
|
|
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: true},
|
|
})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-no-conductor-flag",
|
|
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),
|
|
},
|
|
}
|
|
|
|
// No conductor credits flag set in context
|
|
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "claude-sonnet-4-6",
|
|
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))
|
|
}
|
|
// Should NOT contain credits since conductor didn't request them
|
|
if strings.Contains(requestBodies[0], `"enabledCreditTypes"`) {
|
|
t.Fatalf("request should not contain enabledCreditTypes without conductor flag: %s", requestBodies[0])
|
|
}
|
|
}
|
|
|
|
func TestAntigravityAuthHasCredits(t *testing.T) {
|
|
t.Run("sufficient balance", func(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
auth := &cliproxyauth.Auth{ID: "test-sufficient"}
|
|
antigravityCreditsBalanceByAuth.Store("test-sufficient", antigravityCreditsBalance{
|
|
CreditAmount: 25000,
|
|
MinCreditAmount: 50,
|
|
Known: true,
|
|
})
|
|
if !antigravityAuthHasCredits(auth) {
|
|
t.Fatal("antigravityAuthHasCredits() = false, want true")
|
|
}
|
|
})
|
|
|
|
t.Run("insufficient balance", func(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
auth := &cliproxyauth.Auth{ID: "test-insufficient"}
|
|
antigravityCreditsBalanceByAuth.Store("test-insufficient", antigravityCreditsBalance{
|
|
CreditAmount: 30,
|
|
MinCreditAmount: 50,
|
|
Known: true,
|
|
})
|
|
if antigravityAuthHasCredits(auth) {
|
|
t.Fatal("antigravityAuthHasCredits() = true, want false")
|
|
}
|
|
})
|
|
|
|
t.Run("no balance stored returns true (optimistic)", func(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
auth := &cliproxyauth.Auth{ID: "test-no-balance"}
|
|
if !antigravityAuthHasCredits(auth) {
|
|
t.Fatal("antigravityAuthHasCredits() = false with no balance stored, want true (optimistic default)")
|
|
}
|
|
})
|
|
|
|
t.Run("nil auth returns false", func(t *testing.T) {
|
|
if antigravityAuthHasCredits(nil) {
|
|
t.Fatal("antigravityAuthHasCredits(nil) = true, want false")
|
|
}
|
|
})
|
|
|
|
t.Run("empty ID returns false", func(t *testing.T) {
|
|
auth := &cliproxyauth.Auth{}
|
|
if antigravityAuthHasCredits(auth) {
|
|
t.Fatal("antigravityAuthHasCredits(empty ID) = true, want false")
|
|
}
|
|
})
|
|
|
|
t.Run("unknown balance returns false", func(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
auth := &cliproxyauth.Auth{ID: "test-unknown"}
|
|
antigravityCreditsBalanceByAuth.Store("test-unknown", antigravityCreditsBalance{
|
|
Known: false,
|
|
})
|
|
if antigravityAuthHasCredits(auth) {
|
|
t.Fatal("antigravityAuthHasCredits() = true for unknown balance, want false")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAntigravityAuthHasCreditsRequiredHomeBalanceUsesKV(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
const authID = "home-balance-auth"
|
|
client := newFakeAntigravityKVClient()
|
|
client.values[antigravityCreditsBalanceKey(authID)] = mustAntigravityJSON(t, antigravityCreditsBalance{
|
|
CreditAmount: 10,
|
|
MinCreditAmount: 50,
|
|
Known: true,
|
|
})
|
|
useFakeAntigravityKVClient(t, client, true, nil)
|
|
antigravityCreditsBalanceByAuth.Store(authID, antigravityCreditsBalance{
|
|
CreditAmount: 25000,
|
|
MinCreditAmount: 50,
|
|
Known: true,
|
|
})
|
|
|
|
ok, errCredits := antigravityAuthHasCreditsRequired(context.Background(), &cliproxyauth.Auth{ID: authID})
|
|
if errCredits != nil {
|
|
t.Fatalf("antigravityAuthHasCreditsRequired() error = %v", errCredits)
|
|
}
|
|
if ok {
|
|
t.Fatalf("antigravityAuthHasCreditsRequired() = true, want Home KV balance to win over local cache")
|
|
}
|
|
if client.getCount != 1 {
|
|
t.Fatalf("KVGet count = %d, want 1", client.getCount)
|
|
}
|
|
}
|
|
|
|
func TestStoreAntigravityCreditsBalanceBestEffortHomeKV(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
const authID = "home-balance-write-auth"
|
|
client := newFakeAntigravityKVClient()
|
|
useFakeAntigravityKVClient(t, client, true, nil)
|
|
|
|
storeAntigravityCreditsBalanceBestEffort(authID, antigravityCreditsBalance{
|
|
CreditAmount: 25000,
|
|
MinCreditAmount: 50,
|
|
Known: true,
|
|
})
|
|
|
|
if client.setCount != 1 || client.lastSetKey != antigravityCreditsBalanceKey(authID) || client.lastSetTTL != 30*time.Minute {
|
|
t.Fatalf("KVSet count/key/ttl = %d/%s/%v, want 1/%s/30m", client.setCount, client.lastSetKey, client.lastSetTTL, antigravityCreditsBalanceKey(authID))
|
|
}
|
|
if _, ok := antigravityCreditsBalanceByAuth.Load(authID); ok {
|
|
t.Fatalf("local balance cache was populated in Home mode")
|
|
}
|
|
}
|
|
|
|
func TestAntigravityShortCooldownRequiredHomeKV(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
client := newFakeAntigravityKVClient()
|
|
useFakeAntigravityKVClient(t, client, true, nil)
|
|
auth := &cliproxyauth.Auth{ID: "home-cooldown-auth"}
|
|
now := time.Now()
|
|
duration := 30 * time.Second
|
|
|
|
if errMark := markAntigravityShortCooldownRequired(context.Background(), auth, "claude-sonnet-4-5", now, duration); errMark != nil {
|
|
t.Fatalf("markAntigravityShortCooldownRequired() error = %v", errMark)
|
|
}
|
|
if client.setCount != 1 || client.lastSetTTL != duration+5*time.Second {
|
|
t.Fatalf("KVSet count/ttl = %d/%v, want 1/%v", client.setCount, client.lastSetTTL, duration+5*time.Second)
|
|
}
|
|
antigravityShortCooldownByAuth = sync.Map{}
|
|
inCooldown, remaining, errRead := antigravityIsInShortCooldownRequired(context.Background(), auth, "claude-sonnet-4-5", now.Add(5*time.Second))
|
|
if errRead != nil {
|
|
t.Fatalf("antigravityIsInShortCooldownRequired() error = %v", errRead)
|
|
}
|
|
if !inCooldown || remaining <= 0 {
|
|
t.Fatalf("cooldown = %v remaining %v, want active Home KV cooldown", inCooldown, remaining)
|
|
}
|
|
}
|
|
|
|
func TestAntigravityShortCooldownRequiredHomeKVFailures(t *testing.T) {
|
|
auth := &cliproxyauth.Auth{ID: "home-cooldown-failure-auth"}
|
|
for _, tc := range []struct {
|
|
name string
|
|
client *fakeAntigravityKVClient
|
|
write bool
|
|
}{
|
|
{name: "read", client: &fakeAntigravityKVClient{values: make(map[string][]byte), getErr: errors.New("get failed")}},
|
|
{name: "write", client: &fakeAntigravityKVClient{values: make(map[string][]byte), setErr: errors.New("set failed")}, write: true},
|
|
{name: "delete-expired", client: &fakeAntigravityKVClient{
|
|
values: map[string][]byte{
|
|
antigravityShortCooldownKVKey(auth, "claude-sonnet-4-5"): []byte("1"),
|
|
},
|
|
delErr: errors.New("delete failed"),
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
useFakeAntigravityKVClient(t, tc.client, true, nil)
|
|
if tc.write {
|
|
if errMark := markAntigravityShortCooldownRequired(context.Background(), auth, "claude-sonnet-4-5", time.Now(), time.Second); errMark == nil {
|
|
t.Fatalf("markAntigravityShortCooldownRequired() error = nil, want error")
|
|
}
|
|
return
|
|
}
|
|
if _, _, errRead := antigravityIsInShortCooldownRequired(context.Background(), auth, "claude-sonnet-4-5", time.Now()); errRead == nil {
|
|
t.Fatalf("antigravityIsInShortCooldownRequired() error = nil, want error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMaybeRefreshAntigravityCreditsHintHomeRefreshThrottleUsesSetNX(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
client := newFakeAntigravityKVClient()
|
|
client.setNXResult = false
|
|
useFakeAntigravityKVClient(t, client, true, nil)
|
|
exec := NewAntigravityExecutor(&config.Config{
|
|
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
|
|
})
|
|
auth := &cliproxyauth.Auth{ID: "home-refresh-throttle-auth"}
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
t.Fatalf("refresh request should not run when Home KV throttle lock is not acquired")
|
|
return nil, nil
|
|
}))
|
|
|
|
exec.maybeRefreshAntigravityCreditsHint(ctx, auth, "access-token")
|
|
|
|
if client.setNXCount != 1 || client.lastSetNXKey != antigravityCreditsRefreshLockKey(auth.ID) || client.lastSetNXTTL != antigravityCreditsHintRefreshInterval {
|
|
t.Fatalf("KVSetNX count/key/ttl = %d/%s/%v, want 1/%s/%v", client.setNXCount, client.lastSetNXKey, client.lastSetNXTTL, antigravityCreditsRefreshLockKey(auth.ID), antigravityCreditsHintRefreshInterval)
|
|
}
|
|
}
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
|
|
exec := NewAntigravityExecutor(&config.Config{
|
|
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
|
|
})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-warm-token-credits",
|
|
Metadata: map[string]any{
|
|
"access_token": "token",
|
|
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
}
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
|
|
t.Fatalf("unexpected request url %s", req.URL.String())
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)),
|
|
}, nil
|
|
}))
|
|
|
|
token, updatedAuth, err := exec.ensureAccessToken(ctx, auth)
|
|
if err != nil {
|
|
t.Fatalf("ensureAccessToken() error = %v", err)
|
|
}
|
|
if token != "token" {
|
|
t.Fatalf("ensureAccessToken() token = %q, want %q", token, "token")
|
|
}
|
|
if updatedAuth != nil {
|
|
t.Fatalf("ensureAccessToken() updatedAuth = %v, want nil", updatedAuth)
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) {
|
|
t.Fatal("expected credits hint to be populated for warm token auth")
|
|
}
|
|
hint, ok := cliproxyauth.GetAntigravityCreditsHint(auth.ID)
|
|
if !ok {
|
|
t.Fatal("expected credits hint lookup to succeed")
|
|
}
|
|
if !hint.Available {
|
|
t.Fatalf("hint.Available = %v, want true", hint.Available)
|
|
}
|
|
if hint.CreditAmount != 25000 || hint.MinCreditAmount != 50 {
|
|
t.Fatalf("hint amounts = (%v, %v), want (25000, 50)", hint.CreditAmount, hint.MinCreditAmount)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAntigravityCreditsBalance_LoadCodeAssistUserAgent(t *testing.T) {
|
|
resetAntigravityCreditsRetryState()
|
|
t.Cleanup(resetAntigravityCreditsRetryState)
|
|
|
|
exec := NewAntigravityExecutor(&config.Config{})
|
|
const configuredUserAgent = "antigravity/1.23.2 windows/amd64 google-api-nodejs-client/10.3.0"
|
|
const loadCodeAssistUserAgent = "antigravity/1.23.2 windows/amd64"
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "auth-load-code-assist-ua",
|
|
Attributes: map[string]string{"user_agent": configuredUserAgent},
|
|
}
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
|
|
t.Fatalf("unexpected request url %s", req.URL.String())
|
|
}
|
|
if got := req.Header.Get("User-Agent"); got != loadCodeAssistUserAgent {
|
|
t.Fatalf("User-Agent = %q, want %q", got, loadCodeAssistUserAgent)
|
|
}
|
|
if got := req.Header.Get("X-Goog-Api-Client"); got != "" {
|
|
t.Fatalf("X-Goog-Api-Client = %q, want empty", got)
|
|
}
|
|
body, _ := io.ReadAll(req.Body)
|
|
_ = req.Body.Close()
|
|
if string(body) != `{"metadata":{"ideType":"ANTIGRAVITY"}}` {
|
|
t.Fatalf("loadCodeAssist body = %s", string(body))
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)),
|
|
}, nil
|
|
}))
|
|
|
|
exec.updateAntigravityCreditsBalance(ctx, auth, "token")
|
|
}
|
|
|
|
func TestParseMetaFloat(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value any
|
|
wantVal float64
|
|
wantOK bool
|
|
}{
|
|
{"string", "25000", 25000, true},
|
|
{"float64", float64(100), 100, true},
|
|
{"int", int(50), 50, true},
|
|
{"int64", int64(75), 75, true},
|
|
{"empty string", "", 0, false},
|
|
{"invalid string", "abc", 0, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
meta := map[string]any{"key": tt.value}
|
|
got, ok := parseMetaFloat(meta, "key")
|
|
if ok != tt.wantOK {
|
|
t.Fatalf("parseMetaFloat() ok = %v, want %v", ok, tt.wantOK)
|
|
}
|
|
if ok && got != tt.wantVal {
|
|
t.Fatalf("parseMetaFloat() = %f, want %f", got, tt.wantVal)
|
|
}
|
|
})
|
|
}
|
|
}
|