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