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。',