1use bytes::Bytes;
7use futures::Stream;
8use std::collections::HashMap;
9use std::pin::Pin;
10use std::sync::Arc;
11use tokio::sync::RwLock;
12use tracing::debug;
13
14use super::authenticator::CopilotAuthenticator;
15use super::config::{GITHUB_COPILOT_API_BASE, GitHubCopilotConfig, get_copilot_default_headers};
16use super::model_info::{
17 get_available_models, get_model_info, is_claude_model, supports_reasoning,
18};
19use crate::ProviderError;
20use crate::core::providers::base::{BaseConfig, BaseHttpClient, HttpErrorMapper};
21use crate::core::streaming::utils::is_done_marker;
22use crate::core::traits::error_mapper::trait_def::ErrorMapper;
23use crate::core::traits::provider::llm_provider::trait_definition::LLMProvider;
24use crate::core::types::{
25 chat::ChatMessage,
26 chat::ChatRequest,
27 context::RequestContext,
28 embedding::EmbeddingRequest,
29 health::HealthStatus,
30 message::MessageRole,
31 model::ModelInfo,
32 model::ProviderCapability,
33 responses::{ChatChunk, ChatResponse, EmbeddingResponse},
34};
35
36const GITHUB_COPILOT_CAPABILITIES: &[ProviderCapability] = &[
38 ProviderCapability::ChatCompletion,
39 ProviderCapability::ChatCompletionStream,
40 ProviderCapability::ToolCalling,
41 #[cfg(feature = "gateway")]
42 ProviderCapability::Responses,
43];
44
45#[derive(Debug)]
47pub struct GitHubCopilotProvider {
48 config: GitHubCopilotConfig,
49 authenticator: CopilotAuthenticator,
50 models: Vec<ModelInfo>,
51 cached_api_key: Arc<RwLock<Option<String>>>,
53 cached_api_base: Arc<RwLock<Option<String>>>,
55 #[cfg(test)]
56 native_endpoint_access: crate::core::net::ProviderEndpointAccess,
57}
58
59impl Clone for GitHubCopilotProvider {
60 fn clone(&self) -> Self {
61 Self {
62 config: self.config.clone(),
63 authenticator: self.authenticator.clone(),
64 models: self.models.clone(),
65 cached_api_key: Arc::new(RwLock::new(None)),
66 cached_api_base: Arc::new(RwLock::new(None)),
67 #[cfg(test)]
68 native_endpoint_access: self.native_endpoint_access,
69 }
70 }
71}
72
73impl GitHubCopilotProvider {
74 pub(crate) fn credential_resource_identity(&self) -> [u8; 32] {
75 self.authenticator.credential_resource_identity()
76 }
77
78 pub async fn new(config: GitHubCopilotConfig) -> Result<Self, ProviderError> {
80 let authenticator = CopilotAuthenticator::new(&config);
81
82 let models = get_available_models()
84 .iter()
85 .filter_map(|id| get_model_info(id))
86 .map(|info| {
87 let mut capabilities = Vec::new();
90 if info.supports_tools {
91 capabilities.push(ProviderCapability::ToolCalling);
92 }
93
94 ModelInfo {
95 id: info.model_id.to_string(),
96 name: info.display_name.to_string(),
97 provider: "github_copilot".to_string(),
98 max_context_length: info.max_context_length,
99 max_output_length: info.max_output_length,
100 supports_streaming: info.supports_streaming,
101 supports_tools: info.supports_tools,
102 supports_multimodal: info.supports_multimodal,
103 input_cost_per_1k_tokens: None, output_cost_per_1k_tokens: None,
105 currency: "USD".to_string(),
106 capabilities,
107 created_at: None,
108 updated_at: None,
109 metadata: HashMap::from([(
110 "endpoint_authority".into(),
111 serde_json::json!("account /models supported_endpoints"),
112 )]),
113 }
114 })
115 .collect();
116
117 Ok(Self {
118 config,
119 authenticator,
120 models,
121 cached_api_key: Arc::new(RwLock::new(None)),
122 cached_api_base: Arc::new(RwLock::new(None)),
123 #[cfg(test)]
124 native_endpoint_access: crate::core::net::ProviderEndpointAccess::PublicOnly,
125 })
126 }
127
128 fn native_http_client(
129 &self,
130 api_base: &str,
131 streaming: bool,
132 ) -> Result<BaseHttpClient, ProviderError> {
133 let config = BaseConfig {
134 api_base: Some(api_base.to_string()),
135 timeout: self.config.timeout,
136 #[cfg(test)]
137 endpoint_access: self.native_endpoint_access,
138 ..Default::default()
139 };
140 if streaming {
141 BaseHttpClient::new_for_provider_streaming_no_redirect("github_copilot", config)
142 } else {
143 BaseHttpClient::new_for_provider_no_redirect("github_copilot", config)
144 }
145 }
146
147 async fn require_endpoint(
150 &self,
151 model: &str,
152 endpoint: &str,
153 headers: &reqwest::header::HeaderMap,
154 ) -> Result<(), ProviderError> {
155 let api_base = self.get_api_base().await;
156 let url = format!("{}/models", api_base.trim_end_matches('/'));
157 let client = self.native_http_client(&api_base, false)?;
158 let response = client
159 .get(url)?
160 .headers(headers.clone())
161 .send()
162 .await
163 .map_err(|error| client.map_preserved_request_error(error))?;
164 let response = self.check_native_status(response).await?;
165 let catalog: serde_json::Value = response.json().await.map_err(|_| {
166 ProviderError::response_parsing("github_copilot", "Invalid account model catalog")
167 })?;
168 let models = catalog
169 .get("data")
170 .and_then(serde_json::Value::as_array)
171 .ok_or_else(|| {
172 ProviderError::response_parsing(
173 "github_copilot",
174 "Account model catalog has no data array",
175 )
176 })?;
177 let model_info = models
178 .iter()
179 .find(|entry| entry.get("id").and_then(serde_json::Value::as_str) == Some(model))
180 .ok_or_else(|| ProviderError::model_not_found("github_copilot", model))?;
181 if !model_info
182 .get("supported_endpoints")
183 .and_then(serde_json::Value::as_array)
184 .is_some_and(|endpoints| {
185 endpoints
186 .iter()
187 .any(|entry| entry.as_str() == Some(endpoint))
188 })
189 {
190 return Err(ProviderError::not_supported(
191 "github_copilot",
192 format!("Model {model} does not advertise {endpoint}"),
193 ));
194 }
195 Ok(())
196 }
197
198 async fn check_native_status(
199 &self,
200 response: reqwest::Response,
201 ) -> Result<reqwest::Response, ProviderError> {
202 let status = response.status();
203 if status.is_success() {
204 return Ok(response);
205 }
206 if status.as_u16() == 401 {
207 self.clear_cache().await;
208 }
209 let retry_after = response
210 .headers()
211 .get(reqwest::header::RETRY_AFTER)
212 .and_then(|value| value.to_str().ok())
213 .and_then(|value| value.parse().ok());
214 let body = crate::core::providers::base::read_streaming_error_body(response)
215 .await
216 .unwrap_or_else(|_| "Could not read Copilot error response".into());
217 if status.as_u16() == 429 {
218 return Err(ProviderError::rate_limit_with_retry(
219 "github_copilot",
220 body,
221 retry_after,
222 ));
223 }
224 Err(HttpErrorMapper::map_status_code(
225 "github_copilot",
226 status.as_u16(),
227 &body,
228 ))
229 }
230
231 #[cfg(feature = "gateway")]
232 pub(crate) async fn native_response(
233 &self,
234 body: serde_json::Value,
235 generation_attempted: &mut bool,
236 ) -> Result<reqwest::Response, ProviderError> {
237 use serde_json::Value;
238 let model = body.get("model").and_then(Value::as_str).ok_or_else(|| {
239 ProviderError::invalid_request("github_copilot", "model must be a string")
240 })?;
241 let mut headers = self.build_headers(&[]).await?;
242 self.require_endpoint(model, "/responses", &headers).await?;
243 let items = body.get("input").and_then(Value::as_array);
244 let agent = items.is_some_and(|items| {
245 items.iter().any(|item| {
246 !matches!(
247 item.get("role").and_then(Value::as_str),
248 Some("user" | "system" | "developer")
249 )
250 })
251 });
252 headers.insert(
253 "x-initiator",
254 reqwest::header::HeaderValue::from_static(if agent { "agent" } else { "user" }),
255 );
256 if items.is_some_and(|items| {
257 items
258 .iter()
259 .filter_map(|item| item.get("content").and_then(Value::as_array))
260 .flatten()
261 .any(|part| part.get("type").and_then(Value::as_str) == Some("input_image"))
262 }) {
263 headers.insert(
264 "copilot-vision-request",
265 reqwest::header::HeaderValue::from_static("true"),
266 );
267 }
268 let api_base = self.get_api_base().await;
269 let url = format!("{}/responses", api_base.trim_end_matches('/'));
270 let client =
271 self.native_http_client(&api_base, body.get("stream") == Some(&Value::Bool(true)))?;
272 let request = client.post(url)?.headers(headers).json(&body);
273 *generation_attempted = true;
276 let response = tokio::time::timeout(
277 std::time::Duration::from_secs(self.config.timeout),
278 request.send(),
279 )
280 .await
281 .map_err(|_| ProviderError::timeout("github_copilot", "Responses upstream timed out"))?
282 .map_err(|error| client.map_preserved_request_error(error))?;
283 self.check_native_status(response).await
284 }
285
286 async fn get_api_key(&self) -> Result<String, ProviderError> {
288 {
290 let cache = self.cached_api_key.read().await;
291 if let Some(ref key) = *cache {
292 return Ok(key.clone());
293 }
294 }
295
296 let key = self.authenticator.get_api_key().await?;
298
299 {
301 let mut cache = self.cached_api_key.write().await;
302 *cache = Some(key.clone());
303 }
304
305 if let Some(api_base) = self.authenticator.get_api_base() {
307 let mut cache = self.cached_api_base.write().await;
308 *cache = Some(api_base);
309 }
310
311 Ok(key)
312 }
313
314 async fn get_api_base(&self) -> String {
316 {
318 let cache = self.cached_api_base.read().await;
319 if let Some(ref base) = *cache {
320 return base.clone();
321 }
322 }
323
324 self.config
326 .api_base
327 .clone()
328 .or_else(|| self.authenticator.get_api_base())
329 .unwrap_or_else(|| GITHUB_COPILOT_API_BASE.to_string())
330 }
331
332 async fn clear_cache(&self) {
334 {
335 let mut cache = self.cached_api_key.write().await;
336 *cache = None;
337 }
338 {
339 let mut cache = self.cached_api_base.write().await;
340 *cache = None;
341 }
342 }
343
344 fn transform_messages(&self, messages: &mut [ChatMessage]) {
346 if self.config.disable_system_to_assistant {
347 return;
348 }
349
350 for message in messages.iter_mut() {
352 if message.role == MessageRole::System {
353 message.role = MessageRole::Assistant;
354 }
355 }
356 }
357
358 fn determine_initiator(&self, messages: &[ChatMessage]) -> &'static str {
360 for message in messages {
361 if message.role == MessageRole::Tool || message.role == MessageRole::Assistant {
362 return "agent";
363 }
364 }
365 "user"
366 }
367
368 fn has_vision_content(&self, messages: &[ChatMessage]) -> bool {
370 for message in messages {
371 if let Some(crate::core::types::message::MessageContent::Parts(parts)) =
372 &message.content
373 {
374 for part in parts {
375 if let crate::core::types::content::ContentPart::ImageUrl { .. } = part {
376 return true;
377 }
378 }
379 }
380 }
381 false
382 }
383
384 async fn build_headers(
386 &self,
387 messages: &[ChatMessage],
388 ) -> Result<reqwest::header::HeaderMap, ProviderError> {
389 let api_key = self.get_api_key().await?;
390 let default_headers = get_copilot_default_headers(&api_key);
391
392 let mut headers = reqwest::header::HeaderMap::new();
393 for (key, value) in default_headers {
394 headers.insert(
395 reqwest::header::HeaderName::from_bytes(key.as_bytes()).map_err(|e| {
396 ProviderError::configuration(
397 "github_copilot",
398 format!("Invalid header name: {}", e),
399 )
400 })?,
401 value.parse().map_err(|e| {
402 ProviderError::configuration(
403 "github_copilot",
404 format!("Invalid header value: {}", e),
405 )
406 })?,
407 );
408 }
409
410 let initiator = self.determine_initiator(messages);
412 headers.insert(
413 "x-initiator",
414 initiator.parse().map_err(|e| {
415 ProviderError::configuration(
416 "github_copilot",
417 format!("Invalid x-initiator header value: {}", e),
418 )
419 })?,
420 );
421
422 if self.has_vision_content(messages) {
424 headers.insert(
425 "copilot-vision-request",
426 "true".parse().map_err(|e| {
427 ProviderError::configuration(
428 "github_copilot",
429 format!("Invalid copilot-vision-request header value: {}", e),
430 )
431 })?,
432 );
433 }
434
435 Ok(headers)
436 }
437}
438
439impl LLMProvider for GitHubCopilotProvider {
440 fn name(&self) -> &'static str {
441 "github_copilot"
442 }
443
444 fn capabilities(&self) -> &'static [ProviderCapability] {
445 GITHUB_COPILOT_CAPABILITIES
446 }
447
448 fn models(&self) -> &[ModelInfo] {
449 &self.models
450 }
451
452 fn get_supported_openai_params(&self, model: &str) -> &'static [&'static str] {
453 let is_reasoning = supports_reasoning(model);
454 let is_claude = is_claude_model(model);
455
456 if is_reasoning {
457 if is_claude {
458 &[
459 "temperature",
460 "top_p",
461 "max_tokens",
462 "max_completion_tokens",
463 "stream",
464 "stop",
465 "frequency_penalty",
466 "presence_penalty",
467 "n",
468 "response_format",
469 "seed",
470 "tools",
471 "tool_choice",
472 "user",
473 "thinking",
474 "reasoning_effort",
475 ]
476 } else {
477 &[
478 "temperature",
479 "top_p",
480 "max_tokens",
481 "max_completion_tokens",
482 "stream",
483 "stop",
484 "frequency_penalty",
485 "presence_penalty",
486 "n",
487 "response_format",
488 "seed",
489 "tools",
490 "tool_choice",
491 "user",
492 "reasoning_effort",
493 ]
494 }
495 } else {
496 &[
497 "temperature",
498 "top_p",
499 "max_tokens",
500 "max_completion_tokens",
501 "stream",
502 "stop",
503 "frequency_penalty",
504 "presence_penalty",
505 "n",
506 "response_format",
507 "seed",
508 "tools",
509 "tool_choice",
510 "parallel_tool_calls",
511 "user",
512 "logprobs",
513 "top_logprobs",
514 ]
515 }
516 }
517
518 async fn map_openai_params(
519 &self,
520 params: HashMap<String, serde_json::Value>,
521 _model: &str,
522 ) -> Result<HashMap<String, serde_json::Value>, ProviderError> {
523 Ok(params)
525 }
526
527 async fn transform_request(
528 &self,
529 mut request: ChatRequest,
530 _context: RequestContext,
531 ) -> Result<serde_json::Value, ProviderError> {
532 self.transform_messages(&mut request.messages);
534
535 serde_json::to_value(&request)
537 .map_err(|e| ProviderError::invalid_request("github_copilot", e.to_string()))
538 }
539
540 async fn transform_response(
541 &self,
542 raw_response: &[u8],
543 _model: &str,
544 _request_id: &str,
545 ) -> Result<ChatResponse, ProviderError> {
546 let chat_response: ChatResponse = serde_json::from_slice(raw_response).map_err(|e| {
547 ProviderError::api_error(
548 "github_copilot",
549 500,
550 format!("Failed to parse response: {}", e),
551 )
552 })?;
553
554 Ok(chat_response)
555 }
556
557 fn get_error_mapper(&self) -> Box<dyn ErrorMapper<ProviderError>> {
558 Box::new(crate::core::traits::error_mapper::DefaultErrorMapper)
559 }
560
561 async fn chat_completion(
562 &self,
563 mut request: ChatRequest,
564 _context: RequestContext,
565 ) -> Result<ChatResponse, ProviderError> {
566 debug!("GitHub Copilot chat request: model={}", request.model);
567
568 self.transform_messages(&mut request.messages);
570
571 let headers = self.build_headers(&request.messages).await?;
573 self.require_endpoint(&request.model, "/chat/completions", &headers)
574 .await?;
575
576 let api_base = self.get_api_base().await;
578 let url = format!("{}/chat/completions", api_base.trim_end_matches('/'));
579
580 let client = crate::core::http::outbound::default_outbound_client().clone();
582 let response = client
583 .post(&url)
584 .headers(headers)
585 .json(&request)
586 .send()
587 .await
588 .map_err(|e| ProviderError::network("github_copilot", e.to_string()))?;
589
590 let status = response.status();
591 let body = response
592 .bytes()
593 .await
594 .map_err(|e| ProviderError::network("github_copilot", e.to_string()))?;
595
596 if !status.is_success() {
597 let body_str = String::from_utf8_lossy(&body);
598 let status_code = status.as_u16();
599
600 if status_code == 401 {
602 self.clear_cache().await;
603 }
604
605 return Err(match status_code {
606 401 => ProviderError::authentication("github_copilot", "Invalid API key or token"),
607 404 => ProviderError::model_not_found("github_copilot", body_str.to_string()),
608 429 => ProviderError::rate_limit("github_copilot", None),
609 400 => ProviderError::invalid_request("github_copilot", body_str.to_string()),
610 500..=599 => {
611 ProviderError::provider_unavailable("github_copilot", body_str.to_string())
612 }
613 _ => HttpErrorMapper::map_status_code("github_copilot", status_code, &body_str),
614 });
615 }
616
617 serde_json::from_slice(&body).map_err(|e| {
618 ProviderError::api_error(
619 "github_copilot",
620 500,
621 format!("Failed to parse response: {}", e),
622 )
623 })
624 }
625
626 async fn chat_completion_stream(
627 &self,
628 mut request: ChatRequest,
629 _context: RequestContext,
630 ) -> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk, ProviderError>> + Send>>, ProviderError>
631 {
632 debug!("GitHub Copilot streaming request: model={}", request.model);
633
634 self.transform_messages(&mut request.messages);
636
637 request.stream = true;
639
640 let headers = self.build_headers(&request.messages).await?;
642 self.require_endpoint(&request.model, "/chat/completions", &headers)
643 .await?;
644
645 let api_base = self.get_api_base().await;
647 let url = format!("{}/chat/completions", api_base.trim_end_matches('/'));
648
649 let client = crate::core::http::outbound::streaming_outbound_client().clone();
651 let response = crate::core::providers::base::connection_pool::send_streaming_request(
652 client.post(&url).headers(headers).json(&request),
653 "github_copilot",
654 )
655 .await?;
656
657 if !response.status().is_success() {
658 let status = response.status().as_u16();
659 let body_str =
660 crate::core::providers::base::connection_pool::read_streaming_error_body(response)
661 .await
662 .map_err(|err| err.into_provider_error("github_copilot"))?;
663
664 if status == 401 {
666 self.clear_cache().await;
667 }
668
669 return Err(match status {
670 401 => ProviderError::authentication("github_copilot", "Invalid API key or token"),
671 404 => ProviderError::model_not_found("github_copilot", body_str.clone()),
672 429 => ProviderError::rate_limit("github_copilot", None),
673 400 => ProviderError::invalid_request("github_copilot", body_str.clone()),
674 500..=599 => {
675 ProviderError::provider_unavailable("github_copilot", body_str.clone())
676 }
677 _ => HttpErrorMapper::map_status_code("github_copilot", status, &body_str),
678 });
679 }
680
681 let stream = GitHubCopilotStream::new(response.bytes_stream());
683 Ok(Box::pin(stream))
684 }
685
686 async fn embeddings(
687 &self,
688 request: EmbeddingRequest,
689 _context: RequestContext,
690 ) -> Result<EmbeddingResponse, ProviderError> {
691 debug!("GitHub Copilot embeddings request: model={}", request.model);
692
693 let api_key = self.get_api_key().await?;
695 let headers_map = get_copilot_default_headers(&api_key);
696 let mut headers = reqwest::header::HeaderMap::new();
697 for (key, value) in headers_map {
698 if let (Ok(name), Ok(val)) = (
699 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
700 value.parse(),
701 ) {
702 headers.insert(name, val);
703 }
704 }
705
706 let api_base = self.get_api_base().await;
708 let url = format!("{}/embeddings", api_base.trim_end_matches('/'));
709
710 let client = crate::core::http::outbound::default_outbound_client().clone();
712 let response = client
713 .post(&url)
714 .headers(headers)
715 .json(&request)
716 .send()
717 .await
718 .map_err(|e| ProviderError::network("github_copilot", e.to_string()))?;
719
720 let status = response.status();
721 let body = response
722 .bytes()
723 .await
724 .map_err(|e| ProviderError::network("github_copilot", e.to_string()))?;
725
726 if !status.is_success() {
727 let body_str = String::from_utf8_lossy(&body);
728 let status_code = status.as_u16();
729 return Err(match status_code {
730 401 => ProviderError::authentication("github_copilot", "Invalid API key or token"),
731 404 => ProviderError::model_not_found("github_copilot", body_str.to_string()),
732 429 => ProviderError::rate_limit("github_copilot", None),
733 400 => ProviderError::invalid_request("github_copilot", body_str.to_string()),
734 500..=599 => {
735 ProviderError::provider_unavailable("github_copilot", body_str.to_string())
736 }
737 _ => HttpErrorMapper::map_status_code("github_copilot", status_code, &body_str),
738 });
739 }
740
741 serde_json::from_slice(&body).map_err(|e| {
742 ProviderError::api_error(
743 "github_copilot",
744 500,
745 format!("Failed to parse response: {}", e),
746 )
747 })
748 }
749
750 async fn health_check(&self) -> HealthStatus {
751 match self.get_api_key().await {
753 Ok(_) => HealthStatus::Healthy,
754 Err(_) => HealthStatus::Unhealthy,
755 }
756 }
757
758 async fn calculate_cost(
759 &self,
760 _model: &str,
761 _input_tokens: u32,
762 _output_tokens: u32,
763 ) -> Result<f64, ProviderError> {
764 Ok(0.0)
766 }
767}
768
769pub struct GitHubCopilotStream {
771 inner: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
772 buffer: String,
773}
774
775impl GitHubCopilotStream {
776 pub fn new(stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static) -> Self {
777 Self {
778 inner: Box::pin(stream),
779 buffer: String::new(),
780 }
781 }
782
783 fn parse_sse_line(&self, line: &str) -> Option<Result<ChatChunk, ProviderError>> {
784 if line.is_empty() || line.starts_with(':') {
785 return None;
786 }
787
788 if let Some(data) = line.strip_prefix("data: ") {
789 let data = data.trim();
790
791 if is_done_marker(data) {
792 return None;
793 }
794
795 match serde_json::from_str::<ChatChunk>(data) {
796 Ok(chunk) => Some(Ok(chunk)),
797 Err(e) => Some(Err(ProviderError::api_error(
798 "github_copilot",
799 500,
800 format!("Failed to parse chunk: {}", e),
801 ))),
802 }
803 } else {
804 None
805 }
806 }
807}
808
809impl Stream for GitHubCopilotStream {
810 type Item = Result<ChatChunk, ProviderError>;
811
812 fn poll_next(
813 mut self: Pin<&mut Self>,
814 cx: &mut std::task::Context<'_>,
815 ) -> std::task::Poll<Option<Self::Item>> {
816 loop {
817 if let Some(newline_pos) = self.buffer.find('\n') {
819 let line = self.buffer[..newline_pos].to_string();
820 self.buffer = self.buffer[newline_pos + 1..].to_string();
821
822 if let Some(result) = self.parse_sse_line(&line) {
823 return std::task::Poll::Ready(Some(result));
824 }
825 continue;
826 }
827
828 match self.inner.as_mut().poll_next(cx) {
830 std::task::Poll::Ready(Some(Ok(bytes))) => {
831 self.buffer.push_str(&String::from_utf8_lossy(&bytes));
832 }
833 std::task::Poll::Ready(Some(Err(e))) => {
834 return std::task::Poll::Ready(Some(Err(ProviderError::network(
835 "github_copilot",
836 e.to_string(),
837 ))));
838 }
839 std::task::Poll::Ready(None) => {
840 if !self.buffer.is_empty() {
842 let line = std::mem::take(&mut self.buffer);
843 if let Some(result) = self.parse_sse_line(&line) {
844 return std::task::Poll::Ready(Some(result));
845 }
846 }
847 return std::task::Poll::Ready(None);
848 }
849 std::task::Poll::Pending => return std::task::Poll::Pending,
850 }
851 }
852 }
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858 use crate::core::types::message::MessageContent;
859
860 #[tokio::test]
861 async fn test_github_copilot_provider_creation() {
862 let provider = GitHubCopilotProvider::new(GitHubCopilotConfig::default())
863 .await
864 .expect("provider should build");
865 assert_eq!(provider.name(), "github_copilot");
866 }
867
868 #[tokio::test]
869 async fn test_github_copilot_provider_capabilities() {
870 let provider = GitHubCopilotProvider::new(GitHubCopilotConfig::default())
871 .await
872 .expect("provider should build");
873 let capabilities = provider.capabilities();
874
875 assert!(capabilities.contains(&ProviderCapability::ChatCompletion));
876 assert!(capabilities.contains(&ProviderCapability::ChatCompletionStream));
877 assert!(capabilities.contains(&ProviderCapability::ToolCalling));
878 }
879
880 #[tokio::test]
881 async fn test_github_copilot_provider_models() {
882 let provider = GitHubCopilotProvider::new(GitHubCopilotConfig::default())
883 .await
884 .expect("provider should build");
885 let models = provider.models();
886
887 assert!(!models.is_empty());
888 let model_ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
889 assert!(model_ids.contains(&"gpt-6-sol"));
890 assert!(model_ids.contains(&"claude-opus-5.5"));
891 }
892
893 #[tokio::test]
894 async fn test_github_copilot_provider_supported_params() {
895 let provider = GitHubCopilotProvider::new(GitHubCopilotConfig::default())
896 .await
897 .expect("provider should build");
898
899 let params = provider.get_supported_openai_params("gpt-6-sol");
900 assert!(params.contains(&"temperature"));
901 assert!(params.contains(&"max_tokens"));
902 assert!(params.contains(&"tools"));
903 assert!(params.contains(&"reasoning_effort"));
904
905 let params = provider.get_supported_openai_params("gpt-6-astra");
906 assert!(params.contains(&"reasoning_effort"));
907
908 let params = provider.get_supported_openai_params("claude-opus-5.5");
909 assert!(params.contains(&"thinking"));
910 assert!(params.contains(&"reasoning_effort"));
911 }
912
913 #[test]
914 fn test_github_copilot_stream_parse_done_marker() {
915 let stream = futures::stream::empty::<Result<Bytes, reqwest::Error>>();
916 let parser = GitHubCopilotStream::new(stream);
917 assert!(parser.parse_sse_line("data: [DONE]").is_none());
918 }
919
920 #[test]
921 fn test_github_copilot_stream_parse_valid_chunk() {
922 let stream = futures::stream::empty::<Result<Bytes, reqwest::Error>>();
923 let parser = GitHubCopilotStream::new(stream);
924 let line = r#"data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
925
926 let parsed = parser
927 .parse_sse_line(line)
928 .expect("expected parser to return a chunk result");
929 let chunk = parsed.expect("expected valid chat chunk");
930 assert_eq!(chunk.id, "chatcmpl-123");
931 assert_eq!(chunk.choices.len(), 1);
932 }
933
934 fn text_message(role: MessageRole, text: &str) -> ChatMessage {
935 ChatMessage {
936 role,
937 content: Some(MessageContent::Text(text.to_string())),
938 ..Default::default()
939 }
940 }
941
942 #[test]
943 fn test_determine_initiator() {
944 let config = GitHubCopilotConfig::default();
945 let provider = GitHubCopilotProvider {
946 authenticator: CopilotAuthenticator::new(&config),
947 config,
948 models: vec![],
949 cached_api_key: Arc::new(RwLock::new(None)),
950 cached_api_base: Arc::new(RwLock::new(None)),
951 #[cfg(test)]
952 native_endpoint_access: crate::core::net::ProviderEndpointAccess::PublicOnly,
953 };
954
955 let messages = vec![text_message(MessageRole::User, "Hello")];
956 assert_eq!(provider.determine_initiator(&messages), "user");
957
958 let messages = vec![
959 text_message(MessageRole::User, "Hello"),
960 text_message(MessageRole::Assistant, "Hi!"),
961 ];
962 assert_eq!(provider.determine_initiator(&messages), "agent");
963 }
964
965 #[tokio::test]
966 async fn test_github_copilot_provider_cost_calculation() {
967 let provider = GitHubCopilotProvider::new(GitHubCopilotConfig::default())
968 .await
969 .expect("provider should build");
970
971 let cost = provider.calculate_cost("gpt-4o", 1000, 500).await;
972 assert!(cost.is_ok());
973 assert_eq!(cost.expect("cost should be available"), 0.0);
974 }
975}
976
977#[cfg(all(test, feature = "gateway"))]
978#[path = "native_tests.rs"]
979mod native_tests;