From 7a767d5ce874d155ab644d5965d50dff80f3b5df Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:55:31 +0800 Subject: [PATCH] fix(apicompat): pair legacy chat function calls with their results Legacy Chat function calling has no call IDs: the assistant turn carries function_call and the role=function result refers to it only by name. ChatCompletionsToResponses ignored the assistant function_call and used the function name as the result's call_id, so the Responses upstream got a function_call_output with no matching function_call and rejected the replayed history. Emit a function_call item for each legacy assistant call and give the following same-name result the same call_id. IDs follow message order (call_legacy_N), so the same history converts to the same input across turns, and skip IDs already used by tool_calls. A result with no preceding call keeps the previous name-based call_id. Co-Authored-By: Claude Opus 5.5 --- .../apicompat/chatcompletions_to_responses.go | 74 +++++++++++++++- ..._to_responses_legacy_function_call_test.go | 87 +++++++++++++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index d1f3bccc00..f6585ac3db 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -107,7 +107,18 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, // array into a Responses API input items array. func convertChatMessagesToResponsesInput(msgs []ChatMessage) ([]ResponsesInputItem, error) { var out []ResponsesInputItem + legacyIDs := legacyFunctionCallIDs{msgs: msgs} for _, m := range msgs { + switch { + case m.Role == "assistant" && m.FunctionCall != nil && len(m.ToolCalls) == 0: + m.ToolCalls = []ChatToolCall{{ + ID: legacyIDs.assign(m.FunctionCall.Name), + Type: "function", + Function: *m.FunctionCall, + }} + case m.Role == "function" && m.ToolCallID == "": + m.ToolCallID = legacyIDs.claim(m.Name) + } items, err := chatMessageToResponsesItems(m) if err != nil { return nil, err @@ -117,6 +128,58 @@ func convertChatMessagesToResponsesInput(msgs []ChatMessage) ([]ResponsesInputIt return out, nil } +// legacyFunctionCallIDs pairs legacy function calls with their results. Legacy +// function calling carries no call IDs: the assistant turn has function_call +// and the role=function result refers to it only by name, while the Responses +// API pairs function_call and function_call_output by call_id. IDs follow +// message order, so a replayed history converts to the same input, and skip +// IDs already used by tool_calls in the same conversation. +type legacyFunctionCallIDs struct { + msgs []ChatMessage + used map[string]bool + next int + pending map[string][]string +} + +// assign returns a new call ID for a legacy function call and queues it for +// the next result with the same function name. +func (ids *legacyFunctionCallIDs) assign(name string) string { + if ids.used == nil { + ids.used = make(map[string]bool) + ids.pending = make(map[string][]string) + for _, m := range ids.msgs { + for _, tc := range m.ToolCalls { + ids.used[tc.ID] = true + } + if m.ToolCallID != "" { + ids.used[m.ToolCallID] = true + } + } + } + var id string + for { + ids.next++ + id = fmt.Sprintf("call_legacy_%d", ids.next) + if !ids.used[id] { + break + } + } + ids.used[id] = true + ids.pending[name] = append(ids.pending[name], id) + return id +} + +// claim returns the oldest unanswered call ID for name, or "" when no legacy +// call with that name precedes the result. +func (ids *legacyFunctionCallIDs) claim(name string) string { + queue := ids.pending[name] + if len(queue) == 0 { + return "" + } + ids.pending[name] = queue[1:] + return queue[0] +} + // chatMessageToResponsesItems converts a single ChatMessage into one or more // ResponsesInputItem values. func chatMessageToResponsesItems(m ChatMessage) ([]ResponsesInputItem, error) { @@ -303,8 +366,9 @@ func chatToolToResponses(m ChatMessage) ([]ResponsesInputItem, error) { } // chatFunctionToResponses converts a legacy function result message -// (role=function) into a function_call_output item. The Name field is used as -// call_id since legacy function calls do not carry a separate call_id. +// (role=function) into a function_call_output item. ToolCallID holds the ID +// assigned to the matching legacy call (see legacyFunctionCallIDs); without a +// matching call the Name field is used as call_id. func chatFunctionToResponses(m ChatMessage) ([]ResponsesInputItem, error) { output, err := parseChatContent(m.Content) if err != nil { @@ -313,9 +377,13 @@ func chatFunctionToResponses(m ChatMessage) ([]ResponsesInputItem, error) { if output == "" { output = "(empty)" } + callID := m.ToolCallID + if callID == "" { + callID = m.Name + } return []ResponsesInputItem{{ Type: "function_call_output", - CallID: m.Name, + CallID: callID, Output: output, }}, nil } diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go new file mode 100644 index 0000000000..1c971fd087 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go @@ -0,0 +1,87 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func convertLegacyFunctionHistory(t *testing.T, msgs []ChatMessage) []ResponsesInputItem { + t.Helper() + resp, err := ChatCompletionsToResponses(&ChatCompletionsRequest{Model: "gpt-4o", Messages: msgs}) + require.NoError(t, err) + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + return items +} + +// Legacy function calling has no call IDs: the assistant turn carries +// function_call and the result is a role=function message keyed by name. The +// Responses API needs a function_call item and a function_call_output with the +// same call_id, otherwise the output is rejected as having no matching call. +func TestChatCompletionsToResponses_LegacyFunctionCallHistoryPaired(t *testing.T) { + msgs := []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"Weather in Paris and Rome?"`)}, + {Role: "assistant", Content: json.RawMessage(`"Checking Paris."`), FunctionCall: &ChatFunctionCall{Name: "get_weather", Arguments: `{"city":"Paris"}`}}, + {Role: "function", Name: "get_weather", Content: json.RawMessage(`"sunny"`)}, + {Role: "assistant", FunctionCall: &ChatFunctionCall{Name: "get_weather", Arguments: `{"city":"Rome"}`}}, + {Role: "function", Name: "get_weather", Content: json.RawMessage(`"rainy"`)}, + } + + items := convertLegacyFunctionHistory(t, msgs) + require.Len(t, items, 6) + + require.Equal(t, "user", items[0].Role) + require.Equal(t, "assistant", items[1].Role) + + firstCall, firstOutput := items[2], items[3] + require.Equal(t, "function_call", firstCall.Type) + require.Equal(t, "get_weather", firstCall.Name) + require.Equal(t, `{"city":"Paris"}`, firstCall.Arguments) + require.Equal(t, "function_call_output", firstOutput.Type) + require.Equal(t, "sunny", firstOutput.Output) + require.NotEmpty(t, firstCall.CallID) + require.Equal(t, firstCall.CallID, firstOutput.CallID) + + secondCall, secondOutput := items[4], items[5] + require.Equal(t, "function_call", secondCall.Type) + require.Equal(t, `{"city":"Rome"}`, secondCall.Arguments) + require.Equal(t, "function_call_output", secondOutput.Type) + require.Equal(t, "rainy", secondOutput.Output) + require.Equal(t, secondCall.CallID, secondOutput.CallID) + require.NotEqual(t, firstCall.CallID, secondCall.CallID) + + // Synthesized IDs must be stable across turns so the replayed history keeps + // the same prompt prefix. + require.Equal(t, items, convertLegacyFunctionHistory(t, msgs)) +} + +func TestChatCompletionsToResponses_LegacyFunctionCallIDAvoidsToolCallIDs(t *testing.T) { + items := convertLegacyFunctionHistory(t, []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"hi"`)}, + {Role: "assistant", FunctionCall: &ChatFunctionCall{Name: "lookup", Arguments: `{}`}}, + {Role: "function", Name: "lookup", Content: json.RawMessage(`"legacy result"`)}, + {Role: "assistant", ToolCalls: []ChatToolCall{{ID: "call_legacy_1", Type: "function", Function: ChatFunctionCall{Name: "lookup", Arguments: `{}`}}}}, + {Role: "tool", ToolCallID: "call_legacy_1", Content: json.RawMessage(`"tool result"`)}, + }) + require.Len(t, items, 5) + + legacyCall, legacyOutput := items[1], items[2] + require.Equal(t, "function_call", legacyCall.Type) + require.Equal(t, legacyCall.CallID, legacyOutput.CallID) + require.NotEqual(t, "call_legacy_1", legacyCall.CallID) + + require.Equal(t, "call_legacy_1", items[3].CallID) + require.Equal(t, "call_legacy_1", items[4].CallID) +} + +func TestChatCompletionsToResponses_LegacyFunctionResultWithoutCallKeepsName(t *testing.T) { + items := convertLegacyFunctionHistory(t, []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"hi"`)}, + {Role: "function", Name: "lookup", Content: json.RawMessage(`"orphan"`)}, + }) + require.Len(t, items, 2) + require.Equal(t, "function_call_output", items[1].Type) + require.Equal(t, "lookup", items[1].CallID) +}