feat(translator): support namespace and custom tool handling in OpenAI responses

- Enhanced `convertResponsesCustomToolToOpenAIChat` to handle custom tool overrides with qualified names.
- Added namespace child consolidation logic for tools.
- Updated `responsesCustomToolNames` to include namespace-aware tool extraction.
- Introduced new test cases to validate namespace and custom tool transformations in both streaming and non-streaming responses.
This commit is contained in:
Luis Pater
2026-07-12 21:36:12 +08:00
parent dc39f44547
commit f4a8aee695
3 changed files with 194 additions and 18 deletions

View File

@@ -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": [

View File

@@ -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), &param) {
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)
}
}

View File

@@ -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": "..."}