diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index afebc0502..bc390f309 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -37,15 +37,18 @@ type oaiToResponsesState struct { FuncNames map[string]string FuncCallIDs map[string]string FuncOutputIx map[string]int + FuncArgsSent map[string]int MsgOutputIx map[int]int NextOutputIx int // message item state per output index MsgItemAdded map[int]bool // whether response.output_item.added emitted for message MsgContentAdded map[int]bool // whether response.content_part.added emitted for message MsgItemDone map[int]bool // whether message done events were emitted - // function item done state - FuncArgsDone map[string]bool - FuncItemDone map[string]bool + // function item state + FuncItemAdded map[string]bool + FuncItemCustom map[string]bool + FuncArgsDone map[string]bool + FuncItemDone map[string]bool // names of freeform ("custom") tools from the original request; calls to // these are emitted as custom_tool_call items instead of function_call CustomToolNames map[string]struct{} @@ -169,7 +172,7 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte } callID := st.FuncCallIDs[key] name := st.FuncNames[key] - if _, isCustomTool := st.CustomToolNames[name]; isCustomTool { + if st.FuncItemCustom[key] { item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args)) @@ -218,11 +221,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, FuncNames: make(map[string]string), FuncCallIDs: make(map[string]string), FuncOutputIx: make(map[string]int), + FuncArgsSent: make(map[string]int), MsgOutputIx: make(map[int]int), MsgTextBuf: make(map[int]*strings.Builder), MsgItemAdded: make(map[int]bool), MsgContentAdded: make(map[int]bool), MsgItemDone: make(map[int]bool), + FuncItemAdded: make(map[string]bool), + FuncItemCustom: make(map[string]bool), FuncArgsDone: make(map[string]bool), FuncItemDone: make(map[string]bool), Reasonings: make([]oaiToResponsesStateReasoning, 0), @@ -293,6 +299,67 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } toolStateKey := func(outputIndex, toolIndex int) string { return fmt.Sprintf("%d:%d", outputIndex, toolIndex) } var out [][]byte + emitToolItem := func(key string, force bool) { + if st.FuncItemAdded[key] { + return + } + callID := st.FuncCallIDs[key] + name := st.FuncNames[key] + if !force && (callID == "" || name == "") { + return + } + if name == "" { + if customToolName, ok := responsesSingleCustomToolName(requestForNamespace); ok { + name = customToolName + st.FuncNames[key] = customToolName + } + } + if callID == "" { + callID = fmt.Sprintf("call_%s_%s", st.ResponseID, strings.ReplaceAll(key, ":", "_")) + st.FuncCallIDs[key] = callID + } + + outputIndex := st.FuncOutputIx[key] + _, isCustomTool := st.CustomToolNames[name] + st.FuncItemCustom[key] = isCustomTool + if isCustomTool { + o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`) + o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) + o, _ = sjson.SetBytes(o, "output_index", outputIndex) + o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("ctc_%s", callID)) + o, _ = sjson.SetBytes(o, "item.call_id", callID) + o, _ = sjson.SetBytes(o, "item.name", name) + out = append(out, emitRespEvent("response.output_item.added", o)) + } else { + o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) + o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) + o, _ = sjson.SetBytes(o, "output_index", outputIndex) + o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", callID)) + o, _ = sjson.SetBytes(o, "item.call_id", callID) + o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, name, "item") + out = append(out, emitRespEvent("response.output_item.added", o)) + } + st.FuncItemAdded[key] = true + } + emitPendingFunctionArgs := func(key string) { + if !st.FuncItemAdded[key] || st.FuncItemCustom[key] { + return + } + argsBuf := st.FuncArgsBuf[key] + if argsBuf == nil || argsBuf.Len() <= st.FuncArgsSent[key] { + return + } + args := argsBuf.String() + delta := args[st.FuncArgsSent[key]:] + callID := st.FuncCallIDs[key] + ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) + ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq()) + ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", callID)) + ad, _ = sjson.SetBytes(ad, "output_index", st.FuncOutputIx[key]) + ad, _ = sjson.SetBytes(ad, "delta", delta) + out = append(out, emitRespEvent("response.function_call_arguments.delta", ad)) + st.FuncArgsSent[key] = len(args) + } if !st.Started { st.ResponseID = root.Get("id").String() @@ -306,11 +373,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.FuncNames = make(map[string]string) st.FuncCallIDs = make(map[string]string) st.FuncOutputIx = make(map[string]int) + st.FuncArgsSent = make(map[string]int) st.MsgOutputIx = make(map[int]int) st.NextOutputIx = 0 st.MsgItemAdded = make(map[int]bool) st.MsgContentAdded = make(map[int]bool) st.MsgItemDone = make(map[int]bool) + st.FuncItemAdded = make(map[string]bool) + st.FuncItemCustom = make(map[string]bool) st.FuncArgsDone = make(map[string]bool) st.FuncItemDone = make(map[string]bool) st.CustomToolNames = responsesCustomToolNames(requestForNamespace) @@ -483,74 +553,23 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, tcs.ForEach(func(_, tc gjson.Result) bool { toolIndex := int(tc.Get("index").Int()) key := toolStateKey(idx, toolIndex) - newCallID := tc.Get("id").String() + if st.FuncArgsBuf[key] == nil { + st.FuncArgsBuf[key] = &strings.Builder{} + st.FuncOutputIx[key] = allocOutputIndex() + } + if newCallID := tc.Get("id").String(); newCallID != "" && st.FuncCallIDs[key] == "" { + st.FuncCallIDs[key] = newCallID + } nameChunk := tc.Get("function.name").String() - if nameChunk != "" { + if nameChunk != "" && !st.FuncItemAdded[key] { st.FuncNames[key] = nameChunk } - existingCallID := st.FuncCallIDs[key] - effectiveCallID := existingCallID - shouldEmitItem := false - if existingCallID == "" { - if newCallID == "" { - // Some OpenAI-compatible providers omit tool_call ids in - // streaming deltas; synthesize one so the Responses event - // chain (output_item.added/done) still fires for Codex. - newCallID = fmt.Sprintf("call_%s_%d_%d", st.ResponseID, idx, toolIndex) - } - effectiveCallID = newCallID - st.FuncCallIDs[key] = newCallID - st.FuncOutputIx[key] = allocOutputIndex() - shouldEmitItem = true - } - - _, isCustomTool := st.CustomToolNames[st.FuncNames[key]] - - if shouldEmitItem && effectiveCallID != "" { - outputIndex := st.FuncOutputIx[key] - if isCustomTool { - o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`) - o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) - o, _ = sjson.SetBytes(o, "output_index", outputIndex) - o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("ctc_%s", effectiveCallID)) - o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID) - o, _ = sjson.SetBytes(o, "item.name", st.FuncNames[key]) - out = append(out, emitRespEvent("response.output_item.added", o)) - } else { - o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) - o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) - 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 = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, st.FuncNames[key], "item") - out = append(out, emitRespEvent("response.output_item.added", o)) - } - } - - if st.FuncArgsBuf[key] == nil { - st.FuncArgsBuf[key] = &strings.Builder{} - } - if args := tc.Get("function.arguments"); args.Exists() && args.String() != "" { - refCallID := st.FuncCallIDs[key] - if refCallID == "" { - refCallID = newCallID - } - // Custom tool calls buffer arguments only: JSON argument - // fragments cannot be mapped onto freeform input deltas, so - // the full input is delivered with the done events instead. - if refCallID != "" && !isCustomTool { - outputIndex := st.FuncOutputIx[key] - ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) - ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq()) - ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", refCallID)) - ad, _ = sjson.SetBytes(ad, "output_index", outputIndex) - ad, _ = sjson.SetBytes(ad, "delta", args.String()) - out = append(out, emitRespEvent("response.function_call_arguments.delta", ad)) - } st.FuncArgsBuf[key].WriteString(args.String()) } + emitToolItem(key, false) + emitPendingFunctionArgs(key) return true }) } @@ -608,9 +627,9 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } // Emit function call done events for any active function calls - if len(st.FuncCallIDs) > 0 { - keys := make([]string, 0, len(st.FuncCallIDs)) - for key := range st.FuncCallIDs { + if len(st.FuncArgsBuf) > 0 { + keys := make([]string, 0, len(st.FuncArgsBuf)) + for key := range st.FuncArgsBuf { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { @@ -619,6 +638,8 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, return left < right || (left == right && keys[i] < keys[j]) }) for _, key := range keys { + emitToolItem(key, true) + emitPendingFunctionArgs(key) callID := st.FuncCallIDs[key] if callID == "" || st.FuncItemDone[key] { continue @@ -628,7 +649,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, if b := st.FuncArgsBuf[key]; b != nil && b.Len() > 0 { args = b.String() } - if _, isCustomTool := st.CustomToolNames[st.FuncNames[key]]; isCustomTool { + if st.FuncItemCustom[key] { input := unwrapCustomToolInput(args) inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go index 636c599ed..73ce35fc5 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -593,3 +593,271 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Restores t.Fatalf("non-stream output namespace = %q, want mcp__test_mcp__; response=%s", got, resp) } } + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CustomToolNameArrivesLate(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-5.4", + "tools":[{"type":"custom","name":"exec"}] + }`) + chunks := []string{ + `data: {"id":"chatcmpl_custom_late_name","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","type":"function","function":{"arguments":""}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_custom_late_name","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"exec","arguments":""}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_custom_late_name","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 itemDone 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.call_id").String() == "call_exec" { + added = data + } + case "response.custom_tool_call_input.done": + inputDone = data + case "response.output_item.done": + if data.Get("item.call_id").String() == "call_exec" { + itemDone = 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", itemDone, "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 + ".id").String(); got != "ctc_call_exec" { + t.Fatalf("%s id = %q, want ctc_call_exec", tc.label, got) + } + if got := tc.got.Get(tc.path + ".name").String(); got != "exec" { + t.Fatalf("%s name = %q, want exec", tc.label, got) + } + } + if got := inputDone.Get("item_id").String(); got != "ctc_call_exec" { + t.Fatalf("custom input done item_id = %q, want ctc_call_exec", got) + } + if got := inputDone.Get("input").String(); got != "pwd" { + t.Fatalf("custom input done input = %q, want pwd", got) + } +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CustomToolNameAndIDAreMissing(t *testing.T) { + originalRequest := []byte(`{"model":"gpt-5.4","tools":[{"type":"custom","name":"exec"}]}`) + chunks := []string{ + `data: {"id":"chatcmpl_custom_missing_fields","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"type":"function","function":{"arguments":"{\"input\":\"pwd\"}"}}]},"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": + added = data + case "response.output_item.done": + done = data + case "response.completed": + completed = data + } + } + } + + wantCallID := "call_chatcmpl_custom_missing_fields_0_0" + for _, tc := range []struct { + label string + got gjson.Result + path string + }{ + {"added", added, "item"}, + {"done", done, "item"}, + {"completed", completed, "response.output.0"}, + } { + 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 + ".id").String(); got != "ctc_"+wantCallID { + t.Fatalf("%s id = %q, want %q", tc.label, got, "ctc_"+wantCallID) + } + if got := tc.got.Get(tc.path + ".call_id").String(); got != wantCallID { + t.Fatalf("%s call_id = %q, want %q", tc.label, got, wantCallID) + } + if got := tc.got.Get(tc.path + ".name").String(); got != "exec" { + t.Fatalf("%s name = %q, want exec", tc.label, got) + } + } +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_ToolCallIDMayArriveLateOrBeMissing(t *testing.T) { + tests := []struct { + name string + chunks []string + wantCallID string + }{ + { + name: "late id", + chunks: []string{ + `data: {"id":"chatcmpl_late_id","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"read","arguments":"{\"file"}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_late_id","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late","function":{"arguments":"Path\":\"README.md\"}"}}]},"finish_reason":"tool_calls"}]}`, + }, + wantCallID: "call_late", + }, + { + name: "missing id", + chunks: []string{ + `data: {"id":"chatcmpl_missing_id","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"read","arguments":"{\"filePath\":\"README.md\"}"}}]},"finish_reason":"tool_calls"}]}`, + }, + wantCallID: "call_chatcmpl_missing_id_0_0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var param any + var events []string + var added gjson.Result + var argsDelta gjson.Result + var argsDone gjson.Result + var itemDone gjson.Result + for _, line := range append(tt.chunks, `data: [DONE]`) { + for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", nil, nil, []byte(line), ¶m) { + event, data := parseOpenAIResponsesSSEEvent(t, chunk) + events = append(events, event) + switch event { + case "response.output_item.added": + added = data + case "response.function_call_arguments.delta": + argsDelta = data + case "response.function_call_arguments.done": + argsDone = data + case "response.output_item.done": + itemDone = data + } + } + } + + wantItemID := "fc_" + tt.wantCallID + if got := added.Get("item.id").String(); got != wantItemID { + t.Fatalf("added item id = %q, want %q; events=%v", got, wantItemID, events) + } + if got := added.Get("item.call_id").String(); got != tt.wantCallID { + t.Fatalf("added call id = %q, want %q", got, tt.wantCallID) + } + if got := argsDelta.Get("item_id").String(); got != wantItemID { + t.Fatalf("arguments delta item id = %q, want %q", got, wantItemID) + } + if got := argsDelta.Get("delta").String(); got != `{"filePath":"README.md"}` { + t.Fatalf("arguments delta = %q, want full buffered arguments", got) + } + if got := argsDone.Get("item_id").String(); got != wantItemID { + t.Fatalf("arguments done item id = %q, want %q", got, wantItemID) + } + if got := itemDone.Get("item.id").String(); got != wantItemID { + t.Fatalf("item done id = %q, want %q", got, wantItemID) + } + }) + } +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresAdditionalNamespaceFunctionCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-5.4", + "input":[{ + "type":"additional_tools", + "tools":[{ + "type":"namespace", + "name":"collaboration", + "tools":[{"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}}] + }] + }] + }`) + chunks := []string{ + `data: {"id":"chatcmpl_additional_namespace_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_send","type":"function","function":{"name":"collaboration__send_message","arguments":""}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_additional_namespace_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"target\":\"worker\",\"message\":\"ping\"}"}}]},"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": + added = data + case "response.output_item.done": + done = data + case "response.completed": + completed = data + } + } + } + + for _, tc := range []struct { + label string + got gjson.Result + path string + }{ + {"added", added, "item"}, + {"done", done, "item"}, + {"completed", completed, "response.output.0"}, + } { + if got := tc.got.Get(tc.path + ".name").String(); got != "send_message" { + t.Fatalf("%s name = %q, want send_message", tc.label, got) + } + if got := tc.got.Get(tc.path + ".namespace").String(); got != "collaboration" { + t.Fatalf("%s namespace = %q, want collaboration", tc.label, got) + } + } +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_RestoresAdditionalNamespaceFunctionCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-5.4", + "input":[{ + "type":"additional_tools", + "tools":[{ + "type":"namespace", + "name":"collaboration", + "tools":[{"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}}] + }] + }] + }`) + raw := []byte(`{"id":"chatcmpl_additional_namespace_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_send","type":"function","function":{"name":"collaboration__send_message","arguments":"{\"target\":\"worker\",\"message\":\"ping\"}"}}]},"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 != "send_message" { + t.Fatalf("non-stream output name = %q, want send_message; response=%s", got, resp) + } + if got := data.Get("output.0.namespace").String(); got != "collaboration" { + t.Fatalf("non-stream output namespace = %q, want collaboration; response=%s", got, resp) + } +} diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go index 44143721c..a9a71ef25 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go @@ -168,6 +168,36 @@ func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} { return names } +func responsesSingleCustomToolName(requestRawJSON []byte) (string, bool) { + 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) + } + return true + }) + } + + root := gjson.ParseBytes(requestRawJSON) + 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")) + } + return true + }) + } + return customToolName, toolCount == 1 && customToolName != "" +} + // unwrapCustomToolInput extracts the freeform input from the {"input": "..."} // function-call arguments produced for a converted custom tool; it falls back // to the raw arguments when the wrapper is absent. @@ -201,38 +231,49 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali 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 + collect := func(tools gjson.Result) { + if !tools.Exists() || !tools.IsArray() { + return } - 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 == "" { + tools.ForEach(func(_, tool gjson.Result) bool { + if strings.TrimSpace(tool.Get("type").String()) != "namespace" { return true } - if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName { - bestNamespace = namespaceName - bestChild = childName + 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 + }) + } + + root := gjson.ParseBytes(requestRawJSON) + 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")) } return true }) - return true - }) + } if bestNamespace == "" || bestChild == "" { return qualifiedName, ""