Skip to content

Commit f49d179

Browse files
committed
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.
1 parent 30dc2e7 commit f49d179

3 files changed

Lines changed: 166 additions & 4 deletions

File tree

internal/translator/claude/openai/responses/claude_openai-responses_request.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,51 @@ func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
622622
return namespaceName + "__" + childName
623623
}
624624

625+
func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, qualifiedName string) (name, namespace string) {
626+
qualifiedName = strings.TrimSpace(qualifiedName)
627+
if qualifiedName == "" {
628+
return "", ""
629+
}
630+
631+
tools := gjson.GetBytes(requestRawJSON, "tools")
632+
if !tools.Exists() || !tools.IsArray() {
633+
return qualifiedName, ""
634+
}
635+
636+
var bestNamespace string
637+
var bestChild string
638+
tools.ForEach(func(_, tool gjson.Result) bool {
639+
if strings.TrimSpace(tool.Get("type").String()) != "namespace" {
640+
return true
641+
}
642+
namespaceName := strings.TrimSpace(tool.Get("name").String())
643+
if namespaceName == "" {
644+
return true
645+
}
646+
children := tool.Get("tools")
647+
if !children.Exists() || !children.IsArray() {
648+
return true
649+
}
650+
children.ForEach(func(_, child gjson.Result) bool {
651+
childName := responsesToolName(child)
652+
if childName == "" {
653+
return true
654+
}
655+
if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName {
656+
bestNamespace = namespaceName
657+
bestChild = childName
658+
}
659+
return true
660+
})
661+
return true
662+
})
663+
664+
if bestNamespace == "" || bestChild == "" {
665+
return qualifiedName, ""
666+
}
667+
return bestChild, bestNamespace
668+
}
669+
625670
func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
626671
switch toolType {
627672
case "image_generation", "file_search", "code_interpreter", "computer_use_preview":

internal/translator/claude/openai/responses/claude_openai-responses_response.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
8989
return nil
9090
}
9191

92+
func applyResponsesFunctionCallNamespaceFields(item []byte, requestRawJSON []byte, qualifiedName string, itemPath string) []byte {
93+
name, namespace := splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON, qualifiedName)
94+
namePath := "name"
95+
namespacePath := "namespace"
96+
if itemPath != "" {
97+
namePath = itemPath + ".name"
98+
namespacePath = itemPath + ".namespace"
99+
}
100+
item, _ = sjson.SetBytes(item, namePath, name)
101+
if namespace != "" {
102+
item, _ = sjson.SetBytes(item, namespacePath, namespace)
103+
} else {
104+
item, _ = sjson.DeleteBytes(item, namespacePath)
105+
}
106+
return item
107+
}
108+
92109
func emitEvent(event string, payload []byte) []byte {
93110
return translatorcommon.SSEEventData(event, payload)
94111
}
@@ -236,7 +253,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
236253
item, _ = sjson.SetBytes(item, "output_index", idx)
237254
item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
238255
item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID)
239-
item, _ = sjson.SetBytes(item, "item.name", name)
256+
item = applyResponsesFunctionCallNamespaceFields(item, pickRequestJSON(originalRequestRawJSON, requestRawJSON), name, "item")
240257
out = append(out, emitEvent("response.output_item.added", item))
241258
if st.FuncArgsBuf[idx] == nil {
242259
st.FuncArgsBuf[idx] = &strings.Builder{}
@@ -350,7 +367,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
350367
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
351368
itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
352369
itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID)
353-
itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[idx])
370+
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, pickRequestJSON(originalRequestRawJSON, requestRawJSON), st.FuncNames[idx], "item")
354371
out = append(out, emitEvent("response.output_item.done", itemDone))
355372
st.InFuncBlock = false
356373
} else if st.ReasoningActive {
@@ -512,7 +529,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
512529
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
513530
item, _ = sjson.SetBytes(item, "arguments", args)
514531
item, _ = sjson.SetBytes(item, "call_id", callID)
515-
item, _ = sjson.SetBytes(item, "name", name)
532+
item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "")
516533
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
517534
}
518535
}
@@ -794,7 +811,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
794811
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", st.id))
795812
item, _ = sjson.SetBytes(item, "arguments", args)
796813
item, _ = sjson.SetBytes(item, "call_id", st.id)
797-
item, _ = sjson.SetBytes(item, "name", st.name)
814+
item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, st.name, "")
798815
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
799816
}
800817
}

internal/translator/claude/openai/responses/claude_openai-responses_response_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,103 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ReportsCacheTokens(t *t
246246
t.Fatalf("non-stream usage total_tokens = %d, want %d", got, 22048)
247247
}
248248
}
249+
250+
func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) {
251+
originalRequest := []byte(`{
252+
"model":"gpt-test",
253+
"tools":[
254+
{
255+
"type":"namespace",
256+
"name":"mcp__node_repl",
257+
"tools":[{"type":"function","name":"js","parameters":{"type":"object","properties":{}}}]
258+
}
259+
]
260+
}`)
261+
chunks := [][]byte{
262+
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"output_tokens":0}}}`),
263+
[]byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_abc","name":"mcp__node_repl__js","input":{}}}`),
264+
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{"code":"nodeRepl.write('hello')"}"}}`),
265+
[]byte(`data: {"type":"content_block_stop","index":1}`),
266+
[]byte(`data: {"type":"message_stop"}`),
267+
}
268+
269+
var param any
270+
var added gjson.Result
271+
var done gjson.Result
272+
var completed gjson.Result
273+
for _, chunk := range chunks {
274+
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, &param) {
275+
event, data := parseClaudeResponsesSSEEvent(t, output)
276+
switch event {
277+
case "response.output_item.added":
278+
if data.Get("item.type").String() == "function_call" {
279+
added = data
280+
}
281+
case "response.output_item.done":
282+
if data.Get("item.type").String() == "function_call" {
283+
done = data
284+
}
285+
case "response.completed":
286+
completed = data
287+
}
288+
}
289+
}
290+
291+
for _, tc := range []struct {
292+
label string
293+
got gjson.Result
294+
}{
295+
{"added", added},
296+
{"done", done},
297+
} {
298+
if !tc.got.Exists() {
299+
t.Fatalf("expected function_call %s event", tc.label)
300+
}
301+
if got := tc.got.Get("item.name").String(); got != "js" {
302+
t.Fatalf("%s item.name = %q, want js", tc.label, got)
303+
}
304+
if got := tc.got.Get("item.namespace").String(); got != "mcp__node_repl" {
305+
t.Fatalf("%s item.namespace = %q, want mcp__node_repl", tc.label, got)
306+
}
307+
}
308+
309+
if !completed.Exists() {
310+
t.Fatal("expected response.completed event")
311+
}
312+
if got := completed.Get("response.output.0.name").String(); got != "js" {
313+
t.Fatalf("completed output name = %q, want js", got)
314+
}
315+
if got := completed.Get("response.output.0.namespace").String(); got != "mcp__node_repl" {
316+
t.Fatalf("completed output namespace = %q, want mcp__node_repl", got)
317+
}
318+
}
319+
320+
func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresNamespaceFunctionCall(t *testing.T) {
321+
originalRequest := []byte(`{
322+
"model":"gpt-test",
323+
"tools":[
324+
{
325+
"type":"namespace",
326+
"name":"mcp__node_repl",
327+
"tools":[{"type":"function","name":"js","parameters":{"type":"object","properties":{}}}]
328+
}
329+
]
330+
}`)
331+
raw := []byte(strings.Join([]string{
332+
`data: {"type":"message_start","message":{"id":"msg_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`,
333+
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_abc","name":"mcp__node_repl__js","input":{}}}`,
334+
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"code\":\"nodeRepl.write('hello')\"}"}}`,
335+
`data: {"type":"content_block_stop","index":1}`,
336+
`data: {"type":"message_stop"}`,
337+
}, "\n"))
338+
339+
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil)
340+
root := gjson.ParseBytes(out)
341+
342+
if got := root.Get("output.0.name").String(); got != "js" {
343+
t.Fatalf("non-stream output name = %q, want js", got)
344+
}
345+
if got := root.Get("output.0.namespace").String(); got != "mcp__node_repl" {
346+
t.Fatalf("non-stream output namespace = %q, want mcp__node_repl", got)
347+
}
348+
}

0 commit comments

Comments
 (0)