Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ impl FormatCodec for OpenAiChatCodec {
for message in &request.messages {
messages.extend(encode_message_to_openai(message, &mut diagnostics, policy)?);
}
// Some upstreams (e.g. Moonshot/Kimi) reject an assistant message whose
// `content` is an empty string, which normalized turns with no text and
// no tool calls (e.g. a replayed reasoning-only turn) would otherwise
// produce. Such a message carries no information, so drop it rather than
// send it upstream.
messages.retain(|message| !is_empty_assistant_message(message));
body.insert("messages".to_string(), Value::Array(messages));

if !request.tools.is_empty() {
Expand Down Expand Up @@ -841,6 +847,16 @@ fn is_empty_text_only(content: &[ContentBlock]) -> bool {
matches!(content, [ContentBlock::Text { text }] if text.is_empty())
}

// An assistant message with an empty string `content` and no `tool_calls` — a
// tool-call-only turn instead encodes `content` as `null` (see
// `encode_message_without_tool_results_to_openai`), so this only matches a
// turn that carries no information at all.
fn is_empty_assistant_message(message: &Value) -> bool {
message.get("role").and_then(Value::as_str) == Some("assistant")
&& message.get("content") == Some(&Value::String(String::new()))
&& message.get("tool_calls").is_none()
}

/// Encodes normalized content into OpenAI Chat content JSON.
pub(crate) fn encode_openai_content(
content: &[ContentBlock],
Expand Down
52 changes: 52 additions & 0 deletions crates/switchyard-translation/tests/request_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1588,3 +1588,55 @@ fn anthropic_thinking_is_dropped_from_responses_input() -> TestResult {
);
Ok(())
}

// A replayed assistant turn that carried only thinking blocks (no text, no
// tool call) would otherwise encode as `{"role": "assistant", "content": ""}`
// for OpenAI Chat — a shape some upstreams (e.g. Moonshot/Kimi) reject outright
// ("the message at position N with role 'assistant' must not be empty"). The
// turn carries no information once its thinking is dropped, so it must not
// appear in the encoded messages at all.
#[test]
fn anthropic_thinking_only_turn_does_not_encode_an_empty_openai_chat_message() -> TestResult {
let engine = TranslationEngine::default();
let body = json!({
"model": "claude-opus-4-20250514",
"messages": [
{"role": "user", "content": "Think about it."},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "Just thinking, nothing to say.",
"signature": "sig-abc"
}
]
},
{"role": "user", "content": "Continue."}
],
"max_tokens": 2048
});

let output = engine
.translate_request(
WireFormat::AnthropicMessages,
WireFormat::OpenAiChat,
&body,
&TranslationPolicy::default(),
)?
.body;

let messages = output["messages"]
.as_array()
.ok_or("messages is not an array")?;
assert!(
!messages
.iter()
.any(|message| message["role"] == "assistant" && message["content"] == ""),
"thinking-only turn leaked an empty assistant message: {messages:?}"
);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[1]["role"], "user");
Ok(())
}