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 @@ -881,9 +881,7 @@ fn encode_openai_message_plaintext_reasoning(message: &mut Value, content: &[Con
})
.collect::<Vec<_>>()
.join("\n");
if !reasoning.is_empty() {
message["reasoning"] = Value::String(reasoning);
}
set_openai_reasoning_text(message, reasoning);
}

// Adds exact provider details and text that cannot be recovered from those details.
Expand All @@ -907,9 +905,22 @@ fn encode_openai_message_structured_reasoning(
})
.collect::<Vec<_>>()
.join("\n");
if !fallback.is_empty() {
message["reasoning"] = Value::String(fallback);
set_openai_reasoning_text(message, fallback);
}

// Writes replayed reasoning under both OpenAI-compatible spellings.
//
// `reasoning` is what most OpenAI-compatible providers read. Reasoning-required
// upstreams look for `reasoning_content` specifically and reject a follow-up turn
// whose assistant history lacks it, treating the field as present-or-absent rather
// than reading the alias, so both are written. The decode side already accepts
// either spelling.
fn set_openai_reasoning_text(message: &mut Value, reasoning: String) {
if reasoning.is_empty() {
return;
}
message["reasoning"] = Value::String(reasoning.clone());
message["reasoning_content"] = Value::String(reasoning);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Checks whether any block in a message is a tool result.
Expand Down
83 changes: 82 additions & 1 deletion crates/switchyard-translation/tests/request_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub mod common;
use pretty_assertions::assert_eq;
use serde_json::{Value, json};
use switchyard_translation::{
LossyConversionPolicy, TranslationEngine, TranslationPolicy, WireFormat,
LossyConversionPolicy, PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat,
};

use common::{REASONING_MODEL, normalized_policy, shell_tool_call};
Expand Down Expand Up @@ -1010,6 +1010,85 @@ fn responses_reasoning_items_attach_to_tool_call_turn_for_openai_chat() -> TestR
Ok(())
}

// Reasoning-required upstreams look for `reasoning_content` and treat it as
// present-or-absent, so replaying only `reasoning` fails the next turn. Both
// spellings carry the same text, and tool calls stay alongside them.
#[test]
fn openai_chat_replays_reasoning_under_both_spellings() -> TestResult {
let engine = TranslationEngine::default();
let body = json!({
"model": "deepseek-reasoner",
"messages": [
{"role": "user", "content": "What is 2+2? Use the calculator."},
{
"role": "assistant",
"content": "I'll call the tool.",
"reasoning_content": "The user wants 2+2. Call the calculator.",
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {"name": "calculator", "arguments": "{\"a\": 2, \"b\": 2}"}
}]
},
{"role": "tool", "tool_call_id": "call_abc123", "content": "4"}
]
});

let output = engine
.translate_request(
WireFormat::OpenAiChat,
WireFormat::OpenAiChat,
&body,
&TranslationPolicy {
preservation: PreservationPolicy::Disabled,
..TranslationPolicy::default()
},
)?
.body;

let assistant = &output["messages"][1];
assert_eq!(
assistant["reasoning_content"],
"The user wants 2+2. Call the calculator."
);
assert_eq!(assistant["reasoning"], assistant["reasoning_content"]);
assert_eq!(assistant["content"], "I'll call the tool.");
assert_eq!(assistant["tool_calls"][0]["id"], "call_abc123");
assert_eq!(output["messages"][2]["role"], "tool");
assert_eq!(output["messages"][2]["tool_call_id"], "call_abc123");
Ok(())
}

// A turn carrying no reasoning must not gain either spelling.
#[test]
fn openai_chat_without_reasoning_sends_neither_spelling() -> TestResult {
let engine = TranslationEngine::default();
let body = json!({
"model": "deepseek-reasoner",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "continue"}
]
});

let output = engine
.translate_request(
WireFormat::OpenAiChat,
WireFormat::OpenAiChat,
&body,
&TranslationPolicy {
preservation: PreservationPolicy::Disabled,
..TranslationPolicy::default()
},
)?
.body;

assert!(output["messages"][1].get("reasoning").is_none());
assert!(output["messages"][1].get("reasoning_content").is_none());
Ok(())
}

// Verifies a reasoning item merges into the assistant message that follows it.
#[test]
fn responses_reasoning_item_merges_into_next_assistant_message_for_openai_chat() -> TestResult {
Expand Down Expand Up @@ -1043,6 +1122,7 @@ fn responses_reasoning_item_merges_into_next_assistant_message_for_openai_chat()
{
"role": "assistant",
"content": "Let me check.",
"reasoning_content": "Reading.",
"reasoning": "Reading."
}
])
Expand Down Expand Up @@ -1133,6 +1213,7 @@ fn openai_chat_encrypted_reasoning_details_retain_fallback() -> TestResult {

assert_eq!(output["messages"][0]["reasoning_details"], details);
assert_eq!(output["messages"][0]["reasoning"], "fallback text");
assert_eq!(output["messages"][0]["reasoning_content"], "fallback text");
Ok(())
}

Expand Down