Skip to content

Commit cd9d2dd

Browse files
fl-sean03claude
andcommitted
fix: include content field in assistant messages with tool_calls
Some strict OpenAI-compatible providers (e.g., TAMU AI API) require the `content` field to be present in assistant messages when `tool_calls` are provided, even if there is no text content. Previously, the `format_messages` function omitted `content` entirely when only tool calls were present, causing 400 Bad Request errors from these providers. Now sets `"content": null` on assistant messages that have tool_calls but no text content. Guard is scoped to Role::Assistant only per review feedback. Fixes #6717 Signed-off-by: fl-sean03 <sean@opspawn.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4e87011 commit cd9d2dd

File tree

1 file changed

+67
-0
lines changed

1 file changed

+67
-0
lines changed

crates/goose/src/providers/formats/openai.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,16 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
247247
converted["content"] = json!(text_array.join("\n"));
248248
}
249249

250+
// Ensure assistant messages with tool_calls always have a content field.
251+
// Some strict OpenAI-compatible providers require "content" to be present
252+
// (even as null) when tool_calls are provided. See #6717.
253+
if message.role == Role::Assistant
254+
&& converted.get("tool_calls").is_some()
255+
&& converted.get("content").is_none()
256+
{
257+
converted["content"] = json!(null);
258+
}
259+
250260
if converted.get("content").is_some() || converted.get("tool_calls").is_some() {
251261
output.insert(0, converted);
252262
}
@@ -1643,6 +1653,63 @@ data: [DONE]
16431653
Ok(())
16441654
}
16451655

1656+
#[test]
1657+
fn test_format_messages_tool_calls_without_text_includes_content_null() -> anyhow::Result<()> {
1658+
// Issue #6717: When an assistant message has only tool calls and no text,
1659+
// some strict OpenAI-compatible providers require "content" to be present
1660+
// (e.g., as null or ""). Ensure we always include "content" when tool_calls exist.
1661+
let message = Message::assistant().with_tool_request(
1662+
"tool1",
1663+
Ok(CallToolRequestParams {
1664+
meta: None,
1665+
task: None,
1666+
name: "example".into(),
1667+
arguments: Some(object!({"param1": "value1"})),
1668+
}),
1669+
);
1670+
1671+
let spec = format_messages(&[message], &ImageFormat::OpenAi);
1672+
1673+
assert_eq!(spec.len(), 1);
1674+
assert_eq!(spec[0]["role"], "assistant");
1675+
assert!(spec[0]["tool_calls"].is_array());
1676+
// The key assertion: content field must be present (as null) even with no text
1677+
assert!(
1678+
spec[0].get("content").is_some(),
1679+
"Assistant message with tool_calls must include a 'content' field"
1680+
);
1681+
assert!(
1682+
spec[0]["content"].is_null(),
1683+
"Content should be null when there is no text content"
1684+
);
1685+
1686+
Ok(())
1687+
}
1688+
1689+
#[test]
1690+
fn test_format_messages_tool_calls_with_text_keeps_content_string() -> anyhow::Result<()> {
1691+
// When an assistant message has both text and tool calls, content should be the text string
1692+
let mut message = Message::assistant().with_text("I'll help with that.");
1693+
message.content.push(MessageContent::tool_request(
1694+
"tool1".to_string(),
1695+
Ok(CallToolRequestParams {
1696+
meta: None,
1697+
task: None,
1698+
name: "example".into(),
1699+
arguments: Some(object!({"param1": "value1"})),
1700+
}),
1701+
));
1702+
1703+
let spec = format_messages(&[message], &ImageFormat::OpenAi);
1704+
1705+
assert_eq!(spec.len(), 1);
1706+
assert_eq!(spec[0]["role"], "assistant");
1707+
assert!(spec[0]["tool_calls"].is_array());
1708+
assert_eq!(spec[0]["content"], "I'll help with that.");
1709+
1710+
Ok(())
1711+
}
1712+
16461713
#[tokio::test]
16471714
async fn test_tetrate_claude_streaming_usage_yielded_once() -> anyhow::Result<()> {
16481715
let response_lines = r#"

0 commit comments

Comments
 (0)