mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
+87
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user