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
1dd3158c
Unverified
Commit
1dd3158c
authored
Feb 10, 2026
by
程序猿MT
Committed by
GitHub
Feb 10, 2026
Browse files
Merge branch 'Wei-Shaw:main' into main
parents
3c46f7d2
5dd83d3c
Changes
19
Expand all
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/gateway_handler.go
View file @
1dd3158c
...
@@ -238,6 +238,13 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -238,6 +238,13 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
var
lastFailoverErr
*
service
.
UpstreamFailoverError
var
lastFailoverErr
*
service
.
UpstreamFailoverError
var
forceCacheBilling
bool
// 粘性会话切换时的缓存计费标记
var
forceCacheBilling
bool
// 粘性会话切换时的缓存计费标记
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
if
h
.
gatewayService
.
IsSingleAntigravityAccountGroup
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
)
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
for
{
for
{
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
,
""
)
// Gemini 不使用会话限制
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
,
""
)
// Gemini 不使用会话限制
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -246,6 +253,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -246,6 +253,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"Service temporarily unavailable"
,
streamStarted
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"Service temporarily unavailable"
,
streamStarted
)
return
return
}
}
// Antigravity 单账号退避重试:分组内没有其他可用账号时,
// 对 503 错误不直接返回,而是清除排除列表、等待退避后重试同一个账号。
// 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。
if
lastFailoverErr
!=
nil
&&
lastFailoverErr
.
StatusCode
==
http
.
StatusServiceUnavailable
&&
switchCount
<=
maxAccountSwitches
{
if
sleepAntigravitySingleAccountBackoff
(
c
.
Request
.
Context
(),
switchCount
)
{
log
.
Printf
(
"Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d"
,
switchCount
,
maxAccountSwitches
)
failedAccountIDs
=
make
(
map
[
int64
]
struct
{})
// 设置 context 标记,让 Service 层预检查等待限流过期而非直接切换
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
continue
}
}
if
lastFailoverErr
!=
nil
{
if
lastFailoverErr
!=
nil
{
h
.
handleFailoverExhausted
(
c
,
lastFailoverErr
,
service
.
PlatformGemini
,
streamStarted
)
h
.
handleFailoverExhausted
(
c
,
lastFailoverErr
,
service
.
PlatformGemini
,
streamStarted
)
}
else
{
}
else
{
...
@@ -395,6 +415,13 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -395,6 +415,13 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
fallbackUsed
:=
false
fallbackUsed
:=
false
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
if
h
.
gatewayService
.
IsSingleAntigravityAccountGroup
(
c
.
Request
.
Context
(),
currentAPIKey
.
GroupID
)
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
for
{
for
{
maxAccountSwitches
:=
h
.
maxAccountSwitches
maxAccountSwitches
:=
h
.
maxAccountSwitches
switchCount
:=
0
switchCount
:=
0
...
@@ -412,6 +439,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -412,6 +439,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"Service temporarily unavailable"
,
streamStarted
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"Service temporarily unavailable"
,
streamStarted
)
return
return
}
}
// Antigravity 单账号退避重试:分组内没有其他可用账号时,
// 对 503 错误不直接返回,而是清除排除列表、等待退避后重试同一个账号。
// 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。
if
lastFailoverErr
!=
nil
&&
lastFailoverErr
.
StatusCode
==
http
.
StatusServiceUnavailable
&&
switchCount
<=
maxAccountSwitches
{
if
sleepAntigravitySingleAccountBackoff
(
c
.
Request
.
Context
(),
switchCount
)
{
log
.
Printf
(
"Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d"
,
switchCount
,
maxAccountSwitches
)
failedAccountIDs
=
make
(
map
[
int64
]
struct
{})
// 设置 context 标记,让 Service 层预检查等待限流过期而非直接切换
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
continue
}
}
if
lastFailoverErr
!=
nil
{
if
lastFailoverErr
!=
nil
{
h
.
handleFailoverExhausted
(
c
,
lastFailoverErr
,
platform
,
streamStarted
)
h
.
handleFailoverExhausted
(
c
,
lastFailoverErr
,
platform
,
streamStarted
)
}
else
{
}
else
{
...
@@ -838,6 +878,27 @@ func sleepFailoverDelay(ctx context.Context, switchCount int) bool {
...
@@ -838,6 +878,27 @@ func sleepFailoverDelay(ctx context.Context, switchCount int) bool {
}
}
}
}
// sleepAntigravitySingleAccountBackoff Antigravity 平台单账号分组的 503 退避重试延时。
// 当分组内只有一个可用账号且上游返回 503(MODEL_CAPACITY_EXHAUSTED)时使用,
// 采用短固定延时策略。Service 层在 SingleAccountRetry 模式下已经做了充分的原地重试
// (最多 3 次、总等待 30s),所以 Handler 层的退避只需短暂等待即可。
// 返回 false 表示 context 已取消。
func
sleepAntigravitySingleAccountBackoff
(
ctx
context
.
Context
,
retryCount
int
)
bool
{
// 固定短延时:2s
// Service 层已经在原地等待了足够长的时间(retryDelay × 重试次数),
// Handler 层只需短暂间隔后重新进入 Service 层即可。
const
delay
=
2
*
time
.
Second
log
.
Printf
(
"Antigravity single-account 503 backoff: waiting %v before retry (attempt %d)"
,
delay
,
retryCount
)
select
{
case
<-
ctx
.
Done
()
:
return
false
case
<-
time
.
After
(
delay
)
:
return
true
}
}
func
(
h
*
GatewayHandler
)
handleFailoverExhausted
(
c
*
gin
.
Context
,
failoverErr
*
service
.
UpstreamFailoverError
,
platform
string
,
streamStarted
bool
)
{
func
(
h
*
GatewayHandler
)
handleFailoverExhausted
(
c
*
gin
.
Context
,
failoverErr
*
service
.
UpstreamFailoverError
,
platform
string
,
streamStarted
bool
)
{
statusCode
:=
failoverErr
.
StatusCode
statusCode
:=
failoverErr
.
StatusCode
responseBody
:=
failoverErr
.
ResponseBody
responseBody
:=
failoverErr
.
ResponseBody
...
...
backend/internal/handler/gateway_handler_single_account_retry_test.go
0 → 100644
View file @
1dd3158c
package
handler
import
(
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// sleepAntigravitySingleAccountBackoff 测试
// ---------------------------------------------------------------------------
func
TestSleepAntigravitySingleAccountBackoff_ReturnsTrue
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
start
:=
time
.
Now
()
ok
:=
sleepAntigravitySingleAccountBackoff
(
ctx
,
1
)
elapsed
:=
time
.
Since
(
start
)
require
.
True
(
t
,
ok
,
"should return true when context is not canceled"
)
// 固定延迟 2s
require
.
GreaterOrEqual
(
t
,
elapsed
,
1500
*
time
.
Millisecond
,
"should wait approximately 2s"
)
require
.
Less
(
t
,
elapsed
,
5
*
time
.
Second
,
"should not wait too long"
)
}
func
TestSleepAntigravitySingleAccountBackoff_ContextCanceled
(
t
*
testing
.
T
)
{
ctx
,
cancel
:=
context
.
WithCancel
(
context
.
Background
())
cancel
()
// 立即取消
start
:=
time
.
Now
()
ok
:=
sleepAntigravitySingleAccountBackoff
(
ctx
,
1
)
elapsed
:=
time
.
Since
(
start
)
require
.
False
(
t
,
ok
,
"should return false when context is canceled"
)
require
.
Less
(
t
,
elapsed
,
500
*
time
.
Millisecond
,
"should return immediately on cancel"
)
}
func
TestSleepAntigravitySingleAccountBackoff_FixedDelay
(
t
*
testing
.
T
)
{
// 验证不同 retryCount 都使用固定 2s 延迟
ctx
:=
context
.
Background
()
start
:=
time
.
Now
()
ok
:=
sleepAntigravitySingleAccountBackoff
(
ctx
,
5
)
elapsed
:=
time
.
Since
(
start
)
require
.
True
(
t
,
ok
)
// 即使 retryCount=5,延迟仍然是固定的 2s
require
.
GreaterOrEqual
(
t
,
elapsed
,
1500
*
time
.
Millisecond
)
require
.
Less
(
t
,
elapsed
,
5
*
time
.
Second
)
}
backend/internal/handler/gemini_v1beta_handler.go
View file @
1dd3158c
...
@@ -327,6 +327,13 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
...
@@ -327,6 +327,13 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
var
lastFailoverErr
*
service
.
UpstreamFailoverError
var
lastFailoverErr
*
service
.
UpstreamFailoverError
var
forceCacheBilling
bool
// 粘性会话切换时的缓存计费标记
var
forceCacheBilling
bool
// 粘性会话切换时的缓存计费标记
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
if
h
.
gatewayService
.
IsSingleAntigravityAccountGroup
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
)
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
for
{
for
{
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
modelName
,
failedAccountIDs
,
""
)
// Gemini 不使用会话限制
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
modelName
,
failedAccountIDs
,
""
)
// Gemini 不使用会话限制
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -334,6 +341,19 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
...
@@ -334,6 +341,19 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
googleError
(
c
,
http
.
StatusServiceUnavailable
,
"No available Gemini accounts: "
+
err
.
Error
())
googleError
(
c
,
http
.
StatusServiceUnavailable
,
"No available Gemini accounts: "
+
err
.
Error
())
return
return
}
}
// Antigravity 单账号退避重试:分组内没有其他可用账号时,
// 对 503 错误不直接返回,而是清除排除列表、等待退避后重试同一个账号。
// 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。
if
lastFailoverErr
!=
nil
&&
lastFailoverErr
.
StatusCode
==
http
.
StatusServiceUnavailable
&&
switchCount
<=
maxAccountSwitches
{
if
sleepAntigravitySingleAccountBackoff
(
c
.
Request
.
Context
(),
switchCount
)
{
log
.
Printf
(
"Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d"
,
switchCount
,
maxAccountSwitches
)
failedAccountIDs
=
make
(
map
[
int64
]
struct
{})
// 设置 context 标记,让 Service 层预检查等待限流过期而非直接切换
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
continue
}
}
h
.
handleGeminiFailoverExhausted
(
c
,
lastFailoverErr
)
h
.
handleGeminiFailoverExhausted
(
c
,
lastFailoverErr
)
return
return
}
}
...
...
backend/internal/pkg/antigravity/request_transformer.go
View file @
1dd3158c
...
@@ -271,6 +271,21 @@ func filterOpenCodePrompt(text string) string {
...
@@ -271,6 +271,21 @@ func filterOpenCodePrompt(text string) string {
return
""
return
""
}
}
// systemBlockFilterPrefixes 需要从 system 中过滤的文本前缀列表
var
systemBlockFilterPrefixes
=
[]
string
{
"x-anthropic-billing-header"
,
}
// filterSystemBlockByPrefix 如果文本匹配过滤前缀,返回空字符串
func
filterSystemBlockByPrefix
(
text
string
)
string
{
for
_
,
prefix
:=
range
systemBlockFilterPrefixes
{
if
strings
.
HasPrefix
(
text
,
prefix
)
{
return
""
}
}
return
text
}
// buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致)
// buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致)
func
buildSystemInstruction
(
system
json
.
RawMessage
,
modelName
string
,
opts
TransformOptions
,
tools
[]
ClaudeTool
)
*
GeminiContent
{
func
buildSystemInstruction
(
system
json
.
RawMessage
,
modelName
string
,
opts
TransformOptions
,
tools
[]
ClaudeTool
)
*
GeminiContent
{
var
parts
[]
GeminiPart
var
parts
[]
GeminiPart
...
@@ -287,8 +302,8 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
...
@@ -287,8 +302,8 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
if
strings
.
Contains
(
sysStr
,
"You are Antigravity"
)
{
if
strings
.
Contains
(
sysStr
,
"You are Antigravity"
)
{
userHasAntigravityIdentity
=
true
userHasAntigravityIdentity
=
true
}
}
// 过滤 OpenCode 默认提示词
// 过滤 OpenCode 默认提示词
和黑名单前缀
filtered
:=
filterOpenCodePrompt
(
sysStr
)
filtered
:=
filterSystemBlockByPrefix
(
filterOpenCodePrompt
(
sysStr
)
)
if
filtered
!=
""
{
if
filtered
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
filtered
})
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
filtered
})
}
}
...
@@ -302,8 +317,8 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
...
@@ -302,8 +317,8 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
if
strings
.
Contains
(
block
.
Text
,
"You are Antigravity"
)
{
if
strings
.
Contains
(
block
.
Text
,
"You are Antigravity"
)
{
userHasAntigravityIdentity
=
true
userHasAntigravityIdentity
=
true
}
}
// 过滤 OpenCode 默认提示词
// 过滤 OpenCode 默认提示词
和黑名单前缀
filtered
:=
filterOpenCodePrompt
(
block
.
Text
)
filtered
:=
filterSystemBlockByPrefix
(
filterOpenCodePrompt
(
block
.
Text
)
)
if
filtered
!=
""
{
if
filtered
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
filtered
})
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
filtered
})
}
}
...
...
backend/internal/pkg/ctxkey/ctxkey.go
View file @
1dd3158c
...
@@ -28,4 +28,8 @@ const (
...
@@ -28,4 +28,8 @@ const (
// IsMaxTokensOneHaikuRequest 标识当前请求是否为 max_tokens=1 + haiku 模型的探测请求
// IsMaxTokensOneHaikuRequest 标识当前请求是否为 max_tokens=1 + haiku 模型的探测请求
// 用于 ClaudeCodeOnly 验证绕过(绕过 system prompt 检查,但仍需验证 User-Agent)
// 用于 ClaudeCodeOnly 验证绕过(绕过 system prompt 检查,但仍需验证 User-Agent)
IsMaxTokensOneHaikuRequest
Key
=
"ctx_is_max_tokens_one_haiku"
IsMaxTokensOneHaikuRequest
Key
=
"ctx_is_max_tokens_one_haiku"
// SingleAccountRetry 标识当前请求处于单账号 503 退避重试模式。
// 在此模式下,Service 层的模型限流预检查将等待限流过期而非直接切换账号。
SingleAccountRetry
Key
=
"ctx_single_account_retry"
)
)
backend/internal/service/antigravity_gateway_service.go
View file @
1dd3158c
...
@@ -20,6 +20,7 @@ import (
...
@@ -20,6 +20,7 @@ import (
"time"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/google/uuid"
)
)
...
@@ -46,6 +47,19 @@ const (
...
@@ -46,6 +47,19 @@ const (
googleRPCTypeErrorInfo
=
"type.googleapis.com/google.rpc.ErrorInfo"
googleRPCTypeErrorInfo
=
"type.googleapis.com/google.rpc.ErrorInfo"
googleRPCReasonModelCapacityExhausted
=
"MODEL_CAPACITY_EXHAUSTED"
googleRPCReasonModelCapacityExhausted
=
"MODEL_CAPACITY_EXHAUSTED"
googleRPCReasonRateLimitExceeded
=
"RATE_LIMIT_EXCEEDED"
googleRPCReasonRateLimitExceeded
=
"RATE_LIMIT_EXCEEDED"
// 单账号 503 退避重试:Service 层原地重试的最大次数
// 在 handleSmartRetry 中,对于 shouldRateLimitModel(长延迟 ≥ 7s)的情况,
// 多账号模式下会设限流+切换账号;但单账号模式下改为原地等待+重试。
antigravitySingleAccountSmartRetryMaxAttempts
=
3
// 单账号 503 退避重试:原地重试时单次最大等待时间
// 防止上游返回过长的 retryDelay 导致请求卡住太久
antigravitySingleAccountSmartRetryMaxWait
=
15
*
time
.
Second
// 单账号 503 退避重试:原地重试的总累计等待时间上限
// 超过此上限将不再重试,直接返回 503
antigravitySingleAccountSmartRetryTotalMaxWait
=
30
*
time
.
Second
)
)
// antigravityPassthroughErrorMessages 透传给客户端的错误消息白名单(小写)
// antigravityPassthroughErrorMessages 透传给客户端的错误消息白名单(小写)
...
@@ -148,6 +162,13 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
...
@@ -148,6 +162,13 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
// 情况1: retryDelay >= 阈值,限流模型并切换账号
// 情况1: retryDelay >= 阈值,限流模型并切换账号
if
shouldRateLimitModel
{
if
shouldRateLimitModel
{
// 单账号 503 退避重试模式:不设限流、不切换账号,改为原地等待+重试
// 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。
// 多账号场景下切换账号是最优选择,但单账号场景下设限流毫无意义(只会导致双重等待)。
if
resp
.
StatusCode
==
http
.
StatusServiceUnavailable
&&
isSingleAccountRetry
(
p
.
ctx
)
{
return
s
.
handleSingleAccountRetryInPlace
(
p
,
resp
,
respBody
,
baseURL
,
waitDuration
,
modelName
)
}
rateLimitDuration
:=
waitDuration
rateLimitDuration
:=
waitDuration
if
rateLimitDuration
<=
0
{
if
rateLimitDuration
<=
0
{
rateLimitDuration
=
antigravityDefaultRateLimitDuration
rateLimitDuration
=
antigravityDefaultRateLimitDuration
...
@@ -236,7 +257,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
...
@@ -236,7 +257,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
}
}
}
}
// 所有重试都失败
,限流当前模型并切换账号
// 所有重试都失败
rateLimitDuration
:=
waitDuration
rateLimitDuration
:=
waitDuration
if
rateLimitDuration
<=
0
{
if
rateLimitDuration
<=
0
{
rateLimitDuration
=
antigravityDefaultRateLimitDuration
rateLimitDuration
=
antigravityDefaultRateLimitDuration
...
@@ -245,6 +266,22 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
...
@@ -245,6 +266,22 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
if
retryBody
==
nil
{
if
retryBody
==
nil
{
retryBody
=
respBody
retryBody
=
respBody
}
}
// 单账号 503 退避重试模式:智能重试耗尽后不设限流、不切换账号,
// 直接返回 503 让 Handler 层的单账号退避循环做最终处理。
if
resp
.
StatusCode
==
http
.
StatusServiceUnavailable
&&
isSingleAccountRetry
(
p
.
ctx
)
{
log
.
Printf
(
"%s status=%d smart_retry_exhausted_single_account attempts=%d model=%s account=%d body=%s (return 503 directly)"
,
p
.
prefix
,
resp
.
StatusCode
,
antigravitySmartRetryMaxAttempts
,
modelName
,
p
.
account
.
ID
,
truncateForLog
(
retryBody
,
200
))
return
&
smartRetryResult
{
action
:
smartRetryActionBreakWithResp
,
resp
:
&
http
.
Response
{
StatusCode
:
resp
.
StatusCode
,
Header
:
resp
.
Header
.
Clone
(),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
retryBody
)),
},
}
}
log
.
Printf
(
"%s status=%d smart_retry_exhausted attempts=%d model=%s account=%d upstream_retry_delay=%v body=%s (switch account)"
,
log
.
Printf
(
"%s status=%d smart_retry_exhausted attempts=%d model=%s account=%d upstream_retry_delay=%v body=%s (switch account)"
,
p
.
prefix
,
resp
.
StatusCode
,
antigravitySmartRetryMaxAttempts
,
modelName
,
p
.
account
.
ID
,
rateLimitDuration
,
truncateForLog
(
retryBody
,
200
))
p
.
prefix
,
resp
.
StatusCode
,
antigravitySmartRetryMaxAttempts
,
modelName
,
p
.
account
.
ID
,
rateLimitDuration
,
truncateForLog
(
retryBody
,
200
))
...
@@ -279,17 +316,152 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
...
@@ -279,17 +316,152 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
return
&
smartRetryResult
{
action
:
smartRetryActionContinue
}
return
&
smartRetryResult
{
action
:
smartRetryActionContinue
}
}
}
// handleSingleAccountRetryInPlace 单账号 503 退避重试的原地重试逻辑。
//
// 在多账号场景下,收到 503 + 长 retryDelay(≥ 7s)时会设置模型限流 + 切换账号;
// 但在单账号场景下,设限流毫无意义(因为切换回来的还是同一个账号,还要等限流过期)。
// 此方法改为在 Service 层原地等待 + 重试,避免双重等待问题:
//
// 旧流程:Service 设限流 → Handler 退避等待 → Service 等限流过期 → 再请求(总耗时 = 退避 + 限流)
// 新流程:Service 直接等 retryDelay → 重试 → 成功/再等 → 重试...(总耗时 ≈ 实际 retryDelay × 重试次数)
//
// 约束:
// - 单次等待不超过 antigravitySingleAccountSmartRetryMaxWait
// - 总累计等待不超过 antigravitySingleAccountSmartRetryTotalMaxWait
// - 最多重试 antigravitySingleAccountSmartRetryMaxAttempts 次
func
(
s
*
AntigravityGatewayService
)
handleSingleAccountRetryInPlace
(
p
antigravityRetryLoopParams
,
resp
*
http
.
Response
,
respBody
[]
byte
,
baseURL
string
,
waitDuration
time
.
Duration
,
modelName
string
,
)
*
smartRetryResult
{
// 限制单次等待时间
if
waitDuration
>
antigravitySingleAccountSmartRetryMaxWait
{
waitDuration
=
antigravitySingleAccountSmartRetryMaxWait
}
if
waitDuration
<
antigravitySmartRetryMinWait
{
waitDuration
=
antigravitySmartRetryMinWait
}
log
.
Printf
(
"%s status=%d single_account_503_retry_in_place model=%s account=%d upstream_retry_delay=%v (retrying in-place instead of rate-limiting)"
,
p
.
prefix
,
resp
.
StatusCode
,
modelName
,
p
.
account
.
ID
,
waitDuration
)
var
lastRetryResp
*
http
.
Response
var
lastRetryBody
[]
byte
totalWaited
:=
time
.
Duration
(
0
)
for
attempt
:=
1
;
attempt
<=
antigravitySingleAccountSmartRetryMaxAttempts
;
attempt
++
{
// 检查累计等待是否超限
if
totalWaited
+
waitDuration
>
antigravitySingleAccountSmartRetryTotalMaxWait
{
remaining
:=
antigravitySingleAccountSmartRetryTotalMaxWait
-
totalWaited
if
remaining
<=
0
{
log
.
Printf
(
"%s single_account_503_retry: total_wait_exceeded total=%v max=%v, giving up"
,
p
.
prefix
,
totalWaited
,
antigravitySingleAccountSmartRetryTotalMaxWait
)
break
}
waitDuration
=
remaining
}
log
.
Printf
(
"%s status=%d single_account_503_retry attempt=%d/%d delay=%v total_waited=%v model=%s account=%d"
,
p
.
prefix
,
resp
.
StatusCode
,
attempt
,
antigravitySingleAccountSmartRetryMaxAttempts
,
waitDuration
,
totalWaited
,
modelName
,
p
.
account
.
ID
)
select
{
case
<-
p
.
ctx
.
Done
()
:
log
.
Printf
(
"%s status=context_canceled_during_single_account_retry"
,
p
.
prefix
)
return
&
smartRetryResult
{
action
:
smartRetryActionBreakWithResp
,
err
:
p
.
ctx
.
Err
()}
case
<-
time
.
After
(
waitDuration
)
:
}
totalWaited
+=
waitDuration
// 创建新请求
retryReq
,
err
:=
antigravity
.
NewAPIRequestWithURL
(
p
.
ctx
,
baseURL
,
p
.
action
,
p
.
accessToken
,
p
.
body
)
if
err
!=
nil
{
log
.
Printf
(
"%s single_account_503_retry: request_build_failed error=%v"
,
p
.
prefix
,
err
)
break
}
retryResp
,
retryErr
:=
p
.
httpUpstream
.
Do
(
retryReq
,
p
.
proxyURL
,
p
.
account
.
ID
,
p
.
account
.
Concurrency
)
if
retryErr
==
nil
&&
retryResp
!=
nil
&&
retryResp
.
StatusCode
!=
http
.
StatusTooManyRequests
&&
retryResp
.
StatusCode
!=
http
.
StatusServiceUnavailable
{
log
.
Printf
(
"%s status=%d single_account_503_retry_success attempt=%d/%d total_waited=%v"
,
p
.
prefix
,
retryResp
.
StatusCode
,
attempt
,
antigravitySingleAccountSmartRetryMaxAttempts
,
totalWaited
)
// 关闭之前的响应
if
lastRetryResp
!=
nil
{
_
=
lastRetryResp
.
Body
.
Close
()
}
return
&
smartRetryResult
{
action
:
smartRetryActionBreakWithResp
,
resp
:
retryResp
}
}
// 网络错误时继续重试
if
retryErr
!=
nil
||
retryResp
==
nil
{
log
.
Printf
(
"%s single_account_503_retry: network_error attempt=%d/%d error=%v"
,
p
.
prefix
,
attempt
,
antigravitySingleAccountSmartRetryMaxAttempts
,
retryErr
)
continue
}
// 关闭之前的响应
if
lastRetryResp
!=
nil
{
_
=
lastRetryResp
.
Body
.
Close
()
}
lastRetryResp
=
retryResp
lastRetryBody
,
_
=
io
.
ReadAll
(
io
.
LimitReader
(
retryResp
.
Body
,
2
<<
20
))
_
=
retryResp
.
Body
.
Close
()
// 解析新的重试信息,更新下次等待时间
if
attempt
<
antigravitySingleAccountSmartRetryMaxAttempts
&&
lastRetryBody
!=
nil
{
_
,
_
,
newWaitDuration
,
_
:=
shouldTriggerAntigravitySmartRetry
(
p
.
account
,
lastRetryBody
)
if
newWaitDuration
>
0
{
waitDuration
=
newWaitDuration
if
waitDuration
>
antigravitySingleAccountSmartRetryMaxWait
{
waitDuration
=
antigravitySingleAccountSmartRetryMaxWait
}
if
waitDuration
<
antigravitySmartRetryMinWait
{
waitDuration
=
antigravitySmartRetryMinWait
}
}
}
}
// 所有重试都失败,不设限流,直接返回 503
// Handler 层的单账号退避循环会做最终处理
retryBody
:=
lastRetryBody
if
retryBody
==
nil
{
retryBody
=
respBody
}
log
.
Printf
(
"%s status=%d single_account_503_retry_exhausted attempts=%d total_waited=%v model=%s account=%d body=%s (return 503 directly)"
,
p
.
prefix
,
resp
.
StatusCode
,
antigravitySingleAccountSmartRetryMaxAttempts
,
totalWaited
,
modelName
,
p
.
account
.
ID
,
truncateForLog
(
retryBody
,
200
))
return
&
smartRetryResult
{
action
:
smartRetryActionBreakWithResp
,
resp
:
&
http
.
Response
{
StatusCode
:
resp
.
StatusCode
,
Header
:
resp
.
Header
.
Clone
(),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
retryBody
)),
},
}
}
// antigravityRetryLoop 执行带 URL fallback 的重试循环
// antigravityRetryLoop 执行带 URL fallback 的重试循环
func
(
s
*
AntigravityGatewayService
)
antigravityRetryLoop
(
p
antigravityRetryLoopParams
)
(
*
antigravityRetryLoopResult
,
error
)
{
func
(
s
*
AntigravityGatewayService
)
antigravityRetryLoop
(
p
antigravityRetryLoopParams
)
(
*
antigravityRetryLoopResult
,
error
)
{
// 预检查:如果账号已限流,直接返回切换信号
// 预检查:如果账号已限流,直接返回切换信号
if
p
.
requestedModel
!=
""
{
if
p
.
requestedModel
!=
""
{
if
remaining
:=
p
.
account
.
GetRateLimitRemainingTimeWithContext
(
p
.
ctx
,
p
.
requestedModel
);
remaining
>
0
{
if
remaining
:=
p
.
account
.
GetRateLimitRemainingTimeWithContext
(
p
.
ctx
,
p
.
requestedModel
);
remaining
>
0
{
log
.
Printf
(
"%s pre_check: rate_limit_switch remaining=%v model=%s account=%d"
,
// 单账号 503 退避重试模式:跳过限流预检查,直接发请求。
p
.
prefix
,
remaining
.
Truncate
(
time
.
Millisecond
),
p
.
requestedModel
,
p
.
account
.
ID
)
// 首次请求设的限流是为了多账号调度器跳过该账号,在单账号模式下无意义。
return
nil
,
&
AntigravityAccountSwitchError
{
// 如果上游确实还不可用,handleSmartRetry → handleSingleAccountRetryInPlace
OriginalAccountID
:
p
.
account
.
ID
,
// 会在 Service 层原地等待+重试,不需要在预检查这里等。
RateLimitedModel
:
p
.
requestedModel
,
if
isSingleAccountRetry
(
p
.
ctx
)
{
IsStickySession
:
p
.
isStickySession
,
log
.
Printf
(
"%s pre_check: single_account_retry skipping rate_limit remaining=%v model=%s account=%d (will retry in-place if 503)"
,
p
.
prefix
,
remaining
.
Truncate
(
time
.
Millisecond
),
p
.
requestedModel
,
p
.
account
.
ID
)
}
else
{
log
.
Printf
(
"%s pre_check: rate_limit_switch remaining=%v model=%s account=%d"
,
p
.
prefix
,
remaining
.
Truncate
(
time
.
Millisecond
),
p
.
requestedModel
,
p
.
account
.
ID
)
return
nil
,
&
AntigravityAccountSwitchError
{
OriginalAccountID
:
p
.
account
.
ID
,
RateLimitedModel
:
p
.
requestedModel
,
IsStickySession
:
p
.
isStickySession
,
}
}
}
}
}
}
}
...
@@ -371,12 +543,12 @@ urlFallbackLoop:
...
@@ -371,12 +543,12 @@ urlFallbackLoop:
_
=
resp
.
Body
.
Close
()
_
=
resp
.
Body
.
Close
()
// ★ 统一入口:自定义错误码 + 临时不可调度
// ★ 统一入口:自定义错误码 + 临时不可调度
if
handled
,
policyErr
:=
s
.
applyErrorPolicy
(
p
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
);
handled
{
if
handled
,
outStatus
,
policyErr
:=
s
.
applyErrorPolicy
(
p
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
);
handled
{
if
policyErr
!=
nil
{
if
policyErr
!=
nil
{
return
nil
,
policyErr
return
nil
,
policyErr
}
}
resp
=
&
http
.
Response
{
resp
=
&
http
.
Response
{
StatusCode
:
resp
.
Status
Code
,
StatusCode
:
out
Status
,
Header
:
resp
.
Header
.
Clone
(),
Header
:
resp
.
Header
.
Clone
(),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
)),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
)),
}
}
...
@@ -610,21 +782,22 @@ func (s *AntigravityGatewayService) checkErrorPolicy(ctx context.Context, accoun
...
@@ -610,21 +782,22 @@ func (s *AntigravityGatewayService) checkErrorPolicy(ctx context.Context, accoun
return
s
.
rateLimitService
.
CheckErrorPolicy
(
ctx
,
account
,
statusCode
,
body
)
return
s
.
rateLimitService
.
CheckErrorPolicy
(
ctx
,
account
,
statusCode
,
body
)
}
}
// applyErrorPolicy 应用错误策略结果,返回是否应终止当前循环
// applyErrorPolicy 应用错误策略结果,返回是否应终止当前循环及应返回的状态码。
func
(
s
*
AntigravityGatewayService
)
applyErrorPolicy
(
p
antigravityRetryLoopParams
,
statusCode
int
,
headers
http
.
Header
,
respBody
[]
byte
)
(
handled
bool
,
retErr
error
)
{
// ErrorPolicySkipped 时 outStatus 为 500(前端约定:未命中的错误返回 500)。
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
:
return
true
,
nil
return
true
,
http
.
StatusInternalServerError
,
nil
case
ErrorPolicyMatched
:
case
ErrorPolicyMatched
:
_
=
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
,
nil
return
true
,
statusCode
,
nil
case
ErrorPolicyTempUnscheduled
:
case
ErrorPolicyTempUnscheduled
:
slog
.
Info
(
"temp_unschedulable_matched"
,
slog
.
Info
(
"temp_unschedulable_matched"
,
"prefix"
,
p
.
prefix
,
"status_code"
,
statusCode
,
"account_id"
,
p
.
account
.
ID
)
"prefix"
,
p
.
prefix
,
"status_code"
,
statusCode
,
"account_id"
,
p
.
account
.
ID
)
return
true
,
&
AntigravityAccountSwitchError
{
OriginalAccountID
:
p
.
account
.
ID
,
IsStickySession
:
p
.
isStickySession
}
return
true
,
statusCode
,
&
AntigravityAccountSwitchError
{
OriginalAccountID
:
p
.
account
.
ID
,
IsStickySession
:
p
.
isStickySession
}
}
}
return
false
,
nil
return
false
,
statusCode
,
nil
}
}
// mapAntigravityModel 获取映射后的模型名
// mapAntigravityModel 获取映射后的模型名
...
@@ -1943,6 +2116,12 @@ func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool {
...
@@ -1943,6 +2116,12 @@ func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool {
}
}
}
}
// isSingleAccountRetry 检查 context 中是否设置了单账号退避重试标记
func
isSingleAccountRetry
(
ctx
context
.
Context
)
bool
{
v
,
_
:=
ctx
.
Value
(
ctxkey
.
SingleAccountRetry
)
.
(
bool
)
return
v
}
// setModelRateLimitByModelName 使用官方模型 ID 设置模型级限流
// setModelRateLimitByModelName 使用官方模型 ID 设置模型级限流
// 直接使用上游返回的模型 ID(如 claude-sonnet-4-5)作为限流 key
// 直接使用上游返回的模型 ID(如 claude-sonnet-4-5)作为限流 key
// 返回是否已成功设置(若模型名为空或 repo 为 nil 将返回 false)
// 返回是否已成功设置(若模型名为空或 repo 为 nil 将返回 false)
...
@@ -2242,6 +2421,10 @@ func (s *AntigravityGatewayService) handleUpstreamError(
...
@@ -2242,6 +2421,10 @@ func (s *AntigravityGatewayService) handleUpstreamError(
requestedModel
string
,
requestedModel
string
,
groupID
int64
,
sessionHash
string
,
isStickySession
bool
,
groupID
int64
,
sessionHash
string
,
isStickySession
bool
,
)
*
handleModelRateLimitResult
{
)
*
handleModelRateLimitResult
{
// 遵守自定义错误码策略:未命中则跳过所有限流处理
if
!
account
.
ShouldHandleErrorCode
(
statusCode
)
{
return
nil
}
// 模型级限流处理(优先)
// 模型级限流处理(优先)
result
:=
s
.
handleModelRateLimit
(
&
handleModelRateLimitParams
{
result
:=
s
.
handleModelRateLimit
(
&
handleModelRateLimitParams
{
ctx
:
ctx
,
ctx
:
ctx
,
...
...
backend/internal/service/antigravity_single_account_retry_test.go
0 → 100644
View file @
1dd3158c
This diff is collapsed.
Click to expand it.
backend/internal/service/error_policy_integration_test.go
View file @
1dd3158c
...
@@ -116,7 +116,7 @@ func TestRetryLoop_ErrorPolicy_CustomErrorCodes(t *testing.T) {
...
@@ -116,7 +116,7 @@ func TestRetryLoop_ErrorPolicy_CustomErrorCodes(t *testing.T) {
customCodes
:
[]
any
{
float64
(
500
)},
customCodes
:
[]
any
{
float64
(
500
)},
expectHandleError
:
0
,
expectHandleError
:
0
,
expectUpstream
:
1
,
expectUpstream
:
1
,
expectStatusCode
:
429
,
expectStatusCode
:
500
,
},
},
{
{
name
:
"500_in_custom_codes_matched"
,
name
:
"500_in_custom_codes_matched"
,
...
@@ -364,3 +364,109 @@ func TestRetryLoop_ErrorPolicy_NoPolicy_OriginalBehavior(t *testing.T) {
...
@@ -364,3 +364,109 @@ func TestRetryLoop_ErrorPolicy_NoPolicy_OriginalBehavior(t *testing.T) {
require
.
Equal
(
t
,
antigravityMaxRetries
,
upstream
.
calls
,
"should exhaust all retries"
)
require
.
Equal
(
t
,
antigravityMaxRetries
,
upstream
.
calls
,
"should exhaust all retries"
)
require
.
Equal
(
t
,
1
,
handleErrorCount
,
"handleError should be called once after retries exhausted"
)
require
.
Equal
(
t
,
1
,
handleErrorCount
,
"handleError should be called once after retries exhausted"
)
}
}
// ---------------------------------------------------------------------------
// epTrackingRepo — records SetRateLimited / SetError calls for verification.
// ---------------------------------------------------------------------------
type
epTrackingRepo
struct
{
mockAccountRepoForGemini
rateLimitedCalls
int
rateLimitedID
int64
setErrCalls
int
setErrID
int64
tempCalls
int
}
func
(
r
*
epTrackingRepo
)
SetRateLimited
(
_
context
.
Context
,
id
int64
,
_
time
.
Time
)
error
{
r
.
rateLimitedCalls
++
r
.
rateLimitedID
=
id
return
nil
}
func
(
r
*
epTrackingRepo
)
SetError
(
_
context
.
Context
,
id
int64
,
_
string
)
error
{
r
.
setErrCalls
++
r
.
setErrID
=
id
return
nil
}
func
(
r
*
epTrackingRepo
)
SetTempUnschedulable
(
_
context
.
Context
,
_
int64
,
_
time
.
Time
,
_
string
)
error
{
r
.
tempCalls
++
return
nil
}
// ---------------------------------------------------------------------------
// TestCustomErrorCode599_SkippedErrors_Return500_NoRateLimit
//
// 核心场景:自定义错误码设为 [599](一个不会真正出现的错误码),
// 当上游返回 429/500/503/401 时:
// - 返回给客户端的状态码必须是 500(而不是透传原始状态码)
// - 不调用 SetRateLimited(不进入限流状态)
// - 不调用 SetError(不停止调度)
// - 不调用 handleError
// ---------------------------------------------------------------------------
func
TestCustomErrorCode599_SkippedErrors_Return500_NoRateLimit
(
t
*
testing
.
T
)
{
errorCodes
:=
[]
int
{
429
,
500
,
503
,
401
,
403
}
for
_
,
upstreamStatus
:=
range
errorCodes
{
t
.
Run
(
http
.
StatusText
(
upstreamStatus
),
func
(
t
*
testing
.
T
)
{
saveAndSetBaseURLs
(
t
)
upstream
:=
&
epFixedUpstream
{
statusCode
:
upstreamStatus
,
body
:
`{"error":"some upstream error"}`
,
}
repo
:=
&
epTrackingRepo
{}
rlSvc
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
svc
:=
&
AntigravityGatewayService
{
rateLimitService
:
rlSvc
}
account
:=
&
Account
{
ID
:
500
,
Type
:
AccountTypeAPIKey
,
Platform
:
PlatformAntigravity
,
Schedulable
:
true
,
Status
:
StatusActive
,
Concurrency
:
1
,
Credentials
:
map
[
string
]
any
{
"custom_error_codes_enabled"
:
true
,
"custom_error_codes"
:
[]
any
{
float64
(
599
)},
},
}
var
handleErrorCount
int
p
:=
newRetryParams
(
account
,
upstream
,
func
(
_
context
.
Context
,
_
string
,
_
*
Account
,
_
int
,
_
http
.
Header
,
_
[]
byte
,
_
string
,
_
int64
,
_
string
,
_
bool
)
*
handleModelRateLimitResult
{
handleErrorCount
++
return
nil
})
result
,
err
:=
svc
.
antigravityRetryLoop
(
p
)
// 不应返回 error(Skipped 不触发账号切换)
require
.
NoError
(
t
,
err
,
"should not return error"
)
require
.
NotNil
(
t
,
result
,
"result should not be nil"
)
require
.
NotNil
(
t
,
result
.
resp
,
"response should not be nil"
)
defer
func
()
{
_
=
result
.
resp
.
Body
.
Close
()
}()
// 状态码必须是 500(不透传原始状态码)
require
.
Equal
(
t
,
http
.
StatusInternalServerError
,
result
.
resp
.
StatusCode
,
"skipped error should return 500, not %d"
,
upstreamStatus
)
// 不调用 handleError
require
.
Equal
(
t
,
0
,
handleErrorCount
,
"handleError should NOT be called for skipped errors"
)
// 不标记限流
require
.
Equal
(
t
,
0
,
repo
.
rateLimitedCalls
,
"SetRateLimited should NOT be called for skipped errors"
)
// 不停止调度
require
.
Equal
(
t
,
0
,
repo
.
setErrCalls
,
"SetError should NOT be called for skipped errors"
)
// 只调用一次上游(不重试)
require
.
Equal
(
t
,
1
,
upstream
.
calls
,
"should call upstream exactly once (no retry)"
)
})
}
}
backend/internal/service/error_policy_test.go
View file @
1dd3158c
...
@@ -158,6 +158,7 @@ func TestApplyErrorPolicy(t *testing.T) {
...
@@ -158,6 +158,7 @@ func TestApplyErrorPolicy(t *testing.T) {
statusCode
int
statusCode
int
body
[]
byte
body
[]
byte
expectedHandled
bool
expectedHandled
bool
expectedStatus
int
// expected outStatus
expectedSwitchErr
bool
// expect *AntigravityAccountSwitchError
expectedSwitchErr
bool
// expect *AntigravityAccountSwitchError
handleErrorCalls
int
handleErrorCalls
int
}{
}{
...
@@ -171,6 +172,7 @@ func TestApplyErrorPolicy(t *testing.T) {
...
@@ -171,6 +172,7 @@ func TestApplyErrorPolicy(t *testing.T) {
statusCode
:
500
,
statusCode
:
500
,
body
:
[]
byte
(
`"error"`
),
body
:
[]
byte
(
`"error"`
),
expectedHandled
:
false
,
expectedHandled
:
false
,
expectedStatus
:
500
,
// passthrough
handleErrorCalls
:
0
,
handleErrorCalls
:
0
,
},
},
{
{
...
@@ -187,6 +189,7 @@ func TestApplyErrorPolicy(t *testing.T) {
...
@@ -187,6 +189,7 @@ func TestApplyErrorPolicy(t *testing.T) {
statusCode
:
500
,
// not in custom codes
statusCode
:
500
,
// not in custom codes
body
:
[]
byte
(
`"error"`
),
body
:
[]
byte
(
`"error"`
),
expectedHandled
:
true
,
expectedHandled
:
true
,
expectedStatus
:
http
.
StatusInternalServerError
,
// skipped → 500
handleErrorCalls
:
0
,
handleErrorCalls
:
0
,
},
},
{
{
...
@@ -203,6 +206,7 @@ func TestApplyErrorPolicy(t *testing.T) {
...
@@ -203,6 +206,7 @@ func TestApplyErrorPolicy(t *testing.T) {
statusCode
:
500
,
statusCode
:
500
,
body
:
[]
byte
(
`"error"`
),
body
:
[]
byte
(
`"error"`
),
expectedHandled
:
true
,
expectedHandled
:
true
,
expectedStatus
:
500
,
// matched → original status
handleErrorCalls
:
1
,
handleErrorCalls
:
1
,
},
},
{
{
...
@@ -225,6 +229,7 @@ func TestApplyErrorPolicy(t *testing.T) {
...
@@ -225,6 +229,7 @@ func TestApplyErrorPolicy(t *testing.T) {
statusCode
:
503
,
statusCode
:
503
,
body
:
[]
byte
(
`overloaded`
),
body
:
[]
byte
(
`overloaded`
),
expectedHandled
:
true
,
expectedHandled
:
true
,
expectedStatus
:
503
,
// temp_unscheduled → original status
expectedSwitchErr
:
true
,
expectedSwitchErr
:
true
,
handleErrorCalls
:
0
,
handleErrorCalls
:
0
,
},
},
...
@@ -250,9 +255,10 @@ func TestApplyErrorPolicy(t *testing.T) {
...
@@ -250,9 +255,10 @@ func TestApplyErrorPolicy(t *testing.T) {
isStickySession
:
true
,
isStickySession
:
true
,
}
}
handled
,
retErr
:=
svc
.
applyErrorPolicy
(
p
,
tt
.
statusCode
,
http
.
Header
{},
tt
.
body
)
handled
,
outStatus
,
retErr
:=
svc
.
applyErrorPolicy
(
p
,
tt
.
statusCode
,
http
.
Header
{},
tt
.
body
)
require
.
Equal
(
t
,
tt
.
expectedHandled
,
handled
,
"handled mismatch"
)
require
.
Equal
(
t
,
tt
.
expectedHandled
,
handled
,
"handled mismatch"
)
require
.
Equal
(
t
,
tt
.
expectedStatus
,
outStatus
,
"outStatus mismatch"
)
require
.
Equal
(
t
,
tt
.
handleErrorCalls
,
handleErrorCount
,
"handleError call count mismatch"
)
require
.
Equal
(
t
,
tt
.
handleErrorCalls
,
handleErrorCount
,
"handleError call count mismatch"
)
if
tt
.
expectedSwitchErr
{
if
tt
.
expectedSwitchErr
{
...
...
backend/internal/service/gateway_service.go
View file @
1dd3158c
...
@@ -243,6 +243,12 @@ var (
...
@@ -243,6 +243,12 @@ var (
}
}
)
)
// systemBlockFilterPrefixes 需要从 system 中过滤的文本前缀列表
// OAuth/SetupToken 账号转发时,匹配这些前缀的 system 元素会被移除
var
systemBlockFilterPrefixes
=
[]
string
{
"x-anthropic-billing-header"
,
}
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
var
ErrClaudeCodeOnly
=
errors
.
New
(
"this group only allows Claude Code clients"
)
var
ErrClaudeCodeOnly
=
errors
.
New
(
"this group only allows Claude Code clients"
)
...
@@ -1683,6 +1689,17 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
...
@@ -1683,6 +1689,17 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
return
accounts
,
useMixed
,
nil
return
accounts
,
useMixed
,
nil
}
}
// IsSingleAntigravityAccountGroup 检查指定分组是否只有一个 antigravity 平台的可调度账号。
// 用于 Handler 层在首次请求时提前设置 SingleAccountRetry context,
// 避免单账号分组收到 503 时错误地设置模型限流标记导致后续请求连续快速失败。
func
(
s
*
GatewayService
)
IsSingleAntigravityAccountGroup
(
ctx
context
.
Context
,
groupID
*
int64
)
bool
{
accounts
,
_
,
err
:=
s
.
listSchedulableAccounts
(
ctx
,
groupID
,
PlatformAntigravity
,
true
)
if
err
!=
nil
{
return
false
}
return
len
(
accounts
)
==
1
}
func
(
s
*
GatewayService
)
isAccountAllowedForPlatform
(
account
*
Account
,
platform
string
,
useMixed
bool
)
bool
{
func
(
s
*
GatewayService
)
isAccountAllowedForPlatform
(
account
*
Account
,
platform
string
,
useMixed
bool
)
bool
{
if
account
==
nil
{
if
account
==
nil
{
return
false
return
false
...
@@ -2673,6 +2690,60 @@ func hasClaudeCodePrefix(text string) bool {
...
@@ -2673,6 +2690,60 @@ func hasClaudeCodePrefix(text string) bool {
return
false
return
false
}
}
// matchesFilterPrefix 检查文本是否匹配任一过滤前缀
func
matchesFilterPrefix
(
text
string
)
bool
{
for
_
,
prefix
:=
range
systemBlockFilterPrefixes
{
if
strings
.
HasPrefix
(
text
,
prefix
)
{
return
true
}
}
return
false
}
// filterSystemBlocksByPrefix 从 body 的 system 中移除文本匹配 systemBlockFilterPrefixes 前缀的元素
// 直接从 body 解析 system,不依赖外部传入的 parsed.System(因为前置步骤可能已修改 body 中的 system)
func
filterSystemBlocksByPrefix
(
body
[]
byte
)
[]
byte
{
sys
:=
gjson
.
GetBytes
(
body
,
"system"
)
if
!
sys
.
Exists
()
{
return
body
}
switch
{
case
sys
.
Type
==
gjson
.
String
:
if
matchesFilterPrefix
(
sys
.
Str
)
{
result
,
err
:=
sjson
.
DeleteBytes
(
body
,
"system"
)
if
err
!=
nil
{
return
body
}
return
result
}
case
sys
.
IsArray
()
:
var
parsed
[]
any
if
err
:=
json
.
Unmarshal
([]
byte
(
sys
.
Raw
),
&
parsed
);
err
!=
nil
{
return
body
}
filtered
:=
make
([]
any
,
0
,
len
(
parsed
))
changed
:=
false
for
_
,
item
:=
range
parsed
{
if
m
,
ok
:=
item
.
(
map
[
string
]
any
);
ok
{
if
text
,
ok
:=
m
[
"text"
]
.
(
string
);
ok
&&
matchesFilterPrefix
(
text
)
{
changed
=
true
continue
}
}
filtered
=
append
(
filtered
,
item
)
}
if
changed
{
result
,
err
:=
sjson
.
SetBytes
(
body
,
"system"
,
filtered
)
if
err
!=
nil
{
return
body
}
return
result
}
}
return
body
}
// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
// 处理 null、字符串、数组三种格式
// 处理 null、字符串、数组三种格式
func
injectClaudeCodePrompt
(
body
[]
byte
,
system
any
)
[]
byte
{
func
injectClaudeCodePrompt
(
body
[]
byte
,
system
any
)
[]
byte
{
...
@@ -2952,6 +3023,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -2952,6 +3023,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
body
,
reqModel
=
normalizeClaudeOAuthRequestBody
(
body
,
reqModel
,
normalizeOpts
)
body
,
reqModel
=
normalizeClaudeOAuthRequestBody
(
body
,
reqModel
,
normalizeOpts
)
}
}
// OAuth/SetupToken 账号:移除黑名单前缀匹配的 system 元素(如客户端注入的计费元数据)
// 放在 inject/normalize 之后,确保不会被覆盖
if
account
.
IsOAuth
()
{
body
=
filterSystemBlocksByPrefix
(
body
)
}
// 强制执行 cache_control 块数量限制(最多 4 个)
// 强制执行 cache_control 块数量限制(最多 4 个)
body
=
enforceCacheControlLimit
(
body
)
body
=
enforceCacheControlLimit
(
body
)
...
...
backend/internal/service/gemini_messages_compat_service.go
View file @
1dd3158c
...
@@ -770,6 +770,14 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
...
@@ -770,6 +770,14 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
break
break
}
}
// 错误策略优先:匹配则跳过重试直接处理。
if
matched
,
rebuilt
:=
s
.
checkErrorPolicyInLoop
(
ctx
,
account
,
resp
);
matched
{
resp
=
rebuilt
break
}
else
{
resp
=
rebuilt
}
if
resp
.
StatusCode
>=
400
&&
s
.
shouldRetryGeminiUpstreamError
(
account
,
resp
.
StatusCode
)
{
if
resp
.
StatusCode
>=
400
&&
s
.
shouldRetryGeminiUpstreamError
(
account
,
resp
.
StatusCode
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
_
=
resp
.
Body
.
Close
()
...
@@ -839,7 +847,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
...
@@ -839,7 +847,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
if
upstreamReqID
==
""
{
if
upstreamReqID
==
""
{
upstreamReqID
=
resp
.
Header
.
Get
(
"x-goog-request-id"
)
upstreamReqID
=
resp
.
Header
.
Get
(
"x-goog-request-id"
)
}
}
return
nil
,
s
.
writeGeminiMappedError
(
c
,
account
,
res
p
.
Status
Code
,
upstreamReqID
,
respBody
)
return
nil
,
s
.
writeGeminiMappedError
(
c
,
account
,
htt
p
.
Status
InternalServerError
,
upstreamReqID
,
respBody
)
case
ErrorPolicyMatched
,
ErrorPolicyTempUnscheduled
:
case
ErrorPolicyMatched
,
ErrorPolicyTempUnscheduled
:
s
.
handleGeminiUpstreamError
(
ctx
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
)
s
.
handleGeminiUpstreamError
(
ctx
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
)
upstreamReqID
:=
resp
.
Header
.
Get
(
requestIDHeader
)
upstreamReqID
:=
resp
.
Header
.
Get
(
requestIDHeader
)
...
@@ -1176,6 +1184,14 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
...
@@ -1176,6 +1184,14 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
return
nil
,
s
.
writeGoogleError
(
c
,
http
.
StatusBadGateway
,
"Upstream request failed after retries: "
+
safeErr
)
return
nil
,
s
.
writeGoogleError
(
c
,
http
.
StatusBadGateway
,
"Upstream request failed after retries: "
+
safeErr
)
}
}
// 错误策略优先:匹配则跳过重试直接处理。
if
matched
,
rebuilt
:=
s
.
checkErrorPolicyInLoop
(
ctx
,
account
,
resp
);
matched
{
resp
=
rebuilt
break
}
else
{
resp
=
rebuilt
}
if
resp
.
StatusCode
>=
400
&&
s
.
shouldRetryGeminiUpstreamError
(
account
,
resp
.
StatusCode
)
{
if
resp
.
StatusCode
>=
400
&&
s
.
shouldRetryGeminiUpstreamError
(
account
,
resp
.
StatusCode
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
_
=
resp
.
Body
.
Close
()
...
@@ -1283,7 +1299,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
...
@@ -1283,7 +1299,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
if
contentType
==
""
{
if
contentType
==
""
{
contentType
=
"application/json"
contentType
=
"application/json"
}
}
c
.
Data
(
res
p
.
Status
Code
,
contentType
,
respBody
)
c
.
Data
(
htt
p
.
Status
InternalServerError
,
contentType
,
respBody
)
return
nil
,
fmt
.
Errorf
(
"gemini upstream error: %d (skipped by error policy)"
,
resp
.
StatusCode
)
return
nil
,
fmt
.
Errorf
(
"gemini upstream error: %d (skipped by error policy)"
,
resp
.
StatusCode
)
case
ErrorPolicyMatched
,
ErrorPolicyTempUnscheduled
:
case
ErrorPolicyMatched
,
ErrorPolicyTempUnscheduled
:
s
.
handleGeminiUpstreamError
(
ctx
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
)
s
.
handleGeminiUpstreamError
(
ctx
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
respBody
)
...
@@ -1425,6 +1441,26 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
...
@@ -1425,6 +1441,26 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
},
nil
},
nil
}
}
// checkErrorPolicyInLoop 在重试循环内预检查错误策略。
// 返回 true 表示策略已匹配(调用者应 break),resp 已重建可直接使用。
// 返回 false 表示 ErrorPolicyNone,resp 已重建,调用者继续走重试逻辑。
func
(
s
*
GeminiMessagesCompatService
)
checkErrorPolicyInLoop
(
ctx
context
.
Context
,
account
*
Account
,
resp
*
http
.
Response
,
)
(
matched
bool
,
rebuilt
*
http
.
Response
)
{
if
resp
.
StatusCode
<
400
||
s
.
rateLimitService
==
nil
{
return
false
,
resp
}
body
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
rebuilt
=
&
http
.
Response
{
StatusCode
:
resp
.
StatusCode
,
Header
:
resp
.
Header
.
Clone
(),
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
body
)),
}
policy
:=
s
.
rateLimitService
.
CheckErrorPolicy
(
ctx
,
account
,
resp
.
StatusCode
,
body
)
return
policy
!=
ErrorPolicyNone
,
rebuilt
}
func
(
s
*
GeminiMessagesCompatService
)
shouldRetryGeminiUpstreamError
(
account
*
Account
,
statusCode
int
)
bool
{
func
(
s
*
GeminiMessagesCompatService
)
shouldRetryGeminiUpstreamError
(
account
*
Account
,
statusCode
int
)
bool
{
switch
statusCode
{
switch
statusCode
{
case
429
,
500
,
502
,
503
,
504
,
529
:
case
429
,
500
,
502
,
503
,
504
,
529
:
...
@@ -2597,6 +2633,10 @@ func asInt(v any) (int, bool) {
...
@@ -2597,6 +2633,10 @@ func asInt(v any) (int, bool) {
}
}
func
(
s
*
GeminiMessagesCompatService
)
handleGeminiUpstreamError
(
ctx
context
.
Context
,
account
*
Account
,
statusCode
int
,
headers
http
.
Header
,
body
[]
byte
)
{
func
(
s
*
GeminiMessagesCompatService
)
handleGeminiUpstreamError
(
ctx
context
.
Context
,
account
*
Account
,
statusCode
int
,
headers
http
.
Header
,
body
[]
byte
)
{
// 遵守自定义错误码策略:未命中则跳过所有限流处理
if
!
account
.
ShouldHandleErrorCode
(
statusCode
)
{
return
}
if
s
.
rateLimitService
!=
nil
&&
(
statusCode
==
401
||
statusCode
==
403
||
statusCode
==
529
)
{
if
s
.
rateLimitService
!=
nil
&&
(
statusCode
==
401
||
statusCode
==
403
||
statusCode
==
529
)
{
s
.
rateLimitService
.
HandleUpstreamError
(
ctx
,
account
,
statusCode
,
headers
,
body
)
s
.
rateLimitService
.
HandleUpstreamError
(
ctx
,
account
,
statusCode
,
headers
,
body
)
return
return
...
...
backend/internal/service/ratelimit_service.go
View file @
1dd3158c
...
@@ -623,6 +623,10 @@ func (s *RateLimitService) ClearTempUnschedulable(ctx context.Context, accountID
...
@@ -623,6 +623,10 @@ func (s *RateLimitService) ClearTempUnschedulable(ctx context.Context, accountID
slog
.
Warn
(
"temp_unsched_cache_delete_failed"
,
"account_id"
,
accountID
,
"error"
,
err
)
slog
.
Warn
(
"temp_unsched_cache_delete_failed"
,
"account_id"
,
accountID
,
"error"
,
err
)
}
}
}
}
// 同时清除模型级别限流
if
err
:=
s
.
accountRepo
.
ClearModelRateLimits
(
ctx
,
accountID
);
err
!=
nil
{
slog
.
Warn
(
"clear_model_rate_limits_on_temp_unsched_reset_failed"
,
"account_id"
,
accountID
,
"error"
,
err
)
}
return
nil
return
nil
}
}
...
...
frontend/package.json
View file @
1dd3158c
...
@@ -17,7 +17,7 @@
...
@@ -17,7 +17,7 @@
"dependencies"
:
{
"dependencies"
:
{
"@lobehub/icons"
:
"^4.0.2"
,
"@lobehub/icons"
:
"^4.0.2"
,
"@vueuse/core"
:
"^10.7.0"
,
"@vueuse/core"
:
"^10.7.0"
,
"axios"
:
"^1.
6.2
"
,
"axios"
:
"^1.
13.5
"
,
"chart.js"
:
"^4.4.1"
,
"chart.js"
:
"^4.4.1"
,
"dompurify"
:
"^3.3.1"
,
"dompurify"
:
"^3.3.1"
,
"driver.js"
:
"^1.4.0"
,
"driver.js"
:
"^1.4.0"
,
...
...
frontend/pnpm-lock.yaml
View file @
1dd3158c
...
@@ -15,8 +15,8 @@ importers:
...
@@ -15,8 +15,8 @@ importers:
specifier
:
^10.7.0
specifier
:
^10.7.0
version
:
10.11.1(vue@3.5.26(typescript@5.6.3))
version
:
10.11.1(vue@3.5.26(typescript@5.6.3))
axios
:
axios
:
specifier
:
^1.
6.2
specifier
:
^1.
13.5
version
:
1.13.
2
version
:
1.13.
5
chart.js
:
chart.js
:
specifier
:
^4.4.1
specifier
:
^4.4.1
version
:
4.5.1
version
:
4.5.1
...
@@ -1257,56 +1257,67 @@ packages:
...
@@ -1257,56 +1257,67 @@ packages:
resolution
:
{
integrity
:
sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==
}
resolution
:
{
integrity
:
sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==
}
cpu
:
[
arm
]
cpu
:
[
arm
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
glibc
]
'
@rollup/rollup-linux-arm-musleabihf@4.54.0'
:
'
@rollup/rollup-linux-arm-musleabihf@4.54.0'
:
resolution
:
{
integrity
:
sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==
}
resolution
:
{
integrity
:
sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==
}
cpu
:
[
arm
]
cpu
:
[
arm
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
musl
]
'
@rollup/rollup-linux-arm64-gnu@4.54.0'
:
'
@rollup/rollup-linux-arm64-gnu@4.54.0'
:
resolution
:
{
integrity
:
sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==
}
resolution
:
{
integrity
:
sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==
}
cpu
:
[
arm64
]
cpu
:
[
arm64
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
glibc
]
'
@rollup/rollup-linux-arm64-musl@4.54.0'
:
'
@rollup/rollup-linux-arm64-musl@4.54.0'
:
resolution
:
{
integrity
:
sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==
}
resolution
:
{
integrity
:
sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==
}
cpu
:
[
arm64
]
cpu
:
[
arm64
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
musl
]
'
@rollup/rollup-linux-loong64-gnu@4.54.0'
:
'
@rollup/rollup-linux-loong64-gnu@4.54.0'
:
resolution
:
{
integrity
:
sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==
}
resolution
:
{
integrity
:
sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==
}
cpu
:
[
loong64
]
cpu
:
[
loong64
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
glibc
]
'
@rollup/rollup-linux-ppc64-gnu@4.54.0'
:
'
@rollup/rollup-linux-ppc64-gnu@4.54.0'
:
resolution
:
{
integrity
:
sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==
}
resolution
:
{
integrity
:
sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==
}
cpu
:
[
ppc64
]
cpu
:
[
ppc64
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
glibc
]
'
@rollup/rollup-linux-riscv64-gnu@4.54.0'
:
'
@rollup/rollup-linux-riscv64-gnu@4.54.0'
:
resolution
:
{
integrity
:
sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==
}
resolution
:
{
integrity
:
sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==
}
cpu
:
[
riscv64
]
cpu
:
[
riscv64
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
glibc
]
'
@rollup/rollup-linux-riscv64-musl@4.54.0'
:
'
@rollup/rollup-linux-riscv64-musl@4.54.0'
:
resolution
:
{
integrity
:
sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==
}
resolution
:
{
integrity
:
sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==
}
cpu
:
[
riscv64
]
cpu
:
[
riscv64
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
musl
]
'
@rollup/rollup-linux-s390x-gnu@4.54.0'
:
'
@rollup/rollup-linux-s390x-gnu@4.54.0'
:
resolution
:
{
integrity
:
sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==
}
resolution
:
{
integrity
:
sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==
}
cpu
:
[
s390x
]
cpu
:
[
s390x
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
glibc
]
'
@rollup/rollup-linux-x64-gnu@4.54.0'
:
'
@rollup/rollup-linux-x64-gnu@4.54.0'
:
resolution
:
{
integrity
:
sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==
}
resolution
:
{
integrity
:
sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==
}
cpu
:
[
x64
]
cpu
:
[
x64
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
glibc
]
'
@rollup/rollup-linux-x64-musl@4.54.0'
:
'
@rollup/rollup-linux-x64-musl@4.54.0'
:
resolution
:
{
integrity
:
sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==
}
resolution
:
{
integrity
:
sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==
}
cpu
:
[
x64
]
cpu
:
[
x64
]
os
:
[
linux
]
os
:
[
linux
]
libc
:
[
musl
]
'
@rollup/rollup-openharmony-arm64@4.54.0'
:
'
@rollup/rollup-openharmony-arm64@4.54.0'
:
resolution
:
{
integrity
:
sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==
}
resolution
:
{
integrity
:
sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==
}
...
@@ -1805,8 +1816,8 @@ packages:
...
@@ -1805,8 +1816,8 @@ packages:
peerDependencies
:
peerDependencies
:
postcss
:
^8.1.0
postcss
:
^8.1.0
axios@1.13.
2
:
axios@1.13.
5
:
resolution
:
{
integrity
:
sha512-
VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA
==
}
resolution
:
{
integrity
:
sha512-
cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q
==
}
babel-plugin-macros@3.1.0
:
babel-plugin-macros@3.1.0
:
resolution
:
{
integrity
:
sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==
}
resolution
:
{
integrity
:
sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==
}
...
@@ -6387,7 +6398,7 @@ snapshots:
...
@@ -6387,7 +6398,7 @@ snapshots:
postcss
:
8.5.6
postcss
:
8.5.6
postcss-value-parser
:
4.2.0
postcss-value-parser
:
4.2.0
axios@1.13.
2
:
axios@1.13.
5
:
dependencies
:
dependencies
:
follow-redirects
:
1.15.11
follow-redirects
:
1.15.11
form-data
:
4.0.5
form-data
:
4.0.5
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
1dd3158c
...
@@ -665,8 +665,8 @@
...
@@ -665,8 +665,8 @@
<Icon
name=
"cloud"
size=
"sm"
/>
<Icon
name=
"cloud"
size=
"sm"
/>
</div>
</div>
<div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.types.upstream
'
)
}}
</span>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
API Key
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.
upstreamDesc
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.
antigravityApikey
'
)
}}
</span>
</div>
</div>
</button>
</button>
</div>
</div>
...
@@ -681,7 +681,7 @@
...
@@ -681,7 +681,7 @@
type=
"text"
type=
"text"
required
required
class=
"input"
class=
"input"
placeholder=
"https://
s.konstants.xyz
"
placeholder=
"https://
cloudcode-pa.googleapis.com
"
/>
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.upstream.baseUrlHint
'
)
}}
</p>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.upstream.baseUrlHint
'
)
}}
</p>
</div>
</div>
...
@@ -816,8 +816,8 @@
...
@@ -816,8 +816,8 @@
</div>
</div>
</div>
</div>
<!-- API Key input (only for apikey type) -->
<!-- API Key input (only for apikey type
, excluding Antigravity which has its own fields
) -->
<div
v-if=
"form.type === 'apikey'"
class=
"space-y-4"
>
<div
v-if=
"form.type === 'apikey'
&& form.platform !== 'antigravity'
"
class=
"space-y-4"
>
<div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.baseUrl
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.baseUrl
'
)
}}
</label>
<input
<input
...
@@ -862,7 +862,7 @@
...
@@ -862,7 +862,7 @@
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.gemini.tier.aiStudioHint
'
)
}}
</p>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.gemini.tier.aiStudioHint
'
)
}}
</p>
</div>
</div>
<!-- Model Restriction Section (不适用于 Gemini) -->
<!-- Model Restriction Section (不适用于 Gemini
,Antigravity 已在上层条件排除
) -->
<div
v-if=
"form.platform !== 'gemini'"
class=
"border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div
v-if=
"form.platform !== 'gemini'"
class=
"border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
</label>
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
1dd3158c
...
@@ -39,7 +39,9 @@
...
@@ -39,7 +39,9 @@
? 'https://api.openai.com'
? 'https://api.openai.com'
: account.platform === 'gemini'
: account.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: account.platform === 'antigravity'
? 'https://cloudcode-pa.googleapis.com'
: 'https://api.anthropic.com'
"
"
/>
/>
<p
class=
"input-hint"
>
{{
baseUrlHint
}}
</p>
<p
class=
"input-hint"
>
{{
baseUrlHint
}}
</p>
...
@@ -55,14 +57,16 @@
...
@@ -55,14 +57,16 @@
? 'sk-proj-...'
? 'sk-proj-...'
: account.platform === 'gemini'
: account.platform === 'gemini'
? 'AIza...'
? 'AIza...'
: 'sk-ant-...'
: account.platform === 'antigravity'
? 'sk-...'
: 'sk-ant-...'
"
"
/>
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.leaveEmptyToKeep
'
)
}}
</p>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.leaveEmptyToKeep
'
)
}}
</p>
</div>
</div>
<!-- Model Restriction Section (不适用于 Gemini) -->
<!-- Model Restriction Section (不适用于 Gemini
和 Antigravity
) -->
<div
v-if=
"account.platform !== 'gemini'"
class=
"border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div
v-if=
"account.platform !== 'gemini'
&& account.platform !== 'antigravity'
"
class=
"border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
</label>
<!-- Mode Toggle -->
<!-- Mode Toggle -->
...
@@ -372,7 +376,7 @@
...
@@ -372,7 +376,7 @@
v
-
model
=
"
editBaseUrl
"
v
-
model
=
"
editBaseUrl
"
type
=
"
text
"
type
=
"
text
"
class
=
"
input
"
class
=
"
input
"
placeholder
=
"
https://
s.konstants.xyz
"
placeholder
=
"
https://
cloudcode-pa.googleapis.com
"
/>
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.upstream.baseUrlHint
'
)
}}
<
/p
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.upstream.baseUrlHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
...
...
frontend/src/components/admin/account/AccountActionMenu.vue
View file @
1dd3158c
...
@@ -53,7 +53,19 @@ import type { Account } from '@/types'
...
@@ -53,7 +53,19 @@ import type { Account } from '@/types'
const
props
=
defineProps
<
{
show
:
boolean
;
account
:
Account
|
null
;
position
:
{
top
:
number
;
left
:
number
}
|
null
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
;
account
:
Account
|
null
;
position
:
{
top
:
number
;
left
:
number
}
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
isRateLimited
=
computed
(()
=>
props
.
account
?.
rate_limit_reset_at
&&
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
())
const
isRateLimited
=
computed
(()
=>
{
if
(
props
.
account
?.
rate_limit_reset_at
&&
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
())
{
return
true
}
const
modelLimits
=
(
props
.
account
?.
extra
as
Record
<
string
,
unknown
>
|
undefined
)?.
model_rate_limits
as
|
Record
<
string
,
{
rate_limit_reset_at
:
string
}
>
|
undefined
if
(
modelLimits
)
{
const
now
=
new
Date
()
return
Object
.
values
(
modelLimits
).
some
(
info
=>
new
Date
(
info
.
rate_limit_reset_at
)
>
now
)
}
return
false
})
const
isOverloaded
=
computed
(()
=>
props
.
account
?.
overload_until
&&
new
Date
(
props
.
account
.
overload_until
)
>
new
Date
())
const
isOverloaded
=
computed
(()
=>
props
.
account
?.
overload_until
&&
new
Date
(
props
.
account
.
overload_until
)
>
new
Date
())
const
handleKeydown
=
(
event
:
KeyboardEvent
)
=>
{
const
handleKeydown
=
(
event
:
KeyboardEvent
)
=>
{
...
...
frontend/src/i18n/locales/en.ts
View file @
1dd3158c
...
@@ -1359,6 +1359,7 @@ export default {
...
@@ -1359,6 +1359,7 @@ export default {
googleOauth
:
'
Google OAuth
'
,
googleOauth
:
'
Google OAuth
'
,
codeAssist
:
'
Code Assist
'
,
codeAssist
:
'
Code Assist
'
,
antigravityOauth
:
'
Antigravity OAuth
'
,
antigravityOauth
:
'
Antigravity OAuth
'
,
antigravityApikey
:
'
Connect via Base URL + API Key
'
,
upstream
:
'
Upstream
'
,
upstream
:
'
Upstream
'
,
upstreamDesc
:
'
Connect via Base URL + API Key
'
upstreamDesc
:
'
Connect via Base URL + API Key
'
},
},
...
@@ -1625,7 +1626,7 @@ export default {
...
@@ -1625,7 +1626,7 @@ export default {
// Upstream type
// Upstream type
upstream
:
{
upstream
:
{
baseUrl
:
'
Upstream Base URL
'
,
baseUrl
:
'
Upstream Base URL
'
,
baseUrlHint
:
'
The address of the upstream Antigravity service, e.g., https://
s.konstants.xyz
'
,
baseUrlHint
:
'
The address of the upstream Antigravity service, e.g., https://
cloudcode-pa.googleapis.com
'
,
apiKey
:
'
Upstream API Key
'
,
apiKey
:
'
Upstream API Key
'
,
apiKeyHint
:
'
API Key for the upstream service
'
,
apiKeyHint
:
'
API Key for the upstream service
'
,
pleaseEnterBaseUrl
:
'
Please enter upstream Base URL
'
,
pleaseEnterBaseUrl
:
'
Please enter upstream Base URL
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
1dd3158c
...
@@ -1493,6 +1493,7 @@ export default {
...
@@ -1493,6 +1493,7 @@ export default {
googleOauth
:
'
Google OAuth
'
,
googleOauth
:
'
Google OAuth
'
,
codeAssist
:
'
Code Assist
'
,
codeAssist
:
'
Code Assist
'
,
antigravityOauth
:
'
Antigravity OAuth
'
,
antigravityOauth
:
'
Antigravity OAuth
'
,
antigravityApikey
:
'
通过 Base URL + API Key 连接
'
,
upstream
:
'
对接上游
'
,
upstream
:
'
对接上游
'
,
upstreamDesc
:
'
通过 Base URL + API Key 连接上游
'
,
upstreamDesc
:
'
通过 Base URL + API Key 连接上游
'
,
api_key
:
'
API Key
'
,
api_key
:
'
API Key
'
,
...
@@ -1771,7 +1772,7 @@ export default {
...
@@ -1771,7 +1772,7 @@ export default {
// Upstream type
// Upstream type
upstream
:
{
upstream
:
{
baseUrl
:
'
上游 Base URL
'
,
baseUrl
:
'
上游 Base URL
'
,
baseUrlHint
:
'
上游 Antigravity 服务的地址,例如:https://
s.konstants.xyz
'
,
baseUrlHint
:
'
上游 Antigravity 服务的地址,例如:https://
cloudcode-pa.googleapis.com
'
,
apiKey
:
'
上游 API Key
'
,
apiKey
:
'
上游 API Key
'
,
apiKeyHint
:
'
上游服务的 API Key
'
,
apiKeyHint
:
'
上游服务的 API Key
'
,
pleaseEnterBaseUrl
:
'
请输入上游 Base URL
'
,
pleaseEnterBaseUrl
:
'
请输入上游 Base URL
'
,
...
...
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