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/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ac346fea3f..8eec667fa6 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1349,7 +1349,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { subscription, _ := middleware2.GetSubscriptionFromContext(c) requestPlatform := openAICompatibleRequestPlatform(apiKey) - requiredTransport := service.OpenAIUpstreamTransportResponsesWebsocketV2 + requiredTransport := service.OpenAIUpstreamTransportResponsesWebsocketV2Ingress if requestPlatform == service.PlatformGrok { requiredTransport = service.OpenAIUpstreamTransportHTTPSSE } diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 7f68da46c6..7d5e70a15b 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1474,6 +1474,7 @@ const ( OpenAIWSIngressModeDedicated = "dedicated" OpenAIWSIngressModeCtxPool = "ctx_pool" OpenAIWSIngressModePassthrough = "passthrough" + OpenAIWSIngressModeHTTPBridge = "http_bridge" ) func normalizeOpenAIWSIngressMode(mode string) string { @@ -1484,6 +1485,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.go b/backend/internal/service/openai_account_scheduler.go index 42aa494b1f..ab298d4521 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1420,6 +1420,18 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou if s == nil || account == nil { return false } + if requiredTransport == OpenAIUpstreamTransportResponsesWebsocketV2Ingress { + if s.cfg == nil || !s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled { + return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == OpenAIUpstreamTransportResponsesWebsocketV2 + } + mode := account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) + switch mode { + case OpenAIWSIngressModeCtxPool, OpenAIWSIngressModePassthrough, OpenAIWSIngressModeHTTPBridge, OpenAIWSIngressModeShared, OpenAIWSIngressModeDedicated: + return true + default: + return false + } + } return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport } diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 6d8e38d0bc..0255dbbde3 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -2391,6 +2391,7 @@ func TestDefaultOpenAIAccountScheduler_IsAccountTransportCompatible_Branches(t * require.True(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportAny)) require.True(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportHTTPSSE)) require.False(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportResponsesWebsocketV2)) + require.False(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportResponsesWebsocketV2Ingress)) cfg := newSchedulerTestOpenAIWSV2Config() scheduler.service = &OpenAIGatewayService{cfg: cfg} @@ -2406,6 +2407,15 @@ func TestDefaultOpenAIAccountScheduler_IsAccountTransportCompatible_Branches(t * }, } require.True(t, scheduler.isAccountTransportCompatible(account, OpenAIUpstreamTransportResponsesWebsocketV2)) + require.True(t, scheduler.isAccountTransportCompatible(account, OpenAIUpstreamTransportResponsesWebsocketV2Ingress)) + + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + account.Extra["openai_apikey_responses_websockets_v2_mode"] = OpenAIWSIngressModeHTTPBridge + require.False(t, scheduler.isAccountTransportCompatible(account, OpenAIUpstreamTransportResponsesWebsocketV2)) + require.True(t, scheduler.isAccountTransportCompatible(account, OpenAIUpstreamTransportResponsesWebsocketV2Ingress)) + + account.Extra["openai_apikey_responses_websockets_v2_mode"] = OpenAIWSIngressModeOff + require.False(t, scheduler.isAccountTransportCompatible(account, OpenAIUpstreamTransportResponsesWebsocketV2Ingress)) } 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..3fbdbf8534 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 passthroughErrReadCloser struct { + err error +} + +func (r passthroughErrReadCloser) Read(_ []byte) (int, error) { + if r.err != nil { + return 0, r.err + } + return 0, io.ErrUnexpectedEOF +} + +func (r passthroughErrReadCloser) 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 @@ -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"}]}`) @@ -825,11 +841,12 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test } testCases := []struct { - name string - accountType string - statusCode int - body string - assertRepo func(t *testing.T, repo *openAIPassthroughFailoverRepo, start time.Time) + name string + accountType string + statusCode int + body string + expectFailover bool + assertRepo func(t *testing.T, repo *openAIPassthroughFailoverRepo, start time.Time) }{ { name: "oauth_429_rate_limit", @@ -839,6 +856,7 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test resetAt := time.Now().Add(7 * 24 * time.Hour).Unix() return fmt.Sprintf(`{"error":{"message":"The usage limit has been reached","type":"usage_limit_reached","resets_at":%d}}`, resetAt) }(), + expectFailover: true, assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, _ time.Time) { require.Len(t, repo.rateLimitCalls, 1) require.Empty(t, repo.overloadCalls) @@ -846,16 +864,50 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test }, }, { - name: "oauth_529_overload", - accountType: AccountTypeOAuth, - statusCode: 529, - body: `{"error":{"message":"server overloaded","type":"server_error"}}`, + name: "oauth_529_overload", + accountType: AccountTypeOAuth, + statusCode: 529, + body: `{"error":{"message":"server overloaded","type":"server_error"}}`, + expectFailover: true, assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, start time.Time) { require.Empty(t, repo.rateLimitCalls) require.Len(t, repo.overloadCalls, 1) 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"}}`, + expectFailover: false, + 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"}}`, + expectFailover: false, + 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"}}`, + expectFailover: false, + 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, @@ -864,6 +916,7 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test resetAt := time.Now().Add(7 * 24 * time.Hour).Unix() return fmt.Sprintf(`{"error":{"message":"The usage limit has been reached","type":"usage_limit_reached","resets_at":%d}}`, resetAt) }(), + expectFailover: true, assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, _ time.Time) { require.Len(t, repo.rateLimitCalls, 1) require.Empty(t, repo.overloadCalls) @@ -871,10 +924,11 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test }, }, { - name: "apikey_529_overload", - accountType: AccountTypeAPIKey, - statusCode: 529, - body: `{"error":{"message":"server overloaded","type":"server_error"}}`, + name: "apikey_529_overload", + accountType: AccountTypeAPIKey, + statusCode: 529, + body: `{"error":{"message":"server overloaded","type":"server_error"}}`, + expectFailover: true, assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, start time.Time) { require.Empty(t, repo.rateLimitCalls) require.Len(t, repo.overloadCalls, 1) @@ -919,9 +973,15 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test require.Error(t, err) 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 错误给上层换号,而不是直接向客户端写响应") + if tc.expectFailover { + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, tc.statusCode, failoverErr.StatusCode) + require.False(t, c.Writer.Written(), "retryable passthrough 错误应返回 failover 错误给上层换号,而不是直接向客户端写响应") + } else { + require.False(t, errors.As(err, &failoverErr)) + require.True(t, c.Writer.Written(), "非 failover 的 passthrough http 错误应直接写回客户端") + require.Equal(t, tc.statusCode, rec.Code) + } v, ok := c.Get(OpsUpstreamErrorsKey) require.True(t, ok) @@ -929,7 +989,11 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test require.True(t, ok) require.NotEmpty(t, arr) require.True(t, arr[len(arr)-1].Passthrough) - require.Equal(t, "failover", arr[len(arr)-1].Kind) + if tc.expectFailover { + require.Equal(t, "failover", arr[len(arr)-1].Kind) + } else { + require.Equal(t, "http_error", arr[len(arr)-1].Kind) + } require.Equal(t, tc.statusCode, arr[len(arr)-1].UpstreamStatusCode) tc.assertRepo(t, repo, start) @@ -937,6 +1001,73 @@ 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 + expectFailover bool + }{ + { + name: "request_error", + err: errors.New("stream disconnected before completion"), + expectFailover: true, + }, + { + name: "read_error", + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-compact"}}, + Body: passthroughErrReadCloser{err: io.ErrUnexpectedEOF}, + }, + expectFailover: false, + }, + } + + 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 + if tt.expectFailover { + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.False(t, c.Writer.Written(), "compact 网络错误应交给外层 failover,而不是直接写回客户端") + } else { + require.False(t, errors.As(err, &failoverErr)) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + require.False(t, c.Writer.Written()) + } + }) + } +} + func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index f57515302d..067eeb6029 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -2524,12 +2524,14 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( hooks, wsDecision, ) + case OpenAIWSIngressModeHTTPBridge: + forceHTTPBridge = true case OpenAIWSIngressModeCtxPool, OpenAIWSIngressModeShared, OpenAIWSIngressModeDedicated: // continue default: return NewOpenAIWSClientCloseError( coderws.StatusPolicyViolation, - "websocket mode only supports ctx_pool/passthrough", + "websocket mode only supports ctx_pool/passthrough/http_bridge", nil, ) } @@ -2840,7 +2842,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } refreshIngressRouteState(firstPayload) - if s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) { + if forceHTTPBridge || s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) { logOpenAIWSModeInfo( "ingress_ws_http_bridge_start account_id=%d account_type=%s payload_bytes=%d threshold_bytes=%d has_session_hash=%v store_disabled=%v", account.ID, diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 856e81be02..9ae1b855ea 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -830,6 +830,152 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader)) } +func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_bridge_1"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_http_bridge_1\",\"usage\":{\"input_tokens\":2,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: upstream, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + } + + account := &Account{ + ID: 552, + Name: "openai-ingress-http-bridge", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + }, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge, + }, + } + + serverErrCh := make(chan error, 1) + resultCh := make(chan *OpenAIForwardResult, 1) + hooks := &OpenAIWSIngressHooks{ + AfterTurn: func(_ int, result *OpenAIForwardResult, turnErr error) { + if turnErr == nil && result != nil { + resultCh <- result + } + }, + } + + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{ + CompressionMode: coderws.CompressionContextTakeover, + }) + if err != nil { + serverErrCh <- err + return + } + defer func() { + _ = conn.CloseNow() + }() + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + req := r.Clone(r.Context()) + req.Header = req.Header.Clone() + req.Header.Set("User-Agent", "unit-test-agent/1.0") + ginCtx.Request = req + + readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + msgType, firstMessage, readErr := conn.Read(readCtx) + cancel() + if readErr != nil { + serverErrCh <- readErr + return + } + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + serverErrCh <- errors.New("unsupported websocket client message type") + return + } + + serverErrCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, hooks) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { + _ = clientConn.CloseNow() + }() + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + cancelWrite() + require.NoError(t, err) + + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, event1, readErr1 := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, readErr1) + require.Equal(t, "response.output_text.delta", gjson.GetBytes(event1, "type").String()) + require.Equal(t, "hello", gjson.GetBytes(event1, "delta").String()) + + readCtx2, cancelRead2 := context.WithTimeout(context.Background(), 3*time.Second) + _, event2, readErr2 := clientConn.Read(readCtx2) + cancelRead2() + require.NoError(t, readErr2) + require.Equal(t, "response.completed", gjson.GetBytes(event2, "type").String()) + require.Equal(t, "resp_http_bridge_1", gjson.GetBytes(event2, "response.id").String()) + + _ = clientConn.Close(coderws.StatusNormalClosure, "done") + + select { + case serverErr := <-serverErrCh: + require.NoError(t, serverErr) + case <-time.After(5 * time.Second): + t.Fatal("等待 http_bridge websocket 结束超时") + } + + select { + case result := <-resultCh: + require.Equal(t, "resp_http_bridge_1", result.RequestID) + require.True(t, result.OpenAIWSMode) + require.Equal(t, 2, result.Usage.InputTokens) + require.Equal(t, 1, result.Usage.OutputTokens) + require.Equal(t, 1, result.Usage.CacheReadInputTokens) + require.NotNil(t, result.FirstTokenMs) + case <-time.After(2 * time.Second): + t.Fatal("未收到 http_bridge turn 结果回调") + } + + require.NotNil(t, upstream.lastReq, "http_bridge 模式应调用 HTTP 上游") +} + func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(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..e11975bf37 100644 --- a/backend/internal/service/openai_ws_protocol_resolver.go +++ b/backend/internal/service/openai_ws_protocol_resolver.go @@ -10,6 +10,9 @@ const ( OpenAIUpstreamTransportHTTPSSE OpenAIUpstreamTransport = "http_sse" OpenAIUpstreamTransportResponsesWebsocket OpenAIUpstreamTransport = "responses_websockets" OpenAIUpstreamTransportResponsesWebsocketV2 OpenAIUpstreamTransport = "responses_websockets_v2" + // OpenAIUpstreamTransportResponsesWebsocketV2Ingress 用于 WS ingress 入口选账号: + // mode_router_v2 开启时允许 ctx_pool/passthrough/http_bridge,拒绝 off。 + OpenAIUpstreamTransportResponsesWebsocketV2Ingress OpenAIUpstreamTransport = "responses_websockets_v2_ingress" ) // OpenAIWSProtocolDecision 表示协议决策结果。 @@ -71,6 +74,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..d24488b47a 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' @@ -1194,7 +1195,7 @@ const allOpenAIPassthroughCapable = computed(() => { targetSelectedPlatforms.value.length === 1 && targetSelectedPlatforms.value[0] === 'openai' && targetSelectedTypes.value.length > 0 && - targetSelectedTypes.value.every(t => t === 'oauth' || t === 'apikey') + targetSelectedTypes.value.every(t => t === 'oauth' || t === 'setup-token' || t === 'apikey') ) }) @@ -1203,7 +1204,7 @@ const allOpenAIOAuth = computed(() => { targetSelectedPlatforms.value.length === 1 && targetSelectedPlatforms.value[0] === 'openai' && targetSelectedTypes.value.length > 0 && - targetSelectedTypes.value.every(t => t === 'oauth') + targetSelectedTypes.value.every(t => t === 'oauth' || t === 'setup-token') ) }) @@ -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 c43709a7ac..3514153c82 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3337,6 +3337,7 @@ import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_PASSTHROUGH, + OPENAI_WS_MODE_HTTP_BRIDGE, isOpenAIWSModeEnabled, resolveOpenAIWSModeConcurrencyHintKey, type OpenAIWSMode @@ -3703,7 +3704,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 9a815ee813..b5f97753d8 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1342,7 +1342,7 @@
@@ -1372,7 +1372,7 @@
@@ -1432,7 +1432,7 @@
@@ -1446,7 +1446,7 @@

-
@@ -1680,7 +1680,7 @@
@@ -1735,7 +1735,7 @@
@@ -2442,6 +2442,7 @@ import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_PASSTHROUGH, + OPENAI_WS_MODE_HTTP_BRIDGE, isOpenAIWSModeEnabled, resolveOpenAIWSModeConcurrencyHintKey, type OpenAIWSMode, @@ -2660,7 +2661,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: () => { @@ -3023,7 +3025,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true - // Load OpenAI passthrough toggle (OpenAI OAuth/API Key) + // Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key) openaiPassthroughEnabled.value = false openAICompactMode.value = 'auto' openAIResponsesMode.value = 'auto' @@ -3037,7 +3039,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' - if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { + if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto' if (newAccount.type === 'apikey') { @@ -3069,7 +3071,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'], defaultMode: OPENAI_WS_MODE_OFF }) - if (newAccount.type === 'oauth') { + if (newAccount.type === 'oauth' || newAccount.type === 'setup-token') { codexCLIOnlyEnabled.value = extra?.codex_cli_only === true codexCLIOnlyAppServerEnabled.value = extra?.codex_cli_only_allow_app_server === true @@ -4138,12 +4140,12 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } - // For OpenAI OAuth/API Key accounts, handle passthrough mode in extra - if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) { - const currentExtra = (props.account.extra as Record) || {} - const newExtra: Record = { ...currentExtra } + // For OpenAI OAuth/SetupToken/API Key accounts, handle passthrough mode in extra + if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'setup-token' || props.account.type === 'apikey')) { + const currentExtra = (props.account.extra as Record) || {} + const newExtra: Record = { ...currentExtra } const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true - if (props.account.type === 'oauth') { + if (props.account.type === 'oauth' || props.account.type === 'setup-token') { newExtra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value newExtra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value) } else if (props.account.type === 'apikey') { @@ -4198,7 +4200,7 @@ const handleSubmit = async () => { newExtra.codex_image_generation_bridge = codexImageGenerationBridgeMode.value === 'enabled' } - if (props.account.type === 'oauth') { + if (props.account.type === 'oauth' || props.account.type === 'setup-token') { if (codexCLIOnlyEnabled.value) { newExtra.codex_cli_only = true } else if (hadCodexCLIOnlyEnabled) { 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/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index de59b4d5fe..381d512ee7 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -241,6 +241,17 @@ function buildAntigravityAccount(projectId = 'configured-project') { } as any } +function buildOpenAISetupTokenAccount() { + return { + ...buildAccount(), + type: 'setup-token', + extra: { + openai_oauth_responses_websockets_v2_mode: 'ctx_pool', + openai_oauth_responses_websockets_v2_enabled: true + } + } as any +} + function mountModal(account = buildAccount()) { return mount(EditAccountModal, { props: { @@ -566,6 +577,23 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled') }) + it('setup-token account can select and submit OAuth WS mode', async () => { + const account = buildOpenAISetupTokenAccount() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('[data-testid="edit-openai-ws-mode-select"]').setValue('http_bridge') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_oauth_responses_websockets_v2_mode).toBe('http_bridge') + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_oauth_responses_websockets_v2_enabled).toBe(true) + }) + it('allows saving apikey account when backend redacted api_key but credentials_status reports it exists', async () => { // 新前端 + 新后端:响应已脱敏,credentials 里没有 api_key,credentials_status.has_api_key=true const account = buildAccount() diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index cefd25279f..831b7d7eae 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3515,6 +3515,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 d65d9250c0..64c62f39f8 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3693,6 +3693,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'