Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
2fe8932c
Unverified
Commit
2fe8932c
authored
Feb 03, 2026
by
Call White
Committed by
GitHub
Feb 03, 2026
Browse files
Merge pull request #3 from cyhhao/main
merge to main
parents
2f2e76f9
adb77af1
Changes
267
Show whitespace changes
Inline
Side-by-side
backend/internal/server/routes/auth.go
View file @
2fe8932c
...
...
@@ -26,11 +26,20 @@ func RegisterAuthRoutes(
{
auth
.
POST
(
"/register"
,
h
.
Auth
.
Register
)
auth
.
POST
(
"/login"
,
h
.
Auth
.
Login
)
auth
.
POST
(
"/login/2fa"
,
h
.
Auth
.
Login2FA
)
auth
.
POST
(
"/send-verify-code"
,
h
.
Auth
.
SendVerifyCode
)
// 优惠码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close)
auth
.
POST
(
"/validate-promo-code"
,
rateLimiter
.
LimitWithOptions
(
"validate-promo"
,
10
,
time
.
Minute
,
middleware
.
RateLimitOptions
{
FailureMode
:
middleware
.
RateLimitFailClose
,
}),
h
.
Auth
.
ValidatePromoCode
)
// 忘记密码接口添加速率限制:每分钟最多 5 次(Redis 故障时 fail-close)
auth
.
POST
(
"/forgot-password"
,
rateLimiter
.
LimitWithOptions
(
"forgot-password"
,
5
,
time
.
Minute
,
middleware
.
RateLimitOptions
{
FailureMode
:
middleware
.
RateLimitFailClose
,
}),
h
.
Auth
.
ForgotPassword
)
// 重置密码接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close)
auth
.
POST
(
"/reset-password"
,
rateLimiter
.
LimitWithOptions
(
"reset-password"
,
10
,
time
.
Minute
,
middleware
.
RateLimitOptions
{
FailureMode
:
middleware
.
RateLimitFailClose
,
}),
h
.
Auth
.
ResetPassword
)
auth
.
GET
(
"/oauth/linuxdo/start"
,
h
.
Auth
.
LinuxDoOAuthStart
)
auth
.
GET
(
"/oauth/linuxdo/callback"
,
h
.
Auth
.
LinuxDoOAuthCallback
)
}
...
...
backend/internal/server/routes/user.go
View file @
2fe8932c
...
...
@@ -22,6 +22,17 @@ func RegisterUserRoutes(
user
.
GET
(
"/profile"
,
h
.
User
.
GetProfile
)
user
.
PUT
(
"/password"
,
h
.
User
.
ChangePassword
)
user
.
PUT
(
""
,
h
.
User
.
UpdateProfile
)
// TOTP 双因素认证
totp
:=
user
.
Group
(
"/totp"
)
{
totp
.
GET
(
"/status"
,
h
.
Totp
.
GetStatus
)
totp
.
GET
(
"/verification-method"
,
h
.
Totp
.
GetVerificationMethod
)
totp
.
POST
(
"/send-code"
,
h
.
Totp
.
SendVerifyCode
)
totp
.
POST
(
"/setup"
,
h
.
Totp
.
InitiateSetup
)
totp
.
POST
(
"/enable"
,
h
.
Totp
.
Enable
)
totp
.
POST
(
"/disable"
,
h
.
Totp
.
Disable
)
}
}
// API Key管理
...
...
backend/internal/service/account.go
View file @
2fe8932c
...
...
@@ -197,6 +197,35 @@ func (a *Account) GetCredentialAsTime(key string) *time.Time {
return
nil
}
// GetCredentialAsInt64 解析凭证中的 int64 字段
// 用于读取 _token_version 等内部字段
func
(
a
*
Account
)
GetCredentialAsInt64
(
key
string
)
int64
{
if
a
==
nil
||
a
.
Credentials
==
nil
{
return
0
}
val
,
ok
:=
a
.
Credentials
[
key
]
if
!
ok
||
val
==
nil
{
return
0
}
switch
v
:=
val
.
(
type
)
{
case
int64
:
return
v
case
float64
:
return
int64
(
v
)
case
int
:
return
int64
(
v
)
case
json
.
Number
:
if
i
,
err
:=
v
.
Int64
();
err
==
nil
{
return
i
}
case
string
:
if
i
,
err
:=
strconv
.
ParseInt
(
strings
.
TrimSpace
(
v
),
10
,
64
);
err
==
nil
{
return
i
}
}
return
0
}
func
(
a
*
Account
)
IsTempUnschedulableEnabled
()
bool
{
if
a
.
Credentials
==
nil
{
return
false
...
...
@@ -592,6 +621,44 @@ func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
return
a
.
Platform
==
PlatformAnthropic
&&
(
a
.
Type
==
AccountTypeOAuth
||
a
.
Type
==
AccountTypeSetupToken
)
}
// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征
func
(
a
*
Account
)
IsTLSFingerprintEnabled
()
bool
{
// 仅支持 Anthropic OAuth/SetupToken 账号
if
!
a
.
IsAnthropicOAuthOrSetupToken
()
{
return
false
}
if
a
.
Extra
==
nil
{
return
false
}
if
v
,
ok
:=
a
.
Extra
[
"enable_tls_fingerprint"
];
ok
{
if
enabled
,
ok
:=
v
.
(
bool
);
ok
{
return
enabled
}
}
return
false
}
// IsSessionIDMaskingEnabled 检查是否启用会话ID伪装
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将在一段时间内(15分钟)固定 metadata.user_id 中的 session ID,
// 使上游认为请求来自同一个会话
func
(
a
*
Account
)
IsSessionIDMaskingEnabled
()
bool
{
if
!
a
.
IsAnthropicOAuthOrSetupToken
()
{
return
false
}
if
a
.
Extra
==
nil
{
return
false
}
if
v
,
ok
:=
a
.
Extra
[
"session_id_masking_enabled"
];
ok
{
if
enabled
,
ok
:=
v
.
(
bool
);
ok
{
return
enabled
}
}
return
false
}
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
// 返回 0 表示未启用
func
(
a
*
Account
)
GetWindowCostLimit
()
float64
{
...
...
@@ -668,6 +735,23 @@ func (a *Account) CheckWindowCostSchedulability(currentWindowCost float64) Windo
return
WindowCostNotSchedulable
}
// GetCurrentWindowStartTime 获取当前有效的窗口开始时间
// 逻辑:
// 1. 如果窗口未过期(SessionWindowEnd 存在且在当前时间之后),使用记录的 SessionWindowStart
// 2. 否则(窗口过期或未设置),使用新的预测窗口开始时间(从当前整点开始)
func
(
a
*
Account
)
GetCurrentWindowStartTime
()
time
.
Time
{
now
:=
time
.
Now
()
// 窗口未过期,使用记录的窗口开始时间
if
a
.
SessionWindowStart
!=
nil
&&
a
.
SessionWindowEnd
!=
nil
&&
now
.
Before
(
*
a
.
SessionWindowEnd
)
{
return
*
a
.
SessionWindowStart
}
// 窗口已过期或未设置,预测新的窗口开始时间(从当前整点开始)
// 与 ratelimit_service.go 中 UpdateSessionWindow 的预测逻辑保持一致
return
time
.
Date
(
now
.
Year
(),
now
.
Month
(),
now
.
Day
(),
now
.
Hour
(),
0
,
0
,
0
,
now
.
Location
())
}
// parseExtraFloat64 从 extra 字段解析 float64 值
func
parseExtraFloat64
(
value
any
)
float64
{
switch
v
:=
value
.
(
type
)
{
...
...
backend/internal/service/account_service.go
View file @
2fe8932c
...
...
@@ -37,6 +37,7 @@ type AccountRepository interface {
UpdateLastUsed
(
ctx
context
.
Context
,
id
int64
)
error
BatchUpdateLastUsed
(
ctx
context
.
Context
,
updates
map
[
int64
]
time
.
Time
)
error
SetError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
ClearError
(
ctx
context
.
Context
,
id
int64
)
error
SetSchedulable
(
ctx
context
.
Context
,
id
int64
,
schedulable
bool
)
error
AutoPauseExpiredAccounts
(
ctx
context
.
Context
,
now
time
.
Time
)
(
int64
,
error
)
BindGroups
(
ctx
context
.
Context
,
accountID
int64
,
groupIDs
[]
int64
)
error
...
...
backend/internal/service/account_service_delete_test.go
View file @
2fe8932c
...
...
@@ -99,6 +99,10 @@ func (s *accountRepoStub) SetError(ctx context.Context, id int64, errorMsg strin
panic
(
"unexpected SetError call"
)
}
func
(
s
*
accountRepoStub
)
ClearError
(
ctx
context
.
Context
,
id
int64
)
error
{
panic
(
"unexpected ClearError call"
)
}
func
(
s
*
accountRepoStub
)
SetSchedulable
(
ctx
context
.
Context
,
id
int64
,
schedulable
bool
)
error
{
panic
(
"unexpected SetSchedulable call"
)
}
...
...
backend/internal/service/account_test_service.go
View file @
2fe8932c
...
...
@@ -123,7 +123,7 @@ func createTestPayload(modelID string) (map[string]any, error) {
"system"
:
[]
map
[
string
]
any
{
{
"type"
:
"text"
,
"text"
:
"You are C
laude
Code
, Anthropic's official CLI for Claude."
,
"text"
:
c
laudeCode
SystemPrompt
,
"cache_control"
:
map
[
string
]
string
{
"type"
:
"ephemeral"
,
},
...
...
@@ -265,7 +265,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
Do
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
:=
s
.
httpUpstream
.
Do
WithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -375,7 +375,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
Do
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
:=
s
.
httpUpstream
.
Do
WithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -446,7 +446,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
Do
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
:=
s
.
httpUpstream
.
Do
WithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
backend/internal/service/account_usage_service.go
View file @
2fe8932c
...
...
@@ -32,8 +32,8 @@ type UsageLogRepository interface {
// Admin dashboard stats
GetDashboardStats
(
ctx
context
.
Context
)
(
*
usagestats
.
DashboardStats
,
error
)
GetUsageTrendWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
model
string
,
stream
*
bool
)
([]
usagestats
.
TrendDataPoint
,
error
)
GetModelStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
stream
*
bool
)
([]
usagestats
.
ModelStat
,
error
)
GetUsageTrendWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
model
string
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
TrendDataPoint
,
error
)
GetModelStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
ModelStat
,
error
)
GetAPIKeyUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
APIKeyUsageTrendPoint
,
error
)
GetUserUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
UserUsageTrendPoint
,
error
)
GetBatchUserUsageStats
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
usagestats
.
BatchUserUsageStats
,
error
)
...
...
@@ -157,9 +157,20 @@ type ClaudeUsageResponse struct {
}
`json:"seven_day_sonnet"`
}
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
type
ClaudeUsageFetchOptions
struct
{
AccessToken
string
// OAuth access token
ProxyURL
string
// 代理 URL(可选)
AccountID
int64
// 账号 ID(用于 TLS 指纹选择)
EnableTLSFingerprint
bool
// 是否启用 TLS 指纹伪装
Fingerprint
*
Fingerprint
// 缓存的指纹信息(User-Agent 等)
}
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
type
ClaudeUsageFetcher
interface
{
FetchUsage
(
ctx
context
.
Context
,
accessToken
,
proxyURL
string
)
(
*
ClaudeUsageResponse
,
error
)
// FetchUsageWithOptions 使用完整选项获取用量数据,支持 TLS 指纹和自定义 User-Agent
FetchUsageWithOptions
(
ctx
context
.
Context
,
opts
*
ClaudeUsageFetchOptions
)
(
*
ClaudeUsageResponse
,
error
)
}
// AccountUsageService 账号使用量查询服务
...
...
@@ -170,6 +181,7 @@ type AccountUsageService struct {
geminiQuotaService
*
GeminiQuotaService
antigravityQuotaFetcher
*
AntigravityQuotaFetcher
cache
*
UsageCache
identityCache
IdentityCache
}
// NewAccountUsageService 创建AccountUsageService实例
...
...
@@ -180,6 +192,7 @@ func NewAccountUsageService(
geminiQuotaService
*
GeminiQuotaService
,
antigravityQuotaFetcher
*
AntigravityQuotaFetcher
,
cache
*
UsageCache
,
identityCache
IdentityCache
,
)
*
AccountUsageService
{
return
&
AccountUsageService
{
accountRepo
:
accountRepo
,
...
...
@@ -188,6 +201,7 @@ func NewAccountUsageService(
geminiQuotaService
:
geminiQuotaService
,
antigravityQuotaFetcher
:
antigravityQuotaFetcher
,
cache
:
cache
,
identityCache
:
identityCache
,
}
}
...
...
@@ -272,7 +286,7 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou
}
dayStart
:=
geminiDailyWindowStart
(
now
)
stats
,
err
:=
s
.
usageLogRepo
.
GetModelStatsWithFilters
(
ctx
,
dayStart
,
now
,
0
,
0
,
account
.
ID
,
0
,
nil
)
stats
,
err
:=
s
.
usageLogRepo
.
GetModelStatsWithFilters
(
ctx
,
dayStart
,
now
,
0
,
0
,
account
.
ID
,
0
,
nil
,
nil
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get gemini usage stats failed: %w"
,
err
)
}
...
...
@@ -294,7 +308,7 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou
// Minute window (RPM) - fixed-window approximation: current minute [truncate(now), truncate(now)+1m)
minuteStart
:=
now
.
Truncate
(
time
.
Minute
)
minuteResetAt
:=
minuteStart
.
Add
(
time
.
Minute
)
minuteStats
,
err
:=
s
.
usageLogRepo
.
GetModelStatsWithFilters
(
ctx
,
minuteStart
,
now
,
0
,
0
,
account
.
ID
,
0
,
nil
)
minuteStats
,
err
:=
s
.
usageLogRepo
.
GetModelStatsWithFilters
(
ctx
,
minuteStart
,
now
,
0
,
0
,
account
.
ID
,
0
,
nil
,
nil
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get gemini minute usage stats failed: %w"
,
err
)
}
...
...
@@ -369,12 +383,8 @@ func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Accou
// 如果没有缓存,从数据库查询
if
windowStats
==
nil
{
var
startTime
time
.
Time
if
account
.
SessionWindowStart
!=
nil
{
startTime
=
*
account
.
SessionWindowStart
}
else
{
startTime
=
time
.
Now
()
.
Add
(
-
5
*
time
.
Hour
)
}
// 使用统一的窗口开始时间计算逻辑(考虑窗口过期情况)
startTime
:=
account
.
GetCurrentWindowStartTime
()
stats
,
err
:=
s
.
usageLogRepo
.
GetAccountWindowStats
(
ctx
,
account
.
ID
,
startTime
)
if
err
!=
nil
{
...
...
@@ -428,6 +438,8 @@ func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountI
}
// fetchOAuthUsageRaw 从 Anthropic API 获取原始响应(不构建 UsageInfo)
// 如果账号开启了 TLS 指纹,则使用 TLS 指纹伪装
// 如果有缓存的 Fingerprint,则使用缓存的 User-Agent 等信息
func
(
s
*
AccountUsageService
)
fetchOAuthUsageRaw
(
ctx
context
.
Context
,
account
*
Account
)
(
*
ClaudeUsageResponse
,
error
)
{
accessToken
:=
account
.
GetCredential
(
"access_token"
)
if
accessToken
==
""
{
...
...
@@ -439,7 +451,22 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
proxyURL
=
account
.
Proxy
.
URL
()
}
return
s
.
usageFetcher
.
FetchUsage
(
ctx
,
accessToken
,
proxyURL
)
// 构建完整的选项
opts
:=
&
ClaudeUsageFetchOptions
{
AccessToken
:
accessToken
,
ProxyURL
:
proxyURL
,
AccountID
:
account
.
ID
,
EnableTLSFingerprint
:
account
.
IsTLSFingerprintEnabled
(),
}
// 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
if
s
.
identityCache
!=
nil
{
if
fp
,
err
:=
s
.
identityCache
.
GetFingerprint
(
ctx
,
account
.
ID
);
err
==
nil
&&
fp
!=
nil
{
opts
.
Fingerprint
=
fp
}
}
return
s
.
usageFetcher
.
FetchUsageWithOptions
(
ctx
,
opts
)
}
// parseTime 尝试多种格式解析时间
...
...
backend/internal/service/admin_service.go
View file @
2fe8932c
...
...
@@ -42,6 +42,7 @@ type AdminService interface {
DeleteAccount
(
ctx
context
.
Context
,
id
int64
)
error
RefreshAccountCredentials
(
ctx
context
.
Context
,
id
int64
)
(
*
Account
,
error
)
ClearAccountError
(
ctx
context
.
Context
,
id
int64
)
(
*
Account
,
error
)
SetAccountError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
SetAccountSchedulable
(
ctx
context
.
Context
,
id
int64
,
schedulable
bool
)
(
*
Account
,
error
)
BulkUpdateAccounts
(
ctx
context
.
Context
,
input
*
BulkUpdateAccountsInput
)
(
*
BulkUpdateAccountsResult
,
error
)
...
...
@@ -1101,6 +1102,10 @@ func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Ac
return
account
,
nil
}
func
(
s
*
adminServiceImpl
)
SetAccountError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
{
return
s
.
accountRepo
.
SetError
(
ctx
,
id
,
errorMsg
)
}
func
(
s
*
adminServiceImpl
)
SetAccountSchedulable
(
ctx
context
.
Context
,
id
int64
,
schedulable
bool
)
(
*
Account
,
error
)
{
if
err
:=
s
.
accountRepo
.
SetSchedulable
(
ctx
,
id
,
schedulable
);
err
!=
nil
{
return
nil
,
err
...
...
backend/internal/service/admin_service_delete_test.go
View file @
2fe8932c
...
...
@@ -93,6 +93,18 @@ func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
panic
(
"unexpected RemoveGroupFromAllowedGroups call"
)
}
func
(
s
*
userRepoStub
)
UpdateTotpSecret
(
ctx
context
.
Context
,
userID
int64
,
encryptedSecret
*
string
)
error
{
panic
(
"unexpected UpdateTotpSecret call"
)
}
func
(
s
*
userRepoStub
)
EnableTotp
(
ctx
context
.
Context
,
userID
int64
)
error
{
panic
(
"unexpected EnableTotp call"
)
}
func
(
s
*
userRepoStub
)
DisableTotp
(
ctx
context
.
Context
,
userID
int64
)
error
{
panic
(
"unexpected DisableTotp call"
)
}
type
groupRepoStub
struct
{
affectedUserIDs
[]
int64
deleteErr
error
...
...
backend/internal/service/antigravity_gateway_service.go
View file @
2fe8932c
...
...
@@ -12,6 +12,7 @@ import (
mathrand
"math/rand"
"net"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
...
...
@@ -28,6 +29,207 @@ const (
antigravityRetryMaxDelay
=
16
*
time
.
Second
)
const
antigravityScopeRateLimitEnv
=
"GATEWAY_ANTIGRAVITY_429_SCOPE_LIMIT"
// antigravityRetryLoopParams 重试循环的参数
type
antigravityRetryLoopParams
struct
{
ctx
context
.
Context
prefix
string
account
*
Account
proxyURL
string
accessToken
string
action
string
body
[]
byte
quotaScope
AntigravityQuotaScope
c
*
gin
.
Context
httpUpstream
HTTPUpstream
settingService
*
SettingService
handleError
func
(
ctx
context
.
Context
,
prefix
string
,
account
*
Account
,
statusCode
int
,
headers
http
.
Header
,
body
[]
byte
,
quotaScope
AntigravityQuotaScope
)
}
// antigravityRetryLoopResult 重试循环的结果
type
antigravityRetryLoopResult
struct
{
resp
*
http
.
Response
}
// antigravityRetryLoop 执行带 URL fallback 的重试循环
func
antigravityRetryLoop
(
p
antigravityRetryLoopParams
)
(
*
antigravityRetryLoopResult
,
error
)
{
availableURLs
:=
antigravity
.
DefaultURLAvailability
.
GetAvailableURLs
()
if
len
(
availableURLs
)
==
0
{
availableURLs
=
antigravity
.
BaseURLs
}
var
resp
*
http
.
Response
var
usedBaseURL
string
logBody
:=
p
.
settingService
!=
nil
&&
p
.
settingService
.
cfg
!=
nil
&&
p
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBody
maxBytes
:=
2048
if
p
.
settingService
!=
nil
&&
p
.
settingService
.
cfg
!=
nil
&&
p
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
>
0
{
maxBytes
=
p
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
}
getUpstreamDetail
:=
func
(
body
[]
byte
)
string
{
if
!
logBody
{
return
""
}
return
truncateString
(
string
(
body
),
maxBytes
)
}
urlFallbackLoop
:
for
urlIdx
,
baseURL
:=
range
availableURLs
{
usedBaseURL
=
baseURL
for
attempt
:=
1
;
attempt
<=
antigravityMaxRetries
;
attempt
++
{
select
{
case
<-
p
.
ctx
.
Done
()
:
log
.
Printf
(
"%s status=context_canceled error=%v"
,
p
.
prefix
,
p
.
ctx
.
Err
())
return
nil
,
p
.
ctx
.
Err
()
default
:
}
upstreamReq
,
err
:=
antigravity
.
NewAPIRequestWithURL
(
p
.
ctx
,
baseURL
,
p
.
action
,
p
.
accessToken
,
p
.
body
)
if
err
!=
nil
{
return
nil
,
err
}
// Capture upstream request body for ops retry of this attempt.
if
p
.
c
!=
nil
&&
len
(
p
.
body
)
>
0
{
p
.
c
.
Set
(
OpsUpstreamRequestBodyKey
,
string
(
p
.
body
))
}
resp
,
err
=
p
.
httpUpstream
.
Do
(
upstreamReq
,
p
.
proxyURL
,
p
.
account
.
ID
,
p
.
account
.
Concurrency
)
if
err
!=
nil
{
safeErr
:=
sanitizeUpstreamErrorMessage
(
err
.
Error
())
appendOpsUpstreamError
(
p
.
c
,
OpsUpstreamErrorEvent
{
Platform
:
p
.
account
.
Platform
,
AccountID
:
p
.
account
.
ID
,
AccountName
:
p
.
account
.
Name
,
UpstreamStatusCode
:
0
,
Kind
:
"request_error"
,
Message
:
safeErr
,
})
if
shouldAntigravityFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
log
.
Printf
(
"%s URL fallback (connection error): %s -> %s"
,
p
.
prefix
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
urlFallbackLoop
}
if
attempt
<
antigravityMaxRetries
{
log
.
Printf
(
"%s status=request_failed retry=%d/%d error=%v"
,
p
.
prefix
,
attempt
,
antigravityMaxRetries
,
err
)
if
!
sleepAntigravityBackoffWithContext
(
p
.
ctx
,
attempt
)
{
log
.
Printf
(
"%s status=context_canceled_during_backoff"
,
p
.
prefix
)
return
nil
,
p
.
ctx
.
Err
()
}
continue
}
log
.
Printf
(
"%s status=request_failed retries_exhausted error=%v"
,
p
.
prefix
,
err
)
setOpsUpstreamError
(
p
.
c
,
0
,
safeErr
,
""
)
return
nil
,
fmt
.
Errorf
(
"upstream request failed after retries: %w"
,
err
)
}
// 429 限流处理:区分 URL 级别限流和账户配额限流
if
resp
.
StatusCode
==
http
.
StatusTooManyRequests
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
// "Resource has been exhausted" 是 URL 级别限流,切换 URL
if
isURLLevelRateLimit
(
respBody
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
log
.
Printf
(
"%s URL fallback (429): %s -> %s"
,
p
.
prefix
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
urlFallbackLoop
}
// 账户/模型配额限流,重试 3 次(指数退避)
if
attempt
<
antigravityMaxRetries
{
upstreamMsg
:=
strings
.
TrimSpace
(
extractAntigravityErrorMessage
(
respBody
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
appendOpsUpstreamError
(
p
.
c
,
OpsUpstreamErrorEvent
{
Platform
:
p
.
account
.
Platform
,
AccountID
:
p
.
account
.
ID
,
AccountName
:
p
.
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
UpstreamRequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Kind
:
"retry"
,
Message
:
upstreamMsg
,
Detail
:
getUpstreamDetail
(
respBody
),
})
log
.
Printf
(
"%s status=429 retry=%d/%d body=%s"
,
p
.
prefix
,
attempt
,
antigravityMaxRetries
,
truncateForLog
(
respBody
,
200
))
if
!
sleepAntigravityBackoffWithContext
(
p
.
ctx
,
attempt
)
{
log
.
Printf
(
"%s status=context_canceled_during_backoff"
,
p
.
prefix
)
return
nil
,
p
.
ctx
.
Err
()
}
continue
}
// 重试用尽,标记账户限流
p
.
handleError
(
p
.
ctx
,
p
.
prefix
,
p
.
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
,
p
.
quotaScope
)
log
.
Printf
(
"%s status=429 rate_limited base_url=%s body=%s"
,
p
.
prefix
,
baseURL
,
truncateForLog
(
respBody
,
200
))
resp
=
&
http
.
Response
{
StatusCode
:
resp
.
StatusCode
,
Header
:
resp
.
Header
.
Clone
(),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
)),
}
break
urlFallbackLoop
}
// 其他可重试错误
if
resp
.
StatusCode
>=
400
&&
shouldRetryAntigravityError
(
resp
.
StatusCode
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
if
attempt
<
antigravityMaxRetries
{
upstreamMsg
:=
strings
.
TrimSpace
(
extractAntigravityErrorMessage
(
respBody
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
appendOpsUpstreamError
(
p
.
c
,
OpsUpstreamErrorEvent
{
Platform
:
p
.
account
.
Platform
,
AccountID
:
p
.
account
.
ID
,
AccountName
:
p
.
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
UpstreamRequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Kind
:
"retry"
,
Message
:
upstreamMsg
,
Detail
:
getUpstreamDetail
(
respBody
),
})
log
.
Printf
(
"%s status=%d retry=%d/%d body=%s"
,
p
.
prefix
,
resp
.
StatusCode
,
attempt
,
antigravityMaxRetries
,
truncateForLog
(
respBody
,
500
))
if
!
sleepAntigravityBackoffWithContext
(
p
.
ctx
,
attempt
)
{
log
.
Printf
(
"%s status=context_canceled_during_backoff"
,
p
.
prefix
)
return
nil
,
p
.
ctx
.
Err
()
}
continue
}
resp
=
&
http
.
Response
{
StatusCode
:
resp
.
StatusCode
,
Header
:
resp
.
Header
.
Clone
(),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
)),
}
break
urlFallbackLoop
}
break
urlFallbackLoop
}
}
if
resp
!=
nil
&&
resp
.
StatusCode
<
400
&&
usedBaseURL
!=
""
{
antigravity
.
DefaultURLAvailability
.
MarkSuccess
(
usedBaseURL
)
}
return
&
antigravityRetryLoopResult
{
resp
:
resp
},
nil
}
// shouldRetryAntigravityError 判断是否应该重试
func
shouldRetryAntigravityError
(
statusCode
int
)
bool
{
switch
statusCode
{
case
429
,
500
,
502
,
503
,
504
,
529
:
return
true
default
:
return
false
}
}
// isURLLevelRateLimit 判断是否为 URL 级别的限流(应切换 URL 重试)
// "Resource has been exhausted" 是 URL/节点级别限流,切换 URL 可能成功
// "exhausted your capacity on this model" 是账户/模型配额限流,切换 URL 无效
func
isURLLevelRateLimit
(
body
[]
byte
)
bool
{
// 快速检查:包含 "Resource has been exhausted" 且不包含 "capacity on this model"
bodyStr
:=
string
(
body
)
return
strings
.
Contains
(
bodyStr
,
"Resource has been exhausted"
)
&&
!
strings
.
Contains
(
bodyStr
,
"capacity on this model"
)
}
// isAntigravityConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝)
func
isAntigravityConnectionError
(
err
error
)
bool
{
if
err
==
nil
{
...
...
@@ -238,7 +440,6 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
if
err
!=
nil
{
lastErr
=
fmt
.
Errorf
(
"请求失败: %w"
,
err
)
if
shouldAntigravityFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
antigravity
.
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"[antigravity-Test] URL fallback: %s -> %s"
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
}
...
...
@@ -254,7 +455,6 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
// 检查是否需要 URL 降级
if
shouldAntigravityFallbackToNextURL
(
nil
,
resp
.
StatusCode
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
antigravity
.
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"[antigravity-Test] URL fallback (HTTP %d): %s -> %s"
,
resp
.
StatusCode
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
}
...
...
@@ -266,6 +466,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
// 解析流式响应,提取文本
text
:=
extractTextFromSSEResponse
(
respBody
)
// 标记成功的 URL,下次优先使用
antigravity
.
DefaultURLAvailability
.
MarkSuccess
(
baseURL
)
return
&
TestConnectionResult
{
Text
:
text
,
MappedModel
:
mappedModel
,
...
...
@@ -276,13 +478,14 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
}
// buildGeminiTestRequest 构建 Gemini 格式测试请求
// 使用最小 token 消耗:输入 "." + maxOutputTokens: 1
func
(
s
*
AntigravityGatewayService
)
buildGeminiTestRequest
(
projectID
,
model
string
)
([]
byte
,
error
)
{
payload
:=
map
[
string
]
any
{
"contents"
:
[]
map
[
string
]
any
{
{
"role"
:
"user"
,
"parts"
:
[]
map
[
string
]
any
{
{
"text"
:
"
hi
"
},
{
"text"
:
"
.
"
},
},
},
},
...
...
@@ -292,22 +495,26 @@ func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model stri
{
"text"
:
antigravity
.
GetDefaultIdentityPatch
()},
},
},
"generationConfig"
:
map
[
string
]
any
{
"maxOutputTokens"
:
1
,
},
}
payloadBytes
,
_
:=
json
.
Marshal
(
payload
)
return
s
.
wrapV1InternalRequest
(
projectID
,
model
,
payloadBytes
)
}
// buildClaudeTestRequest 构建 Claude 格式测试请求并转换为 Gemini 格式
// 使用最小 token 消耗:输入 "." + MaxTokens: 1
func
(
s
*
AntigravityGatewayService
)
buildClaudeTestRequest
(
projectID
,
mappedModel
string
)
([]
byte
,
error
)
{
claudeReq
:=
&
antigravity
.
ClaudeRequest
{
Model
:
mappedModel
,
Messages
:
[]
antigravity
.
ClaudeMessage
{
{
Role
:
"user"
,
Content
:
json
.
RawMessage
(
`"
hi
"`
),
Content
:
json
.
RawMessage
(
`"
.
"`
),
},
},
MaxTokens
:
1
024
,
MaxTokens
:
1
,
Stream
:
false
,
}
return
antigravity
.
TransformClaudeToGemini
(
claudeReq
,
projectID
,
mappedModel
)
...
...
@@ -523,9 +730,6 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
proxyURL
=
account
.
Proxy
.
URL
()
}
// Sanitize thinking blocks (clean cache_control and flatten history thinking)
sanitizeThinkingBlocks
(
&
claudeReq
)
// 获取转换选项
// Antigravity 上游要求必须包含身份提示词,否则会返回 429
transformOpts
:=
s
.
getClaudeTransformOptions
(
ctx
)
...
...
@@ -537,150 +741,29 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
return
nil
,
fmt
.
Errorf
(
"transform request: %w"
,
err
)
}
// Safety net: ensure no cache_control leaked into Gemini request
geminiBody
=
cleanCacheControlFromGeminiJSON
(
geminiBody
)
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回
action
:=
"streamGenerateContent"
// URL fallback 循环
availableURLs
:=
antigravity
.
DefaultURLAvailability
.
GetAvailableURLs
()
if
len
(
availableURLs
)
==
0
{
availableURLs
=
antigravity
.
BaseURLs
// 所有 URL 都不可用时,重试所有
}
// 重试循环
var
resp
*
http
.
Response
urlFallbackLoop
:
for
urlIdx
,
baseURL
:=
range
availableURLs
{
for
attempt
:=
1
;
attempt
<=
antigravityMaxRetries
;
attempt
++
{
// 检查 context 是否已取消(客户端断开连接)
select
{
case
<-
ctx
.
Done
()
:
log
.
Printf
(
"%s status=context_canceled error=%v"
,
prefix
,
ctx
.
Err
())
return
nil
,
ctx
.
Err
()
default
:
}
upstreamReq
,
err
:=
antigravity
.
NewAPIRequestWithURL
(
ctx
,
baseURL
,
action
,
accessToken
,
geminiBody
)
// Capture upstream request body for ops retry of this attempt.
if
c
!=
nil
{
c
.
Set
(
OpsUpstreamRequestBodyKey
,
string
(
geminiBody
))
}
if
err
!=
nil
{
return
nil
,
err
}
resp
,
err
=
s
.
httpUpstream
.
Do
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
if
err
!=
nil
{
safeErr
:=
sanitizeUpstreamErrorMessage
(
err
.
Error
())
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
0
,
Kind
:
"request_error"
,
Message
:
safeErr
,
// 执行带重试的请求
result
,
err
:=
antigravityRetryLoop
(
antigravityRetryLoopParams
{
ctx
:
ctx
,
prefix
:
prefix
,
account
:
account
,
proxyURL
:
proxyURL
,
accessToken
:
accessToken
,
action
:
action
,
body
:
geminiBody
,
quotaScope
:
quotaScope
,
c
:
c
,
httpUpstream
:
s
.
httpUpstream
,
settingService
:
s
.
settingService
,
handleError
:
s
.
handleUpstreamError
,
})
// 检查是否应触发 URL 降级
if
shouldAntigravityFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
antigravity
.
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"%s URL fallback (connection error): %s -> %s"
,
prefix
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
urlFallbackLoop
}
if
attempt
<
antigravityMaxRetries
{
log
.
Printf
(
"%s status=request_failed retry=%d/%d error=%v"
,
prefix
,
attempt
,
antigravityMaxRetries
,
err
)
if
!
sleepAntigravityBackoffWithContext
(
ctx
,
attempt
)
{
log
.
Printf
(
"%s status=context_canceled_during_backoff"
,
prefix
)
return
nil
,
ctx
.
Err
()
}
continue
}
log
.
Printf
(
"%s status=request_failed retries_exhausted error=%v"
,
prefix
,
err
)
setOpsUpstreamError
(
c
,
0
,
safeErr
,
""
)
if
err
!=
nil
{
return
nil
,
s
.
writeClaudeError
(
c
,
http
.
StatusBadGateway
,
"upstream_error"
,
"Upstream request failed after retries"
)
}
// 检查是否应触发 URL 降级(仅 429)
if
resp
.
StatusCode
==
http
.
StatusTooManyRequests
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
upstreamMsg
:=
strings
.
TrimSpace
(
extractAntigravityErrorMessage
(
respBody
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
logBody
:=
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBody
maxBytes
:=
2048
if
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
>
0
{
maxBytes
=
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
}
upstreamDetail
:=
""
if
logBody
{
upstreamDetail
=
truncateString
(
string
(
respBody
),
maxBytes
)
}
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
UpstreamRequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Kind
:
"retry"
,
Message
:
upstreamMsg
,
Detail
:
upstreamDetail
,
})
antigravity
.
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"%s URL fallback (HTTP 429): %s -> %s body=%s"
,
prefix
,
baseURL
,
availableURLs
[
urlIdx
+
1
],
truncateForLog
(
respBody
,
200
))
continue
urlFallbackLoop
}
if
resp
.
StatusCode
>=
400
&&
s
.
shouldRetryUpstreamError
(
resp
.
StatusCode
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
if
attempt
<
antigravityMaxRetries
{
upstreamMsg
:=
strings
.
TrimSpace
(
extractAntigravityErrorMessage
(
respBody
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
logBody
:=
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBody
maxBytes
:=
2048
if
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
>
0
{
maxBytes
=
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
}
upstreamDetail
:=
""
if
logBody
{
upstreamDetail
=
truncateString
(
string
(
respBody
),
maxBytes
)
}
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
UpstreamRequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Kind
:
"retry"
,
Message
:
upstreamMsg
,
Detail
:
upstreamDetail
,
})
log
.
Printf
(
"%s status=%d retry=%d/%d body=%s"
,
prefix
,
resp
.
StatusCode
,
attempt
,
antigravityMaxRetries
,
truncateForLog
(
respBody
,
500
))
if
!
sleepAntigravityBackoffWithContext
(
ctx
,
attempt
)
{
log
.
Printf
(
"%s status=context_canceled_during_backoff"
,
prefix
)
return
nil
,
ctx
.
Err
()
}
continue
}
// 所有重试都失败,标记限流状态
if
resp
.
StatusCode
==
429
{
s
.
handleUpstreamError
(
ctx
,
prefix
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
,
quotaScope
)
}
// 最后一次尝试也失败
resp
=
&
http
.
Response
{
StatusCode
:
resp
.
StatusCode
,
Header
:
resp
.
Header
.
Clone
(),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
)),
}
break
urlFallbackLoop
}
break
urlFallbackLoop
}
}
resp
:=
result
.
resp
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
>=
400
{
...
...
@@ -739,11 +822,20 @@ urlFallbackLoop:
if
txErr
!=
nil
{
continue
}
retryReq
,
buildErr
:=
antigravity
.
NewAPIRequest
(
ctx
,
action
,
accessToken
,
retryGeminiBody
)
if
buildErr
!=
nil
{
continue
}
retryResp
,
retryErr
:=
s
.
httpUpstream
.
Do
(
retryReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
retryResult
,
retryErr
:=
antigravityRetryLoop
(
antigravityRetryLoopParams
{
ctx
:
ctx
,
prefix
:
prefix
,
account
:
account
,
proxyURL
:
proxyURL
,
accessToken
:
accessToken
,
action
:
action
,
body
:
retryGeminiBody
,
quotaScope
:
quotaScope
,
c
:
c
,
httpUpstream
:
s
.
httpUpstream
,
settingService
:
s
.
settingService
,
handleError
:
s
.
handleUpstreamError
,
})
if
retryErr
!=
nil
{
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
...
...
@@ -757,6 +849,7 @@ urlFallbackLoop:
continue
}
retryResp
:=
retryResult
.
resp
if
retryResp
.
StatusCode
<
400
{
_
=
resp
.
Body
.
Close
()
resp
=
retryResp
...
...
@@ -766,6 +859,13 @@ urlFallbackLoop:
retryBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
retryResp
.
Body
,
2
<<
20
))
_
=
retryResp
.
Body
.
Close
()
if
retryResp
.
StatusCode
==
http
.
StatusTooManyRequests
{
retryBaseURL
:=
""
if
retryResp
.
Request
!=
nil
&&
retryResp
.
Request
.
URL
!=
nil
{
retryBaseURL
=
retryResp
.
Request
.
URL
.
Scheme
+
"://"
+
retryResp
.
Request
.
URL
.
Host
}
log
.
Printf
(
"%s status=429 rate_limited base_url=%s retry_stage=%s body=%s"
,
prefix
,
retryBaseURL
,
stage
.
name
,
truncateForLog
(
retryBody
,
200
))
}
kind
:=
"signature_retry"
if
strings
.
TrimSpace
(
stage
.
name
)
!=
""
{
kind
=
"signature_retry_"
+
strings
.
ReplaceAll
(
stage
.
name
,
"+"
,
"_"
)
...
...
@@ -908,153 +1008,16 @@ func extractAntigravityErrorMessage(body []byte) string {
// Google-style: {"error": {"message": "..."}}
if
errObj
,
ok
:=
payload
[
"error"
]
.
(
map
[
string
]
any
);
ok
{
if
msg
,
ok
:=
errObj
[
"message"
]
.
(
string
);
ok
&&
strings
.
TrimSpace
(
msg
)
!=
""
{
return
msg
}
}
// Fallback: top-level message
if
msg
,
ok
:=
payload
[
"message"
]
.
(
string
);
ok
&&
strings
.
TrimSpace
(
msg
)
!=
""
{
return
msg
}
return
""
}
// cleanCacheControlFromGeminiJSON removes cache_control from Gemini JSON (emergency fix)
// This should not be needed if transformation is correct, but serves as a safety net
func
cleanCacheControlFromGeminiJSON
(
body
[]
byte
)
[]
byte
{
// Try a more robust approach: parse and clean
var
data
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
body
,
&
data
);
err
!=
nil
{
log
.
Printf
(
"[Antigravity] Failed to parse Gemini JSON for cache_control cleaning: %v"
,
err
)
return
body
}
cleaned
:=
removeCacheControlFromAny
(
data
)
if
!
cleaned
{
return
body
}
if
result
,
err
:=
json
.
Marshal
(
data
);
err
==
nil
{
log
.
Printf
(
"[Antigravity] Successfully cleaned cache_control from Gemini JSON"
)
return
result
}
return
body
}
// removeCacheControlFromAny recursively removes cache_control fields
func
removeCacheControlFromAny
(
v
any
)
bool
{
cleaned
:=
false
switch
val
:=
v
.
(
type
)
{
case
map
[
string
]
any
:
for
k
,
child
:=
range
val
{
if
k
==
"cache_control"
{
delete
(
val
,
k
)
cleaned
=
true
}
else
if
removeCacheControlFromAny
(
child
)
{
cleaned
=
true
}
}
case
[]
any
:
for
_
,
item
:=
range
val
{
if
removeCacheControlFromAny
(
item
)
{
cleaned
=
true
}
}
}
return
cleaned
}
// sanitizeThinkingBlocks cleans cache_control and flattens history thinking blocks
// Thinking blocks do NOT support cache_control field (Anthropic API/Vertex AI requirement)
// Additionally, history thinking blocks are flattened to text to avoid upstream validation errors
func
sanitizeThinkingBlocks
(
req
*
antigravity
.
ClaudeRequest
)
{
if
req
==
nil
{
return
}
log
.
Printf
(
"[Antigravity] sanitizeThinkingBlocks: processing request with %d messages"
,
len
(
req
.
Messages
))
// Clean system blocks
if
len
(
req
.
System
)
>
0
{
var
systemBlocks
[]
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
req
.
System
,
&
systemBlocks
);
err
==
nil
{
for
i
:=
range
systemBlocks
{
if
blockType
,
_
:=
systemBlocks
[
i
][
"type"
]
.
(
string
);
blockType
==
"thinking"
||
systemBlocks
[
i
][
"thinking"
]
!=
nil
{
if
removeCacheControlFromAny
(
systemBlocks
[
i
])
{
log
.
Printf
(
"[Antigravity] Deep cleaned cache_control from thinking block in system[%d]"
,
i
)
}
}
}
// Marshal back
if
cleaned
,
err
:=
json
.
Marshal
(
systemBlocks
);
err
==
nil
{
req
.
System
=
cleaned
}
}
}
// Clean message content blocks and flatten history
lastMsgIdx
:=
len
(
req
.
Messages
)
-
1
for
msgIdx
:=
range
req
.
Messages
{
raw
:=
req
.
Messages
[
msgIdx
]
.
Content
if
len
(
raw
)
==
0
{
continue
}
// Try to parse as blocks array
var
blocks
[]
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
raw
,
&
blocks
);
err
!=
nil
{
continue
}
cleaned
:=
false
for
blockIdx
:=
range
blocks
{
blockType
,
_
:=
blocks
[
blockIdx
][
"type"
]
.
(
string
)
// Check for thinking blocks (typed or untyped)
if
blockType
==
"thinking"
||
blocks
[
blockIdx
][
"thinking"
]
!=
nil
{
// 1. Clean cache_control
if
removeCacheControlFromAny
(
blocks
[
blockIdx
])
{
log
.
Printf
(
"[Antigravity] Deep cleaned cache_control from thinking block in messages[%d].content[%d]"
,
msgIdx
,
blockIdx
)
cleaned
=
true
}
// 2. Flatten to text if it's a history message (not the last one)
if
msgIdx
<
lastMsgIdx
{
log
.
Printf
(
"[Antigravity] Flattening history thinking block to text at messages[%d].content[%d]"
,
msgIdx
,
blockIdx
)
// Extract thinking content
var
textContent
string
if
t
,
ok
:=
blocks
[
blockIdx
][
"thinking"
]
.
(
string
);
ok
{
textContent
=
t
}
else
{
// Fallback for non-string content (marshal it)
if
b
,
err
:=
json
.
Marshal
(
blocks
[
blockIdx
][
"thinking"
]);
err
==
nil
{
textContent
=
string
(
b
)
}
}
// Convert to text block
blocks
[
blockIdx
][
"type"
]
=
"text"
blocks
[
blockIdx
][
"text"
]
=
textContent
delete
(
blocks
[
blockIdx
],
"thinking"
)
delete
(
blocks
[
blockIdx
],
"signature"
)
delete
(
blocks
[
blockIdx
],
"cache_control"
)
// Ensure it's gone
cleaned
=
true
}
return
msg
}
}
// Marshal back if modified
if
cleaned
{
if
marshaled
,
err
:=
json
.
Marshal
(
blocks
);
err
==
nil
{
req
.
Messages
[
msgIdx
]
.
Content
=
marshaled
}
}
// Fallback: top-level message
if
msg
,
ok
:=
payload
[
"message"
]
.
(
string
);
ok
&&
strings
.
TrimSpace
(
msg
)
!=
""
{
return
msg
}
return
""
}
// stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request.
...
...
@@ -1342,6 +1305,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
return
nil
,
err
}
// 清理 Schema
if
cleanedBody
,
err
:=
cleanGeminiRequest
(
injectedBody
);
err
==
nil
{
injectedBody
=
cleanedBody
log
.
Printf
(
"[Antigravity] Cleaned request schema in forwarded request for account %s"
,
account
.
Name
)
}
else
{
log
.
Printf
(
"[Antigravity] Failed to clean schema: %v"
,
err
)
}
// 包装请求
wrappedBody
,
err
:=
s
.
wrapV1InternalRequest
(
projectID
,
mappedModel
,
injectedBody
)
if
err
!=
nil
{
...
...
@@ -1352,138 +1323,25 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后返回
upstreamAction
:=
"streamGenerateContent"
// URL fallback 循环
availableURLs
:=
antigravity
.
DefaultURLAvailability
.
GetAvailableURLs
()
if
len
(
availableURLs
)
==
0
{
availableURLs
=
antigravity
.
BaseURLs
// 所有 URL 都不可用时,重试所有
}
// 重试循环
var
resp
*
http
.
Response
urlFallbackLoop
:
for
urlIdx
,
baseURL
:=
range
availableURLs
{
for
attempt
:=
1
;
attempt
<=
antigravityMaxRetries
;
attempt
++
{
// 检查 context 是否已取消(客户端断开连接)
select
{
case
<-
ctx
.
Done
()
:
log
.
Printf
(
"%s status=context_canceled error=%v"
,
prefix
,
ctx
.
Err
())
return
nil
,
ctx
.
Err
()
default
:
}
upstreamReq
,
err
:=
antigravity
.
NewAPIRequestWithURL
(
ctx
,
baseURL
,
upstreamAction
,
accessToken
,
wrappedBody
)
if
err
!=
nil
{
return
nil
,
err
}
resp
,
err
=
s
.
httpUpstream
.
Do
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
if
err
!=
nil
{
safeErr
:=
sanitizeUpstreamErrorMessage
(
err
.
Error
())
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
0
,
Kind
:
"request_error"
,
Message
:
safeErr
,
// 执行带重试的请求
result
,
err
:=
antigravityRetryLoop
(
antigravityRetryLoopParams
{
ctx
:
ctx
,
prefix
:
prefix
,
account
:
account
,
proxyURL
:
proxyURL
,
accessToken
:
accessToken
,
action
:
upstreamAction
,
body
:
wrappedBody
,
quotaScope
:
quotaScope
,
c
:
c
,
httpUpstream
:
s
.
httpUpstream
,
settingService
:
s
.
settingService
,
handleError
:
s
.
handleUpstreamError
,
})
// 检查是否应触发 URL 降级
if
shouldAntigravityFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
antigravity
.
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"%s URL fallback (connection error): %s -> %s"
,
prefix
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
urlFallbackLoop
}
if
attempt
<
antigravityMaxRetries
{
log
.
Printf
(
"%s status=request_failed retry=%d/%d error=%v"
,
prefix
,
attempt
,
antigravityMaxRetries
,
err
)
if
!
sleepAntigravityBackoffWithContext
(
ctx
,
attempt
)
{
log
.
Printf
(
"%s status=context_canceled_during_backoff"
,
prefix
)
return
nil
,
ctx
.
Err
()
}
continue
}
log
.
Printf
(
"%s status=request_failed retries_exhausted error=%v"
,
prefix
,
err
)
setOpsUpstreamError
(
c
,
0
,
safeErr
,
""
)
if
err
!=
nil
{
return
nil
,
s
.
writeGoogleError
(
c
,
http
.
StatusBadGateway
,
"Upstream request failed after retries"
)
}
// 检查是否应触发 URL 降级(仅 429)
if
resp
.
StatusCode
==
http
.
StatusTooManyRequests
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
upstreamMsg
:=
strings
.
TrimSpace
(
extractAntigravityErrorMessage
(
respBody
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
logBody
:=
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBody
maxBytes
:=
2048
if
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
>
0
{
maxBytes
=
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
}
upstreamDetail
:=
""
if
logBody
{
upstreamDetail
=
truncateString
(
string
(
respBody
),
maxBytes
)
}
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
UpstreamRequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Kind
:
"retry"
,
Message
:
upstreamMsg
,
Detail
:
upstreamDetail
,
})
antigravity
.
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"%s URL fallback (HTTP 429): %s -> %s body=%s"
,
prefix
,
baseURL
,
availableURLs
[
urlIdx
+
1
],
truncateForLog
(
respBody
,
200
))
continue
urlFallbackLoop
}
if
resp
.
StatusCode
>=
400
&&
s
.
shouldRetryUpstreamError
(
resp
.
StatusCode
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
if
attempt
<
antigravityMaxRetries
{
upstreamMsg
:=
strings
.
TrimSpace
(
extractAntigravityErrorMessage
(
respBody
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
logBody
:=
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBody
maxBytes
:=
2048
if
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
>
0
{
maxBytes
=
s
.
settingService
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
}
upstreamDetail
:=
""
if
logBody
{
upstreamDetail
=
truncateString
(
string
(
respBody
),
maxBytes
)
}
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
UpstreamRequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Kind
:
"retry"
,
Message
:
upstreamMsg
,
Detail
:
upstreamDetail
,
})
log
.
Printf
(
"%s status=%d retry=%d/%d"
,
prefix
,
resp
.
StatusCode
,
attempt
,
antigravityMaxRetries
)
if
!
sleepAntigravityBackoffWithContext
(
ctx
,
attempt
)
{
log
.
Printf
(
"%s status=context_canceled_during_backoff"
,
prefix
)
return
nil
,
ctx
.
Err
()
}
continue
}
// 所有重试都失败,标记限流状态
if
resp
.
StatusCode
==
429
{
s
.
handleUpstreamError
(
ctx
,
prefix
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
,
quotaScope
)
}
resp
=
&
http
.
Response
{
StatusCode
:
resp
.
StatusCode
,
Header
:
resp
.
Header
.
Clone
(),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
)),
}
break
urlFallbackLoop
}
break
urlFallbackLoop
}
}
resp
:=
result
.
resp
defer
func
()
{
if
resp
!=
nil
&&
resp
.
Body
!=
nil
{
_
=
resp
.
Body
.
Close
()
...
...
@@ -1525,8 +1383,6 @@ urlFallbackLoop:
goto
handleSuccess
}
s
.
handleUpstreamError
(
ctx
,
prefix
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
,
quotaScope
)
requestID
:=
resp
.
Header
.
Get
(
"x-request-id"
)
if
requestID
!=
""
{
c
.
Header
(
"x-request-id"
,
requestID
)
...
...
@@ -1537,6 +1393,7 @@ urlFallbackLoop:
if
unwrapErr
!=
nil
||
len
(
unwrappedForOps
)
==
0
{
unwrappedForOps
=
respBody
}
s
.
handleUpstreamError
(
ctx
,
prefix
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
,
quotaScope
)
upstreamMsg
:=
strings
.
TrimSpace
(
extractAntigravityErrorMessage
(
unwrappedForOps
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
...
...
@@ -1581,6 +1438,7 @@ urlFallbackLoop:
Message
:
upstreamMsg
,
Detail
:
upstreamDetail
,
})
log
.
Printf
(
"[antigravity-Forward] upstream error status=%d body=%s"
,
resp
.
StatusCode
,
truncateForLog
(
unwrappedForOps
,
500
))
c
.
Data
(
resp
.
StatusCode
,
contentType
,
unwrappedForOps
)
return
nil
,
fmt
.
Errorf
(
"antigravity upstream error: %d"
,
resp
.
StatusCode
)
}
...
...
@@ -1637,15 +1495,6 @@ handleSuccess:
},
nil
}
func
(
s
*
AntigravityGatewayService
)
shouldRetryUpstreamError
(
statusCode
int
)
bool
{
switch
statusCode
{
case
429
,
500
,
502
,
503
,
504
,
529
:
return
true
default
:
return
false
}
}
func
(
s
*
AntigravityGatewayService
)
shouldFailoverUpstreamError
(
statusCode
int
)
bool
{
switch
statusCode
{
case
401
,
403
,
429
,
529
:
...
...
@@ -1679,34 +1528,49 @@ func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool {
}
}
func
antigravityUseScopeRateLimit
()
bool
{
v
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
os
.
Getenv
(
antigravityScopeRateLimitEnv
)))
return
v
==
"1"
||
v
==
"true"
||
v
==
"yes"
||
v
==
"on"
}
func
(
s
*
AntigravityGatewayService
)
handleUpstreamError
(
ctx
context
.
Context
,
prefix
string
,
account
*
Account
,
statusCode
int
,
headers
http
.
Header
,
body
[]
byte
,
quotaScope
AntigravityQuotaScope
)
{
// 429 使用 Gemini 格式解析(从 body 解析重置时间)
if
statusCode
==
429
{
useScopeLimit
:=
antigravityUseScopeRateLimit
()
&&
quotaScope
!=
""
resetAt
:=
ParseGeminiRateLimitResetTime
(
body
)
if
resetAt
==
nil
{
// 解析失败:
Gemini 有重试时间用 5 分钟,Claude 没有用 1 分钟
defaultDur
:=
1
*
time
.
Minute
if
bytes
.
Contains
(
body
,
[]
byte
(
"Please retry in"
))
||
bytes
.
Contains
(
body
,
[]
byte
(
"retryDelay"
))
{
defaultDur
=
5
*
time
.
Minute
// 解析失败:
使用配置的 fallback 时间,直接限流整个账户
fallbackMinutes
:=
5
if
s
.
settingService
!=
nil
&&
s
.
settingService
.
cfg
!=
nil
&&
s
.
settingService
.
cfg
.
Gateway
.
AntigravityFallbackCooldownMinutes
>
0
{
fallbackMinutes
=
s
.
settingService
.
cfg
.
Gateway
.
AntigravityFallbackCooldown
Minute
s
}
defaultDur
:=
time
.
Duration
(
fallbackMinutes
)
*
time
.
Minute
ra
:=
time
.
Now
()
.
Add
(
defaultDur
)
if
useScopeLimit
{
log
.
Printf
(
"%s status=429 rate_limited scope=%s reset_in=%v (fallback)"
,
prefix
,
quotaScope
,
defaultDur
)
if
quotaScope
==
""
{
return
}
if
err
:=
s
.
accountRepo
.
SetAntigravityQuotaScopeLimit
(
ctx
,
account
.
ID
,
quotaScope
,
ra
);
err
!=
nil
{
log
.
Printf
(
"%s status=429 rate_limit_set_failed scope=%s error=%v"
,
prefix
,
quotaScope
,
err
)
}
}
else
{
log
.
Printf
(
"%s status=429 rate_limited account=%d reset_in=%v (fallback)"
,
prefix
,
account
.
ID
,
defaultDur
)
if
err
:=
s
.
accountRepo
.
SetRateLimited
(
ctx
,
account
.
ID
,
ra
);
err
!=
nil
{
log
.
Printf
(
"%s status=429 rate_limit_set_failed account=%d error=%v"
,
prefix
,
account
.
ID
,
err
)
}
}
return
}
resetTime
:=
time
.
Unix
(
*
resetAt
,
0
)
if
useScopeLimit
{
log
.
Printf
(
"%s status=429 rate_limited scope=%s reset_at=%v reset_in=%v"
,
prefix
,
quotaScope
,
resetTime
.
Format
(
"15:04:05"
),
time
.
Until
(
resetTime
)
.
Truncate
(
time
.
Second
))
if
quotaScope
==
""
{
return
}
if
err
:=
s
.
accountRepo
.
SetAntigravityQuotaScopeLimit
(
ctx
,
account
.
ID
,
quotaScope
,
resetTime
);
err
!=
nil
{
log
.
Printf
(
"%s status=429 rate_limit_set_failed scope=%s error=%v"
,
prefix
,
quotaScope
,
err
)
}
}
else
{
log
.
Printf
(
"%s status=429 rate_limited account=%d reset_at=%v reset_in=%v"
,
prefix
,
account
.
ID
,
resetTime
.
Format
(
"15:04:05"
),
time
.
Until
(
resetTime
)
.
Truncate
(
time
.
Second
))
if
err
:=
s
.
accountRepo
.
SetRateLimited
(
ctx
,
account
.
ID
,
resetTime
);
err
!=
nil
{
log
.
Printf
(
"%s status=429 rate_limit_set_failed account=%d error=%v"
,
prefix
,
account
.
ID
,
err
)
}
}
return
}
// 其他错误码继续使用 rateLimitService
...
...
@@ -1849,6 +1713,19 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
if
u
:=
extractGeminiUsage
(
parsed
);
u
!=
nil
{
usage
=
u
}
// Check for MALFORMED_FUNCTION_CALL
if
candidates
,
ok
:=
parsed
[
"candidates"
]
.
([]
any
);
ok
&&
len
(
candidates
)
>
0
{
if
cand
,
ok
:=
candidates
[
0
]
.
(
map
[
string
]
any
);
ok
{
if
fr
,
ok
:=
cand
[
"finishReason"
]
.
(
string
);
ok
&&
fr
==
"MALFORMED_FUNCTION_CALL"
{
log
.
Printf
(
"[Antigravity] MALFORMED_FUNCTION_CALL detected in forward stream"
)
if
content
,
ok
:=
cand
[
"content"
];
ok
{
if
b
,
err
:=
json
.
Marshal
(
content
);
err
==
nil
{
log
.
Printf
(
"[Antigravity] Malformed content: %s"
,
string
(
b
))
}
}
}
}
}
}
if
firstTokenMs
==
nil
{
...
...
@@ -1884,7 +1761,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
}
// handleGeminiStreamToNonStreaming 读取上游流式响应,合并为非流式响应返回给客户端
// Gemini 流式响应
中每个 chunk 都包含累积的完整文本,只需保留最后一个有效响应
// Gemini 流式响应
是增量的,需要累积所有 chunk 的内容
func
(
s
*
AntigravityGatewayService
)
handleGeminiStreamToNonStreaming
(
c
*
gin
.
Context
,
resp
*
http
.
Response
,
startTime
time
.
Time
)
(
*
antigravityStreamResult
,
error
)
{
scanner
:=
bufio
.
NewScanner
(
resp
.
Body
)
maxLineSize
:=
defaultMaxLineSize
...
...
@@ -1897,6 +1774,8 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
var
firstTokenMs
*
int
var
last
map
[
string
]
any
var
lastWithParts
map
[
string
]
any
var
collectedImageParts
[]
map
[
string
]
any
// 收集所有包含图片的 parts
var
collectedTextParts
[]
string
// 收集所有文本片段
type
scanEvent
struct
{
line
string
...
...
@@ -1996,9 +1875,33 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
usage
=
u
}
// Check for MALFORMED_FUNCTION_CALL
if
candidates
,
ok
:=
parsed
[
"candidates"
]
.
([]
any
);
ok
&&
len
(
candidates
)
>
0
{
if
cand
,
ok
:=
candidates
[
0
]
.
(
map
[
string
]
any
);
ok
{
if
fr
,
ok
:=
cand
[
"finishReason"
]
.
(
string
);
ok
&&
fr
==
"MALFORMED_FUNCTION_CALL"
{
log
.
Printf
(
"[Antigravity] MALFORMED_FUNCTION_CALL detected in forward non-stream collect"
)
if
content
,
ok
:=
cand
[
"content"
];
ok
{
if
b
,
err
:=
json
.
Marshal
(
content
);
err
==
nil
{
log
.
Printf
(
"[Antigravity] Malformed content: %s"
,
string
(
b
))
}
}
}
}
}
// 保留最后一个有 parts 的响应
if
parts
:=
extractGeminiParts
(
parsed
);
len
(
parts
)
>
0
{
lastWithParts
=
parsed
// 收集包含图片和文本的 parts
for
_
,
part
:=
range
parts
{
if
inlineData
,
ok
:=
part
[
"inlineData"
]
.
(
map
[
string
]
any
);
ok
{
collectedImageParts
=
append
(
collectedImageParts
,
part
)
_
=
inlineData
// 避免 unused 警告
}
if
text
,
ok
:=
part
[
"text"
]
.
(
string
);
ok
&&
text
!=
""
{
collectedTextParts
=
append
(
collectedTextParts
,
text
)
}
}
}
case
<-
intervalCh
:
...
...
@@ -2020,6 +1923,16 @@ returnResponse:
log
.
Printf
(
"[antigravity-Forward] warning: empty stream response, no valid chunks received"
)
}
// 如果收集到了图片 parts,需要合并到最终响应中
if
len
(
collectedImageParts
)
>
0
{
finalResponse
=
mergeImagePartsToResponse
(
finalResponse
,
collectedImageParts
)
}
// 如果收集到了文本,需要合并到最终响应中
if
len
(
collectedTextParts
)
>
0
{
finalResponse
=
mergeTextPartsToResponse
(
finalResponse
,
collectedTextParts
)
}
respBody
,
err
:=
json
.
Marshal
(
finalResponse
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"failed to marshal response: %w"
,
err
)
...
...
@@ -2029,6 +1942,167 @@ returnResponse:
return
&
antigravityStreamResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
nil
}
// getOrCreateGeminiParts 获取 Gemini 响应的 parts 结构,返回深拷贝和更新回调
func
getOrCreateGeminiParts
(
response
map
[
string
]
any
)
(
result
map
[
string
]
any
,
existingParts
[]
any
,
setParts
func
([]
any
))
{
// 深拷贝 response
result
=
make
(
map
[
string
]
any
)
for
k
,
v
:=
range
response
{
result
[
k
]
=
v
}
// 获取或创建 candidates
candidates
,
ok
:=
result
[
"candidates"
]
.
([]
any
)
if
!
ok
||
len
(
candidates
)
==
0
{
candidates
=
[]
any
{
map
[
string
]
any
{}}
}
// 获取第一个 candidate
candidate
,
ok
:=
candidates
[
0
]
.
(
map
[
string
]
any
)
if
!
ok
{
candidate
=
make
(
map
[
string
]
any
)
candidates
[
0
]
=
candidate
}
// 获取或创建 content
content
,
ok
:=
candidate
[
"content"
]
.
(
map
[
string
]
any
)
if
!
ok
{
content
=
map
[
string
]
any
{
"role"
:
"model"
}
candidate
[
"content"
]
=
content
}
// 获取现有 parts
existingParts
,
ok
=
content
[
"parts"
]
.
([]
any
)
if
!
ok
{
existingParts
=
[]
any
{}
}
// 返回更新回调
setParts
=
func
(
newParts
[]
any
)
{
content
[
"parts"
]
=
newParts
result
[
"candidates"
]
=
candidates
}
return
result
,
existingParts
,
setParts
}
// mergeCollectedPartsToResponse 将收集的所有 parts 合并到 Gemini 响应中
// 这个函数会合并所有类型的 parts:text、thinking、functionCall、inlineData 等
// 保持原始顺序,只合并连续的普通 text parts
func
mergeCollectedPartsToResponse
(
response
map
[
string
]
any
,
collectedParts
[]
map
[
string
]
any
)
map
[
string
]
any
{
if
len
(
collectedParts
)
==
0
{
return
response
}
result
,
_
,
setParts
:=
getOrCreateGeminiParts
(
response
)
// 合并策略:
// 1. 保持原始顺序
// 2. 连续的普通 text parts 合并为一个
// 3. thinking、functionCall、inlineData 等保持原样
var
mergedParts
[]
any
var
textBuffer
strings
.
Builder
flushTextBuffer
:=
func
()
{
if
textBuffer
.
Len
()
>
0
{
mergedParts
=
append
(
mergedParts
,
map
[
string
]
any
{
"text"
:
textBuffer
.
String
(),
})
textBuffer
.
Reset
()
}
}
for
_
,
part
:=
range
collectedParts
{
// 检查是否是普通 text part
if
text
,
ok
:=
part
[
"text"
]
.
(
string
);
ok
{
// 检查是否有 thought 标记
if
thought
,
_
:=
part
[
"thought"
]
.
(
bool
);
thought
{
// thinking part,先刷新 text buffer,然后保留原样
flushTextBuffer
()
mergedParts
=
append
(
mergedParts
,
part
)
}
else
{
// 普通 text,累积到 buffer
_
,
_
=
textBuffer
.
WriteString
(
text
)
}
}
else
{
// 非 text part(functionCall、inlineData 等),先刷新 text buffer,然后保留原样
flushTextBuffer
()
mergedParts
=
append
(
mergedParts
,
part
)
}
}
// 刷新剩余的 text
flushTextBuffer
()
setParts
(
mergedParts
)
return
result
}
// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中
func
mergeImagePartsToResponse
(
response
map
[
string
]
any
,
imageParts
[]
map
[
string
]
any
)
map
[
string
]
any
{
if
len
(
imageParts
)
==
0
{
return
response
}
result
,
existingParts
,
setParts
:=
getOrCreateGeminiParts
(
response
)
// 检查现有 parts 中是否已经有图片
for
_
,
p
:=
range
existingParts
{
if
pm
,
ok
:=
p
.
(
map
[
string
]
any
);
ok
{
if
_
,
hasInline
:=
pm
[
"inlineData"
];
hasInline
{
return
result
// 已有图片,不重复添加
}
}
}
// 添加收集到的图片 parts
for
_
,
imgPart
:=
range
imageParts
{
existingParts
=
append
(
existingParts
,
imgPart
)
}
setParts
(
existingParts
)
return
result
}
// mergeTextPartsToResponse 将收集到的文本合并到 Gemini 响应中
func
mergeTextPartsToResponse
(
response
map
[
string
]
any
,
textParts
[]
string
)
map
[
string
]
any
{
if
len
(
textParts
)
==
0
{
return
response
}
mergedText
:=
strings
.
Join
(
textParts
,
""
)
result
,
existingParts
,
setParts
:=
getOrCreateGeminiParts
(
response
)
// 查找并更新第一个 text part,或创建新的
newParts
:=
make
([]
any
,
0
,
len
(
existingParts
)
+
1
)
textUpdated
:=
false
for
_
,
p
:=
range
existingParts
{
pm
,
ok
:=
p
.
(
map
[
string
]
any
)
if
!
ok
{
newParts
=
append
(
newParts
,
p
)
continue
}
if
_
,
hasText
:=
pm
[
"text"
];
hasText
&&
!
textUpdated
{
// 用累积的文本替换
newPart
:=
make
(
map
[
string
]
any
)
for
k
,
v
:=
range
pm
{
newPart
[
k
]
=
v
}
newPart
[
"text"
]
=
mergedText
newParts
=
append
(
newParts
,
newPart
)
textUpdated
=
true
}
else
{
newParts
=
append
(
newParts
,
pm
)
}
}
if
!
textUpdated
{
newParts
=
append
([]
any
{
map
[
string
]
any
{
"text"
:
mergedText
}},
newParts
...
)
}
setParts
(
newParts
)
return
result
}
func
(
s
*
AntigravityGatewayService
)
writeClaudeError
(
c
*
gin
.
Context
,
status
int
,
errType
,
message
string
)
error
{
c
.
JSON
(
status
,
gin
.
H
{
"type"
:
"error"
,
...
...
@@ -2146,6 +2220,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
var
firstTokenMs
*
int
var
last
map
[
string
]
any
var
lastWithParts
map
[
string
]
any
var
collectedParts
[]
map
[
string
]
any
// 收集所有 parts(包括 text、thinking、functionCall、inlineData 等)
type
scanEvent
struct
{
line
string
...
...
@@ -2240,9 +2315,12 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
last
=
parsed
// 保留最后一个有 parts 的响应
// 保留最后一个有 parts 的响应
,并收集所有 parts
if
parts
:=
extractGeminiParts
(
parsed
);
len
(
parts
)
>
0
{
lastWithParts
=
parsed
// 收集所有 parts(text、thinking、functionCall、inlineData 等)
collectedParts
=
append
(
collectedParts
,
parts
...
)
}
case
<-
intervalCh
:
...
...
@@ -2265,6 +2343,11 @@ returnResponse:
return
nil
,
s
.
writeClaudeError
(
c
,
http
.
StatusBadGateway
,
"upstream_error"
,
"Empty response from upstream"
)
}
// 将收集的所有 parts 合并到最终响应中
if
len
(
collectedParts
)
>
0
{
finalResponse
=
mergeCollectedPartsToResponse
(
finalResponse
,
collectedParts
)
}
// 序列化为 JSON(Gemini 格式)
geminiBody
,
err
:=
json
.
Marshal
(
finalResponse
)
if
err
!=
nil
{
...
...
@@ -2472,3 +2555,55 @@ func isImageGenerationModel(model string) bool {
modelLower
==
"gemini-2.5-flash-image-preview"
||
strings
.
HasPrefix
(
modelLower
,
"gemini-2.5-flash-image-"
)
}
// cleanGeminiRequest 清理 Gemini 请求体中的 Schema
func
cleanGeminiRequest
(
body
[]
byte
)
([]
byte
,
error
)
{
var
payload
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
body
,
&
payload
);
err
!=
nil
{
return
nil
,
err
}
modified
:=
false
// 1. 清理 Tools
if
tools
,
ok
:=
payload
[
"tools"
]
.
([]
any
);
ok
&&
len
(
tools
)
>
0
{
for
_
,
t
:=
range
tools
{
toolMap
,
ok
:=
t
.
(
map
[
string
]
any
)
if
!
ok
{
continue
}
// function_declarations (snake_case) or functionDeclarations (camelCase)
var
funcs
[]
any
if
f
,
ok
:=
toolMap
[
"functionDeclarations"
]
.
([]
any
);
ok
{
funcs
=
f
}
else
if
f
,
ok
:=
toolMap
[
"function_declarations"
]
.
([]
any
);
ok
{
funcs
=
f
}
if
len
(
funcs
)
==
0
{
continue
}
for
_
,
f
:=
range
funcs
{
funcMap
,
ok
:=
f
.
(
map
[
string
]
any
)
if
!
ok
{
continue
}
if
params
,
ok
:=
funcMap
[
"parameters"
]
.
(
map
[
string
]
any
);
ok
{
antigravity
.
DeepCleanUndefined
(
params
)
cleaned
:=
antigravity
.
CleanJSONSchema
(
params
)
funcMap
[
"parameters"
]
=
cleaned
modified
=
true
}
}
}
}
if
!
modified
{
return
body
,
nil
}
return
json
.
Marshal
(
payload
)
}
backend/internal/service/antigravity_model_mapping_test.go
View file @
2fe8932c
...
...
@@ -30,7 +30,7 @@ func TestIsAntigravityModelSupported(t *testing.T) {
{
"可映射 - claude-3-haiku-20240307"
,
"claude-3-haiku-20240307"
,
true
},
// Gemini 前缀透传
{
"Gemini前缀 - gemini-
1
.5-pro"
,
"gemini-
1
.5-pro"
,
true
},
{
"Gemini前缀 - gemini-
2
.5-pro"
,
"gemini-
2
.5-pro"
,
true
},
{
"Gemini前缀 - gemini-unknown-model"
,
"gemini-unknown-model"
,
true
},
{
"Gemini前缀 - gemini-future-version"
,
"gemini-future-version"
,
true
},
...
...
@@ -142,10 +142,10 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
expected
:
"gemini-2.5-flash"
,
},
{
name
:
"Gemini透传 - gemini-
1
.5-pro"
,
requestedModel
:
"gemini-
1
.5-pro"
,
name
:
"Gemini透传 - gemini-
2
.5-pro"
,
requestedModel
:
"gemini-
2
.5-pro"
,
accountMapping
:
nil
,
expected
:
"gemini-
1
.5-pro"
,
expected
:
"gemini-
2
.5-pro"
,
},
{
name
:
"Gemini透传 - gemini-future-model"
,
...
...
backend/internal/service/antigravity_oauth_service.go
View file @
2fe8932c
...
...
@@ -89,6 +89,7 @@ type AntigravityTokenInfo struct {
TokenType
string
`json:"token_type"`
Email
string
`json:"email,omitempty"`
ProjectID
string
`json:"project_id,omitempty"`
ProjectIDMissing
bool
`json:"-"`
// LoadCodeAssist 未返回 project_id
}
// ExchangeCode 用 authorization code 交换 token
...
...
@@ -141,18 +142,13 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
result
.
Email
=
userInfo
.
Email
}
// 获取 project_id(部分账户类型可能没有)
loadResp
,
_
,
err
:=
client
.
LoadCodeAssist
(
ctx
,
tokenResp
.
AccessToken
)
if
err
!=
nil
{
fmt
.
Printf
(
"[AntigravityOAuth] 警告: 获取 project_id 失败: %v
\n
"
,
err
)
}
else
if
loadResp
!=
nil
&&
loadResp
.
CloudAICompanionProject
!=
""
{
result
.
ProjectID
=
loadResp
.
CloudAICompanionProject
}
// 兜底:随机生成 project_id
if
result
.
ProjectID
==
""
{
result
.
ProjectID
=
antigravity
.
GenerateMockProjectID
()
fmt
.
Printf
(
"[AntigravityOAuth] 使用随机生成的 project_id: %s
\n
"
,
result
.
ProjectID
)
// 获取 project_id(部分账户类型可能没有),失败时重试
projectID
,
loadErr
:=
s
.
loadProjectIDWithRetry
(
ctx
,
tokenResp
.
AccessToken
,
proxyURL
,
3
)
if
loadErr
!=
nil
{
fmt
.
Printf
(
"[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v
\n
"
,
loadErr
)
result
.
ProjectIDMissing
=
true
}
else
{
result
.
ProjectID
=
projectID
}
return
result
,
nil
...
...
@@ -236,19 +232,66 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
return
nil
,
err
}
// 保留原有的 project_id 和 email
existingProjectID
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"project_id"
))
if
existingProjectID
!=
""
{
tokenInfo
.
ProjectID
=
existingProjectID
}
// 保留原有的 email
existingEmail
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"email"
))
if
existingEmail
!=
""
{
tokenInfo
.
Email
=
existingEmail
}
// 每次刷新都调用 LoadCodeAssist 获取 project_id,失败时重试
existingProjectID
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"project_id"
))
projectID
,
loadErr
:=
s
.
loadProjectIDWithRetry
(
ctx
,
tokenInfo
.
AccessToken
,
proxyURL
,
3
)
if
loadErr
!=
nil
{
// LoadCodeAssist 失败,保留原有 project_id
tokenInfo
.
ProjectID
=
existingProjectID
// 只有从未获取过 project_id 且本次也获取失败时,才标记为真正缺失
// 如果之前有 project_id,本次只是临时故障,不应标记为错误
if
existingProjectID
==
""
{
tokenInfo
.
ProjectIDMissing
=
true
}
}
else
{
tokenInfo
.
ProjectID
=
projectID
}
return
tokenInfo
,
nil
}
// loadProjectIDWithRetry 带重试机制获取 project_id
// 返回 project_id 和错误,失败时会重试指定次数
func
(
s
*
AntigravityOAuthService
)
loadProjectIDWithRetry
(
ctx
context
.
Context
,
accessToken
,
proxyURL
string
,
maxRetries
int
)
(
string
,
error
)
{
var
lastErr
error
for
attempt
:=
0
;
attempt
<=
maxRetries
;
attempt
++
{
if
attempt
>
0
{
// 指数退避:1s, 2s, 4s
backoff
:=
time
.
Duration
(
1
<<
uint
(
attempt
-
1
))
*
time
.
Second
if
backoff
>
8
*
time
.
Second
{
backoff
=
8
*
time
.
Second
}
time
.
Sleep
(
backoff
)
}
client
:=
antigravity
.
NewClient
(
proxyURL
)
loadResp
,
_
,
err
:=
client
.
LoadCodeAssist
(
ctx
,
accessToken
)
if
err
==
nil
&&
loadResp
!=
nil
&&
loadResp
.
CloudAICompanionProject
!=
""
{
return
loadResp
.
CloudAICompanionProject
,
nil
}
// 记录错误
if
err
!=
nil
{
lastErr
=
err
}
else
if
loadResp
==
nil
{
lastErr
=
fmt
.
Errorf
(
"LoadCodeAssist 返回空响应"
)
}
else
{
lastErr
=
fmt
.
Errorf
(
"LoadCodeAssist 返回空 project_id"
)
}
}
return
""
,
fmt
.
Errorf
(
"获取 project_id 失败 (重试 %d 次后): %w"
,
maxRetries
,
lastErr
)
}
// BuildAccountCredentials 构建账户凭证
func
(
s
*
AntigravityOAuthService
)
BuildAccountCredentials
(
tokenInfo
*
AntigravityTokenInfo
)
map
[
string
]
any
{
creds
:=
map
[
string
]
any
{
...
...
backend/internal/service/antigravity_quota_fetcher.go
View file @
2fe8932c
...
...
@@ -31,11 +31,6 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou
accessToken
:=
account
.
GetCredential
(
"access_token"
)
projectID
:=
account
.
GetCredential
(
"project_id"
)
// 如果没有 project_id,生成一个随机的
if
projectID
==
""
{
projectID
=
antigravity
.
GenerateMockProjectID
()
}
client
:=
antigravity
.
NewClient
(
proxyURL
)
// 调用 API 获取配额
...
...
backend/internal/service/antigravity_rate_limit_test.go
0 → 100644
View file @
2fe8932c
//go:build unit
package
service
import
(
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/stretchr/testify/require"
)
type
stubAntigravityUpstream
struct
{
firstBase
string
secondBase
string
calls
[]
string
}
func
(
s
*
stubAntigravityUpstream
)
Do
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
)
(
*
http
.
Response
,
error
)
{
url
:=
req
.
URL
.
String
()
s
.
calls
=
append
(
s
.
calls
,
url
)
if
strings
.
HasPrefix
(
url
,
s
.
firstBase
)
{
return
&
http
.
Response
{
StatusCode
:
http
.
StatusTooManyRequests
,
Header
:
http
.
Header
{},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
`{"error":{"message":"Resource has been exhausted"}}`
)),
},
nil
}
return
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
"ok"
)),
},
nil
}
func
(
s
*
stubAntigravityUpstream
)
DoWithTLS
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
enableTLSFingerprint
bool
)
(
*
http
.
Response
,
error
)
{
return
s
.
Do
(
req
,
proxyURL
,
accountID
,
accountConcurrency
)
}
type
scopeLimitCall
struct
{
accountID
int64
scope
AntigravityQuotaScope
resetAt
time
.
Time
}
type
rateLimitCall
struct
{
accountID
int64
resetAt
time
.
Time
}
type
stubAntigravityAccountRepo
struct
{
AccountRepository
scopeCalls
[]
scopeLimitCall
rateCalls
[]
rateLimitCall
}
func
(
s
*
stubAntigravityAccountRepo
)
SetAntigravityQuotaScopeLimit
(
ctx
context
.
Context
,
id
int64
,
scope
AntigravityQuotaScope
,
resetAt
time
.
Time
)
error
{
s
.
scopeCalls
=
append
(
s
.
scopeCalls
,
scopeLimitCall
{
accountID
:
id
,
scope
:
scope
,
resetAt
:
resetAt
})
return
nil
}
func
(
s
*
stubAntigravityAccountRepo
)
SetRateLimited
(
ctx
context
.
Context
,
id
int64
,
resetAt
time
.
Time
)
error
{
s
.
rateCalls
=
append
(
s
.
rateCalls
,
rateLimitCall
{
accountID
:
id
,
resetAt
:
resetAt
})
return
nil
}
func
TestAntigravityRetryLoop_URLFallback_UsesLatestSuccess
(
t
*
testing
.
T
)
{
oldBaseURLs
:=
append
([]
string
(
nil
),
antigravity
.
BaseURLs
...
)
oldAvailability
:=
antigravity
.
DefaultURLAvailability
defer
func
()
{
antigravity
.
BaseURLs
=
oldBaseURLs
antigravity
.
DefaultURLAvailability
=
oldAvailability
}()
base1
:=
"https://ag-1.test"
base2
:=
"https://ag-2.test"
antigravity
.
BaseURLs
=
[]
string
{
base1
,
base2
}
antigravity
.
DefaultURLAvailability
=
antigravity
.
NewURLAvailability
(
time
.
Minute
)
upstream
:=
&
stubAntigravityUpstream
{
firstBase
:
base1
,
secondBase
:
base2
}
account
:=
&
Account
{
ID
:
1
,
Name
:
"acc-1"
,
Platform
:
PlatformAntigravity
,
Schedulable
:
true
,
Status
:
StatusActive
,
Concurrency
:
1
,
}
var
handleErrorCalled
bool
result
,
err
:=
antigravityRetryLoop
(
antigravityRetryLoopParams
{
prefix
:
"[test]"
,
ctx
:
context
.
Background
(),
account
:
account
,
proxyURL
:
""
,
accessToken
:
"token"
,
action
:
"generateContent"
,
body
:
[]
byte
(
`{"input":"test"}`
),
quotaScope
:
AntigravityQuotaScopeClaude
,
httpUpstream
:
upstream
,
handleError
:
func
(
ctx
context
.
Context
,
prefix
string
,
account
*
Account
,
statusCode
int
,
headers
http
.
Header
,
body
[]
byte
,
quotaScope
AntigravityQuotaScope
)
{
handleErrorCalled
=
true
},
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
resp
)
defer
func
()
{
_
=
result
.
resp
.
Body
.
Close
()
}()
require
.
Equal
(
t
,
http
.
StatusOK
,
result
.
resp
.
StatusCode
)
require
.
False
(
t
,
handleErrorCalled
)
require
.
Len
(
t
,
upstream
.
calls
,
2
)
require
.
True
(
t
,
strings
.
HasPrefix
(
upstream
.
calls
[
0
],
base1
))
require
.
True
(
t
,
strings
.
HasPrefix
(
upstream
.
calls
[
1
],
base2
))
available
:=
antigravity
.
DefaultURLAvailability
.
GetAvailableURLs
()
require
.
NotEmpty
(
t
,
available
)
require
.
Equal
(
t
,
base2
,
available
[
0
])
}
func
TestAntigravityHandleUpstreamError_UsesScopeLimitWhenEnabled
(
t
*
testing
.
T
)
{
t
.
Setenv
(
antigravityScopeRateLimitEnv
,
"true"
)
repo
:=
&
stubAntigravityAccountRepo
{}
svc
:=
&
AntigravityGatewayService
{
accountRepo
:
repo
}
account
:=
&
Account
{
ID
:
9
,
Name
:
"acc-9"
,
Platform
:
PlatformAntigravity
}
body
:=
buildGeminiRateLimitBody
(
"3s"
)
svc
.
handleUpstreamError
(
context
.
Background
(),
"[test]"
,
account
,
http
.
StatusTooManyRequests
,
http
.
Header
{},
body
,
AntigravityQuotaScopeClaude
)
require
.
Len
(
t
,
repo
.
scopeCalls
,
1
)
require
.
Empty
(
t
,
repo
.
rateCalls
)
call
:=
repo
.
scopeCalls
[
0
]
require
.
Equal
(
t
,
account
.
ID
,
call
.
accountID
)
require
.
Equal
(
t
,
AntigravityQuotaScopeClaude
,
call
.
scope
)
require
.
WithinDuration
(
t
,
time
.
Now
()
.
Add
(
3
*
time
.
Second
),
call
.
resetAt
,
2
*
time
.
Second
)
}
func
TestAntigravityHandleUpstreamError_UsesAccountLimitWhenScopeDisabled
(
t
*
testing
.
T
)
{
t
.
Setenv
(
antigravityScopeRateLimitEnv
,
"false"
)
repo
:=
&
stubAntigravityAccountRepo
{}
svc
:=
&
AntigravityGatewayService
{
accountRepo
:
repo
}
account
:=
&
Account
{
ID
:
10
,
Name
:
"acc-10"
,
Platform
:
PlatformAntigravity
}
body
:=
buildGeminiRateLimitBody
(
"2s"
)
svc
.
handleUpstreamError
(
context
.
Background
(),
"[test]"
,
account
,
http
.
StatusTooManyRequests
,
http
.
Header
{},
body
,
AntigravityQuotaScopeClaude
)
require
.
Len
(
t
,
repo
.
rateCalls
,
1
)
require
.
Empty
(
t
,
repo
.
scopeCalls
)
call
:=
repo
.
rateCalls
[
0
]
require
.
Equal
(
t
,
account
.
ID
,
call
.
accountID
)
require
.
WithinDuration
(
t
,
time
.
Now
()
.
Add
(
2
*
time
.
Second
),
call
.
resetAt
,
2
*
time
.
Second
)
}
func
TestAccountIsSchedulableForModel_AntigravityRateLimits
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
future
:=
now
.
Add
(
10
*
time
.
Minute
)
account
:=
&
Account
{
ID
:
1
,
Name
:
"acc"
,
Platform
:
PlatformAntigravity
,
Status
:
StatusActive
,
Schedulable
:
true
,
}
account
.
RateLimitResetAt
=
&
future
require
.
False
(
t
,
account
.
IsSchedulableForModel
(
"claude-sonnet-4-5"
))
require
.
False
(
t
,
account
.
IsSchedulableForModel
(
"gemini-3-flash"
))
account
.
RateLimitResetAt
=
nil
account
.
Extra
=
map
[
string
]
any
{
antigravityQuotaScopesKey
:
map
[
string
]
any
{
"claude"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future
.
Format
(
time
.
RFC3339
),
},
},
}
require
.
False
(
t
,
account
.
IsSchedulableForModel
(
"claude-sonnet-4-5"
))
require
.
True
(
t
,
account
.
IsSchedulableForModel
(
"gemini-3-flash"
))
}
func
buildGeminiRateLimitBody
(
delay
string
)
[]
byte
{
return
[]
byte
(
fmt
.
Sprintf
(
`{"error":{"message":"too many requests","details":[{"metadata":{"quotaResetDelay":%q}}]}}`
,
delay
))
}
backend/internal/service/antigravity_token_provider.go
View file @
2fe8932c
...
...
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log"
"log/slog"
"strconv"
"strings"
"time"
...
...
@@ -101,8 +102,18 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
return
""
,
errors
.
New
(
"access_token not found in credentials"
)
}
// 3. 存入缓存
// 3. 存入缓存
(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
if
p
.
tokenCache
!=
nil
{
latestAccount
,
isStale
:=
CheckTokenVersion
(
ctx
,
account
,
p
.
accountRepo
)
if
isStale
&&
latestAccount
!=
nil
{
// 版本过时,使用 DB 中的最新 token
slog
.
Debug
(
"antigravity_token_version_stale_use_latest"
,
"account_id"
,
account
.
ID
)
accessToken
=
latestAccount
.
GetCredential
(
"access_token"
)
if
strings
.
TrimSpace
(
accessToken
)
==
""
{
return
""
,
errors
.
New
(
"access_token not found after version check"
)
}
// 不写入缓存,让下次请求重新处理
}
else
{
ttl
:=
30
*
time
.
Minute
if
expiresAt
!=
nil
{
until
:=
time
.
Until
(
*
expiresAt
)
...
...
@@ -117,6 +128,7 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
}
_
=
p
.
tokenCache
.
SetAccessToken
(
ctx
,
cacheKey
,
accessToken
,
ttl
)
}
}
return
accessToken
,
nil
}
...
...
backend/internal/service/antigravity_token_refresher.go
View file @
2fe8932c
...
...
@@ -3,6 +3,8 @@ package service
import
(
"context"
"fmt"
"log"
"strings"
"time"
)
...
...
@@ -55,11 +57,33 @@ func (r *AntigravityTokenRefresher) Refresh(ctx context.Context, account *Accoun
}
newCredentials
:=
r
.
antigravityOAuthService
.
BuildAccountCredentials
(
tokenInfo
)
// 合并旧的 credentials,保留新 credentials 中不存在的字段
for
k
,
v
:=
range
account
.
Credentials
{
if
_
,
exists
:=
newCredentials
[
k
];
!
exists
{
newCredentials
[
k
]
=
v
}
}
// 特殊处理 project_id:如果新值为空但旧值非空,保留旧值
// 这确保了即使 LoadCodeAssist 失败,project_id 也不会丢失
if
newProjectID
,
_
:=
newCredentials
[
"project_id"
]
.
(
string
);
newProjectID
==
""
{
if
oldProjectID
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"project_id"
));
oldProjectID
!=
""
{
newCredentials
[
"project_id"
]
=
oldProjectID
}
}
// 如果 project_id 获取失败,只记录警告,不返回错误
// LoadCodeAssist 失败可能是临时网络问题,应该允许重试而不是立即标记为不可重试错误
// Token 刷新本身是成功的(access_token 和 refresh_token 已更新)
if
tokenInfo
.
ProjectIDMissing
{
if
tokenInfo
.
ProjectID
!=
""
{
// 有旧的 project_id,本次获取失败,保留旧值
log
.
Printf
(
"[AntigravityTokenRefresher] Account %d: LoadCodeAssist 临时失败,保留旧 project_id"
,
account
.
ID
)
}
else
{
// 从未获取过 project_id,本次也失败,但不返回错误以允许下次重试
log
.
Printf
(
"[AntigravityTokenRefresher] Account %d: LoadCodeAssist 失败,project_id 缺失,但 token 已更新,将在下次刷新时重试"
,
account
.
ID
)
}
}
return
newCredentials
,
nil
}
backend/internal/service/api_key_auth_cache_impl.go
View file @
2fe8932c
...
...
@@ -94,6 +94,20 @@ func (s *APIKeyService) initAuthCache(cfg *config.Config) {
s
.
authCacheL1
=
cache
}
// StartAuthCacheInvalidationSubscriber starts the Pub/Sub subscriber for L1 cache invalidation.
// This should be called after the service is fully initialized.
func
(
s
*
APIKeyService
)
StartAuthCacheInvalidationSubscriber
(
ctx
context
.
Context
)
{
if
s
.
cache
==
nil
||
s
.
authCacheL1
==
nil
{
return
}
if
err
:=
s
.
cache
.
SubscribeAuthCacheInvalidation
(
ctx
,
func
(
cacheKey
string
)
{
s
.
authCacheL1
.
Del
(
cacheKey
)
});
err
!=
nil
{
// Log but don't fail - L1 cache will still work, just without cross-instance invalidation
println
(
"[Service] Warning: failed to start auth cache invalidation subscriber:"
,
err
.
Error
())
}
}
func
(
s
*
APIKeyService
)
authCacheKey
(
key
string
)
string
{
sum
:=
sha256
.
Sum256
([]
byte
(
key
))
return
hex
.
EncodeToString
(
sum
[
:
])
...
...
@@ -149,6 +163,8 @@ func (s *APIKeyService) deleteAuthCache(ctx context.Context, cacheKey string) {
return
}
_
=
s
.
cache
.
DeleteAuthCache
(
ctx
,
cacheKey
)
// Publish invalidation message to other instances
_
=
s
.
cache
.
PublishAuthCacheInvalidation
(
ctx
,
cacheKey
)
}
func
(
s
*
APIKeyService
)
loadAuthCacheEntry
(
ctx
context
.
Context
,
key
,
cacheKey
string
)
(
*
APIKeyAuthCacheEntry
,
error
)
{
...
...
backend/internal/service/api_key_service.go
View file @
2fe8932c
...
...
@@ -65,6 +65,10 @@ type APIKeyCache interface {
GetAuthCache
(
ctx
context
.
Context
,
key
string
)
(
*
APIKeyAuthCacheEntry
,
error
)
SetAuthCache
(
ctx
context
.
Context
,
key
string
,
entry
*
APIKeyAuthCacheEntry
,
ttl
time
.
Duration
)
error
DeleteAuthCache
(
ctx
context
.
Context
,
key
string
)
error
// Pub/Sub for L1 cache invalidation across instances
PublishAuthCacheInvalidation
(
ctx
context
.
Context
,
cacheKey
string
)
error
SubscribeAuthCacheInvalidation
(
ctx
context
.
Context
,
handler
func
(
cacheKey
string
))
error
}
// APIKeyAuthCacheInvalidator 提供认证缓存失效能力
...
...
backend/internal/service/api_key_service_cache_test.go
View file @
2fe8932c
...
...
@@ -142,6 +142,14 @@ func (s *authCacheStub) DeleteAuthCache(ctx context.Context, key string) error {
return
nil
}
func
(
s
*
authCacheStub
)
PublishAuthCacheInvalidation
(
ctx
context
.
Context
,
cacheKey
string
)
error
{
return
nil
}
func
(
s
*
authCacheStub
)
SubscribeAuthCacheInvalidation
(
ctx
context
.
Context
,
handler
func
(
cacheKey
string
))
error
{
return
nil
}
func
TestAPIKeyService_GetByKey_UsesL2Cache
(
t
*
testing
.
T
)
{
cache
:=
&
authCacheStub
{}
repo
:=
&
authRepoStub
{
...
...
backend/internal/service/api_key_service_delete_test.go
View file @
2fe8932c
...
...
@@ -168,6 +168,14 @@ func (s *apiKeyCacheStub) DeleteAuthCache(ctx context.Context, key string) error
return
nil
}
func
(
s
*
apiKeyCacheStub
)
PublishAuthCacheInvalidation
(
ctx
context
.
Context
,
cacheKey
string
)
error
{
return
nil
}
func
(
s
*
apiKeyCacheStub
)
SubscribeAuthCacheInvalidation
(
ctx
context
.
Context
,
handler
func
(
cacheKey
string
))
error
{
return
nil
}
// TestApiKeyService_Delete_OwnerMismatch 测试非所有者尝试删除时返回权限错误。
// 预期行为:
// - GetKeyAndOwnerID 返回所有者 ID 为 1
...
...
Prev
1
…
3
4
5
6
7
8
9
10
11
…
14
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment