Merge pull request #7787 from SPHINX998/fix/apicompat-chat-legacy-function-call

fix(apicompat): pair legacy chat function calls with their results
This commit is contained in:
Wesley Liddick
2026-10-08 11:26:15 +08:00
committed by GitHub
2 changed files with 158 additions and 3 deletions
@@ -108,7 +108,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
@@ -118,6 +129,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) {
@@ -306,8 +369,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 {
@@ -316,9 +380,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
}
@@ -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)
}