From 901958ba1b8ee51238d687aa932e215fa5cb7f51 Mon Sep 17 00:00:00 2001 From: zy6p Date: Wed, 29 Apr 2026 17:42:45 +0800 Subject: [PATCH] feat(openai-ws): add http_bridge ingress mode and account ws selector (cherry picked from commit 58647ff63d7ff994c7c14c84c4913a8d3ed6be05) (cherry picked from commit 9f18fb7c24e187fb20e90d1d9e89fcec91c56937) --- backend/internal/config/config.go | 12 +- backend/internal/config/config_test.go | 19 ++- backend/internal/service/account.go | 3 + .../account_openai_passthrough_test.go | 11 ++ .../service/openai_account_scheduler_test.go | 4 + .../service/openai_oauth_passthrough_test.go | 110 +++++++++++++++++- .../service/openai_ws_protocol_resolver.go | 2 + .../openai_ws_protocol_resolver_test.go | 14 +++ deploy/.env.example | 3 + deploy/config.example.yaml | 7 +- .../account/BulkEditAccountModal.vue | 4 +- .../components/account/CreateAccountModal.vue | 4 +- .../components/account/EditAccountModal.vue | 4 +- .../__tests__/BulkEditAccountModal.spec.ts | 6 +- frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + .../src/utils/__tests__/openaiWsMode.spec.ts | 6 + frontend/src/utils/openaiWsMode.ts | 7 +- 18 files changed, 201 insertions(+), 17 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c25d13a8b4..18baa34881 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -721,6 +721,9 @@ type GatewayConfig struct { // OpenAIPassthroughAllowTimeoutHeaders: OpenAI 透传模式是否放行客户端超时头 // 关闭(默认)可避免 x-stainless-timeout 等头导致上游提前断流。 OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"` + // OpenAICompactModel: /responses/compact 上游使用的模型。 + // compact 端点支持模型滞后于普通 /responses 时,可用该配置降级规避上游错误。 + OpenAICompactModel string `mapstructure:"openai_compact_model"` // OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP) OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"` // OpenAIScheduler: OpenAI 高级调度器粘性逃逸配置 @@ -867,7 +870,7 @@ func (c *UserMessageQueueConfig) GetEffectiveMode() string { type GatewayOpenAIWSConfig struct { // ModeRouterV2Enabled: 新版 WS mode 路由开关(默认 false;关闭时保持 legacy 行为) ModeRouterV2Enabled bool `mapstructure:"mode_router_v2_enabled"` - // IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough) + // IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough/http_bridge) IngressModeDefault string `mapstructure:"ingress_mode_default"` // Enabled: 全局总开关(默认 true) Enabled bool `mapstructure:"enabled"` @@ -1836,6 +1839,7 @@ func setDefaults() { viper.SetDefault("gateway.force_codex_cli", false) viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) + viper.SetDefault("gateway.openai_compact_model", "gpt-5.4") // OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚) viper.SetDefault("gateway.openai_ws.enabled", true) viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false) @@ -2624,11 +2628,11 @@ func (c *Config) Validate() error { } if mode := strings.ToLower(strings.TrimSpace(c.Gateway.OpenAIWS.IngressModeDefault)); mode != "" { switch mode { - case "off", "ctx_pool", "passthrough": + case "off", "ctx_pool", "passthrough", "http_bridge": case "shared", "dedicated": - slog.Warn("gateway.openai_ws.ingress_mode_default is deprecated, treating as ctx_pool; please update to off|ctx_pool|passthrough", "value", mode) + slog.Warn("gateway.openai_ws.ingress_mode_default is deprecated, treating as ctx_pool; please update to off|ctx_pool|passthrough|http_bridge", "value", mode) default: - return fmt.Errorf("gateway.openai_ws.ingress_mode_default must be one of off|ctx_pool|passthrough") + return fmt.Errorf("gateway.openai_ws.ingress_mode_default must be one of off|ctx_pool|passthrough|http_bridge") } } if mode := strings.ToLower(strings.TrimSpace(c.Gateway.OpenAIWS.StoreDisabledConnMode)); mode != "" { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index be31976156..bf7a327563 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -184,6 +184,23 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) { } } +func TestLoadDefaultOpenAICompactModel(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "gpt-5.4", cfg.Gateway.OpenAICompactModel) +} + +func TestLoadOpenAICompactModelFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("GATEWAY_OPENAI_COMPACT_MODEL", "gpt-5.3-codex") + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "gpt-5.3-codex", cfg.Gateway.OpenAICompactModel) +} + func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) { resetViperWithJWTSecret(t) @@ -1678,7 +1695,7 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) { wantErr: "gateway.openai_ws.store_disabled_conn_mode", }, { - name: "ingress_mode_default 必须为 off|ctx_pool|passthrough", + name: "ingress_mode_default 必须为 off|ctx_pool|passthrough|http_bridge", mutate: func(c *Config) { c.Gateway.OpenAIWS.IngressModeDefault = "invalid" }, wantErr: "gateway.openai_ws.ingress_mode_default", }, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ffd73d2d80..aba29bfda3 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1445,6 +1445,7 @@ const ( OpenAIWSIngressModeDedicated = "dedicated" OpenAIWSIngressModeCtxPool = "ctx_pool" OpenAIWSIngressModePassthrough = "passthrough" + OpenAIWSIngressModeHTTPBridge = "http_bridge" ) func normalizeOpenAIWSIngressMode(mode string) string { @@ -1455,6 +1456,8 @@ func normalizeOpenAIWSIngressMode(mode string) string { return OpenAIWSIngressModeCtxPool case OpenAIWSIngressModePassthrough: return OpenAIWSIngressModePassthrough + case OpenAIWSIngressModeHTTPBridge: + return OpenAIWSIngressModeHTTPBridge case OpenAIWSIngressModeShared: return OpenAIWSIngressModeShared case OpenAIWSIngressModeDedicated: diff --git a/backend/internal/service/account_openai_passthrough_test.go b/backend/internal/service/account_openai_passthrough_test.go index 50c2b7cb86..2605fdaeb3 100644 --- a/backend/internal/service/account_openai_passthrough_test.go +++ b/backend/internal/service/account_openai_passthrough_test.go @@ -229,6 +229,17 @@ func TestAccount_ResolveOpenAIResponsesWebSocketV2Mode(t *testing.T) { require.Equal(t, OpenAIWSIngressModePassthrough, account.ResolveOpenAIResponsesWebSocketV2Mode(OpenAIWSIngressModeCtxPool)) }) + t.Run("oauth mode supports http_bridge", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge, + }, + } + require.Equal(t, OpenAIWSIngressModeHTTPBridge, account.ResolveOpenAIResponsesWebSocketV2Mode(OpenAIWSIngressModeCtxPool)) + }) + t.Run("legacy enabled maps to ctx_pool", func(t *testing.T) { account := &Account{ Platform: PlatformOpenAI, diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 6d8e38d0bc..ad7b8b6738 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -2406,6 +2406,10 @@ func TestDefaultOpenAIAccountScheduler_IsAccountTransportCompatible_Branches(t * }, } require.True(t, scheduler.isAccountTransportCompatible(account, OpenAIUpstreamTransportResponsesWebsocketV2)) + + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + account.Extra["openai_apikey_responses_websockets_v2_mode"] = OpenAIWSIngressModeHTTPBridge + require.False(t, scheduler.isAccountTransportCompatible(account, OpenAIUpstreamTransportResponsesWebsocketV2)) } func int64PtrForTest(v int64) *int64 { diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 8d326b7637..d5f4c22a3e 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -35,6 +36,21 @@ type httpUpstreamRecorder struct { err error } +type errReadCloser struct { + err error +} + +func (r errReadCloser) Read(_ []byte) (int, error) { + if r.err != nil { + return 0, r.err + } + return 0, io.ErrUnexpectedEOF +} + +func (r errReadCloser) Close() error { + return nil +} + func (u *httpUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { u.lastReq = req u.lastProxyURL = proxyURL @@ -447,7 +463,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactUsesJSONAndKeepsNonStreami require.False(t, gjson.GetBytes(upstream.lastBody, "store").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "stream").Exists()) - require.Equal(t, "gpt-5.1-codex", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, defaultOpenAICompactModel, gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "compact me", gjson.GetBytes(upstream.lastBody, "input.0.text").String()) require.Equal(t, "local-test-instructions", strings.TrimSpace(gjson.GetBytes(upstream.lastBody, "instructions").String())) require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept")) @@ -799,7 +815,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF require.Equal(t, "http_error", arr[len(arr)-1].Kind) } -func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *testing.T) { +func TestOpenAIGatewayService_OpenAIPassthrough_RetryableStatusesTriggerFailover(t *testing.T) { gin.SetMode(gin.TestMode) originalBody := []byte(`{"model":"gpt-5.2","stream":false,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`) @@ -856,6 +872,36 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test require.WithinDuration(t, start.Add(10*time.Minute), repo.overloadCalls[0], 5*time.Second) }, }, + { + name: "oauth_502_bad_gateway", + accountType: AccountTypeOAuth, + statusCode: http.StatusBadGateway, + body: `{"error":{"message":"bad gateway","type":"server_error"}}`, + assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, _ time.Time) { + require.Empty(t, repo.rateLimitCalls) + require.Empty(t, repo.overloadCalls) + }, + }, + { + name: "oauth_503_unavailable", + accountType: AccountTypeOAuth, + statusCode: http.StatusServiceUnavailable, + body: `{"error":{"message":"service unavailable","type":"server_error"}}`, + assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, _ time.Time) { + require.Empty(t, repo.rateLimitCalls) + require.Empty(t, repo.overloadCalls) + }, + }, + { + name: "oauth_504_gateway_timeout", + accountType: AccountTypeOAuth, + statusCode: http.StatusGatewayTimeout, + body: `{"error":{"message":"gateway timeout","type":"server_error"}}`, + assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, _ time.Time) { + require.Empty(t, repo.rateLimitCalls) + require.Empty(t, repo.overloadCalls) + }, + }, { name: "apikey_429_rate_limit", accountType: AccountTypeAPIKey, @@ -921,7 +967,7 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) require.Equal(t, tc.statusCode, failoverErr.StatusCode) - require.False(t, c.Writer.Written(), "429/529 passthrough 应返回 failover 错误给上层换号,而不是直接向客户端写响应") + require.False(t, c.Writer.Written(), "retryable passthrough 错误应返回 failover 错误给上层换号,而不是直接向客户端写响应") v, ok := c.Get(OpsUpstreamErrorsKey) require.True(t, ok) @@ -937,6 +983,64 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test } } +func TestOpenAIGatewayService_OpenAIPassthrough_CompactNetworkErrorsTriggerFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + resp *http.Response + err error + }{ + { + name: "request_error", + err: errors.New("stream disconnected before completion"), + }, + { + name: "read_error", + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-compact"}}, + Body: errReadCloser{err: io.ErrUnexpectedEOF}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0") + + upstream := &httpUpstreamRecorder{resp: tt.resp, err: tt.err} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 123, + Name: "acc", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": true}, + Status: StatusActive, + Schedulable: true, + RateMultiplier: f64p(1), + } + body := []byte(`{"model":"gpt-5.5","instructions":"local-test-instructions","input":[{"type":"text","text":"compact me"}]}`) + + _, err := svc.Forward(context.Background(), c, account, body) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.False(t, c.Writer.Written(), "compact 网络错误应交给外层 failover,而不是直接写回客户端") + }) + } +} + func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_protocol_resolver.go b/backend/internal/service/openai_ws_protocol_resolver.go index 7266759c88..922558c447 100644 --- a/backend/internal/service/openai_ws_protocol_resolver.go +++ b/backend/internal/service/openai_ws_protocol_resolver.go @@ -71,6 +71,8 @@ func (r *defaultOpenAIWSProtocolResolver) Resolve(account *Account) OpenAIWSProt return openAIWSHTTPDecision("account_mode_off") case OpenAIWSIngressModeCtxPool, OpenAIWSIngressModePassthrough: // continue + case OpenAIWSIngressModeHTTPBridge: + return openAIWSHTTPDecision("ws_v2_mode_http_bridge") case OpenAIWSIngressModeShared, OpenAIWSIngressModeDedicated: // 历史值兼容:按 ctx_pool 处理。 mode = OpenAIWSIngressModeCtxPool diff --git a/backend/internal/service/openai_ws_protocol_resolver_test.go b/backend/internal/service/openai_ws_protocol_resolver_test.go index 4d5dc5f18b..6d18604d03 100644 --- a/backend/internal/service/openai_ws_protocol_resolver_test.go +++ b/backend/internal/service/openai_ws_protocol_resolver_test.go @@ -202,6 +202,20 @@ func TestOpenAIWSProtocolResolver_Resolve_ModeRouterV2(t *testing.T) { require.Equal(t, "ws_v2_mode_passthrough", decision.Reason) }) + t.Run("http_bridge mode routes to http_sse", func(t *testing.T) { + httpBridgeAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge, + }, + } + decision := NewOpenAIWSProtocolResolver(cfg).Resolve(httpBridgeAccount) + require.Equal(t, OpenAIUpstreamTransportHTTPSSE, decision.Transport) + require.Equal(t, "ws_v2_mode_http_bridge", decision.Reason) + }) + t.Run("non-positive concurrency is rejected in v2 router", func(t *testing.T) { invalidConcurrency := &Account{ Platform: PlatformOpenAI, diff --git a/deploy/.env.example b/deploy/.env.example index e80663ef1f..59e4b44b91 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -251,6 +251,9 @@ RATE_LIMIT_OVERLOAD_COOLDOWN_MINUTES=10 # # 默认:false GATEWAY_FORCE_CODEX_CLI=false +# OpenAI /responses/compact 上游模型(默认 gpt-5.4)。 +# 当 compact 端点暂未支持更新模型时,可通过这里降级规避失败。 +GATEWAY_OPENAI_COMPACT_MODEL=gpt-5.4 # OpenAI/Codex 等待上游响应头超时(秒);0 表示不使用本地响应头超时截断。 GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=0 # OpenAI HTTP 上游默认启用 HTTP/2;如需紧急回滚可设为 false。 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 566c277196..5a3afb0315 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -242,11 +242,16 @@ gateway: # OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout) # 默认 false:过滤超时头,降低上游提前断流风险。 openai_passthrough_allow_timeout_headers: false + # Model used for OpenAI /responses/compact upstream requests (default: gpt-5.4). + # OpenAI /responses/compact 上游模型(默认 gpt-5.4)。 + # Use this to avoid compact failures when newer models are not yet supported by the compact endpoint. + # 当 compact 端点暂未支持更新模型时,可通过这里降级规避失败。 + openai_compact_model: "gpt-5.4" # OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP) openai_ws: # 新版 WS mode 路由(默认关闭)。关闭时保持当前 legacy 实现行为。 mode_router_v2_enabled: false - # ingress 默认模式:off|ctx_pool|passthrough(仅 mode_router_v2_enabled=true 生效) + # ingress 默认模式:off|ctx_pool|passthrough|http_bridge(仅 mode_router_v2_enabled=true 生效) # 兼容旧值:shared/dedicated 会按 ctx_pool 处理。 ingress_mode_default: ctx_pool # 全局总开关,默认 true;关闭时所有请求保持原有 HTTP/SSE 路由 diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index c062befebe..682bb489b0 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -1153,6 +1153,7 @@ import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_PASSTHROUGH, + OPENAI_WS_MODE_HTTP_BRIDGE, isOpenAIWSModeEnabled, resolveOpenAIWSModeConcurrencyHintKey } from '@/utils/openaiWsMode' @@ -1330,7 +1331,8 @@ const isOpenAIModelRestrictionDisabled = computed( const openAIWSModeOptions = computed(() => [ { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, { value: OPENAI_WS_MODE_CTX_POOL, label: t('admin.accounts.openai.wsModeCtxPool') }, - { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') } + { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }, + { value: OPENAI_WS_MODE_HTTP_BRIDGE, label: t('admin.accounts.openai.wsModeHttpBridge') } ]) const openAICompactModeOptions = computed(() => [ { value: 'auto', label: t('admin.accounts.openai.compactModeAuto') }, diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 4df73c5309..8dcb8e2cb1 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3313,6 +3313,7 @@ import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_PASSTHROUGH, + OPENAI_WS_MODE_HTTP_BRIDGE, isOpenAIWSModeEnabled, resolveOpenAIWSModeConcurrencyHintKey, type OpenAIWSMode @@ -3677,7 +3678,8 @@ const geminiSelectedTier = computed(() => { const openAIWSModeOptions = computed(() => [ { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, { value: OPENAI_WS_MODE_CTX_POOL, label: t('admin.accounts.openai.wsModeCtxPool') }, - { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') } + { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }, + { value: OPENAI_WS_MODE_HTTP_BRIDGE, label: t('admin.accounts.openai.wsModeHttpBridge') } ]) const openaiResponsesWebSocketV2Mode = computed({ diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 747e171fed..dff99a6bf4 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2424,6 +2424,7 @@ import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_PASSTHROUGH, + OPENAI_WS_MODE_HTTP_BRIDGE, isOpenAIWSModeEnabled, resolveOpenAIWSModeConcurrencyHintKey, type OpenAIWSMode, @@ -2636,7 +2637,8 @@ const editResetTimezone = ref(null) const openAIWSModeOptions = computed(() => [ { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, { value: OPENAI_WS_MODE_CTX_POOL, label: t('admin.accounts.openai.wsModeCtxPool') }, - { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') } + { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }, + { value: OPENAI_WS_MODE_HTTP_BRIDGE, label: t('admin.accounts.openai.wsModeHttpBridge') } ]) const openaiResponsesWebSocketV2Mode = computed({ get: () => { diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 1ccf8ed363..31f6e3bd26 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -149,21 +149,21 @@ describe('BulkEditAccountModal', () => { }) }) - it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段', async () => { + it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段(含 http_bridge)', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['oauth'] }) await wrapper.get('#bulk-edit-openai-ws-mode-enabled').setValue(true) - await wrapper.get('[data-testid="bulk-edit-openai-ws-mode-select"]').setValue('passthrough') + await wrapper.get('[data-testid="bulk-edit-openai-ws-mode-select"]').setValue('http_bridge') await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') await flushPromises() expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { extra: { - openai_oauth_responses_websockets_v2_mode: 'passthrough', + openai_oauth_responses_websockets_v2_mode: 'http_bridge', openai_oauth_responses_websockets_v2_enabled: true } }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 34f5e891c8..52bce79987 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3425,6 +3425,7 @@ export default { wsModeOff: 'Off (off)', wsModeCtxPool: 'Context Pool (ctx_pool)', wsModePassthrough: 'Passthrough (passthrough)', + wsModeHttpBridge: 'HTTP Bridge (http_bridge)', wsModeShared: 'Shared (shared)', wsModeDedicated: 'Dedicated (dedicated)', wsModeConcurrencyHint: diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index cf51d588d9..9457cb7d08 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3598,6 +3598,7 @@ export default { wsModeOff: '关闭(off)', wsModeCtxPool: '上下文池(ctx_pool)', wsModePassthrough: '透传(passthrough)', + wsModeHttpBridge: 'HTTP 桥接(http_bridge)', wsModeShared: '共享(shared)', wsModeDedicated: '独享(dedicated)', wsModeConcurrencyHint: '启用 WS mode 后,该账号并发数将作为该账号 WS 连接池上限。', diff --git a/frontend/src/utils/__tests__/openaiWsMode.spec.ts b/frontend/src/utils/__tests__/openaiWsMode.spec.ts index 8e4f33b2cc..5555224b27 100644 --- a/frontend/src/utils/__tests__/openaiWsMode.spec.ts +++ b/frontend/src/utils/__tests__/openaiWsMode.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { OPENAI_WS_MODE_CTX_POOL, + OPENAI_WS_MODE_HTTP_BRIDGE, OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_PASSTHROUGH, isOpenAIWSModeEnabled, @@ -15,6 +16,7 @@ describe('openaiWsMode utils', () => { expect(normalizeOpenAIWSMode('off')).toBe(OPENAI_WS_MODE_OFF) expect(normalizeOpenAIWSMode('ctx_pool')).toBe(OPENAI_WS_MODE_CTX_POOL) expect(normalizeOpenAIWSMode('passthrough')).toBe(OPENAI_WS_MODE_PASSTHROUGH) + expect(normalizeOpenAIWSMode('http_bridge')).toBe(OPENAI_WS_MODE_HTTP_BRIDGE) expect(normalizeOpenAIWSMode(' Shared ')).toBe(OPENAI_WS_MODE_CTX_POOL) expect(normalizeOpenAIWSMode('DEDICATED')).toBe(OPENAI_WS_MODE_CTX_POOL) expect(normalizeOpenAIWSMode('invalid')).toBeNull() @@ -54,6 +56,7 @@ describe('openaiWsMode utils', () => { expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_OFF)).toBe(false) expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_CTX_POOL)).toBe(true) expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_PASSTHROUGH)).toBe(true) + expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_HTTP_BRIDGE)).toBe(true) }) it('resolves concurrency hint key by mode', () => { @@ -66,5 +69,8 @@ describe('openaiWsMode utils', () => { expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_PASSTHROUGH)).toBe( 'admin.accounts.openai.wsModePassthroughHint' ) + expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_HTTP_BRIDGE)).toBe( + 'admin.accounts.openai.wsModePassthroughHint' + ) }) }) diff --git a/frontend/src/utils/openaiWsMode.ts b/frontend/src/utils/openaiWsMode.ts index 52eba8b033..77872bc656 100644 --- a/frontend/src/utils/openaiWsMode.ts +++ b/frontend/src/utils/openaiWsMode.ts @@ -1,16 +1,19 @@ export const OPENAI_WS_MODE_OFF = 'off' export const OPENAI_WS_MODE_CTX_POOL = 'ctx_pool' export const OPENAI_WS_MODE_PASSTHROUGH = 'passthrough' +export const OPENAI_WS_MODE_HTTP_BRIDGE = 'http_bridge' export type OpenAIWSMode = | typeof OPENAI_WS_MODE_OFF | typeof OPENAI_WS_MODE_CTX_POOL | typeof OPENAI_WS_MODE_PASSTHROUGH + | typeof OPENAI_WS_MODE_HTTP_BRIDGE const OPENAI_WS_MODES = new Set([ OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_CTX_POOL, - OPENAI_WS_MODE_PASSTHROUGH + OPENAI_WS_MODE_PASSTHROUGH, + OPENAI_WS_MODE_HTTP_BRIDGE ]) export interface ResolveOpenAIWSModeOptions { @@ -44,7 +47,7 @@ export const isOpenAIWSModeEnabled = (mode: OpenAIWSMode): boolean => { export const resolveOpenAIWSModeConcurrencyHintKey = ( mode: OpenAIWSMode ): 'admin.accounts.openai.wsModeConcurrencyHint' | 'admin.accounts.openai.wsModePassthroughHint' => { - if (mode === OPENAI_WS_MODE_PASSTHROUGH) { + if (mode === OPENAI_WS_MODE_PASSTHROUGH || mode === OPENAI_WS_MODE_HTTP_BRIDGE) { return 'admin.accounts.openai.wsModePassthroughHint' } return 'admin.accounts.openai.wsModeConcurrencyHint'