diff --git a/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server b/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server new file mode 100755 index 000000000..f8b18e900 --- /dev/null +++ b/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server @@ -0,0 +1 @@ +test binary \ No newline at end of file diff --git a/crates/aether-ai/formats/src/formats/openai/chat/response.rs b/crates/aether-ai/formats/src/formats/openai/chat/response.rs index 69d503169..64a6589a2 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/response.rs @@ -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, +) -> Vec<(Option, 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::>(); + 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 { from_raw(body) } @@ -40,21 +87,18 @@ pub fn from_raw(body_json: &Value) -> Option { .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::>(); + 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 { + 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()); + } +} diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index a5a29524e..6f5655fd0 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -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, + last_reasoning_index: Option, tool_calls: BTreeMap, } @@ -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::>(); + // `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::>(); + 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(""), + _ => None, + }) + .collect::>(); + assert_eq!(reasoning, vec!["first", "", "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(); diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 4a20b159f..91285be78 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -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, content: String, + reasoning: String, finish_reason: Option, tool_calls: BTreeMap, } @@ -2195,6 +2197,9 @@ pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option 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 "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!(