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:
parent
f2addff099
commit
014278357a
2 changed files with 123 additions and 0 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue