mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 17:36:47 +08:00
feat(translator): add namespace and function call mapping for Claude responses
- Introduced `applyResponsesFunctionCallNamespaceFields` to manage name and namespace settings in response items. - Added `splitResponsesQualifiedFunctionCallFromRequest` for handling qualified names and matching them with namespaces. - Updated response generation logic to preserve namespace and function call structure in multiple response pathways. - Expanded unit tests to validate namespace and function call restoration in both stream and non-stream scenarios.
This commit is contained in:
@@ -622,6 +622,51 @@ func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
|
||||
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 isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
|
||||
switch toolType {
|
||||
case "image_generation", "file_search", "code_interpreter", "computer_use_preview":
|
||||
|
||||
@@ -89,6 +89,23 @@ func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
|
||||
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
|
||||
}
|
||||
|
||||
func emitEvent(event string, payload []byte) []byte {
|
||||
return translatorcommon.SSEEventData(event, payload)
|
||||
}
|
||||
@@ -236,7 +253,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
item, _ = sjson.SetBytes(item, "output_index", idx)
|
||||
item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
|
||||
item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID)
|
||||
item, _ = sjson.SetBytes(item, "item.name", name)
|
||||
item = applyResponsesFunctionCallNamespaceFields(item, pickRequestJSON(originalRequestRawJSON, requestRawJSON), name, "item")
|
||||
out = append(out, emitEvent("response.output_item.added", item))
|
||||
if st.FuncArgsBuf[idx] == nil {
|
||||
st.FuncArgsBuf[idx] = &strings.Builder{}
|
||||
@@ -350,7 +367,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
|
||||
itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
|
||||
itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID)
|
||||
itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[idx])
|
||||
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, pickRequestJSON(originalRequestRawJSON, requestRawJSON), st.FuncNames[idx], "item")
|
||||
out = append(out, emitEvent("response.output_item.done", itemDone))
|
||||
st.InFuncBlock = false
|
||||
} else if st.ReasoningActive {
|
||||
@@ -512,7 +529,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
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, reqBytes, name, "")
|
||||
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
|
||||
}
|
||||
}
|
||||
@@ -794,7 +811,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
|
||||
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", st.id))
|
||||
item, _ = sjson.SetBytes(item, "arguments", args)
|
||||
item, _ = sjson.SetBytes(item, "call_id", st.id)
|
||||
item, _ = sjson.SetBytes(item, "name", st.name)
|
||||
item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, st.name, "")
|
||||
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,3 +246,103 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ReportsCacheTokens(t *t
|
||||
t.Fatalf("non-stream usage total_tokens = %d, want %d", got, 22048)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model":"gpt-test",
|
||||
"tools":[
|
||||
{
|
||||
"type":"namespace",
|
||||
"name":"mcp__node_repl",
|
||||
"tools":[{"type":"function","name":"js","parameters":{"type":"object","properties":{}}}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
chunks := [][]byte{
|
||||
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"output_tokens":0}}}`),
|
||||
[]byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_abc","name":"mcp__node_repl__js","input":{}}}`),
|
||||
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{"code":"nodeRepl.write('hello')"}"}}`),
|
||||
[]byte(`data: {"type":"content_block_stop","index":1}`),
|
||||
[]byte(`data: {"type":"message_stop"}`),
|
||||
}
|
||||
|
||||
var param any
|
||||
var added gjson.Result
|
||||
var done gjson.Result
|
||||
var completed gjson.Result
|
||||
for _, chunk := range chunks {
|
||||
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) {
|
||||
event, data := parseClaudeResponsesSSEEvent(t, output)
|
||||
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 != "js" {
|
||||
t.Fatalf("%s item.name = %q, want js", tc.label, got)
|
||||
}
|
||||
if got := tc.got.Get("item.namespace").String(); got != "mcp__node_repl" {
|
||||
t.Fatalf("%s item.namespace = %q, want mcp__node_repl", tc.label, got)
|
||||
}
|
||||
}
|
||||
|
||||
if !completed.Exists() {
|
||||
t.Fatal("expected response.completed event")
|
||||
}
|
||||
if got := completed.Get("response.output.0.name").String(); got != "js" {
|
||||
t.Fatalf("completed output name = %q, want js", got)
|
||||
}
|
||||
if got := completed.Get("response.output.0.namespace").String(); got != "mcp__node_repl" {
|
||||
t.Fatalf("completed output namespace = %q, want mcp__node_repl", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresNamespaceFunctionCall(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model":"gpt-test",
|
||||
"tools":[
|
||||
{
|
||||
"type":"namespace",
|
||||
"name":"mcp__node_repl",
|
||||
"tools":[{"type":"function","name":"js","parameters":{"type":"object","properties":{}}}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
raw := []byte(strings.Join([]string{
|
||||
`data: {"type":"message_start","message":{"id":"msg_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`,
|
||||
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_abc","name":"mcp__node_repl__js","input":{}}}`,
|
||||
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"code\":\"nodeRepl.write('hello')\"}"}}`,
|
||||
`data: {"type":"content_block_stop","index":1}`,
|
||||
`data: {"type":"message_stop"}`,
|
||||
}, "\n"))
|
||||
|
||||
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil)
|
||||
root := gjson.ParseBytes(out)
|
||||
|
||||
if got := root.Get("output.0.name").String(); got != "js" {
|
||||
t.Fatalf("non-stream output name = %q, want js", got)
|
||||
}
|
||||
if got := root.Get("output.0.namespace").String(); got != "mcp__node_repl" {
|
||||
t.Fatalf("non-stream output namespace = %q, want mcp__node_repl", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user