Merge pull request #6084 from wucm667/fix/issue-6057-responses-lite-parallel-tools

fix(openai): enforce serial tool calls for Responses Lite
This commit is contained in:
Wesley Liddick
2026-08-24 11:24:17 +08:00
committed by GitHub
4 changed files with 216 additions and 10 deletions
@@ -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
}
@@ -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
@@ -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())
}
})
}
}
@@ -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) {