Skip to repository content1951 lines · 76.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:36:42.315Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
copilot_chat.rs
1use std::pin::Pin;
2use std::str::FromStr as _;
3use std::sync::Arc;
4
5use anthropic::AnthropicModelMode;
6use anyhow::{Result, anyhow};
7use collections::HashMap;
8use copilot::{GlobalCopilotAuth, Status};
9use copilot_chat::responses as copilot_responses;
10use copilot_chat::{
11 ChatLocation, ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat,
12 CopilotChatConfiguration, Function, FunctionContent, ImageUrl, Model as CopilotChatModel,
13 ModelVendor, Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall, ToolCallContent,
14 ToolChoice,
15};
16use futures::future::BoxFuture;
17use futures::stream::BoxStream;
18use futures::{FutureExt, Stream, StreamExt};
19use gpui::{App, AsyncApp, Entity, Subscription, Task};
20use http_client::StatusCode;
21use language::language_settings::all_language_settings;
22use language_model::{
23 AuthenticateError, CompletionIntent, IconOrSvg, LanguageModel, LanguageModelCompletionError,
24 LanguageModelCompletionEvent, LanguageModelCostInfo, LanguageModelEffortLevel, LanguageModelId,
25 LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
26 LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage,
27 LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
28 LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason,
29 TokenUsage,
30};
31use settings::SettingsStore;
32use ui::prelude::*;
33use util::debug_panic;
34
35use crate::provider::anthropic::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic};
36use language_model::util::{fix_streamed_json, parse_tool_arguments};
37
38const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat");
39const PROVIDER_NAME: LanguageModelProviderName =
40 LanguageModelProviderName::new("GitHub Copilot Chat");
41
42pub struct CopilotChatLanguageModelProvider {
43 state: Entity<State>,
44}
45
46pub struct State {
47 _copilot_chat_subscription: Option<Subscription>,
48 _settings_subscription: Subscription,
49}
50
51impl State {
52 fn is_authenticated(&self, cx: &App) -> bool {
53 CopilotChat::global(cx)
54 .map(|m| m.read(cx).is_authenticated())
55 .unwrap_or(false)
56 }
57}
58
59impl CopilotChatLanguageModelProvider {
60 pub fn new(cx: &mut App) -> Self {
61 let state = cx.new(|cx| {
62 let copilot_chat_subscription = CopilotChat::global(cx)
63 .map(|copilot_chat| cx.observe(&copilot_chat, |_, _, cx| cx.notify()));
64 State {
65 _copilot_chat_subscription: copilot_chat_subscription,
66 _settings_subscription: cx.observe_global::<SettingsStore>(|_, cx| {
67 if let Some(copilot_chat) = CopilotChat::global(cx) {
68 let language_settings = all_language_settings(None, cx);
69 let configuration = CopilotChatConfiguration {
70 enterprise_uri: language_settings
71 .edit_predictions
72 .copilot
73 .enterprise_uri
74 .clone(),
75 };
76 copilot_chat.update(cx, |chat, cx| {
77 chat.set_configuration(configuration, cx);
78 });
79 }
80 cx.notify();
81 }),
82 }
83 });
84
85 Self { state }
86 }
87
88 fn create_language_model(&self, model: CopilotChatModel) -> Arc<dyn LanguageModel> {
89 Arc::new(CopilotChatLanguageModel {
90 model,
91 request_limiter: RateLimiter::new(4),
92 })
93 }
94}
95
96impl LanguageModelProviderState for CopilotChatLanguageModelProvider {
97 type ObservableEntity = State;
98
99 fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
100 Some(self.state.clone())
101 }
102}
103
104impl LanguageModelProvider for CopilotChatLanguageModelProvider {
105 fn id(&self) -> LanguageModelProviderId {
106 PROVIDER_ID
107 }
108
109 fn name(&self) -> LanguageModelProviderName {
110 PROVIDER_NAME
111 }
112
113 fn icon(&self) -> IconOrSvg {
114 IconOrSvg::Icon(IconName::Copilot)
115 }
116
117 fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
118 let models = CopilotChat::global(cx).and_then(|m| m.read(cx).models())?;
119 models
120 .first()
121 .map(|model| self.create_language_model(model.clone()))
122 }
123
124 fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
125 // The default model should be Copilot Chat's 'base model', which is likely a relatively fast
126 // model (e.g. 4o) and a sensible choice when considering premium requests
127 self.default_model(cx)
128 }
129
130 fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
131 let Some(models) = CopilotChat::global(cx).and_then(|m| m.read(cx).models()) else {
132 return Vec::new();
133 };
134 models
135 .iter()
136 .map(|model| self.create_language_model(model.clone()))
137 .collect()
138 }
139
140 fn is_authenticated(&self, cx: &App) -> bool {
141 self.state.read(cx).is_authenticated(cx)
142 }
143
144 fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
145 if self.is_authenticated(cx) {
146 return Task::ready(Ok(()));
147 };
148
149 let Some(copilot) = GlobalCopilotAuth::try_global(cx).cloned() else {
150 return Task::ready(Err(anyhow!(concat!(
151 "Copilot must be enabled for Copilot Chat to work. ",
152 "Please enable Copilot and try again."
153 ))
154 .into()));
155 };
156
157 let err = match copilot.0.read(cx).status() {
158 Status::Authorized => return Task::ready(Ok(())),
159 Status::Disabled => anyhow!(
160 "Copilot must be enabled for Copilot Chat to work. Please enable Copilot and try again."
161 ),
162 Status::Error(err) => anyhow!(format!(
163 "Received the following error while signing into Copilot: {err}"
164 )),
165 Status::Starting { task: _ } => anyhow!(
166 "Copilot is still starting, please wait for Copilot to start then try again"
167 ),
168 Status::Unauthorized => anyhow!(
169 "Unable to authorize with Copilot. Please make sure that you have an active Copilot and Copilot Chat subscription."
170 ),
171 Status::SignedOut { .. } => {
172 anyhow!("You have signed out of Copilot. Please sign in to Copilot and try again.")
173 }
174 Status::SigningIn { prompt: _ } => anyhow!("Still signing into Copilot..."),
175 };
176
177 Task::ready(Err(err.into()))
178 }
179
180 fn settings_view(&self, cx: &mut App) -> Option<ProviderSettingsView> {
181 let is_authenticated = self.state.read(cx).is_authenticated(cx);
182 let title = if is_authenticated {
183 None
184 } else {
185 Some("Configure Copilot".into())
186 };
187 let description = if is_authenticated {
188 None
189 } else {
190 Some(language_model::InlineDescription::Text(
191 "Requires an active GitHub Copilot subscription.".into(),
192 ))
193 };
194
195 Some(ProviderSettingsView::Inline(
196 language_model::InlineProviderSettings {
197 title,
198 description,
199 create_view: Arc::new(|_window, cx| {
200 cx.new(|cx| {
201 copilot_ui::ConfigurationView::new(
202 |cx| {
203 CopilotChat::global(cx)
204 .map(|m| m.read(cx).is_authenticated())
205 .unwrap_or(false)
206 },
207 copilot_ui::ConfigurationMode::Chat,
208 cx,
209 )
210 .compact()
211 })
212 .into()
213 }),
214 },
215 ))
216 }
217}
218
219pub struct CopilotChatLanguageModel {
220 model: CopilotChatModel,
221 request_limiter: RateLimiter,
222}
223
224impl LanguageModel for CopilotChatLanguageModel {
225 fn id(&self) -> LanguageModelId {
226 LanguageModelId::from(self.model.id().to_string())
227 }
228
229 fn name(&self) -> LanguageModelName {
230 LanguageModelName::from(self.model.display_name().to_string())
231 }
232
233 fn provider_id(&self) -> LanguageModelProviderId {
234 PROVIDER_ID
235 }
236
237 fn provider_name(&self) -> LanguageModelProviderName {
238 PROVIDER_NAME
239 }
240
241 fn supports_tools(&self) -> bool {
242 self.model.supports_tools()
243 }
244
245 fn supports_streaming_tools(&self) -> bool {
246 true
247 }
248
249 fn supports_images(&self) -> bool {
250 self.model.supports_vision()
251 }
252
253 fn supports_thinking(&self) -> bool {
254 self.model.can_think()
255 }
256
257 fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
258 let levels = self.model.reasoning_effort_levels();
259 if levels.is_empty() {
260 return vec![];
261 }
262 levels
263 .iter()
264 .map(|level| {
265 let name = match level.as_str() {
266 "low" => "Low".into(),
267 "medium" => "Medium".into(),
268 "high" => "High".into(),
269 "xhigh" => "Extra High".into(),
270 _ => language_model::SharedString::from(level.clone()),
271 };
272 LanguageModelEffortLevel {
273 name,
274 value: language_model::SharedString::from(level.clone()),
275 is_default: level == "high",
276 }
277 })
278 .collect()
279 }
280
281 fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
282 match self.model.vendor() {
283 ModelVendor::OpenAI | ModelVendor::Anthropic => {
284 LanguageModelToolSchemaFormat::JsonSchema
285 }
286 ModelVendor::Google | ModelVendor::XAI | ModelVendor::Unknown => {
287 LanguageModelToolSchemaFormat::JsonSchemaSubset
288 }
289 }
290 }
291
292 fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
293 match choice {
294 LanguageModelToolChoice::Auto
295 | LanguageModelToolChoice::Any
296 | LanguageModelToolChoice::None => self.supports_tools(),
297 }
298 }
299
300 fn model_cost_info(&self) -> Option<LanguageModelCostInfo> {
301 LanguageModelCostInfo::RequestCost {
302 cost_per_request: self.model.multiplier(),
303 }
304 .into()
305 }
306
307 fn telemetry_id(&self) -> String {
308 format!("copilot_chat/{}", self.model.id())
309 }
310
311 fn max_token_count(&self) -> u64 {
312 self.model.max_token_count()
313 }
314
315 fn stream_completion(
316 &self,
317 request: LanguageModelRequest,
318 cx: &AsyncApp,
319 ) -> BoxFuture<
320 'static,
321 Result<
322 BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
323 LanguageModelCompletionError,
324 >,
325 > {
326 let is_user_initiated = request.intent.is_none_or(|intent| match intent {
327 CompletionIntent::UserPrompt
328 | CompletionIntent::ThreadContextSummarization
329 | CompletionIntent::InlineAssist
330 | CompletionIntent::TerminalInlineAssist
331 | CompletionIntent::GenerateGitCommitMessage => true,
332
333 CompletionIntent::Subagent
334 | CompletionIntent::ToolResults
335 | CompletionIntent::ThreadSummarization
336 | CompletionIntent::CreateFile
337 | CompletionIntent::EditFile => false,
338 });
339
340 if self.model.supports_messages() {
341 let location = intent_to_chat_location(request.intent);
342 let model = self.model.clone();
343 let request_limiter = self.request_limiter.clone();
344 let future = cx.spawn(async move |cx| {
345 let effort = request
346 .thinking_effort
347 .as_ref()
348 .and_then(|e| anthropic::Effort::from_str(e).ok());
349
350 let mut anthropic_request = into_anthropic(
351 request,
352 model.id().to_string(),
353 0.0,
354 model.max_output_tokens() as u64,
355 if model.supports_adaptive_thinking() {
356 AnthropicModelMode::Thinking {
357 budget_tokens: None,
358 }
359 } else if model.supports_thinking() {
360 AnthropicModelMode::Thinking {
361 budget_tokens: compute_thinking_budget(
362 model.min_thinking_budget(),
363 model.max_thinking_budget(),
364 model.max_output_tokens() as u32,
365 ),
366 }
367 } else {
368 AnthropicModelMode::Default
369 },
370 AnthropicPromptCacheMode::Legacy,
371 &PROVIDER_ID,
372 )?;
373
374 anthropic_request.temperature = None;
375
376 // The Copilot proxy doesn't support eager_input_streaming on tools.
377 for tool in &mut anthropic_request.tools {
378 tool.eager_input_streaming = false;
379 }
380
381 if model.supports_adaptive_thinking() {
382 if anthropic_request.thinking.is_some() {
383 anthropic_request.thinking = Some(anthropic::Thinking::Adaptive {
384 display: Some(anthropic::AdaptiveThinkingDisplay::Summarized),
385 });
386 anthropic_request.output_config =
387 effort.map(|effort| anthropic::OutputConfig {
388 effort: Some(effort),
389 });
390 }
391 }
392
393 let anthropic_beta =
394 if !model.supports_adaptive_thinking() && model.supports_thinking() {
395 Some("interleaved-thinking-2025-05-14".to_string())
396 } else {
397 None
398 };
399
400 let body = serde_json::to_string(&anthropic::StreamingRequest {
401 base: anthropic_request,
402 stream: true,
403 })
404 .map_err(|e| anyhow::anyhow!(e))?;
405
406 let stream = CopilotChat::stream_messages(
407 body,
408 location,
409 is_user_initiated,
410 anthropic_beta,
411 cx.clone(),
412 );
413
414 request_limiter
415 .stream(async move {
416 let events = stream.await?;
417 let mapper = AnthropicEventMapper::new(PROVIDER_NAME, PROVIDER_ID);
418 Ok(mapper.map_stream(events).boxed())
419 })
420 .await
421 });
422 return async move { Ok(future.await?.boxed()) }.boxed();
423 }
424
425 if self.model.supports_response() {
426 let location = intent_to_chat_location(request.intent);
427 let responses_request = match into_copilot_responses(&self.model, request) {
428 Ok(request) => request,
429 Err(error) => return async move { Err(error.into()) }.boxed(),
430 };
431 let request_limiter = self.request_limiter.clone();
432 let future = cx.spawn(async move |cx| {
433 let request = CopilotChat::stream_response(
434 responses_request,
435 location,
436 is_user_initiated,
437 cx.clone(),
438 );
439 request_limiter
440 .stream(async move {
441 let stream = request.await?;
442 let mapper = CopilotResponsesEventMapper::new();
443 Ok(mapper.map_stream(stream).boxed())
444 })
445 .await
446 });
447 return async move { Ok(future.await?.boxed()) }.boxed();
448 }
449
450 let location = intent_to_chat_location(request.intent);
451 let copilot_request = match into_copilot_chat(&self.model, request) {
452 Ok(request) => request,
453 Err(err) => return futures::future::ready(Err(err.into())).boxed(),
454 };
455 let is_streaming = copilot_request.stream;
456
457 let request_limiter = self.request_limiter.clone();
458 let future = cx.spawn(async move |cx| {
459 let request = CopilotChat::stream_completion(
460 copilot_request,
461 location,
462 is_user_initiated,
463 cx.clone(),
464 );
465 request_limiter
466 .stream(async move {
467 let response = request.await?;
468 Ok(map_to_language_model_completion_events(
469 response,
470 is_streaming,
471 ))
472 })
473 .await
474 });
475 async move { Ok(future.await?.boxed()) }.boxed()
476 }
477}
478
479pub fn map_to_language_model_completion_events(
480 events: Pin<Box<dyn Send + Stream<Item = Result<ResponseEvent>>>>,
481 is_streaming: bool,
482) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
483 #[derive(Default)]
484 struct RawToolCall {
485 id: String,
486 name: String,
487 arguments: String,
488 thought_signature: Option<String>,
489 }
490
491 struct State {
492 events: Pin<Box<dyn Send + Stream<Item = Result<ResponseEvent>>>>,
493 tool_calls_by_index: HashMap<usize, RawToolCall>,
494 reasoning_opaque: Option<String>,
495 reasoning_text: Option<String>,
496 }
497
498 futures::stream::unfold(
499 State {
500 events,
501 tool_calls_by_index: HashMap::default(),
502 reasoning_opaque: None,
503 reasoning_text: None,
504 },
505 move |mut state| async move {
506 if let Some(event) = state.events.next().await {
507 match event {
508 Ok(event) => {
509 let Some(choice) = event.choices.first() else {
510 return Some((
511 vec![Err(anyhow!("Response contained no choices").into())],
512 state,
513 ));
514 };
515
516 let delta = if is_streaming {
517 choice.delta.as_ref()
518 } else {
519 choice.message.as_ref()
520 };
521
522 let Some(delta) = delta else {
523 return Some((
524 vec![Err(anyhow!("Response contained no delta").into())],
525 state,
526 ));
527 };
528
529 let mut events = Vec::new();
530 if let Some(content) = delta.content.clone() {
531 events.push(Ok(LanguageModelCompletionEvent::Text(content)));
532 }
533
534 // Capture reasoning data from the delta (e.g. for Gemini 3)
535 if let Some(opaque) = delta.reasoning_opaque.clone() {
536 state.reasoning_opaque = Some(opaque);
537 }
538 if let Some(text) = delta.reasoning_text.clone() {
539 state.reasoning_text = Some(text);
540 }
541
542 for (index, tool_call) in delta.tool_calls.iter().enumerate() {
543 let tool_index = tool_call.index.unwrap_or(index);
544 let entry = state.tool_calls_by_index.entry(tool_index).or_default();
545
546 if let Some(tool_id) = tool_call.id.clone() {
547 entry.id = tool_id;
548 }
549
550 if let Some(function) = tool_call.function.as_ref() {
551 if let Some(name) = function.name.clone() {
552 entry.name = name;
553 }
554
555 if let Some(arguments) = function.arguments.clone() {
556 entry.arguments.push_str(&arguments);
557 }
558
559 if let Some(thought_signature) = function.thought_signature.clone()
560 {
561 entry.thought_signature = Some(thought_signature);
562 }
563 }
564
565 if !entry.id.is_empty() && !entry.name.is_empty() {
566 if let Ok(input) = serde_json::from_str::<serde_json::Value>(
567 &fix_streamed_json(&entry.arguments),
568 ) {
569 events.push(Ok(LanguageModelCompletionEvent::ToolUse(
570 LanguageModelToolUse {
571 id: entry.id.clone().into(),
572 name: entry.name.as_str().into(),
573 is_input_complete: false,
574 input: language_model::LanguageModelToolUseInput::Json(
575 input,
576 ),
577 raw_input: entry.arguments.clone(),
578 thought_signature: entry.thought_signature.clone(),
579 },
580 )));
581 }
582 }
583 }
584
585 if let Some(usage) = event.usage {
586 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(
587 TokenUsage {
588 input_tokens: usage.prompt_tokens,
589 output_tokens: usage.completion_tokens,
590 cache_creation_input_tokens: 0,
591 cache_read_input_tokens: 0,
592 },
593 )));
594 }
595
596 match choice.finish_reason.as_deref() {
597 Some("stop") => {
598 events.push(Ok(LanguageModelCompletionEvent::Stop(
599 StopReason::EndTurn,
600 )));
601 }
602 Some("tool_calls") => {
603 // Gemini 3 models send reasoning_opaque/reasoning_text that must
604 // be preserved and sent back in subsequent requests. Emit as
605 // ReasoningDetails so the agent stores it in the message.
606 if state.reasoning_opaque.is_some()
607 || state.reasoning_text.is_some()
608 {
609 let mut details = serde_json::Map::new();
610 if let Some(opaque) = state.reasoning_opaque.take() {
611 details.insert(
612 "reasoning_opaque".to_string(),
613 serde_json::Value::String(opaque),
614 );
615 }
616 if let Some(text) = state.reasoning_text.take() {
617 details.insert(
618 "reasoning_text".to_string(),
619 serde_json::Value::String(text),
620 );
621 }
622 events.push(Ok(
623 LanguageModelCompletionEvent::ReasoningDetails(
624 serde_json::Value::Object(details),
625 ),
626 ));
627 }
628
629 events.extend(state.tool_calls_by_index.drain().map(
630 |(_, tool_call)| match parse_tool_arguments(
631 &tool_call.arguments,
632 ) {
633 Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
634 LanguageModelToolUse {
635 id: tool_call.id.into(),
636 name: tool_call.name.as_str().into(),
637 is_input_complete: true,
638 input:
639 language_model::LanguageModelToolUseInput::Json(
640 input,
641 ),
642 raw_input: tool_call.arguments,
643 thought_signature: tool_call.thought_signature,
644 },
645 )),
646 Err(error) => Ok(
647 LanguageModelCompletionEvent::ToolUseJsonParseError {
648 id: tool_call.id.into(),
649 tool_name: tool_call.name.as_str().into(),
650 raw_input: tool_call.arguments.into(),
651 json_parse_error: error.to_string(),
652 },
653 ),
654 },
655 ));
656
657 events.push(Ok(LanguageModelCompletionEvent::Stop(
658 StopReason::ToolUse,
659 )));
660 }
661 Some(stop_reason) => {
662 log::error!("Unexpected Copilot Chat stop_reason: {stop_reason:?}");
663 events.push(Ok(LanguageModelCompletionEvent::Stop(
664 StopReason::EndTurn,
665 )));
666 }
667 None => {}
668 }
669
670 return Some((events, state));
671 }
672 Err(err) => return Some((vec![Err(anyhow!(err).into())], state)),
673 }
674 }
675
676 None
677 },
678 )
679 .flat_map(futures::stream::iter)
680}
681
682pub struct CopilotResponsesEventMapper {
683 pending_stop_reason: Option<StopReason>,
684 reasoning_items: Vec<copilot_responses::ResponseReasoningInputItem>,
685}
686
687impl CopilotResponsesEventMapper {
688 pub fn new() -> Self {
689 Self {
690 pending_stop_reason: None,
691 reasoning_items: Vec::new(),
692 }
693 }
694
695 pub fn map_stream(
696 mut self,
697 events: Pin<Box<dyn Send + Stream<Item = Result<copilot_responses::StreamEvent>>>>,
698 ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
699 {
700 events.flat_map(move |event| {
701 futures::stream::iter(match event {
702 Ok(event) => self.map_event(event),
703 Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))],
704 })
705 })
706 }
707
708 fn map_event(
709 &mut self,
710 event: copilot_responses::StreamEvent,
711 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
712 match event {
713 copilot_responses::StreamEvent::OutputItemAdded { item, .. } => match item {
714 copilot_responses::ResponseOutputItem::Message { id, .. } => {
715 vec![Ok(LanguageModelCompletionEvent::StartMessage {
716 message_id: id,
717 })]
718 }
719 _ => Vec::new(),
720 },
721
722 copilot_responses::StreamEvent::OutputTextDelta { delta, .. } => {
723 if delta.is_empty() {
724 Vec::new()
725 } else {
726 vec![Ok(LanguageModelCompletionEvent::Text(delta))]
727 }
728 }
729
730 copilot_responses::StreamEvent::OutputItemDone { item, .. } => match item {
731 copilot_responses::ResponseOutputItem::Message { .. } => Vec::new(),
732 copilot_responses::ResponseOutputItem::FunctionCall {
733 call_id,
734 name,
735 arguments,
736 thought_signature,
737 ..
738 } => {
739 let mut events = Vec::new();
740 match parse_tool_arguments(&arguments) {
741 Ok(input) => events.push(Ok(LanguageModelCompletionEvent::ToolUse(
742 LanguageModelToolUse {
743 id: call_id.into(),
744 name: name.as_str().into(),
745 is_input_complete: true,
746 input: language_model::LanguageModelToolUseInput::Json(input),
747 raw_input: arguments.clone(),
748 thought_signature,
749 },
750 ))),
751 Err(error) => {
752 events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
753 id: call_id.into(),
754 tool_name: name.as_str().into(),
755 raw_input: arguments.clone().into(),
756 json_parse_error: error.to_string(),
757 }))
758 }
759 }
760 // Record that we already emitted a tool-use stop so we can avoid duplicating
761 // a Stop event on Completed.
762 self.pending_stop_reason = Some(StopReason::ToolUse);
763 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
764 events
765 }
766 copilot_responses::ResponseOutputItem::Reasoning {
767 id,
768 summary,
769 encrypted_content,
770 } => {
771 let mut events = Vec::new();
772
773 if let Some(blocks) = summary.as_ref() {
774 let mut text = String::new();
775 for block in blocks {
776 text.push_str(&block.text);
777 }
778 if !text.is_empty() {
779 events.push(Ok(LanguageModelCompletionEvent::Thinking {
780 text,
781 signature: None,
782 }));
783 }
784 }
785
786 if let Some(reasoning_item) =
787 reasoning_input_item_from_output(&id, encrypted_content)
788 {
789 events.extend(self.capture_reasoning_item(reasoning_item));
790 }
791
792 events
793 }
794 },
795
796 copilot_responses::StreamEvent::Completed { response } => {
797 let mut events = Vec::new();
798 if let Some(usage) = response.usage {
799 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
800 input_tokens: usage.input_tokens.unwrap_or(0),
801 output_tokens: usage.output_tokens.unwrap_or(0),
802 cache_creation_input_tokens: 0,
803 cache_read_input_tokens: 0,
804 })));
805 }
806 if self.pending_stop_reason.take() != Some(StopReason::ToolUse) {
807 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
808 }
809 events
810 }
811
812 copilot_responses::StreamEvent::Incomplete { response } => {
813 let reason = response
814 .incomplete_details
815 .as_ref()
816 .and_then(|details| details.reason.as_ref());
817 let stop_reason = match reason {
818 Some(copilot_responses::IncompleteReason::MaxOutputTokens) => {
819 StopReason::MaxTokens
820 }
821 Some(copilot_responses::IncompleteReason::ContentFilter) => StopReason::Refusal,
822 _ => self
823 .pending_stop_reason
824 .take()
825 .unwrap_or(StopReason::EndTurn),
826 };
827
828 let mut events = Vec::new();
829 if let Some(usage) = response.usage {
830 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
831 input_tokens: usage.input_tokens.unwrap_or(0),
832 output_tokens: usage.output_tokens.unwrap_or(0),
833 cache_creation_input_tokens: 0,
834 cache_read_input_tokens: 0,
835 })));
836 }
837 events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason)));
838 events
839 }
840
841 copilot_responses::StreamEvent::Failed { response } => {
842 let provider = PROVIDER_NAME;
843 let (status_code, message) = match response.error {
844 Some(error) => {
845 let status_code = StatusCode::from_str(&error.code)
846 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
847 (status_code, error.message)
848 }
849 None => (
850 StatusCode::INTERNAL_SERVER_ERROR,
851 "response.failed".to_string(),
852 ),
853 };
854 vec![Err(LanguageModelCompletionError::HttpResponseError {
855 provider,
856 status_code,
857 message,
858 })]
859 }
860
861 copilot_responses::StreamEvent::GenericError { error } => vec![Err(
862 LanguageModelCompletionError::Other(anyhow!(error.message)),
863 )],
864
865 copilot_responses::StreamEvent::Created { .. }
866 | copilot_responses::StreamEvent::Unknown => Vec::new(),
867 }
868 }
869
870 fn capture_reasoning_item(
871 &mut self,
872 reasoning_item: copilot_responses::ResponseReasoningInputItem,
873 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
874 if self.reasoning_items.contains(&reasoning_item) {
875 return Vec::new();
876 }
877
878 if let Some(id) = reasoning_item.id.as_ref()
879 && let Some(existing_reasoning_item) = self
880 .reasoning_items
881 .iter_mut()
882 .find(|existing_reasoning_item| existing_reasoning_item.id.as_ref() == Some(id))
883 {
884 *existing_reasoning_item = reasoning_item;
885 } else {
886 self.reasoning_items.push(reasoning_item);
887 }
888
889 self.emit_response_message_metadata()
890 }
891
892 fn emit_response_message_metadata(
893 &self,
894 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
895 let details = serde_json::to_value(CopilotResponseMessageMetadata {
896 reasoning_items: self.reasoning_items.clone(),
897 });
898
899 match details {
900 Ok(details) => vec![Ok(LanguageModelCompletionEvent::ReasoningDetails(details))],
901 Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
902 }
903 }
904}
905
906#[derive(serde::Serialize, serde::Deserialize)]
907struct CopilotResponseMessageMetadata {
908 #[serde(default, skip_serializing_if = "Vec::is_empty")]
909 reasoning_items: Vec<copilot_responses::ResponseReasoningInputItem>,
910}
911
912fn append_reasoning_details_to_response_items(
913 reasoning_details: Option<&serde_json::Value>,
914 replayed_reasoning_item_indexes: &mut HashMap<String, usize>,
915 input_items: &mut Vec<copilot_responses::ResponseInputItem>,
916) {
917 let Some(reasoning_details) = reasoning_details else {
918 return;
919 };
920
921 let Some(metadata) =
922 serde_json::from_value::<CopilotResponseMessageMetadata>(reasoning_details.clone()).ok()
923 else {
924 return;
925 };
926
927 for mut reasoning_item in metadata.reasoning_items {
928 reasoning_item.summary.clear();
929 if let Some(id) = reasoning_item.id.as_ref() {
930 if let Some(index) = replayed_reasoning_item_indexes.get(id) {
931 input_items[*index] =
932 copilot_responses::ResponseInputItem::Reasoning(reasoning_item);
933 return;
934 }
935
936 replayed_reasoning_item_indexes.insert(id.clone(), input_items.len());
937 }
938
939 input_items.push(copilot_responses::ResponseInputItem::Reasoning(
940 reasoning_item,
941 ));
942 }
943}
944
945fn reasoning_input_item_from_output(
946 id: &str,
947 encrypted_content: Option<String>,
948) -> Option<copilot_responses::ResponseReasoningInputItem> {
949 if encrypted_content.is_none() {
950 return None;
951 }
952 Some(copilot_responses::ResponseReasoningInputItem {
953 id: Some(id.to_string()),
954 summary: Vec::new(),
955 encrypted_content,
956 })
957}
958
959fn into_copilot_chat(
960 model: &CopilotChatModel,
961 request: LanguageModelRequest,
962) -> Result<CopilotChatRequest> {
963 let temperature = request.temperature;
964 let tool_choice = request.tool_choice;
965 let thinking_allowed = request.thinking_allowed;
966
967 let mut request_messages: Vec<LanguageModelRequestMessage> = Vec::new();
968 for message in request.messages {
969 if let Some(last_message) = request_messages.last_mut() {
970 if last_message.role == message.role {
971 last_message.content.extend(message.content);
972 } else {
973 request_messages.push(message);
974 }
975 } else {
976 request_messages.push(message);
977 }
978 }
979
980 let mut messages: Vec<ChatMessage> = Vec::new();
981 for message in request_messages {
982 match message.role {
983 Role::User => {
984 for content in &message.content {
985 if let MessageContent::ToolResult(tool_result) = content {
986 let parts: Vec<ChatMessagePart> = tool_result
987 .content
988 .iter()
989 .map(|part| match part {
990 LanguageModelToolResultContent::Text(text) => {
991 ChatMessagePart::Text {
992 text: text.to_string(),
993 }
994 }
995 LanguageModelToolResultContent::Image(image) => {
996 if model.supports_vision() {
997 ChatMessagePart::Image {
998 image_url: ImageUrl {
999 url: image.to_base64_url(),
1000 },
1001 }
1002 } else {
1003 debug_panic!(
1004 "This should be caught at {} level",
1005 tool_result.tool_name
1006 );
1007 ChatMessagePart::Text {
1008 text: "[Tool responded with an image, but this model does not support vision]".to_string(),
1009 }
1010 }
1011 }
1012 })
1013 .collect();
1014
1015 let content = match parts.as_slice() {
1016 [ChatMessagePart::Text { text }] => {
1017 ChatMessageContent::Plain(text.clone())
1018 }
1019 _ => ChatMessageContent::Multipart(parts),
1020 };
1021
1022 messages.push(ChatMessage::Tool {
1023 tool_call_id: tool_result.tool_use_id.to_string(),
1024 content,
1025 });
1026 }
1027 }
1028
1029 let mut content_parts = Vec::new();
1030 for content in &message.content {
1031 match content {
1032 MessageContent::Text(text) | MessageContent::Thinking { text, .. }
1033 if !text.is_empty() =>
1034 {
1035 if let Some(ChatMessagePart::Text { text: text_content }) =
1036 content_parts.last_mut()
1037 {
1038 text_content.push_str(text);
1039 } else {
1040 content_parts.push(ChatMessagePart::Text {
1041 text: text.to_string(),
1042 });
1043 }
1044 }
1045 MessageContent::Image(image) if model.supports_vision() => {
1046 content_parts.push(ChatMessagePart::Image {
1047 image_url: ImageUrl {
1048 url: image.to_base64_url(),
1049 },
1050 });
1051 }
1052 _ => {}
1053 }
1054 }
1055
1056 if !content_parts.is_empty() {
1057 messages.push(ChatMessage::User {
1058 content: content_parts.into(),
1059 });
1060 }
1061 }
1062 Role::Assistant => {
1063 let mut tool_calls = Vec::new();
1064 for content in &message.content {
1065 if let MessageContent::ToolUse(tool_use) = content {
1066 let input = tool_use.input.as_json().ok_or_else(|| {
1067 anyhow!("Copilot Chat does not support custom tool calls")
1068 })?;
1069 tool_calls.push(ToolCall {
1070 id: tool_use.id.to_string(),
1071 content: ToolCallContent::Function {
1072 function: FunctionContent {
1073 name: tool_use.name.to_string(),
1074 arguments: serde_json::to_string(input)?,
1075 thought_signature: tool_use.thought_signature.clone(),
1076 },
1077 },
1078 });
1079 }
1080 }
1081
1082 let text_content = {
1083 let mut buffer = String::new();
1084 for string in message.content.iter().filter_map(|content| match content {
1085 MessageContent::Text(text) => Some(text.as_str()),
1086 MessageContent::Thinking { .. }
1087 | MessageContent::ToolUse(_)
1088 | MessageContent::RedactedThinking(_)
1089 | MessageContent::ToolResult(_)
1090 | MessageContent::Image(_)
1091 | MessageContent::Compaction(_) => None,
1092 }) {
1093 buffer.push_str(string);
1094 }
1095
1096 buffer
1097 };
1098
1099 // Extract reasoning_opaque and reasoning_text from reasoning_details
1100 let (reasoning_opaque, reasoning_text) =
1101 if let Some(details) = &message.reasoning_details {
1102 let opaque = details
1103 .get("reasoning_opaque")
1104 .and_then(|v| v.as_str())
1105 .map(|s| s.to_string());
1106 let text = details
1107 .get("reasoning_text")
1108 .and_then(|v| v.as_str())
1109 .map(|s| s.to_string());
1110 (opaque, text)
1111 } else {
1112 (None, None)
1113 };
1114
1115 messages.push(ChatMessage::Assistant {
1116 content: if text_content.is_empty() {
1117 ChatMessageContent::empty()
1118 } else {
1119 text_content.into()
1120 },
1121 tool_calls,
1122 reasoning_opaque,
1123 reasoning_text,
1124 });
1125 }
1126 Role::System => messages.push(ChatMessage::System {
1127 content: message.string_contents(),
1128 }),
1129 }
1130 }
1131
1132 let tools = request
1133 .tools
1134 .iter()
1135 .map(|tool| match &tool.input {
1136 language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
1137 Ok(Tool::Function {
1138 function: Function {
1139 name: tool.name.clone(),
1140 description: tool.description.clone(),
1141 parameters: input_schema.clone(),
1142 },
1143 })
1144 }
1145 language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!(
1146 "Copilot Chat does not support custom tools"
1147 )),
1148 })
1149 .collect::<Result<Vec<_>>>()?;
1150
1151 Ok(CopilotChatRequest {
1152 n: 1,
1153 stream: model.uses_streaming(),
1154 temperature: temperature.unwrap_or(0.1),
1155 model: model.id().to_string(),
1156 messages,
1157 tools,
1158 tool_choice: tool_choice.map(|choice| match choice {
1159 LanguageModelToolChoice::Auto => ToolChoice::Auto,
1160 LanguageModelToolChoice::Any => ToolChoice::Required,
1161 LanguageModelToolChoice::None => ToolChoice::None,
1162 }),
1163 thinking_budget: if thinking_allowed && model.supports_thinking() {
1164 compute_thinking_budget(
1165 model.min_thinking_budget(),
1166 model.max_thinking_budget(),
1167 model.max_output_tokens() as u32,
1168 )
1169 } else {
1170 None
1171 },
1172 })
1173}
1174
1175fn compute_thinking_budget(
1176 min_budget: Option<u32>,
1177 max_budget: Option<u32>,
1178 max_output_tokens: u32,
1179) -> Option<u32> {
1180 let configured_budget: u32 = 16000;
1181 let min_budget = min_budget.unwrap_or(1024);
1182 let max_budget = max_budget.unwrap_or(max_output_tokens.saturating_sub(1));
1183 let normalized = configured_budget.max(min_budget);
1184 Some(
1185 normalized
1186 .min(max_budget)
1187 .min(max_output_tokens.saturating_sub(1)),
1188 )
1189}
1190
1191fn intent_to_chat_location(intent: Option<CompletionIntent>) -> ChatLocation {
1192 match intent {
1193 Some(CompletionIntent::UserPrompt) => ChatLocation::Agent,
1194 Some(CompletionIntent::Subagent) => ChatLocation::Agent,
1195 Some(CompletionIntent::ToolResults) => ChatLocation::Agent,
1196 Some(CompletionIntent::ThreadSummarization) => ChatLocation::Panel,
1197 Some(CompletionIntent::ThreadContextSummarization) => ChatLocation::Panel,
1198 Some(CompletionIntent::CreateFile) => ChatLocation::Agent,
1199 Some(CompletionIntent::EditFile) => ChatLocation::Agent,
1200 Some(CompletionIntent::InlineAssist) => ChatLocation::Editor,
1201 Some(CompletionIntent::TerminalInlineAssist) => ChatLocation::Terminal,
1202 Some(CompletionIntent::GenerateGitCommitMessage) => ChatLocation::Other,
1203 None => ChatLocation::Panel,
1204 }
1205}
1206
1207fn into_copilot_responses(
1208 model: &CopilotChatModel,
1209 request: LanguageModelRequest,
1210) -> Result<copilot_responses::Request> {
1211 use copilot_responses as responses;
1212
1213 let LanguageModelRequest {
1214 thread_id: _,
1215 prompt_id: _,
1216 intent: _,
1217 messages,
1218 tools,
1219 tool_choice,
1220 stop: _,
1221 temperature,
1222 thinking_allowed,
1223 thinking_effort,
1224 speed: _,
1225 compact_at_tokens: _,
1226 } = request;
1227
1228 let mut input_items: Vec<responses::ResponseInputItem> = Vec::new();
1229 let mut replayed_reasoning_item_indexes = HashMap::default();
1230
1231 for message in messages {
1232 match message.role {
1233 Role::User => {
1234 for content in &message.content {
1235 if let MessageContent::ToolResult(tool_result) = content {
1236 let output = match tool_result.content.as_slice() {
1237 [LanguageModelToolResultContent::Text(text)] => {
1238 responses::ResponseFunctionOutput::Text(text.to_string())
1239 }
1240 _ => {
1241 let parts = tool_result
1242 .content
1243 .iter()
1244 .map(|part| match part {
1245 LanguageModelToolResultContent::Text(text) => {
1246 responses::ResponseInputContent::InputText {
1247 text: text.to_string(),
1248 }
1249 }
1250 LanguageModelToolResultContent::Image(image) => {
1251 if model.supports_vision() {
1252 responses::ResponseInputContent::InputImage {
1253 image_url: Some(image.to_base64_url()),
1254 detail: Default::default(),
1255 }
1256 } else {
1257 debug_panic!(
1258 "This should be caught at {} level",
1259 tool_result.tool_name
1260 );
1261 responses::ResponseInputContent::InputText {
1262 text: "[Tool responded with an image, but this model does not support vision]".to_string(),
1263 }
1264 }
1265 }
1266 })
1267 .collect();
1268 responses::ResponseFunctionOutput::Content(parts)
1269 }
1270 };
1271
1272 input_items.push(responses::ResponseInputItem::FunctionCallOutput {
1273 call_id: tool_result.tool_use_id.to_string(),
1274 output,
1275 status: None,
1276 });
1277 }
1278 }
1279
1280 let mut parts: Vec<responses::ResponseInputContent> = Vec::new();
1281 for content in &message.content {
1282 match content {
1283 MessageContent::Text(text) => {
1284 parts.push(responses::ResponseInputContent::InputText {
1285 text: text.clone(),
1286 });
1287 }
1288
1289 MessageContent::Image(image) => {
1290 if model.supports_vision() {
1291 parts.push(responses::ResponseInputContent::InputImage {
1292 image_url: Some(image.to_base64_url()),
1293 detail: Default::default(),
1294 });
1295 }
1296 }
1297 _ => {}
1298 }
1299 }
1300
1301 if !parts.is_empty() {
1302 input_items.push(responses::ResponseInputItem::Message {
1303 role: "user".into(),
1304 content: Some(parts),
1305 status: None,
1306 });
1307 }
1308 }
1309
1310 Role::Assistant => {
1311 append_reasoning_details_to_response_items(
1312 message.reasoning_details.as_deref(),
1313 &mut replayed_reasoning_item_indexes,
1314 &mut input_items,
1315 );
1316
1317 for content in &message.content {
1318 if let MessageContent::ToolUse(tool_use) = content {
1319 input_items.push(responses::ResponseInputItem::FunctionCall {
1320 call_id: tool_use.id.to_string(),
1321 name: tool_use.name.to_string(),
1322 arguments: tool_use.raw_input.clone(),
1323 status: None,
1324 thought_signature: tool_use.thought_signature.clone(),
1325 });
1326 }
1327 }
1328
1329 let mut parts: Vec<responses::ResponseInputContent> = Vec::new();
1330 for content in &message.content {
1331 match content {
1332 MessageContent::Text(text) => {
1333 parts.push(responses::ResponseInputContent::OutputText {
1334 text: text.clone(),
1335 });
1336 }
1337 MessageContent::Image(_) => {
1338 parts.push(responses::ResponseInputContent::OutputText {
1339 text: "[image omitted]".to_string(),
1340 });
1341 }
1342 _ => {}
1343 }
1344 }
1345
1346 if !parts.is_empty() {
1347 input_items.push(responses::ResponseInputItem::Message {
1348 role: "assistant".into(),
1349 content: Some(parts),
1350 status: Some("completed".into()),
1351 });
1352 }
1353 }
1354
1355 Role::System => {
1356 let mut parts: Vec<responses::ResponseInputContent> = Vec::new();
1357 for content in &message.content {
1358 if let MessageContent::Text(text) = content {
1359 parts.push(responses::ResponseInputContent::InputText {
1360 text: text.clone(),
1361 });
1362 }
1363 }
1364
1365 if !parts.is_empty() {
1366 input_items.push(responses::ResponseInputItem::Message {
1367 role: "system".into(),
1368 content: Some(parts),
1369 status: None,
1370 });
1371 }
1372 }
1373 }
1374 }
1375
1376 let converted_tools: Vec<responses::ToolDefinition> = tools
1377 .into_iter()
1378 .map(|tool| match tool.input {
1379 language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
1380 Ok(responses::ToolDefinition::Function {
1381 name: tool.name,
1382 description: Some(tool.description),
1383 parameters: Some(input_schema),
1384 strict: None,
1385 })
1386 }
1387 language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!(
1388 "Copilot Chat does not support custom tools"
1389 )),
1390 })
1391 .collect::<Result<_>>()?;
1392
1393 let mapped_tool_choice = tool_choice.map(|choice| match choice {
1394 LanguageModelToolChoice::Auto => responses::ToolChoice::Auto,
1395 LanguageModelToolChoice::Any => responses::ToolChoice::Required,
1396 LanguageModelToolChoice::None => responses::ToolChoice::None,
1397 });
1398
1399 Ok(responses::Request {
1400 model: model.id().to_string(),
1401 input: input_items,
1402 stream: model.uses_streaming(),
1403 temperature,
1404 tools: converted_tools,
1405 tool_choice: mapped_tool_choice,
1406 reasoning: if thinking_allowed {
1407 let effort = thinking_effort
1408 .as_deref()
1409 .and_then(|e| e.parse::<copilot_responses::ReasoningEffort>().ok())
1410 .unwrap_or(copilot_responses::ReasoningEffort::Medium);
1411 Some(copilot_responses::ReasoningConfig {
1412 effort,
1413 summary: Some(copilot_responses::ReasoningSummary::Detailed),
1414 })
1415 } else {
1416 None
1417 },
1418 include: Some(vec![
1419 copilot_responses::ResponseIncludable::ReasoningEncryptedContent,
1420 ]),
1421 store: false,
1422 })
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427 use super::*;
1428 use copilot_chat::responses;
1429 use futures::StreamExt;
1430 use serde_json::json;
1431
1432 fn map_events(events: Vec<responses::StreamEvent>) -> Vec<LanguageModelCompletionEvent> {
1433 futures::executor::block_on(async {
1434 CopilotResponsesEventMapper::new()
1435 .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
1436 .collect::<Vec<_>>()
1437 .await
1438 .into_iter()
1439 .map(Result::unwrap)
1440 .collect()
1441 })
1442 }
1443
1444 fn test_responses_model() -> CopilotChatModel {
1445 serde_json::from_value(json!({
1446 "billing": {
1447 "is_premium": false,
1448 "multiplier": 1.0
1449 },
1450 "capabilities": {
1451 "family": "test",
1452 "limits": {
1453 "max_context_window_tokens": 128000,
1454 "max_output_tokens": 4096
1455 },
1456 "supports": {
1457 "streaming": true,
1458 "tool_calls": true,
1459 "parallel_tool_calls": false,
1460 "vision": false
1461 },
1462 "type": "chat"
1463 },
1464 "id": "test-model",
1465 "is_chat_default": false,
1466 "is_chat_fallback": false,
1467 "model_picker_enabled": true,
1468 "name": "Test Model",
1469 "vendor": "OpenAI",
1470 "supported_endpoints": ["/responses"]
1471 }))
1472 .expect("valid test model")
1473 }
1474
1475 #[test]
1476 fn responses_stream_maps_text_and_usage() {
1477 let events = vec![
1478 responses::StreamEvent::OutputItemAdded {
1479 output_index: 0,
1480 sequence_number: None,
1481 item: responses::ResponseOutputItem::Message {
1482 id: "msg_1".into(),
1483 role: "assistant".into(),
1484 content: Some(Vec::new()),
1485 },
1486 },
1487 responses::StreamEvent::OutputTextDelta {
1488 item_id: "msg_1".into(),
1489 output_index: 0,
1490 delta: "Hello".into(),
1491 },
1492 responses::StreamEvent::Completed {
1493 response: responses::Response {
1494 usage: Some(responses::ResponseUsage {
1495 input_tokens: Some(5),
1496 output_tokens: Some(3),
1497 total_tokens: Some(8),
1498 }),
1499 ..Default::default()
1500 },
1501 },
1502 ];
1503
1504 let mapped = map_events(events);
1505 assert!(matches!(
1506 mapped[0],
1507 LanguageModelCompletionEvent::StartMessage { ref message_id } if message_id == "msg_1"
1508 ));
1509 assert!(matches!(
1510 mapped[1],
1511 LanguageModelCompletionEvent::Text(ref text) if text == "Hello"
1512 ));
1513 assert!(matches!(
1514 mapped[2],
1515 LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
1516 input_tokens: 5,
1517 output_tokens: 3,
1518 ..
1519 })
1520 ));
1521 assert!(matches!(
1522 mapped[3],
1523 LanguageModelCompletionEvent::Stop(StopReason::EndTurn)
1524 ));
1525 }
1526
1527 #[test]
1528 fn responses_stream_maps_tool_calls() {
1529 let events = vec![responses::StreamEvent::OutputItemDone {
1530 output_index: 0,
1531 sequence_number: None,
1532 item: responses::ResponseOutputItem::FunctionCall {
1533 id: Some("fn_1".into()),
1534 call_id: "call_1".into(),
1535 name: "do_it".into(),
1536 arguments: "{\"x\":1}".into(),
1537 status: None,
1538 thought_signature: None,
1539 },
1540 }];
1541
1542 let mapped = map_events(events);
1543 assert!(matches!(
1544 mapped[0],
1545 LanguageModelCompletionEvent::ToolUse(ref use_) if use_.id.to_string() == "call_1" && use_.name.as_ref() == "do_it"
1546 ));
1547 assert!(matches!(
1548 mapped[1],
1549 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
1550 ));
1551 }
1552
1553 #[test]
1554 fn responses_stream_handles_json_parse_error() {
1555 let events = vec![responses::StreamEvent::OutputItemDone {
1556 output_index: 0,
1557 sequence_number: None,
1558 item: responses::ResponseOutputItem::FunctionCall {
1559 id: Some("fn_1".into()),
1560 call_id: "call_1".into(),
1561 name: "do_it".into(),
1562 arguments: "{not json}".into(),
1563 status: None,
1564 thought_signature: None,
1565 },
1566 }];
1567
1568 let mapped = map_events(events);
1569 assert!(matches!(
1570 mapped[0],
1571 LanguageModelCompletionEvent::ToolUseJsonParseError { ref id, ref tool_name, .. }
1572 if id.to_string() == "call_1" && tool_name.as_ref() == "do_it"
1573 ));
1574 assert!(matches!(
1575 mapped[1],
1576 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
1577 ));
1578 }
1579
1580 #[test]
1581 fn responses_stream_maps_reasoning_summary_and_encrypted_content() {
1582 let events = vec![responses::StreamEvent::OutputItemDone {
1583 output_index: 0,
1584 sequence_number: None,
1585 item: responses::ResponseOutputItem::Reasoning {
1586 id: "r1".into(),
1587 summary: Some(vec![responses::ResponseReasoningItem {
1588 kind: "summary_text".into(),
1589 text: "Chain".into(),
1590 }]),
1591 encrypted_content: Some("ENC".into()),
1592 },
1593 }];
1594
1595 let mapped = map_events(events);
1596 assert!(matches!(
1597 mapped[0],
1598 LanguageModelCompletionEvent::Thinking { ref text, signature: None } if text == "Chain"
1599 ));
1600 match &mapped[1] {
1601 LanguageModelCompletionEvent::ReasoningDetails(details) => assert_eq!(
1602 details,
1603 &json!({
1604 "reasoning_items": [
1605 {
1606 "id": "r1",
1607 "summary": [],
1608 "encrypted_content": "ENC"
1609 }
1610 ]
1611 })
1612 ),
1613 other => panic!("expected reasoning details, got {other:?}"),
1614 }
1615 }
1616
1617 #[test]
1618 fn responses_stream_ignores_reasoning_items_repeated_in_completed_output() {
1619 let events = vec![
1620 responses::StreamEvent::OutputItemDone {
1621 output_index: 0,
1622 sequence_number: None,
1623 item: responses::ResponseOutputItem::Reasoning {
1624 id: "r1".into(),
1625 summary: Some(Vec::new()),
1626 encrypted_content: Some("ENC1".into()),
1627 },
1628 },
1629 responses::StreamEvent::Completed {
1630 response: responses::Response {
1631 output: vec![
1632 responses::ResponseOutputItem::Reasoning {
1633 id: "r1".into(),
1634 summary: Some(Vec::new()),
1635 encrypted_content: Some("ENC1".into()),
1636 },
1637 responses::ResponseOutputItem::Reasoning {
1638 id: "r2".into(),
1639 summary: Some(Vec::new()),
1640 encrypted_content: Some("ENC2".into()),
1641 },
1642 ],
1643 ..Default::default()
1644 },
1645 },
1646 ];
1647
1648 let mapped = map_events(events);
1649 let reasoning_details = mapped
1650 .iter()
1651 .filter_map(|event| match event {
1652 LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details),
1653 _ => None,
1654 })
1655 .collect::<Vec<_>>();
1656
1657 assert_eq!(
1658 reasoning_details,
1659 vec![&json!({
1660 "reasoning_items": [
1661 {
1662 "id": "r1",
1663 "summary": [],
1664 "encrypted_content": "ENC1"
1665 }
1666 ]
1667 })]
1668 );
1669 }
1670
1671 #[test]
1672 fn into_copilot_responses_replays_reasoning_details() {
1673 let model = test_responses_model();
1674 let request = LanguageModelRequest {
1675 messages: vec![LanguageModelRequestMessage {
1676 role: Role::Assistant,
1677 content: vec![
1678 MessageContent::RedactedThinking("legacy-redacted".into()),
1679 MessageContent::Text("Done".into()),
1680 ],
1681 cache: false,
1682 reasoning_details: Some(Arc::new(json!({
1683 "reasoning_items": [
1684 {
1685 "id": "r1",
1686 "summary": [
1687 {
1688 "type": "summary_text",
1689 "text": "Chain"
1690 }
1691 ],
1692 "encrypted_content": "ENC"
1693 }
1694 ]
1695 }))),
1696 }],
1697 ..Default::default()
1698 };
1699
1700 let serialized = serde_json::to_value(into_copilot_responses(&model, request).unwrap())
1701 .expect("serialized request");
1702 let input = serialized["input"].as_array().expect("input items");
1703
1704 assert_eq!(
1705 input.first(),
1706 Some(&json!({
1707 "type": "reasoning",
1708 "id": "r1",
1709 "summary": [],
1710 "encrypted_content": "ENC"
1711 }))
1712 );
1713 assert_eq!(
1714 input.get(1),
1715 Some(&json!({
1716 "type": "message",
1717 "role": "assistant",
1718 "content": [
1719 {
1720 "type": "output_text",
1721 "text": "Done"
1722 }
1723 ],
1724 "status": "completed"
1725 }))
1726 );
1727 assert!(!serialized.to_string().contains("legacy-redacted"));
1728 }
1729
1730 #[test]
1731 fn responses_stream_handles_incomplete_max_tokens() {
1732 let events = vec![responses::StreamEvent::Incomplete {
1733 response: responses::Response {
1734 usage: Some(responses::ResponseUsage {
1735 input_tokens: Some(10),
1736 output_tokens: Some(0),
1737 total_tokens: Some(10),
1738 }),
1739 incomplete_details: Some(responses::IncompleteDetails {
1740 reason: Some(responses::IncompleteReason::MaxOutputTokens),
1741 }),
1742 ..Default::default()
1743 },
1744 }];
1745
1746 let mapped = map_events(events);
1747 assert!(matches!(
1748 mapped[0],
1749 LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
1750 input_tokens: 10,
1751 output_tokens: 0,
1752 ..
1753 })
1754 ));
1755 assert!(matches!(
1756 mapped[1],
1757 LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)
1758 ));
1759 }
1760
1761 #[test]
1762 fn responses_stream_handles_incomplete_content_filter() {
1763 let events = vec![responses::StreamEvent::Incomplete {
1764 response: responses::Response {
1765 usage: None,
1766 incomplete_details: Some(responses::IncompleteDetails {
1767 reason: Some(responses::IncompleteReason::ContentFilter),
1768 }),
1769 ..Default::default()
1770 },
1771 }];
1772
1773 let mapped = map_events(events);
1774 assert!(matches!(
1775 mapped.last().unwrap(),
1776 LanguageModelCompletionEvent::Stop(StopReason::Refusal)
1777 ));
1778 }
1779
1780 #[test]
1781 fn responses_stream_completed_no_duplicate_after_tool_use() {
1782 let events = vec![
1783 responses::StreamEvent::OutputItemDone {
1784 output_index: 0,
1785 sequence_number: None,
1786 item: responses::ResponseOutputItem::FunctionCall {
1787 id: Some("fn_1".into()),
1788 call_id: "call_1".into(),
1789 name: "do_it".into(),
1790 arguments: "{}".into(),
1791 status: None,
1792 thought_signature: None,
1793 },
1794 },
1795 responses::StreamEvent::Completed {
1796 response: responses::Response::default(),
1797 },
1798 ];
1799
1800 let mapped = map_events(events);
1801
1802 let mut stop_count = 0usize;
1803 let mut saw_tool_use_stop = false;
1804 for event in mapped {
1805 if let LanguageModelCompletionEvent::Stop(reason) = event {
1806 stop_count += 1;
1807 if matches!(reason, StopReason::ToolUse) {
1808 saw_tool_use_stop = true;
1809 }
1810 }
1811 }
1812 assert_eq!(stop_count, 1, "should emit exactly one Stop event");
1813 assert!(saw_tool_use_stop, "Stop reason should be ToolUse");
1814 }
1815
1816 #[test]
1817 fn responses_stream_failed_maps_http_response_error() {
1818 let events = vec![responses::StreamEvent::Failed {
1819 response: responses::Response {
1820 error: Some(responses::ResponseError {
1821 code: "429".into(),
1822 message: "too many requests".into(),
1823 }),
1824 ..Default::default()
1825 },
1826 }];
1827
1828 let mapped_results = futures::executor::block_on(async {
1829 CopilotResponsesEventMapper::new()
1830 .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
1831 .collect::<Vec<_>>()
1832 .await
1833 });
1834
1835 assert_eq!(mapped_results.len(), 1);
1836 match &mapped_results[0] {
1837 Err(LanguageModelCompletionError::HttpResponseError {
1838 status_code,
1839 message,
1840 ..
1841 }) => {
1842 assert_eq!(*status_code, http_client::StatusCode::TOO_MANY_REQUESTS);
1843 assert_eq!(message, "too many requests");
1844 }
1845 other => panic!("expected HttpResponseError, got {:?}", other),
1846 }
1847 }
1848
1849 #[test]
1850 fn chat_completions_stream_maps_reasoning_data() {
1851 use copilot_chat::{
1852 FunctionChunk, ResponseChoice, ResponseDelta, ResponseEvent, Role, ToolCallChunk,
1853 };
1854
1855 let events = vec![
1856 ResponseEvent {
1857 choices: vec![ResponseChoice {
1858 index: Some(0),
1859 finish_reason: None,
1860 delta: Some(ResponseDelta {
1861 content: None,
1862 role: Some(Role::Assistant),
1863 tool_calls: vec![ToolCallChunk {
1864 index: Some(0),
1865 id: Some("call_abc123".to_string()),
1866 function: Some(FunctionChunk {
1867 name: Some("list_directory".to_string()),
1868 arguments: Some("{\"path\":\"test\"}".to_string()),
1869 thought_signature: None,
1870 }),
1871 }],
1872 reasoning_opaque: Some("encrypted_reasoning_token_xyz".to_string()),
1873 reasoning_text: Some("Let me check the directory".to_string()),
1874 }),
1875 message: None,
1876 }],
1877 id: "chatcmpl-123".to_string(),
1878 usage: None,
1879 },
1880 ResponseEvent {
1881 choices: vec![ResponseChoice {
1882 index: Some(0),
1883 finish_reason: Some("tool_calls".to_string()),
1884 delta: Some(ResponseDelta {
1885 content: None,
1886 role: None,
1887 tool_calls: vec![],
1888 reasoning_opaque: None,
1889 reasoning_text: None,
1890 }),
1891 message: None,
1892 }],
1893 id: "chatcmpl-123".to_string(),
1894 usage: None,
1895 },
1896 ];
1897
1898 let mapped = futures::executor::block_on(async {
1899 map_to_language_model_completion_events(
1900 Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
1901 true,
1902 )
1903 .collect::<Vec<_>>()
1904 .await
1905 });
1906
1907 let mut has_reasoning_details = false;
1908 let mut has_tool_use = false;
1909 let mut reasoning_opaque_value: Option<String> = None;
1910 let mut reasoning_text_value: Option<String> = None;
1911
1912 for event_result in mapped {
1913 match event_result {
1914 Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => {
1915 has_reasoning_details = true;
1916 reasoning_opaque_value = details
1917 .get("reasoning_opaque")
1918 .and_then(|v| v.as_str())
1919 .map(|s| s.to_string());
1920 reasoning_text_value = details
1921 .get("reasoning_text")
1922 .and_then(|v| v.as_str())
1923 .map(|s| s.to_string());
1924 }
1925 Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => {
1926 has_tool_use = true;
1927 assert_eq!(tool_use.id.to_string(), "call_abc123");
1928 assert_eq!(tool_use.name.as_ref(), "list_directory");
1929 }
1930 _ => {}
1931 }
1932 }
1933
1934 assert!(
1935 has_reasoning_details,
1936 "Should emit ReasoningDetails event for Gemini 3 reasoning"
1937 );
1938 assert!(has_tool_use, "Should emit ToolUse event");
1939 assert_eq!(
1940 reasoning_opaque_value,
1941 Some("encrypted_reasoning_token_xyz".to_string()),
1942 "Should capture reasoning_opaque"
1943 );
1944 assert_eq!(
1945 reasoning_text_value,
1946 Some("Let me check the directory".to_string()),
1947 "Should capture reasoning_text"
1948 );
1949 }
1950}
1951