diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go index f2f61704..9c8020e5 100644 --- a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go @@ -24,9 +24,12 @@ type interactionsToResponsesStreamState struct { } type interactionsFunctionCallState struct { - ID string - Name string - Arguments strings.Builder + ID string + Name string + Arguments strings.Builder + InitialArgumentsEmitted bool + ArgumentsDoneEmitted bool + ItemDoneEmitted bool } type responsesToInteractionsStreamState struct { @@ -211,6 +214,9 @@ func interactionsStepStartToResponses(root gjson.Result, st *interactionsToRespo } return [][]byte{emitResponsesEvent("response.output_item.added", added)} case "function_call": + if st.FunctionCalls[index] != nil { + return nil + } call := &interactionsFunctionCallState{ ID: itemID, Name: step.Get("name").String(), @@ -225,7 +231,12 @@ func interactionsStepStartToResponses(root gjson.Result, st *interactionsToRespo added, _ = sjson.SetBytes(added, "item.id", itemID) added, _ = sjson.SetBytes(added, "item.call_id", itemID) added, _ = sjson.SetBytes(added, "item.name", call.Name) - return [][]byte{emitResponsesEvent("response.output_item.added", added)} + events := [][]byte{emitResponsesEvent("response.output_item.added", added)} + if call.Arguments.Len() > 0 && !call.InitialArgumentsEmitted { + events = append(events, responsesFunctionCallArgumentsDeltaToResponses(index, itemID, call.Arguments.String(), st)) + call.InitialArgumentsEmitted = true + } + return events } return nil } @@ -248,15 +259,14 @@ func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToRespo } return nil case "arguments_delta": + arguments := delta.Get("arguments").String() if call := st.FunctionCalls[index]; call != nil { - call.Arguments.WriteString(delta.Get("arguments").String()) + if call.ItemDoneEmitted { + return nil + } + call.Arguments.WriteString(arguments) } - payload := []byte(`{"type":"response.function_call_arguments.delta","output_index":0,"delta":""}`) - payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) - payload, _ = sjson.SetBytes(payload, "output_index", index) - payload, _ = sjson.SetBytes(payload, "item_id", st.ItemIDs[index]) - payload, _ = sjson.SetBytes(payload, "delta", delta.Get("arguments").String()) - return [][]byte{emitResponsesEvent("response.function_call_arguments.delta", payload)} + return [][]byte{responsesFunctionCallArgumentsDeltaToResponses(index, st.ItemIDs[index], arguments, st)} default: payload := []byte(`{"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"","delta":""}`) payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) @@ -269,6 +279,24 @@ func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToRespo } } +func responsesFunctionCallArgumentsDeltaToResponses(index int, itemID, arguments string, st *interactionsToResponsesStreamState) []byte { + payload := []byte(`{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"","delta":""}`) + payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) + payload, _ = sjson.SetBytes(payload, "output_index", index) + payload, _ = sjson.SetBytes(payload, "item_id", itemID) + payload, _ = sjson.SetBytes(payload, "delta", arguments) + return emitResponsesEvent("response.function_call_arguments.delta", payload) +} + +func responsesFunctionCallArgumentsDoneToResponses(index int, itemID, arguments string, st *interactionsToResponsesStreamState) []byte { + payload := []byte(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"","arguments":""}`) + payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) + payload, _ = sjson.SetBytes(payload, "output_index", index) + payload, _ = sjson.SetBytes(payload, "item_id", itemID) + payload, _ = sjson.SetBytes(payload, "arguments", arguments) + return emitResponsesEvent("response.function_call_arguments.done", payload) +} + func interactionsStepStopToResponses(root gjson.Result, st *interactionsToResponsesStreamState) [][]byte { index := int(root.Get("index").Int()) itemID := st.ItemIDs[index] @@ -298,16 +326,28 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon return [][]byte{emitResponsesEvent("response.output_text.done", textDone), emitResponsesEvent("response.content_part.done", part), emitResponsesEvent("response.output_item.done", done)} case "function_call": call := st.FunctionCalls[index] + if call == nil { + call = &interactionsFunctionCallState{ID: itemID} + st.FunctionCalls[index] = call + } + if call.ItemDoneEmitted { + return nil + } + events := make([][]byte, 0, 2) + arguments := responsesFunctionCallArguments(call) + if !call.ArgumentsDoneEmitted { + events = append(events, responsesFunctionCallArgumentsDoneToResponses(index, itemID, arguments, st)) + call.ArgumentsDoneEmitted = true + } done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"function_call","call_id":"","name":"","arguments":""}}`) done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st)) done, _ = sjson.SetBytes(done, "output_index", index) done, _ = sjson.SetBytes(done, "item.id", itemID) done, _ = sjson.SetBytes(done, "item.call_id", itemID) - if call != nil { - done, _ = sjson.SetBytes(done, "item.name", call.Name) - done, _ = sjson.SetBytes(done, "item.arguments", call.Arguments.String()) - } - return [][]byte{emitResponsesEvent("response.output_item.done", done)} + done, _ = sjson.SetBytes(done, "item.name", call.Name) + done, _ = sjson.SetBytes(done, "item.arguments", arguments) + call.ItemDoneEmitted = true + return append(events, emitResponsesEvent("response.output_item.done", done)) default: done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{}}`) done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st)) @@ -394,6 +434,13 @@ func setResponsesCompletedOutput(payload []byte, st *interactionsToResponsesStre return payload } +func responsesFunctionCallArguments(call *interactionsFunctionCallState) string { + if call == nil || call.Arguments.Len() == 0 { + return "{}" + } + return call.Arguments.String() +} + func responsesCompletedOutputItem(index int, itemType string, st *interactionsToResponsesStreamState) ([]byte, bool) { switch itemType { case "model_output": @@ -408,13 +455,13 @@ func responsesCompletedOutputItem(index int, itemType string, st *interactionsTo case "thought": return responsesReasoningItem(index, st), true case "function_call": - item := []byte(`{"id":"","type":"function_call","call_id":"","name":"","arguments":""}`) + item := []byte(`{"id":"","type":"function_call","call_id":"","name":"","arguments":"{}"}`) itemID := st.ItemIDs[index] item, _ = sjson.SetBytes(item, "id", itemID) item, _ = sjson.SetBytes(item, "call_id", itemID) if call := st.FunctionCalls[index]; call != nil { item, _ = sjson.SetBytes(item, "name", call.Name) - item, _ = sjson.SetBytes(item, "arguments", call.Arguments.String()) + item, _ = sjson.SetBytes(item, "arguments", responsesFunctionCallArguments(call)) } return item, true } diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go index 182b41e8..26ec530c 100644 --- a/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go @@ -62,6 +62,13 @@ data: [DONE] if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); gjson.GetBytes(payload, "delta").String() != `{"location":"北京"}` { t.Fatalf("function args delta payload = %s", string(payload)) } + argumentsDonePayload := findResponsesEventPayload(out, "response.function_call_arguments.done") + if got := gjson.GetBytes(argumentsDonePayload, "item_id").String(); got != "call_1" { + t.Fatalf("function args done item_id = %q, want call_1. Payload: %s", got, string(argumentsDonePayload)) + } + if got := gjson.GetBytes(argumentsDonePayload, "arguments").String(); got != `{"location":"北京"}` { + t.Fatalf("function args done arguments = %q, want full arguments. Payload: %s", got, string(argumentsDonePayload)) + } completedPayload := findResponsesEventPayload(out, "response.completed") if got := gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int(); got != 399 { t.Fatalf("total_tokens = %d, want 399. Payload: %s", got, string(completedPayload)) @@ -74,6 +81,105 @@ data: [DONE] } } +func TestConvertInteractionsResponseToOpenAIResponsesStreamFunctionCallStartArguments(t *testing.T) { + var param any + var out [][]byte + for _, raw := range [][]byte{ + []byte(`event: step.start +data: {"index":0,"step":{"id":"call_1","type":"function_call","name":"lookup","arguments":{"q":"x"}},"event_type":"step.start"} + +`), + []byte(`event: step.stop +data: {"index":0,"event_type":"step.stop"} + +`), + } { + out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", nil, nil, raw, ¶m)...) + } + + gotEvents := strings.Join(responsesEventNames(out), ",") + wantEvents := "response.output_item.added,response.function_call_arguments.delta,response.function_call_arguments.done,response.output_item.done" + if gotEvents != wantEvents { + t.Fatalf("events = %s, want %s", gotEvents, wantEvents) + } + if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); gjson.GetBytes(payload, "delta").String() != `{"q":"x"}` { + t.Fatalf("function args delta = %s", string(payload)) + } + if payload := findResponsesEventPayload(out, "response.function_call_arguments.done"); gjson.GetBytes(payload, "arguments").String() != `{"q":"x"}` { + t.Fatalf("function args done = %s", string(payload)) + } + if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.arguments").String() != `{"q":"x"}` { + t.Fatalf("output item done = %s", string(payload)) + } +} + +func TestConvertInteractionsResponseToOpenAIResponsesStreamFunctionCallEmptyArguments(t *testing.T) { + var param any + var out [][]byte + for _, raw := range [][]byte{ + []byte(`event: step.start +data: {"index":0,"step":{"id":"call_1","type":"function_call","name":"lookup","arguments":{}},"event_type":"step.start"} + +`), + []byte(`event: step.stop +data: {"index":0,"event_type":"step.stop"} + +`), + []byte(`event: interaction.completed +data: {"interaction":{"id":"interaction_1","status":"completed","model":"gpt-test"},"event_type":"interaction.completed"} + +`), + } { + out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", nil, nil, raw, ¶m)...) + } + + gotEvents := strings.Join(responsesEventNames(out), ",") + wantEvents := "response.output_item.added,response.function_call_arguments.done,response.output_item.done,response.completed" + if gotEvents != wantEvents { + t.Fatalf("events = %s, want %s", gotEvents, wantEvents) + } + if payload := findResponsesEventPayload(out, "response.function_call_arguments.done"); gjson.GetBytes(payload, "arguments").String() != "{}" { + t.Fatalf("function args done = %s", string(payload)) + } + if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.arguments").String() != "{}" { + t.Fatalf("output item done = %s", string(payload)) + } + if payload := findResponsesEventPayload(out, "response.completed"); gjson.GetBytes(payload, "response.output.0.arguments").String() != "{}" { + t.Fatalf("completed output = %s", string(payload)) + } +} + +func TestConvertInteractionsResponseToOpenAIResponsesStreamFunctionCallEventsAreIdempotent(t *testing.T) { + var param any + var out [][]byte + for _, raw := range [][]byte{ + []byte(`event: step.start +data: {"index":0,"step":{"id":"call_1","type":"function_call","name":"lookup","arguments":{"q":"x"}},"event_type":"step.start"} + +`), + []byte(`event: step.start +data: {"index":0,"step":{"id":"call_1","type":"function_call","name":"lookup","arguments":{"q":"x"}},"event_type":"step.start"} + +`), + []byte(`event: step.stop +data: {"index":0,"event_type":"step.stop"} + +`), + []byte(`event: step.stop +data: {"index":0,"event_type":"step.stop"} + +`), + } { + out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", nil, nil, raw, ¶m)...) + } + + gotEvents := strings.Join(responsesEventNames(out), ",") + wantEvents := "response.output_item.added,response.function_call_arguments.delta,response.function_call_arguments.done,response.output_item.done" + if gotEvents != wantEvents { + t.Fatalf("events = %s, want %s", gotEvents, wantEvents) + } +} + func TestConvertInteractionsResponseToOpenAIResponsesStreamModelOutputDoneIncludesText(t *testing.T) { var param any var out [][]byte