From 21033dceb921195343606fa2a5dc7421388930cf Mon Sep 17 00:00:00 2001
From: StarryKira <59300016+StarryKira@users.noreply.github.com>
Date: Tue, 26 May 2026 11:24:25 -0700
Subject: [PATCH] feat(account): configurable pool-mode same-account retry
status codes
Pool mode currently retries the same account for a fixed set of
upstream HTTP statuses: 401, 403, 429. Some upstream pool deployments
also need same-account retry for transient provider/proxy statuses
such as 502, 503, 520, 529, but hard-coding more statuses changes
behavior for everyone.
Add a per-account credentials option `pool_mode_retry_status_codes`
that lets admins choose which upstream HTTP status codes trigger
same-account retry in pool mode:
- Unset (default): preserve the current 401/403/429 default
- Explicit list: override the defaults with the configured codes
- Codes normalized to the 100-599 range, deduplicated, sorted
The standalone `isPoolModeRetryableStatus` helper is kept as the
default-only fallback. All 15 gateway call sites switch to the new
`Account.IsPoolModeRetryableStatus` method so behavior is preserved
for accounts that do not configure the new field.
Frontend admin UI gains a "Retry Status Codes" comma-separated input
under the pool-mode section in both Create/Edit account modals
(en + zh i18n).
Fixes #2731
Co-Authored-By: Claude Opus 4.7 (1M context)
---
backend/internal/service/account.go | 88 +++++++-
.../account_pool_retry_status_codes_test.go | 193 ++++++++++++++++++
backend/internal/service/gateway_service.go | 12 +-
.../openai_gateway_chat_completions.go | 2 +-
.../openai_gateway_chat_completions_raw.go | 2 +-
.../service/openai_gateway_messages.go | 2 +-
.../openai_gateway_responses_chat_fallback.go | 2 +-
.../service/openai_gateway_service.go | 6 +-
backend/internal/service/openai_images.go | 2 +-
.../service/openai_images_responses.go | 2 +-
.../components/account/CreateAccountModal.vue | 52 +++++
.../components/account/EditAccountModal.vue | 75 +++++++
frontend/src/i18n/locales/en.ts | 3 +
frontend/src/i18n/locales/zh.ts | 2 +
14 files changed, 422 insertions(+), 21 deletions(-)
create mode 100644 backend/internal/service/account_pool_retry_status_codes_test.go
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index cd06ffa3..d488aa75 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -890,14 +890,90 @@ func parsePoolModeRetryCount(value any) int {
return defaultPoolModeRetryCount
}
-// isPoolModeRetryableStatus 池模式下应触发同账号重试的状态码
+// defaultPoolModeRetryableStatusCodes 池模式下默认触发同账号重试的状态码。
+// 未在 Account.Credentials 中显式配置 pool_mode_retry_status_codes 时使用。
+var defaultPoolModeRetryableStatusCodes = []int{401, 403, 429}
+
+// isPoolModeRetryableStatus 池模式下应触发同账号重试的状态码(默认列表)。
func isPoolModeRetryableStatus(statusCode int) bool {
- switch statusCode {
- case 401, 403, 429:
- return true
- default:
- return false
+ for _, c := range defaultPoolModeRetryableStatusCodes {
+ if c == statusCode {
+ return true
+ }
}
+ return false
+}
+
+// GetPoolModeRetryStatusCodes 返回账号自定义的池模式同账号重试状态码列表。
+//
+// 返回值语义:
+// - nil:未配置 → 调用方应回退到默认值 [401, 403, 429]
+// - 长度为 0 的切片:管理员显式置空 → 关闭按状态码触发的同账号重试
+// - 非空切片:去重、过滤为合法 HTTP 状态码(100-599)后的覆盖列表
+func (a *Account) GetPoolModeRetryStatusCodes() []int {
+ if a == nil || a.Credentials == nil {
+ return nil
+ }
+ raw, ok := a.Credentials["pool_mode_retry_status_codes"]
+ if !ok || raw == nil {
+ return nil
+ }
+ arr, ok := raw.([]any)
+ if !ok {
+ return nil
+ }
+ seen := make(map[int]struct{}, len(arr))
+ codes := make([]int, 0, len(arr))
+ for _, v := range arr {
+ var code int
+ switch n := v.(type) {
+ case float64:
+ code = int(n)
+ case int:
+ code = n
+ case int64:
+ code = int(n)
+ case json.Number:
+ i, err := n.Int64()
+ if err != nil {
+ continue
+ }
+ code = int(i)
+ case string:
+ i, err := strconv.Atoi(strings.TrimSpace(n))
+ if err != nil {
+ continue
+ }
+ code = i
+ default:
+ continue
+ }
+ if code < 100 || code > 599 {
+ continue
+ }
+ if _, exists := seen[code]; exists {
+ continue
+ }
+ seen[code] = struct{}{}
+ codes = append(codes, code)
+ }
+ sort.Ints(codes)
+ return codes
+}
+
+// IsPoolModeRetryableStatus 在账号上下文中判断给定状态码是否应触发同账号重试。
+// 若账号未配置 pool_mode_retry_status_codes,则回退到默认列表。
+func (a *Account) IsPoolModeRetryableStatus(statusCode int) bool {
+ codes := a.GetPoolModeRetryStatusCodes()
+ if codes == nil {
+ return isPoolModeRetryableStatus(statusCode)
+ }
+ for _, c := range codes {
+ if c == statusCode {
+ return true
+ }
+ }
+ return false
}
func (a *Account) GetCustomErrorCodes() []int {
diff --git a/backend/internal/service/account_pool_retry_status_codes_test.go b/backend/internal/service/account_pool_retry_status_codes_test.go
new file mode 100644
index 00000000..c0b9d7ab
--- /dev/null
+++ b/backend/internal/service/account_pool_retry_status_codes_test.go
@@ -0,0 +1,193 @@
+//go:build unit
+
+package service
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetPoolModeRetryStatusCodes(t *testing.T) {
+ tests := []struct {
+ name string
+ account *Account
+ expected []int
+ }{
+ {
+ name: "nil_account_returns_nil",
+ account: nil,
+ expected: nil,
+ },
+ {
+ name: "nil_credentials_returns_nil",
+ account: &Account{
+ Type: AccountTypeAPIKey,
+ Platform: PlatformOpenAI,
+ },
+ expected: nil,
+ },
+ {
+ name: "missing_key_returns_nil",
+ account: &Account{
+ Type: AccountTypeAPIKey,
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"pool_mode": true},
+ },
+ expected: nil,
+ },
+ {
+ name: "empty_slice_is_preserved",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{},
+ },
+ },
+ expected: []int{},
+ },
+ {
+ name: "float64_values_from_json_are_normalized",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{float64(429), float64(401), float64(403)},
+ },
+ },
+ expected: []int{401, 403, 429},
+ },
+ {
+ name: "json_number_values_supported",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{json.Number("502"), json.Number("503")},
+ },
+ },
+ expected: []int{502, 503},
+ },
+ {
+ name: "string_values_supported",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{"520", "529"},
+ },
+ },
+ expected: []int{520, 529},
+ },
+ {
+ name: "duplicates_are_deduped",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{float64(429), float64(429), float64(401)},
+ },
+ },
+ expected: []int{401, 429},
+ },
+ {
+ name: "out_of_range_values_dropped",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{float64(99), float64(600), float64(429)},
+ },
+ },
+ expected: []int{429},
+ },
+ {
+ name: "invalid_string_dropped",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{"oops", float64(429)},
+ },
+ },
+ expected: []int{429},
+ },
+ {
+ name: "non_array_value_returns_nil",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": "not-an-array",
+ },
+ },
+ expected: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.expected, tt.account.GetPoolModeRetryStatusCodes())
+ })
+ }
+}
+
+func TestIsPoolModeRetryableStatus_Account(t *testing.T) {
+ tests := []struct {
+ name string
+ account *Account
+ statusCode int
+ expected bool
+ }{
+ {
+ name: "nil_account_falls_back_to_default_401",
+ account: nil,
+ statusCode: 401,
+ expected: true,
+ },
+ {
+ name: "nil_account_falls_back_to_default_500",
+ account: nil,
+ statusCode: 500,
+ expected: false,
+ },
+ {
+ name: "unconfigured_uses_default_403",
+ account: &Account{
+ Credentials: map[string]any{"pool_mode": true},
+ },
+ statusCode: 403,
+ expected: true,
+ },
+ {
+ name: "unconfigured_uses_default_502_false",
+ account: &Account{
+ Credentials: map[string]any{"pool_mode": true},
+ },
+ statusCode: 502,
+ expected: false,
+ },
+ {
+ name: "configured_list_overrides_default_401_dropped",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{float64(502), float64(503)},
+ },
+ },
+ statusCode: 401,
+ expected: false,
+ },
+ {
+ name: "configured_list_overrides_default_502_added",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{float64(502), float64(503)},
+ },
+ },
+ statusCode: 502,
+ expected: true,
+ },
+ {
+ name: "empty_list_disables_all_default_codes",
+ account: &Account{
+ Credentials: map[string]any{
+ "pool_mode_retry_status_codes": []any{},
+ },
+ },
+ statusCode: 429,
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.expected, tt.account.IsPoolModeRetryableStatus(tt.statusCode))
+ })
+ }
+}
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 6106391b..797a88eb 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -4854,7 +4854,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleRetryExhaustedError(ctx, resp, c, account)
@@ -4888,7 +4888,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
if resp.StatusCode >= 400 {
@@ -5156,7 +5156,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleRetryExhaustedError(ctx, resp, c, account)
@@ -5190,7 +5190,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
@@ -5929,7 +5929,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleRetryExhaustedError(ctx, resp, c, account)
@@ -5953,7 +5953,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go
index f44d88cf..6181ed49 100644
--- a/backend/internal/service/openai_gateway_chat_completions.go
+++ b/backend/internal/service/openai_gateway_chat_completions.go
@@ -280,7 +280,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
+ RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
}
}
return s.handleChatCompletionsErrorResponse(resp, c, account)
diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go
index efac4671..8851282a 100644
--- a/backend/internal/service/openai_gateway_chat_completions_raw.go
+++ b/backend/internal/service/openai_gateway_chat_completions_raw.go
@@ -212,7 +212,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
+ RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
}
}
return s.handleChatCompletionsErrorResponse(resp, c, account)
diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go
index a624175b..3a788a84 100644
--- a/backend/internal/service/openai_gateway_messages.go
+++ b/backend/internal/service/openai_gateway_messages.go
@@ -342,7 +342,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
+ RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
}
}
// Non-failover error: return Anthropic-formatted error to client
diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go
index 91203bc1..b7d4dd0b 100644
--- a/backend/internal/service/openai_gateway_responses_chat_fallback.go
+++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go
@@ -192,7 +192,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
+ RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
}
}
return s.handleErrorResponse(ctx, resp, c, account, chatBody)
diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go
index d4921511..9fd1d042 100644
--- a/backend/internal/service/openai_gateway_service.go
+++ b/backend/internal/service/openai_gateway_service.go
@@ -2856,7 +2856,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
+ RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
}
}
return s.handleErrorResponse(ctx, resp, c, account, body)
@@ -4156,7 +4156,7 @@ func (s *OpenAIGatewayService) handleErrorResponse(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: body,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
@@ -4290,7 +4290,7 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: body,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go
index 19066f1d..cf57f95f 100644
--- a/backend/internal/service/openai_images.go
+++ b/backend/internal/service/openai_images.go
@@ -642,7 +642,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleErrorResponse(upstreamCtx, resp, c, account, forwardBody)
diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go
index b39fa609..294a9ff8 100644
--- a/backend/internal/service/openai_images_responses.go
+++ b/backend/internal/service/openai_images_responses.go
@@ -1192,7 +1192,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
- RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
+ RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleErrorResponse(upstreamCtx, resp, c, account, responsesBody)
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 90d5e15a..331295f7 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -1290,6 +1290,18 @@
}}
+
+
+
+
+ {{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
+
+
@@ -1635,6 +1647,18 @@
}}
+
+
+
+
+ {{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
+
+
@@ -3297,8 +3321,27 @@ const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref([])
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
const MAX_POOL_MODE_RETRY_COUNT = 10
+const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
const poolModeEnabled = ref(false)
const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT)
+const poolModeRetryStatusCodesInput = ref('')
+
+function parsePoolModeRetryStatusCodes(input: string): number[] {
+ if (!input || !input.trim()) return []
+ const seen = new Set()
+ const out: number[] = []
+ for (const token of input.split(/[,\s]+/)) {
+ const trimmed = token.trim()
+ if (!trimmed) continue
+ const n = Number(trimmed)
+ if (!Number.isFinite(n) || !Number.isInteger(n)) continue
+ if (n < 100 || n > 599) continue
+ if (seen.has(n)) continue
+ seen.add(n)
+ out.push(n)
+ }
+ return out.sort((a, b) => a - b)
+}
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref([])
const customErrorCodeInput = ref(null)
@@ -4068,6 +4111,7 @@ const resetForm = () => {
})
poolModeEnabled.value = false
poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT
+ poolModeRetryStatusCodesInput.value = ''
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
customErrorCodeInput.value = null
@@ -4350,6 +4394,10 @@ const handleSubmit = async () => {
if (poolModeEnabled.value) {
credentials.pool_mode = true
credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
+ const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
+ if (parsedRetryStatusCodes.length > 0) {
+ credentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
+ }
}
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
@@ -4460,6 +4508,10 @@ const handleSubmit = async () => {
if (poolModeEnabled.value) {
credentials.pool_mode = true
credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
+ const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
+ if (parsedRetryStatusCodes.length > 0) {
+ credentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
+ }
}
// Add custom error codes if enabled
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 070887fe..f44b5d38 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -305,6 +305,18 @@
}}
+
+
+
+
+ {{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
+
+
@@ -973,6 +985,18 @@
}}
+
+
+
+
+ {{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
+
+
@@ -2317,8 +2341,42 @@ const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref([])
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
const MAX_POOL_MODE_RETRY_COUNT = 10
+const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
const poolModeEnabled = ref(false)
const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT)
+const poolModeRetryStatusCodesInput = ref('')
+
+function parsePoolModeRetryStatusCodes(input: string): number[] {
+ if (!input || !input.trim()) return []
+ const seen = new Set()
+ const out: number[] = []
+ for (const token of input.split(/[,\s]+/)) {
+ const trimmed = token.trim()
+ if (!trimmed) continue
+ const n = Number(trimmed)
+ if (!Number.isFinite(n) || !Number.isInteger(n)) continue
+ if (n < 100 || n > 599) continue
+ if (seen.has(n)) continue
+ seen.add(n)
+ out.push(n)
+ }
+ return out.sort((a, b) => a - b)
+}
+
+function formatPoolModeRetryStatusCodes(value: unknown): string {
+ if (!Array.isArray(value)) return ''
+ const out: number[] = []
+ const seen = new Set()
+ for (const v of value) {
+ const n = typeof v === 'string' ? Number(v.trim()) : Number(v)
+ if (!Number.isFinite(n) || !Number.isInteger(n)) continue
+ if (n < 100 || n > 599) continue
+ if (seen.has(n)) continue
+ seen.add(n)
+ out.push(n)
+ }
+ return out.sort((a, b) => a - b).join(', ')
+}
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref([])
const customErrorCodeInput = ref(null)
@@ -2807,6 +2865,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
poolModeRetryCount.value = normalizePoolModeRetryCount(
Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT)
)
+ poolModeRetryStatusCodesInput.value = formatPoolModeRetryStatusCodes(credentials.pool_mode_retry_status_codes)
// Load custom error codes
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
@@ -2834,6 +2893,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
poolModeEnabled.value = bedrockCreds.pool_mode === true
const retryCount = bedrockCreds.pool_mode_retry_count
poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT
+ poolModeRetryStatusCodesInput.value = formatPoolModeRetryStatusCodes(bedrockCreds.pool_mode_retry_status_codes)
// Load quota limits for bedrock
const bedrockExtra = (newAccount.extra as Record) || {}
@@ -2876,6 +2936,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
}
poolModeEnabled.value = false
poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT
+ poolModeRetryStatusCodesInput.value = ''
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
}
@@ -3427,9 +3488,16 @@ const handleSubmit = async () => {
if (poolModeEnabled.value) {
newCredentials.pool_mode = true
newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
+ const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
+ if (parsedRetryStatusCodes.length > 0) {
+ newCredentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
+ } else {
+ delete newCredentials.pool_mode_retry_status_codes
+ }
} else {
delete newCredentials.pool_mode
delete newCredentials.pool_mode_retry_count
+ delete newCredentials.pool_mode_retry_status_codes
}
// Add custom error codes if enabled
@@ -3545,9 +3613,16 @@ const handleSubmit = async () => {
if (poolModeEnabled.value) {
newCredentials.pool_mode = true
newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
+ const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
+ if (parsedRetryStatusCodes.length > 0) {
+ newCredentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
+ } else {
+ delete newCredentials.pool_mode_retry_status_codes
+ }
} else {
delete newCredentials.pool_mode
delete newCredentials.pool_mode_retry_count
+ delete newCredentials.pool_mode_retry_status_codes
}
// Model mapping
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 1d94fa29..f5e20f2d 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -3410,6 +3410,9 @@ export default {
poolModeRetryCount: 'Same-Account Retries',
poolModeRetryCountHint:
'Only applies in pool mode. Use 0 to disable in-place retry. Default {default}, maximum {max}.',
+ poolModeRetryStatusCodes: 'Retry Status Codes',
+ poolModeRetryStatusCodesHint:
+ 'Comma-separated HTTP status codes (100-599) that trigger same-account retry in pool mode. Leave blank to use defaults ({default}).',
customErrorCodes: 'Custom Error Codes',
customErrorCodesHint: 'Only stop scheduling for selected error codes',
customErrorCodesWarning:
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 8fa15e72..9836023f 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -3553,6 +3553,8 @@ export default {
'启用后,上游 429/403/401 错误将自动重试而不标记账号限流或错误,适用于上游指向另一个 sub2api 实例的场景。',
poolModeRetryCount: '同账号重试次数',
poolModeRetryCountHint: '仅在池模式下生效。0 表示不原地重试;默认 {default},最大 {max}。',
+ poolModeRetryStatusCodes: '同账号重试状态码',
+ poolModeRetryStatusCodesHint: '仅在池模式下生效。以英文逗号分隔的 HTTP 状态码(100-599),命中时触发同账号重试。留空使用默认值({default})。',
customErrorCodes: '自定义错误码',
customErrorCodesHint: '仅对选中的错误码停止调度',
customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。',