mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 01:46:48 +08:00
feat(translator): support namespace tools in OpenAI response transformations
- Added `convertResponsesToolToOpenAIChatTools` and helper methods to handle namespace tools during request conversions. - Enhanced response handling to restore namespace context for function calls using `applyResponsesFunctionCallNamespaceFields` and related utilities. - Updated tests to validate namespace flattening, function call restoration, and non-stream response handling. Closes: #3298
This commit is contained in:
@@ -230,35 +230,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
|
||||
var chatCompletionsTools []interface{}
|
||||
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
// Built-in tools (e.g. {"type":"web_search"}) are already compatible with the Chat Completions schema.
|
||||
// Only function tools need structural conversion because Chat Completions nests details under "function".
|
||||
toolType := tool.Get("type").String()
|
||||
if toolType != "" && toolType != "function" && tool.IsObject() {
|
||||
// Almost all providers lack built-in tools, so we just ignore them.
|
||||
// chatCompletionsTools = append(chatCompletionsTools, tool.Value())
|
||||
return true
|
||||
for _, chatTool := range convertResponsesToolToOpenAIChatTools(tool) {
|
||||
chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value())
|
||||
}
|
||||
|
||||
chatTool := []byte(`{"type":"function","function":{}}`)
|
||||
|
||||
// Convert tool structure from responses format to chat completions format
|
||||
function := []byte(`{"name":"","description":"","parameters":{}}`)
|
||||
|
||||
if name := tool.Get("name"); name.Exists() {
|
||||
function, _ = sjson.SetBytes(function, "name", name.String())
|
||||
}
|
||||
|
||||
if description := tool.Get("description"); description.Exists() {
|
||||
function, _ = sjson.SetBytes(function, "description", description.String())
|
||||
}
|
||||
|
||||
if parameters := tool.Get("parameters"); parameters.Exists() {
|
||||
function, _ = sjson.SetRawBytes(function, "parameters", []byte(parameters.Raw))
|
||||
}
|
||||
|
||||
chatTool, _ = sjson.SetRawBytes(chatTool, "function", function)
|
||||
chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value())
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -122,3 +122,54 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntil
|
||||
t.Fatalf("messages.3.content = %q, want %q", got, "next")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FlattensNamespaceTools(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"input": [
|
||||
{"role":"user","content":"Use add_numbers."}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "mcp__test_mcp__",
|
||||
"description": "Tools in the mcp__test_mcp__ namespace.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "add_numbers",
|
||||
"description": "Add two numbers",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": { "type": "number" },
|
||||
"b": { "type": "number" }
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_choice": "auto"
|
||||
}`)
|
||||
t.Logf("input json:\n%s", prettyJSONForTest(raw))
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
|
||||
t.Logf("output json:\n%s", prettyJSONForTest(out))
|
||||
|
||||
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.type").String(); got != "function" {
|
||||
t.Fatalf("tools.0.type = %q, want function; output=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tools.0.function.name").String(); got != "mcp__test_mcp__add_numbers" {
|
||||
t.Fatalf("tools.0.function.name = %q, want mcp__test_mcp__add_numbers; output=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tools.0.function.description").String(); got != "Add two numbers" {
|
||||
t.Fatalf("tools.0.function.description = %q, want Add two numbers; output=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tools.0.function.parameters.required.0").String(); got != "a" {
|
||||
t.Fatalf("tools.0.function.parameters.required.0 = %q, want a; output=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte
|
||||
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
|
||||
item, _ = sjson.SetBytes(item, "arguments", args)
|
||||
item, _ = sjson.SetBytes(item, "call_id", callID)
|
||||
item, _ = sjson.SetBytes(item, "name", name)
|
||||
item = applyResponsesFunctionCallNamespaceFields(item, requestRawJSON, name, "")
|
||||
outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
|
||||
}
|
||||
}
|
||||
@@ -226,10 +226,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
if len(rawJSON) == 0 {
|
||||
return [][]byte{}
|
||||
}
|
||||
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
|
||||
if bytes.Equal(rawJSON, []byte("[DONE]")) {
|
||||
if st.CompletionPending && !st.CompletedEmitted {
|
||||
st.CompletedEmitted = true
|
||||
return [][]byte{buildResponsesCompletedEvent(st, requestRawJSON, func() int { st.Seq++; return st.Seq })}
|
||||
return [][]byte{buildResponsesCompletedEvent(st, requestForNamespace, func() int { st.Seq++; return st.Seq })}
|
||||
}
|
||||
return [][]byte{}
|
||||
}
|
||||
@@ -488,7 +489,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
|
||||
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID))
|
||||
o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID)
|
||||
o, _ = sjson.SetBytes(o, "item.name", st.FuncNames[key])
|
||||
o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, st.FuncNames[key], "item")
|
||||
out = append(out, emitRespEvent("response.output_item.added", o))
|
||||
}
|
||||
|
||||
@@ -602,7 +603,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
|
||||
itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
|
||||
itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
|
||||
itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[key])
|
||||
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForNamespace, st.FuncNames[key], "item")
|
||||
out = append(out, emitRespEvent("response.output_item.done", itemDone))
|
||||
st.FuncItemDone[key] = true
|
||||
st.FuncArgsDone[key] = true
|
||||
@@ -622,6 +623,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
// from a non-streaming OpenAI Chat Completions response.
|
||||
func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
|
||||
|
||||
// Basic response scaffold
|
||||
resp := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`)
|
||||
@@ -760,7 +762,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
|
||||
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
|
||||
item, _ = sjson.SetBytes(item, "arguments", args)
|
||||
item, _ = sjson.SetBytes(item, "call_id", callID)
|
||||
item, _ = sjson.SetBytes(item, "name", name)
|
||||
item = applyResponsesFunctionCallNamespaceFields(item, requestForNamespace, name, "")
|
||||
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -502,3 +502,94 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_OmitsTop
|
||||
t.Fatalf("output text = %q, want %q; response=%s", got, "ping", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model":"deepseek-v4-flash",
|
||||
"tools":[
|
||||
{
|
||||
"type":"namespace",
|
||||
"name":"mcp__test_mcp__",
|
||||
"tools":[{"type":"function","name":"add_numbers","parameters":{"type":"object","properties":{}}}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
chunks := []string{
|
||||
`data: {"id":"chatcmpl_namespace_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_ns","type":"function","function":{"name":"mcp__test_mcp__add_numbers","arguments":""}}]},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl_namespace_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":3,\"b\":5}"}}]},"finish_reason":"tool_calls"}]}`,
|
||||
`data: [DONE]`,
|
||||
}
|
||||
|
||||
var param any
|
||||
var added 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":
|
||||
if data.Get("item.type").String() == "function_call" {
|
||||
added = data
|
||||
}
|
||||
case "response.output_item.done":
|
||||
if data.Get("item.type").String() == "function_call" {
|
||||
done = data
|
||||
}
|
||||
case "response.completed":
|
||||
completed = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
label string
|
||||
got gjson.Result
|
||||
}{
|
||||
{"added", added},
|
||||
{"done", done},
|
||||
} {
|
||||
if !tc.got.Exists() {
|
||||
t.Fatalf("expected function_call %s event", tc.label)
|
||||
}
|
||||
if got := tc.got.Get("item.name").String(); got != "add_numbers" {
|
||||
t.Fatalf("%s item.name = %q, want add_numbers", tc.label, got)
|
||||
}
|
||||
if got := tc.got.Get("item.namespace").String(); got != "mcp__test_mcp__" {
|
||||
t.Fatalf("%s item.namespace = %q, want mcp__test_mcp__", tc.label, got)
|
||||
}
|
||||
}
|
||||
if !completed.Exists() {
|
||||
t.Fatal("expected response.completed event")
|
||||
}
|
||||
if got := completed.Get("response.output.0.name").String(); got != "add_numbers" {
|
||||
t.Fatalf("completed output name = %q, want add_numbers", got)
|
||||
}
|
||||
if got := completed.Get("response.output.0.namespace").String(); got != "mcp__test_mcp__" {
|
||||
t.Fatalf("completed output namespace = %q, want mcp__test_mcp__", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_RestoresNamespaceFunctionCall(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model":"deepseek-v4-flash",
|
||||
"tools":[
|
||||
{
|
||||
"type":"namespace",
|
||||
"name":"mcp__test_mcp__",
|
||||
"tools":[{"type":"function","name":"add_numbers","parameters":{"type":"object","properties":{}}}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
raw := []byte(`{"id":"chatcmpl_namespace_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_ns","type":"function","function":{"name":"mcp__test_mcp__add_numbers","arguments":"{\"a\":3,\"b\":5}"}}]},"finish_reason":"tool_calls"}]}`)
|
||||
|
||||
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil)
|
||||
data := gjson.ParseBytes(resp)
|
||||
|
||||
if got := data.Get("output.0.name").String(); got != "add_numbers" {
|
||||
t.Fatalf("non-stream output name = %q, want add_numbers; response=%s", got, resp)
|
||||
}
|
||||
if got := data.Get("output.0.namespace").String(); got != "mcp__test_mcp__" {
|
||||
t.Fatalf("non-stream output namespace = %q, want mcp__test_mcp__; response=%s", got, resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
func convertResponsesToolToOpenAIChatTools(tool gjson.Result) [][]byte {
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
switch toolType {
|
||||
case "", "function":
|
||||
if tJSON, ok := convertResponsesFunctionToolToOpenAIChat(tool, ""); ok {
|
||||
return [][]byte{tJSON}
|
||||
}
|
||||
case "namespace":
|
||||
return convertResponsesNamespaceToolToOpenAIChat(tool)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertResponsesNamespaceToolToOpenAIChat(tool gjson.Result) [][]byte {
|
||||
namespaceName := strings.TrimSpace(tool.Get("name").String())
|
||||
children := tool.Get("tools")
|
||||
if !children.Exists() || !children.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out [][]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)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func convertResponsesFunctionToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) {
|
||||
name := strings.TrimSpace(overrideName)
|
||||
if name == "" {
|
||||
name = responsesToolName(tool)
|
||||
}
|
||||
if name == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{}}}`)
|
||||
chatTool, _ = sjson.SetBytes(chatTool, "function.name", name)
|
||||
if description := responsesToolDescription(tool); description != "" {
|
||||
chatTool, _ = sjson.SetBytes(chatTool, "function.description", description)
|
||||
}
|
||||
if parameters := responsesToolParameters(tool); parameters.Exists() {
|
||||
chatTool, _ = sjson.SetRawBytes(chatTool, "function.parameters", []byte(parameters.Raw))
|
||||
}
|
||||
return chatTool, true
|
||||
}
|
||||
|
||||
func responsesToolName(tool gjson.Result) string {
|
||||
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(tool.Get("function.name").String())
|
||||
}
|
||||
|
||||
func responsesToolDescription(tool gjson.Result) string {
|
||||
if description := tool.Get("description").String(); description != "" {
|
||||
return description
|
||||
}
|
||||
return tool.Get("function.description").String()
|
||||
}
|
||||
|
||||
func responsesToolParameters(tool gjson.Result) gjson.Result {
|
||||
for _, path := range []string{
|
||||
"parameters",
|
||||
"parametersJsonSchema",
|
||||
"input_schema",
|
||||
"function.parameters",
|
||||
"function.parametersJsonSchema",
|
||||
} {
|
||||
if parameters := tool.Get(path); parameters.Exists() {
|
||||
return parameters
|
||||
}
|
||||
}
|
||||
return gjson.Result{}
|
||||
}
|
||||
|
||||
func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
|
||||
childName = strings.TrimSpace(childName)
|
||||
if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
|
||||
return childName
|
||||
}
|
||||
if strings.HasPrefix(childName, namespaceName) {
|
||||
return childName
|
||||
}
|
||||
if strings.HasSuffix(namespaceName, "__") {
|
||||
return namespaceName + childName
|
||||
}
|
||||
return namespaceName + "__" + childName
|
||||
}
|
||||
|
||||
func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, qualifiedName string) (name, namespace string) {
|
||||
qualifiedName = strings.TrimSpace(qualifiedName)
|
||||
if qualifiedName == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
tools := gjson.GetBytes(requestRawJSON, "tools")
|
||||
if !tools.Exists() || !tools.IsArray() {
|
||||
return qualifiedName, ""
|
||||
}
|
||||
|
||||
var bestNamespace string
|
||||
var bestChild string
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
if strings.TrimSpace(tool.Get("type").String()) != "namespace" {
|
||||
return true
|
||||
}
|
||||
namespaceName := strings.TrimSpace(tool.Get("name").String())
|
||||
if namespaceName == "" {
|
||||
return true
|
||||
}
|
||||
children := tool.Get("tools")
|
||||
if !children.Exists() || !children.IsArray() {
|
||||
return true
|
||||
}
|
||||
children.ForEach(func(_, child gjson.Result) bool {
|
||||
childName := responsesToolName(child)
|
||||
if childName == "" {
|
||||
return true
|
||||
}
|
||||
if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName {
|
||||
bestNamespace = namespaceName
|
||||
bestChild = childName
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
if bestNamespace == "" || bestChild == "" {
|
||||
return qualifiedName, ""
|
||||
}
|
||||
return bestChild, bestNamespace
|
||||
}
|
||||
|
||||
func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
|
||||
if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
|
||||
return originalRequestRawJSON
|
||||
}
|
||||
if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {
|
||||
return requestRawJSON
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyResponsesFunctionCallNamespaceFields(item []byte, requestRawJSON []byte, qualifiedName string, itemPath string) []byte {
|
||||
name, namespace := splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON, qualifiedName)
|
||||
namePath := "name"
|
||||
namespacePath := "namespace"
|
||||
if itemPath != "" {
|
||||
namePath = itemPath + ".name"
|
||||
namespacePath = itemPath + ".namespace"
|
||||
}
|
||||
item, _ = sjson.SetBytes(item, namePath, name)
|
||||
if namespace != "" {
|
||||
item, _ = sjson.SetBytes(item, namespacePath, namespace)
|
||||
} else {
|
||||
item, _ = sjson.DeleteBytes(item, namespacePath)
|
||||
}
|
||||
return item
|
||||
}
|
||||
Reference in New Issue
Block a user