diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go index 26a7fc0d3..7202a9a1e 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -275,6 +275,62 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FlattensNamespaceT } } +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FlattensNamespaceCustomTools(t *testing.T) { + tests := []struct { + name string + raw []byte + }{ + { + name: "top-level tools", + raw: []byte(`{ + "tools":[{ + "type":"namespace", + "name":"terminal", + "tools":[{"type":"custom","name":"exec","description":"Run a command"}] + }] + }`), + }, + { + name: "additional tools", + raw: []byte(`{ + "input":[{ + "type":"additional_tools", + "tools":[{ + "type":"namespace", + "name":"terminal", + "tools":[{"type":"custom","name":"exec","description":"Run a command"}] + }] + }] + }`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", tt.raw, false) + + if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 { + t.Fatalf("tools count = %d, want 1; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.function.name").String(); got != "terminal__exec" { + t.Fatalf("tool name = %q, want terminal__exec; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.function.description").String(); got != "Run a command" { + t.Fatalf("tool description = %q, want Run a command; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.function.parameters.type").String(); got != "object" { + t.Fatalf("parameters type = %q, want object; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.function.parameters.properties.input.type").String(); got != "string" { + t.Fatalf("input type = %q, want string; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.function.parameters.required.0").String(); got != "input" { + t.Fatalf("required parameter = %q, want input; output=%s", got, out) + } + }) + } +} + func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesStructuredToolChoice(t *testing.T) { raw := []byte(`{ "input": [ diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go index 73ce35fc5..9898744a0 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -861,3 +861,101 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Restores t.Fatalf("non-stream output namespace = %q, want collaboration; response=%s", got, resp) } } + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresAdditionalNamespaceCustomToolCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-5.4", + "input":[{ + "type":"additional_tools", + "tools":[{ + "type":"namespace", + "name":"terminal", + "tools":[{"type":"custom","name":"exec"}] + }] + }] + }`) + chunks := []string{ + `data: {"id":"chatcmpl_additional_namespace_custom_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","type":"function","function":{"name":"terminal__exec","arguments":""}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_additional_namespace_custom_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"input\":\"pwd\"}"}}]},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`, + } + + var param any + var added gjson.Result + var inputDone gjson.Result + var done gjson.Result + var completed gjson.Result + for _, line := range chunks { + for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) { + event, data := parseOpenAIResponsesSSEEvent(t, chunk) + switch event { + case "response.output_item.added": + added = data + case "response.custom_tool_call_input.done": + inputDone = data + case "response.output_item.done": + done = data + case "response.completed": + completed = data + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + t.Fatalf("unexpected function call event %q: %s", event, chunk) + } + } + } + + for _, tc := range []struct { + label string + got gjson.Result + path string + }{ + {"added", added, "item"}, + {"done", done, "item"}, + {"completed", completed, "response.output.0"}, + } { + if !tc.got.Exists() { + t.Fatalf("expected %s event", tc.label) + } + if got := tc.got.Get(tc.path + ".type").String(); got != "custom_tool_call" { + t.Fatalf("%s type = %q, want custom_tool_call", tc.label, got) + } + if got := tc.got.Get(tc.path + ".name").String(); got != "terminal__exec" { + t.Fatalf("%s name = %q, want terminal__exec", tc.label, got) + } + } + if got := inputDone.Get("input").String(); got != "pwd" { + t.Fatalf("custom input = %q, want pwd", got) + } + if got := done.Get("item.input").String(); got != "pwd" { + t.Fatalf("done input = %q, want pwd", got) + } + if got := completed.Get("response.output.0.input").String(); got != "pwd" { + t.Fatalf("completed input = %q, want pwd", got) + } +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_RestoresAdditionalNamespaceCustomToolCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-5.4", + "input":[{ + "type":"additional_tools", + "tools":[{ + "type":"namespace", + "name":"terminal", + "tools":[{"type":"custom","name":"exec"}] + }] + }] + }`) + raw := []byte(`{"id":"chatcmpl_additional_namespace_custom_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_exec","type":"function","function":{"name":"terminal__exec","arguments":"{\"input\":\"pwd\"}"}}]},"finish_reason":"tool_calls"}]}`) + + resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil) + data := gjson.ParseBytes(resp) + if got := data.Get("output.0.type").String(); got != "custom_tool_call" { + t.Fatalf("output type = %q, want custom_tool_call; response=%s", got, resp) + } + if got := data.Get("output.0.name").String(); got != "terminal__exec" { + t.Fatalf("output name = %q, want terminal__exec; response=%s", got, resp) + } + if got := data.Get("output.0.input").String(); got != "pwd" { + t.Fatalf("output input = %q, want pwd; response=%s", got, resp) + } +} diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go index a9a71ef25..d4a9007b5 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go @@ -17,7 +17,7 @@ func convertResponsesToolToOpenAIChatTools(tool gjson.Result) [][]byte { case "namespace": return convertResponsesNamespaceToolToOpenAIChat(tool) case "custom": - if tJSON, ok := convertResponsesCustomToolToOpenAIChat(tool); ok { + if tJSON, ok := convertResponsesCustomToolToOpenAIChat(tool, ""); ok { return [][]byte{tJSON} } default: @@ -29,8 +29,11 @@ func convertResponsesToolToOpenAIChatTools(tool gjson.Result) [][]byte { // convertResponsesCustomToolToOpenAIChat maps a Responses freeform ("custom") // tool onto a Chat Completions function tool with a single freeform "input" // string, mirroring the function-based shape Codex uses for apply_patch. -func convertResponsesCustomToolToOpenAIChat(tool gjson.Result) ([]byte, bool) { - name := responsesToolName(tool) +func convertResponsesCustomToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) { + name := strings.TrimSpace(overrideName) + if name == "" { + name = responsesToolName(tool) + } if name == "" { return nil, false } @@ -53,8 +56,15 @@ func convertResponsesNamespaceToolToOpenAIChat(tool gjson.Result) [][]byte { children.ForEach(func(_, child gjson.Result) bool { childName := responsesToolName(child) qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName) - if tJSON, ok := convertResponsesFunctionToolToOpenAIChat(child, qualifiedName); ok { - out = append(out, tJSON) + switch strings.TrimSpace(child.Get("type").String()) { + case "", "function": + if tJSON, ok := convertResponsesFunctionToolToOpenAIChat(child, qualifiedName); ok { + out = append(out, tJSON) + } + case "custom": + if tJSON, ok := convertResponsesCustomToolToOpenAIChat(child, qualifiedName); ok { + out = append(out, tJSON) + } } return true }) @@ -139,28 +149,37 @@ func responsesToolOutputText(output gjson.Result) string { // responsesCustomToolNames collects the names of freeform ("custom") tools // declared in the original Responses request, both in the top-level "tools" -// field and in Codex Desktop "additional_tools" input items. +// field and in Codex Desktop "additional_tools" input items. Namespace child +// names use the qualified Chat Completions form. func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} { names := make(map[string]struct{}) - collect := func(tools gjson.Result) { + var collect func(gjson.Result, string) + collect = func(tools gjson.Result, namespaceName string) { if !tools.Exists() || !tools.IsArray() { return } tools.ForEach(func(_, tool gjson.Result) bool { - if strings.TrimSpace(tool.Get("type").String()) == "custom" { - if name := responsesToolName(tool); name != "" { + switch strings.TrimSpace(tool.Get("type").String()) { + case "custom": + name := responsesToolName(tool) + if namespaceName != "" { + name = qualifyResponsesNamespaceToolName(namespaceName, name) + } + if name != "" { names[name] = struct{}{} } + case "namespace": + collect(tool.Get("tools"), strings.TrimSpace(tool.Get("name").String())) } return true }) } root := gjson.ParseBytes(requestRawJSON) - collect(root.Get("tools")) + collect(root.Get("tools"), "") if input := root.Get("input"); input.Exists() && input.IsArray() { input.ForEach(func(_, item gjson.Result) bool { if item.Get("type").String() == "additional_tools" { - collect(item.Get("tools")) + collect(item.Get("tools"), "") } return true }) @@ -169,18 +188,18 @@ func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} { } func responsesSingleCustomToolName(requestRawJSON []byte) (string, bool) { + customToolNames := responsesCustomToolNames(requestRawJSON) + if len(customToolNames) != 1 { + return "", false + } + toolCount := 0 - customToolName := "" collect := func(tools gjson.Result) { if !tools.Exists() || !tools.IsArray() { return } tools.ForEach(func(_, tool gjson.Result) bool { - convertedTools := convertResponsesToolToOpenAIChatTools(tool) - toolCount += len(convertedTools) - if len(convertedTools) == 1 && strings.TrimSpace(tool.Get("type").String()) == "custom" { - customToolName = responsesToolName(tool) - } + toolCount += len(convertResponsesToolToOpenAIChatTools(tool)) return true }) } @@ -195,7 +214,10 @@ func responsesSingleCustomToolName(requestRawJSON []byte) (string, bool) { return true }) } - return customToolName, toolCount == 1 && customToolName != "" + for name := range customToolNames { + return name, toolCount == 1 + } + return "", false } // unwrapCustomToolInput extracts the freeform input from the {"input": "..."}