From 014278357aa43a05af9b151718bb272c63f98c84 Mon Sep 17 00:00:00 2001 From: Eric Jacksch Date: Thu, 12 Mar 2026 19:22:34 -0400 Subject: [PATCH] fix(openai_compat): omit empty content when tool_calls present Some providers (via OpenRouter) reject assistant messages with "content": "" alongside tool_calls. The OpenAI spec permits content to be absent when tool_calls is set. Switch openaiMessage.Content from string to *string with omitempty and introduce msgContent() to return nil when content is empty and tool calls are present. Co-Authored-By: Claude Sonnet 4.6 --- pkg/providers/openai_compat/provider.go | 78 ++++++++++++++++++++ pkg/providers/openai_compat/provider_test.go | 45 +++++++++++ 2 files changed, 123 insertions(+) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index fb2abaa5c..f0d07c0f0 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -175,6 +175,84 @@ func (p *Provider) Chat( return common.ReadAndParseResponse(resp, p.apiBase) } +// openaiMessage is the wire-format message for OpenAI-compatible APIs. +// It mirrors protocoltypes.Message but omits SystemParts, which is an +// internal field that would be unknown to third-party endpoints. +type openaiMessage struct { + Role string `json:"role"` + Content *string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +// msgContent returns the content pointer for an outbound message. +// When content is empty and tool_calls are present, nil is returned so the +// field is omitted entirely. The OpenAI spec allows content to be absent (or +// null) when tool_calls is set, and some strict providers reject "" in that +// position, causing intermittent failures. +func msgContent(content string, toolCalls []ToolCall) *string { + if content == "" && len(toolCalls) > 0 { + return nil + } + return &content +} + +// serializeMessages converts internal Message structs to the OpenAI wire format. +// - Strips SystemParts (unknown to third-party endpoints) +// - Converts messages with Media to multipart content format (text + image_url parts) +// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages +func serializeMessages(messages []Message) []any { + out := make([]any, 0, len(messages)) + for _, m := range messages { + if len(m.Media) == 0 { + out = append(out, openaiMessage{ + Role: m.Role, + Content: msgContent(m.Content, m.ToolCalls), + ReasoningContent: m.ReasoningContent, + ToolCalls: m.ToolCalls, + ToolCallID: m.ToolCallID, + }) + continue + } + + // Multipart content format for messages with media + parts := make([]map[string]any, 0, 1+len(m.Media)) + if m.Content != "" { + parts = append(parts, map[string]any{ + "type": "text", + "text": m.Content, + }) + } + for _, mediaURL := range m.Media { + if strings.HasPrefix(mediaURL, "data:image/") { + parts = append(parts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": mediaURL, + }, + }) + } + } + + msg := map[string]any{ + "role": m.Role, + "content": parts, + } + if m.ToolCallID != "" { + msg["tool_call_id"] = m.ToolCallID + } + if len(m.ToolCalls) > 0 { + msg["tool_calls"] = m.ToolCalls + } + if m.ReasoningContent != "" { + msg["reasoning_content"] = m.ReasoningContent + } + out = append(out, msg) + } + return out +} + func normalizeModel(model, apiBase string) string { before, after, ok := strings.Cut(model, "/") if !ok { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index ed9747f9d..27f1fedda 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -824,6 +824,51 @@ func TestSupportsPromptCacheKey(t *testing.T) { } } +func TestSerializeMessages_OmitsContentWhenEmptyAndToolCallsPresent(t *testing.T) { + messages := []protocoltypes.Message{ + { + Role: "assistant", + Content: "", + ToolCalls: []protocoltypes.ToolCall{ + {ID: "call_1", Type: "function", Function: &protocoltypes.FunctionCall{Name: "fn", Arguments: "{}"}}, + }, + }, + } + result := serializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if _, ok := msgs[0]["content"]; ok { + t.Fatalf("content should be omitted when empty and tool_calls present, got %v", msgs[0]["content"]) + } + if msgs[0]["tool_calls"] == nil { + t.Fatal("tool_calls should be present") + } +} + +func TestSerializeMessages_IncludesContentWhenNonEmptyWithToolCalls(t *testing.T) { + messages := []protocoltypes.Message{ + { + Role: "assistant", + Content: "thinking...", + ToolCalls: []protocoltypes.ToolCall{ + {ID: "call_1", Type: "function", Function: &protocoltypes.FunctionCall{Name: "fn", Arguments: "{}"}}, + }, + }, + } + result := serializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if msgs[0]["content"] != "thinking..." { + t.Fatalf("content should be preserved when non-empty, got %v", msgs[0]["content"]) + } +} + func TestSerializeMessages_StripsSystemParts(t *testing.T) { messages := []protocoltypes.Message{ {