Merge pull request #2927 from moonagic/main

fix antigravity gemini rate limit and account scheduling
This commit is contained in:
Wesley Liddick 2026-06-01 14:50:36 +08:00 committed by GitHub
commit 5f63fe1945
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 747 additions and 51 deletions

View File

@ -441,7 +441,17 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 记录 Forward 前已写入字节数Forward 后若增加则说明 SSE 内容已发,禁止 failover // 记录 Forward 前已写入字节数Forward 后若增加则说明 SSE 内容已发,禁止 failover
writerSizeBeforeForward := c.Writer.Size() writerSizeBeforeForward := c.Writer.Size()
if account.Platform == service.PlatformAntigravity { if account.Platform == service.PlatformAntigravity {
result, err = h.antigravityGatewayService.ForwardGemini(requestCtx, c, account, reqModel, "generateContent", reqStream, body, hasBoundSession) result, err = h.antigravityGatewayService.ForwardGemini(
requestCtx,
c,
account,
reqModel,
"generateContent",
reqStream,
body,
hasBoundSession,
service.WithForwardGeminiSession(derefGroupID(apiKey.GroupID), sessionKey),
)
} else { } else {
result, err = h.geminiCompatService.Forward(requestCtx, c, account, body) result, err = h.geminiCompatService.Forward(requestCtx, c, account, body)
} }

View File

@ -477,8 +477,19 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
if fs.SwitchCount > 0 { if fs.SwitchCount > 0 {
requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled()) requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
} }
sessionGroupID := derefGroupID(apiKey.GroupID)
if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey { if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
result, err = h.antigravityGatewayService.ForwardGemini(requestCtx, c, account, modelName, action, stream, body, hasBoundSession) result, err = h.antigravityGatewayService.ForwardGemini(
requestCtx,
c,
account,
modelName,
action,
stream,
body,
hasBoundSession,
service.WithForwardGeminiSession(sessionGroupID, sessionKey),
)
} else { } else {
result, err = h.geminiCompatService.ForwardNative(requestCtx, c, account, modelName, action, stream, body) result, err = h.geminiCompatService.ForwardNative(requestCtx, c, account, modelName, action, stream, body)
} }

View File

@ -559,6 +559,7 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
"auto_pause_7d_threshold", "auto_pause_7d_threshold",
"auto_pause_5h_disabled", "auto_pause_5h_disabled",
"auto_pause_7d_disabled", "auto_pause_7d_disabled",
"model_rate_limits",
} }
filtered := make(map[string]any) filtered := make(map[string]any)
for _, key := range keys { for _, key := range keys {

View File

@ -108,3 +108,29 @@ func TestBuildSchedulerMetadataAccount_KeepsQuotaAutoPauseFields(t *testing.T) {
require.Equal(t, true, got.Extra["auto_pause_5h_disabled"]) require.Equal(t, true, got.Extra["auto_pause_5h_disabled"])
require.Equal(t, false, got.Extra["auto_pause_7d_disabled"]) require.Equal(t, false, got.Extra["auto_pause_7d_disabled"])
} }
func TestBuildSchedulerMetadataAccount_KeepsModelRateLimits(t *testing.T) {
account := service.Account{
ID: 90,
Platform: service.PlatformAntigravity,
Extra: map[string]any{
"model_rate_limits": map[string]any{
"gemini-3-flash": map[string]any{
"rate_limit_reset_at": "2026-05-30T10:10:00Z",
},
"antigravity:gemini": map[string]any{
"rate_limit_reset_at": "2026-05-30T10:10:00Z",
},
},
"unused_large_field": "drop-me",
},
}
got := buildSchedulerMetadataAccount(account)
limits, ok := got.Extra["model_rate_limits"].(map[string]any)
require.True(t, ok)
require.Contains(t, limits, "gemini-3-flash")
require.Contains(t, limits, "antigravity:gemini")
require.Nil(t, got.Extra["unused_large_field"])
}

View File

@ -0,0 +1,61 @@
//go:build !unit
package service
import (
"context"
"time"
)
type defaultRateLimitCall struct {
accountID int64
resetAt time.Time
}
type defaultModelRateLimitCall struct {
accountID int64
modelKey string
resetAt time.Time
}
type defaultExtraUpdateCall struct {
accountID int64
updates map[string]any
}
type stubAntigravityAccountRepo struct {
AccountRepository
rateCalls []defaultRateLimitCall
modelRateLimitCalls []defaultModelRateLimitCall
extraUpdateCalls []defaultExtraUpdateCall
}
func (s *stubAntigravityAccountRepo) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error {
s.rateCalls = append(s.rateCalls, defaultRateLimitCall{accountID: id, resetAt: resetAt})
return nil
}
func (s *stubAntigravityAccountRepo) SetModelRateLimit(_ context.Context, id int64, modelKey string, resetAt time.Time, _ ...string) error {
s.modelRateLimitCalls = append(s.modelRateLimitCalls, defaultModelRateLimitCall{accountID: id, modelKey: modelKey, resetAt: resetAt})
return nil
}
func (s *stubAntigravityAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
s.extraUpdateCalls = append(s.extraUpdateCalls, defaultExtraUpdateCall{accountID: id, updates: updates})
return nil
}
type defaultDeleteSessionCall struct {
groupID int64
sessionHash string
}
type stubSmartRetryCache struct {
GatewayCache
deleteCalls []defaultDeleteSessionCall
}
func (c *stubSmartRetryCache) DeleteSessionAccountID(_ context.Context, groupID int64, sessionHash string) error {
c.deleteCalls = append(c.deleteCalls, defaultDeleteSessionCall{groupID: groupID, sessionHash: sessionHash})
return nil
}

View File

@ -228,12 +228,11 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
p.prefix, resp.StatusCode, modelName, p.account.ID, rateLimitDuration, truncateForLog(respBody, 200)) p.prefix, resp.StatusCode, modelName, p.account.ID, rateLimitDuration, truncateForLog(respBody, 200))
resetAt := time.Now().Add(rateLimitDuration) resetAt := time.Now().Add(rateLimitDuration)
if !setModelRateLimitByModelName(p.ctx, p.accountRepo, p.account.ID, modelName, p.prefix, resp.StatusCode, resetAt, false) { if !s.setAntigravityModelRateLimits(p.ctx, p.accountRepo, p.account, modelName, p.prefix, resp.StatusCode, resetAt, false) {
p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession)
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d rate_limited account=%d (no model mapping)", p.prefix, resp.StatusCode, p.account.ID) logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d rate_limited account=%d (no model mapping)", p.prefix, resp.StatusCode, p.account.ID)
} else {
s.updateAccountModelRateLimitInCache(p.ctx, p.account, modelName, resetAt)
} }
s.clearStickySession(p.ctx, p.groupID, p.sessionHash)
// 返回账号切换信号,让上层切换账号重试 // 返回账号切换信号,让上层切换账号重试
return &smartRetryResult{ return &smartRetryResult{
@ -392,20 +391,10 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, rateLimitDuration, truncateForLog(retryBody, 200)) p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, rateLimitDuration, truncateForLog(retryBody, 200))
resetAt := time.Now().Add(rateLimitDuration) resetAt := time.Now().Add(rateLimitDuration)
if p.accountRepo != nil && modelName != "" { s.setAntigravityModelRateLimits(p.ctx, p.accountRepo, p.account, modelName, p.prefix, resp.StatusCode, resetAt, true)
if err := p.accountRepo.SetModelRateLimit(p.ctx, p.account.ID, modelName, resetAt); err != nil {
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limit_failed model=%s error=%v", p.prefix, resp.StatusCode, modelName, err)
} else {
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited_after_smart_retry model=%s account=%d reset_in=%v",
p.prefix, resp.StatusCode, modelName, p.account.ID, rateLimitDuration)
s.updateAccountModelRateLimitInCache(p.ctx, p.account, modelName, resetAt)
}
}
// 清除粘性会话绑定,避免下次请求仍命中限流账号 // 清除粘性会话绑定,避免下次请求仍命中限流账号
if s.cache != nil && p.sessionHash != "" { s.clearStickySession(p.ctx, p.groupID, p.sessionHash)
_ = s.cache.DeleteSessionAccountID(p.ctx, p.groupID, p.sessionHash)
}
// 返回账号切换信号,让上层切换账号重试 // 返回账号切换信号,让上层切换账号重试
return &smartRetryResult{ return &smartRetryResult{
@ -954,8 +943,14 @@ func (s *AntigravityGatewayService) checkErrorPolicy(ctx context.Context, accoun
func (s *AntigravityGatewayService) applyErrorPolicy(p antigravityRetryLoopParams, statusCode int, headers http.Header, respBody []byte) (handled bool, outStatus int, retErr error) { func (s *AntigravityGatewayService) applyErrorPolicy(p antigravityRetryLoopParams, statusCode int, headers http.Header, respBody []byte) (handled bool, outStatus int, retErr error) {
switch s.checkErrorPolicy(p.ctx, p.account, statusCode, respBody) { switch s.checkErrorPolicy(p.ctx, p.account, statusCode, respBody) {
case ErrorPolicySkipped: case ErrorPolicySkipped:
if s.handleAntigravityModelRateLimitBeforePolicy(p, statusCode, headers, respBody) {
return true, statusCode, nil
}
return true, http.StatusInternalServerError, nil return true, http.StatusInternalServerError, nil
case ErrorPolicyMatched: case ErrorPolicyMatched:
if s.handleAntigravityModelRateLimitBeforePolicy(p, statusCode, headers, respBody) {
return true, statusCode, nil
}
_ = p.handleError(p.ctx, p.prefix, p.account, statusCode, headers, respBody, _ = p.handleError(p.ctx, p.prefix, p.account, statusCode, headers, respBody,
p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) p.requestedModel, p.groupID, p.sessionHash, p.isStickySession)
return true, statusCode, nil return true, statusCode, nil
@ -967,6 +962,31 @@ func (s *AntigravityGatewayService) applyErrorPolicy(p antigravityRetryLoopParam
return false, statusCode, nil return false, statusCode, nil
} }
func (s *AntigravityGatewayService) handleAntigravityModelRateLimitBeforePolicy(p antigravityRetryLoopParams, statusCode int, headers http.Header, respBody []byte) bool {
if statusCode != http.StatusTooManyRequests && statusCode != http.StatusServiceUnavailable {
return false
}
if p.account == nil || p.account.Platform != PlatformAntigravity {
return false
}
_, shouldRateLimitModel, waitDuration, modelName, isModelCapacityExhausted := shouldTriggerAntigravitySmartRetry(p.account, respBody)
if isModelCapacityExhausted || !shouldRateLimitModel || strings.TrimSpace(modelName) == "" {
return false
}
rateLimitDuration := waitDuration
if rateLimitDuration <= 0 {
rateLimitDuration = antigravityDefaultRateLimitDuration
}
resetAt := time.Now().Add(rateLimitDuration)
if !s.setAntigravityModelRateLimits(p.ctx, p.accountRepo, p.account, modelName, p.prefix, statusCode, resetAt, false) {
return false
}
s.clearStickySession(p.ctx, p.groupID, p.sessionHash)
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited_before_error_policy model=%s account=%d reset_in=%v",
p.prefix, statusCode, modelName, p.account.ID, rateLimitDuration)
return true
}
// mapAntigravityModel 获取映射后的模型名 // mapAntigravityModel 获取映射后的模型名
// 完全依赖映射配置:账户映射(通配符)→ 默认映射兜底DefaultAntigravityModelMapping // 完全依赖映射配置:账户映射(通配符)→ 默认映射兜底DefaultAntigravityModelMapping
// 注意:返回空字符串表示模型不被支持,调度时会过滤掉该账号 // 注意:返回空字符串表示模型不被支持,调度时会过滤掉该账号
@ -974,6 +994,7 @@ func mapAntigravityModel(account *Account, requestedModel string) string {
if account == nil { if account == nil {
return "" return ""
} }
requestedModel = strings.TrimPrefix(requestedModel, "models/")
// 获取映射表(未配置时自动使用 DefaultAntigravityModelMapping // 获取映射表(未配置时自动使用 DefaultAntigravityModelMapping
mapping := account.GetModelMapping() mapping := account.GetModelMapping()
@ -2073,8 +2094,28 @@ func stripSignatureSensitiveBlocksFromClaudeRequest(req *antigravity.ClaudeReque
// └─ retryDelay < 7s → 等待后重试 1 次 // └─ retryDelay < 7s → 等待后重试 1 次
// ├─ 成功 → 正常返回 // ├─ 成功 → 正常返回
// └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号 // └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号
func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool) (*ForwardResult, error) { type ForwardGeminiOption func(*forwardGeminiOptions)
type forwardGeminiOptions struct {
groupID int64
sessionHash string
}
func WithForwardGeminiSession(groupID int64, sessionHash string) ForwardGeminiOption {
return func(opts *forwardGeminiOptions) {
opts.groupID = groupID
opts.sessionHash = sessionHash
}
}
func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool, options ...ForwardGeminiOption) (*ForwardResult, error) {
startTime := time.Now() startTime := time.Now()
forwardOpts := forwardGeminiOptions{}
for _, apply := range options {
if apply != nil {
apply(&forwardOpts)
}
}
sessionID := getSessionID(c) sessionID := getSessionID(c)
prefix := logPrefix(sessionID, account.Name) prefix := logPrefix(sessionID, account.Name)
@ -2179,8 +2220,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
handleError: s.handleUpstreamError, handleError: s.handleUpstreamError,
requestedModel: originalModel, requestedModel: originalModel,
isStickySession: isStickySession, // ForwardGemini 由上层判断粘性会话 isStickySession: isStickySession, // ForwardGemini 由上层判断粘性会话
groupID: 0, // ForwardGemini 方法没有 groupID由上层处理粘性会话清除 groupID: forwardOpts.groupID,
sessionHash: "", // ForwardGemini 方法没有 sessionHash由上层处理粘性会话清除 sessionHash: forwardOpts.sessionHash,
}) })
if err != nil { if err != nil {
// 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号 // 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号
@ -2278,8 +2319,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
handleError: s.handleUpstreamError, handleError: s.handleUpstreamError,
requestedModel: originalModel, requestedModel: originalModel,
isStickySession: isStickySession, isStickySession: isStickySession,
groupID: 0, groupID: forwardOpts.groupID,
sessionHash: "", sessionHash: forwardOpts.sessionHash,
}) })
if retryErr == nil { if retryErr == nil {
retryResp := retryResult.resp retryResp := retryResult.resp
@ -2355,7 +2396,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
if unwrapErr != nil || len(unwrappedForOps) == 0 { if unwrapErr != nil || len(unwrappedForOps) == 0 {
unwrappedForOps = respBody unwrappedForOps = respBody
} }
s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", isStickySession) s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, forwardOpts.groupID, forwardOpts.sessionHash, isStickySession)
upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(unwrappedForOps)) upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(unwrappedForOps))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
upstreamDetail := s.getUpstreamErrorDetail(unwrappedForOps) upstreamDetail := s.getUpstreamErrorDetail(unwrappedForOps)
@ -2566,6 +2607,34 @@ func setModelRateLimitByModelName(ctx context.Context, repo AccountRepository, a
return true return true
} }
func (s *AntigravityGatewayService) setAntigravityModelRateLimits(ctx context.Context, repo AccountRepository, account *Account, modelName, prefix string, statusCode int, resetAt time.Time, afterSmartRetry bool) bool {
if account == nil || repo == nil {
return false
}
keys := antigravityModelRateLimitKeys(modelName)
if len(keys) == 0 {
return false
}
success := false
for _, key := range keys {
if setModelRateLimitByModelName(ctx, repo, account.ID, key, prefix, statusCode, resetAt, afterSmartRetry) {
s.updateAccountModelRateLimitInCache(ctx, account, key, resetAt)
success = true
}
}
return success
}
func (s *AntigravityGatewayService) clearStickySession(ctx context.Context, groupID int64, sessionHash string) {
if s == nil || s.cache == nil || strings.TrimSpace(sessionHash) == "" {
return
}
if err := s.cache.DeleteSessionAccountID(ctx, groupID, sessionHash); err != nil {
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] sticky_session_clear_failed group_id=%d session=%s err=%v", groupID, shortSessionHash(sessionHash), err)
}
}
func antigravityFallbackCooldownSeconds() (time.Duration, bool) { func antigravityFallbackCooldownSeconds() (time.Duration, bool) {
raw := strings.TrimSpace(os.Getenv(antigravityFallbackSecondsEnv)) raw := strings.TrimSpace(os.Getenv(antigravityFallbackSecondsEnv))
if raw == "" { if raw == "" {
@ -2644,7 +2713,7 @@ func parseAntigravitySmartRetryInfo(body []byte) *antigravitySmartRetryInfo {
if atType == googleRPCTypeErrorInfo { if atType == googleRPCTypeErrorInfo {
if meta, ok := dm["metadata"].(map[string]any); ok { if meta, ok := dm["metadata"].(map[string]any); ok {
if model, ok := meta["model"].(string); ok { if model, ok := meta["model"].(string); ok {
modelName = model modelName = normalizeAntigravityModelName(model)
} }
} }
// 检查 reason // 检查 reason
@ -2818,13 +2887,7 @@ func (s *AntigravityGatewayService) setModelRateLimitAndClearSession(p *handleMo
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited model=%s account=%d reset_in=%v", logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited model=%s account=%d reset_in=%v",
p.prefix, p.statusCode, info.ModelName, p.account.ID, info.RetryDelay) p.prefix, p.statusCode, info.ModelName, p.account.ID, info.RetryDelay)
// 设置模型限流状态(数据库) s.setAntigravityModelRateLimits(p.ctx, s.accountRepo, p.account, info.ModelName, p.prefix, p.statusCode, resetAt, false)
if err := s.accountRepo.SetModelRateLimit(p.ctx, p.account.ID, info.ModelName, resetAt); err != nil {
logger.LegacyPrintf("service.antigravity_gateway", "%s model_rate_limit_failed model=%s error=%v", p.prefix, info.ModelName, err)
}
// 立即更新 Redis 快照中账号的限流状态,避免并发请求重复选中
s.updateAccountModelRateLimitInCache(p.ctx, p.account, info.ModelName, resetAt)
// 清除粘性会话绑定 // 清除粘性会话绑定
if p.cache != nil && p.sessionHash != "" { if p.cache != nil && p.sessionHash != "" {
@ -2914,12 +2977,11 @@ func (s *AntigravityGatewayService) handleUpstreamError(
} }
if modelKey != "" { if modelKey != "" {
ra := s.resolveResetTime(resetAt, defaultDur) ra := s.resolveResetTime(resetAt, defaultDur)
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, ra); err != nil { if !s.setAntigravityModelRateLimits(ctx, s.accountRepo, account, modelKey, prefix, statusCode, ra, false) {
logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limit_set_failed model=%s error=%v", prefix, modelKey, err) logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limit_set_failed model=%s", prefix, modelKey)
} else { } else {
logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limited model=%s account=%d reset_at=%v reset_in=%v", logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limited model=%s account=%d reset_at=%v reset_in=%v",
prefix, modelKey, account.ID, ra.Format("15:04:05"), time.Until(ra).Truncate(time.Second)) prefix, modelKey, account.ID, ra.Format("15:04:05"), time.Until(ra).Truncate(time.Second))
s.updateAccountModelRateLimitInCache(ctx, account, modelKey, ra)
} }
return nil return nil
} }

View File

@ -500,6 +500,86 @@ func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling(
require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch") require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch")
} }
func TestAntigravityGatewayService_ForwardGemini_ClearsStickySessionOnGeminiRateLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
body, err := json.Marshal(map[string]any{
"contents": []map[string]any{
{"role": "user", "parts": []map[string]any{{"text": "hi"}}},
},
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3-flash-preview:generateContent", bytes.NewReader(body))
c.Request = req
respBody := []byte(`{
"error": {
"status": "RESOURCE_EXHAUSTED",
"details": [
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"},
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
]
}
}`)
upstream := &httpUpstreamStub{resp: &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{},
Body: io.NopCloser(bytes.NewReader(respBody)),
}}
repo := &stubAntigravityAccountRepo{}
cache := &stubSmartRetryCache{}
svc := &AntigravityGatewayService{
tokenProvider: &AntigravityTokenProvider{},
httpUpstream: upstream,
accountRepo: repo,
cache: cache,
}
account := &Account{
ID: 44,
Name: "acc-gemini-runtime-rate-limited",
Platform: PlatformAntigravity,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "token",
"expires_at": time.Now().Add(time.Hour).Format(time.RFC3339),
"project_id": "proj",
},
Extra: map[string]any{
"mixed_scheduling": true,
},
}
result, err := svc.ForwardGemini(
context.Background(),
c,
account,
"gemini-3-flash-preview",
"generateContent",
false,
body,
true,
WithForwardGeminiSession(77, "gemini:sticky-runtime"),
)
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
require.Len(t, repo.modelRateLimitCalls, 2)
require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
require.Len(t, cache.deleteCalls, 1)
require.Equal(t, int64(77), cache.deleteCalls[0].groupID)
require.Equal(t, "gemini:sticky-runtime", cache.deleteCalls[0].sessionHash)
}
// TestAntigravityGatewayService_Forward_BillsWithMappedModel // TestAntigravityGatewayService_Forward_BillsWithMappedModel
// 验证Antigravity Claude 转发返回的计费模型使用映射后的模型 // 验证Antigravity Claude 转发返回的计费模型使用映射后的模型
func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) { func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) {

View File

@ -8,7 +8,17 @@ import (
func normalizeAntigravityModelName(model string) string { func normalizeAntigravityModelName(model string) string {
normalized := strings.ToLower(strings.TrimSpace(model)) normalized := strings.ToLower(strings.TrimSpace(model))
normalized = strings.TrimPrefix(normalized, "models/") if idx := strings.LastIndex(normalized, "/publishers/google/models/"); idx != -1 {
normalized = normalized[idx+len("/publishers/google/models/"):]
} else if idx := strings.LastIndex(normalized, "/publishers/anthropic/models/"); idx != -1 {
normalized = normalized[idx+len("/publishers/anthropic/models/"):]
} else if idx := strings.LastIndex(normalized, "/models/"); idx != -1 {
normalized = normalized[idx+len("/models/"):]
} else {
normalized = strings.TrimPrefix(normalized, "publishers/google/models/")
normalized = strings.TrimPrefix(normalized, "publishers/anthropic/models/")
normalized = strings.TrimPrefix(normalized, "models/")
}
return normalized return normalized
} }

View File

@ -821,6 +821,51 @@ func TestSetModelRateLimitByModelName_NotConvertToScope(t *testing.T) {
require.NotEqual(t, "claude_sonnet", call.modelKey, "should NOT be scope") require.NotEqual(t, "claude_sonnet", call.modelKey, "should NOT be scope")
} }
func TestSetAntigravityModelRateLimits_GeminiWritesFamilyScope(t *testing.T) {
repo := &stubAntigravityAccountRepo{}
svc := &AntigravityGatewayService{}
account := &Account{ID: 789, Platform: PlatformAntigravity}
resetAt := time.Now().Add(30 * time.Second)
success := svc.setAntigravityModelRateLimits(
context.Background(),
repo,
account,
"gemini-3-pro",
"[test]",
429,
resetAt,
false,
)
require.True(t, success)
require.Len(t, repo.modelRateLimitCalls, 2)
require.Equal(t, "gemini-3-pro", repo.modelRateLimitCalls[0].modelKey)
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
}
func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing.T) {
repo := &stubAntigravityAccountRepo{}
svc := &AntigravityGatewayService{}
account := &Account{ID: 790, Platform: PlatformAntigravity}
resetAt := time.Now().Add(30 * time.Second)
success := svc.setAntigravityModelRateLimits(
context.Background(),
repo,
account,
"claude-sonnet-4-5",
"[test]",
429,
resetAt,
false,
)
require.True(t, success)
require.Len(t, repo.modelRateLimitCalls, 1)
require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
}
func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) { func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) {
upstream := &recordingOKUpstream{} upstream := &recordingOKUpstream{}
account := &Account{ account := &Account{
@ -1124,3 +1169,53 @@ func TestSchedulerSnapshotService_UpdateAccountInCache(t *testing.T) {
require.ErrorIs(t, err, expectedErr) require.ErrorIs(t, err, expectedErr)
}) })
} }
func TestNormalizeAntigravityModelName(t *testing.T) {
tests := []struct {
name string
model string
expected string
}{
{
name: "plain model name",
model: "gemini-1.5-pro",
expected: "gemini-1.5-pro",
},
{
name: "models/ prefix",
model: "models/gemini-1.5-pro",
expected: "gemini-1.5-pro",
},
{
name: "publishers/google/models/ prefix",
model: "publishers/google/models/gemini-1.5-pro",
expected: "gemini-1.5-pro",
},
{
name: "projects/.../publishers/google/models/ path",
model: "projects/my-proj/locations/us-central1/publishers/google/models/gemini-2.5-flash",
expected: "gemini-2.5-flash",
},
{
name: "publishers/anthropic/models/ prefix",
model: "publishers/anthropic/models/claude-sonnet-4-5",
expected: "claude-sonnet-4-5",
},
{
name: "projects/.../publishers/anthropic/models/ path",
model: "projects/my-proj/locations/global/publishers/anthropic/models/claude-sonnet-4-5",
expected: "claude-sonnet-4-5",
},
{
name: "mixed case and spaces",
model: " Models/Gemini-1.5-Pro ",
expected: "gemini-1.5-pro",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := normalizeAntigravityModelName(tt.model)
require.Equal(t, tt.expected, actual)
})
}
}

View File

@ -196,8 +196,10 @@ func TestHandleSmartRetry_503_LongDelay_NoSingleAccountRetry_StillSwitches(t *te
require.Nil(t, result.resp, "should not return resp when switchError is set") require.Nil(t, result.resp, "should not return resp when switchError is set")
// 对照:多账号模式应设模型限流 // 对照:多账号模式应设模型限流
require.Len(t, repo.modelRateLimitCalls, 1, require.Len(t, repo.modelRateLimitCalls, 2,
"multi-account mode SHOULD set model rate limit") "multi-account mode SHOULD set model rate limit")
require.Equal(t, "gemini-3-pro-high", repo.modelRateLimitCalls[0].modelKey)
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
} }
// TestHandleSmartRetry_429_LongDelay_SingleAccountRetry_StillSwitches // TestHandleSmartRetry_429_LongDelay_SingleAccountRetry_StillSwitches
@ -412,8 +414,10 @@ func TestHandleSmartRetry_503_ShortDelay_NoSingleAccountRetry_SetsRateLimit(t *t
// 对照:多账号模式应返回 switchError // 对照:多账号模式应返回 switchError
require.NotNil(t, result.switchError, "multi-account mode should return switchError for 503") require.NotNil(t, result.switchError, "multi-account mode should return switchError for 503")
// 对照:多账号模式应设模型限流 // 对照:多账号模式应设模型限流
require.Len(t, repo.modelRateLimitCalls, 1, require.Len(t, repo.modelRateLimitCalls, 2,
"multi-account mode should set model rate limit") "multi-account mode should set model rate limit")
require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -328,9 +328,10 @@ func TestHandleSmartRetry_ShortDelay_SmartRetryFailed_ReturnsSwitchError(t *test
require.Equal(t, "gemini-3-flash", result.switchError.RateLimitedModel) require.Equal(t, "gemini-3-flash", result.switchError.RateLimitedModel)
require.False(t, result.switchError.IsStickySession) require.False(t, result.switchError.IsStickySession)
// 验证模型限流已设置 // 验证模型限流已设置Gemini 同时写入精确模型和家族级 scope
require.Len(t, repo.modelRateLimitCalls, 1) require.Len(t, repo.modelRateLimitCalls, 2)
require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey) require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
require.Len(t, upstream.calls, 1, "should have made one retry call (max attempts)") require.Len(t, upstream.calls, 1, "should have made one retry call (max attempts)")
} }
@ -1104,10 +1105,9 @@ func TestHandleSmartRetry_ShortDelay_StickySession_SuccessRetry_NoDeleteSession(
require.Len(t, cache.deleteCalls, 0, "should NOT call DeleteSessionAccountID on successful retry") require.Len(t, cache.deleteCalls, 0, "should NOT call DeleteSessionAccountID on successful retry")
} }
// TestHandleSmartRetry_LongDelay_StickySession_NoDeleteInHandleSmartRetry // TestHandleSmartRetry_LongDelay_StickySession_ClearsSession
// 长延迟路径情况1在 handleSmartRetry 中不直接调用 DeleteSessionAccountID // 长延迟路径情况1应立即清除 sticky 绑定,避免下一次请求继续命中已限流账号。
// (清除由 handler 层的 shouldClearStickySession 在下次请求时处理) func TestHandleSmartRetry_LongDelay_StickySession_ClearsSession(t *testing.T) {
func TestHandleSmartRetry_LongDelay_StickySession_NoDeleteInHandleSmartRetry(t *testing.T) {
repo := &stubAntigravityAccountRepo{} repo := &stubAntigravityAccountRepo{}
cache := &stubSmartRetryCache{} cache := &stubSmartRetryCache{}
account := &Account{ account := &Account{
@ -1159,10 +1159,9 @@ func TestHandleSmartRetry_LongDelay_StickySession_NoDeleteInHandleSmartRetry(t *
require.NotNil(t, result.switchError) require.NotNil(t, result.switchError)
require.True(t, result.switchError.IsStickySession) require.True(t, result.switchError.IsStickySession)
// 长延迟路径不在 handleSmartRetry 中调用 DeleteSessionAccountID require.Len(t, cache.deleteCalls, 1, "long delay path should clear sticky session in handleSmartRetry")
// (由上游 handler 的 shouldClearStickySession 处理) require.Equal(t, int64(42), cache.deleteCalls[0].groupID)
require.Len(t, cache.deleteCalls, 0, require.Equal(t, "sticky-hash-long-delay", cache.deleteCalls[0].sessionHash)
"long delay path should NOT call DeleteSessionAccountID in handleSmartRetry (handled by handler layer)")
} }
// TestHandleSmartRetry_ShortDelay_NetworkError_StickySession_ClearsSession // TestHandleSmartRetry_ShortDelay_NetworkError_StickySession_ClearsSession
@ -1227,6 +1226,10 @@ func TestHandleSmartRetry_ShortDelay_NetworkError_StickySession_ClearsSession(t
require.Len(t, cache.deleteCalls, 1, "should call DeleteSessionAccountID after network error exhausts retry") require.Len(t, cache.deleteCalls, 1, "should call DeleteSessionAccountID after network error exhausts retry")
require.Equal(t, int64(99), cache.deleteCalls[0].groupID) require.Equal(t, int64(99), cache.deleteCalls[0].groupID)
require.Equal(t, "sticky-net-error", cache.deleteCalls[0].sessionHash) require.Equal(t, "sticky-net-error", cache.deleteCalls[0].sessionHash)
require.Len(t, repo.modelRateLimitCalls, 2)
require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
} }
// TestHandleSmartRetry_ShortDelay_503_StickySession_FailedRetry_ClearsSession // TestHandleSmartRetry_ShortDelay_503_StickySession_FailedRetry_ClearsSession
@ -1308,9 +1311,10 @@ func TestHandleSmartRetry_ShortDelay_503_StickySession_FailedRetry_ClearsSession
require.Equal(t, int64(77), cache.deleteCalls[0].groupID) require.Equal(t, int64(77), cache.deleteCalls[0].groupID)
require.Equal(t, "sticky-503-short", cache.deleteCalls[0].sessionHash) require.Equal(t, "sticky-503-short", cache.deleteCalls[0].sessionHash)
// 验证模型限流已设置 // 验证模型限流已设置Gemini 同时写入精确模型和家族级 scope
require.Len(t, repo.modelRateLimitCalls, 1) require.Len(t, repo.modelRateLimitCalls, 2)
require.Equal(t, "gemini-3-pro", repo.modelRateLimitCalls[0].modelKey) require.Equal(t, "gemini-3-pro", repo.modelRateLimitCalls[0].modelKey)
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
} }
// TestAntigravityRetryLoop_SmartRetryFailed_StickySession_SwitchErrorPropagates // TestAntigravityRetryLoop_SmartRetryFailed_StickySession_SwitchErrorPropagates

View File

@ -389,6 +389,60 @@ func TestApplyErrorPolicy(t *testing.T) {
} }
} }
func TestApplyErrorPolicy_GeminiRateLimitBypassesCustomSkip(t *testing.T) {
repo := &stubAntigravityAccountRepo{}
cache := &stubSmartRetryCache{}
rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
svc := &AntigravityGatewayService{
rateLimitService: rlSvc,
accountRepo: repo,
cache: cache,
}
account := &Account{
ID: 31,
Type: AccountTypeAPIKey,
Platform: PlatformAntigravity,
Credentials: map[string]any{
"custom_error_codes_enabled": true,
"custom_error_codes": []any{float64(500)},
},
}
body := []byte(`{
"error": {
"status": "RESOURCE_EXHAUSTED",
"details": [
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"},
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
]
}
}`)
p := antigravityRetryLoopParams{
ctx: context.Background(),
prefix: "[test]",
account: account,
accountRepo: repo,
groupID: 42,
sessionHash: "gemini:sticky",
handleError: func(context.Context, string, *Account, int, http.Header, []byte, string, int64, string, bool) *handleModelRateLimitResult {
t.Fatal("model rate limit should be handled before custom error fallback")
return nil
},
}
handled, outStatus, retErr := svc.applyErrorPolicy(p, http.StatusTooManyRequests, http.Header{}, body)
require.True(t, handled)
require.Equal(t, http.StatusTooManyRequests, outStatus)
require.NoError(t, retErr)
require.Len(t, repo.modelRateLimitCalls, 2)
require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
require.Len(t, cache.deleteCalls, 1)
require.Equal(t, int64(42), cache.deleteCalls[0].groupID)
require.Equal(t, "gemini:sticky", cache.deleteCalls[0].sessionHash)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// errorPolicyRepoStub — minimal AccountRepository stub for error policy tests // errorPolicyRepoStub — minimal AccountRepository stub for error policy tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1229,6 +1229,106 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
require.Equal(t, int64(2), acc.ID, "应选择优先级最高的账户包含启用混合调度的antigravity") require.Equal(t, int64(2), acc.ID, "应选择优先级最高的账户包含启用混合调度的antigravity")
}) })
t.Run("混合调度-Gemini家族限流后跳过Antigravity账户", func(t *testing.T) {
resetAt := time.Now().Add(10 * time.Minute).Format(time.RFC3339)
repo := &mockAccountRepoForPlatform{
accounts: []Account{
{
ID: 1,
Platform: PlatformAntigravity,
Priority: 1,
Status: StatusActive,
Schedulable: true,
Extra: map[string]any{
"mixed_scheduling": true,
modelRateLimitsKey: map[string]any{
antigravityGeminiModelRateLimitKey: map[string]any{
"rate_limit_reset_at": resetAt,
},
},
},
},
{
ID: 2,
Platform: PlatformAntigravity,
Priority: 1,
Status: StatusActive,
Schedulable: true,
Extra: map[string]any{
"mixed_scheduling": true,
modelRateLimitsKey: map[string]any{
antigravityGeminiModelRateLimitKey: map[string]any{
"rate_limit_reset_at": resetAt,
},
},
},
},
{
ID: 3,
Platform: PlatformAntigravity,
Priority: 2,
Status: StatusActive,
Schedulable: true,
Extra: map[string]any{"mixed_scheduling": true},
},
},
accountsByID: map[int64]*Account{},
}
for i := range repo.accounts {
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
}
svc := &GatewayService{
accountRepo: repo,
cache: &mockGatewayCacheForPlatform{},
cfg: testConfig(),
}
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "gemini-3-pro-preview", nil, PlatformGemini)
require.NoError(t, err)
require.NotNil(t, acc)
require.Equal(t, int64(3), acc.ID)
})
t.Run("混合调度-Gemini家族限流不影响Claude调度", func(t *testing.T) {
resetAt := time.Now().Add(10 * time.Minute).Format(time.RFC3339)
repo := &mockAccountRepoForPlatform{
accounts: []Account{
{
ID: 1,
Platform: PlatformAntigravity,
Priority: 1,
Status: StatusActive,
Schedulable: true,
Extra: map[string]any{
"mixed_scheduling": true,
modelRateLimitsKey: map[string]any{
antigravityGeminiModelRateLimitKey: map[string]any{
"rate_limit_reset_at": resetAt,
},
},
},
},
{ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
},
accountsByID: map[int64]*Account{},
}
for i := range repo.accounts {
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
}
svc := &GatewayService{
accountRepo: repo,
cache: &mockGatewayCacheForPlatform{},
cfg: testConfig(),
}
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-sonnet-4-5", nil, PlatformAnthropic)
require.NoError(t, err)
require.NotNil(t, acc)
require.Equal(t, int64(1), acc.ID)
})
t.Run("混合调度-路由优先选择路由账号", func(t *testing.T) { t.Run("混合调度-路由优先选择路由账号", func(t *testing.T) {
groupID := int64(30) groupID := int64(30)
requestedModel := "claude-sonnet-4-5" requestedModel := "claude-sonnet-4-5"

View File

@ -6,7 +6,10 @@ import (
"time" "time"
) )
const modelRateLimitsKey = "model_rate_limits" const (
modelRateLimitsKey = "model_rate_limits"
antigravityGeminiModelRateLimitKey = "antigravity:gemini"
)
// isRateLimitActiveForKey 检查指定 key 的限流是否生效 // isRateLimitActiveForKey 检查指定 key 的限流是否生效
func (a *Account) isRateLimitActiveForKey(key string) bool { func (a *Account) isRateLimitActiveForKey(key string) bool {
@ -35,6 +38,9 @@ func (a *Account) isModelRateLimitedWithContext(ctx context.Context, requestedMo
modelKey := a.GetMappedModel(requestedModel) modelKey := a.GetMappedModel(requestedModel)
if a.Platform == PlatformAntigravity { if a.Platform == PlatformAntigravity {
modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel) modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel)
if isAntigravityGeminiModel(modelKey) && a.isRateLimitActiveForKey(antigravityGeminiModelRateLimitKey) {
return true
}
} }
modelKey = strings.TrimSpace(modelKey) modelKey = strings.TrimSpace(modelKey)
if modelKey == "" { if modelKey == "" {
@ -62,7 +68,13 @@ func (a *Account) GetModelRateLimitRemainingTimeWithContext(ctx context.Context,
if modelKey == "" { if modelKey == "" {
return 0 return 0
} }
return a.getRateLimitRemainingForKey(modelKey) remaining := a.getRateLimitRemainingForKey(modelKey)
if a.Platform == PlatformAntigravity && isAntigravityGeminiModel(modelKey) {
if familyRemaining := a.getRateLimitRemainingForKey(antigravityGeminiModelRateLimitKey); familyRemaining > remaining {
return familyRemaining
}
}
return remaining
} }
func resolveFinalAntigravityModelKey(ctx context.Context, account *Account, requestedModel string) string { func resolveFinalAntigravityModelKey(ctx context.Context, account *Account, requestedModel string) string {
@ -77,6 +89,22 @@ func resolveFinalAntigravityModelKey(ctx context.Context, account *Account, requ
return modelKey return modelKey
} }
func isAntigravityGeminiModel(model string) bool {
return strings.HasPrefix(normalizeAntigravityModelName(model), "gemini-")
}
func antigravityModelRateLimitKeys(model string) []string {
model = strings.TrimSpace(model)
if model == "" {
return nil
}
keys := []string{model}
if isAntigravityGeminiModel(model) && model != antigravityGeminiModelRateLimitKey {
keys = append(keys, antigravityGeminiModelRateLimitKey)
}
return keys
}
func (a *Account) modelRateLimitResetAt(scope string) *time.Time { func (a *Account) modelRateLimitResetAt(scope string) *time.Time {
if a == nil || a.Extra == nil || scope == "" { if a == nil || a.Extra == nil || scope == "" {
return nil return nil

View File

@ -121,6 +121,36 @@ func TestIsModelRateLimited(t *testing.T) {
requestedModel: "gemini-3-pro-preview", requestedModel: "gemini-3-pro-preview",
expected: true, expected: true,
}, },
{
name: "antigravity platform - gemini family rate limit blocks mapped preview",
account: &Account{
Platform: PlatformAntigravity,
Extra: map[string]any{
modelRateLimitsKey: map[string]any{
antigravityGeminiModelRateLimitKey: map[string]any{
"rate_limit_reset_at": future,
},
},
},
},
requestedModel: "gemini-3-pro-preview",
expected: true,
},
{
name: "antigravity platform - gemini family rate limit does not block claude",
account: &Account{
Platform: PlatformAntigravity,
Extra: map[string]any{
modelRateLimitsKey: map[string]any{
antigravityGeminiModelRateLimitKey: map[string]any{
"rate_limit_reset_at": future,
},
},
},
},
requestedModel: "claude-sonnet-4-5",
expected: false,
},
{ {
name: "non-antigravity platform - gemini-3-pro-preview NOT mapped", name: "non-antigravity platform - gemini-3-pro-preview NOT mapped",
account: &Account{ account: &Account{
@ -306,6 +336,38 @@ func TestGetModelRateLimitRemainingTime(t *testing.T) {
minExpected: 4 * time.Minute, minExpected: 4 * time.Minute,
maxExpected: 6 * time.Minute, maxExpected: 6 * time.Minute,
}, },
{
name: "antigravity platform - gemini family rate limit remaining",
account: &Account{
Platform: PlatformAntigravity,
Extra: map[string]any{
modelRateLimitsKey: map[string]any{
antigravityGeminiModelRateLimitKey: map[string]any{
"rate_limit_reset_at": future10m,
},
},
},
},
requestedModel: "gemini-3-pro-preview",
minExpected: 9 * time.Minute,
maxExpected: 11 * time.Minute,
},
{
name: "antigravity platform - gemini family remaining ignored for claude",
account: &Account{
Platform: PlatformAntigravity,
Extra: map[string]any{
modelRateLimitsKey: map[string]any{
antigravityGeminiModelRateLimitKey: map[string]any{
"rate_limit_reset_at": future10m,
},
},
},
},
requestedModel: "claude-sonnet-4-5",
minExpected: 0,
maxExpected: 0,
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@ -6,6 +6,8 @@ import (
"context" "context"
"testing" "testing"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config"
) )
type snapshotHydrationCache struct { type snapshotHydrationCache struct {
@ -186,3 +188,89 @@ func TestGatewaySelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedu
t.Fatalf("expected hydrated api key, got %q", got) t.Fatalf("expected hydrated api key, got %q", got)
} }
} }
func TestGatewaySelectAccountWithLoadAwareness_SkipsAntigravityGeminiFamilyRateLimitedSnapshot(t *testing.T) {
resetAt := time.Now().Add(10 * time.Minute).Format(time.RFC3339)
cache := &snapshotHydrationCache{
snapshot: []*Account{
{
ID: 1,
Platform: PlatformAntigravity,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Priority: 1,
AccountGroups: []AccountGroup{
{AccountID: 1, GroupID: 22},
},
GroupIDs: []int64{22},
Extra: map[string]any{
"mixed_scheduling": true,
modelRateLimitsKey: map[string]any{
antigravityGeminiModelRateLimitKey: map[string]any{
"rate_limit_reset_at": resetAt,
},
},
},
},
{
ID: 2,
Platform: PlatformAntigravity,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Priority: 2,
AccountGroups: []AccountGroup{
{AccountID: 2, GroupID: 22},
},
GroupIDs: []int64{22},
Extra: map[string]any{
"mixed_scheduling": true,
},
},
},
accounts: map[int64]*Account{
1: {ID: 1, Platform: PlatformAntigravity, Type: AccountTypeOAuth},
2: {ID: 2, Platform: PlatformAntigravity, Type: AccountTypeOAuth},
},
}
groupID := int64(22)
svc := &GatewayService{
schedulerSnapshot: NewSchedulerSnapshotService(cache, nil, nil, nil, nil),
groupRepo: &mockGroupRepoForGateway{
groups: map[int64]*Group{
groupID: {
ID: groupID,
Platform: PlatformGemini,
Status: StatusActive,
Hydrated: true,
},
},
},
concurrencyService: NewConcurrencyService(&mockConcurrencyCache{}),
cfg: &config.Config{
Gateway: config.GatewayConfig{
Scheduling: config.GatewaySchedulingConfig{
LoadBatchEnabled: true,
StickySessionMaxWaiting: 3,
StickySessionWaitTimeout: time.Second,
FallbackWaitTimeout: time.Second,
FallbackMaxWaiting: 10,
},
},
},
}
result, err := svc.SelectAccountWithLoadAwareness(context.Background(), &groupID, "", "gemini-3-flash-preview", nil, "", 0)
if err != nil {
t.Fatalf("SelectAccountWithLoadAwareness error: %v", err)
}
if result == nil || result.Account == nil {
t.Fatalf("expected selected account")
}
if result.Account.ID != 2 {
t.Fatalf("expected scheduler to skip Gemini-family limited antigravity account 1, got %d", result.Account.ID)
}
}