Skip to main content

aither_copilot/
client.rs

1//! GitHub Copilot client implementation.
2
3use 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/// GitHub Copilot language model client.
30///
31/// Uses the OpenAI-compatible chat completions API at `api.githubcopilot.com`.
32#[derive(Clone, Debug)]
33pub struct Copilot {
34    inner: Arc<Config>,
35}
36
37impl Copilot {
38    /// Create a new Copilot client with the given OAuth token.
39    pub fn new(token: impl Into<String>) -> Self {
40        Self::builder(token).build()
41    }
42
43    /// Create a builder for configuring the Copilot client.
44    #[must_use]
45    pub fn builder(token: impl Into<String>) -> Builder {
46        Builder::new(token)
47    }
48
49    /// Override the default chat model.
50    #[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    /// Override the REST base URL.
57    #[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    /// Provide an OAuth token so the client can refresh session tokens on 401s.
64    #[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, &parameters.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, &parameters, 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            // Try to get context window from models database
110            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/// Builder for [`Copilot`] clients.
143#[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    /// Set a custom base URL.
166    #[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    /// Set the model identifier.
173    #[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    /// Set the editor version header.
180    #[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    /// Set the integration ID header.
187    #[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    /// Provide an OAuth token so the client can refresh session tokens on 401s.
194    #[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    /// Build the Copilot client.
201    #[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// === Request/Response Types ===
217
218#[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
380// === Conversion Functions ===
381
382async 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 &params.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
526// === Streaming ===
527
528const 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                // Chat Completions carries no reasoning signature to round-trip.
750                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(&params, 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(&params),
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                    // A `finish_reason` only ends the content: with `include_usage`
844                    // the usage arrives in a later chunk, before `[DONE]`.
845                    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    /// With `include_usage` the finish chunk is followed by a usage chunk with
930    /// empty `choices`, then `[DONE]`.
931    #[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}