From 53d76ad80020b5846580ce2d44b77769f44bd1c6 Mon Sep 17 00:00:00 2001 From: Jiao Ziang <2720649216@qq.com> Date: Tue, 25 Aug 2026 00:21:29 +0800 Subject: [PATCH] fix(openai): enforce Responses Lite tool call mode --- .../service/openai_gateway_forward.go | 5 +- .../service/openai_responses_lite_tools.go | 37 +++++- .../openai_responses_lite_tools_test.go | 114 +++++++++++++++++- .../service/openai_ws_forwarder_ingress.go | 4 +- ...penai_ws_forwarder_ingress_session_test.go | 10 +- .../openai_ws_v2_passthrough_adapter.go | 8 +- 6 files changed, 162 insertions(+), 16 deletions(-) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index ef53b6732d..38d747ca33 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -76,8 +76,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco body = reasoningBody } } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { - liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) + responsesLite := account.IsOpenAI() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) + if responsesLite { + liteBody, changed, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account) if liteErr != nil { param := "tools" var validationErr *openAIResponsesLiteValidationError diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index f374b59bbf..b13e1443d0 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -101,8 +101,17 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) { - parallel := reqBody["parallel_tool_calls"] + parallel, exists := reqBody["parallel_tool_calls"] + if exists { + if _, ok := parallel.(bool); !ok { + return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") + } + } if !openAIResponsesLiteHasTools(reqBody) { + if exists { + delete(reqBody, "parallel_tool_calls") + return true, nil + } return changed, nil } if parallel == false { @@ -267,3 +276,29 @@ func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) } return rebuilt, true, nil } + +func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) { + var requestBody map[string]any + if err := json.Unmarshal(body, &requestBody); err != nil { + return body, false, fmt.Errorf("decode responses Lite request body: %w", err) + } + changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false) + if err != nil || !changed { + return body, false, err + } + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return body, false, fmt.Errorf("encode responses Lite request body: %w", err) + } + return rebuilt, true, nil +} + +func normalizeOpenAIResponsesLitePayloadForAccount(body []byte, account *Account) ([]byte, bool, error) { + if account == nil || !account.IsOpenAI() { + return body, false, nil + } + if account.IsOpenAIOAuthLike() { + return normalizeOpenAIResponsesLiteToolsPayload(body) + } + return normalizeOpenAIResponsesLiteParallelToolCallsPayload(body) +} diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index 145ddf95b2..e1166ef4e9 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -201,17 +201,25 @@ func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testi } } -func TestNormalizeOpenAIResponsesLiteTools_DoesNotAddParallelToolCallsWithoutTools(t *testing.T) { - reqBody := map[string]any{ - "reasoning": map[string]any{"context": "all_turns"}, - "parallel_tool_calls": true, +func TestNormalizeOpenAIResponsesLiteTools_RemovesParallelToolCallsWithoutTools(t *testing.T) { + for _, value := range []bool{true, false} { + reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, + "parallel_tool_calls": value, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + require.NotContains(t, reqBody, "parallel_tool_calls") } + reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}} changed, err := normalizeOpenAIResponsesLiteTools(reqBody) - require.NoError(t, err) require.False(t, changed) - require.Equal(t, true, reqBody["parallel_tool_calls"]) + require.NotContains(t, reqBody, "parallel_tool_calls") } func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) { @@ -453,3 +461,97 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes }) } } + +func TestOpenAIGatewayServiceForward_RemovesParallelToolCallsForToollessResponsesLite(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, passthrough := range []bool{false, true} { + name := "managed" + if passthrough { + name = "passthrough" + } + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "reasoning":{"effort":"high","context":"current_turn"}, + "parallel_tool_calls":true, + "input":[{"type":"message","role":"user","content":"hello"}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists(), string(upstream.lastBody)) + }) + } +} + +func TestOpenAIGatewayServiceForward_DisablesParallelToolCallsForResponsesLiteAPIKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, passthrough := range []bool{false, true} { + name := "managed" + if passthrough { + name = "passthrough" + } + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 503, Name: "responses-lite-api-key", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}], + "parallel_tool_calls":true, + "input":[{"type":"message","role":"user","content":"hello"}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.True(t, gjson.GetBytes(upstream.lastBody, "tools").IsArray()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) + }) + } +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 5eff93fc2e..5f9aed1f61 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -320,8 +320,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if accountScoped { normalized = accountScopedPayload } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) { - litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized) + if isOpenAIResponsesLiteWebSocketPayload(normalized) { + litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account) if liteErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( coderws.StatusPolicyViolation, 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 097d845037..2e09082d6e 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -1361,7 +1361,12 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe }() 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}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create","model":"gpt-5.1","stream":false, + "parallel_tool_calls":true, + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}] + }`)) cancelWrite() require.NoError(t, err) @@ -1402,6 +1407,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe } require.NotNil(t, upstream.lastReq, "http_bridge 模式应调用 HTTP 上游") + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(t *testing.T) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 96e1c859bb..d22e386e11 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -679,8 +679,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { - liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage) + if isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { + liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(firstClientMessage, account) if liteErr != nil { return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) } @@ -1011,8 +1011,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } if isResponseCreate { - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(payload) { - litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload) + if isOpenAIResponsesLiteWebSocketPayload(payload) { + litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(payload, account) if liteErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) }