Skip to content

Commit 474165d

Browse files
authored
Merge pull request #1043 from touwaeriol/pr/antigravity-credits-overages
feat: Antigravity AI Credits overages handling & balance display
2 parents 94e067a + 552a4b9 commit 474165d

24 files changed

Lines changed: 1805 additions & 141 deletions

backend/internal/pkg/antigravity/client.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,68 @@ type IneligibleTier struct {
124124
type LoadCodeAssistResponse struct {
125125
CloudAICompanionProject string `json:"cloudaicompanionProject"`
126126
CurrentTier *TierInfo `json:"currentTier,omitempty"`
127-
PaidTier *TierInfo `json:"paidTier,omitempty"`
127+
PaidTier *PaidTierInfo `json:"paidTier,omitempty"`
128128
IneligibleTiers []*IneligibleTier `json:"ineligibleTiers,omitempty"`
129129
}
130130

131+
// PaidTierInfo 付费等级信息,包含 AI Credits 余额。
132+
type PaidTierInfo struct {
133+
ID string `json:"id"`
134+
Name string `json:"name"`
135+
Description string `json:"description"`
136+
AvailableCredits []AvailableCredit `json:"availableCredits,omitempty"`
137+
}
138+
139+
// UnmarshalJSON 兼容 paidTier 既可能是字符串也可能是对象的情况。
140+
func (p *PaidTierInfo) UnmarshalJSON(data []byte) error {
141+
data = bytes.TrimSpace(data)
142+
if len(data) == 0 || string(data) == "null" {
143+
return nil
144+
}
145+
if data[0] == '"' {
146+
var id string
147+
if err := json.Unmarshal(data, &id); err != nil {
148+
return err
149+
}
150+
p.ID = id
151+
return nil
152+
}
153+
type alias PaidTierInfo
154+
var raw alias
155+
if err := json.Unmarshal(data, &raw); err != nil {
156+
return err
157+
}
158+
*p = PaidTierInfo(raw)
159+
return nil
160+
}
161+
162+
// AvailableCredit 表示一条 AI Credits 余额记录。
163+
type AvailableCredit struct {
164+
CreditType string `json:"creditType,omitempty"`
165+
CreditAmount string `json:"creditAmount,omitempty"`
166+
MinimumCreditAmountForUsage string `json:"minimumCreditAmountForUsage,omitempty"`
167+
}
168+
169+
// GetAmount 将 creditAmount 解析为浮点数。
170+
func (c *AvailableCredit) GetAmount() float64 {
171+
if c.CreditAmount == "" {
172+
return 0
173+
}
174+
var value float64
175+
_, _ = fmt.Sscanf(c.CreditAmount, "%f", &value)
176+
return value
177+
}
178+
179+
// GetMinimumAmount 将 minimumCreditAmountForUsage 解析为浮点数。
180+
func (c *AvailableCredit) GetMinimumAmount() float64 {
181+
if c.MinimumCreditAmountForUsage == "" {
182+
return 0
183+
}
184+
var value float64
185+
_, _ = fmt.Sscanf(c.MinimumCreditAmountForUsage, "%f", &value)
186+
return value
187+
}
188+
131189
// OnboardUserRequest onboardUser 请求
132190
type OnboardUserRequest struct {
133191
TierID string `json:"tierId"`
@@ -157,6 +215,14 @@ func (r *LoadCodeAssistResponse) GetTier() string {
157215
return ""
158216
}
159217

218+
// GetAvailableCredits 返回 paid tier 中的 AI Credits 余额列表。
219+
func (r *LoadCodeAssistResponse) GetAvailableCredits() []AvailableCredit {
220+
if r.PaidTier == nil {
221+
return nil
222+
}
223+
return r.PaidTier.AvailableCredits
224+
}
225+
160226
// Client Antigravity API 客户端
161227
type Client struct {
162228
httpClient *http.Client

backend/internal/pkg/antigravity/client_test.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func TestTierInfo_UnmarshalJSON_通过JSON嵌套结构(t *testing.T) {
190190
func TestGetTier_PaidTier优先(t *testing.T) {
191191
resp := &LoadCodeAssistResponse{
192192
CurrentTier: &TierInfo{ID: "free-tier"},
193-
PaidTier: &TierInfo{ID: "g1-pro-tier"},
193+
PaidTier: &PaidTierInfo{ID: "g1-pro-tier"},
194194
}
195195
if got := resp.GetTier(); got != "g1-pro-tier" {
196196
t.Errorf("应返回 paidTier: got %s", got)
@@ -209,14 +209,40 @@ func TestGetTier_回退到CurrentTier(t *testing.T) {
209209
func TestGetTier_PaidTier为空ID(t *testing.T) {
210210
resp := &LoadCodeAssistResponse{
211211
CurrentTier: &TierInfo{ID: "free-tier"},
212-
PaidTier: &TierInfo{ID: ""},
212+
PaidTier: &PaidTierInfo{ID: ""},
213213
}
214214
// paidTier.ID 为空时应回退到 currentTier
215215
if got := resp.GetTier(); got != "free-tier" {
216216
t.Errorf("paidTier.ID 为空时应回退到 currentTier: got %s", got)
217217
}
218218
}
219219

220+
func TestGetAvailableCredits(t *testing.T) {
221+
resp := &LoadCodeAssistResponse{
222+
PaidTier: &PaidTierInfo{
223+
ID: "g1-pro-tier",
224+
AvailableCredits: []AvailableCredit{
225+
{
226+
CreditType: "GOOGLE_ONE_AI",
227+
CreditAmount: "25",
228+
MinimumCreditAmountForUsage: "5",
229+
},
230+
},
231+
},
232+
}
233+
234+
credits := resp.GetAvailableCredits()
235+
if len(credits) != 1 {
236+
t.Fatalf("AI Credits 数量不匹配: got %d", len(credits))
237+
}
238+
if credits[0].GetAmount() != 25 {
239+
t.Errorf("CreditAmount 解析不正确: got %v", credits[0].GetAmount())
240+
}
241+
if credits[0].GetMinimumAmount() != 5 {
242+
t.Errorf("MinimumCreditAmountForUsage 解析不正确: got %v", credits[0].GetMinimumAmount())
243+
}
244+
}
245+
220246
func TestGetTier_两者都为nil(t *testing.T) {
221247
resp := &LoadCodeAssistResponse{}
222248
if got := resp.GetTier(); got != "" {

backend/internal/service/account.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,22 @@ func (a *Account) IsMixedSchedulingEnabled() bool {
901901
return false
902902
}
903903

904+
// IsOveragesEnabled 检查 Antigravity 账号是否启用 AI Credits 超量请求。
905+
func (a *Account) IsOveragesEnabled() bool {
906+
if a.Platform != PlatformAntigravity {
907+
return false
908+
}
909+
if a.Extra == nil {
910+
return false
911+
}
912+
if v, ok := a.Extra["allow_overages"]; ok {
913+
if enabled, ok := v.(bool); ok {
914+
return enabled
915+
}
916+
}
917+
return false
918+
}
919+
904920
// IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用“自动透传(仅替换认证)”。
905921
//
906922
// 新字段:accounts.extra.openai_passthrough。

backend/internal/service/account_usage_service.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,13 @@ type AntigravityModelDetail struct {
166166
SupportedMimeTypes map[string]bool `json:"supported_mime_types,omitempty"`
167167
}
168168

169+
// AICredit 表示 Antigravity 账号的 AI Credits 余额信息。
170+
type AICredit struct {
171+
CreditType string `json:"credit_type,omitempty"`
172+
Amount float64 `json:"amount,omitempty"`
173+
MinimumBalance float64 `json:"minimum_balance,omitempty"`
174+
}
175+
169176
// UsageInfo 账号使用量信息
170177
type UsageInfo struct {
171178
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
@@ -189,6 +196,9 @@ type UsageInfo struct {
189196
// Antigravity 模型详细能力信息(与 antigravity_quota 同 key)
190197
AntigravityQuotaDetails map[string]*AntigravityModelDetail `json:"antigravity_quota_details,omitempty"`
191198

199+
// Antigravity AI Credits 余额
200+
AICredits []AICredit `json:"ai_credits,omitempty"`
201+
192202
// Antigravity 废弃模型转发规则 (old_model_id -> new_model_id)
193203
ModelForwardingRules map[string]string `json:"model_forwarding_rules,omitempty"`
194204

backend/internal/service/admin_service.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,10 @@ type ProxyExitInfoProber interface {
368368
ProbeProxy(ctx context.Context, proxyURL string) (*ProxyExitInfo, int64, error)
369369
}
370370

371+
type groupExistenceBatchReader interface {
372+
ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error)
373+
}
374+
371375
type proxyQualityTarget struct {
372376
Target string
373377
URL string
@@ -445,10 +449,6 @@ type userGroupRateBatchReader interface {
445449
GetByUserIDs(ctx context.Context, userIDs []int64) (map[int64]map[int64]float64, error)
446450
}
447451

448-
type groupExistenceBatchReader interface {
449-
ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error)
450-
}
451-
452452
// NewAdminService creates a new AdminService
453453
func NewAdminService(
454454
userRepo UserRepository,
@@ -1516,6 +1516,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
15161516
if err != nil {
15171517
return nil, err
15181518
}
1519+
wasOveragesEnabled := account.IsOveragesEnabled()
15191520

15201521
if input.Name != "" {
15211522
account.Name = input.Name
@@ -1537,6 +1538,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
15371538
}
15381539
}
15391540
account.Extra = input.Extra
1541+
if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() {
1542+
delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
1543+
// 清除 AICredits 限流 key
1544+
if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok {
1545+
delete(rawLimits, creditsExhaustedKey)
1546+
}
1547+
}
1548+
if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() {
1549+
delete(account.Extra, modelRateLimitsKey)
1550+
delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
1551+
}
15401552
// 校验并预计算固定时间重置的下次重置时间
15411553
if err := ValidateQuotaResetConfig(account.Extra); err != nil {
15421554
return nil, err
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//go:build unit
2+
3+
package service
4+
5+
import (
6+
"context"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
type updateAccountOveragesRepoStub struct {
14+
mockAccountRepoForGemini
15+
account *Account
16+
updateCalls int
17+
}
18+
19+
func (r *updateAccountOveragesRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) {
20+
return r.account, nil
21+
}
22+
23+
func (r *updateAccountOveragesRepoStub) Update(ctx context.Context, account *Account) error {
24+
r.updateCalls++
25+
r.account = account
26+
return nil
27+
}
28+
29+
func TestUpdateAccount_DisableOveragesClearsAICreditsKey(t *testing.T) {
30+
accountID := int64(101)
31+
repo := &updateAccountOveragesRepoStub{
32+
account: &Account{
33+
ID: accountID,
34+
Platform: PlatformAntigravity,
35+
Type: AccountTypeOAuth,
36+
Status: StatusActive,
37+
Extra: map[string]any{
38+
"allow_overages": true,
39+
"mixed_scheduling": true,
40+
modelRateLimitsKey: map[string]any{
41+
"claude-sonnet-4-5": map[string]any{
42+
"rate_limited_at": "2026-03-15T00:00:00Z",
43+
"rate_limit_reset_at": "2099-03-15T00:00:00Z",
44+
},
45+
creditsExhaustedKey: map[string]any{
46+
"rate_limited_at": "2026-03-15T00:00:00Z",
47+
"rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339),
48+
},
49+
},
50+
},
51+
},
52+
}
53+
54+
svc := &adminServiceImpl{accountRepo: repo}
55+
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
56+
Extra: map[string]any{
57+
"mixed_scheduling": true,
58+
modelRateLimitsKey: map[string]any{
59+
"claude-sonnet-4-5": map[string]any{
60+
"rate_limited_at": "2026-03-15T00:00:00Z",
61+
"rate_limit_reset_at": "2099-03-15T00:00:00Z",
62+
},
63+
creditsExhaustedKey: map[string]any{
64+
"rate_limited_at": "2026-03-15T00:00:00Z",
65+
"rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339),
66+
},
67+
},
68+
},
69+
})
70+
71+
require.NoError(t, err)
72+
require.NotNil(t, updated)
73+
require.Equal(t, 1, repo.updateCalls)
74+
require.False(t, updated.IsOveragesEnabled())
75+
76+
// 关闭 overages 后,AICredits key 应被清除
77+
rawLimits, ok := repo.account.Extra[modelRateLimitsKey].(map[string]any)
78+
if ok {
79+
_, exists := rawLimits[creditsExhaustedKey]
80+
require.False(t, exists, "关闭 overages 时应清除 AICredits 限流 key")
81+
}
82+
// 普通模型限流应保留
83+
require.True(t, ok)
84+
_, exists := rawLimits["claude-sonnet-4-5"]
85+
require.True(t, exists, "普通模型限流应保留")
86+
}
87+
88+
func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testing.T) {
89+
accountID := int64(102)
90+
repo := &updateAccountOveragesRepoStub{
91+
account: &Account{
92+
ID: accountID,
93+
Platform: PlatformAntigravity,
94+
Type: AccountTypeOAuth,
95+
Status: StatusActive,
96+
Extra: map[string]any{
97+
"mixed_scheduling": true,
98+
modelRateLimitsKey: map[string]any{
99+
"claude-sonnet-4-5": map[string]any{
100+
"rate_limited_at": "2026-03-15T00:00:00Z",
101+
"rate_limit_reset_at": "2099-03-15T00:00:00Z",
102+
},
103+
},
104+
},
105+
},
106+
}
107+
108+
svc := &adminServiceImpl{accountRepo: repo}
109+
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
110+
Extra: map[string]any{
111+
"mixed_scheduling": true,
112+
"allow_overages": true,
113+
},
114+
})
115+
116+
require.NoError(t, err)
117+
require.NotNil(t, updated)
118+
require.Equal(t, 1, repo.updateCalls)
119+
require.True(t, updated.IsOveragesEnabled())
120+
121+
_, exists := repo.account.Extra[modelRateLimitsKey]
122+
require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流")
123+
}

0 commit comments

Comments
 (0)