openai: keep tool message content parts in one message

FromChatRequest splits array content into one api.Message per part. That
is intended for user content, but a tool message is a single result: the
split also dropped its tool_call_id and tool name, since those were only
copied when the message had tool_calls. Renderers that resolve tool
results by ID then see extra, unnamed results; for example, Kimi K3 fails
with "tool message 2 has no resolvable tool name" on a tool result with
two text parts, which the OpenAI spec allows.

Merge the parts of a tool message into one message with the text joined,
the images kept, and ToolCallID and ToolName set, matching how
function_call_output items are converted for /v1/responses.
This commit is contained in:
Dong Chen 2026-09-30 13:02:36 -07:00 • committed by Dong Chen
parent 1abe35e6e6
commit de119ea47f
2 changed files with 65 additions and 0 deletions

View file

@ -634,6 +634,7 @@ func FromChatRequest(r ChatCompletionRequest, thinking ...*model.Thinking) (*api
}
messages = append(messages, api.Message{Role: msg.Role, Content: content, Thinking: msg.Reasoning, ToolCalls: toolCalls, ToolName: toolName, ToolCallID: msg.ToolCallID})
case []any:
start := len(messages)
for _, c := range content {
data, ok := c.(map[string]any)
if !ok {
@ -682,6 +683,16 @@ func FromChatRequest(r ChatCompletionRequest, thinking ...*model.Thinking) (*api
return nil, errors.New("invalid message format")
}
}
// a tool message is a single result, so keep its parts together and
// preserve the tool call it answers
if strings.ToLower(msg.Role) == "tool" && len(messages) > start {
tool := api.Message{Role: msg.Role, ToolName: toolName, ToolCallID: msg.ToolCallID}
for _, part := range messages[start:] {
tool.Content += part.Content
tool.Images = append(tool.Images, part.Images...)
}
messages = append(messages[:start], tool)
}
// since we might have added multiple messages above, if we have tools
// calls we'll add them to the last message
if len(messages) > 0 && len(msg.ToolCalls) > 0 {

View file

@ -157,6 +157,60 @@ func TestFromChatRequest_WithImage(t *testing.T) {
}
}
func TestFromChatRequest_ToolMessageArrayContent(t *testing.T) {
imgData, _ := base64.StdEncoding.DecodeString(image)
req := ChatCompletionRequest{
Model: "test-model",
Messages: []Message{
{Role: "user", Content: "What's in the screenshot?"},
{
Role: "assistant",
ToolCalls: []ToolCall{{
ID: "call_1",
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "screenshot", Arguments: "{}"},
}},
},
{
Role: "tool",
ToolCallID: "call_1",
Content: []any{
map[string]any{"type": "text", "text": "Captured "},
map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": prefix + image},
},
map[string]any{"type": "text", "text": "screen.png"},
},
},
},
}
result, err := FromChatRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Messages) != 3 {
t.Fatalf("expected 3 messages, got %d", len(result.Messages))
}
tool := result.Messages[2]
if tool.Role != "tool" || tool.ToolCallID != "call_1" || tool.ToolName != "screenshot" {
t.Errorf("expected tool message for call_1/screenshot, got role=%q id=%q name=%q", tool.Role, tool.ToolCallID, tool.ToolName)
}
if tool.Content != "Captured screen.png" {
t.Errorf("expected content 'Captured screen.png', got %q", tool.Content)
}
if len(tool.Images) != 1 || string(tool.Images[0]) != string(imgData) {
t.Errorf("expected the tool image to be kept, got %d images", len(tool.Images))
}
}
func TestFromCompleteRequest_Basic(t *testing.T) {
temp := float32(0.8)
req := CompletionRequest{