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) <noreply@anthropic.com>
This commit is contained in:
parent
f7ac5e5931
commit
21033dceb9
@ -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 {
|
||||
|
||||
193
backend/internal/service/account_pool_retry_status_codes_test.go
Normal file
193
backend/internal/service/account_pool_retry_status_codes_test.go
Normal file
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1290,6 +1290,18 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="poolModeEnabled" class="mt-3">
|
||||
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
|
||||
<input
|
||||
v-model="poolModeRetryStatusCodesInput"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Error Codes Section -->
|
||||
@ -1635,6 +1647,18 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="poolModeEnabled" class="mt-3">
|
||||
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
|
||||
<input
|
||||
v-model="poolModeRetryStatusCodesInput"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3297,8 +3321,27 @@ const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
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<number>()
|
||||
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<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(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
|
||||
|
||||
@ -305,6 +305,18 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="poolModeEnabled" class="mt-3">
|
||||
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
|
||||
<input
|
||||
v-model="poolModeRetryStatusCodesInput"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Error Codes Section -->
|
||||
@ -973,6 +985,18 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="poolModeEnabled" class="mt-3">
|
||||
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
|
||||
<input
|
||||
v-model="poolModeRetryStatusCodesInput"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2317,8 +2341,42 @@ const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
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<number>()
|
||||
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<number>()
|
||||
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<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(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<string, unknown>) || {}
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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。',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user