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) -> Result<ProcessedSse, CopilotError> {
658 if data.is_empty() {
659 return Ok(ProcessedSse {
660 events: Vec::new(),
661 saw_payload: false,
662 done: false,
663 });
664 }
665 if data == "[DONE]" {
666 return Ok(ProcessedSse {
667 events: Vec::new(),
668 saw_payload: false,
669 done: true,
670 });
671 }
672 if let Ok(error_obj) = serde_json::from_str::<Value>(data)
673 && let Some(error) = error_obj.get("error")
674 {
675 let msg = error
676 .get("message")
677 .and_then(|m| m.as_str())
678 .unwrap_or("Unknown API error");
679 return Err(CopilotError::Api(msg.to_string()));
680 }
681
682 let chunk: ChatCompletionChunk = serde_json::from_str(data).map_err(CopilotError::Json)?;
683 let mut events = Vec::new();
684 let mut saw_payload = chunk.usage.is_some();
685 if let Some(chunk_usage) = &chunk.usage {
686 *usage = Some(usage_from_chat_completion(chunk_usage));
687 }
688 for choice in &chunk.choices {
689 if let Some(content) = &choice.delta.content
690 && !content.is_empty()
691 {
692 saw_payload = true;
693 events.push(Event::Text(content.clone()));
694 }
695 if let Some(calls) = &choice.delta.tool_calls
696 && !calls.is_empty()
697 {
698 saw_payload = true;
699 accumulate_tool_calls(calls, tool_calls);
700 }
701 }
702 Ok(ProcessedSse {
703 events,
704 saw_payload,
705 done: false,
706 })
707}
708
709fn accumulate_tool_calls(
710 calls: &[ChunkToolCall],
711 tool_calls: &mut HashMap<usize, ToolCallAccumulator>,
712) {
713 for call in calls {
714 let index = call.index.unwrap_or(0);
715 let acc = tool_calls.entry(index).or_default();
716 if let Some(id) = &call.id {
717 acc.id = Some(id.clone());
718 }
719 if let Some(function) = &call.function {
720 if let Some(name) = &function.name {
721 acc.name = Some(name.clone());
722 }
723 if let Some(args) = &function.arguments {
724 acc.arguments.push_str(args);
725 }
726 }
727 }
728}
729
730fn accumulated_tool_call_events(tool_calls: HashMap<usize, ToolCallAccumulator>) -> Vec<Event> {
731 let mut sorted_calls: Vec<_> = tool_calls.into_iter().collect();
732 sorted_calls.sort_by_key(|(index, _)| *index);
733 sorted_calls
734 .into_iter()
735 .filter_map(|(_, acc)| {
736 let (Some(id), Some(name)) = (acc.id, acc.name) else {
737 return None;
738 };
739 let arguments = if acc.arguments.is_empty() {
740 Value::Object(serde_json::Map::default())
741 } else {
742 serde_json::from_str(&acc.arguments)
743 .unwrap_or_else(|_| Value::Object(serde_json::Map::default()))
744 };
745 Some(Event::ToolCall(ToolCall {
746 id,
747 name,
748 arguments,
749 reasoning_state: None,
751 }))
752 })
753 .collect()
754}
755
756fn chat_completions_stream(
757 cfg: Arc<Config>,
758 payload_messages: Vec<ChatMessagePayload>,
759 params: &Parameters,
760 tools: Option<Vec<ToolPayload>>,
761) -> impl Stream<Item = Result<Event, CopilotError>> + Send + Unpin {
762 let params = params.clone();
763 Box::pin(chat_completions_stream_inner(
764 cfg,
765 payload_messages,
766 params,
767 tools,
768 ))
769}
770
771fn chat_completions_stream_inner(
772 cfg: Arc<Config>,
773 payload_messages: Vec<ChatMessagePayload>,
774 params: Parameters,
775 tools: Option<Vec<ToolPayload>>,
776) -> impl Stream<Item = Result<Event, CopilotError>> + Send {
777 async_stream::stream! {
778 let has_tools = tools.as_ref().is_some_and(|t| !t.is_empty());
779 let mut cfg = cfg.as_ref().clone();
780 let request = ChatCompletionRequest {
781 model: cfg.model.clone(),
782 messages: payload_messages,
783 stream: true,
784 stream_options: Some(StreamOptions {
785 include_usage: true,
786 }),
787 temperature: params.temperature,
788 top_p: params.top_p,
789 max_tokens: params.max_tokens,
790 tools,
791 tool_choice: tool_choice(¶ms, has_tools),
792 prompt_cache_key: params
793 .cache
794 .openai
795 .as_ref()
796 .and_then(|cache| cache.key.clone()),
797 prompt_cache_retention: prompt_cache_retention(¶ms),
798 };
799
800 tracing::debug!(
801 request = %serde_json::to_string_pretty(&request).unwrap_or_default(),
802 "Sending Copilot chat completion request"
803 );
804
805 let sse_stream = match open_sse_stream_with_refresh(&mut cfg, &request).await {
806 Ok(stream) => stream,
807 Err(err) => {
808 yield Err(err);
809 return;
810 }
811 };
812
813 let events = stream_chat_completion_events(sse_stream);
814 futures_lite::pin!(events);
815 while let Some(event) = events.next().await {
816 yield event;
817 }
818 }
819}
820
821fn stream_chat_completion_events(
822 sse_stream: zenwave::sse::SseStream,
823) -> impl Stream<Item = Result<Event, CopilotError>> + Send {
824 async_stream::stream! {
825 let mut event_count = 0usize;
826 let mut saw_payload = false;
827 let mut last_progress = Instant::now();
828 let mut tool_calls: HashMap<usize, ToolCallAccumulator> = HashMap::new();
829 let mut usage: Option<Usage> = None;
830
831 futures_lite::pin!(sse_stream);
832 loop {
833 let next = next_sse_event(&mut sse_stream, saw_payload, last_progress).await;
834 match next {
835 SseStep::Event(Ok(event)) => {
836 let data = event.text_data();
837 let data = data.trim();
838 if data.is_empty() {
839 continue;
840 }
841 tracing::trace!(sse_event = %data, "Received Copilot SSE event");
842 event_count += 1;
843 let processed = match process_sse_data(data, &mut usage, &mut tool_calls) {
846 Ok(processed) => processed,
847 Err(err) => {
848 yield Err(err);
849 return;
850 }
851 };
852 if processed.done {
853 break;
854 }
855 if processed.saw_payload {
856 saw_payload = true;
857 last_progress = Instant::now();
858 }
859 for event in processed.events {
860 yield Ok(event);
861 }
862 }
863 SseStep::Event(Err(err)) => {
864 yield Err(CopilotError::Stream(err));
865 return;
866 }
867 SseStep::Closed => break,
868 SseStep::IdleTimeout => {
869 tracing::warn!("Copilot SSE idle timeout; ending stream");
870 break;
871 }
872 SseStep::FirstEventTimeout => {
873 yield Err(CopilotError::Timeout);
874 return;
875 }
876 }
877 }
878
879 tracing::debug!(event_count, "Processed Copilot SSE events");
880 for event in accumulated_tool_call_events(tool_calls) {
881 yield Ok(event);
882 }
883 if let Some(final_usage) = usage {
884 yield Ok(Event::Usage(final_usage));
885 }
886 }
887}
888
889#[derive(Debug, Default)]
890struct ToolCallAccumulator {
891 id: Option<String>,
892 name: Option<String>,
893 arguments: String,
894}
895
896fn usage_from_chat_completion(raw: &ChatCompletionUsage) -> Usage {
897 Usage {
898 prompt_tokens: raw.prompt_tokens,
899 completion_tokens: raw.completion_tokens,
900 total_tokens: raw.total_tokens,
901 reasoning_tokens: raw
902 .completion_tokens_details
903 .as_ref()
904 .and_then(|details| details.reasoning_tokens),
905 cache_read_tokens: raw
906 .prompt_tokens_details
907 .as_ref()
908 .and_then(|details| details.cached_tokens),
909 cache_write_tokens: None,
910 cost_usd: None,
911 stop_reason: None,
912 }
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918 use zenwave::ResponseExt;
919
920 fn sse_body(chunks: &[&str]) -> zenwave::sse::SseStream {
921 let body = chunks
922 .iter()
923 .map(|data| format!("data: {data}\n\n"))
924 .collect::<Vec<_>>()
925 .concat();
926 zenwave::Response::new(body.into()).into_sse()
927 }
928
929 #[tokio::test]
932 async fn usage_after_finish_reason_is_reported() {
933 let stream = sse_body(&[
934 r#"{"choices":[{"index":0,"delta":{"content":"Hel"},"finish_reason":null}]}"#,
935 r#"{"choices":[{"index":0,"delta":{"content":"lo"},"finish_reason":null}]}"#,
936 r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#,
937 r#"{"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18,"prompt_tokens_details":{"cached_tokens":3},"completion_tokens_details":{"reasoning_tokens":2}}}"#,
938 "[DONE]",
939 ]);
940 let events: Vec<Event> = stream_chat_completion_events(stream)
941 .map(|event| event.expect("stream event"))
942 .collect()
943 .await;
944
945 let text: String = events
946 .iter()
947 .filter_map(|event| match event {
948 Event::Text(text) => Some(text.as_str()),
949 _ => None,
950 })
951 .collect();
952 assert_eq!(text, "Hello");
953 let Some(Event::Usage(usage)) = events.last() else {
954 panic!("expected trailing usage event, got {events:?}");
955 };
956 assert_eq!(usage.prompt_tokens, Some(11));
957 assert_eq!(usage.completion_tokens, Some(7));
958 assert_eq!(usage.total_tokens, Some(18));
959 assert_eq!(usage.reasoning_tokens, Some(2));
960 assert_eq!(usage.cache_read_tokens, Some(3));
961 }
962}