fix(ai-formats): carry OpenRouter reasoning fields through chat conversion

OpenRouter reports reasoning under `reasoning` and `reasoning_details`
rather than the DeepSeek-style `reasoning_content` this crate recognized.
Its streaming reasoning phase sends chunks whose `delta.content` is an
empty string, so those chunks were dropped and Responses clients saw
nothing after `response.in_progress` until they timed the stream out.
The sync aggregator kept only content and tool calls, so a stream
downgraded to a sync response lost the reasoning entirely.

Read all three spellings through one helper. `reasoning_details` wins
because only it carries the block index, and OpenRouter repeats the same
text in both fields, so exactly one source is read per object. Entries
typed `reasoning.encrypted` carry opaque provider state rather than
readable text and are skipped. A change of block index closes the open
part so downstream summaries keep the provider's segmentation.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
stabey
2026-09-16 00:39:20 +08:00
co-authored by Claude Opus 5
parent 60b89cc840
commit 03496c46c5
4 changed files with 511 additions and 23 deletions
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
@@ -13,6 +13,53 @@ use crate::{
},
};
/// Reasoning text carried by one Chat Completions `message` or streaming
/// `delta`, paired with the provider's reasoning block index where one exists.
///
/// The field name is not standardized. DeepSeek-style upstreams send
/// `reasoning_content`; OpenRouter sends `reasoning` alongside a structured
/// `reasoning_details` array. OpenRouter repeats the same text in both of its
/// fields, so exactly one source is read per object and `reasoning_details`
/// wins because only it carries the block index.
pub(crate) fn openai_chat_reasoning_texts(
object: &Map<String, Value>,
) -> Vec<(Option<usize>, String)> {
if let Some(details) = object.get("reasoning_details").and_then(Value::as_array) {
let texts = details
.iter()
.filter_map(Value::as_object)
.filter_map(|detail| {
// `reasoning.encrypted` carries opaque provider state rather
// than readable text, so it has nothing to hand downstream.
if detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted") {
return None;
}
let text = detail
.get("text")
.or_else(|| detail.get("summary"))
.and_then(Value::as_str)
.filter(|text| !text.is_empty())?;
let index = detail
.get("index")
.and_then(Value::as_u64)
.map(|index| index as usize);
Some((index, text.to_string()))
})
.collect::<Vec<_>>();
if !texts.is_empty() {
return texts;
}
}
// A provider may null out one spelling while filling the other, so skip
// past any key that is present but carries no string.
["reasoning_content", "reasoning"]
.iter()
.find_map(|key| object.get(*key).and_then(Value::as_str))
.filter(|text| !text.is_empty())
.map(|text| vec![(None, text.to_string())])
.unwrap_or_default()
}
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_raw(body)
}
@@ -40,21 +87,18 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
.iter()
.any(|block| matches!(block, CanonicalContentBlock::Thinking { .. }))
{
if let Some(reasoning_content) = message
.get("reasoning_content")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
{
content.insert(
0,
CanonicalContentBlock::Thinking {
text: reasoning_content.to_string(),
signature: None,
encrypted_content: None,
extensions: BTreeMap::new(),
},
);
}
let thinking = openai_chat_reasoning_texts(message)
.into_iter()
.map(|(_, text)| text)
.filter(|text| !text.trim().is_empty())
.map(|text| CanonicalContentBlock::Thinking {
text,
signature: None,
encrypted_content: None,
extensions: BTreeMap::new(),
})
.collect::<Vec<_>>();
content.splice(0..0, thinking);
}
let stop_reason =
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str));
@@ -179,3 +223,161 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
}
response
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::canonical::CanonicalContentBlock;
fn thinking_texts(response: &CanonicalResponse) -> Vec<String> {
response
.content
.iter()
.filter_map(|block| match block {
CanonicalContentBlock::Thinking { text, .. } => Some(text.clone()),
_ => None,
})
.collect()
}
#[test]
fn openrouter_reasoning_details_become_thinking_blocks() {
let response = from_raw(&json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "done",
"reasoning": "step onestep two",
"reasoning_details": [
{"type": "reasoning.text", "text": "step one", "index": 0},
{"type": "reasoning.text", "text": "step two", "index": 1}
]
},
"finish_reason": "stop"
}]
}))
.expect("openrouter response should convert");
// `reasoning` repeats the same text the details already carry, so the
// details win and the provider's own segmentation survives.
assert_eq!(thinking_texts(&response), vec!["step one", "step two"]);
}
#[test]
fn openrouter_reasoning_string_becomes_a_thinking_block() {
let response = from_raw(&json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "done",
"reasoning": "thought about it"
},
"finish_reason": "stop"
}]
}))
.expect("openrouter response should convert");
assert_eq!(thinking_texts(&response), vec!["thought about it"]);
}
#[test]
fn deepseek_reasoning_content_still_becomes_a_thinking_block() {
let response = from_raw(&json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "42",
"reasoning_content": "let me work it out"
},
"finish_reason": "stop"
}]
}))
.expect("deepseek response should convert");
assert_eq!(thinking_texts(&response), vec!["let me work it out"]);
}
#[test]
fn deepseek_reasoning_content_wins_over_a_bare_reasoning_field() {
let response = from_raw(&json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "42",
"reasoning_content": "the real one",
"reasoning": "the other spelling"
},
"finish_reason": "stop"
}]
}))
.expect("deepseek response should convert");
assert_eq!(thinking_texts(&response), vec!["the real one"]);
}
#[test]
fn blank_reasoning_content_produces_no_thinking_block() {
let response = from_raw(&json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "42", "reasoning_content": " "},
"finish_reason": "stop"
}]
}))
.expect("deepseek response should convert");
assert!(thinking_texts(&response).is_empty());
}
#[test]
fn plain_openai_response_without_reasoning_is_unchanged() {
let response = from_raw(&json!({
"id": "chatcmpl-openai",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "hello"},
"finish_reason": "stop"
}]
}))
.expect("openai response should convert");
assert!(thinking_texts(&response).is_empty());
}
#[test]
fn encrypted_reasoning_details_carry_no_thinking_text() {
let response = from_raw(&json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "done",
"reasoning_details": [
{"type": "reasoning.encrypted", "data": "b3BhcXVl", "index": 0}
]
},
"finish_reason": "stop"
}]
}))
.expect("openrouter response should convert");
assert!(thinking_texts(&response).is_empty());
}
}
@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use crate::formats::openai::chat::response::openai_chat_reasoning_texts;
use crate::formats::openai::namespace::NamespaceToolAliases;
use crate::formats::openai::responses::{
encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id,
@@ -41,6 +42,7 @@ pub struct OpenAIChatProviderState {
started: bool,
finished: bool,
pending_finish_reason: Option<String>,
last_reasoning_index: Option<usize>,
tool_calls: BTreeMap<usize, OpenAIChatProviderToolState>,
}
@@ -271,24 +273,39 @@ impl OpenAIChatProviderState {
} else if delta.contains_key("content") {
recognized_delta = true;
}
if let Some(reasoning_content) = delta.get("reasoning_content").and_then(Value::as_str)
if delta.contains_key("reasoning_content")
|| delta.contains_key("reasoning_details")
|| delta.contains_key("reasoning")
{
recognized_delta = true;
if !reasoning_content.is_empty() {
for (reasoning_index, text) in openai_chat_reasoning_texts(delta) {
self.ensure_started(report_context, &mut out);
if !self.terminal_only {
let (id, model) = self.identity(report_context);
// A change of reasoning block index closes the part that
// is open, so downstream summaries keep the provider's
// own segmentation instead of collapsing into one
// paragraph.
if let Some(reasoning_index) = reasoning_index {
if self
.last_reasoning_index
.is_some_and(|last| last != reasoning_index)
{
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ReasoningSummaryDone,
});
}
self.last_reasoning_index = Some(reasoning_index);
}
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ReasoningDelta(
reasoning_content.to_string(),
),
event: CanonicalStreamEvent::ReasoningDelta(text),
});
}
}
} else if delta.contains_key("reasoning_content") {
recognized_delta = true;
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
@@ -4287,6 +4304,175 @@ mod tests {
)));
}
#[test]
fn openai_chat_provider_state_reads_openrouter_reasoning_once() {
let mut state = OpenAIChatProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"delta": {
"content": "",
"role": "assistant",
"reasoning": " me translate the",
"reasoning_details": [{
"type": "reasoning.text",
"text": " me translate the",
"format": "unknown",
"index": 0
}]
},
"finish_reason": Value::Null
}]
})),
)
.expect("openrouter reasoning delta should parse");
let reasoning = frames
.iter()
.filter_map(|frame| match frame.event {
CanonicalStreamEvent::ReasoningDelta(ref text) => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>();
// `reasoning` and `reasoning_details` repeat the same text, so only one
// of the two may reach the client.
assert_eq!(reasoning, vec![" me translate the"]);
assert!(!frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_))));
}
#[test]
fn openai_chat_provider_state_still_reads_deepseek_reasoning_content() {
let mut state = OpenAIChatProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"delta": {"role": "assistant", "reasoning_content": "let me work it out"},
"finish_reason": Value::Null
}]
})),
)
.expect("deepseek reasoning delta should parse");
let reasoning = frames
.iter()
.filter_map(|frame| match frame.event {
CanonicalStreamEvent::ReasoningDelta(ref text) => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(reasoning, vec!["let me work it out"]);
// A single reasoning block carries no index, so nothing may close a part.
assert!(!frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::ReasoningSummaryDone)));
assert!(!frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_))));
}
#[test]
fn openai_chat_provider_state_splits_reasoning_details_on_block_index() {
let mut state = OpenAIChatProviderState::default();
let report_context = json!({});
let reasoning_chunk = |index: u64, text: &str| {
data_line(json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"delta": {
"content": "",
"role": "assistant",
"reasoning_details": [{
"type": "reasoning.text",
"text": text,
"index": index
}]
},
"finish_reason": Value::Null
}]
}))
};
let mut frames = state
.push_line(&report_context, reasoning_chunk(0, "first"))
.expect("first reasoning block should parse");
frames.extend(
state
.push_line(&report_context, reasoning_chunk(1, "second"))
.expect("second reasoning block should parse"),
);
let reasoning = frames
.iter()
.filter_map(|frame| match frame.event {
CanonicalStreamEvent::ReasoningDelta(ref text) => Some(text.as_str()),
CanonicalStreamEvent::ReasoningSummaryDone => Some("<part-done>"),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(reasoning, vec!["first", "<part-done>", "second"]);
}
#[test]
fn openai_chat_reasoning_only_stream_reaches_responses_clients() {
// Regression: OpenRouter streams a long reasoning phase as chunks whose
// `delta.content` is an empty string and whose text sits under
// `reasoning`. Dropping those chunks left Responses clients with
// nothing after `response.in_progress` until they timed the stream out.
let mut state = OpenAIChatProviderState::default();
let mut emitter = OpenAIResponsesClientEmitter::default();
let report_context = json!({});
let mut bytes = Vec::new();
for piece in ["Let", " me think."] {
let frames = state
.push_line(
&report_context,
data_line(json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"delta": {
"content": "",
"role": "assistant",
"reasoning": piece,
"reasoning_details": [{
"type": "reasoning.text",
"text": piece,
"format": "unknown",
"index": 0
}]
},
"finish_reason": Value::Null
}]
})),
)
.expect("reasoning chunk should parse");
for frame in frames {
bytes.extend(emitter.emit(frame).expect("frame should encode"));
}
}
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(sse.contains("\"delta\":\"Let\""));
assert!(sse.contains("\"delta\":\" me think.\""));
}
#[test]
fn openai_chat_provider_state_waits_for_real_tool_call_identity() {
let mut state = OpenAIChatProviderState::default();
@@ -26,6 +26,7 @@ use serde_json::{json, Map, Value};
use super::{decode_sync_report_body_base64, AiSurfaceFinalizeError};
use crate::formats::claude::messages::stream::ClaudeProviderState;
use crate::formats::gemini::generate_content::stream::GeminiProviderState;
use crate::formats::openai::chat::response::openai_chat_reasoning_texts;
use crate::formats::openai::chat::stream::{OpenAIChatProviderState, OpenAIResponsesProviderState};
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::response::sanitize_claude_read_tool_inputs;
@@ -1827,6 +1828,7 @@ fn apply_report_context_model_fallback(model: &mut String, report_context: &Valu
struct OpenAIChatChoiceState {
role: Option<String>,
content: String,
reasoning: String,
finish_reason: Option<String>,
tool_calls: BTreeMap<usize, OpenAIChatToolCallState>,
}
@@ -2195,6 +2197,9 @@ pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value>
if let Some(content) = delta.get("content").and_then(Value::as_str) {
state.content.push_str(content);
}
for (_, piece) in openai_chat_reasoning_texts(delta) {
state.reasoning.push_str(&piece);
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_calls {
let Some(tool_call_object) = tool_call.as_object() else {
@@ -2254,6 +2259,14 @@ pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value>
"role".to_string(),
Value::String(state.role.unwrap_or_else(|| "assistant".to_string())),
);
// Reassemble under the spelling this crate emits for Chat clients; the
// provider's own spelling was already normalized away by the parser.
if !state.reasoning.is_empty() {
message.insert(
"reasoning_content".to_string(),
Value::String(state.reasoning),
);
}
if state.tool_calls.is_empty() {
message.insert("content".to_string(), Value::String(state.content));
} else {
@@ -4139,6 +4152,92 @@ mod tests {
);
}
#[test]
fn aggregates_openai_chat_stream_reasoning_into_sync_body() {
// The aggregator used to keep only `content` and `tool_calls`, so a
// stream downgraded to a sync response lost the reasoning entirely —
// for OpenRouter's `reasoning`/`reasoning_details` and for the
// DeepSeek-style `reasoning_content` alike.
let body = concat!(
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"Let me\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"Let me\",\"index\":0}]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" think.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" think.\",\"index\":0}]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n",
);
let result = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("openrouter chat stream should aggregate into a sync body");
let message = &result["choices"][0]["message"];
assert_eq!(message["content"], "Done.");
// `reasoning` and `reasoning_details` repeat one another, so the
// reassembled text must not double up.
assert_eq!(message["reasoning_content"], "Let me think.");
assert_eq!(result["choices"][0]["finish_reason"], "stop");
}
#[test]
fn aggregates_deepseek_reasoning_content_into_sync_body() {
let body = concat!(
"data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\"Let me\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" think.\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"42\"},\"finish_reason\":\"stop\"}]}\n\n",
);
let result = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("deepseek chat stream should aggregate into a sync body");
let message = &result["choices"][0]["message"];
assert_eq!(message["content"], "42");
assert_eq!(message["reasoning_content"], "Let me think.");
assert_eq!(result["choices"][0]["finish_reason"], "stop");
}
#[test]
fn aggregated_chat_stream_without_reasoning_adds_no_reasoning_key() {
let body = "data: {\"id\":\"chatcmpl-openai\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n";
let result = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("plain chat stream should aggregate into a sync body");
let message = &result["choices"][0]["message"];
assert_eq!(message["content"], "hi");
assert!(
message.get("reasoning_content").is_none(),
"a stream with no reasoning must not gain a reasoning key: {message}"
);
}
#[test]
fn aggregated_openai_chat_reasoning_reaches_every_client_format() {
let body = concat!(
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"Thinking.\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\",\"role\":\"assistant\"},\"finish_reason\":\"stop\"}]}\n\n",
);
let aggregated = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("openrouter chat stream should aggregate into a sync body");
let report_context = json!({});
for (client_api_format, marker) in [
("openai:responses", "\"type\":\"reasoning\""),
("claude:messages", "\"type\":\"thinking\""),
("gemini:generate_content", "\"thought\":true"),
] {
let converted = convert_standard_chat_response(
&aggregated,
"openai:chat",
client_api_format,
&report_context,
)
.unwrap_or_else(|| panic!("{client_api_format} should convert"));
let encoded = serde_json::to_string(&converted).expect("converted body should encode");
assert!(
encoded.contains(marker),
"{client_api_format} dropped the reasoning block: {encoded}"
);
}
}
#[test]
fn aggregates_openai_chat_stream_tool_usage_and_finish_into_sync_body() {
let body = concat!(