1pub 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
64const DEFAULT_BASE_URL: &str = "https://api.githubcopilot.com";
66
67const TOKEN_EXCHANGE_URL: &str = "https://api.github.com/copilot_internal/v2/token";
70
71const EDITOR_VERSION: &str = "agent-framework-rs/0.1.0";
76
77const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
81
82const TOKEN_REFRESH_MARGIN_SECS: i64 = 60;
85
86const GITHUB_TOKEN_ENV: &str = "GITHUB_COPILOT_TOKEN";
89
90const GITHUB_TOKEN_ENV_ALT: &str = "GH_COPILOT_TOKEN";
93
94const BASE_URL_ENV: &str = "GITHUB_COPILOT_BASE_URL";
98
99fn 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
111fn 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
120fn 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#[derive(Clone, Debug, PartialEq)]
134struct CachedToken {
135 token: String,
136 expires_at: i64,
137}
138
139#[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 github_token: String,
153 copilot_token: Mutex<Option<CachedToken>>,
157}
158
159impl Clone for Inner {
160 fn clone(&self) -> Self {
161 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 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 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 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 pub fn model(&self) -> &str {
246 &self.inner.model
247 }
248
249 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 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 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
391fn 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
458struct 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 #[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 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 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 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 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 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 #[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 #[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 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 assert!(!token_needs_refresh(Some(1_300), 1_000));
634 }
635
636 #[test]
637 fn token_needs_refresh_boundary_at_exactly_margin() {
638 assert!(!token_needs_refresh(Some(1_060), 1_000));
643 assert!(token_needs_refresh(Some(1_059), 1_000));
644 }
645
646 #[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 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 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 assert_eq!(updates.len(), 1);
770 }
771
772 }