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::{
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/// 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            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, &parameters, 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            // Try to get context window from models database
115            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/// Builder for [`Copilot`] clients.
150#[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    /// Set a custom base URL.
173    #[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    /// Set the model identifier.
180    #[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    /// Set the editor version header.
187    #[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    /// Set the integration ID header.
194    #[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    /// Provide an OAuth token so the client can refresh session tokens on 401s.
201    #[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    /// Build the Copilot client.
208    #[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// === Request/Response Types ===
224
225#[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
387// === Conversion Functions ===
388
389async 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 &params.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
533// === Streaming ===
534
535const 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                // Chat Completions carries no reasoning signature to round-trip.
766                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(&params, 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(&params),
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}