diff --git a/crates/backends/xlai-backend-openai/src/lib.rs b/crates/backends/xlai-backend-openai/src/lib.rs index 3cbb664..1c947c2 100644 --- a/crates/backends/xlai-backend-openai/src/lib.rs +++ b/crates/backends/xlai-backend-openai/src/lib.rs @@ -362,6 +362,24 @@ impl ChatModel for OpenAiChatModel { }); } } + Some("response.reasoning_summary_text.delta") => { + let output_index = event_value + .get("output_index") + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + let summary_index = event_value + .get("summary_index") + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + if let Some(delta) = event_value.get("delta").and_then(Value::as_str) { + let chunk = state.apply_reasoning_summary_delta( + output_index, + summary_index, + delta.to_owned(), + ); + yield ChatChunk::ReasoningSummaryDelta(chunk); + } + } Some("response.output_item.added") => { if let Some(item) = event_value.get("item").and_then(Value::as_object) { diff --git a/crates/backends/xlai-backend-openai/src/request.rs b/crates/backends/xlai-backend-openai/src/request.rs index 7afeb0a..d68ec56 100644 --- a/crates/backends/xlai-backend-openai/src/request.rs +++ b/crates/backends/xlai-backend-openai/src/request.rs @@ -3,7 +3,7 @@ use serde::Serialize; use serde_json::Value; use xlai_core::{ ChatMessage, ChatRequest, ContentPart, ErrorKind, ImageDetail, MediaSource, MessageRole, - StructuredOutputFormat, ToolCall, ToolDefinition, XlaiError, + ReasoningEffort, ReasoningSummary, StructuredOutputFormat, ToolCall, ToolDefinition, XlaiError, }; use crate::OpenAiConfig; @@ -83,11 +83,7 @@ impl OpenAiChatRequest { .collect(), temperature: request.temperature, max_output_tokens: request.max_output_tokens, - reasoning: request - .reasoning_effort - .map(|effort| OpenAiReasoningConfig { - effort: reasoning_effort_openai(effort), - }), + reasoning: openai_reasoning_config(request.reasoning_effort, request.reasoning_summary), tool_choice: tools.as_ref().map(|_| "auto"), tools, text, @@ -241,7 +237,10 @@ const fn image_detail_openai(detail: ImageDetail) -> &'static str { #[derive(Serialize)] struct OpenAiReasoningConfig { - effort: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + effort: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option<&'static str>, } #[derive(Serialize)] @@ -293,10 +292,38 @@ fn tool_json_schema(tool: &ToolDefinition) -> Value { tool.resolved_input_schema().json_schema() } -const fn reasoning_effort_openai(effort: xlai_core::ReasoningEffort) -> &'static str { +const fn openai_reasoning_config( + effort: Option, + summary: Option, +) -> Option { + if effort.is_none() && summary.is_none() { + return None; + } + + Some(OpenAiReasoningConfig { + effort: match effort { + Some(effort) => Some(reasoning_effort_openai(effort)), + None => None, + }, + summary: match summary { + Some(summary) => Some(reasoning_summary_openai(summary)), + None => None, + }, + }) +} + +const fn reasoning_effort_openai(effort: ReasoningEffort) -> &'static str { match effort { - xlai_core::ReasoningEffort::Low => "low", - xlai_core::ReasoningEffort::Medium => "medium", - xlai_core::ReasoningEffort::High => "high", + ReasoningEffort::Low => "low", + ReasoningEffort::Medium => "medium", + ReasoningEffort::High => "high", + } +} + +const fn reasoning_summary_openai(summary: ReasoningSummary) -> &'static str { + match summary { + ReasoningSummary::Auto => "auto", + ReasoningSummary::Concise => "concise", + ReasoningSummary::Detailed => "detailed", } } diff --git a/crates/backends/xlai-backend-openai/src/response.rs b/crates/backends/xlai-backend-openai/src/response.rs index 5126438..40b7802 100644 --- a/crates/backends/xlai-backend-openai/src/response.rs +++ b/crates/backends/xlai-backend-openai/src/response.rs @@ -4,7 +4,8 @@ use serde::Deserialize; use serde_json::Value; use xlai_core::{ ChatContent, ChatMessage, ChatResponse, ContentPart, ErrorKind, FinishReason, MediaSource, - MessageRole, TokenUsage, TokenUsageSource, ToolCall, XlaiError, + MessageRole, TokenUsage, TokenUsageSource, ToolCall, XLAI_REASONING_SUMMARY_METADATA_KEY, + XlaiError, }; pub(crate) const OPENAI_RESPONSE_OUTPUT_METADATA_KEY: &str = "openai_response_output"; @@ -24,16 +25,20 @@ pub(crate) struct OpenAiChatResponse { impl OpenAiChatResponse { pub(crate) fn into_core_response(self) -> Result { let (content, tool_calls) = openai_response_output_to_chat(&self.output)?; - let message = attach_response_output_items( - ChatMessage { - role: MessageRole::Assistant, - content, - tool_name: None, - tool_call_id: None, - metadata: BTreeMap::new(), - } - .with_assistant_tool_calls(&tool_calls), - &self.output, + let reasoning_summary = reasoning_summary_from_response_output(&self.output); + let message = attach_reasoning_summary( + attach_response_output_items( + ChatMessage { + role: MessageRole::Assistant, + content, + tool_name: None, + tool_call_id: None, + metadata: BTreeMap::new(), + } + .with_assistant_tool_calls(&tool_calls), + &self.output, + ), + &reasoning_summary, ); let has_tool_calls = !tool_calls.is_empty(); @@ -88,6 +93,19 @@ pub(crate) fn attach_response_output_items( message } +pub(crate) fn attach_reasoning_summary( + mut message: ChatMessage, + summary: &[String], +) -> ChatMessage { + if !summary.is_empty() { + message.metadata.insert( + XLAI_REASONING_SUMMARY_METADATA_KEY.to_owned(), + Value::Array(summary.iter().cloned().map(Value::String).collect()), + ); + } + message +} + pub(crate) fn response_output_items_from_message(message: &ChatMessage) -> Option> { message .metadata @@ -138,6 +156,32 @@ pub(crate) fn openai_response_output_to_chat( Ok((content, tool_calls)) } +pub(crate) fn reasoning_summary_from_response_output(output: &[Value]) -> Vec { + let mut summaries = Vec::new(); + for item in output { + let Some(obj) = item.as_object() else { + continue; + }; + if obj.get("type").and_then(Value::as_str) != Some("reasoning") { + continue; + } + let Some(summary_items) = obj.get("summary").and_then(Value::as_array) else { + continue; + }; + summaries.extend( + summary_items + .iter() + .filter_map(parse_reasoning_summary_part), + ); + } + summaries +} + +fn parse_reasoning_summary_part(value: &Value) -> Option { + let obj = value.as_object()?; + obj.get("text").and_then(Value::as_str).map(str::to_owned) +} + fn parse_openai_response_content_part(value: &Value) -> Option { let obj = value.as_object()?; match obj.get("type")?.as_str()? { @@ -247,6 +291,7 @@ pub(crate) fn finish_reason_from_api( #[allow(clippy::expect_used, clippy::panic)] mod tests { use serde_json::json; + use xlai_core::XLAI_REASONING_SUMMARY_METADATA_KEY; use super::OpenAiChatResponse; @@ -279,4 +324,45 @@ mod tests { assert_eq!(usage.cached_input_tokens, Some(12)); assert_eq!(usage.uncached_input_tokens, Some(8)); } + + #[test] + fn maps_reasoning_summary_to_message_metadata() { + let parsed = serde_json::from_value::(json!({ + "output": [ + { + "type": "reasoning", + "summary": [ + { "type": "summary_text", "text": "Checked constraints." } + ] + }, + { + "type": "message", + "content": [ + { "type": "output_text", "text": "Final answer." } + ] + } + ], + "status": "completed" + })); + let Ok(response) = parsed else { + panic!("deserialize response"); + }; + + let mapped = response.into_core_response(); + let Ok(response) = mapped else { + panic!("map response"); + }; + + assert_eq!( + response + .message + .metadata + .get(XLAI_REASONING_SUMMARY_METADATA_KEY), + Some(&json!(["Checked constraints."])) + ); + assert_eq!( + response.message.content.text_parts_concatenated(), + "Final answer." + ); + } } diff --git a/crates/backends/xlai-backend-openai/src/stream.rs b/crates/backends/xlai-backend-openai/src/stream.rs index 9c44059..8d7e171 100644 --- a/crates/backends/xlai-backend-openai/src/stream.rs +++ b/crates/backends/xlai-backend-openai/src/stream.rs @@ -2,13 +2,13 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; use xlai_core::{ - ChatContent, ChatMessage, ChatResponse, ErrorKind, FinishReason, MessageRole, ToolCall, - ToolCallChunk, XlaiError, + ChatContent, ChatMessage, ChatResponse, ErrorKind, FinishReason, MessageRole, + ReasoningSummaryDelta, ToolCall, ToolCallChunk, XlaiError, }; use crate::response::{ - OpenAiChatResponse, attach_response_output_items, finish_reason_from_api, - openai_response_output_to_chat, + OpenAiChatResponse, attach_reasoning_summary, attach_response_output_items, + finish_reason_from_api, openai_response_output_to_chat, reasoning_summary_from_response_output, }; pub(crate) struct StreamState { @@ -17,6 +17,7 @@ pub(crate) struct StreamState { pub(crate) finish_reason: FinishReason, output_items: Vec, started_message_indices: BTreeSet, + reasoning_summary: BTreeMap<(usize, usize), String>, } impl Default for StreamState { @@ -27,6 +28,7 @@ impl Default for StreamState { finish_reason: FinishReason::Completed, output_items: Vec::new(), started_message_indices: BTreeSet::new(), + reasoning_summary: BTreeMap::new(), } } } @@ -80,6 +82,23 @@ impl StreamState { self.output_items.push(item); } + pub(crate) fn apply_reasoning_summary_delta( + &mut self, + output_index: usize, + summary_index: usize, + delta: String, + ) -> ReasoningSummaryDelta { + self.reasoning_summary + .entry((output_index, summary_index)) + .or_default() + .push_str(&delta); + ReasoningSummaryDelta { + output_index, + summary_index, + delta, + } + } + pub(crate) fn mark_message_started(&mut self, message_index: usize) -> bool { self.started_message_indices.insert(message_index) } @@ -89,6 +108,14 @@ impl StreamState { } pub(crate) fn into_chat_response(self) -> Result { + let reasoning_summary = if self.output_items.is_empty() { + self.reasoning_summary + .into_values() + .filter(|summary| !summary.is_empty()) + .collect::>() + } else { + reasoning_summary_from_response_output(&self.output_items) + }; let (content, tool_calls) = if self.output_items.is_empty() { let tool_calls = self .tool_calls @@ -102,16 +129,19 @@ impl StreamState { }; Ok(ChatResponse { - message: attach_response_output_items( - ChatMessage { - role: MessageRole::Assistant, - content, - tool_name: None, - tool_call_id: None, - metadata: BTreeMap::new(), - } - .with_assistant_tool_calls(&tool_calls), - &self.output_items, + message: attach_reasoning_summary( + attach_response_output_items( + ChatMessage { + role: MessageRole::Assistant, + content, + tool_name: None, + tool_call_id: None, + metadata: BTreeMap::new(), + } + .with_assistant_tool_calls(&tool_calls), + &self.output_items, + ), + &reasoning_summary, ), tool_calls, usage: None, @@ -228,6 +258,8 @@ pub(crate) fn maybe_completed_response(event: &Value) -> Result { + let output_index = event_value + .get("output_index") + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + let summary_index = event_value + .get("summary_index") + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + if let Some(delta) = event_value.get("delta").and_then(Value::as_str) { + let chunk = state.apply_reasoning_summary_delta( + output_index, + summary_index, + delta.to_owned(), + ); + yield ChatChunk::ReasoningSummaryDelta(chunk); + } + } Some("response.output_item.added") => { if let Some(item) = event_value.get("item").and_then(Value::as_object) { diff --git a/crates/backends/xlai-backend-openrouter/src/request.rs b/crates/backends/xlai-backend-openrouter/src/request.rs index 53f8249..de225ca 100644 --- a/crates/backends/xlai-backend-openrouter/src/request.rs +++ b/crates/backends/xlai-backend-openrouter/src/request.rs @@ -3,7 +3,7 @@ use serde::Serialize; use serde_json::Value; use xlai_core::{ ChatMessage, ChatRequest, ContentPart, ErrorKind, ImageDetail, MediaSource, MessageRole, - StructuredOutputFormat, ToolCall, ToolDefinition, XlaiError, + ReasoningEffort, ReasoningSummary, StructuredOutputFormat, ToolCall, ToolDefinition, XlaiError, }; use crate::OpenRouterConfig; @@ -83,11 +83,10 @@ impl OpenRouterChatRequest { .collect(), temperature: request.temperature, max_output_tokens: request.max_output_tokens, - reasoning: request - .reasoning_effort - .map(|effort| OpenRouterReasoningConfig { - effort: reasoning_effort_openrouter(effort), - }), + reasoning: openrouter_reasoning_config( + request.reasoning_effort, + request.reasoning_summary, + ), tool_choice: tools.as_ref().map(|_| "auto"), tools, text, @@ -245,7 +244,10 @@ const fn image_detail_openrouter(detail: ImageDetail) -> &'static str { #[derive(Serialize)] struct OpenRouterReasoningConfig { - effort: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + effort: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option<&'static str>, } #[derive(Serialize)] @@ -294,10 +296,38 @@ fn tool_json_schema(tool: &ToolDefinition) -> Value { tool.resolved_input_schema().json_schema() } -const fn reasoning_effort_openrouter(effort: xlai_core::ReasoningEffort) -> &'static str { +const fn openrouter_reasoning_config( + effort: Option, + summary: Option, +) -> Option { + if effort.is_none() && summary.is_none() { + return None; + } + + Some(OpenRouterReasoningConfig { + effort: match effort { + Some(effort) => Some(reasoning_effort_openrouter(effort)), + None => None, + }, + summary: match summary { + Some(summary) => Some(reasoning_summary_openrouter(summary)), + None => None, + }, + }) +} + +const fn reasoning_effort_openrouter(effort: ReasoningEffort) -> &'static str { match effort { - xlai_core::ReasoningEffort::Low => "low", - xlai_core::ReasoningEffort::Medium => "medium", - xlai_core::ReasoningEffort::High => "high", + ReasoningEffort::Low => "low", + ReasoningEffort::Medium => "medium", + ReasoningEffort::High => "high", + } +} + +const fn reasoning_summary_openrouter(summary: ReasoningSummary) -> &'static str { + match summary { + ReasoningSummary::Auto => "auto", + ReasoningSummary::Concise => "concise", + ReasoningSummary::Detailed => "detailed", } } diff --git a/crates/backends/xlai-backend-openrouter/src/response.rs b/crates/backends/xlai-backend-openrouter/src/response.rs index 38a376b..dc6e16b 100644 --- a/crates/backends/xlai-backend-openrouter/src/response.rs +++ b/crates/backends/xlai-backend-openrouter/src/response.rs @@ -4,7 +4,8 @@ use serde::Deserialize; use serde_json::Value; use xlai_core::{ ChatContent, ChatMessage, ChatResponse, ContentPart, ErrorKind, FinishReason, MediaSource, - MessageRole, TokenUsage, TokenUsageSource, ToolCall, XlaiError, + MessageRole, TokenUsage, TokenUsageSource, ToolCall, XLAI_REASONING_SUMMARY_METADATA_KEY, + XlaiError, }; pub(crate) const OPENROUTER_RESPONSE_OUTPUT_METADATA_KEY: &str = "openrouter_response_output"; @@ -24,16 +25,20 @@ pub(crate) struct OpenRouterChatResponse { impl OpenRouterChatResponse { pub(crate) fn into_core_response(self) -> Result { let (content, tool_calls) = openrouter_response_output_to_chat(&self.output)?; - let message = attach_response_output_items( - ChatMessage { - role: MessageRole::Assistant, - content, - tool_name: None, - tool_call_id: None, - metadata: BTreeMap::new(), - } - .with_assistant_tool_calls(&tool_calls), - &self.output, + let reasoning_summary = reasoning_summary_from_response_output(&self.output); + let message = attach_reasoning_summary( + attach_response_output_items( + ChatMessage { + role: MessageRole::Assistant, + content, + tool_name: None, + tool_call_id: None, + metadata: BTreeMap::new(), + } + .with_assistant_tool_calls(&tool_calls), + &self.output, + ), + &reasoning_summary, ); let has_tool_calls = !tool_calls.is_empty(); @@ -88,6 +93,19 @@ pub(crate) fn attach_response_output_items( message } +pub(crate) fn attach_reasoning_summary( + mut message: ChatMessage, + summary: &[String], +) -> ChatMessage { + if !summary.is_empty() { + message.metadata.insert( + XLAI_REASONING_SUMMARY_METADATA_KEY.to_owned(), + Value::Array(summary.iter().cloned().map(Value::String).collect()), + ); + } + message +} + pub(crate) fn response_output_items_from_message(message: &ChatMessage) -> Option> { message .metadata @@ -138,6 +156,32 @@ pub(crate) fn openrouter_response_output_to_chat( Ok((content, tool_calls)) } +pub(crate) fn reasoning_summary_from_response_output(output: &[Value]) -> Vec { + let mut summaries = Vec::new(); + for item in output { + let Some(obj) = item.as_object() else { + continue; + }; + if obj.get("type").and_then(Value::as_str) != Some("reasoning") { + continue; + } + let Some(summary_items) = obj.get("summary").and_then(Value::as_array) else { + continue; + }; + summaries.extend( + summary_items + .iter() + .filter_map(parse_reasoning_summary_part), + ); + } + summaries +} + +fn parse_reasoning_summary_part(value: &Value) -> Option { + let obj = value.as_object()?; + obj.get("text").and_then(Value::as_str).map(str::to_owned) +} + fn parse_openrouter_response_content_part(value: &Value) -> Option { let obj = value.as_object()?; match obj.get("type")?.as_str()? { @@ -247,6 +291,7 @@ pub(crate) fn finish_reason_from_api( #[allow(clippy::expect_used, clippy::panic)] mod tests { use serde_json::json; + use xlai_core::XLAI_REASONING_SUMMARY_METADATA_KEY; use super::OpenRouterChatResponse; @@ -279,4 +324,45 @@ mod tests { assert_eq!(usage.cached_input_tokens, Some(12)); assert_eq!(usage.uncached_input_tokens, Some(8)); } + + #[test] + fn maps_reasoning_summary_to_message_metadata() { + let parsed = serde_json::from_value::(json!({ + "output": [ + { + "type": "reasoning", + "summary": [ + { "type": "summary_text", "text": "Checked constraints." } + ] + }, + { + "type": "message", + "content": [ + { "type": "output_text", "text": "Final answer." } + ] + } + ], + "status": "completed" + })); + let Ok(response) = parsed else { + panic!("deserialize response"); + }; + + let mapped = response.into_core_response(); + let Ok(response) = mapped else { + panic!("map response"); + }; + + assert_eq!( + response + .message + .metadata + .get(XLAI_REASONING_SUMMARY_METADATA_KEY), + Some(&json!(["Checked constraints."])) + ); + assert_eq!( + response.message.content.text_parts_concatenated(), + "Final answer." + ); + } } diff --git a/crates/backends/xlai-backend-openrouter/src/stream.rs b/crates/backends/xlai-backend-openrouter/src/stream.rs index c78c831..931e0d8 100644 --- a/crates/backends/xlai-backend-openrouter/src/stream.rs +++ b/crates/backends/xlai-backend-openrouter/src/stream.rs @@ -2,13 +2,14 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; use xlai_core::{ - ChatContent, ChatMessage, ChatResponse, ErrorKind, FinishReason, MessageRole, ToolCall, - ToolCallChunk, XlaiError, + ChatContent, ChatMessage, ChatResponse, ErrorKind, FinishReason, MessageRole, + ReasoningSummaryDelta, ToolCall, ToolCallChunk, XlaiError, }; use crate::response::{ - OpenRouterChatResponse, attach_response_output_items, finish_reason_from_api, - openrouter_response_output_to_chat, + OpenRouterChatResponse, attach_reasoning_summary, attach_response_output_items, + finish_reason_from_api, openrouter_response_output_to_chat, + reasoning_summary_from_response_output, }; pub(crate) struct StreamState { @@ -17,6 +18,7 @@ pub(crate) struct StreamState { pub(crate) finish_reason: FinishReason, output_items: Vec, started_message_indices: BTreeSet, + reasoning_summary: BTreeMap<(usize, usize), String>, } impl Default for StreamState { @@ -27,6 +29,7 @@ impl Default for StreamState { finish_reason: FinishReason::Completed, output_items: Vec::new(), started_message_indices: BTreeSet::new(), + reasoning_summary: BTreeMap::new(), } } } @@ -80,6 +83,23 @@ impl StreamState { self.output_items.push(item); } + pub(crate) fn apply_reasoning_summary_delta( + &mut self, + output_index: usize, + summary_index: usize, + delta: String, + ) -> ReasoningSummaryDelta { + self.reasoning_summary + .entry((output_index, summary_index)) + .or_default() + .push_str(&delta); + ReasoningSummaryDelta { + output_index, + summary_index, + delta, + } + } + pub(crate) fn mark_message_started(&mut self, message_index: usize) -> bool { self.started_message_indices.insert(message_index) } @@ -89,6 +109,14 @@ impl StreamState { } pub(crate) fn into_chat_response(self) -> Result { + let reasoning_summary = if self.output_items.is_empty() { + self.reasoning_summary + .into_values() + .filter(|summary| !summary.is_empty()) + .collect::>() + } else { + reasoning_summary_from_response_output(&self.output_items) + }; let (content, tool_calls) = if self.output_items.is_empty() { let tool_calls = self .tool_calls @@ -102,16 +130,19 @@ impl StreamState { }; Ok(ChatResponse { - message: attach_response_output_items( - ChatMessage { - role: MessageRole::Assistant, - content, - tool_name: None, - tool_call_id: None, - metadata: BTreeMap::new(), - } - .with_assistant_tool_calls(&tool_calls), - &self.output_items, + message: attach_reasoning_summary( + attach_response_output_items( + ChatMessage { + role: MessageRole::Assistant, + content, + tool_name: None, + tool_call_id: None, + metadata: BTreeMap::new(), + } + .with_assistant_tool_calls(&tool_calls), + &self.output_items, + ), + &reasoning_summary, ), tool_calls, usage: None, @@ -225,3 +256,41 @@ pub(crate) fn maybe_completed_response(event: &Value) -> Result, #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_policy: Option, /// Advisory execution hints merged from runtime/session/request layers. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -569,6 +581,13 @@ pub struct ToolCallChunk { pub arguments_delta: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReasoningSummaryDelta { + pub output_index: usize, + pub summary_index: usize, + pub delta: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum ChatChunk { MessageStart { @@ -577,6 +596,8 @@ pub enum ChatChunk { }, /// Incremental text for multimodal part at `part_index` (usually `0` for plain assistant streams). ContentDelta(StreamTextDelta), + /// Incremental summarized reasoning text from Responses API reasoning output items. + ReasoningSummaryDelta(ReasoningSummaryDelta), ToolCallDelta(ToolCallChunk), Finished(ChatResponse), } diff --git a/crates/core/xlai-core/src/lib_tests.rs b/crates/core/xlai-core/src/lib_tests.rs index 33ebdd6..a323c02 100644 --- a/crates/core/xlai-core/src/lib_tests.rs +++ b/crates/core/xlai-core/src/lib_tests.rs @@ -5,9 +5,9 @@ use crate::{ ChatContent, ChatMessage, ChatRequest, ChatRetryPolicy, ContentPart, EmbeddingRequest, ErrorKind, GeneratedImage, ImageGenerationBackground, ImageGenerationOutputFormat, ImageGenerationQuality, ImageGenerationRequest, ImageGenerationResponse, MediaSource, - MessageRole, ReasoningEffort, StructuredOutput, StructuredOutputFormat, ToolCall, - ToolCallExecutionMode, ToolDefinition, ToolParameter, ToolParameterType, ToolSchema, TtsChunk, - TtsResponse, XlaiError, + MessageRole, ReasoningEffort, ReasoningSummary, StructuredOutput, StructuredOutputFormat, + ToolCall, ToolCallExecutionMode, ToolDefinition, ToolParameter, ToolParameterType, ToolSchema, + TtsChunk, TtsResponse, XlaiError, }; #[test] @@ -338,6 +338,23 @@ fn reasoning_effort_round_trips_snake_case_json() { assert_eq!(back, ReasoningEffort::Medium); } +#[test] +fn reasoning_summary_round_trips_snake_case_json() { + let serialized = serde_json::to_value(ReasoningSummary::Concise); + assert!(serialized.is_ok(), "serialize"); + let Ok(v) = serialized else { + return; + }; + assert_eq!(v, json!("concise")); + + let deserialized: Result = serde_json::from_value(v); + assert!(deserialized.is_ok(), "deserialize"); + let Ok(back) = deserialized else { + return; + }; + assert_eq!(back, ReasoningSummary::Concise); +} + #[test] fn tool_schema_round_trips_nested_json() { let schema = ToolSchema::object( diff --git a/crates/platform/xlai-wasm/src/agent_session.rs b/crates/platform/xlai-wasm/src/agent_session.rs index 218b415..ce384df 100644 --- a/crates/platform/xlai-wasm/src/agent_session.rs +++ b/crates/platform/xlai-wasm/src/agent_session.rs @@ -109,8 +109,7 @@ impl WasmAgentSession { } /// Runs the agent streaming loop and returns all execution events as a JSON array (`kind` + - /// `data` per item). Intermediate tool-loop assistant rounds are surfaced as `thinking` - /// events instead of normal `model` chunks. + /// `data` per item). #[wasm_bindgen(js_name = streamPrompt)] pub async fn stream_prompt(&self, content: String) -> Result { let mut stream = self.inner.stream_prompt(content); diff --git a/crates/platform/xlai-wasm/src/api.rs b/crates/platform/xlai-wasm/src/api.rs index 33d1f87..7e55469 100644 --- a/crates/platform/xlai-wasm/src/api.rs +++ b/crates/platform/xlai-wasm/src/api.rs @@ -55,6 +55,7 @@ pub async fn chat(options: JsValue) -> Result { temperature, max_output_tokens, reasoning_effort, + reasoning_summary, retry_policy, chat_execution, runtime_chat_execution_defaults, @@ -70,6 +71,7 @@ pub async fn chat(options: JsValue) -> Result { temperature, max_output_tokens, reasoning_effort, + reasoning_summary, retry_policy, chat_execution, runtime_chat_execution_defaults, @@ -95,6 +97,7 @@ pub async fn agent(options: JsValue) -> Result { temperature, max_output_tokens, reasoning_effort, + reasoning_summary, retry_policy, chat_execution, runtime_chat_execution_defaults, @@ -110,6 +113,7 @@ pub async fn agent(options: JsValue) -> Result { temperature, max_output_tokens, reasoning_effort, + reasoning_summary, retry_policy, chat_execution, runtime_chat_execution_defaults, diff --git a/crates/platform/xlai-wasm/src/factory/js_reflect.rs b/crates/platform/xlai-wasm/src/factory/js_reflect.rs index 74ae921..68354fe 100644 --- a/crates/platform/xlai-wasm/src/factory/js_reflect.rs +++ b/crates/platform/xlai-wasm/src/factory/js_reflect.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use js_sys::Reflect; use serde::de::DeserializeOwned; use wasm_bindgen::JsValue; -use xlai_core::ReasoningEffort; +use xlai_core::{ReasoningEffort, ReasoningSummary}; use xlai_runtime::FileSystem; use crate::js_file_system::JsFileSystem; @@ -65,6 +65,15 @@ pub(crate) fn parse_transformers_session_options( Some(serde_wasm_bindgen::from_value::(v).map_err(js_error)?) } }; + let reasoning_summary = { + let v = Reflect::get(&options, &JsValue::from_str("reasoningSummary")) + .map_err(|e| js_error(format!("failed to read reasoningSummary: {e:?}")))?; + if v.is_null() || v.is_undefined() { + None + } else { + Some(serde_wasm_bindgen::from_value::(v).map_err(js_error)?) + } + }; let chat_execution = optional_js_value_deserialize::(&options, "chatExecution")?; @@ -85,6 +94,7 @@ pub(crate) fn parse_transformers_session_options( temperature: optional_js_f32_field(&options, "temperature")?, max_output_tokens: optional_js_u32_field(&options, "maxOutputTokens")?, reasoning_effort, + reasoning_summary, retry_policy, chat_execution, runtime_chat_execution_defaults, diff --git a/crates/platform/xlai-wasm/src/factory/openai.rs b/crates/platform/xlai-wasm/src/factory/openai.rs index 57e450a..9a527b1 100644 --- a/crates/platform/xlai-wasm/src/factory/openai.rs +++ b/crates/platform/xlai-wasm/src/factory/openai.rs @@ -97,6 +97,9 @@ pub(crate) fn create_chat_session_with_dyn_file_system( if let Some(reasoning_effort) = options.reasoning_effort { chat = chat.with_reasoning_effort(reasoning_effort); } + if let Some(reasoning_summary) = options.reasoning_summary { + chat = chat.with_reasoning_summary(reasoning_summary); + } if let Some(ref rp) = options.retry_policy { chat = chat.with_retry_policy(Some(rp.clone().into())); @@ -160,6 +163,9 @@ pub(crate) fn create_agent_session_with_dyn_file_system( if let Some(reasoning_effort) = options.reasoning_effort { agent = agent.with_reasoning_effort(reasoning_effort); } + if let Some(reasoning_summary) = options.reasoning_summary { + agent = agent.with_reasoning_summary(reasoning_summary); + } if let Some(ref rp) = options.retry_policy { agent = agent.with_retry_policy(Some(rp.clone().into())); diff --git a/crates/platform/xlai-wasm/src/factory/transformers.rs b/crates/platform/xlai-wasm/src/factory/transformers.rs index 80c935d..d6a3d96 100644 --- a/crates/platform/xlai-wasm/src/factory/transformers.rs +++ b/crates/platform/xlai-wasm/src/factory/transformers.rs @@ -26,6 +26,7 @@ pub(crate) fn create_transformers_chat_session_with_dyn_file_system( temperature, max_output_tokens, reasoning_effort: _, + reasoning_summary: _, retry_policy, chat_execution, runtime_chat_execution_defaults, @@ -99,6 +100,7 @@ pub(crate) fn create_transformers_agent_session_with_dyn_file_system( temperature, max_output_tokens, reasoning_effort: _, + reasoning_summary: _, retry_policy, chat_execution, runtime_chat_execution_defaults, diff --git a/crates/platform/xlai-wasm/src/lib.rs b/crates/platform/xlai-wasm/src/lib.rs index 7f75b9d..65623e4 100644 --- a/crates/platform/xlai-wasm/src/lib.rs +++ b/crates/platform/xlai-wasm/src/lib.rs @@ -78,7 +78,7 @@ mod tests { use serde_json::json; use xlai_core::{ ChatContent, ChatMessage, FinishReason, FsEntry, FsEntryKind, FsPath, MessageRole, - ReasoningEffort, TokenUsage, + ReasoningEffort, ReasoningSummary, TokenUsage, }; use crate::factory::create_agent_session_inner; @@ -181,6 +181,7 @@ mod tests { temperature: Some(0.2), max_output_tokens: Some(512), reasoning_effort: Some(ReasoningEffort::Medium), + reasoning_summary: Some(ReasoningSummary::Auto), content: None, retry_policy: None, chat_execution: None, @@ -196,6 +197,7 @@ mod tests { assert_eq!(options.temperature, Some(0.2)); assert_eq!(options.max_output_tokens, Some(512)); assert_eq!(options.reasoning_effort, Some(ReasoningEffort::Medium)); + assert_eq!(options.reasoning_summary, Some(ReasoningSummary::Auto)); } #[test] @@ -209,6 +211,7 @@ mod tests { temperature: Some(0.1), max_output_tokens: Some(256), reasoning_effort: Some(ReasoningEffort::Low), + reasoning_summary: None, retry_policy: None, chat_execution: None, runtime_chat_execution_defaults: None, @@ -235,6 +238,7 @@ mod tests { temperature: None, max_output_tokens: None, reasoning_effort: None, + reasoning_summary: None, retry_policy: None, chat_execution: None, runtime_chat_execution_defaults: None, diff --git a/crates/platform/xlai-wasm/src/types.rs b/crates/platform/xlai-wasm/src/types.rs index aa08b11..cfb723c 100644 --- a/crates/platform/xlai-wasm/src/types.rs +++ b/crates/platform/xlai-wasm/src/types.rs @@ -5,8 +5,8 @@ use serde_json::Value as JsonValue; use xlai_core::{ ChatContent, ChatExecutionOverrides, ChatResponse, ChatRetryPolicy, ExecutionLatencyMode, FinishReason, FsEntry, FsEntryKind, ImageGenerationBackground, ImageGenerationOutputFormat, - ImageGenerationQuality, MessageRole, ReasoningEffort, TokenUsage, TtsAudioFormat, - TtsDeliveryMode, TtsExecutionOverrides, VoiceSpec, + ImageGenerationQuality, MessageRole, ReasoningEffort, ReasoningSummary, TokenUsage, + TtsAudioFormat, TtsDeliveryMode, TtsExecutionOverrides, VoiceSpec, }; pub(crate) const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; @@ -121,6 +121,8 @@ pub(crate) struct WasmChatRequest { pub(crate) max_output_tokens: Option, #[serde(default)] pub(crate) reasoning_effort: Option, + #[serde(default)] + pub(crate) reasoning_summary: Option, /// When set, used as the user message body instead of plain-text [`Self::prompt`]. #[serde(default)] pub(crate) content: Option, @@ -154,6 +156,8 @@ pub(crate) struct WasmAgentRequest { #[serde(default)] pub(crate) reasoning_effort: Option, #[serde(default)] + pub(crate) reasoning_summary: Option, + #[serde(default)] pub(crate) content: Option, #[serde(default)] pub(crate) retry_policy: Option, @@ -193,6 +197,8 @@ pub(crate) struct WasmChatSessionOptions { #[serde(default)] pub(crate) reasoning_effort: Option, #[serde(default)] + pub(crate) reasoning_summary: Option, + #[serde(default)] pub(crate) retry_policy: Option, #[serde(default)] pub(crate) chat_execution: Option, @@ -297,6 +303,7 @@ impl From for WasmChatSessionOptions { temperature: value.temperature, max_output_tokens: value.max_output_tokens, reasoning_effort: value.reasoning_effort, + reasoning_summary: value.reasoning_summary, retry_policy: value.retry_policy, chat_execution: value.chat_execution, runtime_chat_execution_defaults: value.runtime_chat_execution_defaults, @@ -318,6 +325,7 @@ impl From for WasmChatSessionOptions { temperature: value.temperature, max_output_tokens: value.max_output_tokens, reasoning_effort: value.reasoning_effort, + reasoning_summary: value.reasoning_summary, retry_policy: value.retry_policy, chat_execution: value.chat_execution, runtime_chat_execution_defaults: value.runtime_chat_execution_defaults, @@ -342,6 +350,9 @@ pub(crate) struct WasmTransformersSessionOptions { /// Uniform options surface with the OpenAI path; ignored by the transformers.js backend. #[allow(dead_code)] pub(crate) reasoning_effort: Option, + /// Uniform options surface with the OpenAI path; ignored by the transformers.js backend. + #[allow(dead_code)] + pub(crate) reasoning_summary: Option, pub(crate) retry_policy: Option, pub(crate) chat_execution: Option, pub(crate) runtime_chat_execution_defaults: Option, diff --git a/crates/runtime/xlai-runtime/src/agent.rs b/crates/runtime/xlai-runtime/src/agent.rs index ed282af..b87d5bd 100644 --- a/crates/runtime/xlai-runtime/src/agent.rs +++ b/crates/runtime/xlai-runtime/src/agent.rs @@ -13,8 +13,8 @@ use tera::Context; use xlai_core::{ BoxFuture, BoxStream, CancellationSignal, ChatChunk, ChatContent, ChatExecutionConfig, ChatExecutionOverrides, ChatMessage, ChatResponse, ContentPart, ErrorKind, MaybeSend, - MessageRole, ReasoningEffort, RuntimeBound, StructuredOutput, ToolDefinition, ToolResult, - XlaiError, + MessageRole, ReasoningEffort, ReasoningSummary, RuntimeBound, StructuredOutput, ToolDefinition, + ToolResult, XlaiError, }; use crate::chat::Chat; @@ -165,6 +165,12 @@ impl Agent { self } + #[must_use] + pub fn with_reasoning_summary(mut self, reasoning_summary: ReasoningSummary) -> Self { + self.chat = self.chat.with_reasoning_summary(reasoning_summary); + self + } + #[must_use] pub fn with_structured_output(mut self, structured_output: StructuredOutput) -> Self { self.chat = self.chat.with_structured_output(structured_output); @@ -499,10 +505,6 @@ impl Agent { return; } - if response.message.role == MessageRole::Assistant { - yield ChatExecutionEvent::Thinking(response.clone()); - } - for call in &response.tool_calls { yield ChatExecutionEvent::ToolCall(call.clone()); } diff --git a/crates/runtime/xlai-runtime/src/chat.rs b/crates/runtime/xlai-runtime/src/chat.rs index f76532f..e25e4be 100644 --- a/crates/runtime/xlai-runtime/src/chat.rs +++ b/crates/runtime/xlai-runtime/src/chat.rs @@ -11,8 +11,8 @@ use tera::Context; use xlai_core::{ BoxFuture, BoxStream, CancellationSignal, ChatChunk, ChatContent, ChatExecutionConfig, ChatExecutionOverrides, ChatMessage, ChatRequest, ChatResponse, ChatRetryPolicy, ContentPart, - ErrorKind, MaybeSend, MessageRole, ReasoningEffort, RuntimeBound, StructuredOutput, ToolCall, - ToolCallExecutionMode, ToolDefinition, ToolResult, XlaiError, + ErrorKind, MaybeSend, MessageRole, ReasoningEffort, ReasoningSummary, RuntimeBound, + StructuredOutput, ToolCall, ToolCallExecutionMode, ToolDefinition, ToolResult, XlaiError, }; use crate::{ChatExecutionHandle, EmbeddedPromptStore, XlaiRuntime}; @@ -39,16 +39,12 @@ struct RegisteredTool { /// One item from a chat/agent stream. /// -/// Agent streams may classify intermediate assistant rounds as [`Self::Thinking`] so consumers can -/// render them differently from the final assistant reply. -/// /// Serialized for WASM/JS as -/// `{"kind":"model"|"thinking"|"toolCall"|"toolResult","data":...}` (camelCase). +/// `{"kind":"model"|"toolCall"|"toolResult","data":...}` (camelCase). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", content = "data", rename_all = "camelCase")] pub enum ChatExecutionEvent { Model(ChatChunk), - Thinking(ChatResponse), ToolCall(ToolCall), ToolResult(ToolResult), } @@ -67,6 +63,7 @@ pub struct Chat { temperature: Option, max_output_tokens: Option, reasoning_effort: Option, + reasoning_summary: Option, structured_output: Option, retry_policy: Option, /// Session-level advisory execution overrides (merged on top of [`XlaiRuntime::chat_execution_defaults`]). @@ -84,6 +81,7 @@ impl Chat { temperature: None, max_output_tokens: None, reasoning_effort: None, + reasoning_summary: None, structured_output: None, retry_policy: None, execution_overrides: ChatExecutionOverrides::default(), @@ -162,6 +160,12 @@ impl Chat { self } + #[must_use] + pub fn with_reasoning_summary(mut self, reasoning_summary: ReasoningSummary) -> Self { + self.reasoning_summary = Some(reasoning_summary); + self + } + #[must_use] pub fn with_structured_output(mut self, structured_output: StructuredOutput) -> Self { self.structured_output = Some(structured_output); @@ -415,6 +419,7 @@ impl Chat { temperature: self.temperature, max_output_tokens: self.max_output_tokens, reasoning_effort: self.reasoning_effort, + reasoning_summary: self.reasoning_summary, retry_policy: self.retry_policy.clone(), execution, cancellation, diff --git a/crates/runtime/xlai-runtime/src/tests/chat.rs b/crates/runtime/xlai-runtime/src/tests/chat.rs index 6e29265..9746aa9 100644 --- a/crates/runtime/xlai-runtime/src/tests/chat.rs +++ b/crates/runtime/xlai-runtime/src/tests/chat.rs @@ -5,8 +5,8 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use serde_json::{Value, json}; use xlai_core::{ ChatContent, ChatMessage, ChatResponse, ChatRetryPolicy, ContentPart, ErrorKind, FinishReason, - MediaSource, MessageRole, ReasoningEffort, StructuredOutput, StructuredOutputFormat, ToolCall, - XlaiError, + MediaSource, MessageRole, ReasoningEffort, ReasoningSummary, StructuredOutput, + StructuredOutputFormat, ToolCall, XlaiError, }; use super::common::*; @@ -417,3 +417,31 @@ async fn chat_session_propagates_reasoning_effort() -> Result<(), XlaiError> { assert_eq!(requests[0].reasoning_effort, Some(ReasoningEffort::High)); Ok(()) } + +#[allow(clippy::panic_in_result_fn)] +#[tokio::test] +async fn chat_session_propagates_reasoning_summary() -> Result<(), XlaiError> { + let requests = Arc::new(Mutex::new(Vec::new())); + let model = Arc::new(RecordingChatModel::new( + requests.clone(), + vec![ChatResponse { + message: assistant_message("done."), + tool_calls: Vec::new(), + usage: None, + finish_reason: FinishReason::Completed, + metadata: empty_metadata(), + }], + )); + + let runtime = RuntimeBuilder::new().with_chat_model(model).build()?; + let chat = runtime + .chat_session() + .with_reasoning_summary(ReasoningSummary::Auto); + + let _response = chat.prompt("Hello").await?; + + let requests = lock_unpoisoned(&requests); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].reasoning_summary, Some(ReasoningSummary::Auto)); + Ok(()) +} diff --git a/crates/runtime/xlai-runtime/src/tests/streaming.rs b/crates/runtime/xlai-runtime/src/tests/streaming.rs index 7548e87..584ef9d 100644 --- a/crates/runtime/xlai-runtime/src/tests/streaming.rs +++ b/crates/runtime/xlai-runtime/src/tests/streaming.rs @@ -12,7 +12,7 @@ use crate::{ChatExecutionEvent, RuntimeBuilder}; #[allow(clippy::panic_in_result_fn)] #[tokio::test] -async fn agent_stream_emits_thinking_for_intermediate_rounds() -> Result<(), XlaiError> { +async fn agent_stream_emits_tool_events_for_intermediate_rounds() -> Result<(), XlaiError> { let model = Arc::new(StreamingChatModel::new(vec![ vec![ ChatChunk::MessageStart { @@ -91,16 +91,12 @@ async fn agent_stream_emits_thinking_for_intermediate_rounds() -> Result<(), Xla let mut stream = agent.stream_prompt("Stream the weather."); let mut content_deltas = Vec::new(); - let mut thinking_messages = Vec::new(); let mut saw_tool_call = false; let mut saw_tool_result = false; let mut finished_messages = Vec::new(); while let Some(event) = stream.next().await { match event? { - ChatExecutionEvent::Thinking(response) => { - thinking_messages.push(response.message.content.text_parts_concatenated()); - } ChatExecutionEvent::Model(ChatChunk::ContentDelta(StreamTextDelta { delta, .. })) => { @@ -118,12 +114,13 @@ async fn agent_stream_emits_thinking_for_intermediate_rounds() -> Result<(), Xla assert_eq!(result.content, "weather for Paris: sunny"); } ChatExecutionEvent::Model( - ChatChunk::MessageStart { .. } | ChatChunk::ToolCallDelta(_), + ChatChunk::MessageStart { .. } + | ChatChunk::ReasoningSummaryDelta(_) + | ChatChunk::ToolCallDelta(_), ) => {} } } - assert_eq!(thinking_messages, vec!["Looking up weather"]); assert_eq!( content_deltas, vec!["Looking up weather", "Paris is sunny."] @@ -186,7 +183,7 @@ async fn chat_stream_preserves_multiple_assistant_message_indices() -> Result<() .. })) => deltas.push((message_index, delta)), ChatExecutionEvent::Model(ChatChunk::Finished(_)) => {} - ChatExecutionEvent::Thinking(_) + ChatExecutionEvent::Model(ChatChunk::ReasoningSummaryDelta(_)) | ChatExecutionEvent::Model(ChatChunk::ToolCallDelta(_)) | ChatExecutionEvent::ToolCall(_) | ChatExecutionEvent::ToolResult(_) => {} diff --git a/packages/xlai/src/shared.ts b/packages/xlai/src/shared.ts index 0edfdca..26674bc 100644 --- a/packages/xlai/src/shared.ts +++ b/packages/xlai/src/shared.ts @@ -10,6 +10,7 @@ import type { ChatContent, ChatOptions, ReasoningEffort, + ReasoningSummary, ChatResponse, ChatRetryPolicy, ChatSessionOptions, @@ -32,6 +33,7 @@ export type ResolvedRequestOptions = { temperature?: number; maxOutputTokens?: number; reasoningEffort?: ReasoningEffort; + reasoningSummary?: ReasoningSummary; retryPolicy?: ChatRetryPolicy; }; @@ -150,6 +152,7 @@ export function resolveRequestOptions( temperature: options.temperature, maxOutputTokens: options.maxOutputTokens, reasoningEffort: options.reasoningEffort, + reasoningSummary: options.reasoningSummary, ...(options.retryPolicy !== undefined ? { retryPolicy: options.retryPolicy } : {}), @@ -174,6 +177,7 @@ export function resolveSessionOptions( temperature: options.temperature, maxOutputTokens: options.maxOutputTokens, reasoningEffort: options.reasoningEffort, + reasoningSummary: options.reasoningSummary, ...(options.retryPolicy !== undefined ? { retryPolicy: options.retryPolicy } : {}), diff --git a/packages/xlai/src/types.ts b/packages/xlai/src/types.ts index 9ad4b3b..f30f2ce 100644 --- a/packages/xlai/src/types.ts +++ b/packages/xlai/src/types.ts @@ -61,6 +61,7 @@ export interface ChatRetryPolicy { } export type ReasoningEffort = 'low' | 'medium' | 'high'; +export type ReasoningSummary = 'auto' | 'concise' | 'detailed'; export interface ChatOptions { prompt?: string; @@ -72,6 +73,7 @@ export interface ChatOptions { temperature?: number; maxOutputTokens?: number; reasoningEffort?: ReasoningEffort; + reasoningSummary?: ReasoningSummary; retryPolicy?: ChatRetryPolicy; } @@ -145,12 +147,9 @@ export interface ChatMessage { /** * One event from `AgentSession.streamPrompt` / `streamPromptWithContent` (WASM JSON). - * Intermediate tool-loop assistant rounds are surfaced as `thinking` so consumers can render - * them separately from the terminal assistant reply. */ export type ChatExecutionEvent = | { kind: 'model'; data: unknown } - | { kind: 'thinking'; data: ChatResponse } | { kind: 'toolCall'; data: unknown } | { kind: 'toolResult'; data: unknown }; @@ -270,6 +269,7 @@ export interface ChatSessionOptions { temperature?: number; maxOutputTokens?: number; reasoningEffort?: ReasoningEffort; + reasoningSummary?: ReasoningSummary; retryPolicy?: ChatRetryPolicy; fileSystem?: FileSystemApi; /** When set, the WASM runtime behind the session includes local QTS. */