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::Configuration => {
387 return Err("Copilot takes no per-message effort update".to_string());
388 }
389 Role::System => ("system", None, None),
390 Role::User => ("user", None, None),
391 Role::Assistant => {
392 let calls = if msg.tool_calls().is_empty() {
393 None
394 } else {
395 Some(
396 msg.tool_calls()
397 .iter()
398 .map(|tc| ChatToolCallPayload {
399 id: tc.id.clone(),
400 kind: "function",
401 function: ChatToolCallFunction {
402 name: tc.name.clone(),
403 arguments: tc.arguments.to_string(),
404 },
405 })
406 .collect(),
407 )
408 };
409 ("assistant", calls, None)
410 }
411 Role::Tool => (
412 "tool",
413 None,
414 Some(msg.tool_call_id().unwrap_or_default().to_string()),
415 ),
416 };
417 payloads.push(ChatMessagePayload {
418 role,
419 content: build_content(msg).await?,
420 tool_calls,
421 tool_call_id,
422 });
423 }
424 Ok(payloads)
425}
426
427async fn build_content(message: &Message) -> Result<ContentPayload, String> {
428 let attachments = message.attachments();
429
430 if attachments.is_empty() {
431 return Ok(ContentPayload::Text(message.content().to_owned()));
432 }
433
434 let mut parts = Vec::with_capacity(attachments.len() + 1);
435 for attachment in attachments {
436 let media_type = attachment.media_type().as_ref();
437 if !media_type.starts_with("image/") {
438 return Err(format!(
439 "Copilot Chat Completions does not support attachment MIME type '{media_type}'"
440 ));
441 }
442 let data_url = url_to_data_url(attachment.url(), media_type).await?;
443 parts.push(ContentPart::ImageUrl {
444 image_url: ImageUrlPayload { url: data_url },
445 });
446 }
447
448 if !message.content().is_empty() {
449 parts.push(ContentPart::Text {
450 text: message.content().to_owned(),
451 });
452 }
453
454 Ok(ContentPayload::Parts(parts))
455}
456
457async fn url_to_data_url(url: &url::Url, media_type: &str) -> Result<String, String> {
458 match url.scheme() {
459 "data" | "http" | "https" => Ok(url.as_str().to_string()),
460 "file" => read_file_to_data_url(url, media_type).await,
461 scheme => Err(format!(
462 "Copilot does not support attachment URL scheme '{scheme}'"
463 )),
464 }
465}
466
467async fn read_file_to_data_url(url: &url::Url, media_type: &str) -> Result<String, String> {
468 use base64::Engine;
469
470 let path = url
471 .to_file_path()
472 .map_err(|()| "attachment file URL could not be converted to a path".to_string())?;
473 let data = async_fs::read(&path)
474 .await
475 .map_err(|error| format!("failed to read attachment '{}': {error}", path.display()))?;
476 let base64_data = base64::engine::general_purpose::STANDARD.encode(data);
477 Ok(format!("data:{media_type};base64,{base64_data}"))
478}
479
480fn convert_tools(tool_defs: &[ToolDefinition]) -> Vec<ToolPayload> {
481 tool_defs
482 .iter()
483 .map(|def| ToolPayload {
484 r#type: "function",
485 function: ToolFunction {
486 name: def.name().to_string(),
487 description: def.description().to_string(),
488 parameters: def.arguments_openai_schema(),
489 },
490 })
491 .collect()
492}
493
494fn tool_choice(params: &Parameters, has_tools: bool) -> Option<ToolChoicePayload> {
495 if !has_tools {
496 return None;
497 }
498 match ¶ms.tool_choice {
499 ToolChoice::Auto => Some(ToolChoicePayload::Mode("auto")),
500 ToolChoice::Required => Some(ToolChoicePayload::Mode("required")),
501 ToolChoice::None => Some(ToolChoicePayload::Mode("none")),
502 ToolChoice::Exact(name) => Some(ToolChoicePayload::Function {
503 kind: "function",
504 function: ToolChoiceFunction { name: name.clone() },
505 }),
506 }
507}
508
509fn filter_tool_definitions(defs: Vec<ToolDefinition>, choice: &ToolChoice) -> Vec<ToolDefinition> {
510 match choice {
511 ToolChoice::None => Vec::new(),
512 ToolChoice::Exact(name) => defs
513 .into_iter()
514 .filter(|tool| tool.name() == name)
515 .collect(),
516 ToolChoice::Auto | ToolChoice::Required => defs,
517 }
518}
519
520fn prompt_cache_retention(params: &Parameters) -> Option<&'static str> {
521 params
522 .cache
523 .openai
524 .as_ref()
525 .and_then(|cache| cache.retention)
526 .map(OpenAIPromptCacheRetention::as_str)
527}
528
529const SSE_FIRST_EVENT_TIMEOUT: Duration = Duration::from_secs(90);
532const SSE_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
533
534async fn open_sse_stream(
535 cfg: &Config,
536 request: &ChatCompletionRequest,
537) -> Result<zenwave::sse::SseStream, CopilotError> {
538 let endpoint = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
539 let mut backend = client();
540
541 let build_result = backend
542 .post(&endpoint)
543 .and_then(|b| {
544 b.header(
545 header::AUTHORIZATION.as_str(),
546 format!("Bearer {}", cfg.token),
547 )
548 })
549 .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-copilot/0.1"))
550 .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"))
551 .and_then(|b| b.header("editor-version", cfg.editor_version.clone()))
552 .and_then(|b| b.header("Copilot-Integration-Id", cfg.integration_id.clone()));
553
554 let builder = build_result.map_err(CopilotError::Http)?;
555
556 match builder
557 .json_body(request)
558 .map_err(CopilotError::Http)?
559 .sse()
560 .await
561 {
562 Ok(stream) => Ok(stream),
563 Err(zenwave::Error::Timeout) => Err(CopilotError::Timeout),
564 Err(e) => Err(CopilotError::Http(e)),
565 }
566}
567
568const fn is_unauthorized(err: &CopilotError) -> bool {
569 matches!(
570 err,
571 CopilotError::Http(zenwave::Error::Http { status, .. }) if status.as_u16() == 401
572 )
573}
574
575async fn refresh_session_config(cfg: &Config) -> Result<Option<Config>, CopilotError> {
576 let Some(oauth_token) = cfg.oauth_token.as_deref() else {
577 return Ok(None);
578 };
579
580 let session = crate::auth::get_session_token(oauth_token).await?;
581
582 let mut refreshed = cfg.clone();
583 refreshed.token = session.token;
584 refreshed.base_url = session.api_endpoint;
585
586 Ok(Some(refreshed))
587}
588
589async fn open_sse_stream_with_refresh(
590 cfg: &mut Config,
591 request: &ChatCompletionRequest,
592) -> Result<zenwave::sse::SseStream, CopilotError> {
593 match open_sse_stream(cfg, request).await {
594 Ok(stream) => Ok(stream),
595 Err(err) if is_unauthorized(&err) => match refresh_session_config(cfg).await? {
596 Some(refreshed) => {
597 *cfg = refreshed;
598 open_sse_stream(cfg, request).await
599 }
600 None => Err(err),
601 },
602 Err(err) => Err(err),
603 }
604}
605
606struct ProcessedSse {
607 events: Vec<Event>,
608 saw_payload: bool,
609 done: bool,
610}
611
612enum SseStep {
613 Event(Result<zenwave::sse::Event, zenwave::sse::ParseError>),
614 Closed,
615 IdleTimeout,
616 FirstEventTimeout,
617}
618
619async fn next_sse_event(
620 sse_stream: &mut zenwave::sse::SseStream,
621 saw_payload: bool,
622 last_progress: Instant,
623) -> SseStep {
624 let timeout = if saw_payload {
625 SSE_IDLE_TIMEOUT
626 } else {
627 SSE_FIRST_EVENT_TIMEOUT
628 };
629 let elapsed = Instant::now().saturating_duration_since(last_progress);
630 if elapsed >= timeout {
631 return if saw_payload {
632 SseStep::IdleTimeout
633 } else {
634 SseStep::FirstEventTimeout
635 };
636 }
637 futures_lite::future::race(
638 async {
639 sse_stream
640 .next()
641 .await
642 .map_or(SseStep::Closed, SseStep::Event)
643 },
644 async {
645 sleep(timeout.saturating_sub(elapsed)).await;
646 if saw_payload {
647 SseStep::IdleTimeout
648 } else {
649 SseStep::FirstEventTimeout
650 }
651 },
652 )
653 .await
654}
655
656fn process_sse_data(
657 data: &str,
658 usage: &mut Option<Usage>,
659 tool_calls: &mut HashMap<usize, ToolCallAccumulator>,
660) -> Result<ProcessedSse, CopilotError> {
661 if data.is_empty() {
662 return Ok(ProcessedSse {
663 events: Vec::new(),
664 saw_payload: false,
665 done: false,
666 });
667 }
668 if data == "[DONE]" {
669 return Ok(ProcessedSse {
670 events: Vec::new(),
671 saw_payload: false,
672 done: true,
673 });
674 }
675 if let Ok(error_obj) = serde_json::from_str::<Value>(data)
676 && let Some(error) = error_obj.get("error")
677 {
678 let msg = error
679 .get("message")
680 .and_then(|m| m.as_str())
681 .unwrap_or("Unknown API error");
682 return Err(CopilotError::Api(msg.to_string()));
683 }
684
685 let chunk: ChatCompletionChunk = serde_json::from_str(data).map_err(CopilotError::Json)?;
686 let mut events = Vec::new();
687 let mut saw_payload = chunk.usage.is_some();
688 if let Some(chunk_usage) = &chunk.usage {
689 *usage = Some(usage_from_chat_completion(chunk_usage));
690 }
691 for choice in &chunk.choices {
692 if let Some(content) = &choice.delta.content
693 && !content.is_empty()
694 {
695 saw_payload = true;
696 events.push(Event::Text(content.clone()));
697 }
698 if let Some(calls) = &choice.delta.tool_calls
699 && !calls.is_empty()
700 {
701 saw_payload = true;
702 accumulate_tool_calls(calls, tool_calls);
703 }
704 }
705 Ok(ProcessedSse {
706 events,
707 saw_payload,
708 done: false,
709 })
710}
711
712fn accumulate_tool_calls(
713 calls: &[ChunkToolCall],
714 tool_calls: &mut HashMap<usize, ToolCallAccumulator>,
715) {
716 for call in calls {
717 let index = call.index.unwrap_or(0);
718 let acc = tool_calls.entry(index).or_default();
719 if let Some(id) = &call.id {
720 acc.id = Some(id.clone());
721 }
722 if let Some(function) = &call.function {
723 if let Some(name) = &function.name {
724 acc.name = Some(name.clone());
725 }
726 if let Some(args) = &function.arguments {
727 acc.arguments.push_str(args);
728 }
729 }
730 }
731}
732
733fn accumulated_tool_call_events(tool_calls: HashMap<usize, ToolCallAccumulator>) -> Vec<Event> {
734 let mut sorted_calls: Vec<_> = tool_calls.into_iter().collect();
735 sorted_calls.sort_by_key(|(index, _)| *index);
736 sorted_calls
737 .into_iter()
738 .filter_map(|(_, acc)| {
739 let (Some(id), Some(name)) = (acc.id, acc.name) else {
740 return None;
741 };
742 let arguments = if acc.arguments.is_empty() {
743 Value::Object(serde_json::Map::default())
744 } else {
745 serde_json::from_str(&acc.arguments)
746 .unwrap_or_else(|_| Value::Object(serde_json::Map::default()))
747 };
748 Some(Event::ToolCall(ToolCall {
749 id,
750 name,
751 arguments,
752 reasoning_state: None,
754 }))
755 })
756 .collect()
757}
758
759fn chat_completions_stream(
760 cfg: Arc<Config>,
761 payload_messages: Vec<ChatMessagePayload>,
762 params: &Parameters,
763 tools: Option<Vec<ToolPayload>>,
764) -> impl Stream<Item = Result<Event, CopilotError>> + Send + Unpin {
765 let params = params.clone();
766 Box::pin(chat_completions_stream_inner(
767 cfg,
768 payload_messages,
769 params,
770 tools,
771 ))
772}
773
774fn chat_completions_stream_inner(
775 cfg: Arc<Config>,
776 payload_messages: Vec<ChatMessagePayload>,
777 params: Parameters,
778 tools: Option<Vec<ToolPayload>>,
779) -> impl Stream<Item = Result<Event, CopilotError>> + Send {
780 async_stream::stream! {
781 let has_tools = tools.as_ref().is_some_and(|t| !t.is_empty());
782 let mut cfg = cfg.as_ref().clone();
783 let request = ChatCompletionRequest {
784 model: cfg.model.clone(),
785 messages: payload_messages,
786 stream: true,
787 stream_options: Some(StreamOptions {
788 include_usage: true,
789 }),
790 temperature: params.temperature,
791 top_p: params.top_p,
792 max_tokens: params.max_tokens,
793 tools,
794 tool_choice: tool_choice(¶ms, has_tools),
795 prompt_cache_key: params
796 .cache
797 .openai
798 .as_ref()
799 .and_then(|cache| cache.key.clone()),
800 prompt_cache_retention: prompt_cache_retention(¶ms),
801 };
802
803 tracing::debug!(
804 request = %serde_json::to_string_pretty(&request).unwrap_or_default(),
805 "Sending Copilot chat completion request"
806 );
807
808 let sse_stream = match open_sse_stream_with_refresh(&mut cfg, &request).await {
809 Ok(stream) => stream,
810 Err(err) => {
811 yield Err(err);
812 return;
813 }
814 };
815
816 let events = stream_chat_completion_events(sse_stream);
817 futures_lite::pin!(events);
818 while let Some(event) = events.next().await {
819 yield event;
820 }
821 }
822}
823
824fn stream_chat_completion_events(
825 sse_stream: zenwave::sse::SseStream,
826) -> impl Stream<Item = Result<Event, CopilotError>> + Send {
827 async_stream::stream! {
828 let mut event_count = 0usize;
829 let mut saw_payload = false;
830 let mut last_progress = Instant::now();
831 let mut tool_calls: HashMap<usize, ToolCallAccumulator> = HashMap::new();
832 let mut usage: Option<Usage> = None;
833
834 futures_lite::pin!(sse_stream);
835 loop {
836 let next = next_sse_event(&mut sse_stream, saw_payload, last_progress).await;
837 match next {
838 SseStep::Event(Ok(event)) => {
839 let data = event.text_data();
840 let data = data.trim();
841 if data.is_empty() {
842 continue;
843 }
844 tracing::trace!(sse_event = %data, "Received Copilot SSE event");
845 event_count += 1;
846 let processed = match process_sse_data(data, &mut usage, &mut tool_calls) {
849 Ok(processed) => processed,
850 Err(err) => {
851 yield Err(err);
852 return;
853 }
854 };
855 if processed.done {
856 break;
857 }
858 if processed.saw_payload {
859 saw_payload = true;
860 last_progress = Instant::now();
861 }
862 for event in processed.events {
863 yield Ok(event);
864 }
865 }
866 SseStep::Event(Err(err)) => {
867 yield Err(CopilotError::Stream(err));
868 return;
869 }
870 SseStep::Closed => break,
871 SseStep::IdleTimeout => {
872 tracing::warn!("Copilot SSE idle timeout; ending stream");
873 break;
874 }
875 SseStep::FirstEventTimeout => {
876 yield Err(CopilotError::Timeout);
877 return;
878 }
879 }
880 }
881
882 tracing::debug!(event_count, "Processed Copilot SSE events");
883 for event in accumulated_tool_call_events(tool_calls) {
884 yield Ok(event);
885 }
886 if let Some(final_usage) = usage {
887 yield Ok(Event::Usage(final_usage));
888 }
889 }
890}
891
892#[derive(Debug, Default)]
893struct ToolCallAccumulator {
894 id: Option<String>,
895 name: Option<String>,
896 arguments: String,
897}
898
899fn usage_from_chat_completion(raw: &ChatCompletionUsage) -> Usage {
900 Usage {
901 prompt_tokens: raw.prompt_tokens,
902 completion_tokens: raw.completion_tokens,
903 total_tokens: raw.total_tokens,
904 reasoning_tokens: raw
905 .completion_tokens_details
906 .as_ref()
907 .and_then(|details| details.reasoning_tokens),
908 cache_read_tokens: raw
909 .prompt_tokens_details
910 .as_ref()
911 .and_then(|details| details.cached_tokens),
912 cache_write_tokens: None,
913 cost_usd: None,
914 stop_reason: None,
915 }
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921 use zenwave::ResponseExt;
922
923 fn sse_body(chunks: &[&str]) -> zenwave::sse::SseStream {
924 let body = chunks
925 .iter()
926 .map(|data| format!("data: {data}\n\n"))
927 .collect::<Vec<_>>()
928 .concat();
929 zenwave::Response::new(body.into()).into_sse()
930 }
931
932 #[tokio::test]
935 async fn usage_after_finish_reason_is_reported() {
936 let stream = sse_body(&[
937 r#"{"choices":[{"index":0,"delta":{"content":"Hel"},"finish_reason":null}]}"#,
938 r#"{"choices":[{"index":0,"delta":{"content":"lo"},"finish_reason":null}]}"#,
939 r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#,
940 r#"{"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18,"prompt_tokens_details":{"cached_tokens":3},"completion_tokens_details":{"reasoning_tokens":2}}}"#,
941 "[DONE]",
942 ]);
943 let events: Vec<Event> = stream_chat_completion_events(stream)
944 .map(|event| event.expect("stream event"))
945 .collect()
946 .await;
947
948 let text: String = events
949 .iter()
950 .filter_map(|event| match event {
951 Event::Text(text) => Some(text.as_str()),
952 _ => None,
953 })
954 .collect();
955 assert_eq!(text, "Hello");
956 let Some(Event::Usage(usage)) = events.last() else {
957 panic!("expected trailing usage event, got {events:?}");
958 };
959 assert_eq!(usage.prompt_tokens, Some(11));
960 assert_eq!(usage.completion_tokens, Some(7));
961 assert_eq!(usage.total_tokens, Some(18));
962 assert_eq!(usage.reasoning_tokens, Some(2));
963 assert_eq!(usage.cache_read_tokens, Some(3));
964 }
965}