mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
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:
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "..."}
|
||||
|
||||
Reference in New Issue
Block a user