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::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 &params.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
529// === Streaming ===
530
531const 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                // Chat Completions carries no reasoning signature to round-trip.
753                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(&params, 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(&params),
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                    // A `finish_reason` only ends the content: with `include_usage`
847                    // the usage arrives in a later chunk, before `[DONE]`.
848                    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    /// With `include_usage` the finish chunk is followed by a usage chunk with
933    /// empty `choices`, then `[DONE]`.
934    #[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}