diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index b4d717cec..0a4aa2341 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -76,9 +76,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) if liteErr != nil { + param := "tools" + var validationErr *openAIResponsesLiteValidationError + if errors.As(liteErr, &validationErr) { + param = validationErr.param + } setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "") c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ - "type": "invalid_request_error", "message": liteErr.Error(), "param": "tools", + "type": "invalid_request_error", "message": liteErr.Error(), "param": param, }}) return nil, liteErr } diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index c5c16ecaa..f374b59bb 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -7,6 +7,17 @@ import ( "strings" ) +type openAIResponsesLiteValidationError struct { + param string + message string +} + +func (e *openAIResponsesLiteValidationError) Error() string { return e.message } + +func newOpenAIResponsesLiteValidationError(param, format string, args ...any) error { + return &openAIResponsesLiteValidationError{param: param, message: fmt.Sprintf(format, args...)} +} + // normalizeOpenAIResponsesLiteTools applies the Responses Lite request // contract: reasoning must cover all turns, and private namespace declarations // use the input.additional_tools carrier. Other top-level tools must belong to @@ -16,18 +27,27 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { if reqBody == nil { return false, nil } + if parallel, exists := reqBody["parallel_tool_calls"]; exists { + if _, ok := parallel.(bool); !ok { + return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") + } + } if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil { if _, ok := rawReasoning.(map[string]any); !ok { - return false, fmt.Errorf("responses Lite requires reasoning to be an object") + return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object") } } rawTools, exists := reqBody["tools"] if !exists || rawTools == nil { - return ensureOpenAIResponsesLiteReasoningContext(reqBody) + changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody) + if err != nil { + return false, err + } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed) } tools, ok := rawTools.([]any) if !ok { - return false, fmt.Errorf("responses Lite requires tools to be an array") + return false, newOpenAIResponsesLiteValidationError("tools", "responses Lite requires tools to be an array") } topLevelTools := make([]any, 0, len(tools)) @@ -57,7 +77,11 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } } if len(namespaceTools) == 0 { - return ensureOpenAIResponsesLiteReasoningContext(reqBody) + changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody) + if err != nil { + return false, err + } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed) } input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools) @@ -73,9 +97,38 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } else { reqBody["tools"] = topLevelTools } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, true) +} + +func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) { + parallel := reqBody["parallel_tool_calls"] + if !openAIResponsesLiteHasTools(reqBody) { + return changed, nil + } + if parallel == false { + return changed, nil + } + reqBody["parallel_tool_calls"] = false return true, nil } +func openAIResponsesLiteHasTools(reqBody map[string]any) bool { + if tools, ok := reqBody["tools"].([]any); ok && len(tools) > 0 { + return true + } + input, _ := reqBody["input"].([]any) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { + continue + } + if tools, ok := item["tools"].([]any); ok && len(tools) > 0 { + return true + } + } + return false +} + func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) { rawReasoning, exists := reqBody["reasoning"] if !exists || rawReasoning == nil { @@ -84,7 +137,7 @@ func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, er } reasoning, ok := rawReasoning.(map[string]any) if !ok { - return false, fmt.Errorf("responses Lite requires reasoning to be an object") + return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object") } if context, ok := reasoning["context"].(string); ok && context == "all_turns" { return false, nil diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index f7fd7309c..145ddf95b 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -162,8 +162,95 @@ func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testin changed, err := normalizeOpenAIResponsesLiteTools(reqBody) require.NoError(t, err) - require.False(t, changed) + require.True(t, changed) require.Len(t, reqBody["tools"], 4) + require.Equal(t, false, reqBody["parallel_tool_calls"]) +} + +func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testing.T) { + tests := []struct { + name string + body map[string]any + }{ + { + name: "top-level tools", + body: map[string]any{ + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": true, + }, + }, + { + name: "input additional tools", + body: map[string]any{ + "input": []any{map[string]any{ + "type": "additional_tools", + "tools": []any{map[string]any{"type": "namespace", "name": "collaboration"}}, + }}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changed, err := normalizeOpenAIResponsesLiteTools(tt.body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, false, tt.body["parallel_tool_calls"]) + }) + } +} + +func TestNormalizeOpenAIResponsesLiteTools_DoesNotAddParallelToolCallsWithoutTools(t *testing.T) { + reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, + "parallel_tool_calls": true, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, true, reqBody["parallel_tool_calls"]) +} + +func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) { + for _, value := range []any{"false", float64(0), nil, map[string]any{}} { + reqBody := map[string]any{ + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": value, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.False(t, changed) + require.Equal(t, value, reqBody["parallel_tool_calls"]) + } + + reqBody := map[string]any{"parallel_tool_calls": []any{}} + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.False(t, changed) +} + +func TestNormalizeOpenAIResponsesLiteTools_ParallelToolCallsIsIdempotent(t *testing.T) { + reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": true, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, false, reqBody["parallel_tool_calls"]) + + changed, err = normalizeOpenAIResponsesLiteTools(reqBody) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, false, reqBody["parallel_tool_calls"]) } func TestNormalizeOpenAIResponsesLiteTools_EnsuresReasoningContext(t *testing.T) { @@ -245,6 +332,8 @@ func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t require.False(t, gjson.GetBytes(updated, "tools").Exists()) require.Equal(t, "collaboration", gjson.GetBytes(updated, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.GetBytes(updated, "tool_choice.type").String()) + require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) } func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) { @@ -297,6 +386,7 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes body := []byte(`{ "model":"gpt-5.6-terra","stream":true,"instructions":"test", "reasoning":{"effort":"high","context":"current_turn"}, + "parallel_tool_calls":true, "tools":[ {"type":"function","name":"shell","parameters":{"type":"object"}}, {"type":"custom","name":"exec"}, @@ -321,6 +411,45 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tool_choice.type").String()) require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) + + badRec := httptest.NewRecorder() + badCtx, _ := gin.CreateTestContext(badRec) + badCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + badCtx.Request.Header.Set(responsesLiteHeader, "true") + badUpstream := &httpUpstreamRecorder{} + svc.httpUpstream = badUpstream + + result, err = svc.Forward(context.Background(), badCtx, account, []byte(`{"model":"gpt-5.6-terra","tools":[{"type":"function","name":"shell"}],"parallel_tool_calls":"false"}`)) + + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, badRec.Code) + require.Equal(t, "invalid_request_error", gjson.Get(badRec.Body.String(), "error.type").String()) + require.Equal(t, "parallel_tool_calls", gjson.Get(badRec.Body.String(), "error.param").String()) + require.Contains(t, gjson.Get(badRec.Body.String(), "error.message").String(), "parallel_tool_calls to be a boolean") + require.Nil(t, badUpstream.lastReq) + + for _, malformed := range []struct { + body string + wantParam string + }{ + {body: `{"model":"gpt-5.6-terra","tools":{}}`, wantParam: "tools"}, + {body: `{"model":"gpt-5.6-terra","reasoning":[]}`, wantParam: "reasoning"}, + } { + rec := httptest.NewRecorder() + requestCtx, _ := gin.CreateTestContext(rec) + requestCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + requestCtx.Request.Header.Set(responsesLiteHeader, "true") + + result, err = svc.Forward(context.Background(), requestCtx, account, []byte(malformed.body)) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, malformed.wantParam, gjson.Get(rec.Body.String(), "error.param").String()) + } }) } } 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 868da4776..7633c9061 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -732,7 +732,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge }() 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,"input":"draw a cat","sequence":900719925474099312345}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","parallel_tool_calls":true,"sequence":900719925474099312345}`)) cancelWrite() require.NoError(t, err) @@ -750,6 +750,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge "stream":false, "previous_response_id":"resp_codex_image_bridge", "reasoning":{"effort":"high"}, + "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[ @@ -787,11 +788,23 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, coderws.MessageText, msgType) require.Equal(t, "resp_codex_image_function", gjson.GetBytes(message, "response.id").String()) - _ = clientConn.Close(coderws.StatusNormalClosure, "done") + writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create", + "model":"gpt-5.5", + "parallel_tool_calls":"false", + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, + "tools":[{"type":"function","name":"shell"}] + }`)) + cancelWrite() + require.NoError(t, err) select { case serverErr := <-serverErrCh: - require.NoError(t, serverErr) + var closeErr *OpenAIWSClientCloseError + require.ErrorAs(t, serverErr, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode()) + require.Contains(t, closeErr.Reason(), "parallel_tool_calls to be a boolean") case <-time.After(5 * time.Second): t.Fatal("等待 ingress websocket 结束超时") } @@ -803,6 +816,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String()) require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation") require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists()) + require.True(t, gjson.Get(nonLitePayload, "parallel_tool_calls").Bool()) require.Equal(t, "900719925474099312345", gjson.Get(nonLitePayload, "sequence").Raw) litePayload := requestToJSONString(captureConn.writes[1]) @@ -816,6 +830,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String()) require.Equal(t, "high", gjson.Get(litePayload, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.Get(litePayload, "reasoning.context").String()) + require.True(t, gjson.Get(litePayload, "parallel_tool_calls").Exists()) + require.False(t, gjson.Get(litePayload, "parallel_tool_calls").Bool()) functionPayload := requestToJSONString(captureConn.writes[2]) require.True(t, gjson.Get(functionPayload, `tools.#(name=="image_gen.imagegen")`).Exists()) @@ -1201,6 +1217,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade "stream":false, "prompt_cache_key":"pcache_passthrough", "reasoning":{"effort":"medium","context":"current_turn"}, + "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[{"type":"message","role":"user","content":"hello"}], @@ -1236,6 +1253,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String()) require.Equal(t, "medium", gjson.Get(forwarded, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.Get(forwarded, "reasoning.context").String()) + require.True(t, gjson.Get(forwarded, "parallel_tool_calls").Exists()) + require.False(t, gjson.Get(forwarded, "parallel_tool_calls").Bool()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) {