diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ee1004a587..1d7e41305f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1274,7 +1274,9 @@ type GatewayOpenAIWSConfig struct { MaxConnsPerAccount int `mapstructure:"max_conns_per_account"` MinIdlePerAccount int `mapstructure:"min_idle_per_account"` MaxIdlePerAccount int `mapstructure:"max_idle_per_account"` - // DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限 + // DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限。 + // 旧版及 mode_router_v2 的 ctx_pool 共用此开关和类型系数;关闭后使用 max_conns_per_account。 + // mode_router_v2 下并发数 <= 0 的账号仍不可调度。 DynamicMaxConnsByAccountConcurrencyEnabled bool `mapstructure:"dynamic_max_conns_by_account_concurrency_enabled"` // OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor),再受 max_conns_per_account 封顶)。 // ctx_pool 接入下每个客户端会话在整个生命周期(含轮次之间)持有一条上游连接,此上限限制的是同时持有连接的会话数, diff --git a/backend/internal/service/openai_ws_pool.go b/backend/internal/service/openai_ws_pool.go index a500484232..b632bba523 100644 --- a/backend/internal/service/openai_ws_pool.go +++ b/backend/internal/service/openai_ws_pool.go @@ -2221,14 +2221,8 @@ func (p *openAIWSConnPool) effectiveMaxConnsByAccount(account *Account) int { if hardCap <= 0 { return 0 } - if p.modeRouterV2Enabled() { - if account == nil { - return hardCap - } - if account.Concurrency <= 0 { - return 0 - } - return min(account.Concurrency, hardCap) + if p.modeRouterV2Enabled() && account != nil && account.Concurrency <= 0 { + return 0 } if account == nil || !p.dynamicMaxConnsEnabled() { return hardCap diff --git a/backend/internal/service/openai_ws_pool_test.go b/backend/internal/service/openai_ws_pool_test.go index e2639d5321..a57c922a91 100644 --- a/backend/internal/service/openai_ws_pool_test.go +++ b/backend/internal/service/openai_ws_pool_test.go @@ -1338,21 +1338,95 @@ func TestOpenAIWSConnPool_EffectiveMaxConnsDisabledFallbackHardCap(t *testing.T) require.Equal(t, 8, pool.effectiveMaxConnsByAccount(account), "关闭动态模式后应保持旧行为") } -func TestOpenAIWSConnPool_EffectiveMaxConnsByAccount_ModeRouterV2RespectsHardCap(t *testing.T) { - cfg := &config.Config{} - cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true - cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 8 - cfg.Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled = true - cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor = 0.3 - cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor = 0.6 +func TestOpenAIWSConnPool_EffectiveMaxConnsByAccount_ModeRouterV2(t *testing.T) { + tests := []struct { + name string + accountType string + concurrency int + factor float64 + dynamic bool + want int + }{ + {name: "oauth expansion", accountType: AccountTypeOAuth, concurrency: 1, factor: 5, dynamic: true, want: 5}, + {name: "apikey expansion", accountType: AccountTypeAPIKey, concurrency: 1, factor: 5, dynamic: true, want: 5}, + {name: "oauth fraction", accountType: AccountTypeOAuth, concurrency: 20, factor: 0.3, dynamic: true, want: 6}, + {name: "apikey rounding", accountType: AccountTypeAPIKey, concurrency: 3, factor: 0.6, dynamic: true, want: 2}, + {name: "minimum one", accountType: AccountTypeOAuth, concurrency: 1, factor: 0.3, dynamic: true, want: 1}, + {name: "hard cap", accountType: AccountTypeOAuth, concurrency: 3, factor: 5, dynamic: true, want: 8}, + {name: "dynamic disabled", accountType: AccountTypeAPIKey, concurrency: 2, factor: 0.6, want: 8}, + {name: "zero concurrency", accountType: AccountTypeOAuth, factor: 5, dynamic: true, want: 0}, + {name: "negative concurrency", accountType: AccountTypeAPIKey, concurrency: -1, factor: 5, dynamic: true, want: 0}, + {name: "zero concurrency dynamic disabled", accountType: AccountTypeOAuth, factor: 5, want: 0}, + {name: "negative concurrency dynamic disabled", accountType: AccountTypeAPIKey, concurrency: -1, factor: 5, want: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 8 + cfg.Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled = tt.dynamic + cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor = 1 + cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor = 1 + if tt.accountType == AccountTypeOAuth { + cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor = tt.factor + } else { + cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor = tt.factor + } + pool := newOpenAIWSConnPool(cfg) + t.Cleanup(pool.Close) + account := &Account{Platform: PlatformOpenAI, Type: tt.accountType, Concurrency: tt.concurrency} + require.Equal(t, tt.want, pool.effectiveMaxConnsByAccount(account)) + require.Equal(t, 8, pool.effectiveMaxConnsByAccount(nil)) + }) + } +} - pool := newOpenAIWSConnPool(cfg) +func TestOpenAIWSConnPool_AcquireRetainedSessionsUsesScaledCapacity(t *testing.T) { + for _, mode := range []struct { + name string + v2 bool + }{ + {name: "legacy"}, + {name: "mode router v2", v2: true}, + } { + t.Run(mode.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = mode.v2 + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 3 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 3 + cfg.Gateway.OpenAIWS.PoolTargetUtilization = 1 + cfg.Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled = true + cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor = 2 + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSFakeDialer{}) + t.Cleanup(pool.Close) + req := openAIWSAcquireRequest{ + Account: &Account{ID: 902, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 2}, + WSURL: "wss://example.com/v1/responses", + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + leases := make([]*openAIWSConnLease, 0, 3) + for range 3 { + lease, err := pool.Acquire(ctx, req) + require.NoError(t, err, "轮次之间仍持有连接的会话应可使用系数扩出的容量") + t.Cleanup(lease.Release) + leases = append(leases, lease) + } - high := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 20} - require.Equal(t, 8, pool.effectiveMaxConnsByAccount(high), "v2 路径也必须受连接池硬上限约束") + fullCtx, fullCancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer fullCancel() + _, err := pool.Acquire(fullCtx, req) + require.ErrorIs(t, err, context.DeadlineExceeded, "扩容后仍必须受全局硬上限约束") - nonPositive := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 0} - require.Equal(t, 0, pool.effectiveMaxConnsByAccount(nonPositive), "并发数<=0 时应不可调度") + leases[0].Release() + reused, err := pool.Acquire(ctx, req) + require.NoError(t, err) + defer reused.Release() + require.Equal(t, leases[0].ConnID(), reused.ConnID()) + require.True(t, reused.Reused()) + }) + } } func TestOpenAIWSConnPool_AcquireRejectsWhenEffectiveMaxConnsIsZero(t *testing.T) { diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index be9ae4f371..e93f6c96f1 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -357,6 +357,8 @@ gateway: max_idle_per_account: 12 # 是否按账号并发动态计算连接池上限: # effective_max_conns = min(max_conns_per_account, ceil(account.concurrency * factor)) + # 旧版及 mode_router_v2 的 ctx_pool 共用此开关和下方类型系数;关闭后使用 max_conns_per_account。 + # mode_router_v2 下并发数 <= 0 的账号仍不可调度。 dynamic_max_conns_by_account_concurrency_enabled: true # 按账号类型分别设置系数(OAuth / API Key)。 # ctx_pool 接入下每个客户端会话在整个生命周期(轮次之间也不释放)持有一条上游连接, diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 6e751df816..f66fb85fda 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -873,8 +873,8 @@

{{ t('admin.accounts.openai.wsModeDesc') }}

-

- {{ t(openAIWSModeConcurrencyHintKey) }} +

+ {{ t(openAIWSModeHintKey) }}

- resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value) +const openAIWSModeHintKey = computed(() => + resolveOpenAIWSModeHintKey(openaiOAuthResponsesWebSocketV2Mode.value) ) -const openAIAPIKeyWSModeConcurrencyHintKey = computed(() => - resolveOpenAIWSModeConcurrencyHintKey(openaiAPIKeyResponsesWebSocketV2Mode.value) +const openAIAPIKeyWSModeHintKey = computed(() => + resolveOpenAIWSModeHintKey(openaiAPIKeyResponsesWebSocketV2Mode.value) ) // Model mapping helpers diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 8e2ca7dcb7..54a6b2331f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3132,8 +3132,8 @@

{{ t('admin.accounts.openai.wsModeDesc') }}

-

- {{ t(openAIWSModeConcurrencyHintKey) }} +

+ {{ t(openAIWSModeHintKey) }}

@@ -3975,7 +3975,7 @@ import { OPENAI_WS_MODE_PASSTHROUGH, OPENAI_WS_MODE_HTTP_BRIDGE, isOpenAIWSModeEnabled, - resolveOpenAIWSModeConcurrencyHintKey, + resolveOpenAIWSModeHintKey, type OpenAIWSMode } from '@/utils/openaiWsMode' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' @@ -4653,8 +4653,8 @@ const openaiResponsesWebSocketV2Mode = computed({ } }) -const openAIWSModeConcurrencyHintKey = computed(() => - resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value) +const openAIWSModeHintKey = computed(() => + resolveOpenAIWSModeHintKey(openaiResponsesWebSocketV2Mode.value) ) const isOpenAIModelRestrictionDisabled = computed(() => diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index dad0bfc9f4..0d3c235bea 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1871,8 +1871,8 @@

{{ t('admin.accounts.openai.wsModeDesc') }}

-

- {{ t(openAIWSModeConcurrencyHintKey) }} +

+ {{ t(openAIWSModeHintKey) }}

@@ -3101,7 +3101,7 @@ import { OPENAI_WS_MODE_PASSTHROUGH, OPENAI_WS_MODE_HTTP_BRIDGE, isOpenAIWSModeEnabled, - resolveOpenAIWSModeConcurrencyHintKey, + resolveOpenAIWSModeHintKey, type OpenAIWSMode, resolveOpenAIWSModeFromExtra } from '@/utils/openaiWsMode' @@ -3588,8 +3588,8 @@ const openaiResponsesWebSocketV2Mode = computed({ openaiOAuthResponsesWebSocketV2Mode.value = mode } }) -const openAIWSModeConcurrencyHintKey = computed(() => - resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value) +const openAIWSModeHintKey = computed(() => + resolveOpenAIWSModeHintKey(openaiResponsesWebSocketV2Mode.value) ) const codexImageToolOptions = computed { it('documents the global v2 router requirement for account WS modes', () => { - expect(zh.accounts.openai.wsModeDesc).toContain('mode_router_v2_enabled') - expect(zh.accounts.openai.wsModeDesc).toContain('http_bridge') - expect(en.accounts.openai.wsModeDesc).toContain('mode_router_v2_enabled') - expect(en.accounts.openai.wsModeDesc).toContain('http_bridge') + expect(zh.accounts.openai.wsModeDesc).toContain('mode_router_v2_enabled=true') + expect(en.accounts.openai.wsModeDesc).toContain('mode_router_v2_enabled=true') }) }) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 0c122113f0..8c675a1c31 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -605,16 +605,19 @@ export default { 'Disabled by default. Enable to allow responses_websockets_v2 capability (still gated by global and account-type switches).', wsMode: 'WS mode', wsModeDesc: - 'Only applies to the current OpenAI account type; account WS modes, including http_bridge, take effect only when the global gateway.openai_ws.mode_router_v2_enabled=true.', + 'Applies only to the current OpenAI account type. Select Off to disable WS. Other modes use the selected connection method only when gateway.openai_ws.mode_router_v2_enabled=true; otherwise, they use the context pool.', wsModeOff: 'Off (off)', wsModeCtxPool: 'Context Pool (ctx_pool)', wsModePassthrough: 'Passthrough (passthrough)', wsModeHttpBridge: 'HTTP Bridge (http_bridge)', wsModeShared: 'Shared (shared)', wsModeDedicated: 'Dedicated (dedicated)', - wsModeConcurrencyHint: - 'When WS mode is enabled, account concurrency becomes the WS connection pool limit for this account.', - wsModePassthroughHint: 'Passthrough mode does not use the WS connection pool.', + wsModeCtxPoolHint: + 'The gateway gets and reuses upstream WS connections from a pool, with the pool limit determined by gateway configuration.', + wsModePassthroughHint: + 'The gateway opens a separate upstream WS connection for each client session, without using a connection pool.', + wsModeHttpBridgeHint: + 'The gateway converts client WS requests to upstream HTTP requests, then converts SSE streaming responses back into WS messages.', oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode', oauthResponsesWebsocketsV2Desc: 'Only applies to OpenAI OAuth. This account can use OpenAI WebSocket Mode only when enabled.', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index c19ad3653b..a93e548421 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -691,15 +691,16 @@ export default { '默认关闭。开启后可启用 responses_websockets_v2 协议能力(受网关全局开关与账号类型开关约束)。', wsMode: 'WS mode', wsModeDesc: - '仅对当前 OpenAI 账号类型生效;包括 http_bridge 在内的账号 WS mode 仅在全局 gateway.openai_ws.mode_router_v2_enabled=true 时生效。', + '仅对当前 OpenAI 账号类型生效。选择“关闭”可禁用 WS;其余模式需全局 gateway.openai_ws.mode_router_v2_enabled=true 才按所选方式连接,未开启时统一使用上下文池。', wsModeOff: '关闭(off)', wsModeCtxPool: '上下文池(ctx_pool)', wsModePassthrough: '透传(passthrough)', wsModeHttpBridge: 'HTTP 桥接(http_bridge)', wsModeShared: '共享(shared)', wsModeDedicated: '独享(dedicated)', - wsModeConcurrencyHint: '启用 WS mode 后,该账号并发数将作为该账号 WS 连接池上限。', - wsModePassthroughHint: 'passthrough 模式不使用 WS 连接池。', + wsModeCtxPoolHint: '网关从连接池获取并复用上游 WS 连接,连接池上限由网关配置决定。', + wsModePassthroughHint: '网关为每个客户端会话单独建立上游 WS 连接,不使用连接池。', + wsModeHttpBridgeHint: '网关将客户端 WS 请求转换为上游 HTTP 请求,再将 SSE 流式响应转换为 WS 消息返回。', oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode', oauthResponsesWebsocketsV2Desc: '仅对 OpenAI OAuth 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。', diff --git a/frontend/src/utils/__tests__/openaiWsMode.spec.ts b/frontend/src/utils/__tests__/openaiWsMode.spec.ts index 5555224b27..8689b3179a 100644 --- a/frontend/src/utils/__tests__/openaiWsMode.spec.ts +++ b/frontend/src/utils/__tests__/openaiWsMode.spec.ts @@ -7,7 +7,7 @@ import { isOpenAIWSModeEnabled, normalizeOpenAIWSMode, openAIWSModeFromEnabled, - resolveOpenAIWSModeConcurrencyHintKey, + resolveOpenAIWSModeHintKey, resolveOpenAIWSModeFromExtra } from '@/utils/openaiWsMode' @@ -59,18 +59,16 @@ describe('openaiWsMode utils', () => { expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_HTTP_BRIDGE)).toBe(true) }) - it('resolves concurrency hint key by mode', () => { - expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_OFF)).toBe( - 'admin.accounts.openai.wsModeConcurrencyHint' + it('hides the off hint and resolves each active mode separately', () => { + expect(resolveOpenAIWSModeHintKey(OPENAI_WS_MODE_OFF)).toBeNull() + expect(resolveOpenAIWSModeHintKey(OPENAI_WS_MODE_CTX_POOL)).toBe( + 'admin.accounts.openai.wsModeCtxPoolHint' ) - expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_CTX_POOL)).toBe( - 'admin.accounts.openai.wsModeConcurrencyHint' - ) - expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_PASSTHROUGH)).toBe( + expect(resolveOpenAIWSModeHintKey(OPENAI_WS_MODE_PASSTHROUGH)).toBe( 'admin.accounts.openai.wsModePassthroughHint' ) - expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_HTTP_BRIDGE)).toBe( - 'admin.accounts.openai.wsModePassthroughHint' + expect(resolveOpenAIWSModeHintKey(OPENAI_WS_MODE_HTTP_BRIDGE)).toBe( + 'admin.accounts.openai.wsModeHttpBridgeHint' ) }) }) diff --git a/frontend/src/utils/openaiWsMode.ts b/frontend/src/utils/openaiWsMode.ts index 77872bc656..b806746731 100644 --- a/frontend/src/utils/openaiWsMode.ts +++ b/frontend/src/utils/openaiWsMode.ts @@ -44,13 +44,23 @@ export const isOpenAIWSModeEnabled = (mode: OpenAIWSMode): boolean => { return mode !== OPENAI_WS_MODE_OFF } -export const resolveOpenAIWSModeConcurrencyHintKey = ( +export const resolveOpenAIWSModeHintKey = ( mode: OpenAIWSMode -): 'admin.accounts.openai.wsModeConcurrencyHint' | 'admin.accounts.openai.wsModePassthroughHint' => { - if (mode === OPENAI_WS_MODE_PASSTHROUGH || mode === OPENAI_WS_MODE_HTTP_BRIDGE) { - return 'admin.accounts.openai.wsModePassthroughHint' +): + | 'admin.accounts.openai.wsModeCtxPoolHint' + | 'admin.accounts.openai.wsModePassthroughHint' + | 'admin.accounts.openai.wsModeHttpBridgeHint' + | null => { + switch (mode) { + case OPENAI_WS_MODE_CTX_POOL: + return 'admin.accounts.openai.wsModeCtxPoolHint' + case OPENAI_WS_MODE_PASSTHROUGH: + return 'admin.accounts.openai.wsModePassthroughHint' + case OPENAI_WS_MODE_HTTP_BRIDGE: + return 'admin.accounts.openai.wsModeHttpBridgeHint' + default: + return null } - return 'admin.accounts.openai.wsModeConcurrencyHint' } export const resolveOpenAIWSModeFromExtra = (