diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go index 5a063a7d71..613f11d048 100644 --- a/backend/internal/service/image_generation_intent.go +++ b/backend/internal/service/image_generation_intent.go @@ -9,9 +9,23 @@ import ( const ( openAIResponsesEndpoint = "/v1/responses" openAIResponsesCompactEndpoint = "/v1/responses/compact" + responsesLiteHeader = "X-OpenAI-Internal-Codex-Responses-Lite" + responsesLiteHeaderKey = "x-openai-internal-codex-responses-lite" + responsesLiteWSMetadataKey = "ws_request_header_x_openai_internal_codex_responses_lite" imageGenerationPermissionMessage = "Image generation is not enabled for this group" ) +func isOpenAIResponsesLiteHeader(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "true") +} + +func isOpenAIResponsesLiteWebSocketPayload(body []byte) bool { + if len(body) == 0 || !gjson.ValidBytes(body) { + return false + } + return isOpenAIResponsesLiteHeader(gjson.GetBytes(body, "client_metadata."+responsesLiteWSMetadataKey).String()) +} + // ImageGenerationPermissionMessage returns the stable end-user error text for disabled groups. func ImageGenerationPermissionMessage() string { return imageGenerationPermissionMessage diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 1c13738020..b7c850d0c8 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -173,7 +173,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if apiKey != nil { imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group) } - codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) + codexImageGenerationBridgeEnabled := isCodexCLI && + !isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) && + imageGenerationAllowed && + codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && + s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) var imageIntent bool if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { decoded, decodeErr := ensureReqBody() diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 6817aa7e98..11a8255bfa 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -70,6 +70,7 @@ var openaiAllowedHeaders = map[string]bool{ "x-codex-beta-features": true, "x-codex-turn-state": true, "x-codex-turn-metadata": true, + responsesLiteHeaderKey: true, } // OpenAI passthrough allowed headers whitelist. @@ -86,6 +87,7 @@ var openaiPassthroughAllowedHeaders = map[string]bool{ "x-codex-beta-features": true, "x-codex-turn-state": true, "x-codex-turn-metadata": true, + responsesLiteHeaderKey: true, } // codex_cli_only 拒绝时记录的请求头白名单(仅用于诊断日志,不参与上游透传) diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index 4dd784ddb6..090948afd4 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -87,11 +87,13 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability( name string allowImages bool bridgeEnabled bool + responsesLite bool wantInjected bool }{ {name: "disabled group skips injection", allowImages: false, bridgeEnabled: true, wantInjected: false}, {name: "enabled group skips injection by default", allowImages: true, bridgeEnabled: false, wantInjected: false}, {name: "enabled group injects image tool when bridge enabled", allowImages: true, bridgeEnabled: true, wantInjected: true}, + {name: "responses lite skips hosted image bridge", allowImages: true, bridgeEnabled: true, responsesLite: true, wantInjected: false}, } for _, tt := range tests { @@ -106,6 +108,9 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability( svc := newOpenAIImageGenerationControlTestService(upstream) svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = tt.bridgeEnabled c, _ := newOpenAIImageGenerationControlTestContext(tt.allowImages, "codex_cli_rs/0.98.0") + if tt.responsesLite { + c.Request.Header.Set(responsesLiteHeader, "true") + } account := newOpenAIImageGenerationControlTestAccount() result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`)) @@ -115,6 +120,11 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability( require.NotNil(t, upstream.lastReq) hasImageTool := gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists() require.Equal(t, tt.wantInjected, hasImageTool) + expectedLiteHeader := "" + if tt.responsesLite { + expectedLiteHeader = "true" + } + require.Equal(t, expectedLiteHeader, upstream.lastReq.Header.Get(responsesLiteHeader)) instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() require.Equal(t, tt.wantInjected, strings.Contains(instructions, "image_generation")) toolChoice := gjson.GetBytes(upstream.lastBody, "tool_choice") @@ -126,6 +136,24 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability( } } +func TestOpenAIBuildUpstreamRequestOpenAIPassthroughForwardsResponsesLiteHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0") + c.Request.Header.Set(responsesLiteHeader, "true") + + svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{}) + req, err := svc.buildUpstreamRequestOpenAIPassthrough( + c.Request.Context(), + c, + newOpenAIImageGenerationControlTestAccount(), + []byte(`{"model":"gpt-5.4","input":"write code"}`), + "test-token", + ) + + require.NoError(t, err) + require.Equal(t, "true", req.Header.Get(responsesLiteHeader)) +} + func TestOpenAIGatewayServiceForward_ExplicitImageToolWorksWithBridgeDisabled(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 504a7302f2..b632c451df 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -238,7 +238,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if isCodexCLI { codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() } - codexBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) + codexBridgeEnabled := isCodexCLI && + !isOpenAIResponsesLiteWebSocketPayload(normalized) && + imageGenerationAllowed && + codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && + s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) if codexBridgeEnabled { payloadMap := make(map[string]any) if err := json.Unmarshal(normalized, &payloadMap); err != nil { 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 9d150856fd..5ec4b5333b 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -403,7 +403,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_FollowupCreateCa require.Equal(t, "resp_omit_model_1", gjson.Get(requestToJSONString(captureConn.writes[1]), "previous_response_id").String()) } -func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InjectsCodexImageBridge(t *testing.T) { +func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridgeRespectsResponsesLite(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{} @@ -424,6 +424,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InjectsCodexImag captureConn := &openAIWSCaptureConn{ events: [][]byte{ []byte(`{"type":"response.completed","response":{"id":"resp_codex_image_bridge","model":"gpt-5.5","usage":{"input_tokens":1,"output_tokens":1}}}`), + []byte(`{"type":"response.completed","response":{"id":"resp_codex_image_lite","model":"gpt-5.5","usage":{"input_tokens":1,"output_tokens":1}}}`), }, } captureDialer := &openAIWSCaptureDialer{conn: captureConn} @@ -523,6 +524,28 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InjectsCodexImag require.Equal(t, coderws.MessageText, msgType) require.Equal(t, "resp_codex_image_bridge", gjson.GetBytes(message, "response.id").String()) + writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create", + "model":"gpt-5.5", + "stream":false, + "previous_response_id":"resp_codex_image_bridge", + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, + "input":[ + {"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"exec","description":"Execute code-mode tools, including image_gen.imagegen."}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"draw a cat"}]} + ] + }`)) + cancelWrite() + require.NoError(t, err) + + readCtx, cancelRead = context.WithTimeout(context.Background(), 3*time.Second) + msgType, message, err = clientConn.Read(readCtx) + cancelRead() + require.NoError(t, err) + require.Equal(t, coderws.MessageText, msgType) + require.Equal(t, "resp_codex_image_lite", gjson.GetBytes(message, "response.id").String()) + _ = clientConn.Close(coderws.StatusNormalClosure, "done") select { @@ -532,12 +555,19 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InjectsCodexImag t.Fatal("等待 ingress websocket 结束超时") } - require.Len(t, captureConn.writes, 1) - upstreamPayload := requestToJSONString(captureConn.writes[0]) - require.True(t, gjson.Get(upstreamPayload, `tools.#(type=="image_generation")`).Exists()) - require.Equal(t, "png", gjson.Get(upstreamPayload, `tools.#(type=="image_generation").output_format`).String()) - require.Equal(t, "auto", gjson.Get(upstreamPayload, "tool_choice").String()) - require.Contains(t, gjson.Get(upstreamPayload, "instructions").String(), "image_generation") + require.Len(t, captureConn.writes, 2) + nonLitePayload := requestToJSONString(captureConn.writes[0]) + require.True(t, gjson.Get(nonLitePayload, `tools.#(type=="image_generation")`).Exists()) + require.Equal(t, "png", gjson.Get(nonLitePayload, `tools.#(type=="image_generation").output_format`).String()) + require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String()) + require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation") + + litePayload := requestToJSONString(captureConn.writes[1]) + require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists()) + require.False(t, gjson.Get(litePayload, "tool_choice").Exists()) + require.NotContains(t, gjson.Get(litePayload, "instructions").String(), "image_generation") + require.Equal(t, "exec", gjson.Get(litePayload, `input.#(type=="additional_tools").tools.0.name`).String()) + require.Contains(t, gjson.Get(litePayload, `input.#(type=="additional_tools").tools.0.description`).String(), "image_gen.imagegen") } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_DedicatedModeDoesNotReuseConnAcrossSessions(t *testing.T) { diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index a5aba67c6d..dbc24c850a 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -201,6 +201,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if err != nil { return nil, err } + if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { + upstreamReq.Header.Set(responsesLiteHeader, "true") + } proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index d2046b9006..b1bfb4d342 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -91,7 +91,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { Concurrency: 1, Status: StatusActive, } - payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"input":"hi"}`) + payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi"}`) type bridgeResult struct { result *OpenAIForwardResult @@ -173,6 +173,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { require.NotNil(t, upstream.lastReq) require.Equal(t, http.MethodPost, upstream.lastReq.Method) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) require.False(t, gjson.GetBytes(upstream.lastBody, "type").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "generate").Exists()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())