Merge pull request #823 from stabey/fix/openrouter-reasoning-fields

fix(ai-formats): OpenRouter 推理字段在 Chat 转换链路中丢失
This commit is contained in:
ZheFox
2026-09-16 23:57:54 +08:00
committed by GitHub
4 changed files with 511 additions and 23 deletions
@@ -1,6 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use serde_json::{json, Value}; use serde_json::{json, Map, Value};
use crate::{ use crate::{
formats::context::FormatContext, 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> { pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_raw(body) from_raw(body)
} }
@@ -40,21 +87,18 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
.iter() .iter()
.any(|block| matches!(block, CanonicalContentBlock::Thinking { .. })) .any(|block| matches!(block, CanonicalContentBlock::Thinking { .. }))
{ {
if let Some(reasoning_content) = message let thinking = openai_chat_reasoning_texts(message)
.get("reasoning_content") .into_iter()
.and_then(Value::as_str) .map(|(_, text)| text)
.filter(|value| !value.trim().is_empty()) .filter(|text| !text.trim().is_empty())
{ .map(|text| CanonicalContentBlock::Thinking {
content.insert( text,
0, signature: None,
CanonicalContentBlock::Thinking { encrypted_content: None,
text: reasoning_content.to_string(), extensions: BTreeMap::new(),
signature: None, })
encrypted_content: None, .collect::<Vec<_>>();
extensions: BTreeMap::new(), content.splice(0..0, thinking);
},
);
}
} }
let stop_reason = let stop_reason =
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str)); 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 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 serde_json::{json, Map, Value};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::formats::openai::chat::response::openai_chat_reasoning_texts;
use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::namespace::NamespaceToolAliases;
use crate::formats::openai::responses::{ use crate::formats::openai::responses::{
encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id, encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id,
@@ -41,6 +42,7 @@ pub struct OpenAIChatProviderState {
started: bool, started: bool,
finished: bool, finished: bool,
pending_finish_reason: Option<String>, pending_finish_reason: Option<String>,
last_reasoning_index: Option<usize>,
tool_calls: BTreeMap<usize, OpenAIChatProviderToolState>, tool_calls: BTreeMap<usize, OpenAIChatProviderToolState>,
} }
@@ -271,24 +273,39 @@ impl OpenAIChatProviderState {
} else if delta.contains_key("content") { } else if delta.contains_key("content") {
recognized_delta = true; 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; 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); self.ensure_started(report_context, &mut out);
if !self.terminal_only { if !self.terminal_only {
let (id, model) = self.identity(report_context); 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 { out.push(CanonicalStreamFrame {
id, id,
model, model,
event: CanonicalStreamEvent::ReasoningDelta( event: CanonicalStreamEvent::ReasoningDelta(text),
reasoning_content.to_string(),
),
}); });
} }
} }
} else if delta.contains_key("reasoning_content") {
recognized_delta = true;
} }
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) { if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
@@ -4291,6 +4308,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] #[test]
fn openai_chat_provider_state_waits_for_real_tool_call_identity() { fn openai_chat_provider_state_waits_for_real_tool_call_identity() {
let mut state = OpenAIChatProviderState::default(); let mut state = OpenAIChatProviderState::default();
@@ -27,6 +27,7 @@ use serde_json::{json, Map, Value};
use super::{decode_sync_report_body_base64, AiSurfaceFinalizeError}; use super::{decode_sync_report_body_base64, AiSurfaceFinalizeError};
use crate::formats::claude::messages::stream::ClaudeProviderState; use crate::formats::claude::messages::stream::ClaudeProviderState;
use crate::formats::gemini::generate_content::stream::GeminiProviderState; 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::openai::chat::stream::{OpenAIChatProviderState, OpenAIResponsesProviderState};
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context; use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::response::sanitize_claude_read_tool_inputs; use crate::formats::shared::response::sanitize_claude_read_tool_inputs;
@@ -1828,6 +1829,7 @@ fn apply_report_context_model_fallback(model: &mut String, report_context: &Valu
struct OpenAIChatChoiceState { struct OpenAIChatChoiceState {
role: Option<String>, role: Option<String>,
content: String, content: String,
reasoning: String,
finish_reason: Option<String>, finish_reason: Option<String>,
tool_calls: BTreeMap<usize, OpenAIChatToolCallState>, tool_calls: BTreeMap<usize, OpenAIChatToolCallState>,
} }
@@ -2196,6 +2198,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) { if let Some(content) = delta.get("content").and_then(Value::as_str) {
state.content.push_str(content); 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) { if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_calls { for tool_call in tool_calls {
let Some(tool_call_object) = tool_call.as_object() else { let Some(tool_call_object) = tool_call.as_object() else {
@@ -2255,6 +2260,14 @@ pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value>
"role".to_string(), "role".to_string(),
Value::String(state.role.unwrap_or_else(|| "assistant".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() { if state.tool_calls.is_empty() {
message.insert("content".to_string(), Value::String(state.content)); message.insert("content".to_string(), Value::String(state.content));
} else { } else {
@@ -4157,6 +4170,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] #[test]
fn aggregates_openai_chat_stream_tool_usage_and_finish_into_sync_body() { fn aggregates_openai_chat_stream_tool_usage_and_finish_into_sync_body() {
let body = concat!( let body = concat!(