1use crate::{
4 CopilotError,
5 constant::{COPILOT_BASE_URL, COPILOT_INTEGRATION_ID, DEFAULT_MODEL, EDITOR_VERSION},
6};
7use aither_core::time::sleep;
8use aither_core::{
9 LanguageModel,
10 llm::{
11 Event, LLMRequest, Message, Role, ToolCall, Usage,
12 model::{
13 Ability, OpenAIPromptCacheRetention, Parameters, Profile as ModelProfile, ToolChoice,
14 },
15 tool::ToolDefinition,
16 },
17};
18use futures_core::Stream;
19use futures_lite::StreamExt;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::{
23 collections::HashMap,
24 sync::Arc,
25 time::{Duration, Instant},
26};
27use zenwave::{Client, client, header};
28
29#[derive(Clone, Debug)]
33pub struct Copilot {
34 inner: Arc<Config>,
35}
36
37impl Copilot {
38 pub fn new(token: impl Into<String>) -> Self {
40 Self::builder(token).build()
41 }
42
43 #[must_use]
45 pub fn builder(token: impl Into<String>) -> Builder {
46 Builder::new(token)
47 }
48
49 #[must_use]
51 pub fn with_model(mut self, model: impl Into<String>) -> Self {
52 Arc::make_mut(&mut self.inner).model = model.into().trim().to_string();
53 self
54 }
55
56 #[must_use]
58 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
59 Arc::make_mut(&mut self.inner).base_url = base_url.into();
60 self
61 }
62
63 #[must_use]
65 pub fn with_oauth_token(mut self, token: impl Into<String>) -> Self {
66 Arc::make_mut(&mut self.inner).oauth_token = Some(token.into());
67 self
68 }
69}
70
71impl LanguageModel for Copilot {
72 type Error = CopilotError;
73
74 fn respond(
75 &self,
76 request: LLMRequest,
77 ) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
78 let cfg = self.inner.clone();
79 let (messages, parameters, tool_defs) = request.into_parts();
80 let tool_defs = filter_tool_definitions(tool_defs, ¶meters.tool_choice);
81
82 async_stream::stream! {
83 let payload_messages = match to_chat_messages(&messages).await {
84 Ok(messages) => messages,
85 Err(error) => {
86 yield Err(CopilotError::Api(error));
87 return;
88 }
89 };
90 let openai_tools = if tool_defs.is_empty() {
91 None
92 } else {
93 Some(convert_tools(&tool_defs))
94 };
95
96 let mut events = chat_completions_stream(cfg, payload_messages, ¶meters, openai_tools);
97
98 while let Some(event) = events.next().await {
99 yield event;
100 }
101 }
102 }
103
104 fn profile(
105 &self,
106 ) -> impl std::future::Future<Output = Result<ModelProfile, Self::Error>> + Send {
107 let cfg = self.inner.clone();
108 async move {
109 let context_length = aither_models::lookup(&cfg.model)
111 .and_then(aither_models::ModelEntry::max_input_tokens)
112 .ok_or_else(|| {
113 CopilotError::Api(format!(
114 "cannot determine context length for model '{model}': \
115 aither-models has no entry",
116 model = cfg.model,
117 ))
118 })?;
119
120 Ok(ModelProfile::new(
121 cfg.model.clone(),
122 "GitHub Copilot",
123 cfg.model.clone(),
124 "GitHub Copilot model",
125 context_length,
126 )
127 .with_ability(Ability::ToolUse))
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
133struct Config {
134 token: String,
135 base_url: String,
136 model: String,
137 editor_version: String,
138 integration_id: String,
139 oauth_token: Option<String>,
140}
141
142#[derive(Debug)]
144pub struct Builder {
145 token: String,
146 base_url: String,
147 model: String,
148 editor_version: String,
149 integration_id: String,
150 oauth_token: Option<String>,
151}
152
153impl Builder {
154 fn new(token: impl Into<String>) -> Self {
155 Self {
156 token: token.into(),
157 base_url: COPILOT_BASE_URL.to_string(),
158 model: DEFAULT_MODEL.to_string(),
159 editor_version: EDITOR_VERSION.to_string(),
160 integration_id: COPILOT_INTEGRATION_ID.to_string(),
161 oauth_token: None,
162 }
163 }
164
165 #[must_use]
167 pub fn base_url(mut self, url: impl Into<String>) -> Self {
168 self.base_url = url.into();
169 self
170 }
171
172 #[must_use]
174 pub fn model(mut self, model: impl Into<String>) -> Self {
175 self.model = model.into().trim().to_string();
176 self
177 }
178
179 #[must_use]
181 pub fn editor_version(mut self, version: impl Into<String>) -> Self {
182 self.editor_version = version.into();
183 self
184 }
185
186 #[must_use]
188 pub fn integration_id(mut self, id: impl Into<String>) -> Self {
189 self.integration_id = id.into();
190 self
191 }
192
193 #[must_use]
195 pub fn oauth_token(mut self, token: impl Into<String>) -> Self {
196 self.oauth_token = Some(token.into());
197 self
198 }
199
200 #[must_use]
202 pub fn build(self) -> Copilot {
203 Copilot {
204 inner: Arc::new(Config {
205 token: self.token,
206 base_url: self.base_url,
207 model: self.model,
208 editor_version: self.editor_version,
209 integration_id: self.integration_id,
210 oauth_token: self.oauth_token,
211 }),
212 }
213 }
214}
215
216#[derive(Debug, Serialize)]
219struct ChatCompletionRequest {
220 model: String,
221 messages: Vec<ChatMessagePayload>,
222 stream: bool,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 stream_options: Option<StreamOptions>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 temperature: Option<f32>,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 top_p: Option<f32>,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 max_tokens: Option<u32>,
231 #[serde(skip_serializing_if = "Option::is_none")]
232 tools: Option<Vec<ToolPayload>>,
233 #[serde(skip_serializing_if = "Option::is_none")]
234 tool_choice: Option<ToolChoicePayload>,
235 #[serde(skip_serializing_if = "Option::is_none")]
236 prompt_cache_key: Option<String>,
237 #[serde(skip_serializing_if = "Option::is_none")]
238 prompt_cache_retention: Option<&'static str>,
239}
240
241#[derive(Debug, Serialize, Clone)]
242struct StreamOptions {
243 include_usage: bool,
244}
245
246#[derive(Debug, Serialize, Clone)]
247struct ChatMessagePayload {
248 role: &'static str,
249 content: ContentPayload,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 tool_calls: Option<Vec<ChatToolCallPayload>>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 tool_call_id: Option<String>,
254}
255
256#[derive(Debug, Clone, Serialize)]
257#[serde(untagged)]
258enum ContentPayload {
259 Text(String),
260 Parts(Vec<ContentPart>),
261}
262
263#[derive(Debug, Clone, Serialize)]
264#[serde(tag = "type")]
265enum ContentPart {
266 #[serde(rename = "text")]
267 Text { text: String },
268 #[serde(rename = "image_url")]
269 ImageUrl { image_url: ImageUrlPayload },
270}
271
272#[derive(Debug, Clone, Serialize)]
273struct ImageUrlPayload {
274 url: String,
275}
276
277#[derive(Debug, Serialize, Clone)]
278struct ChatToolCallPayload {
279 id: String,
280 #[serde(rename = "type")]
281 kind: &'static str,
282 function: ChatToolCallFunction,
283}
284
285#[derive(Debug, Serialize, Clone)]
286struct ChatToolCallFunction {
287 name: String,
288 arguments: String,
289}
290
291#[derive(Debug, Serialize, Clone)]
292struct ToolPayload {
293 r#type: &'static str,
294 function: ToolFunction,
295}
296
297#[derive(Debug, Serialize, Clone)]
298struct ToolFunction {
299 name: String,
300 description: String,
301 parameters: Value,
302}
303
304#[derive(Debug, Serialize)]
305#[serde(untagged)]
306enum ToolChoicePayload {
307 Mode(&'static str),
308 Function {
309 #[serde(rename = "type")]
310 kind: &'static str,
311 function: ToolChoiceFunction,
312 },
313}
314
315#[derive(Debug, Serialize, Clone)]
316struct ToolChoiceFunction {
317 name: String,
318}
319
320#[derive(Debug, Deserialize)]
321struct ChatCompletionChunk {
322 #[serde(default)]
323 usage: Option<ChatCompletionUsage>,
324 choices: Vec<ChunkChoice>,
325}
326
327#[derive(Debug, Deserialize, Clone, Default)]
328struct ChatCompletionUsage {
329 #[serde(default)]
330 prompt_tokens: Option<u32>,
331 #[serde(default)]
332 completion_tokens: Option<u32>,
333 #[serde(default)]
334 total_tokens: Option<u32>,
335 #[serde(default)]
336 prompt_tokens_details: Option<PromptTokenDetails>,
337 #[serde(default)]
338 completion_tokens_details: Option<CompletionTokenDetails>,
339}
340
341#[derive(Debug, Deserialize, Clone, Default)]
342struct PromptTokenDetails {
343 #[serde(default)]
344 cached_tokens: Option<u32>,
345}
346
347#[derive(Debug, Deserialize, Clone, Default)]
348struct CompletionTokenDetails {
349 #[serde(default)]
350 reasoning_tokens: Option<u32>,
351}
352
353#[derive(Debug, Deserialize)]
354struct ChunkChoice {
355 delta: ChunkDelta,
356 #[serde(default)]
357 #[allow(dead_code)]
358 finish_reason: Option<String>,
359}
360
361#[derive(Debug, Deserialize, Default)]
362struct ChunkDelta {
363 content: Option<String>,
364 tool_calls: Option<Vec<ChunkToolCall>>,
365}
366
367#[derive(Debug, Deserialize)]
368struct ChunkToolCall {
369 index: Option<usize>,
370 id: Option<String>,
371 function: Option<ChunkToolFunction>,
372}
373
374#[derive(Debug, Deserialize)]
375struct ChunkToolFunction {
376 name: Option<String>,
377 arguments: Option<String>,
378}
379
380async fn to_chat_messages(messages: &[Message]) -> Result<Vec<ChatMessagePayload>, String> {
383 let mut payloads = Vec::with_capacity(messages.len());
384 for msg in messages {
385 let (role, tool_calls, tool_call_id) = match msg.role() {
386 Role::System => ("system", None, None),
387 Role::User => ("user", None, None),
388 Role::Assistant => {
389 let calls = if msg.tool_calls().is_empty() {
390 None
391 } else {
392 Some(
393 msg.tool_calls()
394 .iter()
395 .map(|tc| ChatToolCallPayload {
396 id: tc.id.clone(),
397 kind: "function",
398 function: ChatToolCallFunction {
399 name: tc.name.clone(),
400 arguments: tc.arguments.to_string(),
401 },
402 })
403 .collect(),
404 )
405 };
406 ("assistant", calls, None)
407 }
408 Role::Tool => (
409 "tool",
410 None,
411 Some(msg.tool_call_id().unwrap_or_default().to_string()),
412 ),
413 };
414 payloads.push(ChatMessagePayload {
415 role,
416 content: build_content(msg).await?,
417 tool_calls,
418 tool_call_id,
419 });
420 }
421 Ok(payloads)
422}
423
424async fn build_content(message: &Message) -> Result<ContentPayload, String> {
425 let attachments = message.attachments();
426
427 if attachments.is_empty() {
428 return Ok(ContentPayload::Text(message.content().to_owned()));
429 }
430
431 let mut parts = Vec::with_capacity(attachments.len() + 1);
432 for attachment in attachments {
433 let media_type = attachment.media_type().as_ref();
434 if !media_type.starts_with("image/") {
435 return Err(format!(
436 "Copilot Chat Completions does not support attachment MIME type '{media_type}'"
437 ));
438 }
439 let data_url = url_to_data_url(attachment.url(), media_type).await?;
440 parts.push(ContentPart::ImageUrl {
441 image_url: ImageUrlPayload { url: data_url },
442 });
443 }
444
445 if !message.content().is_empty() {
446 parts.push(ContentPart::Text {
447 text: message.content().to_owned(),
448 });
449 }
450
451 Ok(ContentPayload::Parts(parts))
452}
453
454async fn url_to_data_url(url: &url::Url, media_type: &str) -> Result<String, String> {
455 match url.scheme() {
456 "data" | "http" | "https" => Ok(url.as_str().to_string()),
457 "file" => read_file_to_data_url(url, media_type).await,
458 scheme => Err(format!(
459 "Copilot does not support attachment URL scheme '{scheme}'"
460 )),
461 }
462}
463
464async fn read_file_to_data_url(url: &url::Url, media_type: &str) -> Result<String, String> {
465 use base64::Engine;
466
467 let path = url
468 .to_file_path()
469 .map_err(|()| "attachment file URL could not be converted to a path".to_string())?;
470 let data = async_fs::read(&path)
471 .await
472 .map_err(|error| format!("failed to read attachment '{}': {error}", path.display()))?;
473 let base64_data = base64::engine::general_purpose::STANDARD.encode(data);
474 Ok(format!("data:{media_type};base64,{base64_data}"))
475}
476
477fn convert_tools(tool_defs: &[ToolDefinition]) -> Vec<ToolPayload> {
478 tool_defs
479 .iter()
480 .map(|def| ToolPayload {
481 r#type: "function",
482 function: ToolFunction {
483 name: def.name().to_string(),
484 description: def.description().to_string(),
485 parameters: def.arguments_openai_schema(),
486 },
487 })
488 .collect()
489}
490
491fn tool_choice(params: &Parameters, has_tools: bool) -> Option<ToolChoicePayload> {
492 if !has_tools {
493 return None;
494 }
495 match ¶ms.tool_choice {
496 ToolChoice::Auto => Some(ToolChoicePayload::Mode("auto")),
497 ToolChoice::Required => Some(ToolChoicePayload::Mode("required")),
498 ToolChoice::None => Some(ToolChoicePayload::Mode("none")),
499 ToolChoice::Exact(name) => Some(ToolChoicePayload::Function {
500 kind: "function",
501 function: ToolChoiceFunction { name: name.clone() },
502 }),
503 }
504}
505
506fn filter_tool_definitions(defs: Vec<ToolDefinition>, choice: &ToolChoice) -> Vec<ToolDefinition> {
507 match choice {
508 ToolChoice::None => Vec::new(),
509 ToolChoice::Exact(name) => defs
510 .into_iter()
511 .filter(|tool| tool.name() == name)
512 .collect(),
513 ToolChoice::Auto | ToolChoice::Required => defs,
514 }
515}
516
517fn prompt_cache_retention(params: &Parameters) -> Option<&'static str> {
518 params
519 .cache
520 .openai
521 .as_ref()
522 .and_then(|cache| cache.retention)
523 .map(OpenAIPromptCacheRetention::as_str)
524}
525
526const SSE_FIRST_EVENT_TIMEOUT: Duration = Duration::from_secs(90);
529const SSE_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
530
531async fn open_sse_stream(
532 cfg: &Config,
533 request: &ChatCompletionRequest,
534) -> Result<zenwave::sse::SseStream, CopilotError> {
535 let endpoint = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
536 let mut backend = client();
537
538 let build_result = backend
539 .post(&endpoint)
540 .and_then(|b| {
541 b.header(
542 header::AUTHORIZATION.as_str(),
543 format!("Bearer {}", cfg.token),
544 )
545 })
546 .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-copilot/0.1"))
547 .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"))
548 .and_then(|b| b.header("editor-version", cfg.editor_version.clone()))
549 .and_then(|b| b.header("Copilot-Integration-Id", cfg.integration_id.clone()));
550
551 let builder = build_result.map_err(CopilotError::Http)?;
552
553 match builder
554 .json_body(request)
555 .map_err(CopilotError::Http)?
556 .sse()
557 .await
558 {
559 Ok(stream) => Ok(stream),
560 Err(zenwave::Error::Timeout) => Err(CopilotError::Timeout),
561 Err(e) => Err(CopilotError::Http(e)),
562 }
563}
564
565const fn is_unauthorized(err: &CopilotError) -> bool {
566 matches!(
567 err,
568 CopilotError::Http(zenwave::Error::Http { status, .. }) if status.as_u16() == 401
569 )
570}
571
572async fn refresh_session_config(cfg: &Config) -> Result<Option<Config>, CopilotError> {
573 let Some(oauth_token) = cfg.oauth_token.as_deref() else {
574 return Ok(None);
575 };
576
577 let session = crate::auth::get_session_token(oauth_token).await?;
578
579 let mut refreshed = cfg.clone();
580 refreshed.token = session.token;
581 refreshed.base_url = session.api_endpoint;
582
583 Ok(Some(refreshed))
584}
585
586async fn open_sse_stream_with_refresh(
587 cfg: &mut Config,
588 request: &ChatCompletionRequest,
589) -> Result<zenwave::sse::SseStream, CopilotError> {
590 match open_sse_stream(cfg, request).await {
591 Ok(stream) => Ok(stream),
592 Err(err) if is_unauthorized(&err) => match refresh_session_config(cfg).await? {
593 Some(refreshed) => {
594 *cfg = refreshed;
595 open_sse_stream(cfg, request).await
596 }
597 None => Err(err),
598 },
599 Err(err) => Err(err),
600 }
601}
602
603struct ProcessedSse {
604 events: Vec<Event>,
605 saw_payload: bool,
606 done: bool,
607}
608
609enum SseStep {
610 Event(Result<zenwave::sse::Event, zenwave::sse::ParseError>),
611 Closed,
612 IdleTimeout,
613 FirstEventTimeout,
614}
615
616async fn next_sse_event(
617 sse_stream: &mut zenwave::sse::SseStream,
618 saw_payload: bool,
619 last_progress: Instant,
620) -> SseStep {
621 let timeout = if saw_payload {
622 SSE_IDLE_TIMEOUT
623 } else {
624 SSE_FIRST_EVENT_TIMEOUT
625 };
626 let elapsed = Instant::now().saturating_duration_since(last_progress);
627 if elapsed >= timeout {
628 return if saw_payload {
629 SseStep::IdleTimeout
630 } else {
631 SseStep::FirstEventTimeout
632 };
633 }
634 futures_lite::future::race(
635 async {
636 sse_stream
637 .next()
638 .await
639 .map_or(SseStep::Closed, SseStep::Event)
640 },
641 async {
642 sleep(timeout.saturating_sub(elapsed)).await;
643 if saw_payload {
644 SseStep::IdleTimeout
645 } else {
646 SseStep::FirstEventTimeout
647 }
648 },
649 )
650 .await
651}
652
653fn process_sse_data(
654 data: &str,
655 usage: &mut Option<Usage>,
656 tool_calls: &mut HashMap<usize, ToolCallAccumulator>,
657 saw_finish: &mut bool,
658) -> Result<ProcessedSse, CopilotError> {
659 if data.is_empty() {
660 return Ok(ProcessedSse {
661 events: Vec::new(),
662 saw_payload: false,
663 done: false,
664 });
665 }
666 if data == "[DONE]" {
667 return Ok(ProcessedSse {
668 events: Vec::new(),
669 saw_payload: false,
670 done: true,
671 });
672 }
673 if let Ok(error_obj) = serde_json::from_str::<Value>(data)
674 && let Some(error) = error_obj.get("error")
675 {
676 let msg = error
677 .get("message")
678 .and_then(|m| m.as_str())
679 .unwrap_or("Unknown API error");
680 return Err(CopilotError::Api(msg.to_string()));
681 }
682
683 let chunk: ChatCompletionChunk = serde_json::from_str(data).map_err(CopilotError::Json)?;
684 let mut events = Vec::new();
685 let mut saw_payload = chunk.usage.is_some();
686 if let Some(chunk_usage) = &chunk.usage {
687 *usage = Some(usage_from_chat_completion(chunk_usage));
688 }
689 if !chunk.choices.is_empty()
690 && chunk
691 .choices
692 .iter()
693 .all(|choice| choice.finish_reason.is_some())
694 {
695 *saw_finish = true;
696 }
697 for choice in &chunk.choices {
698 if let Some(content) = &choice.delta.content
699 && !content.is_empty()
700 {
701 saw_payload = true;
702 events.push(Event::Text(content.clone()));
703 }
704 if let Some(calls) = &choice.delta.tool_calls
705 && !calls.is_empty()
706 {
707 saw_payload = true;
708 accumulate_tool_calls(calls, tool_calls);
709 }
710 }
711 Ok(ProcessedSse {
712 events,
713 saw_payload,
714 done: false,
715 })
716}
717
718fn accumulate_tool_calls(
719 calls: &[ChunkToolCall],
720 tool_calls: &mut HashMap<usize, ToolCallAccumulator>,
721) {
722 for call in calls {
723 let index = call.index.unwrap_or(0);
724 let acc = tool_calls.entry(index).or_default();
725 if let Some(id) = &call.id {
726 acc.id = Some(id.clone());
727 }
728 if let Some(function) = &call.function {
729 if let Some(name) = &function.name {
730 acc.name = Some(name.clone());
731 }
732 if let Some(args) = &function.arguments {
733 acc.arguments.push_str(args);
734 }
735 }
736 }
737}
738
739fn accumulated_tool_call_events(tool_calls: HashMap<usize, ToolCallAccumulator>) -> Vec<Event> {
740 let mut sorted_calls: Vec<_> = tool_calls.into_iter().collect();
741 sorted_calls.sort_by_key(|(index, _)| *index);
742 sorted_calls
743 .into_iter()
744 .filter_map(|(_, acc)| {
745 let (Some(id), Some(name)) = (acc.id, acc.name) else {
746 return None;
747 };
748 let arguments = if acc.arguments.is_empty() {
749 Value::Object(serde_json::Map::default())
750 } else {
751 serde_json::from_str(&acc.arguments)
752 .unwrap_or_else(|_| Value::Object(serde_json::Map::default()))
753 };
754 Some(Event::ToolCall(ToolCall {
755 id,
756 name,
757 arguments,
758 reasoning_state: None,
760 }))
761 })
762 .collect()
763}
764
765fn chat_completions_stream(
766 cfg: Arc<Config>,
767 payload_messages: Vec<ChatMessagePayload>,
768 params: &Parameters,
769 tools: Option<Vec<ToolPayload>>,
770) -> impl Stream<Item = Result<Event, CopilotError>> + Send + Unpin {
771 let params = params.clone();
772 Box::pin(chat_completions_stream_inner(
773 cfg,
774 payload_messages,
775 params,
776 tools,
777 ))
778}
779
780fn chat_completions_stream_inner(
781 cfg: Arc<Config>,
782 payload_messages: Vec<ChatMessagePayload>,
783 params: Parameters,
784 tools: Option<Vec<ToolPayload>>,
785) -> impl Stream<Item = Result<Event, CopilotError>> + Send {
786 async_stream::stream! {
787 let has_tools = tools.as_ref().is_some_and(|t| !t.is_empty());
788 let mut cfg = cfg.as_ref().clone();
789 let request = ChatCompletionRequest {
790 model: cfg.model.clone(),
791 messages: payload_messages,
792 stream: true,
793 stream_options: Some(StreamOptions {
794 include_usage: true,
795 }),
796 temperature: params.temperature,
797 top_p: params.top_p,
798 max_tokens: params.max_tokens,
799 tools,
800 tool_choice: tool_choice(¶ms, has_tools),
801 prompt_cache_key: params
802 .cache
803 .openai
804 .as_ref()
805 .and_then(|cache| cache.key.clone()),
806 prompt_cache_retention: prompt_cache_retention(¶ms),
807 };
808
809 tracing::debug!(
810 request = %serde_json::to_string_pretty(&request).unwrap_or_default(),
811 "Sending Copilot chat completion request"
812 );
813
814 let sse_stream = match open_sse_stream_with_refresh(&mut cfg, &request).await {
815 Ok(stream) => stream,
816 Err(err) => {
817 yield Err(err);
818 return;
819 }
820 };
821
822 let events = stream_chat_completion_events(sse_stream);
823 futures_lite::pin!(events);
824 while let Some(event) = events.next().await {
825 yield event;
826 }
827 }
828}
829
830fn stream_chat_completion_events(
831 sse_stream: zenwave::sse::SseStream,
832) -> impl Stream<Item = Result<Event, CopilotError>> + Send {
833 async_stream::stream! {
834 let mut event_count = 0usize;
835 let mut saw_finish = false;
836 let mut saw_payload = false;
837 let mut last_progress = Instant::now();
838 let mut tool_calls: HashMap<usize, ToolCallAccumulator> = HashMap::new();
839 let mut usage: Option<Usage> = None;
840
841 futures_lite::pin!(sse_stream);
842 loop {
843 let next = next_sse_event(&mut sse_stream, saw_payload, last_progress).await;
844 match next {
845 SseStep::Event(Ok(event)) => {
846 let data = event.text_data();
847 let data = data.trim();
848 if data.is_empty() {
849 continue;
850 }
851 tracing::trace!(sse_event = %data, "Received Copilot SSE event");
852 event_count += 1;
853 let processed = match process_sse_data(
854 data,
855 &mut usage,
856 &mut tool_calls,
857 &mut saw_finish,
858 ) {
859 Ok(processed) => processed,
860 Err(err) => {
861 yield Err(err);
862 return;
863 }
864 };
865 if processed.done {
866 break;
867 }
868 if processed.saw_payload {
869 saw_payload = true;
870 last_progress = Instant::now();
871 }
872 for event in processed.events {
873 yield Ok(event);
874 }
875 }
876 SseStep::Event(Err(err)) => {
877 yield Err(CopilotError::Stream(err));
878 return;
879 }
880 SseStep::Closed => break,
881 SseStep::IdleTimeout => {
882 tracing::warn!("Copilot SSE idle timeout; ending stream");
883 break;
884 }
885 SseStep::FirstEventTimeout => {
886 yield Err(CopilotError::Timeout);
887 return;
888 }
889 }
890 if saw_finish {
891 break;
892 }
893 }
894
895 tracing::debug!(event_count, "Processed Copilot SSE events");
896 for event in accumulated_tool_call_events(tool_calls) {
897 yield Ok(event);
898 }
899 if let Some(final_usage) = usage {
900 yield Ok(Event::Usage(final_usage));
901 }
902 }
903}
904
905#[derive(Debug, Default)]
906struct ToolCallAccumulator {
907 id: Option<String>,
908 name: Option<String>,
909 arguments: String,
910}
911
912fn usage_from_chat_completion(raw: &ChatCompletionUsage) -> Usage {
913 Usage {
914 prompt_tokens: raw.prompt_tokens,
915 completion_tokens: raw.completion_tokens,
916 total_tokens: raw.total_tokens,
917 reasoning_tokens: raw
918 .completion_tokens_details
919 .as_ref()
920 .and_then(|details| details.reasoning_tokens),
921 cache_read_tokens: raw
922 .prompt_tokens_details
923 .as_ref()
924 .and_then(|details| details.cached_tokens),
925 cache_write_tokens: None,
926 cost_usd: None,
927 stop_reason: None,
928 }
929}