From bc6d9c47d2bfdb5b9cea05d6d520e080c296c675 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Wed, 15 Jul 2026 11:26:55 +0800 Subject: [PATCH] fix(openai): normalize Responses Lite tool declarations --- .../service/openai_codex_transform.go | 19 +- .../service/openai_gateway_forward.go | 13 + .../service/openai_responses_lite_tools.go | 191 +++++++++++++ .../openai_responses_lite_tools_test.go | 267 ++++++++++++++++++ .../service/openai_ws_forwarder_ingress.go | 11 + ...penai_ws_forwarder_ingress_session_test.go | 26 +- .../openai_ws_v2_passthrough_adapter.go | 16 ++ 7 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/openai_responses_lite_tools.go create mode 100644 backend/internal/service/openai_responses_lite_tools_test.go diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 3bde4abcfb..d6617ba6ca 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -312,13 +312,30 @@ func normalizeCodexToolChoice(reqBody map[string]any) bool { } return modified } - if codexToolsContainType(reqBody["tools"], choiceType) { + if codexToolsContainType(reqBody["tools"], choiceType) || codexInputAdditionalToolsContainType(reqBody["input"], choiceType) { return modified } reqBody["tool_choice"] = "auto" return true } +func codexInputAdditionalToolsContainType(rawInput any, toolType string) bool { + input, ok := rawInput.([]any) + if !ok || strings.TrimSpace(toolType) == "" { + return false + } + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { + continue + } + if codexToolsContainType(item["tools"], toolType) { + return true + } + } + return false +} + func codexToolsContainType(rawTools any, toolType string) bool { tools, ok := rawTools.([]any) if !ok || strings.TrimSpace(toolType) == "" { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index a6b24189ff..b298abecf0 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -44,6 +44,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } + if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { + liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) + if liteErr != nil { + setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "type": "invalid_request_error", "message": liteErr.Error(), "param": "tools", + }}) + return nil, liteErr + } + if changed { + body = liteBody + } + } wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c)) diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go new file mode 100644 index 0000000000..a15bc498bc --- /dev/null +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -0,0 +1,191 @@ +package service + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// normalizeOpenAIResponsesLiteTools converts private namespace declarations +// into the input.additional_tools carrier required by Responses Lite. Other +// top-level tools must belong to the small set accepted by the Lite endpoint; +// rejecting unsupported hosted tools is intentional because silently dropping +// them would change the client's requested behavior. +func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { + if reqBody == nil { + return false, nil + } + rawTools, exists := reqBody["tools"] + if !exists || rawTools == nil { + return false, nil + } + tools, ok := rawTools.([]any) + if !ok { + return false, fmt.Errorf("responses Lite requires tools to be an array") + } + + topLevelTools := make([]any, 0, len(tools)) + namespaceTools := make([]any, 0, len(tools)) + for index, rawTool := range tools { + if customTool, ok := rawTool.(string); ok { + if strings.TrimSpace(customTool) == "" { + return false, fmt.Errorf("responses Lite custom tool at index %d must not be empty", index) + } + topLevelTools = append(topLevelTools, rawTool) + continue + } + tool, ok := rawTool.(map[string]any) + if !ok { + return false, fmt.Errorf("responses Lite tool at index %d must be an object", index) + } + toolType := strings.TrimSpace(firstNonEmptyString(tool["type"])) + switch toolType { + case "function", "custom", "tool_search": + topLevelTools = append(topLevelTools, rawTool) + case "namespace": + namespaceTools = append(namespaceTools, rawTool) + case "": + return false, fmt.Errorf("responses Lite tool at index %d is missing type", index) + default: + return false, fmt.Errorf("responses Lite does not support top-level tool type %q at index %d", toolType, index) + } + } + if len(namespaceTools) == 0 { + return false, nil + } + + input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools) + if err != nil { + return false, err + } + reqBody["input"] = input + if len(topLevelTools) == 0 { + delete(reqBody, "tools") + } else { + reqBody["tools"] = topLevelTools + } + return true, nil +} + +func appendOpenAIResponsesLiteAdditionalTools(input any, namespaceTools []any) ([]any, error) { + var items []any + switch typed := input.(type) { + case nil: + items = make([]any, 0, 1) + case string: + items = []any{map[string]any{ + "type": "message", + "role": "user", + "content": typed, + }} + case []any: + items = typed + default: + return nil, fmt.Errorf("responses Lite namespace tools require input to be a string or array") + } + + var target map[string]any + var targetTools []any + var allAdditionalTools []any + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { + continue + } + rawAdditionalTools, exists := item["tools"] + additionalTools := []any(nil) + toolsOK := true + if exists && rawAdditionalTools != nil { + additionalTools, toolsOK = rawAdditionalTools.([]any) + } + if !toolsOK { + return nil, fmt.Errorf("responses Lite input.additional_tools tools must be an array") + } + if target == nil { + target = item + targetTools = additionalTools + } + allAdditionalTools = append(allAdditionalTools, additionalTools...) + } + + merged, err := mergeOpenAIResponsesLiteAdditionalTools(allAdditionalTools, namespaceTools) + if err != nil { + return nil, err + } + newTools := merged[len(allAdditionalTools):] + if target != nil { + if len(newTools) > 0 { + target["tools"] = append(append([]any(nil), targetTools...), newTools...) + } + return items, nil + } + + items = append(items, map[string]any{ + "type": "additional_tools", + "role": "developer", + "tools": newTools, + }) + return items, nil +} + +func mergeOpenAIResponsesLiteAdditionalTools(existing []any, moved []any) ([]any, error) { + merged := append([]any(nil), existing...) + seen := make(map[string]any, len(existing)+len(moved)) + for _, rawTool := range existing { + if identity := openAIResponsesLiteToolIdentity(rawTool); identity != "" { + if previous, exists := seen[identity]; exists && !reflect.DeepEqual(previous, rawTool) { + return nil, fmt.Errorf("responses Lite additional_tools contains conflicting definitions for %s", openAIResponsesLiteToolIdentityForError(rawTool)) + } + seen[identity] = rawTool + } + } + for _, rawTool := range moved { + identity := openAIResponsesLiteToolIdentity(rawTool) + if identity != "" { + if previous, exists := seen[identity]; exists { + if reflect.DeepEqual(previous, rawTool) { + continue + } + return nil, fmt.Errorf("responses Lite additional_tools conflicts with migrated %s", openAIResponsesLiteToolIdentityForError(rawTool)) + } + seen[identity] = rawTool + } + merged = append(merged, rawTool) + } + return merged, nil +} + +func openAIResponsesLiteToolIdentity(rawTool any) string { + tool, ok := rawTool.(map[string]any) + if !ok { + return "" + } + toolType := strings.TrimSpace(firstNonEmptyString(tool["type"])) + name := strings.TrimSpace(firstNonEmptyString(tool["name"])) + if toolType == "" || name == "" { + return "" + } + return toolType + "\x00" + name +} + +func openAIResponsesLiteToolIdentityForError(rawTool any) string { + tool, _ := rawTool.(map[string]any) + return fmt.Sprintf("tool type %q name %q", strings.TrimSpace(firstNonEmptyString(tool["type"])), strings.TrimSpace(firstNonEmptyString(tool["name"]))) +} + +func normalizeOpenAIResponsesLiteToolsPayload(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 := normalizeOpenAIResponsesLiteTools(requestBody) + 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 +} diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go new file mode 100644 index 0000000000..aa03954463 --- /dev/null +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -0,0 +1,267 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestNormalizeOpenAIResponsesLiteTools_MovesNamespacesAndKeepsSupportedTools(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.6-terra", + "tools": []any{ + map[string]any{"type": "function", "name": "shell"}, + map[string]any{"type": "custom", "name": "exec"}, + map[string]any{"type": "tool_search"}, + map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{ + map[string]any{"type": "function", "name": "spawn_agent"}, + }}, + }, + "input": []any{ + map[string]any{"type": "message", "role": "user", "content": "hello"}, + map[string]any{"type": "additional_tools", "role": "developer", "tools": []any{ + map[string]any{"type": "namespace", "name": "image_gen"}, + map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{ + map[string]any{"type": "function", "name": "spawn_agent"}, + }}, + }}, + }, + "tool_choice": map[string]any{"type": "namespace", "name": "collaboration"}, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + tools := reqBody["tools"].([]any) + require.Len(t, tools, 3) + require.Equal(t, "function", tools[0].(map[string]any)["type"]) + require.Equal(t, "custom", tools[1].(map[string]any)["type"]) + require.Equal(t, "tool_search", tools[2].(map[string]any)["type"]) + input := reqBody["input"].([]any) + require.Len(t, input, 2) + additional := input[1].(map[string]any)["tools"].([]any) + require.Len(t, additional, 2) + require.Equal(t, "image_gen", additional[0].(map[string]any)["name"]) + require.Equal(t, "collaboration", additional[1].(map[string]any)["name"], "existing namespace must not be duplicated") + require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"]) +} + +func TestNormalizeOpenAIResponsesLiteTools_RejectsConflictingAdditionalTool(t *testing.T) { + reqBody := map[string]any{ + "tools": []any{map[string]any{ + "type": "namespace", + "name": "collaboration", + "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}}, + }}, + "input": []any{map[string]any{ + "type": "additional_tools", + "tools": []any{map[string]any{ + "type": "namespace", + "name": "collaboration", + "tools": []any{map[string]any{"type": "function", "name": "send_message"}}, + }}, + }}, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.ErrorContains(t, err, `conflicts with migrated tool type "namespace" name "collaboration"`) + require.False(t, changed) + require.Len(t, reqBody["tools"], 1, "conflicts must not partially remove top-level tools") +} + +func TestNormalizeOpenAIResponsesLiteTools_DeduplicatesAcrossAdditionalToolItems(t *testing.T) { + namespace := map[string]any{ + "type": "namespace", + "name": "collaboration", + "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}}, + } + reqBody := map[string]any{ + "tools": []any{namespace}, + "input": []any{ + map[string]any{ + "type": "additional_tools", + "tools": []any{map[string]any{"type": "custom", "name": "exec"}}, + }, + map[string]any{ + "type": "additional_tools", + "tools": []any{namespace}, + }, + }, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + require.NotContains(t, reqBody, "tools") + input := reqBody["input"].([]any) + require.Len(t, input[0].(map[string]any)["tools"], 1) + require.Len(t, input[1].(map[string]any)["tools"], 1) +} + +func TestNormalizeOpenAIResponsesLiteTools_ConvertsStringInput(t *testing.T) { + reqBody := map[string]any{ + "input": "hello", + "tools": []any{map[string]any{ + "type": "namespace", + "name": "collaboration", + }}, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + require.NotContains(t, reqBody, "tools") + input := reqBody["input"].([]any) + require.Len(t, input, 2) + require.Equal(t, "message", input[0].(map[string]any)["type"]) + require.Equal(t, "hello", input[0].(map[string]any)["content"]) + require.Equal(t, "additional_tools", input[1].(map[string]any)["type"]) +} + +func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testing.T) { + reqBody := map[string]any{ + "tools": []any{ + map[string]any{"type": "function", "name": "shell"}, + map[string]any{"type": "custom", "name": "exec"}, + map[string]any{"type": "tool_search"}, + "custom shorthand", + }, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.False(t, changed) + require.Len(t, reqBody["tools"], 4) +} + +func TestNormalizeOpenAIResponsesLiteTools_RejectsUnsupportedTools(t *testing.T) { + tests := []struct { + name string + tool map[string]any + want string + }{ + {name: "hosted web search", tool: map[string]any{"type": "web_search"}, want: `top-level tool type "web_search"`}, + {name: "hosted image generation", tool: map[string]any{"type": "image_generation"}, want: `top-level tool type "image_generation"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := map[string]any{"tools": []any{tt.tool}} + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.ErrorContains(t, err, tt.want) + require.False(t, changed) + require.Len(t, reqBody["tools"], 1, "validation errors must not partially mutate tools") + }) + } +} + +func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t *testing.T) { + body := []byte(`{ + "type":"response.create", + "model":"gpt-5.6-terra", + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, + "input":[{"type":"message","role":"user","content":"hello"}], + "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], + "tool_choice":{"type":"namespace","name":"collaboration"} + }`) + + updated, changed, err := normalizeOpenAIResponsesLiteToolsPayload(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "response.create", gjson.GetBytes(updated, "type").String()) + 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()) +} + +func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.6-terra", + "input": []any{map[string]any{ + "type": "additional_tools", + "tools": []any{map[string]any{ + "type": "namespace", + "name": "collaboration", + }}, + }}, + "tool_choice": map[string]any{"type": "namespace", "name": "collaboration"}, + } + + applyCodexOAuthTransform(reqBody, true, false) + + require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"]) +} + +func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(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: 501, Name: "responses-lite", 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", + "tools":[ + {"type":"function","name":"shell","parameters":{"type":"object"}}, + {"type":"custom","name":"exec"}, + {"type":"tool_search"}, + {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ], + "input":[{"type":"message","role":"user","content":"hello"}], + "tool_choice":{"type":"namespace","name":"collaboration"} + }`) + + 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, `tools.#(type=="namespace")`).Exists()) + require.Equal(t, "shell", gjson.GetBytes(upstream.lastBody, `tools.#(type=="function").name`).String()) + require.Equal(t, "exec", gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom").name`).String()) + require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="tool_search")`).Exists()) + 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()) + }) + } +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 635312738b..51aace6419 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -232,6 +232,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } + if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(normalized) { + litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized) + if liteErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + liteErr.Error(), + liteErr, + ) + } + normalized = litePayload + } apiKey := getAPIKeyFromContext(c) imageGenerationAllowed := GroupAllowsImageGeneration(apiKeyGroup(apiKey)) codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow 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 5ec4b5333b..aa0c8bf09c 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -531,10 +531,12 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge "stream":false, "previous_response_id":"resp_codex_image_bridge", "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":"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"}]} - ] + ], + "tool_choice":{"type":"namespace","name":"collaboration"} }`)) cancelWrite() require.NoError(t, err) @@ -564,10 +566,13 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge 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") + require.False(t, gjson.Get(litePayload, `tools.#(type=="namespace")`).Exists()) + require.Equal(t, "collaboration", gjson.Get(litePayload, `input.#(type=="additional_tools").tools.1.name`).String()) + require.Equal(t, "namespace", gjson.Get(litePayload, "tool_choice.type").String()) + require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_DedicatedModeDoesNotReuseConnAcrossSessions(t *testing.T) { @@ -940,7 +945,16 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade }() 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,"prompt_cache_key":"pcache_passthrough"}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create", + "model":"gpt-5.1", + "stream":false, + "prompt_cache_key":"pcache_passthrough", + "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"}], + "tool_choice":{"type":"namespace","name":"collaboration"} + }`)) cancelWrite() require.NoError(t, err) @@ -963,6 +977,12 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade require.Equal(t, isolateOpenAISessionID(0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id")) require.Equal(t, "turn-state-1", captureDialer.lastHeaders.Get(openAIWSTurnStateHeader)) require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader)) + require.Len(t, upstreamConn.writes, 1) + forwarded := requestToJSONString(upstreamConn.writes[0]) + require.False(t, gjson.Get(forwarded, `tools.#(type=="namespace")`).Exists()) + require.Equal(t, "collaboration", gjson.Get(forwarded, `input.#(type=="additional_tools").tools.0.name`).String()) + require.Equal(t, "namespace", gjson.Get(forwarded, "tool_choice.type").String()) + require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(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 72fbbd0f35..d2cf26a8ce 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -246,6 +246,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } + if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { + liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage) + if liteErr != nil { + return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) + } + firstClientMessage = liteFirstMessage + } requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String()) logOpenAIWSV2Passthrough( @@ -428,6 +435,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if msgType != coderws.MessageText { return payload, nil, nil } + if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { + if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(payload) { + litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload) + if liteErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) + } + payload = litePayload + } + } if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" && hooks != nil && hooks.BeforeRequest != nil { turnNo := int(completedTurns.Load()) + 1 if turnNo < 2 {