mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-08-31 01:13:06 +08:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user