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 <noreply@anthropic.com>
This commit is contained in:
Eric Jacksch 2026-03-12 19:22:34 -04:00
parent f2addff099
commit 014278357a
2 changed files with 123 additions and 0 deletions

View file

@ -175,6 +175,84 @@ func (p *Provider) Chat(
return common.ReadAndParseResponse(resp, p.apiBase) 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 { func normalizeModel(model, apiBase string) string {
before, after, ok := strings.Cut(model, "/") before, after, ok := strings.Cut(model, "/")
if !ok { if !ok {

View file

@ -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) { func TestSerializeMessages_StripsSystemParts(t *testing.T) {
messages := []protocoltypes.Message{ messages := []protocoltypes.Message{
{ {