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