feat(antigravity): conductor-level credits fallback for Claude models
Move credits handling from executor-level retry to conductor-level orchestration. When all free-tier auths are exhausted (429/503), the conductor discovers auths with available Google One AI credits and retries with enabledCreditTypes injected via context flag. Key changes: - Add AntigravityCreditsHint system for tracking per-auth credits state - Conductor tries credits fallback after all auths fail (Execute/Stream/Count) - Executor injects enabledCreditTypes only when conductor sets context flag - Credits fallback respects provider scope (requires antigravity in providers) - Add context cancellation check in credits fallback to avoid wasted requests - Remove executor-level attemptCreditsFallback and preferCredits machinery - Restructure 429 decision logic (parse details first, keyword fallback) - Expand shouldAbort to cover INVALID_ARGUMENT/FAILED_PRECONDITION/500+UNKNOWN - Support human-readable retry delay parsing (e.g. "1h43m56s")
This commit is contained in:
@@ -18,8 +18,8 @@ import (
|
||||
|
||||
func resetAntigravityCreditsRetryState() {
|
||||
antigravityCreditsFailureByAuth = sync.Map{}
|
||||
antigravityPreferCreditsByModel = sync.Map{}
|
||||
antigravityShortCooldownByAuth = sync.Map{}
|
||||
antigravityCreditsBalanceByAuth = sync.Map{}
|
||||
}
|
||||
|
||||
func TestClassifyAntigravity429(t *testing.T) {
|
||||
@@ -30,6 +30,43 @@ func TestClassifyAntigravity429(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
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": {
|
||||
@@ -67,8 +104,32 @@ func TestClassifyAntigravity429(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
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":"gemini-2.5-flash","request":{}}`)
|
||||
body := []byte(`{"model":"claude-sonnet-4-6","request":{}}`)
|
||||
got := injectEnabledCreditTypes(body)
|
||||
if got == nil {
|
||||
t.Fatal("injectEnabledCreditTypes() returned nil")
|
||||
@@ -82,37 +143,22 @@ func TestInjectEnabledCreditTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldMarkAntigravityCreditsExhausted(t *testing.T) {
|
||||
t.Run("credit errors are marked", func(t *testing.T) {
|
||||
for _, body := range [][]byte{
|
||||
[]byte(`{"error":{"message":"Insufficient GOOGLE_ONE_AI credits"}}`),
|
||||
[]byte(`{"error":{"message":"minimumCreditAmountForUsage requirement not met"}}`),
|
||||
} {
|
||||
if !shouldMarkAntigravityCreditsExhausted(http.StatusForbidden, body, nil) {
|
||||
t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transient 429 resource exhausted is not marked", func(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`)
|
||||
if shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) {
|
||||
t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = true, want false", string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resource exhausted with quota metadata is still marked", func(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","metadata":{"quotaResetDelay":"1h","model":"claude-sonnet-4-6"}}]}}`)
|
||||
if !shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, 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 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)
|
||||
@@ -147,7 +193,7 @@ func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) {
|
||||
}
|
||||
|
||||
resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||
Model: "gemini-2.5-flash",
|
||||
Model: "claude-sonnet-4-6",
|
||||
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FormatAntigravity,
|
||||
@@ -163,32 +209,18 @@ func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
|
||||
func TestAntigravityExecute_CreditsInjectedWhenConductorRequests(t *testing.T) {
|
||||
resetAntigravityCreditsRetryState()
|
||||
t.Cleanup(resetAntigravityCreditsRetryState)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requestBodies []string
|
||||
)
|
||||
|
||||
var 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))
|
||||
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}}}`))
|
||||
@@ -199,7 +231,7 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
|
||||
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
|
||||
})
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "auth-credits-ok",
|
||||
ID: "auth-credits-conductor",
|
||||
Attributes: map[string]string{
|
||||
"base_url": server.URL,
|
||||
},
|
||||
@@ -210,8 +242,11 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||
Model: "gemini-2.5-flash",
|
||||
// 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,
|
||||
@@ -222,227 +257,12 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
|
||||
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))
|
||||
if len(requestBodies) != 1 {
|
||||
t.Fatalf("request count = %d, want 1", 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),
|
||||
},
|
||||
}
|
||||
recordAntigravityCreditsFailure(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) {
|
||||
func TestAntigravityExecute_NoCreditsWithoutConductorFlag(t *testing.T) {
|
||||
resetAntigravityCreditsRetryState()
|
||||
t.Cleanup(resetAntigravityCreditsRetryState)
|
||||
|
||||
@@ -457,10 +277,10 @@ func TestAntigravityExecute_DoesNotDirectInjectCreditsWhenFlagDisabled(t *testin
|
||||
defer server.Close()
|
||||
|
||||
exec := NewAntigravityExecutor(&config.Config{
|
||||
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: false},
|
||||
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
|
||||
})
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "auth-flag-disabled",
|
||||
ID: "auth-no-conductor-flag",
|
||||
Attributes: map[string]string{
|
||||
"base_url": server.URL,
|
||||
},
|
||||
@@ -470,10 +290,10 @@ func TestAntigravityExecute_DoesNotDirectInjectCreditsWhenFlagDisabled(t *testin
|
||||
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
markAntigravityPreferCredits(auth, "gemini-2.5-flash", time.Now(), nil)
|
||||
|
||||
// No conductor credits flag set in context
|
||||
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||
Model: "gemini-2.5-flash",
|
||||
Model: "claude-sonnet-4-6",
|
||||
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FormatAntigravity,
|
||||
@@ -484,7 +304,150 @@ func TestAntigravityExecute_DoesNotDirectInjectCreditsWhenFlagDisabled(t *testin
|
||||
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])
|
||||
// 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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{})
|
||||
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)
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user