Skip to main content

agent_framework_github_copilot/
lib.rs

1//! # agent-framework-github-copilot
2//!
3//! A [GitHub Copilot](https://github.com/features/copilot) chat client for
4//! `agent-framework-rs`.
5//!
6//! GitHub Copilot's chat endpoint is OpenAI Chat-Completions-compatible
7//! (`POST {base_url}/chat/completions`, default base URL
8//! `https://api.githubcopilot.com`), so request/response conversion is reused
9//! from [`agent_framework_openai::convert`] rather than duplicated — the same
10//! streaming approach used by `agent-framework-ollama` and
11//! `agent-framework-foundry-local` (see [`convert`] for the small,
12//! Copilot-specific streaming-delta parser that *is* implemented locally).
13//!
14//! Unlike those two clients, Copilot's endpoint is not directly reachable
15//! with a plain API key: callers supply a long-lived GitHub OAuth token or
16//! personal access token (the `github_token`), and before every request this
17//! client transparently exchanges it for a short-lived Copilot API bearer
18//! token via `GET https://api.github.com/copilot_internal/v2/token`
19//! (`Authorization: token <github_token>`). The exchanged token is cached in
20//! the client and only re-fetched once it is missing or within about 60
21//! seconds of its `expires_at`. Every `/chat/completions` request also
22//! carries two headers the Copilot API requires beyond the OpenAI-compatible
23//! shape: `Editor-Version` and `Copilot-Integration-Id`; requests missing
24//! them are rejected by the real service.
25//!
26//! ```no_run
27//! use agent_framework_github_copilot::GitHubCopilotChatClient;
28//! use agent_framework_core::prelude::*;
29//!
30//! # async fn demo() -> Result<()> {
31//! let client = GitHubCopilotChatClient::new("gho_examplegithubtoken", "gpt-4o");
32//! let agent = Agent::builder(client)
33//!     .instructions("You are concise.")
34//!     .build();
35//! let reply = agent.run_once("Say hi").await?;
36//! println!("{}", reply.text());
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! Pointing at a non-default (e.g. enterprise or proxied) endpoint:
42//!
43//! ```no_run
44//! use agent_framework_github_copilot::GitHubCopilotChatClient;
45//!
46//! let client = GitHubCopilotChatClient::new("gho_examplegithubtoken", "gpt-4o")
47//!     .with_base_url("https://copilot-proxy.example.internal");
48//! # let _ = client;
49//! ```
50
51pub mod convert;
52
53use std::collections::{HashMap, VecDeque};
54use std::sync::Arc;
55
56use agent_framework_core::client::{ChatClient, ChatStream};
57use agent_framework_core::error::{Error, Result};
58use agent_framework_core::streaming::Utf8StreamDecoder;
59use agent_framework_core::types::{ChatOptions, ChatResponse, ChatResponseUpdate, Message};
60use futures::StreamExt;
61use serde_json::Value;
62use tokio::sync::Mutex;
63
64/// The default GitHub Copilot chat-completions base URL.
65const DEFAULT_BASE_URL: &str = "https://api.githubcopilot.com";
66
67/// The GitHub token-exchange endpoint used to trade a long-lived GitHub OAuth
68/// token / PAT for a short-lived Copilot API bearer token.
69const TOKEN_EXCHANGE_URL: &str = "https://api.github.com/copilot_internal/v2/token";
70
71/// The `Editor-Version` header value sent on every `/chat/completions`
72/// request. The Copilot API requires this header to be present; its exact
73/// value is not validated beyond being well-formed, but it identifies this
74/// crate as the calling "editor".
75const EDITOR_VERSION: &str = "agent-framework-rs/0.1.0";
76
77/// The `Copilot-Integration-Id` header value sent on every
78/// `/chat/completions` request. The Copilot API requires this header to be
79/// present to authorize the request as coming from a chat surface.
80const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
81
82/// A cached Copilot API token is refreshed once fewer than this many seconds
83/// remain before its `expires_at`, to avoid a token expiring mid-flight.
84const TOKEN_REFRESH_MARGIN_SECS: i64 = 60;
85
86/// The environment variable [`GitHubCopilotChatClient::from_env`] reads for
87/// the long-lived GitHub OAuth token / PAT.
88const GITHUB_TOKEN_ENV: &str = "GITHUB_COPILOT_TOKEN";
89
90/// An alternate environment variable [`GitHubCopilotChatClient::from_env`]
91/// falls back to when [`GITHUB_TOKEN_ENV`] is unset.
92const GITHUB_TOKEN_ENV_ALT: &str = "GH_COPILOT_TOKEN";
93
94/// The environment variable [`GitHubCopilotChatClient::from_env`] reads for a
95/// non-default base URL (e.g. an enterprise deployment or a proxy in front of
96/// the Copilot API).
97const BASE_URL_ENV: &str = "GITHUB_COPILOT_BASE_URL";
98
99/// Parse a `Retry-After` header into a delay in seconds. Mirrors the
100/// OpenAI/Anthropic/Azure/Ollama/Foundry Local clients (Copilot's
101/// OpenAI-compatibility layer doesn't generally emit this, but is tolerated
102/// if a proxy in front of it does).
103fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<f64> {
104    headers
105        .get(reqwest::header::RETRY_AFTER)
106        .and_then(|v| v.to_str().ok())
107        .and_then(|s| s.trim().parse::<f64>().ok())
108        .filter(|s| s.is_finite() && *s >= 0.0)
109}
110
111/// The current time as Unix seconds, matching the `expires_at` field returned
112/// by the Copilot token-exchange endpoint.
113fn unix_now() -> i64 {
114    std::time::SystemTime::now()
115        .duration_since(std::time::UNIX_EPOCH)
116        .map(|d| d.as_secs() as i64)
117        .unwrap_or(0)
118}
119
120/// Whether a cached Copilot token needs to be (re-)exchanged: `true` if there
121/// is no cached token, or if fewer than [`TOKEN_REFRESH_MARGIN_SECS`] seconds
122/// remain before it expires. Factored out as a small pure function so the
123/// refresh policy is unit-testable without any network access.
124fn token_needs_refresh(expires_at: Option<i64>, now: i64) -> bool {
125    match expires_at {
126        None => true,
127        Some(expires_at) => expires_at - now < TOKEN_REFRESH_MARGIN_SECS,
128    }
129}
130
131/// A cached, exchanged Copilot API token and its expiry (Unix seconds, as
132/// returned by the token-exchange endpoint's `expires_at` field).
133#[derive(Clone, Debug, PartialEq)]
134struct CachedToken {
135    token: String,
136    expires_at: i64,
137}
138
139/// A GitHub Copilot chat client (`POST {base_url}/chat/completions`).
140#[derive(Clone)]
141pub struct GitHubCopilotChatClient {
142    inner: Arc<Inner>,
143}
144
145struct Inner {
146    http: reqwest::Client,
147    base_url: String,
148    model: String,
149    /// The long-lived GitHub OAuth token / PAT supplied by the caller,
150    /// exchanged for short-lived Copilot API tokens by
151    /// [`GitHubCopilotChatClient::ensure_copilot_token`].
152    github_token: String,
153    /// The most recently exchanged Copilot API token, if any, refreshed on
154    /// demand by [`GitHubCopilotChatClient::ensure_copilot_token`]. Guarded
155    /// by an async mutex since the exchange itself is an async HTTP call.
156    copilot_token: Mutex<Option<CachedToken>>,
157}
158
159impl Clone for Inner {
160    fn clone(&self) -> Self {
161        // `Arc::make_mut` (used by the builder setters below) requires
162        // `Inner: Clone`, but `tokio::sync::Mutex` intentionally has no
163        // `Clone` impl of its own (cloning while a lock might be held is
164        // exactly what it prevents). The setters only ever run before the
165        // client has been shared (i.e. while the `Arc` refcount is 1), so
166        // `Arc::make_mut` never actually needs to materialize a real clone —
167        // but the bound must still typecheck, hence this manual impl.
168        // `try_lock` is fine here: at refcount 1 nothing else can hold the
169        // lock, and if it were somehow held we'd rather start unauthenticated
170        // (forcing a fresh exchange) than block synchronously inside `Clone`.
171        let copilot_token = self
172            .copilot_token
173            .try_lock()
174            .ok()
175            .and_then(|guard| guard.clone());
176        Self {
177            http: self.http.clone(),
178            base_url: self.base_url.clone(),
179            model: self.model.clone(),
180            github_token: self.github_token.clone(),
181            copilot_token: Mutex::new(copilot_token),
182        }
183    }
184}
185
186impl std::fmt::Debug for GitHubCopilotChatClient {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("GitHubCopilotChatClient")
189            .field("base_url", &self.inner.base_url)
190            .field("model", &self.inner.model)
191            .finish_non_exhaustive()
192    }
193}
194
195impl GitHubCopilotChatClient {
196    /// Create a client for the given GitHub OAuth token / PAT and default
197    /// model, targeting the default Copilot API base URL.
198    ///
199    /// `github_token` is *not* sent directly to the chat endpoint — it is
200    /// exchanged for a short-lived Copilot API token on first use (see the
201    /// crate-level docs).
202    pub fn new(github_token: impl Into<String>, model: impl Into<String>) -> Self {
203        Self {
204            inner: Arc::new(Inner {
205                http: reqwest::Client::new(),
206                base_url: DEFAULT_BASE_URL.to_string(),
207                model: model.into(),
208                github_token: github_token.into(),
209                copilot_token: Mutex::new(None),
210            }),
211        }
212    }
213
214    /// Build a client from the environment: the GitHub OAuth token / PAT is
215    /// read from `GITHUB_TOKEN_ENV` (`GITHUB_COPILOT_TOKEN`), falling back
216    /// to `GITHUB_TOKEN_ENV_ALT` (`GH_COPILOT_TOKEN`) if unset; an
217    /// [`Error::Configuration`] is returned if neither is set. The optional
218    /// `BASE_URL_ENV` (`GITHUB_COPILOT_BASE_URL`) overrides the default
219    /// base URL when present.
220    pub fn from_env(model: impl Into<String>) -> Result<Self> {
221        let token = std::env::var(GITHUB_TOKEN_ENV)
222            .or_else(|_| std::env::var(GITHUB_TOKEN_ENV_ALT))
223            .map_err(|_| {
224                Error::Configuration(format!(
225                    "{GITHUB_TOKEN_ENV} (or {GITHUB_TOKEN_ENV_ALT}) is not set"
226                ))
227            })?;
228        let mut client = Self::new(token, model);
229        if let Ok(base_url) = std::env::var(BASE_URL_ENV) {
230            if !base_url.trim().is_empty() {
231                client = client.with_base_url(base_url);
232            }
233        }
234        Ok(client)
235    }
236
237    /// Override the base URL (e.g. for an enterprise deployment or a proxy in
238    /// front of the Copilot API).
239    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
240        Arc::make_mut(&mut self.inner).base_url = base_url.into();
241        self
242    }
243
244    /// The default model id.
245    pub fn model(&self) -> &str {
246        &self.inner.model
247    }
248
249    /// The configured base URL.
250    pub fn base_url(&self) -> &str {
251        &self.inner.base_url
252    }
253
254    fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
255        let model = options
256            .model
257            .clone()
258            .unwrap_or_else(|| self.inner.model.clone());
259        convert::build_request(messages, options, &model, stream)
260    }
261
262    /// Return a valid Copilot API bearer token, exchanging the configured
263    /// GitHub token for a fresh one if none is cached or the cached one is
264    /// missing/within [`TOKEN_REFRESH_MARGIN_SECS`] seconds of expiring.
265    ///
266    /// This performs a live `GET` against [`TOKEN_EXCHANGE_URL`] only when a
267    /// refresh is actually needed; the refresh *decision* itself is the pure,
268    /// unit-tested [`token_needs_refresh`].
269    async fn ensure_copilot_token(&self) -> Result<String> {
270        let now = unix_now();
271        {
272            let guard = self.inner.copilot_token.lock().await;
273            if let Some(cached) = guard.as_ref() {
274                if !token_needs_refresh(Some(cached.expires_at), now) {
275                    return Ok(cached.token.clone());
276                }
277            }
278        }
279
280        let resp = self
281            .inner
282            .http
283            .get(TOKEN_EXCHANGE_URL)
284            .header(
285                "Authorization",
286                format!("token {}", self.inner.github_token),
287            )
288            .send()
289            .await
290            .map_err(|e| Error::service(format!("Copilot token exchange failed: {e}")))?;
291
292        if !resp.status().is_success() {
293            let status = resp.status();
294            let text = resp.text().await.unwrap_or_default();
295            return Err(Error::service_invalid_auth(format!(
296                "Copilot token exchange error {status}: {text}"
297            )));
298        }
299
300        let value: Value = resp
301            .json()
302            .await
303            .map_err(|e| Error::service(format!("invalid Copilot token exchange response: {e}")))?;
304        let token = value
305            .get("token")
306            .and_then(Value::as_str)
307            .ok_or_else(|| Error::service("Copilot token exchange response missing `token` field"))?
308            .to_string();
309        let expires_at = value
310            .get("expires_at")
311            .and_then(Value::as_i64)
312            .unwrap_or(now + TOKEN_REFRESH_MARGIN_SECS);
313
314        let mut guard = self.inner.copilot_token.lock().await;
315        *guard = Some(CachedToken {
316            token: token.clone(),
317            expires_at,
318        });
319        Ok(token)
320    }
321
322    async fn post(&self, body: &Value) -> Result<reqwest::Response> {
323        let copilot_token = self.ensure_copilot_token().await?;
324        let url = format!(
325            "{}/chat/completions",
326            self.inner.base_url.trim_end_matches('/')
327        );
328        let resp = self
329            .inner
330            .http
331            .post(&url)
332            .bearer_auth(&copilot_token)
333            .header("Editor-Version", EDITOR_VERSION)
334            .header("Copilot-Integration-Id", COPILOT_INTEGRATION_ID)
335            .json(body)
336            .send()
337            .await
338            .map_err(|e| Error::service(format!("request failed: {e}")))?;
339        if !resp.status().is_success() {
340            let status = resp.status();
341            let retry_after = parse_retry_after(resp.headers());
342            let text = resp.text().await.unwrap_or_default();
343            // The Copilot chat endpoint is OpenAI-wire-compatible for errors
344            // too, so status/body classification is shared verbatim with
345            // `agent-framework-openai` rather than duplicated.
346            return Err(agent_framework_openai::classify_service_error(
347                status.as_u16(),
348                &text,
349                format!("GitHub Copilot API error {status}: {text}"),
350                retry_after,
351            ));
352        }
353        Ok(resp)
354    }
355}
356
357#[async_trait::async_trait]
358impl ChatClient for GitHubCopilotChatClient {
359    async fn get_response(
360        &self,
361        messages: Vec<Message>,
362        options: ChatOptions,
363    ) -> Result<ChatResponse> {
364        let body = self.build_body(&messages, &options, false);
365        let resp = self.post(&body).await?;
366        let value: Value = resp
367            .json()
368            .await
369            .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
370        Ok(agent_framework_openai::convert::parse_response(&value))
371    }
372
373    async fn get_streaming_response(
374        &self,
375        messages: Vec<Message>,
376        options: ChatOptions,
377    ) -> Result<ChatStream> {
378        let body = self.build_body(&messages, &options, true);
379        let resp = self.post(&body).await?;
380        Ok(parse_sse_stream(resp).boxed())
381    }
382
383    fn model(&self) -> Option<&str> {
384        Some(&self.inner.model)
385    }
386}
387
388type ByteStream =
389    std::pin::Pin<Box<dyn futures::Stream<Item = reqwest::Result<bytes::Bytes>> + Send>>;
390
391/// Turn an SSE HTTP response into a stream of [`ChatResponseUpdate`]s.
392fn parse_sse_stream(
393    resp: reqwest::Response,
394) -> impl futures::Stream<Item = Result<ChatResponseUpdate>> + Send {
395    let byte_stream: ByteStream = Box::pin(resp.bytes_stream());
396    futures::stream::unfold(
397        SseState {
398            byte_stream,
399            buffer: String::new(),
400            utf8: Utf8StreamDecoder::new(),
401            queued: VecDeque::new(),
402            tool_ids: HashMap::new(),
403            done: false,
404        },
405        |mut state| async move {
406            loop {
407                if let Some(update) = state.queued.pop_front() {
408                    return Some((Ok(update), state));
409                }
410                if state.done {
411                    return None;
412                }
413                match state.byte_stream.next().await {
414                    Some(Ok(bytes)) => {
415                        let decoded = state.utf8.push(&bytes);
416                        state.buffer.push_str(&decoded);
417                        while let Some(pos) = state.buffer.find('\n') {
418                            let line = state.buffer[..pos].trim().to_string();
419                            state.buffer.drain(..=pos);
420                            if let Some(data) = line.strip_prefix("data:") {
421                                let data = data.trim();
422                                if data == "[DONE]" {
423                                    return drain_or_end(state);
424                                }
425                                if data.is_empty() {
426                                    continue;
427                                }
428                                if let Ok(value) = serde_json::from_str::<Value>(data) {
429                                    if let Some(err) = value.get("error") {
430                                        let msg = err
431                                            .get("message")
432                                            .and_then(Value::as_str)
433                                            .unwrap_or("unknown stream error")
434                                            .to_string();
435                                        state.done = true;
436                                        return Some((Err(Error::service(msg)), state));
437                                    }
438                                    if let Some(update) =
439                                        convert::parse_delta(&value, &mut state.tool_ids)
440                                    {
441                                        state.queued.push_back(update);
442                                    }
443                                }
444                            }
445                        }
446                    }
447                    Some(Err(e)) => {
448                        state.done = true;
449                        return Some((Err(Error::service(format!("stream error: {e}"))), state));
450                    }
451                    None => return drain_or_end(state),
452                }
453            }
454        },
455    )
456}
457
458/// State carried across `unfold` iterations while parsing the SSE stream.
459struct SseState {
460    byte_stream: ByteStream,
461    buffer: String,
462    utf8: Utf8StreamDecoder,
463    queued: VecDeque<ChatResponseUpdate>,
464    tool_ids: HashMap<i64, String>,
465    done: bool,
466}
467
468fn drain_or_end(mut state: SseState) -> Option<(Result<ChatResponseUpdate>, SseState)> {
469    match state.queued.pop_front() {
470        Some(update) => {
471            state.done = true;
472            Some((Ok(update), state))
473        }
474        None => None,
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    // region: base URL and constructor defaults
483
484    #[test]
485    fn new_sets_default_base_url_and_model() {
486        let client = GitHubCopilotChatClient::new("gho_token", "gpt-4o");
487        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
488        assert_eq!(client.model(), "gpt-4o");
489    }
490
491    #[test]
492    fn with_base_url_overrides_default() {
493        let client = GitHubCopilotChatClient::new("gho_token", "gpt-4o")
494            .with_base_url("https://copilot-proxy.example.internal");
495        assert_eq!(client.base_url(), "https://copilot-proxy.example.internal");
496    }
497
498    #[test]
499    fn debug_impl_redacts_tokens() {
500        let client = GitHubCopilotChatClient::new("gho_super_secret_token", "gpt-4o");
501        let debug = format!("{client:?}");
502        assert!(!debug.contains("gho_super_secret_token"));
503        assert!(debug.contains("gpt-4o"));
504        assert!(debug.contains(DEFAULT_BASE_URL));
505    }
506
507    // endregion
508
509    // region: from_env
510
511    /// Guards env mutation: tests within a crate run on multiple threads, and
512    /// env vars are process-global.
513    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
514
515    #[test]
516    fn from_env_errors_when_token_missing() {
517        let _guard = ENV_MUTEX.lock().unwrap();
518        // SAFETY: serialized by ENV_MUTEX against the other env-var tests in
519        // this module; no other test in this crate touches these variables.
520        unsafe {
521            std::env::remove_var(GITHUB_TOKEN_ENV);
522            std::env::remove_var(GITHUB_TOKEN_ENV_ALT);
523            std::env::remove_var(BASE_URL_ENV);
524        }
525        let result = GitHubCopilotChatClient::from_env("gpt-4o");
526        assert!(matches!(result, Err(Error::Configuration(_))));
527    }
528
529    #[test]
530    fn from_env_reads_primary_token_var() {
531        let _guard = ENV_MUTEX.lock().unwrap();
532        // SAFETY: serialized by ENV_MUTEX; see above.
533        unsafe {
534            std::env::set_var(GITHUB_TOKEN_ENV, "gho_from_env_token");
535            std::env::remove_var(GITHUB_TOKEN_ENV_ALT);
536            std::env::remove_var(BASE_URL_ENV);
537        }
538        let client = GitHubCopilotChatClient::from_env("gpt-4o").unwrap();
539        assert_eq!(client.model(), "gpt-4o");
540        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
541        unsafe {
542            std::env::remove_var(GITHUB_TOKEN_ENV);
543        }
544    }
545
546    #[test]
547    fn from_env_falls_back_to_alt_token_var() {
548        let _guard = ENV_MUTEX.lock().unwrap();
549        // SAFETY: serialized by ENV_MUTEX; see above.
550        unsafe {
551            std::env::remove_var(GITHUB_TOKEN_ENV);
552            std::env::set_var(GITHUB_TOKEN_ENV_ALT, "gho_alt_token");
553        }
554        let client = GitHubCopilotChatClient::from_env("gpt-4o").unwrap();
555        assert_eq!(client.model(), "gpt-4o");
556        unsafe {
557            std::env::remove_var(GITHUB_TOKEN_ENV_ALT);
558        }
559    }
560
561    #[test]
562    fn from_env_reads_base_url_override() {
563        let _guard = ENV_MUTEX.lock().unwrap();
564        // SAFETY: serialized by ENV_MUTEX; see above.
565        unsafe {
566            std::env::set_var(GITHUB_TOKEN_ENV, "gho_from_env_token");
567            std::env::set_var(BASE_URL_ENV, "https://copilot-proxy.example.internal");
568        }
569        let client = GitHubCopilotChatClient::from_env("gpt-4o").unwrap();
570        assert_eq!(client.base_url(), "https://copilot-proxy.example.internal");
571        unsafe {
572            std::env::remove_var(GITHUB_TOKEN_ENV);
573            std::env::remove_var(BASE_URL_ENV);
574        }
575    }
576
577    // endregion
578
579    // region: request building
580
581    #[test]
582    fn build_body_uses_client_default_model_when_options_model_unset() {
583        let client = GitHubCopilotChatClient::new("gho_token", "gpt-4o");
584        let body = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
585        assert_eq!(body["model"], serde_json::json!("gpt-4o"));
586        assert_eq!(
587            body["messages"],
588            serde_json::json!([{ "role": "user", "content": "hi" }])
589        );
590    }
591
592    #[test]
593    fn build_body_prefers_per_request_model() {
594        let client = GitHubCopilotChatClient::new("gho_token", "gpt-4o");
595        let options = ChatOptions {
596            model: Some("claude-3.5-sonnet".to_string()),
597            ..ChatOptions::new()
598        };
599        let body = client.build_body(&[Message::user("hi")], &options, false);
600        assert_eq!(body["model"], serde_json::json!("claude-3.5-sonnet"));
601    }
602
603    #[test]
604    fn build_body_sets_stream_flag() {
605        let client = GitHubCopilotChatClient::new("gho_token", "gpt-4o");
606        let body = client.build_body(&[Message::user("hi")], &ChatOptions::new(), true);
607        assert_eq!(body["stream"], serde_json::json!(true));
608    }
609
610    // endregion
611
612    // region: Copilot token refresh predicate (pure, no network)
613
614    #[test]
615    fn token_needs_refresh_when_none_cached() {
616        assert!(token_needs_refresh(None, 1_000));
617    }
618
619    #[test]
620    fn token_needs_refresh_when_within_margin_of_expiry() {
621        // Expires in 30s, margin is 60s: needs refresh.
622        assert!(token_needs_refresh(Some(1_030), 1_000));
623    }
624
625    #[test]
626    fn token_needs_refresh_when_already_expired() {
627        assert!(token_needs_refresh(Some(900), 1_000));
628    }
629
630    #[test]
631    fn token_does_not_need_refresh_when_comfortably_valid() {
632        // Expires in 300s, well beyond the 60s margin.
633        assert!(!token_needs_refresh(Some(1_300), 1_000));
634    }
635
636    #[test]
637    fn token_needs_refresh_boundary_at_exactly_margin() {
638        // Exactly at the margin (60s remaining) is comfortably valid — the
639        // predicate is a strict `<`, so 60s remaining itself does not
640        // trigger a refresh but one second less does. This pins that
641        // boundary.
642        assert!(!token_needs_refresh(Some(1_060), 1_000));
643        assert!(token_needs_refresh(Some(1_059), 1_000));
644    }
645
646    // endregion
647
648    // region: Inner clone (used by `Arc::make_mut` in builder setters)
649
650    #[tokio::test]
651    async fn inner_clone_preserves_cached_token() {
652        let client = GitHubCopilotChatClient::new("gho_token", "gpt-4o");
653        {
654            let mut guard = client.inner.copilot_token.lock().await;
655            *guard = Some(CachedToken {
656                token: "cached-copilot-token".to_string(),
657                expires_at: 9_999_999_999,
658            });
659        }
660        // Exercises the manual `Clone for Inner` impl the same way
661        // `with_base_url`'s `Arc::make_mut` does internally.
662        let cloned_inner = client.inner.as_ref().clone();
663        let guard = cloned_inner.copilot_token.lock().await;
664        assert_eq!(
665            guard.as_ref().map(|c| c.token.as_str()),
666            Some("cached-copilot-token")
667        );
668    }
669
670    // endregion
671
672    // region: SSE stream parsing over a synthetic byte stream
673
674    fn sse_bytes(lines: &[String]) -> bytes::Bytes {
675        bytes::Bytes::from(lines.join("\n") + "\n\n")
676    }
677
678    async fn collect_via_state(text: bytes::Bytes) -> Vec<ChatResponseUpdate> {
679        let stream = futures::stream::once(async move { Ok::<_, reqwest::Error>(text) });
680        let byte_stream: ByteStream = Box::pin(stream);
681        let mut state = SseState {
682            byte_stream,
683            buffer: String::new(),
684            utf8: Utf8StreamDecoder::new(),
685            queued: VecDeque::new(),
686            tool_ids: HashMap::new(),
687            done: false,
688        };
689        let mut updates = Vec::new();
690        if let Some(Ok(bytes)) = state.byte_stream.next().await {
691            let decoded = state.utf8.push(&bytes);
692            state.buffer.push_str(&decoded);
693            while let Some(pos) = state.buffer.find('\n') {
694                let line = state.buffer[..pos].trim().to_string();
695                state.buffer.drain(..=pos);
696                let Some(data) = line.strip_prefix("data:") else {
697                    continue;
698                };
699                let data = data.trim();
700                if data.is_empty() || data == "[DONE]" {
701                    continue;
702                }
703                let value: Value = serde_json::from_str(data).unwrap();
704                if let Some(update) = convert::parse_delta(&value, &mut state.tool_ids) {
705                    updates.push(update);
706                }
707            }
708        }
709        updates
710    }
711
712    #[tokio::test]
713    async fn streaming_chunk_produces_text_update() {
714        let chunk = serde_json::json!({
715            "id": "chatcmpl-1",
716            "model": "gpt-4o",
717            "choices": [{ "delta": { "role": "assistant", "content": "Hello" }, "finish_reason": null }],
718        });
719        let bytes = sse_bytes(&[format!("data: {chunk}")]);
720        let updates = collect_via_state(bytes).await;
721        assert_eq!(updates.len(), 1);
722        let resp = ChatResponse::from_updates(updates);
723        assert_eq!(resp.text(), "Hello");
724        assert_eq!(resp.response_id.as_deref(), Some("chatcmpl-1"));
725    }
726
727    #[tokio::test]
728    async fn streaming_tool_call_and_finish_reason_accumulate() {
729        let call_chunk = serde_json::json!({
730            "id": "chatcmpl-2",
731            "choices": [{
732                "delta": { "tool_calls": [{ "index": 0, "id": "call_1", "function": { "name": "get_weather", "arguments": "{}" } }] },
733                "finish_reason": null,
734            }],
735        });
736        let finish_chunk = serde_json::json!({
737            "id": "chatcmpl-2",
738            "choices": [{ "delta": {}, "finish_reason": "tool_calls" }],
739        });
740        let bytes = sse_bytes(&[
741            format!("data: {call_chunk}"),
742            format!("data: {finish_chunk}"),
743        ]);
744        let updates = collect_via_state(bytes).await;
745        assert_eq!(updates.len(), 2);
746        let resp = ChatResponse::from_updates(updates);
747        let calls = resp.function_calls();
748        assert_eq!(calls.len(), 1);
749        assert_eq!(calls[0].call_id, "call_1");
750        assert_eq!(calls[0].name, "get_weather");
751        assert_eq!(
752            resp.finish_reason,
753            Some(agent_framework_core::types::FinishReason::tool_calls())
754        );
755    }
756
757    #[tokio::test]
758    async fn streaming_done_sentinel_ends_stream_without_extra_update() {
759        let chunk = serde_json::json!({
760            "id": "chatcmpl-3",
761            "choices": [{ "delta": { "content": "hi" }, "finish_reason": null }],
762        });
763        let bytes = sse_bytes(&[format!("data: {chunk}"), "data: [DONE]".to_string()]);
764        let updates = collect_via_state(bytes).await;
765        // `collect_via_state` mirrors the raw line loop (no special-casing of
766        // [DONE] beyond `continue`), so this asserts the sentinel line never
767        // parses into a spurious update, matching the real stream (which
768        // instead terminates on it).
769        assert_eq!(updates.len(), 1);
770    }
771
772    // endregion
773}