mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-05 16:00:21 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dd83d3cf2 | ||
|
|
14e1aac9b5 | ||
|
|
aa4b102108 | ||
|
|
e4bc35151f | ||
|
|
56da498b7e | ||
|
|
1bba1a62b1 | ||
|
|
4a84ca9a02 | ||
|
|
a70d37a676 | ||
|
|
6892e84ad2 | ||
|
|
73f455745c | ||
|
|
021abfca18 | ||
|
|
7d66f7ff0d | ||
|
|
470b37be7e | ||
|
|
f6cfab9901 | ||
|
|
51572b5da0 | ||
|
|
91ca28b7e3 | ||
|
|
04cedce9a1 | ||
|
|
5e0d789440 |
@@ -424,10 +424,17 @@ type TestAccountRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SyncFromCRSRequest struct {
|
type SyncFromCRSRequest struct {
|
||||||
BaseURL string `json:"base_url" binding:"required"`
|
BaseURL string `json:"base_url" binding:"required"`
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
SyncProxies *bool `json:"sync_proxies"`
|
SyncProxies *bool `json:"sync_proxies"`
|
||||||
|
SelectedAccountIDs []string `json:"selected_account_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreviewFromCRSRequest struct {
|
||||||
|
BaseURL string `json:"base_url" binding:"required"`
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test handles testing account connectivity with SSE streaming
|
// Test handles testing account connectivity with SSE streaming
|
||||||
@@ -466,10 +473,11 @@ func (h *AccountHandler) SyncFromCRS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{
|
result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{
|
||||||
BaseURL: req.BaseURL,
|
BaseURL: req.BaseURL,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
SyncProxies: syncProxies,
|
SyncProxies: syncProxies,
|
||||||
|
SelectedAccountIDs: req.SelectedAccountIDs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Provide detailed error message for CRS sync failures
|
// Provide detailed error message for CRS sync failures
|
||||||
@@ -480,6 +488,28 @@ func (h *AccountHandler) SyncFromCRS(c *gin.Context) {
|
|||||||
response.Success(c, result)
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreviewFromCRS handles previewing accounts from CRS before sync
|
||||||
|
// POST /api/v1/admin/accounts/sync/crs/preview
|
||||||
|
func (h *AccountHandler) PreviewFromCRS(c *gin.Context) {
|
||||||
|
var req PreviewFromCRSRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.crsSyncService.PreviewFromCRS(c.Request.Context(), service.SyncFromCRSInput{
|
||||||
|
BaseURL: req.BaseURL,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.InternalError(c, "CRS preview failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh handles refreshing account credentials
|
// Refresh handles refreshing account credentials
|
||||||
// POST /api/v1/admin/accounts/:id/refresh
|
// POST /api/v1/admin/accounts/:id/refresh
|
||||||
func (h *AccountHandler) Refresh(c *gin.Context) {
|
func (h *AccountHandler) Refresh(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 {
|
||||||
@@ -245,6 +252,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), 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 {
|
||||||
@@ -396,6 +416,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) {
|
|||||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), 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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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) {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|||||||
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
|
|||||||
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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -282,6 +282,34 @@ func (r *accountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID
|
|||||||
return &accounts[0], nil
|
return &accounts[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *accountRepository) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
|
rows, err := r.sql.QueryContext(ctx, `
|
||||||
|
SELECT id, extra->>'crs_account_id'
|
||||||
|
FROM accounts
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND extra->>'crs_account_id' IS NOT NULL
|
||||||
|
AND extra->>'crs_account_id' != ''
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
result := make(map[string]int64)
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var crsID string
|
||||||
|
if err := rows.Scan(&id, &crsID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[crsID] = id
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *accountRepository) Update(ctx context.Context, account *service.Account) error {
|
func (r *accountRepository) Update(ctx context.Context, account *service.Account) error {
|
||||||
if account == nil {
|
if account == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1049,6 +1049,10 @@ func (s *stubAccountRepo) BulkUpdate(ctx context.Context, ids []int64, updates s
|
|||||||
return int64(len(ids)), nil
|
return int64(len(ids)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubAccountRepo) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
type stubProxyRepo struct{}
|
type stubProxyRepo struct{}
|
||||||
|
|
||||||
func (stubProxyRepo) Create(ctx context.Context, proxy *service.Proxy) error {
|
func (stubProxyRepo) Create(ctx context.Context, proxy *service.Proxy) error {
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
accounts.GET("/:id", h.Admin.Account.GetByID)
|
accounts.GET("/:id", h.Admin.Account.GetByID)
|
||||||
accounts.POST("", h.Admin.Account.Create)
|
accounts.POST("", h.Admin.Account.Create)
|
||||||
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
|
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
|
||||||
|
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
|
||||||
accounts.PUT("/:id", h.Admin.Account.Update)
|
accounts.PUT("/:id", h.Admin.Account.Update)
|
||||||
accounts.DELETE("/:id", h.Admin.Account.Delete)
|
accounts.DELETE("/:id", h.Admin.Account.Delete)
|
||||||
accounts.POST("/:id/test", h.Admin.Account.Test)
|
accounts.POST("/:id/test", h.Admin.Account.Test)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ type AccountRepository interface {
|
|||||||
// GetByCRSAccountID finds an account previously synced from CRS.
|
// GetByCRSAccountID finds an account previously synced from CRS.
|
||||||
// Returns (nil, nil) if not found.
|
// Returns (nil, nil) if not found.
|
||||||
GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error)
|
GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error)
|
||||||
|
// ListCRSAccountIDs returns a map of crs_account_id -> local account ID
|
||||||
|
// for all accounts that have been synced from CRS.
|
||||||
|
ListCRSAccountIDs(ctx context.Context) (map[string]int64, error)
|
||||||
Update(ctx context.Context, account *Account) error
|
Update(ctx context.Context, account *Account) error
|
||||||
Delete(ctx context.Context, id int64) error
|
Delete(ctx context.Context, id int64) error
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ func (s *accountRepoStub) GetByCRSAccountID(ctx context.Context, crsAccountID st
|
|||||||
panic("unexpected GetByCRSAccountID call")
|
panic("unexpected GetByCRSAccountID call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *accountRepoStub) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
|
panic("unexpected ListCRSAccountIDs call")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *accountRepoStub) Update(ctx context.Context, account *Account) error {
|
func (s *accountRepoStub) Update(ctx context.Context, account *Account) error {
|
||||||
panic("unexpected Update call")
|
panic("unexpected Update call")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|||||||
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
|
|||||||
|
|
||||||
// 情况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
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有重试都失败,限流当前模型并切换账号
|
// 所有重试都失败
|
||||||
rateLimitDuration := waitDuration
|
rateLimitDuration := waitDuration
|
||||||
if rateLimitDuration <= 0 {
|
if rateLimitDuration <= 0 {
|
||||||
rateLimitDuration = antigravityDefaultRateLimitDuration
|
rateLimitDuration = antigravityDefaultRateLimitDuration
|
||||||
@@ -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
|
|||||||
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:
|
|||||||
_ = 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.StatusCode,
|
StatusCode: outStatus,
|
||||||
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
|
|||||||
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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
|||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,902 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 辅助函数:构造带 SingleAccountRetry 标记的 context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func ctxWithSingleAccountRetry() context.Context {
|
||||||
|
return context.WithValue(context.Background(), ctxkey.SingleAccountRetry, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. isSingleAccountRetry 测试
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIsSingleAccountRetry_True(t *testing.T) {
|
||||||
|
ctx := context.WithValue(context.Background(), ctxkey.SingleAccountRetry, true)
|
||||||
|
require.True(t, isSingleAccountRetry(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSingleAccountRetry_False_NoValue(t *testing.T) {
|
||||||
|
require.False(t, isSingleAccountRetry(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSingleAccountRetry_False_ExplicitFalse(t *testing.T) {
|
||||||
|
ctx := context.WithValue(context.Background(), ctxkey.SingleAccountRetry, false)
|
||||||
|
require.False(t, isSingleAccountRetry(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSingleAccountRetry_False_WrongType(t *testing.T) {
|
||||||
|
ctx := context.WithValue(context.Background(), ctxkey.SingleAccountRetry, "true")
|
||||||
|
require.False(t, isSingleAccountRetry(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. 常量验证
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSingleAccountRetryConstants(t *testing.T) {
|
||||||
|
require.Equal(t, 3, antigravitySingleAccountSmartRetryMaxAttempts,
|
||||||
|
"单账号原地重试最多 3 次")
|
||||||
|
require.Equal(t, 15*time.Second, antigravitySingleAccountSmartRetryMaxWait,
|
||||||
|
"单次最大等待 15s")
|
||||||
|
require.Equal(t, 30*time.Second, antigravitySingleAccountSmartRetryTotalMaxWait,
|
||||||
|
"总累计等待不超过 30s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. handleSmartRetry + 503 + SingleAccountRetry → 走 handleSingleAccountRetryInPlace
|
||||||
|
// (而非设模型限流 + 切换账号)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestHandleSmartRetry_503_LongDelay_SingleAccountRetry_RetryInPlace
|
||||||
|
// 核心场景:503 + retryDelay >= 7s + SingleAccountRetry 标记
|
||||||
|
// → 不设模型限流、不切换账号,改为原地重试
|
||||||
|
func TestHandleSmartRetry_503_LongDelay_SingleAccountRetry_RetryInPlace(t *testing.T) {
|
||||||
|
// 原地重试成功
|
||||||
|
successResp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)),
|
||||||
|
}
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
responses: []*http.Response{successResp},
|
||||||
|
errors: []error{nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &stubAntigravityAccountRepo{}
|
||||||
|
account := &Account{
|
||||||
|
ID: 1,
|
||||||
|
Name: "acc-single",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Concurrency: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 503 + 39s >= 7s 阈值 + MODEL_CAPACITY_EXHAUSTED
|
||||||
|
respBody := []byte(`{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "39s"}
|
||||||
|
],
|
||||||
|
"message": "No capacity available for model gemini-3-pro-high on the server"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(), // 关键:设置单账号标记
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
accountRepo: repo,
|
||||||
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availableURLs := []string{"https://ag-1.test"}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
// 关键断言:返回 resp(原地重试成功),而非 switchError(切换账号)
|
||||||
|
require.NotNil(t, result.resp, "should return successful response from in-place retry")
|
||||||
|
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||||
|
require.Nil(t, result.switchError, "should NOT return switchError in single account mode")
|
||||||
|
require.Nil(t, result.err)
|
||||||
|
|
||||||
|
// 验证未设模型限流(单账号模式不应设限流)
|
||||||
|
require.Len(t, repo.modelRateLimitCalls, 0,
|
||||||
|
"should NOT set model rate limit in single account retry mode")
|
||||||
|
|
||||||
|
// 验证确实调用了 upstream(原地重试)
|
||||||
|
require.GreaterOrEqual(t, len(upstream.calls), 1, "should have made at least one retry call")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSmartRetry_503_LongDelay_NoSingleAccountRetry_StillSwitches
|
||||||
|
// 对照组:503 + retryDelay >= 7s + 无 SingleAccountRetry 标记
|
||||||
|
// → 照常设模型限流 + 切换账号
|
||||||
|
func TestHandleSmartRetry_503_LongDelay_NoSingleAccountRetry_StillSwitches(t *testing.T) {
|
||||||
|
repo := &stubAntigravityAccountRepo{}
|
||||||
|
account := &Account{
|
||||||
|
ID: 2,
|
||||||
|
Name: "acc-multi",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 503 + 39s >= 7s 阈值
|
||||||
|
respBody := []byte(`{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "39s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: context.Background(), // 关键:无单账号标记
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
accountRepo: repo,
|
||||||
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availableURLs := []string{"https://ag-1.test"}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
// 对照:多账号模式返回 switchError
|
||||||
|
require.NotNil(t, result.switchError, "multi-account mode should return switchError for 503")
|
||||||
|
require.Nil(t, result.resp, "should not return resp when switchError is set")
|
||||||
|
|
||||||
|
// 对照:多账号模式应设模型限流
|
||||||
|
require.Len(t, repo.modelRateLimitCalls, 1,
|
||||||
|
"multi-account mode SHOULD set model rate limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSmartRetry_429_LongDelay_SingleAccountRetry_StillSwitches
|
||||||
|
// 边界情况:429(非 503)+ SingleAccountRetry 标记
|
||||||
|
// → 单账号原地重试仅针对 503,429 依然走切换账号逻辑
|
||||||
|
func TestHandleSmartRetry_429_LongDelay_SingleAccountRetry_StillSwitches(t *testing.T) {
|
||||||
|
repo := &stubAntigravityAccountRepo{}
|
||||||
|
account := &Account{
|
||||||
|
ID: 3,
|
||||||
|
Name: "acc-429",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429 + 15s >= 7s 阈值
|
||||||
|
respBody := []byte(`{
|
||||||
|
"error": {
|
||||||
|
"status": "RESOURCE_EXHAUSTED",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusTooManyRequests, // 429,不是 503
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(), // 有单账号标记
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
accountRepo: repo,
|
||||||
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availableURLs := []string{"https://ag-1.test"}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
// 429 即使有单账号标记,也应走切换账号
|
||||||
|
require.NotNil(t, result.switchError, "429 should still return switchError even with SingleAccountRetry")
|
||||||
|
require.Len(t, repo.modelRateLimitCalls, 1,
|
||||||
|
"429 should still set model rate limit even with SingleAccountRetry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. handleSmartRetry + 503 + 短延迟 + SingleAccountRetry → 智能重试耗尽后不设限流
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestHandleSmartRetry_503_ShortDelay_SingleAccountRetry_NoRateLimit
|
||||||
|
// 503 + retryDelay < 7s + SingleAccountRetry → 智能重试耗尽后直接返回 503,不设限流
|
||||||
|
func TestHandleSmartRetry_503_ShortDelay_SingleAccountRetry_NoRateLimit(t *testing.T) {
|
||||||
|
// 智能重试也返回 503
|
||||||
|
failRespBody := `{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
failResp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(failRespBody)),
|
||||||
|
}
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
responses: []*http.Response{failResp},
|
||||||
|
errors: []error{nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &stubAntigravityAccountRepo{}
|
||||||
|
account := &Account{
|
||||||
|
ID: 4,
|
||||||
|
Name: "acc-short-503",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.1s < 7s 阈值
|
||||||
|
respBody := []byte(`{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(),
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
accountRepo: repo,
|
||||||
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availableURLs := []string{"https://ag-1.test"}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
// 关键断言:单账号 503 模式下,智能重试耗尽后直接返回 503 响应,不切换
|
||||||
|
require.NotNil(t, result.resp, "should return 503 response directly for single account mode")
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, result.resp.StatusCode)
|
||||||
|
require.Nil(t, result.switchError, "should NOT switch account in single account mode")
|
||||||
|
|
||||||
|
// 关键断言:不设模型限流
|
||||||
|
require.Len(t, repo.modelRateLimitCalls, 0,
|
||||||
|
"should NOT set model rate limit for 503 in single account mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSmartRetry_503_ShortDelay_NoSingleAccountRetry_SetsRateLimit
|
||||||
|
// 对照组:503 + retryDelay < 7s + 无 SingleAccountRetry → 智能重试耗尽后照常设限流
|
||||||
|
func TestHandleSmartRetry_503_ShortDelay_NoSingleAccountRetry_SetsRateLimit(t *testing.T) {
|
||||||
|
failRespBody := `{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
failResp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(failRespBody)),
|
||||||
|
}
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
responses: []*http.Response{failResp},
|
||||||
|
errors: []error{nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &stubAntigravityAccountRepo{}
|
||||||
|
account := &Account{
|
||||||
|
ID: 5,
|
||||||
|
Name: "acc-multi-503",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody := []byte(`{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: context.Background(), // 无单账号标记
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
accountRepo: repo,
|
||||||
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availableURLs := []string{"https://ag-1.test"}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
// 对照:多账号模式应返回 switchError
|
||||||
|
require.NotNil(t, result.switchError, "multi-account mode should return switchError for 503")
|
||||||
|
// 对照:多账号模式应设模型限流
|
||||||
|
require.Len(t, repo.modelRateLimitCalls, 1,
|
||||||
|
"multi-account mode should set model rate limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. handleSingleAccountRetryInPlace 直接测试
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestHandleSingleAccountRetryInPlace_Success 原地重试成功
|
||||||
|
func TestHandleSingleAccountRetryInPlace_Success(t *testing.T) {
|
||||||
|
successResp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)),
|
||||||
|
}
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
responses: []*http.Response{successResp},
|
||||||
|
errors: []error{nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 10,
|
||||||
|
Name: "acc-inplace-ok",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Concurrency: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(),
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSingleAccountRetryInPlace(params, resp, nil, "https://ag-1.test", 1*time.Second, "gemini-3-pro")
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
require.NotNil(t, result.resp, "should return successful response")
|
||||||
|
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||||
|
require.Nil(t, result.switchError, "should not switch account on success")
|
||||||
|
require.Nil(t, result.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSingleAccountRetryInPlace_AllRetriesFail 所有重试都失败,返回 503(不设限流)
|
||||||
|
func TestHandleSingleAccountRetryInPlace_AllRetriesFail(t *testing.T) {
|
||||||
|
// 构造 3 个 503 响应(对应 3 次原地重试)
|
||||||
|
var responses []*http.Response
|
||||||
|
var errors []error
|
||||||
|
for i := 0; i < antigravitySingleAccountSmartRetryMaxAttempts; i++ {
|
||||||
|
responses = append(responses, &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)),
|
||||||
|
})
|
||||||
|
errors = append(errors, nil)
|
||||||
|
}
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
responses: responses,
|
||||||
|
errors: errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 11,
|
||||||
|
Name: "acc-inplace-fail",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Concurrency: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
origBody := []byte(`{"error":{"code":503,"status":"UNAVAILABLE"}}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{"X-Test": {"original"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(),
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSingleAccountRetryInPlace(params, resp, origBody, "https://ag-1.test", 1*time.Second, "gemini-3-pro")
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
// 关键:返回 503 resp,不返回 switchError
|
||||||
|
require.NotNil(t, result.resp, "should return 503 response directly")
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, result.resp.StatusCode)
|
||||||
|
require.Nil(t, result.switchError, "should NOT return switchError - let Handler handle it")
|
||||||
|
require.Nil(t, result.err)
|
||||||
|
|
||||||
|
// 验证确实重试了指定次数
|
||||||
|
require.Len(t, upstream.calls, antigravitySingleAccountSmartRetryMaxAttempts,
|
||||||
|
"should have made exactly maxAttempts retry calls")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSingleAccountRetryInPlace_WaitDurationClamped 等待时间被限制在 [min, max] 范围
|
||||||
|
func TestHandleSingleAccountRetryInPlace_WaitDurationClamped(t *testing.T) {
|
||||||
|
// 用短延迟的成功响应,只验证不 panic
|
||||||
|
successResp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)),
|
||||||
|
}
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
responses: []*http.Response{successResp},
|
||||||
|
errors: []error{nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 12,
|
||||||
|
Name: "acc-clamp",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Concurrency: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(),
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
|
||||||
|
// 等待时间过大应被 clamp 到 antigravitySingleAccountSmartRetryMaxWait
|
||||||
|
result := svc.handleSingleAccountRetryInPlace(params, resp, nil, "https://ag-1.test", 999*time.Second, "gemini-3-pro")
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
require.NotNil(t, result.resp)
|
||||||
|
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSingleAccountRetryInPlace_ContextCanceled context 取消时立即返回
|
||||||
|
func TestHandleSingleAccountRetryInPlace_ContextCanceled(t *testing.T) {
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
responses: []*http.Response{nil},
|
||||||
|
errors: []error{nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 13,
|
||||||
|
Name: "acc-cancel",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Concurrency: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
ctx = context.WithValue(ctx, ctxkey.SingleAccountRetry, true)
|
||||||
|
cancel() // 立即取消
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctx,
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSingleAccountRetryInPlace(params, resp, nil, "https://ag-1.test", 1*time.Second, "gemini-3-pro")
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
require.Error(t, result.err, "should return context error")
|
||||||
|
// 不应调用 upstream(因为在等待阶段就被取消了)
|
||||||
|
require.Len(t, upstream.calls, 0, "should not call upstream when context is canceled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSingleAccountRetryInPlace_NetworkError_ContinuesRetry 网络错误时继续重试
|
||||||
|
func TestHandleSingleAccountRetryInPlace_NetworkError_ContinuesRetry(t *testing.T) {
|
||||||
|
successResp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)),
|
||||||
|
}
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
// 第1次网络错误(nil resp),第2次成功
|
||||||
|
responses: []*http.Response{nil, successResp},
|
||||||
|
errors: []error{nil, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 14,
|
||||||
|
Name: "acc-net-retry",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Concurrency: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(),
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSingleAccountRetryInPlace(params, resp, nil, "https://ag-1.test", 1*time.Second, "gemini-3-pro")
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
require.NotNil(t, result.resp, "should return successful response after network error recovery")
|
||||||
|
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||||
|
require.Len(t, upstream.calls, 2, "first call fails (network error), second succeeds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 6. antigravityRetryLoop 预检查:单账号模式跳过限流
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestAntigravityRetryLoop_PreCheck_SingleAccountRetry_SkipsRateLimit
|
||||||
|
// 预检查中,如果有 SingleAccountRetry 标记,即使账号已限流也跳过直接发请求
|
||||||
|
func TestAntigravityRetryLoop_PreCheck_SingleAccountRetry_SkipsRateLimit(t *testing.T) {
|
||||||
|
// 创建一个已设模型限流的账号
|
||||||
|
upstream := &recordingOKUpstream{}
|
||||||
|
account := &Account{
|
||||||
|
ID: 20,
|
||||||
|
Name: "acc-rate-limited",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Schedulable: true,
|
||||||
|
Status: StatusActive,
|
||||||
|
Concurrency: 1,
|
||||||
|
Extra: map[string]any{
|
||||||
|
modelRateLimitsKey: map[string]any{
|
||||||
|
"claude-sonnet-4-5": map[string]any{
|
||||||
|
"rate_limit_reset_at": time.Now().Add(30 * time.Second).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(),
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
requestedModel: "claude-sonnet-4-5",
|
||||||
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err, "should not return error")
|
||||||
|
require.NotNil(t, result, "should return result")
|
||||||
|
require.NotNil(t, result.resp, "should have response")
|
||||||
|
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||||
|
// 关键:尽管限流了,有 SingleAccountRetry 标记时仍然到达了 upstream
|
||||||
|
require.Equal(t, 1, upstream.calls, "should have reached upstream despite rate limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntigravityRetryLoop_PreCheck_NoSingleAccountRetry_SwitchesOnRateLimit
|
||||||
|
// 对照组:无 SingleAccountRetry + 已限流 → 预检查返回 switchError
|
||||||
|
func TestAntigravityRetryLoop_PreCheck_NoSingleAccountRetry_SwitchesOnRateLimit(t *testing.T) {
|
||||||
|
upstream := &recordingOKUpstream{}
|
||||||
|
account := &Account{
|
||||||
|
ID: 21,
|
||||||
|
Name: "acc-rate-limited-multi",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Schedulable: true,
|
||||||
|
Status: StatusActive,
|
||||||
|
Concurrency: 1,
|
||||||
|
Extra: map[string]any{
|
||||||
|
modelRateLimitsKey: map[string]any{
|
||||||
|
"claude-sonnet-4-5": map[string]any{
|
||||||
|
"rate_limit_reset_at": time.Now().Add(30 * time.Second).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||||
|
ctx: context.Background(), // 无单账号标记
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
requestedModel: "claude-sonnet-4-5",
|
||||||
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Nil(t, result, "should not return result on rate limit switch")
|
||||||
|
require.NotNil(t, err, "should return error")
|
||||||
|
|
||||||
|
var switchErr *AntigravityAccountSwitchError
|
||||||
|
require.ErrorAs(t, err, &switchErr, "should return AntigravityAccountSwitchError")
|
||||||
|
require.Equal(t, account.ID, switchErr.OriginalAccountID)
|
||||||
|
require.Equal(t, "claude-sonnet-4-5", switchErr.RateLimitedModel)
|
||||||
|
|
||||||
|
// upstream 不应被调用(预检查就短路了)
|
||||||
|
require.Equal(t, 0, upstream.calls, "upstream should NOT be called when pre-check blocks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 7. 端到端集成场景测试
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestHandleSmartRetry_503_SingleAccount_RetryInPlace_ThenSuccess_E2E
|
||||||
|
// 端到端场景:503 + 单账号 + 原地重试第2次成功
|
||||||
|
func TestHandleSmartRetry_503_SingleAccount_RetryInPlace_ThenSuccess_E2E(t *testing.T) {
|
||||||
|
// 第1次原地重试仍返回 503,第2次成功
|
||||||
|
fail503Body := `{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
resp503 := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(fail503Body)),
|
||||||
|
}
|
||||||
|
successResp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)),
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
responses: []*http.Response{resp503, successResp},
|
||||||
|
errors: []error{nil, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 30,
|
||||||
|
Name: "acc-e2e",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Concurrency: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
|
||||||
|
params := antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(),
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result := svc.handleSingleAccountRetryInPlace(params, resp, nil, "https://ag-1.test", 1*time.Second, "gemini-3-pro")
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||||
|
require.NotNil(t, result.resp, "should return successful response after 2nd attempt")
|
||||||
|
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||||
|
require.Nil(t, result.switchError)
|
||||||
|
require.Len(t, upstream.calls, 2, "first 503, second OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntigravityRetryLoop_503_SingleAccount_InPlaceRetryUsed_E2E
|
||||||
|
// 通过 antigravityRetryLoop → handleSmartRetry → handleSingleAccountRetryInPlace 完整链路
|
||||||
|
func TestAntigravityRetryLoop_503_SingleAccount_InPlaceRetryUsed_E2E(t *testing.T) {
|
||||||
|
// 初始请求返回 503 + 长延迟
|
||||||
|
initial503Body := []byte(`{
|
||||||
|
"error": {
|
||||||
|
"code": 503,
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"details": [
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||||
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "10s"}
|
||||||
|
],
|
||||||
|
"message": "No capacity available"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
initial503Resp := &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(initial503Body)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原地重试成功
|
||||||
|
successResp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)),
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream := &mockSmartRetryUpstream{
|
||||||
|
// 第1次调用(retryLoop 主循环)返回 503
|
||||||
|
// 第2次调用(handleSingleAccountRetryInPlace 原地重试)返回 200
|
||||||
|
responses: []*http.Response{initial503Resp, successResp},
|
||||||
|
errors: []error{nil, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &stubAntigravityAccountRepo{}
|
||||||
|
account := &Account{
|
||||||
|
ID: 31,
|
||||||
|
Name: "acc-e2e-loop",
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Schedulable: true,
|
||||||
|
Status: StatusActive,
|
||||||
|
Concurrency: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &AntigravityGatewayService{}
|
||||||
|
result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||||
|
ctx: ctxWithSingleAccountRetry(),
|
||||||
|
prefix: "[test]",
|
||||||
|
account: account,
|
||||||
|
accessToken: "token",
|
||||||
|
action: "generateContent",
|
||||||
|
body: []byte(`{"input":"test"}`),
|
||||||
|
httpUpstream: upstream,
|
||||||
|
accountRepo: repo,
|
||||||
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err, "should not return error on successful retry")
|
||||||
|
require.NotNil(t, result, "should return result")
|
||||||
|
require.NotNil(t, result.resp, "should return response")
|
||||||
|
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||||
|
|
||||||
|
// 验证未设模型限流
|
||||||
|
require.Len(t, repo.modelRateLimitCalls, 0,
|
||||||
|
"should NOT set model rate limit in single account retry mode")
|
||||||
|
}
|
||||||
112
backend/internal/service/crs_sync_helpers_test.go
Normal file
112
backend/internal/service/crs_sync_helpers_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildSelectedSet(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ids []string
|
||||||
|
wantNil bool
|
||||||
|
wantSize int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil input returns nil (backward compatible: create all)",
|
||||||
|
ids: nil,
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty slice returns empty map (create none)",
|
||||||
|
ids: []string{},
|
||||||
|
wantNil: false,
|
||||||
|
wantSize: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single ID",
|
||||||
|
ids: []string{"abc-123"},
|
||||||
|
wantNil: false,
|
||||||
|
wantSize: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple IDs",
|
||||||
|
ids: []string{"a", "b", "c"},
|
||||||
|
wantNil: false,
|
||||||
|
wantSize: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate IDs are deduplicated",
|
||||||
|
ids: []string{"a", "a", "b"},
|
||||||
|
wantNil: false,
|
||||||
|
wantSize: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := buildSelectedSet(tt.ids)
|
||||||
|
if tt.wantNil {
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("buildSelectedSet(%v) = %v, want nil", tt.ids, got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("buildSelectedSet(%v) = nil, want non-nil map", tt.ids)
|
||||||
|
}
|
||||||
|
if len(got) != tt.wantSize {
|
||||||
|
t.Errorf("buildSelectedSet(%v) has %d entries, want %d", tt.ids, len(got), tt.wantSize)
|
||||||
|
}
|
||||||
|
// Verify all unique IDs are present
|
||||||
|
for _, id := range tt.ids {
|
||||||
|
if _, ok := got[id]; !ok {
|
||||||
|
t.Errorf("buildSelectedSet(%v) missing key %q", tt.ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldCreateAccount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
crsID string
|
||||||
|
selectedSet map[string]struct{}
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil set allows all (backward compatible)",
|
||||||
|
crsID: "any-id",
|
||||||
|
selectedSet: nil,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty set blocks all",
|
||||||
|
crsID: "any-id",
|
||||||
|
selectedSet: map[string]struct{}{},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ID in set is allowed",
|
||||||
|
crsID: "abc-123",
|
||||||
|
selectedSet: map[string]struct{}{"abc-123": {}, "def-456": {}},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ID not in set is blocked",
|
||||||
|
crsID: "xyz-789",
|
||||||
|
selectedSet: map[string]struct{}{"abc-123": {}, "def-456": {}},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := shouldCreateAccount(tt.crsID, tt.selectedSet)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("shouldCreateAccount(%q, %v) = %v, want %v",
|
||||||
|
tt.crsID, tt.selectedSet, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,10 +45,11 @@ func NewCRSSyncService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SyncFromCRSInput struct {
|
type SyncFromCRSInput struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
SyncProxies bool
|
SyncProxies bool
|
||||||
|
SelectedAccountIDs []string // if non-empty, only create new accounts with these CRS IDs
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncFromCRSItemResult struct {
|
type SyncFromCRSItemResult struct {
|
||||||
@@ -190,25 +191,27 @@ type crsGeminiAPIKeyAccount struct {
|
|||||||
Extra map[string]any `json:"extra"`
|
Extra map[string]any `json:"extra"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) {
|
// fetchCRSExport validates the connection parameters, authenticates with CRS,
|
||||||
|
// and returns the exported accounts. Shared by SyncFromCRS and PreviewFromCRS.
|
||||||
|
func (s *CRSSyncService) fetchCRSExport(ctx context.Context, baseURL, username, password string) (*crsExportResponse, error) {
|
||||||
if s.cfg == nil {
|
if s.cfg == nil {
|
||||||
return nil, errors.New("config is not available")
|
return nil, errors.New("config is not available")
|
||||||
}
|
}
|
||||||
baseURL := strings.TrimSpace(input.BaseURL)
|
normalizedURL := strings.TrimSpace(baseURL)
|
||||||
if s.cfg.Security.URLAllowlist.Enabled {
|
if s.cfg.Security.URLAllowlist.Enabled {
|
||||||
normalized, err := normalizeBaseURL(baseURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts)
|
normalized, err := normalizeBaseURL(normalizedURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
baseURL = normalized
|
normalizedURL = normalized
|
||||||
} else {
|
} else {
|
||||||
normalized, err := urlvalidator.ValidateURLFormat(baseURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
normalized, err := urlvalidator.ValidateURLFormat(normalizedURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid base_url: %w", err)
|
return nil, fmt.Errorf("invalid base_url: %w", err)
|
||||||
}
|
}
|
||||||
baseURL = normalized
|
normalizedURL = normalized
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" {
|
if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" {
|
||||||
return nil, errors.New("username and password are required")
|
return nil, errors.New("username and password are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,12 +224,16 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
client = &http.Client{Timeout: 20 * time.Second}
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
}
|
}
|
||||||
|
|
||||||
adminToken, err := crsLogin(ctx, client, baseURL, input.Username, input.Password)
|
adminToken, err := crsLogin(ctx, client, normalizedURL, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
exported, err := crsExportAccounts(ctx, client, baseURL, adminToken)
|
return crsExportAccounts(ctx, client, normalizedURL, adminToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) {
|
||||||
|
exported, err := s.fetchCRSExport(ctx, input.BaseURL, input.Username, input.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -241,6 +248,8 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedSet := buildSelectedSet(input.SelectedAccountIDs)
|
||||||
|
|
||||||
var proxies []Proxy
|
var proxies []Proxy
|
||||||
if input.SyncProxies {
|
if input.SyncProxies {
|
||||||
proxies, _ = s.proxyRepo.ListActive(ctx)
|
proxies, _ = s.proxyRepo.ListActive(ctx)
|
||||||
@@ -329,6 +338,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
|
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||||
|
item.Action = "skipped"
|
||||||
|
item.Error = "not selected"
|
||||||
|
result.Skipped++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
account := &Account{
|
account := &Account{
|
||||||
Name: defaultName(src.Name, src.ID),
|
Name: defaultName(src.Name, src.ID),
|
||||||
Platform: PlatformAnthropic,
|
Platform: PlatformAnthropic,
|
||||||
@@ -446,6 +462,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
|
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||||
|
item.Action = "skipped"
|
||||||
|
item.Error = "not selected"
|
||||||
|
result.Skipped++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
account := &Account{
|
account := &Account{
|
||||||
Name: defaultName(src.Name, src.ID),
|
Name: defaultName(src.Name, src.ID),
|
||||||
Platform: PlatformAnthropic,
|
Platform: PlatformAnthropic,
|
||||||
@@ -569,6 +592,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
|
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||||
|
item.Action = "skipped"
|
||||||
|
item.Error = "not selected"
|
||||||
|
result.Skipped++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
account := &Account{
|
account := &Account{
|
||||||
Name: defaultName(src.Name, src.ID),
|
Name: defaultName(src.Name, src.ID),
|
||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
@@ -690,6 +720,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
|
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||||
|
item.Action = "skipped"
|
||||||
|
item.Error = "not selected"
|
||||||
|
result.Skipped++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
account := &Account{
|
account := &Account{
|
||||||
Name: defaultName(src.Name, src.ID),
|
Name: defaultName(src.Name, src.ID),
|
||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
@@ -798,6 +835,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
|
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||||
|
item.Action = "skipped"
|
||||||
|
item.Error = "not selected"
|
||||||
|
result.Skipped++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
account := &Account{
|
account := &Account{
|
||||||
Name: defaultName(src.Name, src.ID),
|
Name: defaultName(src.Name, src.ID),
|
||||||
Platform: PlatformGemini,
|
Platform: PlatformGemini,
|
||||||
@@ -909,6 +953,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
|
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||||
|
item.Action = "skipped"
|
||||||
|
item.Error = "not selected"
|
||||||
|
result.Skipped++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
account := &Account{
|
account := &Account{
|
||||||
Name: defaultName(src.Name, src.ID),
|
Name: defaultName(src.Name, src.ID),
|
||||||
Platform: PlatformGemini,
|
Platform: PlatformGemini,
|
||||||
@@ -1253,3 +1304,102 @@ func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *Account
|
|||||||
|
|
||||||
return newCredentials
|
return newCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildSelectedSet converts a slice of selected CRS account IDs to a set for O(1) lookup.
|
||||||
|
// Returns nil if ids is nil (field not sent → backward compatible: create all).
|
||||||
|
// Returns an empty map if ids is non-nil but empty (user selected none → create none).
|
||||||
|
func buildSelectedSet(ids []string) map[string]struct{} {
|
||||||
|
if ids == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
set[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldCreateAccount checks if a new CRS account should be created based on user selection.
|
||||||
|
// Returns true if selectedSet is nil (backward compatible: create all) or if crsID is in the set.
|
||||||
|
func shouldCreateAccount(crsID string, selectedSet map[string]struct{}) bool {
|
||||||
|
if selectedSet == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, ok := selectedSet[crsID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewFromCRSResult contains the preview of accounts from CRS before sync.
|
||||||
|
type PreviewFromCRSResult struct {
|
||||||
|
NewAccounts []CRSPreviewAccount `json:"new_accounts"`
|
||||||
|
ExistingAccounts []CRSPreviewAccount `json:"existing_accounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRSPreviewAccount represents a single account in the preview result.
|
||||||
|
type CRSPreviewAccount struct {
|
||||||
|
CRSAccountID string `json:"crs_account_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewFromCRS connects to CRS, fetches all accounts, and classifies them
|
||||||
|
// as new or existing by batch-querying local crs_account_id mappings.
|
||||||
|
func (s *CRSSyncService) PreviewFromCRS(ctx context.Context, input SyncFromCRSInput) (*PreviewFromCRSResult, error) {
|
||||||
|
exported, err := s.fetchCRSExport(ctx, input.BaseURL, input.Username, input.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch query all existing CRS account IDs
|
||||||
|
existingCRSIDs, err := s.accountRepo.ListCRSAccountIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list existing CRS accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &PreviewFromCRSResult{
|
||||||
|
NewAccounts: make([]CRSPreviewAccount, 0),
|
||||||
|
ExistingAccounts: make([]CRSPreviewAccount, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
classify := func(crsID, kind, name, platform, accountType string) {
|
||||||
|
preview := CRSPreviewAccount{
|
||||||
|
CRSAccountID: crsID,
|
||||||
|
Kind: kind,
|
||||||
|
Name: defaultName(name, crsID),
|
||||||
|
Platform: platform,
|
||||||
|
Type: accountType,
|
||||||
|
}
|
||||||
|
if _, exists := existingCRSIDs[crsID]; exists {
|
||||||
|
result.ExistingAccounts = append(result.ExistingAccounts, preview)
|
||||||
|
} else {
|
||||||
|
result.NewAccounts = append(result.NewAccounts, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, src := range exported.Data.ClaudeAccounts {
|
||||||
|
authType := strings.TrimSpace(src.AuthType)
|
||||||
|
if authType == "" {
|
||||||
|
authType = AccountTypeOAuth
|
||||||
|
}
|
||||||
|
classify(src.ID, src.Kind, src.Name, PlatformAnthropic, authType)
|
||||||
|
}
|
||||||
|
for _, src := range exported.Data.ClaudeConsoleAccounts {
|
||||||
|
classify(src.ID, src.Kind, src.Name, PlatformAnthropic, AccountTypeAPIKey)
|
||||||
|
}
|
||||||
|
for _, src := range exported.Data.OpenAIOAuthAccounts {
|
||||||
|
classify(src.ID, src.Kind, src.Name, PlatformOpenAI, AccountTypeOAuth)
|
||||||
|
}
|
||||||
|
for _, src := range exported.Data.OpenAIResponsesAccounts {
|
||||||
|
classify(src.ID, src.Kind, src.Name, PlatformOpenAI, AccountTypeAPIKey)
|
||||||
|
}
|
||||||
|
for _, src := range exported.Data.GeminiOAuthAccounts {
|
||||||
|
classify(src.ID, src.Kind, src.Name, PlatformGemini, AccountTypeOAuth)
|
||||||
|
}
|
||||||
|
for _, src := range exported.Data.GeminiAPIKeyAccounts {
|
||||||
|
classify(src.ID, src.Kind, src.Name, PlatformGemini, AccountTypeAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
|||||||
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)")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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 {
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ func (m *mockAccountRepoForPlatform) Create(ctx context.Context, account *Accoun
|
|||||||
func (m *mockAccountRepoForPlatform) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) {
|
func (m *mockAccountRepoForPlatform) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (m *mockAccountRepoForPlatform) Update(ctx context.Context, account *Account) error {
|
func (m *mockAccountRepoForPlatform) Update(ctx context.Context, account *Account) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|||||||
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 {
|
|||||||
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
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|||||||
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, resp.StatusCode, upstreamReqID, respBody)
|
return nil, s.writeGeminiMappedError(c, account, http.StatusInternalServerError, 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.
|
|||||||
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.
|
|||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
contentType = "application/json"
|
contentType = "application/json"
|
||||||
}
|
}
|
||||||
c.Data(resp.StatusCode, contentType, respBody)
|
c.Data(http.StatusInternalServerError, 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.
|
|||||||
}, 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) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ func (m *mockAccountRepoForGemini) Create(ctx context.Context, account *Account)
|
|||||||
func (m *mockAccountRepoForGemini) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) {
|
func (m *mockAccountRepoForGemini) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockAccountRepoForGemini) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (m *mockAccountRepoForGemini) Update(ctx context.Context, account *Account) error { return nil }
|
func (m *mockAccountRepoForGemini) Update(ctx context.Context, account *Account) error { return nil }
|
||||||
func (m *mockAccountRepoForGemini) Delete(ctx context.Context, id int64) error { return nil }
|
func (m *mockAccountRepoForGemini) Delete(ctx context.Context, id int64) error { return nil }
|
||||||
func (m *mockAccountRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
func (m *mockAccountRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
21
frontend/pnpm-lock.yaml
generated
21
frontend/pnpm-lock.yaml
generated
@@ -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:
|
|||||||
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:
|
|||||||
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:
|
|||||||
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
|
||||||
|
|||||||
@@ -327,11 +327,34 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CRSPreviewAccount {
|
||||||
|
crs_account_id: string
|
||||||
|
kind: string
|
||||||
|
name: string
|
||||||
|
platform: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewFromCRSResult {
|
||||||
|
new_accounts: CRSPreviewAccount[]
|
||||||
|
existing_accounts: CRSPreviewAccount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewFromCrs(params: {
|
||||||
|
base_url: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}): Promise<PreviewFromCRSResult> {
|
||||||
|
const { data } = await apiClient.post<PreviewFromCRSResult>('/admin/accounts/sync/crs/preview', params)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncFromCrs(params: {
|
export async function syncFromCrs(params: {
|
||||||
base_url: string
|
base_url: string
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
sync_proxies?: boolean
|
sync_proxies?: boolean
|
||||||
|
selected_account_ids?: string[]
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
created: number
|
created: number
|
||||||
updated: number
|
updated: number
|
||||||
@@ -345,7 +368,19 @@ export async function syncFromCrs(params: {
|
|||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
}> {
|
}> {
|
||||||
const { data } = await apiClient.post('/admin/accounts/sync/crs', params)
|
const { data } = await apiClient.post<{
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
skipped: number
|
||||||
|
failed: number
|
||||||
|
items: Array<{
|
||||||
|
crs_account_id: string
|
||||||
|
kind: string
|
||||||
|
name: string
|
||||||
|
action: string
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
}>('/admin/accounts/sync/crs', params)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +477,7 @@ export const accountsAPI = {
|
|||||||
batchCreate,
|
batchCreate,
|
||||||
batchUpdateCredentials,
|
batchUpdateCredentials,
|
||||||
bulkUpdate,
|
bulkUpdate,
|
||||||
|
previewFromCrs,
|
||||||
syncFromCrs,
|
syncFromCrs,
|
||||||
exportData,
|
exportData,
|
||||||
importData,
|
importData,
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
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 @@
|
|||||||
</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 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
? '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 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -6,15 +6,20 @@
|
|||||||
close-on-click-outside
|
close-on-click-outside
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<form id="sync-from-crs-form" class="space-y-4" @submit.prevent="handleSync">
|
<!-- Step 1: Input credentials -->
|
||||||
|
<form
|
||||||
|
v-if="currentStep === 'input'"
|
||||||
|
id="sync-from-crs-form"
|
||||||
|
class="space-y-4"
|
||||||
|
@submit.prevent="handlePreview"
|
||||||
|
>
|
||||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||||
{{ t('admin.accounts.syncFromCrsDesc') }}
|
{{ t('admin.accounts.syncFromCrsDesc') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
||||||
>
|
>
|
||||||
已有账号仅同步 CRS
|
{{ t('admin.accounts.crsUpdateBehaviorNote') }}
|
||||||
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||||
@@ -24,26 +29,30 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
|
<label for="crs-base-url" class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
|
||||||
<input
|
<input
|
||||||
|
id="crs-base-url"
|
||||||
v-model="form.base_url"
|
v-model="form.base_url"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
|
required
|
||||||
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
|
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
<label for="crs-username" class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
||||||
<input v-model="form.username" type="text" class="input" autocomplete="username" />
|
<input id="crs-username" v-model="form.username" type="text" class="input" required autocomplete="username" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
<label for="crs-password" class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
||||||
<input
|
<input
|
||||||
|
id="crs-password"
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,9 +67,101 @@
|
|||||||
{{ t('admin.accounts.syncProxies') }}
|
{{ t('admin.accounts.syncProxies') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Step 2: Preview & select -->
|
||||||
|
<div v-else-if="currentStep === 'preview' && previewResult" class="space-y-4">
|
||||||
|
<!-- Existing accounts (read-only info) -->
|
||||||
|
<div
|
||||||
|
v-if="previewResult.existing_accounts.length"
|
||||||
|
class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700/60"
|
||||||
|
>
|
||||||
|
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-dark-300">
|
||||||
|
{{ t('admin.accounts.crsExistingAccounts') }}
|
||||||
|
<span class="ml-1 text-xs text-gray-400">({{ previewResult.existing_accounts.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-32 overflow-auto text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
<div
|
||||||
|
v-for="acc in previewResult.existing_accounts"
|
||||||
|
:key="acc.crs_account_id"
|
||||||
|
class="flex items-center gap-2 py-0.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||||
|
>{{ acc.platform }} / {{ acc.type }}</span>
|
||||||
|
<span class="truncate">{{ acc.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New accounts (selectable) -->
|
||||||
|
<div v-if="previewResult.new_accounts.length">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.accounts.crsNewAccounts') }}
|
||||||
|
<span class="ml-1 text-xs text-gray-400">({{ previewResult.new_accounts.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||||
|
@click="selectAll"
|
||||||
|
>{{ t('admin.accounts.crsSelectAll') }}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-600 dark:text-gray-400"
|
||||||
|
@click="selectNone"
|
||||||
|
>{{ t('admin.accounts.crsSelectNone') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="max-h-48 overflow-auto rounded-lg border border-gray-200 p-2 dark:border-dark-600"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="acc in previewResult.new_accounts"
|
||||||
|
:key="acc.crs_account_id"
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700/40"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedIds.has(acc.crs_account_id)"
|
||||||
|
class="rounded border-gray-300 dark:border-dark-600"
|
||||||
|
@change="toggleSelect(acc.crs_account_id)"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="inline-block rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
>{{ acc.platform }} / {{ acc.type }}</span>
|
||||||
|
<span class="truncate text-sm text-gray-700 dark:text-dark-300">{{ acc.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-400">
|
||||||
|
{{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync options summary -->
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
<span>{{ t('admin.accounts.syncProxies') }}:</span>
|
||||||
|
<span :class="form.sync_proxies ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-dark-500'">
|
||||||
|
{{ form.sync_proxies ? t('common.yes') : t('common.no') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No new accounts -->
|
||||||
|
<div
|
||||||
|
v-if="!previewResult.new_accounts.length"
|
||||||
|
class="rounded-lg bg-gray-50 p-4 text-center text-sm text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.crsNoNewAccounts') }}
|
||||||
|
<span v-if="previewResult.existing_accounts.length">
|
||||||
|
{{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Result -->
|
||||||
|
<div v-else-if="currentStep === 'result' && result" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-if="result"
|
|
||||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||||
>
|
>
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
@@ -84,21 +185,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button class="btn btn-secondary" type="button" :disabled="syncing" @click="handleClose">
|
<!-- Step 1: Input -->
|
||||||
{{ t('common.cancel') }}
|
<template v-if="currentStep === 'input'">
|
||||||
</button>
|
<button
|
||||||
<button
|
class="btn btn-secondary"
|
||||||
class="btn btn-primary"
|
type="button"
|
||||||
type="submit"
|
:disabled="previewing"
|
||||||
form="sync-from-crs-form"
|
@click="handleClose"
|
||||||
:disabled="syncing"
|
>
|
||||||
>
|
{{ t('common.cancel') }}
|
||||||
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
|
</button>
|
||||||
</button>
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="submit"
|
||||||
|
form="sync-from-crs-form"
|
||||||
|
:disabled="previewing"
|
||||||
|
>
|
||||||
|
{{ previewing ? t('admin.accounts.crsPreviewing') : t('admin.accounts.crsPreview') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 2: Preview -->
|
||||||
|
<template v-else-if="currentStep === 'preview'">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="syncing"
|
||||||
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.crsBack') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="syncing || hasNewButNoneSelected"
|
||||||
|
@click="handleSync"
|
||||||
|
>
|
||||||
|
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 3: Result -->
|
||||||
|
<template v-else-if="currentStep === 'result'">
|
||||||
|
<button class="btn btn-secondary" type="button" @click="handleClose">
|
||||||
|
{{ t('common.close') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
@@ -110,6 +246,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
|
import type { PreviewFromCRSResult } from '@/api/admin/accounts'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -126,7 +263,12 @@ const emit = defineEmits<Emits>()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
type Step = 'input' | 'preview' | 'result'
|
||||||
|
const currentStep = ref<Step>('input')
|
||||||
|
const previewing = ref(false)
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
|
const previewResult = ref<PreviewFromCRSResult | null>(null)
|
||||||
|
const selectedIds = ref(new Set<string>())
|
||||||
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
|
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -136,28 +278,90 @@ const form = reactive({
|
|||||||
sync_proxies: true
|
sync_proxies: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasNewButNoneSelected = computed(() => {
|
||||||
|
if (!previewResult.value) return false
|
||||||
|
return previewResult.value.new_accounts.length > 0 && selectedIds.value.size === 0
|
||||||
|
})
|
||||||
|
|
||||||
const errorItems = computed(() => {
|
const errorItems = computed(() => {
|
||||||
if (!result.value?.items) return []
|
if (!result.value?.items) return []
|
||||||
return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped')
|
return result.value.items.filter(
|
||||||
|
(i) => i.action === 'failed' || (i.action === 'skipped' && i.error !== 'not selected')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(open) => {
|
(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
currentStep.value = 'input'
|
||||||
|
previewResult.value = null
|
||||||
|
selectedIds.value = new Set()
|
||||||
result.value = null
|
result.value = null
|
||||||
|
form.base_url = ''
|
||||||
|
form.username = ''
|
||||||
|
form.password = ''
|
||||||
|
form.sync_proxies = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// 防止在同步进行中关闭对话框
|
if (syncing.value || previewing.value) {
|
||||||
if (syncing.value) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
currentStep.value = 'input'
|
||||||
|
previewResult.value = null
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
if (!previewResult.value) return
|
||||||
|
selectedIds.value = new Set(previewResult.value.new_accounts.map((a) => a.crs_account_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectNone = () => {
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
const s = new Set(selectedIds.value)
|
||||||
|
if (s.has(id)) {
|
||||||
|
s.delete(id)
|
||||||
|
} else {
|
||||||
|
s.add(id)
|
||||||
|
}
|
||||||
|
selectedIds.value = s
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
|
||||||
|
appStore.showError(t('admin.accounts.syncMissingFields'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previewing.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.accounts.previewFromCrs({
|
||||||
|
base_url: form.base_url.trim(),
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password
|
||||||
|
})
|
||||||
|
previewResult.value = res
|
||||||
|
// Auto-select all new accounts
|
||||||
|
selectedIds.value = new Set(res.new_accounts.map((a) => a.crs_account_id))
|
||||||
|
currentStep.value = 'preview'
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error?.message || t('admin.accounts.crsPreviewFailed'))
|
||||||
|
} finally {
|
||||||
|
previewing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
|
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
|
||||||
appStore.showError(t('admin.accounts.syncMissingFields'))
|
appStore.showError(t('admin.accounts.syncMissingFields'))
|
||||||
@@ -170,16 +374,18 @@ const handleSync = async () => {
|
|||||||
base_url: form.base_url.trim(),
|
base_url: form.base_url.trim(),
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
password: form.password,
|
password: form.password,
|
||||||
sync_proxies: form.sync_proxies
|
sync_proxies: form.sync_proxies,
|
||||||
|
selected_account_ids: [...selectedIds.value]
|
||||||
})
|
})
|
||||||
result.value = res
|
result.value = res
|
||||||
|
currentStep.value = 'result'
|
||||||
|
|
||||||
if (res.failed > 0) {
|
if (res.failed > 0) {
|
||||||
appStore.showError(t('admin.accounts.syncCompletedWithErrors', res))
|
appStore.showError(t('admin.accounts.syncCompletedWithErrors', res))
|
||||||
} else {
|
} else {
|
||||||
appStore.showSuccess(t('admin.accounts.syncCompleted', res))
|
appStore.showSuccess(t('admin.accounts.syncCompleted', res))
|
||||||
emit('synced')
|
|
||||||
}
|
}
|
||||||
|
emit('synced')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appStore.showError(error?.message || t('admin.accounts.syncFailed'))
|
appStore.showError(error?.message || t('admin.accounts.syncFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
/>
|
/>
|
||||||
<GroupBadge
|
<GroupBadge
|
||||||
:name="group.name"
|
:name="group.name"
|
||||||
|
:platform="group.platform"
|
||||||
:subscription-type="group.subscription_type"
|
:subscription-type="group.subscription_type"
|
||||||
:rate-multiplier="group.rate_multiplier"
|
:rate-multiplier="group.rate_multiplier"
|
||||||
class="min-w-0 flex-1"
|
class="min-w-0 flex-1"
|
||||||
|
|||||||
@@ -1309,10 +1309,23 @@ export default {
|
|||||||
syncResult: 'Sync Result',
|
syncResult: 'Sync Result',
|
||||||
syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}',
|
syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}',
|
||||||
syncErrors: 'Errors / Skipped Details',
|
syncErrors: 'Errors / Skipped Details',
|
||||||
syncCompleted: 'Sync completed: created {created}, updated {updated}',
|
syncCompleted: 'Sync completed: created {created}, updated {updated}, skipped {skipped}',
|
||||||
syncCompletedWithErrors:
|
syncCompletedWithErrors:
|
||||||
'Sync completed with errors: failed {failed} (created {created}, updated {updated})',
|
'Sync completed with errors: failed {failed} (created {created}, updated {updated}, skipped {skipped})',
|
||||||
syncFailed: 'Sync failed',
|
syncFailed: 'Sync failed',
|
||||||
|
crsPreview: 'Preview',
|
||||||
|
crsPreviewing: 'Previewing...',
|
||||||
|
crsPreviewFailed: 'Preview failed',
|
||||||
|
crsExistingAccounts: 'Existing accounts (will be updated)',
|
||||||
|
crsNewAccounts: 'New accounts (select to sync)',
|
||||||
|
crsSelectAll: 'Select all',
|
||||||
|
crsSelectNone: 'Select none',
|
||||||
|
crsNoNewAccounts: 'All CRS accounts are already synced.',
|
||||||
|
crsWillUpdate: 'Will update {count} existing accounts.',
|
||||||
|
crsSelectedCount: '{count} new accounts selected',
|
||||||
|
crsUpdateBehaviorNote:
|
||||||
|
'Existing accounts only sync fields returned by CRS; missing fields keep their current values. Credentials are merged by key — keys not returned by CRS are preserved. Proxies are kept when "Sync proxies" is unchecked.',
|
||||||
|
crsBack: 'Back',
|
||||||
editAccount: 'Edit Account',
|
editAccount: 'Edit Account',
|
||||||
deleteAccount: 'Delete Account',
|
deleteAccount: 'Delete Account',
|
||||||
searchAccounts: 'Search accounts...',
|
searchAccounts: 'Search accounts...',
|
||||||
@@ -1346,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'
|
||||||
},
|
},
|
||||||
@@ -1612,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',
|
||||||
|
|||||||
@@ -1397,9 +1397,22 @@ export default {
|
|||||||
syncResult: '同步结果',
|
syncResult: '同步结果',
|
||||||
syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}',
|
syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}',
|
||||||
syncErrors: '错误/跳过详情',
|
syncErrors: '错误/跳过详情',
|
||||||
syncCompleted: '同步完成:创建 {created},更新 {updated}',
|
syncCompleted: '同步完成:创建 {created},更新 {updated},跳过 {skipped}',
|
||||||
syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})',
|
syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated},跳过 {skipped})',
|
||||||
syncFailed: '同步失败',
|
syncFailed: '同步失败',
|
||||||
|
crsPreview: '预览',
|
||||||
|
crsPreviewing: '预览中...',
|
||||||
|
crsPreviewFailed: '预览失败',
|
||||||
|
crsExistingAccounts: '将自动更新的已有账号',
|
||||||
|
crsNewAccounts: '新账号(可选择)',
|
||||||
|
crsSelectAll: '全选',
|
||||||
|
crsSelectNone: '全不选',
|
||||||
|
crsNoNewAccounts: '所有 CRS 账号均已同步。',
|
||||||
|
crsWillUpdate: '将更新 {count} 个已有账号。',
|
||||||
|
crsSelectedCount: '已选择 {count} 个新账号',
|
||||||
|
crsUpdateBehaviorNote:
|
||||||
|
'已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。',
|
||||||
|
crsBack: '返回',
|
||||||
editAccount: '编辑账号',
|
editAccount: '编辑账号',
|
||||||
deleteAccount: '删除账号',
|
deleteAccount: '删除账号',
|
||||||
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
||||||
@@ -1480,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',
|
||||||
@@ -1758,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',
|
||||||
|
|||||||
Reference in New Issue
Block a user