mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
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:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user