1mod auth;
24
25use crate::client::{
26 self, ApiKey, Capabilities, Capable, DebugExt, ModelLister, Nothing, Provider, ProviderBuilder,
27 ProviderClient, Transport,
28};
29use crate::completion::{self, CompletionError, GetTokenUsage};
30use crate::embeddings::{self, EmbeddingError};
31use crate::http_client::{self, HttpClientExt};
32use crate::model::{Model, ModelList, ModelListingError};
33use crate::providers::internal::openai_chat_completions_compatible::{
34 self, CompatibleChoiceData, CompatibleChunk, CompatibleFinishReason, CompatibleStreamProfile,
35 CompatibleToolCallChunk,
36};
37use crate::providers::openai;
38use crate::providers::openai::responses_api::{self, CompletionRequest as ResponsesRequest};
39use crate::streaming::{self, RawStreamingChoice, StreamingCompletionResponse};
40use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
41use async_stream::stream;
42use futures::StreamExt;
43use http::Request;
44use serde::{Deserialize, Serialize};
45use serde_json::json;
46use std::borrow::Cow;
47use std::collections::HashMap;
48use std::fmt::Debug;
49use std::path::{Path, PathBuf};
50use tracing::info_span;
51use tracing_futures::Instrument as _;
52
53const GITHUB_COPILOT_API_BASE_URL: &str = "https://api.githubcopilot.com";
54pub(crate) const EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
55pub(crate) const USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
56pub(crate) const EDITOR_VERSION: &str = "vscode/1.107.0";
57const API_VERSION: &str = "2025-04-01";
58
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub enum CopilotIntent {
62 #[default]
64 Panel,
65 Edits,
67}
68
69impl CopilotIntent {
70 fn as_header(self) -> &'static str {
71 match self {
72 Self::Panel => "conversation-panel",
73 Self::Edits => "conversation-edits",
74 }
75 }
76}
77
78pub const GPT_4: &str = "gpt-4";
80pub const GPT_4O: &str = "gpt-4o";
82pub const GPT_4O_MINI: &str = "gpt-4o-mini";
84pub const GPT_4_1: &str = "gpt-4.1";
86pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
88pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
90pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
92pub const GPT_5_1_CODEX: &str = "gpt-5.1-codex";
94pub const GPT_5_5: &str = "gpt-5.5";
96pub const GPT_5_4: &str = "gpt-5.4";
98pub const CLAUDE_SONNET_4: &str = "claude-sonnet-4";
100pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4.6";
102pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4.6";
104pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4.7";
106pub const CLAUDE_3_5_SONNET: &str = "claude-3.5-sonnet";
108pub const GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
110pub const GEMINI_3_1_PRO_FLASH: &str = "gemini-3.1-pro-preview";
112pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash-001";
114pub const O3_MINI: &str = "o3-mini";
116pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
118pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
120pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
122
123pub use openai::EncodingFormat;
124
125#[derive(Clone)]
126pub enum CopilotAuth {
127 ApiKey(String),
128 GitHubAccessToken(String),
129 OAuth,
130}
131
132impl ApiKey for CopilotAuth {}
133
134impl<S> From<S> for CopilotAuth
135where
136 S: Into<String>,
137{
138 fn from(value: S) -> Self {
139 Self::ApiKey(value.into())
140 }
141}
142
143impl Debug for CopilotAuth {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
147 Self::GitHubAccessToken(_) => f.write_str("GitHubAccessToken(<redacted>)"),
148 Self::OAuth => f.write_str("OAuth"),
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
154pub struct CopilotBuilder {
155 access_token_file: Option<PathBuf>,
156 api_key_file: Option<PathBuf>,
157 device_code_handler: auth::DeviceCodeHandler,
158}
159
160#[derive(Clone)]
161pub struct CopilotExt {
162 auth: auth::Authenticator,
163}
164
165impl Debug for CopilotExt {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.debug_struct("CopilotExt")
168 .field("auth", &self.auth)
169 .finish()
170 }
171}
172
173pub type Client<H = reqwest::Client> = client::Client<CopilotExt, H>;
174pub type ClientBuilder<H = crate::markers::Missing> =
175 client::ClientBuilder<CopilotBuilder, CopilotAuth, H>;
176
177impl Default for CopilotBuilder {
178 fn default() -> Self {
179 let token_dir = default_token_dir();
180 Self {
181 access_token_file: token_dir.as_ref().map(|dir| dir.join("access-token")),
182 api_key_file: token_dir.map(|dir| dir.join("api-key.json")),
183 device_code_handler: auth::DeviceCodeHandler::default(),
184 }
185 }
186}
187
188impl Provider for CopilotExt {
189 type Builder = CopilotBuilder;
190
191 const VERIFY_PATH: &'static str = "";
192}
193
194impl<H> Capabilities<H> for CopilotExt {
195 type Completion = Capable<CompletionModel<H>>;
196 type Embeddings = Capable<EmbeddingModel<H>>;
197 type Transcription = Nothing;
198 type ModelListing = Capable<CopilotModelLister<H>>;
199 #[cfg(feature = "image")]
200 type ImageGeneration = Nothing;
201 #[cfg(feature = "audio")]
202 type AudioGeneration = Nothing;
203 type Rerank = Nothing;
204}
205
206impl DebugExt for CopilotExt {}
207
208impl ProviderBuilder for CopilotBuilder {
209 type Extension<H>
210 = CopilotExt
211 where
212 H: HttpClientExt;
213 type ApiKey = CopilotAuth;
214
215 const BASE_URL: &'static str = GITHUB_COPILOT_API_BASE_URL;
216
217 fn build<H>(
218 builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
219 ) -> http_client::Result<Self::Extension<H>>
220 where
221 H: HttpClientExt,
222 {
223 let auth = match builder.get_api_key() {
224 CopilotAuth::ApiKey(api_key) => auth::AuthSource::ApiKey(api_key.clone()),
225 CopilotAuth::GitHubAccessToken(access_token) => {
226 auth::AuthSource::GitHubAccessToken(access_token.clone())
227 }
228 CopilotAuth::OAuth => auth::AuthSource::OAuth,
229 };
230
231 let ext = builder.ext();
232 Ok(CopilotExt {
233 auth: auth::Authenticator::new(
234 auth,
235 ext.access_token_file.clone(),
236 ext.api_key_file.clone(),
237 ext.device_code_handler.clone(),
238 ),
239 })
240 }
241}
242
243impl ProviderClient for Client {
244 type Input = CopilotAuth;
245 type Error = crate::client::ProviderClientError;
246
247 fn from_env() -> Result<Self, Self::Error> {
248 let mut builder = Self::builder();
249 fn get(name: &str) -> Option<String> {
250 std::env::var(name).ok()
251 }
252
253 if let Some(base_url) = env_base_url(&get) {
254 builder = builder.base_url(base_url);
255 }
256
257 if let Some(api_key) = env_api_key(&get) {
258 builder.api_key(api_key).build().map_err(Into::into)
259 } else if let Some(access_token) = env_github_access_token(&get) {
260 builder
261 .github_access_token(access_token)
262 .build()
263 .map_err(Into::into)
264 } else {
265 builder.oauth().build().map_err(Into::into)
266 }
267 }
268
269 fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
270 Self::builder().api_key(input).build().map_err(Into::into)
271 }
272}
273
274impl<H> client::ClientBuilder<CopilotBuilder, crate::markers::Missing, H> {
275 pub fn github_access_token(
276 self,
277 access_token: impl Into<String>,
278 ) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
279 self.api_key(CopilotAuth::GitHubAccessToken(access_token.into()))
280 }
281
282 pub fn oauth(self) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
283 self.api_key(CopilotAuth::OAuth)
284 }
285}
286
287impl<H> ClientBuilder<H> {
288 pub fn on_device_code<F>(self, handler: F) -> Self
289 where
290 F: Fn(auth::DeviceCodePrompt) + Send + Sync + 'static,
291 {
292 self.over_ext(|mut ext| {
293 ext.device_code_handler = auth::DeviceCodeHandler::new(handler);
294 ext
295 })
296 }
297
298 pub fn token_dir(self, path: impl AsRef<Path>) -> Self {
299 let path = path.as_ref();
300 self.over_ext(|mut ext| {
301 ext.access_token_file = Some(path.join("access-token"));
302 ext.api_key_file = Some(path.join("api-key.json"));
303 ext
304 })
305 }
306
307 pub fn access_token_file(self, path: impl AsRef<Path>) -> Self {
308 let path = path.as_ref().to_path_buf();
309 self.over_ext(|mut ext| {
310 ext.access_token_file = Some(path);
311 ext
312 })
313 }
314
315 pub fn api_key_file(self, path: impl AsRef<Path>) -> Self {
316 let path = path.as_ref().to_path_buf();
317 self.over_ext(|mut ext| {
318 ext.api_key_file = Some(path);
319 ext
320 })
321 }
322}
323
324fn env_value<F>(get: &F, name: &str) -> Option<String>
325where
326 F: Fn(&str) -> Option<String>,
327{
328 get(name).filter(|value| !value.trim().is_empty())
329}
330
331fn first_env_value<F>(get: &F, keys: &[&str]) -> Option<String>
332where
333 F: Fn(&str) -> Option<String>,
334{
335 keys.iter().find_map(|key| env_value(get, key))
336}
337
338fn env_api_key<F>(get: &F) -> Option<String>
339where
340 F: Fn(&str) -> Option<String>,
341{
342 first_env_value(get, &["GITHUB_COPILOT_API_KEY", "COPILOT_API_KEY"])
343}
344
345fn env_github_access_token<F>(get: &F) -> Option<String>
346where
347 F: Fn(&str) -> Option<String>,
348{
349 first_env_value(get, &["COPILOT_GITHUB_ACCESS_TOKEN", "GITHUB_TOKEN"])
350}
351
352fn env_base_url<F>(get: &F) -> Option<String>
353where
354 F: Fn(&str) -> Option<String>,
355{
356 first_env_value(get, &["GITHUB_COPILOT_API_BASE", "COPILOT_BASE_URL"])
357}
358
359impl<H> Client<H>
360where
361 H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
362{
363 pub async fn authorize(&self) -> Result<(), auth::AuthError> {
364 self.ext().auth.auth_context().await.map(|_| ())
365 }
366}
367
368fn default_headers(
369 api_key: &str,
370 initiator: &'static str,
371 has_vision: bool,
372 intent: CopilotIntent,
373) -> Vec<(&'static str, String)> {
374 let mut headers = vec![
375 (
376 http::header::AUTHORIZATION.as_str(),
377 format!("Bearer {api_key}"),
378 ),
379 ("copilot-integration-id", "vscode-chat".to_string()),
380 ("editor-version", EDITOR_VERSION.to_string()),
381 ("editor-plugin-version", EDITOR_PLUGIN_VERSION.to_string()),
382 ("user-agent", USER_AGENT.to_string()),
383 ("openai-intent", intent.as_header().to_string()),
384 ("x-github-api-version", API_VERSION.to_string()),
385 ("x-request-id", nanoid::nanoid!()),
386 (
387 "x-vscode-user-agent-library-version",
388 "electron-fetch".to_string(),
389 ),
390 ("X-Initiator", initiator.to_string()),
391 ];
392
393 if has_vision {
394 headers.push(("copilot-vision-request", "true".to_string()));
395 }
396
397 headers
398}
399
400fn apply_headers(
401 builder: http_client::Builder,
402 headers: &[(&'static str, String)],
403) -> http_client::Builder {
404 headers
405 .iter()
406 .fold(builder, |builder, (key, value)| builder.header(*key, value))
407}
408
409fn runtime_base_url<'a, H>(client: &'a Client<H>, auth: &'a auth::AuthContext) -> Cow<'a, str> {
410 if client.base_url() != GITHUB_COPILOT_API_BASE_URL {
411 return Cow::Borrowed(client.base_url());
412 }
413
414 if let Some(api_base) = auth.api_base.as_deref() {
415 return Cow::Borrowed(api_base);
416 }
417
418 if let Some(base_url) = base_url_from_token(&auth.api_key) {
419 return Cow::Owned(base_url);
420 }
421
422 Cow::Borrowed(client.base_url())
423}
424
425fn base_url_from_token(token: &str) -> Option<String> {
432 let proxy_ep = token
433 .split(';')
434 .find_map(|part| part.trim().strip_prefix("proxy-ep="))?
435 .trim();
436
437 normalize_copilot_proxy_endpoint(proxy_ep)
438}
439
440fn normalize_copilot_proxy_endpoint(proxy_ep: &str) -> Option<String> {
441 if proxy_ep.is_empty() {
442 return None;
443 }
444
445 let candidate = if proxy_ep.starts_with("http://") || proxy_ep.starts_with("https://") {
446 proxy_ep.to_string()
447 } else {
448 format!("https://{proxy_ep}")
449 };
450
451 let mut url = url::Url::parse(&candidate).ok()?;
452 if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
453 return None;
454 }
455 if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
456 return None;
457 }
458
459 let host = url.host_str()?.to_ascii_lowercase();
460 if !is_allowed_token_derived_copilot_host(&host) {
461 return None;
462 }
463
464 let api_host = host
465 .strip_prefix("proxy.")
466 .map(|suffix| format!("api.{suffix}"))
467 .unwrap_or(host);
468 url.set_host(Some(&api_host)).ok()?;
469
470 Some(url.to_string().trim_end_matches('/').to_string())
471}
472
473fn is_allowed_token_derived_copilot_host(host: &str) -> bool {
474 host == "githubcopilot.com" || host.ends_with(".githubcopilot.com")
475}
476
477fn post_with_auth_base<H>(
478 client: &Client<H>,
479 auth: &auth::AuthContext,
480 path: &str,
481 transport: Transport,
482) -> http_client::Result<http_client::Builder> {
483 let uri = client
484 .ext()
485 .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
486 let mut req = Request::post(uri);
487
488 if let Some(headers) = req.headers_mut() {
489 headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
490 }
491
492 client.ext().with_custom(req)
493}
494
495fn get_with_auth_base<H>(
496 client: &Client<H>,
497 auth: &auth::AuthContext,
498 path: &str,
499 transport: Transport,
500) -> http_client::Result<http_client::Builder> {
501 let uri = client
502 .ext()
503 .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
504 let mut req = Request::get(uri);
505
506 if let Some(headers) = req.headers_mut() {
507 headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
508 }
509
510 client.ext().with_custom(req)
511}
512
513fn request_initiator(request: &completion::CompletionRequest) -> &'static str {
514 for message in request.chat_history.iter() {
515 match message {
516 crate::completion::Message::Assistant { .. } => return "agent",
517 crate::completion::Message::User { content } => {
518 if content
519 .iter()
520 .any(|item| matches!(item, crate::message::UserContent::ToolResult(_)))
521 {
522 return "agent";
523 }
524 }
525 crate::completion::Message::System { .. } => {}
526 }
527 }
528
529 "user"
530}
531
532fn request_has_vision(request: &completion::CompletionRequest) -> bool {
533 request.chat_history.iter().any(|message| match message {
534 crate::completion::Message::User { content } => content
535 .iter()
536 .any(|item| matches!(item, crate::message::UserContent::Image(_))),
537 _ => false,
538 })
539}
540
541#[derive(Clone, Copy, Debug, PartialEq, Eq)]
542enum CompletionRoute {
543 ChatCompletions,
544 Responses,
545}
546
547fn route_for_model(model: &str) -> CompletionRoute {
548 if model.to_ascii_lowercase().contains("codex") {
549 CompletionRoute::Responses
550 } else {
551 CompletionRoute::ChatCompletions
552 }
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556#[serde(tag = "api", rename_all = "snake_case")]
557pub enum CopilotCompletionResponse {
558 Chat(ChatCompletionResponse),
559 Responses(Box<responses_api::CompletionResponse>),
560}
561
562#[derive(Clone, Serialize, Deserialize)]
563#[serde(tag = "api", rename_all = "snake_case")]
564pub enum CopilotStreamingResponse {
565 Chat(openai::completion::streaming::StreamingCompletionResponse),
566 Responses(responses_api::streaming::StreamingCompletionResponse),
567}
568
569impl GetTokenUsage for CopilotStreamingResponse {
570 fn token_usage(&self) -> completion::Usage {
571 match self {
572 Self::Chat(response) => response.token_usage(),
573 Self::Responses(response) => response.token_usage(),
574 }
575 }
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct ChatCompletionResponse {
580 pub id: String,
581 #[serde(default)]
582 pub object: Option<String>,
583 #[serde(default)]
584 pub created: Option<u64>,
585 pub model: String,
586 pub system_fingerprint: Option<String>,
587 pub choices: Vec<ChatChoice>,
588 pub usage: Option<openai::completion::Usage>,
589}
590
591#[derive(Clone, Debug, Serialize, Deserialize)]
592pub struct ChatChoice {
593 #[serde(default)]
594 pub index: usize,
595 pub message: openai::completion::Message,
596 pub logprobs: Option<serde_json::Value>,
597 #[serde(default)]
598 pub finish_reason: Option<String>,
599}
600
601impl TryFrom<ChatCompletionResponse> for completion::CompletionResponse<ChatCompletionResponse> {
602 type Error = CompletionError;
603
604 fn try_from(response: ChatCompletionResponse) -> Result<Self, Self::Error> {
605 let choice = response.choices.first().ok_or_else(|| {
606 CompletionError::ResponseError("Response contained no choices".to_owned())
607 })?;
608
609 let content = match &choice.message {
610 openai::completion::Message::Assistant {
611 content,
612 tool_calls,
613 ..
614 } => {
615 let mut content = content
616 .iter()
617 .filter_map(|c| {
618 let s = match c {
619 openai::completion::AssistantContent::Text { text } => text,
620 openai::completion::AssistantContent::Refusal { refusal } => refusal,
621 };
622 if s.is_empty() {
623 None
624 } else {
625 Some(completion::AssistantContent::text(s))
626 }
627 })
628 .collect::<Vec<_>>();
629
630 content.extend(
631 tool_calls
632 .iter()
633 .map(|call| {
634 completion::AssistantContent::tool_call(
635 &call.id,
636 &call.function.name,
637 call.function.arguments.clone(),
638 )
639 })
640 .collect::<Vec<_>>(),
641 );
642 Ok(content)
643 }
644 _ => Err(CompletionError::ResponseError(
645 "Response did not contain a valid message or tool call".into(),
646 )),
647 }?;
648
649 let choice = crate::OneOrMany::many(content).map_err(|_| {
650 CompletionError::ResponseError(
651 "Response contained no message or tool call (empty)".to_owned(),
652 )
653 })?;
654
655 let usage = response
656 .usage
657 .as_ref()
658 .map(|usage| completion::Usage {
659 input_tokens: usage.prompt_tokens as u64,
660 output_tokens: (usage.total_tokens - usage.prompt_tokens) as u64,
661 total_tokens: usage.total_tokens as u64,
662 cached_input_tokens: usage
663 .prompt_tokens_details
664 .as_ref()
665 .map(|d| d.cached_tokens as u64)
666 .unwrap_or(0),
667 cache_creation_input_tokens: 0,
668 tool_use_prompt_tokens: 0,
669 reasoning_tokens: 0,
670 })
671 .unwrap_or_default();
672
673 Ok(completion::CompletionResponse {
674 choice,
675 usage,
676 raw_response: response,
677 message_id: None,
678 })
679 }
680}
681
682#[derive(Debug, Deserialize)]
683pub struct ChatApiErrorResponse {
684 #[serde(default)]
685 pub message: Option<String>,
686 #[serde(default)]
687 pub error: Option<String>,
688}
689
690impl ChatApiErrorResponse {
691 pub fn error_message(&self) -> &str {
692 self.message
693 .as_deref()
694 .or(self.error.as_deref())
695 .unwrap_or("unknown error")
696 }
697}
698
699#[derive(Debug, Deserialize)]
700#[serde(untagged)]
701enum ChatApiResponse<T> {
702 Ok(T),
703 Err(ChatApiErrorResponse),
704}
705
706#[derive(Clone)]
707pub struct CompletionModel<H = reqwest::Client> {
708 client: Client<H>,
709 pub model: String,
710 pub strict_tools: bool,
711 pub tool_result_array_content: bool,
712 pub intent: CopilotIntent,
713}
714
715impl<H> CompletionModel<H>
716where
717 Client<H>: HttpClientExt + Clone + Debug + 'static,
718 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
719{
720 pub fn new(client: Client<H>, model: impl Into<String>) -> Self {
721 Self {
722 client,
723 model: model.into(),
724 strict_tools: false,
725 tool_result_array_content: false,
726 intent: CopilotIntent::default(),
727 }
728 }
729
730 pub fn with_strict_tools(mut self) -> Self {
731 self.strict_tools = true;
732 self
733 }
734
735 pub fn with_tool_result_array_content(mut self) -> Self {
736 self.tool_result_array_content = true;
737 self
738 }
739
740 pub fn with_intent(mut self, intent: CopilotIntent) -> Self {
742 self.intent = intent;
743 self
744 }
745
746 pub fn with_panel_intent(self) -> Self {
748 self.with_intent(CopilotIntent::Panel)
749 }
750
751 pub fn with_edits_intent(self) -> Self {
753 self.with_intent(CopilotIntent::Edits)
754 }
755
756 fn route(&self) -> CompletionRoute {
757 route_for_model(&self.model)
758 }
759
760 async fn auth_context(&self) -> Result<auth::AuthContext, CompletionError> {
761 self.client
762 .ext()
763 .auth
764 .auth_context()
765 .await
766 .map_err(|err| CompletionError::ProviderError(err.to_string()))
767 }
768
769 fn chat_request(
770 &self,
771 completion_request: completion::CompletionRequest,
772 ) -> Result<openai::completion::CompletionRequest, CompletionError> {
773 openai::completion::CompletionRequest::try_from(openai::completion::OpenAIRequestParams {
774 model: self.model.clone(),
775 request: completion_request,
776 strict_tools: self.strict_tools,
777 tool_result_array_content: self.tool_result_array_content,
778 })
779 }
780
781 fn responses_request(
782 &self,
783 completion_request: completion::CompletionRequest,
784 ) -> Result<ResponsesRequest, CompletionError> {
785 ResponsesRequest::try_from((self.model.clone(), completion_request))
786 }
787
788 async fn completion_chat(
789 &self,
790 completion_request: completion::CompletionRequest,
791 ) -> Result<completion::CompletionResponse<CopilotCompletionResponse>, CompletionError> {
792 let initiator = request_initiator(&completion_request);
793 let has_vision = request_has_vision(&completion_request);
794 let request = self.chat_request(completion_request)?;
795 let body = serde_json::to_vec(&request)?;
796 let auth = self.auth_context().await?;
797
798 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
799 let req = apply_headers(
800 post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Http)?,
801 &headers,
802 )
803 .body(body)
804 .map_err(|err| CompletionError::HttpError(err.into()))?;
805
806 let span = if tracing::Span::current().is_disabled() {
807 info_span!(
808 target: "rig::completions",
809 "chat",
810 gen_ai.operation.name = "chat",
811 gen_ai.provider.name = "copilot",
812 gen_ai.request.model = self.model,
813 gen_ai.response.id = tracing::field::Empty,
814 gen_ai.response.model = tracing::field::Empty,
815 gen_ai.usage.output_tokens = tracing::field::Empty,
816 gen_ai.usage.input_tokens = tracing::field::Empty,
817 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
818 )
819 } else {
820 tracing::Span::current()
821 };
822
823 async move {
824 let response = self.client.send(req).await?;
825
826 if response.status().is_success() {
827 let body = http_client::text(response).await?;
828 match serde_json::from_str::<ChatApiResponse<ChatCompletionResponse>>(&body)? {
829 ChatApiResponse::Ok(response) => {
830 let core = completion::CompletionResponse::try_from(response.clone())?;
831 let span = tracing::Span::current();
832 span.record("gen_ai.response.id", response.id.as_str());
833 span.record("gen_ai.response.model", response.model.as_str());
834 if let Some(usage) = &response.usage {
835 span.record("gen_ai.usage.input_tokens", usage.prompt_tokens);
836 span.record(
837 "gen_ai.usage.output_tokens",
838 usage.total_tokens - usage.prompt_tokens,
839 );
840 span.record(
841 "gen_ai.usage.cache_read.input_tokens",
842 usage
843 .prompt_tokens_details
844 .as_ref()
845 .map(|details| details.cached_tokens)
846 .unwrap_or(0),
847 );
848 }
849
850 Ok(completion::CompletionResponse {
851 choice: core.choice,
852 usage: core.usage,
853 raw_response: CopilotCompletionResponse::Chat(response),
854 message_id: core.message_id,
855 })
856 }
857 ChatApiResponse::Err(err) => Err(CompletionError::ProviderError(
858 err.error_message().to_string(),
859 )),
860 }
861 } else {
862 let body = http_client::text(response).await?;
863 Err(CompletionError::ProviderError(body))
864 }
865 }
866 .instrument(span)
867 .await
868 }
869
870 async fn completion_responses(
871 &self,
872 completion_request: completion::CompletionRequest,
873 ) -> Result<completion::CompletionResponse<CopilotCompletionResponse>, CompletionError> {
874 let initiator = request_initiator(&completion_request);
875 let has_vision = request_has_vision(&completion_request);
876 let request = self.responses_request(completion_request)?;
877 let auth = self.auth_context().await?;
878
879 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
880 let req = apply_headers(
881 post_with_auth_base(&self.client, &auth, "/responses", Transport::Http)?,
882 &headers,
883 )
884 .body(serde_json::to_vec(&request)?)
885 .map_err(|err| CompletionError::HttpError(err.into()))?;
886
887 let span = if tracing::Span::current().is_disabled() {
888 info_span!(
889 target: "rig::completions",
890 "chat",
891 gen_ai.operation.name = "chat",
892 gen_ai.provider.name = "copilot",
893 gen_ai.request.model = self.model,
894 gen_ai.response.id = tracing::field::Empty,
895 gen_ai.response.model = tracing::field::Empty,
896 gen_ai.usage.output_tokens = tracing::field::Empty,
897 gen_ai.usage.input_tokens = tracing::field::Empty,
898 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
899 )
900 } else {
901 tracing::Span::current()
902 };
903
904 async move {
905 let response = self.client.send(req).await?;
906 if response.status().is_success() {
907 let body = http_client::text(response).await?;
908 let response = serde_json::from_str::<responses_api::CompletionResponse>(&body)?;
909 let core = completion::CompletionResponse::try_from(response.clone())?;
910
911 let span = tracing::Span::current();
912 span.record("gen_ai.response.id", response.id.as_str());
913 span.record("gen_ai.response.model", response.model.as_str());
914 if let Some(usage) = &response.usage {
915 span.record("gen_ai.usage.input_tokens", usage.input_tokens);
916 span.record("gen_ai.usage.output_tokens", usage.output_tokens);
917 span.record(
918 "gen_ai.usage.cache_read.input_tokens",
919 usage
920 .input_tokens_details
921 .as_ref()
922 .map(|details| details.cached_tokens)
923 .unwrap_or(0),
924 );
925 }
926
927 Ok(completion::CompletionResponse {
928 choice: core.choice,
929 usage: core.usage,
930 raw_response: CopilotCompletionResponse::Responses(Box::new(response)),
931 message_id: core.message_id,
932 })
933 } else {
934 let body = http_client::text(response).await?;
935 Err(CompletionError::ProviderError(body))
936 }
937 }
938 .instrument(span)
939 .await
940 }
941
942 async fn stream_chat(
943 &self,
944 completion_request: completion::CompletionRequest,
945 ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
946 let initiator = request_initiator(&completion_request);
947 let has_vision = request_has_vision(&completion_request);
948 let request = self.chat_request(completion_request)?;
949 let auth = self.auth_context().await?;
950 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
951 let mut request_json = serde_json::to_value(&request)?;
952 let request_object = request_json.as_object_mut().ok_or_else(|| {
953 CompletionError::ResponseError("copilot request body must be a JSON object".into())
954 })?;
955 request_object.insert("stream".to_owned(), json!(true));
956 request_object.insert(
957 "stream_options".to_owned(),
958 json!({ "include_usage": true }),
959 );
960
961 let req = apply_headers(
962 post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Sse)?,
963 &headers,
964 )
965 .body(serde_json::to_vec(&request_json)?)
966 .map_err(|err| CompletionError::HttpError(err.into()))?;
967
968 let span = if tracing::Span::current().is_disabled() {
969 info_span!(
970 target: "rig::completions",
971 "chat_streaming",
972 gen_ai.operation.name = "chat_streaming",
973 gen_ai.provider.name = "copilot",
974 gen_ai.request.model = self.model,
975 gen_ai.response.id = tracing::field::Empty,
976 gen_ai.response.model = tracing::field::Empty,
977 gen_ai.usage.output_tokens = tracing::field::Empty,
978 gen_ai.usage.input_tokens = tracing::field::Empty,
979 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
980 )
981 } else {
982 tracing::Span::current()
983 };
984
985 tracing::Instrument::instrument(
986 send_copilot_chat_streaming_request(self.client.clone(), req),
987 span,
988 )
989 .await
990 }
991
992 async fn stream_responses(
993 &self,
994 completion_request: completion::CompletionRequest,
995 ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
996 let initiator = request_initiator(&completion_request);
997 let has_vision = request_has_vision(&completion_request);
998 let mut request = self.responses_request(completion_request)?;
999 request.stream = Some(true);
1000 let auth = self.auth_context().await?;
1001
1002 let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
1003 let req = apply_headers(
1004 post_with_auth_base(&self.client, &auth, "/responses", Transport::Sse)?,
1005 &headers,
1006 )
1007 .body(serde_json::to_vec(&request)?)
1008 .map_err(|err| CompletionError::HttpError(err.into()))?;
1009
1010 let span = if tracing::Span::current().is_disabled() {
1011 info_span!(
1012 target: "rig::completions",
1013 "chat_streaming",
1014 gen_ai.operation.name = "chat_streaming",
1015 gen_ai.provider.name = "copilot",
1016 gen_ai.request.model = self.model,
1017 gen_ai.response.id = tracing::field::Empty,
1018 gen_ai.response.model = tracing::field::Empty,
1019 gen_ai.usage.output_tokens = tracing::field::Empty,
1020 gen_ai.usage.input_tokens = tracing::field::Empty,
1021 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1022 )
1023 } else {
1024 tracing::Span::current()
1025 };
1026
1027 let client = self.client.clone();
1028 let mut event_source = crate::http_client::sse::GenericEventSource::new(client, req);
1029
1030 let stream = tracing_futures::Instrument::instrument(
1031 stream! {
1032 let mut final_usage = responses_api::ResponsesUsage::new();
1033 let mut tool_calls: Vec<streaming::RawStreamingChoice<CopilotStreamingResponse>> = Vec::new();
1034 let mut tool_call_internal_ids: HashMap<String, String> = HashMap::new();
1035 let span = tracing::Span::current();
1036
1037 let mut terminated_with_error = false;
1038
1039 while let Some(event_result) = event_source.next().await {
1040 match event_result {
1041 Ok(crate::http_client::sse::Event::Open) => continue,
1042 Ok(crate::http_client::sse::Event::Message(evt)) => {
1043 if evt.data.trim().is_empty() {
1044 continue;
1045 }
1046
1047 let Ok(data) = serde_json::from_str::<responses_api::streaming::StreamingCompletionChunk>(&evt.data) else {
1048 continue;
1049 };
1050
1051 if let responses_api::streaming::StreamingCompletionChunk::Delta(chunk) = &data {
1052 use responses_api::streaming::{ItemChunkKind, StreamingItemDoneOutput};
1053
1054 match &chunk.data {
1055 ItemChunkKind::OutputItemAdded(message) => {
1056 if let StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } = message {
1057 let internal_call_id = tool_call_internal_ids
1058 .entry(func.id.clone())
1059 .or_insert_with(|| nanoid::nanoid!())
1060 .clone();
1061 yield Ok(RawStreamingChoice::ToolCallDelta {
1062 id: func.id.clone(),
1063 internal_call_id,
1064 content: streaming::ToolCallDeltaContent::Name(func.name.clone()),
1065 });
1066 }
1067 }
1068 ItemChunkKind::OutputItemDone(message) => match message {
1069 StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } => {
1070 let internal_id = tool_call_internal_ids
1071 .entry(func.id.clone())
1072 .or_insert_with(|| nanoid::nanoid!())
1073 .clone();
1074 let raw_tool_call = streaming::RawStreamingToolCall::new(
1075 func.id.clone(),
1076 func.name.clone(),
1077 func.arguments.clone(),
1078 )
1079 .with_internal_call_id(internal_id)
1080 .with_call_id(func.call_id.clone());
1081 tool_calls.push(RawStreamingChoice::ToolCall(raw_tool_call));
1082 }
1083 StreamingItemDoneOutput { item: responses_api::Output::Reasoning { summary, id, encrypted_content, .. }, .. } => {
1084 for reasoning_choice in responses_api::streaming::reasoning_choices_from_done_item(
1085 id,
1086 summary,
1087 encrypted_content.as_deref(),
1088 ) {
1089 match reasoning_choice {
1090 RawStreamingChoice::Reasoning { id, content } => {
1091 yield Ok(RawStreamingChoice::Reasoning { id, content });
1092 }
1093 RawStreamingChoice::ReasoningDelta { id, reasoning } => {
1094 yield Ok(RawStreamingChoice::ReasoningDelta { id, reasoning });
1095 }
1096 _ => {}
1097 }
1098 }
1099 }
1100 StreamingItemDoneOutput { item: responses_api::Output::Message(msg), .. } => {
1101 yield Ok(RawStreamingChoice::MessageId(msg.id.clone()));
1102 }
1103 StreamingItemDoneOutput { item: responses_api::Output::Unknown, .. } => {}
1104 },
1105 ItemChunkKind::OutputTextDelta(delta) => {
1106 yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1107 }
1108 ItemChunkKind::ReasoningSummaryTextDelta(delta) => {
1109 yield Ok(RawStreamingChoice::ReasoningDelta { id: None, reasoning: delta.delta.clone() })
1110 }
1111 ItemChunkKind::RefusalDelta(delta) => {
1112 yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1113 }
1114 ItemChunkKind::FunctionCallArgsDelta(delta) => {
1115 if let Some(item_id) = chunk.item_id.as_ref() {
1116 let internal_call_id = tool_call_internal_ids
1117 .entry(item_id.clone())
1118 .or_insert_with(|| nanoid::nanoid!())
1119 .clone();
1120 yield Ok(RawStreamingChoice::ToolCallDelta {
1121 id: item_id.clone(),
1122 internal_call_id,
1123 content: streaming::ToolCallDeltaContent::Delta(delta.delta.clone())
1124 })
1125 }
1126 }
1127 _ => continue,
1128 }
1129 }
1130
1131 if let responses_api::streaming::StreamingCompletionChunk::Response(chunk) = data {
1132 let responses_api::streaming::ResponseChunk { kind, response, .. } = *chunk;
1133 match kind {
1134 responses_api::streaming::ResponseChunkKind::ResponseCompleted => {
1135 span.record("gen_ai.response.id", response.id.as_str());
1136 span.record("gen_ai.response.model", response.model.as_str());
1137 if let Some(usage) = response.usage {
1138 final_usage = usage;
1139 }
1140 }
1141 responses_api::streaming::ResponseChunkKind::ResponseFailed
1142 | responses_api::streaming::ResponseChunkKind::ResponseIncomplete => {
1143 let error = response
1144 .error
1145 .as_ref()
1146 .map(|err| err.message.clone())
1147 .unwrap_or_else(|| "Copilot response stream failed".into());
1148 terminated_with_error = true;
1149 yield Err(CompletionError::ProviderError(error));
1150 break;
1151 }
1152 _ => continue,
1153 }
1154 }
1155 }
1156 Err(crate::http_client::Error::StreamEnded) => {
1157 break;
1158 }
1159 Err(error) => {
1160 terminated_with_error = true;
1161 yield Err(CompletionError::ProviderError(error.to_string()));
1162 break;
1163 }
1164 }
1165 }
1166
1167 event_source.close();
1168
1169 if terminated_with_error {
1170 return;
1171 }
1172
1173 for tool_call in &tool_calls {
1174 yield Ok(tool_call.to_owned())
1175 }
1176
1177 span.record("gen_ai.usage.input_tokens", final_usage.input_tokens);
1178 span.record("gen_ai.usage.output_tokens", final_usage.output_tokens);
1179 span.record(
1180 "gen_ai.usage.cache_read.input_tokens",
1181 final_usage
1182 .input_tokens_details
1183 .as_ref()
1184 .map(|details| details.cached_tokens)
1185 .unwrap_or(0),
1186 );
1187
1188 yield Ok(RawStreamingChoice::FinalResponse(
1189 CopilotStreamingResponse::Responses(
1190 responses_api::streaming::StreamingCompletionResponse { usage: final_usage }
1191 )
1192 ));
1193 },
1194 span,
1195 );
1196
1197 Ok(StreamingCompletionResponse::stream(Box::pin(stream)))
1198 }
1199}
1200
1201impl<H> completion::CompletionModel for CompletionModel<H>
1202where
1203 Client<H>: HttpClientExt + Clone + Debug + 'static,
1204 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1205{
1206 type Response = CopilotCompletionResponse;
1207 type StreamingResponse = CopilotStreamingResponse;
1208 type Client = Client<H>;
1209
1210 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1211 Self::new(client.clone(), model)
1212 }
1213
1214 async fn completion(
1215 &self,
1216 completion_request: completion::CompletionRequest,
1217 ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1218 match self.route() {
1219 CompletionRoute::ChatCompletions => self.completion_chat(completion_request).await,
1220 CompletionRoute::Responses => self.completion_responses(completion_request).await,
1221 }
1222 }
1223
1224 async fn stream(
1225 &self,
1226 completion_request: completion::CompletionRequest,
1227 ) -> Result<StreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
1228 match self.route() {
1229 CompletionRoute::ChatCompletions => self.stream_chat(completion_request).await,
1230 CompletionRoute::Responses => self.stream_responses(completion_request).await,
1231 }
1232 }
1233}
1234
1235#[derive(Clone)]
1236pub struct EmbeddingModel<H = reqwest::Client> {
1237 client: Client<H>,
1238 pub model: String,
1239 pub encoding_format: Option<openai::EncodingFormat>,
1240 pub user: Option<String>,
1241 ndims: usize,
1242}
1243
1244#[derive(Deserialize)]
1245struct CopilotEmbeddingResponse {
1246 data: Vec<CopilotEmbeddingData>,
1247}
1248
1249#[derive(Deserialize)]
1250struct CopilotEmbeddingData {
1251 embedding: Vec<serde_json::Number>,
1252}
1253
1254impl<H> EmbeddingModel<H>
1255where
1256 Client<H>: HttpClientExt + Clone + Debug + 'static,
1257 H: Clone + Default + Debug + 'static,
1258{
1259 pub fn new(client: Client<H>, model: impl Into<String>, ndims: usize) -> Self {
1260 Self {
1261 client,
1262 model: model.into(),
1263 encoding_format: None,
1264 user: None,
1265 ndims,
1266 }
1267 }
1268}
1269
1270impl<H> embeddings::EmbeddingModel for EmbeddingModel<H>
1271where
1272 Client<H>: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static,
1273 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1274{
1275 const MAX_DOCUMENTS: usize = 1024;
1276 type Client = Client<H>;
1277
1278 fn make(client: &Self::Client, model: impl Into<String>, ndims: Option<usize>) -> Self {
1279 let model = model.into();
1280 let dims = ndims.unwrap_or(match model.as_str() {
1281 TEXT_EMBEDDING_3_LARGE => 3072,
1282 TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => 1536,
1283 _ => 0,
1284 });
1285 Self::new(client.clone(), model, dims)
1286 }
1287
1288 fn ndims(&self) -> usize {
1289 self.ndims
1290 }
1291
1292 async fn embed_texts(
1293 &self,
1294 documents: impl IntoIterator<Item = String>,
1295 ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
1296 let documents = documents.into_iter().collect::<Vec<_>>();
1297 let auth = self
1298 .client
1299 .ext()
1300 .auth
1301 .auth_context()
1302 .await
1303 .map_err(|err| EmbeddingError::ProviderError(err.to_string()))?;
1304
1305 let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1306 let mut body = json!({
1307 "model": self.model,
1308 "input": documents,
1309 });
1310
1311 let body_object = body.as_object_mut().ok_or_else(|| {
1312 EmbeddingError::ResponseError("embedding request body must be a JSON object".into())
1313 })?;
1314
1315 if self.ndims > 0 && self.model.as_str() != TEXT_EMBEDDING_ADA_002 {
1316 body_object.insert("dimensions".to_owned(), json!(self.ndims));
1317 }
1318 if let Some(encoding_format) = &self.encoding_format {
1319 body_object.insert("encoding_format".to_owned(), json!(encoding_format));
1320 }
1321 if let Some(user) = &self.user {
1322 body_object.insert("user".to_owned(), json!(user));
1323 }
1324
1325 let req = apply_headers(
1326 post_with_auth_base(&self.client, &auth, "/embeddings", Transport::Http)?,
1327 &headers,
1328 )
1329 .body(serde_json::to_vec(&body)?)
1330 .map_err(|err| EmbeddingError::HttpError(err.into()))?;
1331
1332 let response = self.client.send(req).await?;
1333 if response.status().is_success() {
1334 let body: Vec<u8> = response.into_body().await?;
1335 #[derive(Deserialize)]
1336 struct NestedApiError {
1337 error: NestedApiErrorMessage,
1338 }
1339
1340 #[derive(Deserialize)]
1341 struct NestedApiErrorMessage {
1342 message: String,
1343 }
1344
1345 let body: CopilotEmbeddingResponse = match serde_json::from_slice(&body) {
1346 Ok(parsed) => parsed,
1347 Err(parse_error) => {
1348 if let Ok(err) = serde_json::from_slice::<NestedApiError>(&body) {
1349 return Err(EmbeddingError::ProviderError(err.error.message));
1350 }
1351
1352 let preview = String::from_utf8_lossy(&body);
1353 let preview = if preview.len() > 512 {
1354 format!("{}...", &preview[..512])
1355 } else {
1356 preview.into_owned()
1357 };
1358
1359 return Err(EmbeddingError::ProviderError(format!(
1360 "Failed to parse Copilot embeddings response: {parse_error}; body: {preview}"
1361 )));
1362 }
1363 };
1364
1365 Ok(body
1366 .data
1367 .into_iter()
1368 .zip(documents.into_iter())
1369 .map(|(embedding, document)| embeddings::Embedding {
1370 document,
1371 vec: embedding
1372 .embedding
1373 .into_iter()
1374 .filter_map(|n| n.as_f64())
1375 .collect(),
1376 })
1377 .collect())
1378 } else {
1379 let text = http_client::text(response).await?;
1380 Err(EmbeddingError::ProviderError(text))
1381 }
1382 }
1383}
1384
1385const MODEL_LISTING_PATH: &str = "/models";
1386const MODEL_LISTING_PROVIDER: &str = "Copilot";
1387
1388#[derive(Debug, Deserialize)]
1389struct ListModelsResponse {
1390 data: Vec<ListModelEntry>,
1391}
1392
1393#[derive(Debug, Deserialize)]
1394struct ListModelEntry {
1395 id: String,
1396 #[serde(default)]
1397 name: Option<String>,
1398 #[serde(default)]
1399 vendor: Option<String>,
1400 #[serde(default)]
1401 capabilities: Option<ListModelEntryCapabilities>,
1402}
1403
1404#[derive(Debug, Deserialize)]
1405struct ListModelEntryCapabilities {
1406 #[serde(default, rename = "type")]
1407 r#type: Option<String>,
1408}
1409
1410impl From<ListModelEntry> for Model {
1411 fn from(value: ListModelEntry) -> Self {
1412 let mut model = Model::from_id(value.id);
1413 model.name = value.name;
1414 model.owned_by = value.vendor;
1415 if let Some(caps) = value.capabilities {
1416 model.r#type = caps.r#type;
1417 }
1418 model
1419 }
1420}
1421
1422#[derive(Clone)]
1424pub struct CopilotModelLister<H = reqwest::Client> {
1425 client: Client<H>,
1426}
1427
1428impl<H> ModelLister<H> for CopilotModelLister<H>
1429where
1430 H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
1431{
1432 type Client = Client<H>;
1433
1434 fn new(client: Self::Client) -> Self {
1435 Self { client }
1436 }
1437
1438 async fn list_all(&self) -> Result<ModelList, ModelListingError> {
1439 let auth = self.client.ext().auth.auth_context().await.map_err(|err| {
1440 ModelListingError::AuthError {
1441 message: err.to_string(),
1442 }
1443 })?;
1444
1445 let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1446 let req = apply_headers(
1447 get_with_auth_base(&self.client, &auth, MODEL_LISTING_PATH, Transport::Http)?,
1448 &headers,
1449 )
1450 .body(http_client::NoBody)?;
1451
1452 let response = self.client.send::<_, Vec<u8>>(req).await?;
1453
1454 if !response.status().is_success() {
1455 let status_code = response.status().as_u16();
1456 let body = response.into_body().await?;
1457 return Err(ModelListingError::api_error_with_context(
1458 MODEL_LISTING_PROVIDER,
1459 MODEL_LISTING_PATH,
1460 status_code,
1461 &body,
1462 ));
1463 }
1464
1465 let body = response.into_body().await?;
1466 let api_resp: ListModelsResponse = serde_json::from_slice(&body).map_err(|error| {
1467 ModelListingError::parse_error_with_context(
1468 MODEL_LISTING_PROVIDER,
1469 MODEL_LISTING_PATH,
1470 &error,
1471 &body,
1472 )
1473 })?;
1474 let models = api_resp.data.into_iter().map(Model::from).collect();
1475
1476 Ok(ModelList::new(models))
1477 }
1478}
1479
1480#[derive(Deserialize, Debug)]
1481struct ChatStreamingFunction {
1482 name: Option<String>,
1483 arguments: Option<String>,
1484}
1485
1486#[derive(Deserialize, Debug)]
1487struct ChatStreamingToolCall {
1488 index: usize,
1489 id: Option<String>,
1490 function: ChatStreamingFunction,
1491}
1492
1493impl From<&ChatStreamingToolCall> for CompatibleToolCallChunk {
1494 fn from(value: &ChatStreamingToolCall) -> Self {
1495 Self {
1496 index: value.index,
1497 id: value.id.clone(),
1498 name: value.function.name.clone(),
1499 arguments: value.function.arguments.clone(),
1500 }
1501 }
1502}
1503
1504#[derive(Deserialize, Debug, Default)]
1505struct ChatStreamingDelta {
1506 #[serde(default)]
1507 content: Option<String>,
1508 #[serde(default)]
1509 reasoning_content: Option<String>,
1510 #[serde(default, deserialize_with = "crate::json_utils::null_or_vec")]
1511 tool_calls: Vec<ChatStreamingToolCall>,
1512}
1513
1514#[derive(Deserialize, Debug, PartialEq)]
1515#[serde(rename_all = "snake_case")]
1516enum ChatFinishReason {
1517 ToolCalls,
1518 Stop,
1519 ContentFilter,
1520 Length,
1521 #[serde(untagged)]
1522 Other(String),
1523}
1524
1525#[derive(Deserialize, Debug)]
1526struct ChatStreamingChoice {
1527 delta: ChatStreamingDelta,
1528 finish_reason: Option<ChatFinishReason>,
1529}
1530
1531#[derive(Deserialize, Debug)]
1532struct ChatStreamingChunk {
1533 id: Option<String>,
1534 model: Option<String>,
1535 choices: Vec<ChatStreamingChoice>,
1536 usage: Option<openai::completion::Usage>,
1537}
1538
1539#[derive(Clone, Copy)]
1540struct CopilotChatCompatibleProfile;
1541
1542impl CompatibleStreamProfile for CopilotChatCompatibleProfile {
1543 type Usage = openai::completion::Usage;
1544 type Detail = ();
1545 type FinalResponse = CopilotStreamingResponse;
1546
1547 fn normalize_chunk(
1548 &self,
1549 data: &str,
1550 ) -> Result<Option<CompatibleChunk<Self::Usage, Self::Detail>>, CompletionError> {
1551 let data = match serde_json::from_str::<ChatStreamingChunk>(data) {
1552 Ok(data) => data,
1553 Err(error) => {
1554 tracing::debug!(?error, "Couldn't parse Copilot chat SSE payload");
1555 return Ok(None);
1556 }
1557 };
1558
1559 Ok(Some(
1560 openai_chat_completions_compatible::normalize_first_choice_chunk(
1561 data.id,
1562 data.model,
1563 data.usage,
1564 &data.choices,
1565 |choice| CompatibleChoiceData {
1566 finish_reason: if choice.finish_reason == Some(ChatFinishReason::ToolCalls) {
1567 CompatibleFinishReason::ToolCalls
1568 } else {
1569 CompatibleFinishReason::Other
1570 },
1571 text: choice.delta.content.clone(),
1572 reasoning: choice.delta.reasoning_content.clone(),
1573 tool_calls: openai_chat_completions_compatible::tool_call_chunks(
1574 &choice.delta.tool_calls,
1575 ),
1576 details: Vec::new(),
1577 },
1578 ),
1579 ))
1580 }
1581
1582 fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse {
1583 CopilotStreamingResponse::Chat(openai::completion::streaming::StreamingCompletionResponse {
1584 usage,
1585 })
1586 }
1587
1588 fn uses_distinct_tool_call_eviction(&self) -> bool {
1589 true
1590 }
1591}
1592
1593async fn send_copilot_chat_streaming_request<T>(
1594 http_client: T,
1595 req: Request<Vec<u8>>,
1596) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError>
1597where
1598 T: HttpClientExt + Clone + 'static,
1599{
1600 openai_chat_completions_compatible::send_compatible_streaming_request(
1601 http_client,
1602 req,
1603 CopilotChatCompatibleProfile,
1604 )
1605 .await
1606}
1607
1608fn default_token_dir() -> Option<PathBuf> {
1609 config_dir().map(|dir| dir.join("github_copilot"))
1610}
1611
1612fn config_dir() -> Option<PathBuf> {
1613 #[cfg(target_os = "windows")]
1614 {
1615 std::env::var_os("APPDATA").map(PathBuf::from)
1616 }
1617
1618 #[cfg(not(target_os = "windows"))]
1619 {
1620 std::env::var_os("XDG_CONFIG_HOME")
1621 .map(PathBuf::from)
1622 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
1623 }
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628 use super::{
1629 ChatApiErrorResponse, ChatCompletionResponse, Client, CompletionRoute, CopilotIntent,
1630 TEXT_EMBEDDING_3_SMALL, base_url_from_token, default_headers, env_api_key, env_base_url,
1631 env_github_access_token, route_for_model,
1632 };
1633 use crate::client::CompletionClient;
1634 use crate::completion::CompletionModel;
1635 use crate::http_client;
1636 use crate::providers::internal::openai_chat_completions_compatible::test_support::{
1637 sse_bytes_from_data_lines, sse_bytes_from_json_events,
1638 };
1639 use crate::streaming::StreamedAssistantContent;
1640 use crate::test_utils::MockStreamingClient;
1641 use crate::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
1642 use futures::StreamExt;
1643 use std::collections::HashMap;
1644
1645 fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
1646 entries
1647 .iter()
1648 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1649 .collect()
1650 }
1651
1652 fn minimal_chat_response() -> &'static str {
1653 r#"{
1654 "id": "chatcmpl-123",
1655 "model": "gpt-4o",
1656 "choices": [{
1657 "index": 0,
1658 "message": {
1659 "role": "assistant",
1660 "content": "hello"
1661 },
1662 "finish_reason": "stop"
1663 }],
1664 "usage": {
1665 "prompt_tokens": 4,
1666 "total_tokens": 7
1667 }
1668 }"#
1669 }
1670
1671 fn minimal_responses_response() -> &'static str {
1672 r#"{
1673 "id": "resp_123",
1674 "object": "response",
1675 "created_at": 1700000000,
1676 "status": "completed",
1677 "error": null,
1678 "incomplete_details": null,
1679 "instructions": null,
1680 "max_output_tokens": null,
1681 "model": "gpt-5.3-codex",
1682 "usage": {
1683 "input_tokens": 4,
1684 "input_tokens_details": {
1685 "cached_tokens": 0
1686 },
1687 "output_tokens": 3,
1688 "output_tokens_details": {
1689 "reasoning_tokens": 0
1690 },
1691 "total_tokens": 7
1692 },
1693 "output": [{
1694 "type": "message",
1695 "id": "msg_123",
1696 "role": "assistant",
1697 "status": "completed",
1698 "content": [{
1699 "type": "output_text",
1700 "text": "hello"
1701 }]
1702 }],
1703 "tools": []
1704 }"#
1705 }
1706
1707 fn minimal_embeddings_response() -> &'static str {
1708 r#"{
1709 "data": [
1710 {
1711 "embedding": [0.1, 0.2, 0.3]
1712 },
1713 {
1714 "embedding": [0.4, 0.5, 0.6]
1715 }
1716 ]
1717 }"#
1718 }
1719
1720 #[test]
1721 fn deserialize_standard_openai_response() {
1722 let json = r#"{
1723 "id": "chatcmpl-abc123",
1724 "object": "chat.completion",
1725 "created": 1700000000,
1726 "model": "gpt-4o",
1727 "choices": [{
1728 "index": 0,
1729 "message": {
1730 "role": "assistant",
1731 "content": "Hello!"
1732 },
1733 "finish_reason": "stop"
1734 }],
1735 "usage": {
1736 "prompt_tokens": 10,
1737 "completion_tokens": 5,
1738 "total_tokens": 15
1739 }
1740 }"#;
1741
1742 let response: ChatCompletionResponse =
1743 serde_json::from_str(json).expect("standard OpenAI response should deserialize");
1744 assert_eq!(response.id, "chatcmpl-abc123");
1745 assert_eq!(response.object.as_deref(), Some("chat.completion"));
1746 assert_eq!(response.created, Some(1700000000));
1747 assert_eq!(response.model, "gpt-4o");
1748 assert_eq!(response.choices.len(), 1);
1749 assert_eq!(response.choices[0].finish_reason.as_deref(), Some("stop"));
1750 }
1751
1752 #[test]
1753 fn deserialize_copilot_response_without_object_and_created() {
1754 let response: ChatCompletionResponse = serde_json::from_str(minimal_chat_response())
1755 .expect("Copilot response should deserialize");
1756
1757 assert_eq!(response.id, "chatcmpl-123");
1758 assert_eq!(response.object, None);
1759 assert_eq!(response.created, None);
1760 assert_eq!(response.model, "gpt-4o");
1761 assert_eq!(response.choices.len(), 1);
1762 }
1763
1764 #[test]
1765 fn deserialize_copilot_response_without_finish_reason() {
1766 let json = r#"{
1767 "id": "chatcmpl-claude-001",
1768 "model": "claude-3.5-sonnet",
1769 "choices": [{
1770 "message": {
1771 "role": "assistant",
1772 "content": "Here is my analysis."
1773 }
1774 }],
1775 "usage": {
1776 "prompt_tokens": 50,
1777 "total_tokens": 80
1778 }
1779 }"#;
1780
1781 let response: ChatCompletionResponse =
1782 serde_json::from_str(json).expect("Claude-via-Copilot response should deserialize");
1783
1784 assert_eq!(response.model, "claude-3.5-sonnet");
1785 assert_eq!(response.choices[0].finish_reason, None);
1786 assert_eq!(response.choices[0].index, 0);
1787 }
1788
1789 #[test]
1790 fn error_response_with_message_field() {
1791 let json = r#"{"message": "rate limit exceeded"}"#;
1792 let err: ChatApiErrorResponse = serde_json::from_str(json).expect("message-shaped error");
1793
1794 assert_eq!(err.error_message(), "rate limit exceeded");
1795 }
1796
1797 #[test]
1798 fn error_response_with_error_field() {
1799 let json = r#"{"error": "model not found"}"#;
1800 let err: ChatApiErrorResponse = serde_json::from_str(json).expect("error-shaped error");
1801
1802 assert_eq!(err.error_message(), "model not found");
1803 }
1804
1805 #[test]
1806 fn routes_codex_models_to_responses() {
1807 assert_eq!(route_for_model("gpt-5.3-codex"), CompletionRoute::Responses);
1808 assert_eq!(
1809 route_for_model("gpt-5.1-CODEX-mini"),
1810 CompletionRoute::Responses
1811 );
1812 assert_eq!(route_for_model("gpt-5.2"), CompletionRoute::ChatCompletions);
1813 assert_eq!(
1814 route_for_model("claude-sonnet-4.5"),
1815 CompletionRoute::ChatCompletions
1816 );
1817 }
1818
1819 #[test]
1820 fn copilot_intent_headers_use_panel_by_default_and_edits_when_requested() {
1821 let panel_headers = default_headers("token", "user", false, CopilotIntent::default());
1822 assert_eq!(
1823 panel_headers
1824 .iter()
1825 .find(|(name, _)| *name == "openai-intent")
1826 .map(|(_, value)| value.as_str()),
1827 Some("conversation-panel")
1828 );
1829
1830 let edits_headers = default_headers("token", "user", false, CopilotIntent::Edits);
1831 assert_eq!(
1832 edits_headers
1833 .iter()
1834 .find(|(name, _)| *name == "openai-intent")
1835 .map(|(_, value)| value.as_str()),
1836 Some("conversation-edits")
1837 );
1838 }
1839
1840 #[test]
1841 fn copilot_completion_model_intent_builders_update_intent() {
1842 let client = Client::builder()
1843 .api_key("copilot-token")
1844 .build()
1845 .expect("build client");
1846
1847 let default_model = client.completion_model("gpt-4o");
1848 assert_eq!(default_model.intent.as_header(), "conversation-panel");
1849
1850 let edits_model = client
1851 .completion_model("gpt-4o")
1852 .with_intent(CopilotIntent::Edits);
1853 assert_eq!(edits_model.intent.as_header(), "conversation-edits");
1854
1855 let panel_model = client
1856 .completion_model("gpt-4o")
1857 .with_edits_intent()
1858 .with_panel_intent();
1859 assert_eq!(panel_model.intent.as_header(), "conversation-panel");
1860 }
1861
1862 #[test]
1863 fn base_url_from_token_derives_api_endpoint() {
1864 assert_eq!(
1865 base_url_from_token("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1866 .as_deref(),
1867 Some("https://api.individual.githubcopilot.com")
1868 );
1869 assert_eq!(
1870 base_url_from_token("tid=1;proxy-ep=https://proxy.individual.githubcopilot.com;exp=2")
1871 .as_deref(),
1872 Some("https://api.individual.githubcopilot.com")
1873 );
1874 assert_eq!(base_url_from_token("tid=1;exp=2"), None);
1875 }
1876
1877 #[test]
1878 fn base_url_from_token_rejects_unsafe_or_non_copilot_endpoints() {
1879 assert_eq!(
1880 base_url_from_token("tid=1;proxy-ep=http://proxy.individual.githubcopilot.com;exp=2"),
1881 None
1882 );
1883 assert_eq!(
1884 base_url_from_token("tid=1;proxy-ep=https://evil.example.com;exp=2"),
1885 None
1886 );
1887 assert_eq!(base_url_from_token("tid=1;proxy-ep=://bad;exp=2"), None);
1888 assert_eq!(base_url_from_token("tid=1;proxy-ep=;exp=2"), None);
1889 assert_eq!(
1890 base_url_from_token(
1891 "tid=1;proxy-ep=https://proxy.individual.githubcopilot.com/base;exp=2"
1892 ),
1893 None
1894 );
1895 }
1896
1897 #[tokio::test]
1898 async fn api_key_with_proxy_endpoint_overrides_base_url() {
1899 let http_client = RecordingHttpClient::new(minimal_chat_response());
1900 let client = Client::builder()
1901 .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1902 .http_client(http_client.clone())
1903 .build()
1904 .expect("build client");
1905 let model = client.completion_model("gpt-4o");
1906 let request = model.completion_request("hello").build();
1907
1908 let _response = model.completion(request).await.expect("chat completion");
1909
1910 let requests = http_client.requests();
1911 assert_eq!(requests.len(), 1);
1912 assert!(
1913 requests[0]
1914 .uri
1915 .starts_with("https://api.individual.githubcopilot.com"),
1916 "expected proxy-derived base URL, got {}",
1917 requests[0].uri
1918 );
1919 }
1920
1921 #[tokio::test]
1922 async fn explicit_base_url_wins_over_token_proxy_endpoint() {
1923 let http_client = RecordingHttpClient::new(minimal_chat_response());
1924 let client = Client::builder()
1925 .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1926 .base_url("https://custom.example.com")
1927 .http_client(http_client.clone())
1928 .build()
1929 .expect("build client");
1930 let model = client.completion_model("gpt-4o");
1931 let request = model.completion_request("hello").build();
1932
1933 let _response = model.completion(request).await.expect("chat completion");
1934
1935 let requests = http_client.requests();
1936 assert_eq!(requests.len(), 1);
1937 assert!(
1938 requests[0].uri.starts_with("https://custom.example.com"),
1939 "expected explicit base URL, got {}",
1940 requests[0].uri
1941 );
1942 }
1943
1944 #[tokio::test]
1945 async fn completion_model_edits_intent_sets_request_header() {
1946 let http_client = RecordingHttpClient::new(minimal_chat_response());
1947 let client = Client::builder()
1948 .api_key("copilot-token")
1949 .http_client(http_client.clone())
1950 .build()
1951 .expect("build client");
1952 let model = client.completion_model("gpt-4o").with_edits_intent();
1953 let request = model.completion_request("hello").build();
1954
1955 let _response = model.completion(request).await.expect("chat completion");
1956
1957 let requests = http_client.requests();
1958 assert_eq!(requests.len(), 1);
1959 assert_eq!(
1960 requests[0]
1961 .headers
1962 .get("openai-intent")
1963 .and_then(|value| value.to_str().ok()),
1964 Some("conversation-edits")
1965 );
1966 }
1967
1968 #[tokio::test]
1969 async fn completion_model_routes_chat_requests_to_chat_completions() {
1970 let http_client = RecordingHttpClient::new(minimal_chat_response());
1971 let client = Client::builder()
1972 .api_key("copilot-token")
1973 .http_client(http_client.clone())
1974 .build()
1975 .expect("build client");
1976 let model = client.completion_model("gpt-4o");
1977 let request = model.completion_request("hello").build();
1978
1979 let _response = model.completion(request).await.expect("chat completion");
1980
1981 let requests = http_client.requests();
1982 assert_eq!(requests.len(), 1);
1983 assert!(requests[0].uri.ends_with("/chat/completions"));
1984 assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-4o\""));
1985 }
1986
1987 #[tokio::test]
1988 async fn completion_model_routes_codex_requests_to_responses() {
1989 let http_client = RecordingHttpClient::new(minimal_responses_response());
1990 let client = Client::builder()
1991 .api_key("copilot-token")
1992 .http_client(http_client.clone())
1993 .build()
1994 .expect("build client");
1995 let model = client.completion_model("gpt-5.3-codex");
1996 let request = model.completion_request("hello").build();
1997
1998 let _response = model
1999 .completion(request)
2000 .await
2001 .expect("responses completion");
2002
2003 let requests = http_client.requests();
2004 assert_eq!(requests.len(), 1);
2005 assert!(requests[0].uri.ends_with("/responses"));
2006 assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-5.3-codex\""));
2007 }
2008
2009 #[tokio::test]
2010 async fn embeddings_accept_minimal_copilot_response_shape() {
2011 use crate::client::EmbeddingsClient;
2012 use crate::embeddings::EmbeddingModel as _;
2013
2014 let http_client = RecordingHttpClient::new(minimal_embeddings_response());
2015 let client = Client::builder()
2016 .api_key("copilot-token")
2017 .http_client(http_client.clone())
2018 .build()
2019 .expect("build client");
2020 let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
2021
2022 let embeddings = model
2023 .embed_texts(["one".to_string(), "two".to_string()])
2024 .await
2025 .expect("embeddings should deserialize");
2026
2027 assert_eq!(embeddings.len(), 2);
2028 assert_eq!(embeddings[0].vec, vec![0.1, 0.2, 0.3]);
2029 assert_eq!(embeddings[1].vec, vec![0.4, 0.5, 0.6]);
2030
2031 let requests = http_client.requests();
2032 assert_eq!(requests.len(), 1);
2033 assert!(requests[0].uri.ends_with("/embeddings"));
2034 assert!(
2035 String::from_utf8_lossy(&requests[0].body)
2036 .contains("\"model\":\"text-embedding-3-small\"")
2037 );
2038 }
2039
2040 #[tokio::test]
2041 async fn responses_stream_terminates_after_terminal_error() {
2042 let tool_call_done = serde_json::json!({
2043 "type": "response.output_item.done",
2044 "sequence_number": 1,
2045 "item": {
2046 "type": "function_call",
2047 "id": "fc_123",
2048 "arguments": "{}",
2049 "call_id": "call_123",
2050 "name": "example_tool",
2051 "status": "completed"
2052 }
2053 });
2054 let failed = serde_json::json!({
2055 "type": "response.failed",
2056 "sequence_number": 2,
2057 "response": {
2058 "id": "resp_123",
2059 "object": "response",
2060 "created_at": 1700000000,
2061 "status": "failed",
2062 "error": {
2063 "code": "server_error",
2064 "message": "Copilot response stream failed"
2065 },
2066 "incomplete_details": null,
2067 "instructions": null,
2068 "max_output_tokens": null,
2069 "model": "gpt-5.3-codex",
2070 "usage": null,
2071 "output": [],
2072 "tools": []
2073 }
2074 });
2075 let http_client = MockStreamingClient {
2076 sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
2077 };
2078 let client = Client::builder()
2079 .api_key("copilot-token")
2080 .http_client(http_client)
2081 .build()
2082 .expect("build client");
2083 let model = client.completion_model("gpt-5.3-codex");
2084 let request = model.completion_request("hello").build();
2085 let mut stream = model.stream(request).await.expect("stream should start");
2086
2087 let err = match stream.next().await.expect("stream should yield an item") {
2088 Ok(_) => panic!("stream should surface a provider error"),
2089 Err(err) => err,
2090 };
2091 assert_eq!(
2092 err.to_string(),
2093 "ProviderError: Copilot response stream failed"
2094 );
2095 assert!(
2096 stream.next().await.is_none(),
2097 "responses stream should terminate immediately after a terminal error"
2098 );
2099 }
2100
2101 #[tokio::test]
2102 async fn chat_stream_terminates_after_transport_error() {
2103 let chunks = vec![
2104 Ok(sse_bytes_from_data_lines([
2105 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
2106 ])),
2107 Err(http_client::Error::InvalidStatusCode(
2108 http::StatusCode::BAD_GATEWAY,
2109 )),
2110 ];
2111
2112 let http_client = SequencedStreamingHttpClient::new(chunks);
2113 let client = Client::builder()
2114 .api_key("copilot-token")
2115 .http_client(http_client)
2116 .build()
2117 .expect("build client");
2118 let model = client.completion_model("gpt-4o");
2119 let request = model.completion_request("hello").build();
2120 let mut stream = model.stream(request).await.expect("stream should start");
2121
2122 let mut saw_error = false;
2123 while let Some(item) = stream.next().await {
2124 match item {
2125 Ok(StreamedAssistantContent::ToolCallDelta { .. }) => {}
2126 Err(err) => {
2127 assert_eq!(
2128 err.to_string(),
2129 "ProviderError: Invalid status code: 502 Bad Gateway"
2130 );
2131 saw_error = true;
2132 break;
2133 }
2134 Ok(_) => panic!("unexpected non-error stream item before transport failure"),
2135 }
2136 }
2137
2138 assert!(saw_error, "stream should surface the transport error");
2139 assert!(
2140 stream.next().await.is_none(),
2141 "chat stream should terminate immediately after a transport error"
2142 );
2143 }
2144
2145 #[test]
2146 fn env_api_key_prefers_github_prefixed_vars() {
2147 let env = env_map(&[
2148 ("COPILOT_API_KEY", "copilot-key"),
2149 ("GITHUB_COPILOT_API_KEY", "github-key"),
2150 ("GITHUB_TOKEN", "bootstrap-token"),
2151 ]);
2152 let get = |name: &str| env.get(name).cloned();
2153
2154 assert_eq!(env_api_key(&get).as_deref(), Some("github-key"));
2155 }
2156
2157 #[test]
2158 fn env_github_access_token_prefers_explicit_bootstrap_var() {
2159 let env = env_map(&[
2160 ("COPILOT_GITHUB_ACCESS_TOKEN", "explicit-bootstrap"),
2161 ("GITHUB_TOKEN", "fallback-bootstrap"),
2162 ]);
2163 let get = |name: &str| env.get(name).cloned();
2164
2165 assert_eq!(
2166 env_github_access_token(&get).as_deref(),
2167 Some("explicit-bootstrap")
2168 );
2169 }
2170
2171 #[test]
2172 fn env_base_url_prefers_github_prefixed_vars() {
2173 let env = env_map(&[
2174 ("COPILOT_BASE_URL", "https://copilot.example"),
2175 ("GITHUB_COPILOT_API_BASE", "https://github.example"),
2176 ]);
2177 let get = |name: &str| env.get(name).cloned();
2178
2179 assert_eq!(
2180 env_base_url(&get).as_deref(),
2181 Some("https://github.example")
2182 );
2183 }
2184
2185 #[test]
2186 fn env_without_api_key_falls_back_to_oauth() {
2187 let env = env_map(&[("COPILOT_BASE_URL", "https://copilot.example")]);
2188 let get = |name: &str| env.get(name).cloned();
2189
2190 assert!(env_api_key(&get).is_none());
2191 assert!(env_github_access_token(&get).is_none());
2192 assert_eq!(
2193 env_base_url(&get).as_deref(),
2194 Some("https://copilot.example")
2195 );
2196 }
2197
2198 #[test]
2199 fn env_github_token_is_not_treated_as_copilot_api_key() {
2200 let env = env_map(&[("GITHUB_TOKEN", "bootstrap-token")]);
2201 let get = |name: &str| env.get(name).cloned();
2202
2203 assert!(env_api_key(&get).is_none());
2204 assert_eq!(
2205 env_github_access_token(&get).as_deref(),
2206 Some("bootstrap-token")
2207 );
2208 }
2209}