Skip to main content

litellm_rs/core/providers/github_copilot/
provider.rs

1//! Main GitHub Copilot Provider Implementation
2//!
3//! Implements the LLMProvider trait for GitHub Copilot API.
4//! Handles OAuth authentication and OpenAI-compatible chat completions.
5
6use 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
36/// Static capabilities for GitHub Copilot provider
37const GITHUB_COPILOT_CAPABILITIES: &[ProviderCapability] = &[
38    ProviderCapability::ChatCompletion,
39    ProviderCapability::ChatCompletionStream,
40    ProviderCapability::ToolCalling,
41    #[cfg(feature = "gateway")]
42    ProviderCapability::Responses,
43];
44
45/// GitHub Copilot provider implementation
46#[derive(Debug)]
47pub struct GitHubCopilotProvider {
48    config: GitHubCopilotConfig,
49    authenticator: CopilotAuthenticator,
50    models: Vec<ModelInfo>,
51    /// Cached API key
52    cached_api_key: Arc<RwLock<Option<String>>>,
53    /// Cached API base
54    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    /// Create a new GitHub Copilot provider instance
79    pub async fn new(config: GitHubCopilotConfig) -> Result<Self, ProviderError> {
80        let authenticator = CopilotAuthenticator::new(&config);
81
82        // Build model list from static configuration
83        let models = get_available_models()
84            .iter()
85            .filter_map(|id| get_model_info(id))
86            .map(|info| {
87                // Endpoint support is account-specific and checked against
88                // /models before inference; static IDs do not imply chat support.
89                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, // Copilot is subscription-based
104                    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    /// The account model catalog, not the model name or price table, decides
148    /// whether a request may use a particular Copilot endpoint.
149    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        // Discovery/authentication failures above cannot have created a response.
274        // Once send is polled, a transport failure can hide an accepted POST.
275        *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    /// Get the API key, using cache or refreshing if needed
287    async fn get_api_key(&self) -> Result<String, ProviderError> {
288        // Check cache first
289        {
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        // Get fresh key
297        let key = self.authenticator.get_api_key().await?;
298
299        // Update cache
300        {
301            let mut cache = self.cached_api_key.write().await;
302            *cache = Some(key.clone());
303        }
304
305        // Also update API base cache
306        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    /// Get the API base URL
315    async fn get_api_base(&self) -> String {
316        // Check cache first
317        {
318            let cache = self.cached_api_base.read().await;
319            if let Some(ref base) = *cache {
320                return base.clone();
321            }
322        }
323
324        // Use config or authenticator
325        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    /// Clear cached credentials (for refresh)
333    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    /// Transform messages for Copilot API
345    fn transform_messages(&self, messages: &mut [ChatMessage]) {
346        if self.config.disable_system_to_assistant {
347            return;
348        }
349
350        // Convert system messages to assistant messages (Copilot requirement)
351        for message in messages.iter_mut() {
352            if message.role == MessageRole::System {
353                message.role = MessageRole::Assistant;
354            }
355        }
356    }
357
358    /// Determine X-Initiator header value
359    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    /// Check if request contains vision content
369    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    /// Build request headers
385    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        // Add X-Initiator header
411        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        // Add Copilot-Vision-Request if contains images
423        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        // GitHub Copilot uses the same parameters as OpenAI
524        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        // Transform messages
533        self.transform_messages(&mut request.messages);
534
535        // Convert to JSON value
536        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        // Transform messages
569        self.transform_messages(&mut request.messages);
570
571        // Build headers
572        let headers = self.build_headers(&request.messages).await?;
573        self.require_endpoint(&request.model, "/chat/completions", &headers)
574            .await?;
575
576        // Build URL
577        let api_base = self.get_api_base().await;
578        let url = format!("{}/chat/completions", api_base.trim_end_matches('/'));
579
580        // Execute request
581        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            // Clear cache on auth errors
601            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        // Transform messages
635        self.transform_messages(&mut request.messages);
636
637        // Enable streaming
638        request.stream = true;
639
640        // Build headers
641        let headers = self.build_headers(&request.messages).await?;
642        self.require_endpoint(&request.model, "/chat/completions", &headers)
643            .await?;
644
645        // Build URL
646        let api_base = self.get_api_base().await;
647        let url = format!("{}/chat/completions", api_base.trim_end_matches('/'));
648
649        // Execute request
650        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            // Clear cache on auth errors
665            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        // Create SSE stream
682        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        // Build headers
694        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        // Build URL
707        let api_base = self.get_api_base().await;
708        let url = format!("{}/embeddings", api_base.trim_end_matches('/'));
709
710        // Execute request
711        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        // Try to get API key as health check
752        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        // GitHub Copilot is subscription-based, no per-token cost
765        Ok(0.0)
766    }
767}
768
769/// SSE stream implementation for GitHub Copilot
770pub 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            // Check if we have complete lines in the buffer
818            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            // Need more data
829            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                    // Stream ended, check remaining buffer
841                    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;