diff --git a/crates/copilot_chat/src/responses.rs b/crates/copilot_chat/src/responses.rs index 1241a76fb14..8e32e272d88 100644 --- a/crates/copilot_chat/src/responses.rs +++ b/crates/copilot_chat/src/responses.rs @@ -139,12 +139,7 @@ pub enum ResponseInputItem { #[serde(skip_serializing_if = "Option::is_none")] status: Option, }, - Reasoning { - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - summary: Vec, - encrypted_content: String, - }, + Reasoning(ResponseReasoningInputItem), } #[derive(Deserialize, Debug, Clone)] @@ -162,7 +157,17 @@ pub struct IncompleteDetails { pub reason: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ResponseReasoningInputItem { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default)] + pub summary: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encrypted_content: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ResponseReasoningItem { #[serde(rename = "type")] pub kind: String, diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index cf8f11a5bb0..d9db887e12d 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -659,12 +659,14 @@ pub fn map_to_language_model_completion_events( pub struct CopilotResponsesEventMapper { pending_stop_reason: Option, + reasoning_items: Vec, } impl CopilotResponsesEventMapper { pub fn new() -> Self { Self { pending_stop_reason: None, + reasoning_items: Vec::new(), } } @@ -740,13 +742,13 @@ impl CopilotResponsesEventMapper { events } copilot_responses::ResponseOutputItem::Reasoning { + id, summary, encrypted_content, - .. } => { let mut events = Vec::new(); - if let Some(blocks) = summary { + if let Some(blocks) = summary.as_ref() { let mut text = String::new(); for block in blocks { text.push_str(&block.text); @@ -759,8 +761,10 @@ impl CopilotResponsesEventMapper { } } - if let Some(data) = encrypted_content { - events.push(Ok(LanguageModelCompletionEvent::RedactedThinking { data })); + if let Some(reasoning_item) = + reasoning_input_item_from_output(&id, encrypted_content) + { + events.extend(self.capture_reasoning_item(reasoning_item)); } events @@ -769,6 +773,7 @@ impl CopilotResponsesEventMapper { copilot_responses::StreamEvent::Completed { response } => { let mut events = Vec::new(); + events.extend(self.capture_reasoning_items_from_output(&response.output)); if let Some(usage) = response.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: usage.input_tokens.unwrap_or(0), @@ -800,6 +805,7 @@ impl CopilotResponsesEventMapper { }; let mut events = Vec::new(); + events.extend(self.capture_reasoning_items_from_output(&response.output)); if let Some(usage) = response.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: usage.input_tokens.unwrap_or(0), @@ -840,6 +846,116 @@ impl CopilotResponsesEventMapper { | copilot_responses::StreamEvent::Unknown => Vec::new(), } } + + fn capture_reasoning_items_from_output( + &mut self, + output: &[copilot_responses::ResponseOutputItem], + ) -> Vec> { + let mut events = Vec::new(); + for item in output { + if let copilot_responses::ResponseOutputItem::Reasoning { + id, + summary: _, + encrypted_content, + } = item + { + if let Some(reasoning_item) = + reasoning_input_item_from_output(&id, encrypted_content.clone()) + { + events.extend(self.capture_reasoning_item(reasoning_item)); + } + } + } + events + } + + fn capture_reasoning_item( + &mut self, + reasoning_item: copilot_responses::ResponseReasoningInputItem, + ) -> Vec> { + if self.reasoning_items.contains(&reasoning_item) { + return Vec::new(); + } + + if let Some(id) = reasoning_item.id.as_ref() + && let Some(existing_reasoning_item) = self + .reasoning_items + .iter_mut() + .find(|existing_reasoning_item| existing_reasoning_item.id.as_ref() == Some(id)) + { + *existing_reasoning_item = reasoning_item; + } else { + self.reasoning_items.push(reasoning_item); + } + + self.emit_response_message_metadata() + } + + fn emit_response_message_metadata( + &self, + ) -> Vec> { + let details = serde_json::to_value(CopilotResponseMessageMetadata { + reasoning_items: self.reasoning_items.clone(), + }); + + match details { + Ok(details) => vec![Ok(LanguageModelCompletionEvent::ReasoningDetails(details))], + Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))], + } + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct CopilotResponseMessageMetadata { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + reasoning_items: Vec, +} + +fn append_reasoning_details_to_response_items( + reasoning_details: Option<&serde_json::Value>, + replayed_reasoning_item_indexes: &mut HashMap, + input_items: &mut Vec, +) { + let Some(reasoning_details) = reasoning_details else { + return; + }; + + let Some(metadata) = + serde_json::from_value::(reasoning_details.clone()).ok() + else { + return; + }; + + for mut reasoning_item in metadata.reasoning_items { + reasoning_item.summary.clear(); + if let Some(id) = reasoning_item.id.as_ref() { + if let Some(index) = replayed_reasoning_item_indexes.get(id) { + input_items[*index] = + copilot_responses::ResponseInputItem::Reasoning(reasoning_item); + return; + } + + replayed_reasoning_item_indexes.insert(id.clone(), input_items.len()); + } + + input_items.push(copilot_responses::ResponseInputItem::Reasoning( + reasoning_item, + )); + } +} + +fn reasoning_input_item_from_output( + id: &str, + encrypted_content: Option, +) -> Option { + if encrypted_content.is_none() { + return None; + } + Some(copilot_responses::ResponseReasoningInputItem { + id: Some(id.to_string()), + summary: Vec::new(), + encrypted_content, + }) } fn into_copilot_chat( @@ -1100,6 +1216,7 @@ fn into_copilot_responses( } = request; let mut input_items: Vec = Vec::new(); + let mut replayed_reasoning_item_indexes = HashMap::default(); for message in messages { match message.role { @@ -1181,6 +1298,12 @@ fn into_copilot_responses( } Role::Assistant => { + append_reasoning_details_to_response_items( + message.reasoning_details.as_ref(), + &mut replayed_reasoning_item_indexes, + &mut input_items, + ); + for content in &message.content { if let MessageContent::ToolUse(tool_use) = content { input_items.push(responses::ResponseInputItem::FunctionCall { @@ -1193,16 +1316,6 @@ fn into_copilot_responses( } } - for content in &message.content { - if let MessageContent::RedactedThinking(data) = content { - input_items.push(responses::ResponseInputItem::Reasoning { - id: None, - summary: Vec::new(), - encrypted_content: data.clone(), - }); - } - } - let mut parts: Vec = Vec::new(); for content in &message.content { match content { @@ -1297,6 +1410,7 @@ mod tests { use super::*; use copilot_chat::responses; use futures::StreamExt; + use serde_json::json; fn map_events(events: Vec) -> Vec { futures::executor::block_on(async { @@ -1310,6 +1424,37 @@ mod tests { }) } + fn test_responses_model() -> CopilotChatModel { + serde_json::from_value(json!({ + "billing": { + "is_premium": false, + "multiplier": 1.0 + }, + "capabilities": { + "family": "test", + "limits": { + "max_context_window_tokens": 128000, + "max_output_tokens": 4096 + }, + "supports": { + "streaming": true, + "tool_calls": true, + "parallel_tool_calls": false, + "vision": false + }, + "type": "chat" + }, + "id": "test-model", + "is_chat_default": false, + "is_chat_fallback": false, + "model_picker_enabled": true, + "name": "Test Model", + "vendor": "OpenAI", + "supported_endpoints": ["/responses"] + })) + .expect("valid test model") + } + #[test] fn responses_stream_maps_text_and_usage() { let events = vec![ @@ -1435,10 +1580,80 @@ mod tests { mapped[0], LanguageModelCompletionEvent::Thinking { ref text, signature: None } if text == "Chain" )); - assert!(matches!( - mapped[1], - LanguageModelCompletionEvent::RedactedThinking { ref data } if data == "ENC" - )); + match &mapped[1] { + LanguageModelCompletionEvent::ReasoningDetails(details) => assert_eq!( + details, + &json!({ + "reasoning_items": [ + { + "id": "r1", + "summary": [], + "encrypted_content": "ENC" + } + ] + }) + ), + other => panic!("expected reasoning details, got {other:?}"), + } + } + + #[test] + fn into_copilot_responses_replays_reasoning_details() { + let model = test_responses_model(); + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::RedactedThinking("legacy-redacted".into()), + MessageContent::Text("Done".into()), + ], + cache: false, + reasoning_details: Some(json!({ + "reasoning_items": [ + { + "id": "r1", + "summary": [ + { + "type": "summary_text", + "text": "Chain" + } + ], + "encrypted_content": "ENC" + } + ] + })), + }], + ..Default::default() + }; + + let serialized = serde_json::to_value(into_copilot_responses(&model, request)) + .expect("serialized request"); + let input = serialized["input"].as_array().expect("input items"); + + assert_eq!( + input.first(), + Some(&json!({ + "type": "reasoning", + "id": "r1", + "summary": [], + "encrypted_content": "ENC" + })) + ); + assert_eq!( + input.get(1), + Some(&json!({ + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Done" + } + ], + "status": "completed" + })) + ); + assert!(!serialized.to_string().contains("legacy-redacted")); } #[test]