diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index dbe77ca2a..343e7b7cf 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -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() { @@ -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], diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 71c765d01..704189709 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -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(()) +}