1pub mod responses;
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::OnceLock;
6
7use anyhow::Context as _;
8use anyhow::{Result, anyhow};
9use chrono::DateTime;
10use collections::HashSet;
11use fs::Fs;
12use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
13use gpui::WeakEntity;
14use gpui::{App, AsyncApp, Global, prelude::*};
15use http_client::HttpRequestExt;
16use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
17use paths::home_dir;
18use serde::{Deserialize, Serialize};
19
20use settings::watch_config_dir;
21
22pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN";
23
24#[derive(Default, Clone, Debug, PartialEq)]
25pub struct CopilotChatConfiguration {
26 pub enterprise_uri: Option<String>,
27}
28
29impl CopilotChatConfiguration {
30 pub fn token_url(&self) -> String {
31 if let Some(enterprise_uri) = &self.enterprise_uri {
32 let domain = Self::parse_domain(enterprise_uri);
33 format!("https://api.{}/copilot_internal/v2/token", domain)
34 } else {
35 "https://api.github.com/copilot_internal/v2/token".to_string()
36 }
37 }
38
39 pub fn oauth_domain(&self) -> String {
40 if let Some(enterprise_uri) = &self.enterprise_uri {
41 Self::parse_domain(enterprise_uri)
42 } else {
43 "github.com".to_string()
44 }
45 }
46
47 pub fn chat_completions_url_from_endpoint(&self, endpoint: &str) -> String {
48 format!("{}/chat/completions", endpoint)
49 }
50
51 pub fn responses_url_from_endpoint(&self, endpoint: &str) -> String {
52 format!("{}/responses", endpoint)
53 }
54
55 pub fn models_url_from_endpoint(&self, endpoint: &str) -> String {
56 format!("{}/models", endpoint)
57 }
58
59 fn parse_domain(enterprise_uri: &str) -> String {
60 let uri = enterprise_uri.trim_end_matches('/');
61
62 if let Some(domain) = uri.strip_prefix("https://") {
63 domain.split('/').next().unwrap_or(domain).to_string()
64 } else if let Some(domain) = uri.strip_prefix("http://") {
65 domain.split('/').next().unwrap_or(domain).to_string()
66 } else {
67 uri.split('/').next().unwrap_or(uri).to_string()
68 }
69 }
70}
71
72#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
73#[serde(rename_all = "lowercase")]
74pub enum Role {
75 User,
76 Assistant,
77 System,
78}
79
80#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
81pub enum ModelSupportedEndpoint {
82 #[serde(rename = "/chat/completions")]
83 ChatCompletions,
84 #[serde(rename = "/responses")]
85 Responses,
86 #[serde(rename = "/v1/messages")]
87 Messages,
88 /// Unknown endpoint that we don't explicitly support yet
89 #[serde(other)]
90 Unknown,
91}
92
93#[derive(Deserialize)]
94struct ModelSchema {
95 #[serde(deserialize_with = "deserialize_models_skip_errors")]
96 data: Vec<Model>,
97}
98
99fn deserialize_models_skip_errors<'de, D>(deserializer: D) -> Result<Vec<Model>, D::Error>
100where
101 D: serde::Deserializer<'de>,
102{
103 let raw_values = Vec::<serde_json::Value>::deserialize(deserializer)?;
104 let models = raw_values
105 .into_iter()
106 .filter_map(|value| match serde_json::from_value::<Model>(value) {
107 Ok(model) => Some(model),
108 Err(err) => {
109 log::warn!("GitHub Copilot Chat model failed to deserialize: {:?}", err);
110 None
111 }
112 })
113 .collect();
114
115 Ok(models)
116}
117
118#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
119pub struct Model {
120 billing: ModelBilling,
121 capabilities: ModelCapabilities,
122 id: String,
123 name: String,
124 policy: Option<ModelPolicy>,
125 vendor: ModelVendor,
126 is_chat_default: bool,
127 // The model with this value true is selected by VSCode copilot if a premium request limit is
128 // reached. Zed does not currently implement this behaviour
129 is_chat_fallback: bool,
130 model_picker_enabled: bool,
131 #[serde(default)]
132 supported_endpoints: Vec<ModelSupportedEndpoint>,
133}
134
135#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
136struct ModelBilling {
137 is_premium: bool,
138 multiplier: f64,
139 // List of plans a model is restricted to
140 // Field is not present if a model is available for all plans
141 #[serde(default)]
142 restricted_to: Option<Vec<String>>,
143}
144
145#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
146struct ModelCapabilities {
147 family: String,
148 #[serde(default)]
149 limits: ModelLimits,
150 supports: ModelSupportedFeatures,
151 #[serde(rename = "type")]
152 model_type: String,
153 #[serde(default)]
154 tokenizer: Option<String>,
155}
156
157#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
158struct ModelLimits {
159 #[serde(default)]
160 max_context_window_tokens: usize,
161 #[serde(default)]
162 max_output_tokens: usize,
163 #[serde(default)]
164 max_prompt_tokens: u64,
165}
166
167#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
168struct ModelPolicy {
169 state: String,
170}
171
172#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
173struct ModelSupportedFeatures {
174 #[serde(default)]
175 streaming: bool,
176 #[serde(default)]
177 tool_calls: bool,
178 #[serde(default)]
179 parallel_tool_calls: bool,
180 #[serde(default)]
181 vision: bool,
182}
183
184#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
185pub enum ModelVendor {
186 // Azure OpenAI should have no functional difference from OpenAI in Copilot Chat
187 #[serde(alias = "Azure OpenAI")]
188 OpenAI,
189 Google,
190 Anthropic,
191 #[serde(rename = "xAI")]
192 XAI,
193 /// Unknown vendor that we don't explicitly support yet
194 #[serde(other)]
195 Unknown,
196}
197
198#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
199#[serde(tag = "type")]
200pub enum ChatMessagePart {
201 #[serde(rename = "text")]
202 Text { text: String },
203 #[serde(rename = "image_url")]
204 Image { image_url: ImageUrl },
205}
206
207#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
208pub struct ImageUrl {
209 pub url: String,
210}
211
212impl Model {
213 pub fn uses_streaming(&self) -> bool {
214 self.capabilities.supports.streaming
215 }
216
217 pub fn id(&self) -> &str {
218 self.id.as_str()
219 }
220
221 pub fn display_name(&self) -> &str {
222 self.name.as_str()
223 }
224
225 pub fn max_token_count(&self) -> u64 {
226 self.capabilities.limits.max_prompt_tokens
227 }
228
229 pub fn supports_tools(&self) -> bool {
230 self.capabilities.supports.tool_calls
231 }
232
233 pub fn vendor(&self) -> ModelVendor {
234 self.vendor
235 }
236
237 pub fn supports_vision(&self) -> bool {
238 self.capabilities.supports.vision
239 }
240
241 pub fn supports_parallel_tool_calls(&self) -> bool {
242 self.capabilities.supports.parallel_tool_calls
243 }
244
245 pub fn tokenizer(&self) -> Option<&str> {
246 self.capabilities.tokenizer.as_deref()
247 }
248
249 pub fn supports_response(&self) -> bool {
250 self.supported_endpoints.len() > 0
251 && !self
252 .supported_endpoints
253 .contains(&ModelSupportedEndpoint::ChatCompletions)
254 && self
255 .supported_endpoints
256 .contains(&ModelSupportedEndpoint::Responses)
257 }
258}
259
260#[derive(Serialize, Deserialize)]
261pub struct Request {
262 pub intent: bool,
263 pub n: usize,
264 pub stream: bool,
265 pub temperature: f32,
266 pub model: String,
267 pub messages: Vec<ChatMessage>,
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub tools: Vec<Tool>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub tool_choice: Option<ToolChoice>,
272}
273
274#[derive(Serialize, Deserialize)]
275pub struct Function {
276 pub name: String,
277 pub description: String,
278 pub parameters: serde_json::Value,
279}
280
281#[derive(Serialize, Deserialize)]
282#[serde(tag = "type", rename_all = "snake_case")]
283pub enum Tool {
284 Function { function: Function },
285}
286
287#[derive(Serialize, Deserialize, Debug)]
288#[serde(rename_all = "lowercase")]
289pub enum ToolChoice {
290 Auto,
291 Any,
292 None,
293}
294
295#[derive(Serialize, Deserialize, Debug)]
296#[serde(tag = "role", rename_all = "lowercase")]
297pub enum ChatMessage {
298 Assistant {
299 content: ChatMessageContent,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 tool_calls: Vec<ToolCall>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 reasoning_opaque: Option<String>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 reasoning_text: Option<String>,
306 },
307 User {
308 content: ChatMessageContent,
309 },
310 System {
311 content: String,
312 },
313 Tool {
314 content: ChatMessageContent,
315 tool_call_id: String,
316 },
317}
318
319#[derive(Debug, Serialize, Deserialize)]
320#[serde(untagged)]
321pub enum ChatMessageContent {
322 Plain(String),
323 Multipart(Vec<ChatMessagePart>),
324}
325
326impl ChatMessageContent {
327 pub fn empty() -> Self {
328 ChatMessageContent::Multipart(vec![])
329 }
330}
331
332impl From<Vec<ChatMessagePart>> for ChatMessageContent {
333 fn from(mut parts: Vec<ChatMessagePart>) -> Self {
334 if let [ChatMessagePart::Text { text }] = parts.as_mut_slice() {
335 ChatMessageContent::Plain(std::mem::take(text))
336 } else {
337 ChatMessageContent::Multipart(parts)
338 }
339 }
340}
341
342impl From<String> for ChatMessageContent {
343 fn from(text: String) -> Self {
344 ChatMessageContent::Plain(text)
345 }
346}
347
348#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
349pub struct ToolCall {
350 pub id: String,
351 #[serde(flatten)]
352 pub content: ToolCallContent,
353}
354
355#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
356#[serde(tag = "type", rename_all = "lowercase")]
357pub enum ToolCallContent {
358 Function { function: FunctionContent },
359}
360
361#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
362pub struct FunctionContent {
363 pub name: String,
364 pub arguments: String,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub thought_signature: Option<String>,
367}
368
369#[derive(Deserialize, Debug)]
370#[serde(tag = "type", rename_all = "snake_case")]
371pub struct ResponseEvent {
372 pub choices: Vec<ResponseChoice>,
373 pub id: String,
374 pub usage: Option<Usage>,
375}
376
377#[derive(Deserialize, Debug)]
378pub struct Usage {
379 pub completion_tokens: u64,
380 pub prompt_tokens: u64,
381 pub total_tokens: u64,
382}
383
384#[derive(Debug, Deserialize)]
385pub struct ResponseChoice {
386 pub index: Option<usize>,
387 pub finish_reason: Option<String>,
388 pub delta: Option<ResponseDelta>,
389 pub message: Option<ResponseDelta>,
390}
391
392#[derive(Debug, Deserialize)]
393pub struct ResponseDelta {
394 pub content: Option<String>,
395 pub role: Option<Role>,
396 #[serde(default)]
397 pub tool_calls: Vec<ToolCallChunk>,
398 pub reasoning_opaque: Option<String>,
399 pub reasoning_text: Option<String>,
400}
401#[derive(Deserialize, Debug, Eq, PartialEq)]
402pub struct ToolCallChunk {
403 pub index: Option<usize>,
404 pub id: Option<String>,
405 pub function: Option<FunctionChunk>,
406}
407
408#[derive(Deserialize, Debug, Eq, PartialEq)]
409pub struct FunctionChunk {
410 pub name: Option<String>,
411 pub arguments: Option<String>,
412 pub thought_signature: Option<String>,
413}
414
415#[derive(Deserialize)]
416struct ApiTokenResponse {
417 token: String,
418 expires_at: i64,
419 endpoints: ApiTokenResponseEndpoints,
420}
421
422#[derive(Deserialize)]
423struct ApiTokenResponseEndpoints {
424 api: String,
425}
426
427#[derive(Clone)]
428struct ApiToken {
429 api_key: String,
430 expires_at: DateTime<chrono::Utc>,
431 api_endpoint: String,
432}
433
434impl ApiToken {
435 pub fn remaining_seconds(&self) -> i64 {
436 self.expires_at
437 .timestamp()
438 .saturating_sub(chrono::Utc::now().timestamp())
439 }
440}
441
442impl TryFrom<ApiTokenResponse> for ApiToken {
443 type Error = anyhow::Error;
444
445 fn try_from(response: ApiTokenResponse) -> Result<Self, Self::Error> {
446 let expires_at =
447 DateTime::from_timestamp(response.expires_at, 0).context("invalid expires_at")?;
448
449 Ok(Self {
450 api_key: response.token,
451 expires_at,
452 api_endpoint: response.endpoints.api,
453 })
454 }
455}
456
457struct GlobalCopilotChat(gpui::Entity<CopilotChat>);
458
459impl Global for GlobalCopilotChat {}
460
461pub struct CopilotChat {
462 oauth_token: Option<String>,
463 api_token: Option<ApiToken>,
464 configuration: CopilotChatConfiguration,
465 models: Option<Vec<Model>>,
466 client: Arc<dyn HttpClient>,
467}
468
469pub fn init(
470 fs: Arc<dyn Fs>,
471 client: Arc<dyn HttpClient>,
472 configuration: CopilotChatConfiguration,
473 cx: &mut App,
474) {
475 let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, configuration, cx));
476 cx.set_global(GlobalCopilotChat(copilot_chat));
477}
478
479pub fn copilot_chat_config_dir() -> &'static PathBuf {
480 static COPILOT_CHAT_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
481
482 COPILOT_CHAT_CONFIG_DIR.get_or_init(|| {
483 let config_dir = if cfg!(target_os = "windows") {
484 dirs::data_local_dir().expect("failed to determine LocalAppData directory")
485 } else {
486 std::env::var("XDG_CONFIG_HOME")
487 .map(PathBuf::from)
488 .unwrap_or_else(|_| home_dir().join(".config"))
489 };
490
491 config_dir.join("github-copilot")
492 })
493}
494
495fn copilot_chat_config_paths() -> [PathBuf; 2] {
496 let base_dir = copilot_chat_config_dir();
497 [base_dir.join("hosts.json"), base_dir.join("apps.json")]
498}
499
500impl CopilotChat {
501 pub fn global(cx: &App) -> Option<gpui::Entity<Self>> {
502 cx.try_global::<GlobalCopilotChat>()
503 .map(|model| model.0.clone())
504 }
505
506 fn new(
507 fs: Arc<dyn Fs>,
508 client: Arc<dyn HttpClient>,
509 configuration: CopilotChatConfiguration,
510 cx: &mut Context<Self>,
511 ) -> Self {
512 let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
513 let dir_path = copilot_chat_config_dir();
514
515 cx.spawn(async move |this, cx| {
516 let mut parent_watch_rx = watch_config_dir(
517 cx.background_executor(),
518 fs.clone(),
519 dir_path.clone(),
520 config_paths,
521 );
522 while let Some(contents) = parent_watch_rx.next().await {
523 let oauth_domain =
524 this.read_with(cx, |this, _| this.configuration.oauth_domain())?;
525 let oauth_token = extract_oauth_token(contents, &oauth_domain);
526
527 this.update(cx, |this, cx| {
528 this.oauth_token = oauth_token.clone();
529 cx.notify();
530 })?;
531
532 if oauth_token.is_some() {
533 Self::update_models(&this, cx).await?;
534 }
535 }
536 anyhow::Ok(())
537 })
538 .detach_and_log_err(cx);
539
540 let this = Self {
541 oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR).ok(),
542 api_token: None,
543 models: None,
544 configuration,
545 client,
546 };
547
548 if this.oauth_token.is_some() {
549 cx.spawn(async move |this, cx| Self::update_models(&this, cx).await)
550 .detach_and_log_err(cx);
551 }
552
553 this
554 }
555
556 async fn update_models(this: &WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
557 let (oauth_token, client, configuration) = this.read_with(cx, |this, _| {
558 (
559 this.oauth_token.clone(),
560 this.client.clone(),
561 this.configuration.clone(),
562 )
563 })?;
564
565 let oauth_token = oauth_token
566 .ok_or_else(|| anyhow!("OAuth token is missing while updating Copilot Chat models"))?;
567
568 let token_url = configuration.token_url();
569 let api_token = request_api_token(&oauth_token, token_url.into(), client.clone()).await?;
570
571 let models_url = configuration.models_url_from_endpoint(&api_token.api_endpoint);
572 let models =
573 get_models(models_url.into(), api_token.api_key.clone(), client.clone()).await?;
574
575 this.update(cx, |this, cx| {
576 this.api_token = Some(api_token);
577 this.models = Some(models);
578 cx.notify();
579 })?;
580 anyhow::Ok(())
581 }
582
583 pub fn is_authenticated(&self) -> bool {
584 self.oauth_token.is_some()
585 }
586
587 pub fn models(&self) -> Option<&[Model]> {
588 self.models.as_deref()
589 }
590
591 pub async fn stream_completion(
592 request: Request,
593 is_user_initiated: bool,
594 mut cx: AsyncApp,
595 ) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
596 let (client, token, configuration) = Self::get_auth_details(&mut cx).await?;
597
598 let api_url = configuration.chat_completions_url_from_endpoint(&token.api_endpoint);
599 stream_completion(
600 client.clone(),
601 token.api_key,
602 api_url.into(),
603 request,
604 is_user_initiated,
605 )
606 .await
607 }
608
609 pub async fn stream_response(
610 request: responses::Request,
611 is_user_initiated: bool,
612 mut cx: AsyncApp,
613 ) -> Result<BoxStream<'static, Result<responses::StreamEvent>>> {
614 let (client, token, configuration) = Self::get_auth_details(&mut cx).await?;
615
616 let api_url = configuration.responses_url_from_endpoint(&token.api_endpoint);
617 responses::stream_response(
618 client.clone(),
619 token.api_key,
620 api_url,
621 request,
622 is_user_initiated,
623 )
624 .await
625 }
626
627 async fn get_auth_details(
628 cx: &mut AsyncApp,
629 ) -> Result<(Arc<dyn HttpClient>, ApiToken, CopilotChatConfiguration)> {
630 let this = cx
631 .update(|cx| Self::global(cx))
632 .context("Copilot chat is not enabled")?;
633
634 let (oauth_token, api_token, client, configuration) = this.read_with(cx, |this, _| {
635 (
636 this.oauth_token.clone(),
637 this.api_token.clone(),
638 this.client.clone(),
639 this.configuration.clone(),
640 )
641 });
642
643 let oauth_token = oauth_token.context("No OAuth token available")?;
644
645 let token = match api_token {
646 Some(api_token) if api_token.remaining_seconds() > 5 * 60 => api_token,
647 _ => {
648 let token_url = configuration.token_url();
649 let token =
650 request_api_token(&oauth_token, token_url.into(), client.clone()).await?;
651 this.update(cx, |this, cx| {
652 this.api_token = Some(token.clone());
653 cx.notify();
654 });
655 token
656 }
657 };
658
659 Ok((client, token, configuration))
660 }
661
662 pub fn set_configuration(
663 &mut self,
664 configuration: CopilotChatConfiguration,
665 cx: &mut Context<Self>,
666 ) {
667 let same_configuration = self.configuration == configuration;
668 self.configuration = configuration;
669 if !same_configuration {
670 self.api_token = None;
671 cx.spawn(async move |this, cx| {
672 Self::update_models(&this, cx).await?;
673 Ok::<_, anyhow::Error>(())
674 })
675 .detach();
676 }
677 }
678}
679
680async fn get_models(
681 models_url: Arc<str>,
682 api_token: String,
683 client: Arc<dyn HttpClient>,
684) -> Result<Vec<Model>> {
685 let all_models = request_models(models_url, api_token, client).await?;
686
687 let mut models: Vec<Model> = all_models
688 .into_iter()
689 .filter(|model| {
690 model.model_picker_enabled
691 && model.capabilities.model_type.as_str() == "chat"
692 && model
693 .policy
694 .as_ref()
695 .is_none_or(|policy| policy.state == "enabled")
696 })
697 .collect();
698
699 if let Some(default_model_position) = models.iter().position(|model| model.is_chat_default) {
700 let default_model = models.remove(default_model_position);
701 models.insert(0, default_model);
702 }
703
704 Ok(models)
705}
706
707fn copilot_request_headers(builder: http_client::Builder, api_token: &str) -> http_client::Builder {
708 builder
709 .header("Authorization", format!("Bearer {}", api_token))
710 .header("Content-Type", "application/json")
711 .header(
712 "Editor-Version",
713 format!(
714 "Zed/{}",
715 option_env!("CARGO_PKG_VERSION").unwrap_or("unknown")
716 ),
717 )
718}
719
720async fn request_models(
721 models_url: Arc<str>,
722 api_token: String,
723 client: Arc<dyn HttpClient>,
724) -> Result<Vec<Model>> {
725 let request_builder = copilot_request_headers(
726 HttpRequest::builder()
727 .method(Method::GET)
728 .uri(models_url.as_ref()),
729 &api_token,
730 )
731 .header("x-github-api-version", "2025-05-01");
732
733 let request = request_builder.body(AsyncBody::empty())?;
734
735 let mut response = client.send(request).await?;
736
737 anyhow::ensure!(
738 response.status().is_success(),
739 "Failed to request models: {}",
740 response.status()
741 );
742 let mut body = Vec::new();
743 response.body_mut().read_to_end(&mut body).await?;
744
745 let body_str = std::str::from_utf8(&body)?;
746
747 let models = serde_json::from_str::<ModelSchema>(body_str)?.data;
748
749 Ok(models)
750}
751
752async fn request_api_token(
753 oauth_token: &str,
754 auth_url: Arc<str>,
755 client: Arc<dyn HttpClient>,
756) -> Result<ApiToken> {
757 let request_builder = HttpRequest::builder()
758 .method(Method::GET)
759 .uri(auth_url.as_ref())
760 .header("Authorization", format!("token {}", oauth_token))
761 .header("Accept", "application/json");
762
763 let request = request_builder.body(AsyncBody::empty())?;
764
765 let mut response = client.send(request).await?;
766
767 if response.status().is_success() {
768 let mut body = Vec::new();
769 response.body_mut().read_to_end(&mut body).await?;
770
771 let body_str = std::str::from_utf8(&body)?;
772
773 let parsed: ApiTokenResponse = serde_json::from_str(body_str)?;
774 ApiToken::try_from(parsed)
775 } else {
776 let mut body = Vec::new();
777 response.body_mut().read_to_end(&mut body).await?;
778
779 let body_str = std::str::from_utf8(&body)?;
780 anyhow::bail!("Failed to request API token: {body_str}");
781 }
782}
783
784fn extract_oauth_token(contents: String, domain: &str) -> Option<String> {
785 serde_json::from_str::<serde_json::Value>(&contents)
786 .map(|v| {
787 v.as_object().and_then(|obj| {
788 obj.iter().find_map(|(key, value)| {
789 if key.starts_with(domain) {
790 value["oauth_token"].as_str().map(|v| v.to_string())
791 } else {
792 None
793 }
794 })
795 })
796 })
797 .ok()
798 .flatten()
799}
800
801async fn stream_completion(
802 client: Arc<dyn HttpClient>,
803 api_key: String,
804 completion_url: Arc<str>,
805 request: Request,
806 is_user_initiated: bool,
807) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
808 let is_vision_request = request.messages.iter().any(|message| match message {
809 ChatMessage::User { content }
810 | ChatMessage::Assistant { content, .. }
811 | ChatMessage::Tool { content, .. } => {
812 matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. })))
813 }
814 _ => false,
815 });
816
817 let request_initiator = if is_user_initiated { "user" } else { "agent" };
818
819 let request_builder = copilot_request_headers(
820 HttpRequest::builder()
821 .method(Method::POST)
822 .uri(completion_url.as_ref()),
823 &api_key,
824 )
825 .header("X-Initiator", request_initiator)
826 .when(is_vision_request, |builder| {
827 builder.header("Copilot-Vision-Request", is_vision_request.to_string())
828 });
829
830 let is_streaming = request.stream;
831
832 let json = serde_json::to_string(&request)?;
833 let request = request_builder.body(AsyncBody::from(json))?;
834 let mut response = client.send(request).await?;
835
836 if !response.status().is_success() {
837 let mut body = Vec::new();
838 response.body_mut().read_to_end(&mut body).await?;
839 let body_str = std::str::from_utf8(&body)?;
840 anyhow::bail!(
841 "Failed to connect to API: {} {}",
842 response.status(),
843 body_str
844 );
845 }
846
847 if is_streaming {
848 let reader = BufReader::new(response.into_body());
849 Ok(reader
850 .lines()
851 .filter_map(|line| async move {
852 match line {
853 Ok(line) => {
854 let line = line.strip_prefix("data: ")?;
855 if line.starts_with("[DONE]") {
856 return None;
857 }
858
859 match serde_json::from_str::<ResponseEvent>(line) {
860 Ok(response) => {
861 if response.choices.is_empty() {
862 None
863 } else {
864 Some(Ok(response))
865 }
866 }
867 Err(error) => Some(Err(anyhow!(error))),
868 }
869 }
870 Err(error) => Some(Err(anyhow!(error))),
871 }
872 })
873 .boxed())
874 } else {
875 let mut body = Vec::new();
876 response.body_mut().read_to_end(&mut body).await?;
877 let body_str = std::str::from_utf8(&body)?;
878 let response: ResponseEvent = serde_json::from_str(body_str)?;
879
880 Ok(futures::stream::once(async move { Ok(response) }).boxed())
881 }
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887
888 #[test]
889 fn test_resilient_model_schema_deserialize() {
890 let json = r#"{
891 "data": [
892 {
893 "billing": {
894 "is_premium": false,
895 "multiplier": 0
896 },
897 "capabilities": {
898 "family": "gpt-4",
899 "limits": {
900 "max_context_window_tokens": 32768,
901 "max_output_tokens": 4096,
902 "max_prompt_tokens": 32768
903 },
904 "object": "model_capabilities",
905 "supports": { "streaming": true, "tool_calls": true },
906 "tokenizer": "cl100k_base",
907 "type": "chat"
908 },
909 "id": "gpt-4",
910 "is_chat_default": false,
911 "is_chat_fallback": false,
912 "model_picker_enabled": false,
913 "name": "GPT 4",
914 "object": "model",
915 "preview": false,
916 "vendor": "Azure OpenAI",
917 "version": "gpt-4-0613"
918 },
919 {
920 "some-unknown-field": 123
921 },
922 {
923 "billing": {
924 "is_premium": true,
925 "multiplier": 1,
926 "restricted_to": [
927 "pro",
928 "pro_plus",
929 "business",
930 "enterprise"
931 ]
932 },
933 "capabilities": {
934 "family": "claude-3.7-sonnet",
935 "limits": {
936 "max_context_window_tokens": 200000,
937 "max_output_tokens": 16384,
938 "max_prompt_tokens": 90000,
939 "vision": {
940 "max_prompt_image_size": 3145728,
941 "max_prompt_images": 1,
942 "supported_media_types": ["image/jpeg", "image/png", "image/webp"]
943 }
944 },
945 "object": "model_capabilities",
946 "supports": {
947 "parallel_tool_calls": true,
948 "streaming": true,
949 "tool_calls": true,
950 "vision": true
951 },
952 "tokenizer": "o200k_base",
953 "type": "chat"
954 },
955 "id": "claude-3.7-sonnet",
956 "is_chat_default": false,
957 "is_chat_fallback": false,
958 "model_picker_enabled": true,
959 "name": "Claude 3.7 Sonnet",
960 "object": "model",
961 "policy": {
962 "state": "enabled",
963 "terms": "Enable access to the latest Claude 3.7 Sonnet model from Anthropic. [Learn more about how GitHub Copilot serves Claude 3.7 Sonnet](https://docs.github.com/copilot/using-github-copilot/using-claude-sonnet-in-github-copilot)."
964 },
965 "preview": false,
966 "vendor": "Anthropic",
967 "version": "claude-3.7-sonnet"
968 }
969 ],
970 "object": "list"
971 }"#;
972
973 let schema: ModelSchema = serde_json::from_str(json).unwrap();
974
975 assert_eq!(schema.data.len(), 2);
976 assert_eq!(schema.data[0].id, "gpt-4");
977 assert_eq!(schema.data[1].id, "claude-3.7-sonnet");
978 }
979
980 #[test]
981 fn test_unknown_vendor_resilience() {
982 let json = r#"{
983 "data": [
984 {
985 "billing": {
986 "is_premium": false,
987 "multiplier": 1
988 },
989 "capabilities": {
990 "family": "future-model",
991 "limits": {
992 "max_context_window_tokens": 128000,
993 "max_output_tokens": 8192,
994 "max_prompt_tokens": 120000
995 },
996 "object": "model_capabilities",
997 "supports": { "streaming": true, "tool_calls": true },
998 "type": "chat"
999 },
1000 "id": "future-model-v1",
1001 "is_chat_default": false,
1002 "is_chat_fallback": false,
1003 "model_picker_enabled": true,
1004 "name": "Future Model v1",
1005 "object": "model",
1006 "preview": false,
1007 "vendor": "SomeNewVendor",
1008 "version": "v1.0"
1009 }
1010 ],
1011 "object": "list"
1012 }"#;
1013
1014 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1015
1016 assert_eq!(schema.data.len(), 1);
1017 assert_eq!(schema.data[0].id, "future-model-v1");
1018 assert_eq!(schema.data[0].vendor, ModelVendor::Unknown);
1019 }
1020
1021 #[test]
1022 fn test_models_with_pending_policy_deserialize() {
1023 // This test verifies that models with policy states other than "enabled"
1024 // (such as "pending" or "requires_consent") are properly deserialized.
1025 // Note: These models will be filtered out by get_models() and won't appear
1026 // in the model picker until the user enables them on GitHub.
1027 let json = r#"{
1028 "data": [
1029 {
1030 "billing": { "is_premium": true, "multiplier": 1 },
1031 "capabilities": {
1032 "family": "claude-sonnet-4",
1033 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1034 "object": "model_capabilities",
1035 "supports": { "streaming": true, "tool_calls": true },
1036 "type": "chat"
1037 },
1038 "id": "claude-sonnet-4",
1039 "is_chat_default": false,
1040 "is_chat_fallback": false,
1041 "model_picker_enabled": true,
1042 "name": "Claude Sonnet 4",
1043 "object": "model",
1044 "policy": {
1045 "state": "pending",
1046 "terms": "Enable access to Claude models from Anthropic."
1047 },
1048 "preview": false,
1049 "vendor": "Anthropic",
1050 "version": "claude-sonnet-4"
1051 },
1052 {
1053 "billing": { "is_premium": true, "multiplier": 1 },
1054 "capabilities": {
1055 "family": "claude-opus-4",
1056 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1057 "object": "model_capabilities",
1058 "supports": { "streaming": true, "tool_calls": true },
1059 "type": "chat"
1060 },
1061 "id": "claude-opus-4",
1062 "is_chat_default": false,
1063 "is_chat_fallback": false,
1064 "model_picker_enabled": true,
1065 "name": "Claude Opus 4",
1066 "object": "model",
1067 "policy": {
1068 "state": "requires_consent",
1069 "terms": "Enable access to Claude models from Anthropic."
1070 },
1071 "preview": false,
1072 "vendor": "Anthropic",
1073 "version": "claude-opus-4"
1074 }
1075 ],
1076 "object": "list"
1077 }"#;
1078
1079 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1080
1081 // Both models should deserialize successfully (filtering happens in get_models)
1082 assert_eq!(schema.data.len(), 2);
1083 assert_eq!(schema.data[0].id, "claude-sonnet-4");
1084 assert_eq!(schema.data[1].id, "claude-opus-4");
1085 }
1086
1087 #[test]
1088 fn test_multiple_anthropic_models_preserved() {
1089 // This test verifies that multiple Claude models from Anthropic
1090 // are all preserved and not incorrectly deduplicated.
1091 // This was the root cause of issue #47540.
1092 let json = r#"{
1093 "data": [
1094 {
1095 "billing": { "is_premium": true, "multiplier": 1 },
1096 "capabilities": {
1097 "family": "claude-sonnet-4",
1098 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1099 "object": "model_capabilities",
1100 "supports": { "streaming": true, "tool_calls": true },
1101 "type": "chat"
1102 },
1103 "id": "claude-sonnet-4",
1104 "is_chat_default": false,
1105 "is_chat_fallback": false,
1106 "model_picker_enabled": true,
1107 "name": "Claude Sonnet 4",
1108 "object": "model",
1109 "preview": false,
1110 "vendor": "Anthropic",
1111 "version": "claude-sonnet-4"
1112 },
1113 {
1114 "billing": { "is_premium": true, "multiplier": 1 },
1115 "capabilities": {
1116 "family": "claude-opus-4",
1117 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1118 "object": "model_capabilities",
1119 "supports": { "streaming": true, "tool_calls": true },
1120 "type": "chat"
1121 },
1122 "id": "claude-opus-4",
1123 "is_chat_default": false,
1124 "is_chat_fallback": false,
1125 "model_picker_enabled": true,
1126 "name": "Claude Opus 4",
1127 "object": "model",
1128 "preview": false,
1129 "vendor": "Anthropic",
1130 "version": "claude-opus-4"
1131 },
1132 {
1133 "billing": { "is_premium": true, "multiplier": 1 },
1134 "capabilities": {
1135 "family": "claude-sonnet-4.5",
1136 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1137 "object": "model_capabilities",
1138 "supports": { "streaming": true, "tool_calls": true },
1139 "type": "chat"
1140 },
1141 "id": "claude-sonnet-4.5",
1142 "is_chat_default": false,
1143 "is_chat_fallback": false,
1144 "model_picker_enabled": true,
1145 "name": "Claude Sonnet 4.5",
1146 "object": "model",
1147 "preview": false,
1148 "vendor": "Anthropic",
1149 "version": "claude-sonnet-4.5"
1150 }
1151 ],
1152 "object": "list"
1153 }"#;
1154
1155 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1156
1157 // All three Anthropic models should be preserved
1158 assert_eq!(schema.data.len(), 3);
1159 assert_eq!(schema.data[0].id, "claude-sonnet-4");
1160 assert_eq!(schema.data[1].id, "claude-opus-4");
1161 assert_eq!(schema.data[2].id, "claude-sonnet-4.5");
1162 }
1163
1164 #[test]
1165 fn test_models_with_same_family_both_preserved() {
1166 // Test that models sharing the same family (e.g., thinking variants)
1167 // are both preserved in the model list.
1168 let json = r#"{
1169 "data": [
1170 {
1171 "billing": { "is_premium": true, "multiplier": 1 },
1172 "capabilities": {
1173 "family": "claude-sonnet-4",
1174 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1175 "object": "model_capabilities",
1176 "supports": { "streaming": true, "tool_calls": true },
1177 "type": "chat"
1178 },
1179 "id": "claude-sonnet-4",
1180 "is_chat_default": false,
1181 "is_chat_fallback": false,
1182 "model_picker_enabled": true,
1183 "name": "Claude Sonnet 4",
1184 "object": "model",
1185 "preview": false,
1186 "vendor": "Anthropic",
1187 "version": "claude-sonnet-4"
1188 },
1189 {
1190 "billing": { "is_premium": true, "multiplier": 1 },
1191 "capabilities": {
1192 "family": "claude-sonnet-4",
1193 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1194 "object": "model_capabilities",
1195 "supports": { "streaming": true, "tool_calls": true },
1196 "type": "chat"
1197 },
1198 "id": "claude-sonnet-4-thinking",
1199 "is_chat_default": false,
1200 "is_chat_fallback": false,
1201 "model_picker_enabled": true,
1202 "name": "Claude Sonnet 4 (Thinking)",
1203 "object": "model",
1204 "preview": false,
1205 "vendor": "Anthropic",
1206 "version": "claude-sonnet-4-thinking"
1207 }
1208 ],
1209 "object": "list"
1210 }"#;
1211
1212 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1213
1214 // Both models should be preserved even though they share the same family
1215 assert_eq!(schema.data.len(), 2);
1216 assert_eq!(schema.data[0].id, "claude-sonnet-4");
1217 assert_eq!(schema.data[1].id, "claude-sonnet-4-thinking");
1218 }
1219
1220 #[test]
1221 fn test_mixed_vendor_models_all_preserved() {
1222 // Test that models from different vendors are all preserved.
1223 let json = r#"{
1224 "data": [
1225 {
1226 "billing": { "is_premium": false, "multiplier": 1 },
1227 "capabilities": {
1228 "family": "gpt-4o",
1229 "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 },
1230 "object": "model_capabilities",
1231 "supports": { "streaming": true, "tool_calls": true },
1232 "type": "chat"
1233 },
1234 "id": "gpt-4o",
1235 "is_chat_default": true,
1236 "is_chat_fallback": false,
1237 "model_picker_enabled": true,
1238 "name": "GPT-4o",
1239 "object": "model",
1240 "preview": false,
1241 "vendor": "Azure OpenAI",
1242 "version": "gpt-4o"
1243 },
1244 {
1245 "billing": { "is_premium": true, "multiplier": 1 },
1246 "capabilities": {
1247 "family": "claude-sonnet-4",
1248 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1249 "object": "model_capabilities",
1250 "supports": { "streaming": true, "tool_calls": true },
1251 "type": "chat"
1252 },
1253 "id": "claude-sonnet-4",
1254 "is_chat_default": false,
1255 "is_chat_fallback": false,
1256 "model_picker_enabled": true,
1257 "name": "Claude Sonnet 4",
1258 "object": "model",
1259 "preview": false,
1260 "vendor": "Anthropic",
1261 "version": "claude-sonnet-4"
1262 },
1263 {
1264 "billing": { "is_premium": true, "multiplier": 1 },
1265 "capabilities": {
1266 "family": "gemini-2.0-flash",
1267 "limits": { "max_context_window_tokens": 1000000, "max_output_tokens": 8192, "max_prompt_tokens": 900000 },
1268 "object": "model_capabilities",
1269 "supports": { "streaming": true, "tool_calls": true },
1270 "type": "chat"
1271 },
1272 "id": "gemini-2.0-flash",
1273 "is_chat_default": false,
1274 "is_chat_fallback": false,
1275 "model_picker_enabled": true,
1276 "name": "Gemini 2.0 Flash",
1277 "object": "model",
1278 "preview": false,
1279 "vendor": "Google",
1280 "version": "gemini-2.0-flash"
1281 }
1282 ],
1283 "object": "list"
1284 }"#;
1285
1286 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1287
1288 // All three models from different vendors should be preserved
1289 assert_eq!(schema.data.len(), 3);
1290 assert_eq!(schema.data[0].id, "gpt-4o");
1291 assert_eq!(schema.data[1].id, "claude-sonnet-4");
1292 assert_eq!(schema.data[2].id, "gemini-2.0-flash");
1293 }
1294
1295 #[test]
1296 fn test_model_with_messages_endpoint_deserializes() {
1297 // Anthropic Claude models use /v1/messages endpoint.
1298 // This test verifies such models deserialize correctly (issue #47540 root cause).
1299 let json = r#"{
1300 "data": [
1301 {
1302 "billing": { "is_premium": true, "multiplier": 1 },
1303 "capabilities": {
1304 "family": "claude-sonnet-4",
1305 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1306 "object": "model_capabilities",
1307 "supports": { "streaming": true, "tool_calls": true },
1308 "type": "chat"
1309 },
1310 "id": "claude-sonnet-4",
1311 "is_chat_default": false,
1312 "is_chat_fallback": false,
1313 "model_picker_enabled": true,
1314 "name": "Claude Sonnet 4",
1315 "object": "model",
1316 "preview": false,
1317 "vendor": "Anthropic",
1318 "version": "claude-sonnet-4",
1319 "supported_endpoints": ["/v1/messages"]
1320 }
1321 ],
1322 "object": "list"
1323 }"#;
1324
1325 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1326
1327 assert_eq!(schema.data.len(), 1);
1328 assert_eq!(schema.data[0].id, "claude-sonnet-4");
1329 assert_eq!(
1330 schema.data[0].supported_endpoints,
1331 vec![ModelSupportedEndpoint::Messages]
1332 );
1333 }
1334
1335 #[test]
1336 fn test_model_with_unknown_endpoint_deserializes() {
1337 // Future-proofing: unknown endpoints should deserialize to Unknown variant
1338 // instead of causing the entire model to fail deserialization.
1339 let json = r#"{
1340 "data": [
1341 {
1342 "billing": { "is_premium": false, "multiplier": 1 },
1343 "capabilities": {
1344 "family": "future-model",
1345 "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 8192, "max_prompt_tokens": 120000 },
1346 "object": "model_capabilities",
1347 "supports": { "streaming": true, "tool_calls": true },
1348 "type": "chat"
1349 },
1350 "id": "future-model-v2",
1351 "is_chat_default": false,
1352 "is_chat_fallback": false,
1353 "model_picker_enabled": true,
1354 "name": "Future Model v2",
1355 "object": "model",
1356 "preview": false,
1357 "vendor": "OpenAI",
1358 "version": "v2.0",
1359 "supported_endpoints": ["/v2/completions", "/chat/completions"]
1360 }
1361 ],
1362 "object": "list"
1363 }"#;
1364
1365 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1366
1367 assert_eq!(schema.data.len(), 1);
1368 assert_eq!(schema.data[0].id, "future-model-v2");
1369 assert_eq!(
1370 schema.data[0].supported_endpoints,
1371 vec![
1372 ModelSupportedEndpoint::Unknown,
1373 ModelSupportedEndpoint::ChatCompletions
1374 ]
1375 );
1376 }
1377
1378 #[test]
1379 fn test_model_with_multiple_endpoints() {
1380 // Test model with multiple supported endpoints (common for newer models).
1381 let json = r#"{
1382 "data": [
1383 {
1384 "billing": { "is_premium": true, "multiplier": 1 },
1385 "capabilities": {
1386 "family": "gpt-4o",
1387 "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 },
1388 "object": "model_capabilities",
1389 "supports": { "streaming": true, "tool_calls": true },
1390 "type": "chat"
1391 },
1392 "id": "gpt-4o",
1393 "is_chat_default": true,
1394 "is_chat_fallback": false,
1395 "model_picker_enabled": true,
1396 "name": "GPT-4o",
1397 "object": "model",
1398 "preview": false,
1399 "vendor": "OpenAI",
1400 "version": "gpt-4o",
1401 "supported_endpoints": ["/chat/completions", "/responses"]
1402 }
1403 ],
1404 "object": "list"
1405 }"#;
1406
1407 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1408
1409 assert_eq!(schema.data.len(), 1);
1410 assert_eq!(schema.data[0].id, "gpt-4o");
1411 assert_eq!(
1412 schema.data[0].supported_endpoints,
1413 vec![
1414 ModelSupportedEndpoint::ChatCompletions,
1415 ModelSupportedEndpoint::Responses
1416 ]
1417 );
1418 }
1419
1420 #[test]
1421 fn test_supports_response_method() {
1422 // Test the supports_response() method which determines endpoint routing.
1423 let model_with_responses_only = Model {
1424 billing: ModelBilling {
1425 is_premium: false,
1426 multiplier: 1.0,
1427 restricted_to: None,
1428 },
1429 capabilities: ModelCapabilities {
1430 family: "test".to_string(),
1431 limits: ModelLimits::default(),
1432 supports: ModelSupportedFeatures {
1433 streaming: true,
1434 tool_calls: true,
1435 parallel_tool_calls: false,
1436 vision: false,
1437 },
1438 model_type: "chat".to_string(),
1439 tokenizer: None,
1440 },
1441 id: "test-model".to_string(),
1442 name: "Test Model".to_string(),
1443 policy: None,
1444 vendor: ModelVendor::OpenAI,
1445 is_chat_default: false,
1446 is_chat_fallback: false,
1447 model_picker_enabled: true,
1448 supported_endpoints: vec![ModelSupportedEndpoint::Responses],
1449 };
1450
1451 let model_with_chat_completions = Model {
1452 supported_endpoints: vec![ModelSupportedEndpoint::ChatCompletions],
1453 ..model_with_responses_only.clone()
1454 };
1455
1456 let model_with_both = Model {
1457 supported_endpoints: vec![
1458 ModelSupportedEndpoint::ChatCompletions,
1459 ModelSupportedEndpoint::Responses,
1460 ],
1461 ..model_with_responses_only.clone()
1462 };
1463
1464 let model_with_messages = Model {
1465 supported_endpoints: vec![ModelSupportedEndpoint::Messages],
1466 ..model_with_responses_only.clone()
1467 };
1468
1469 // Only /responses endpoint -> supports_response = true
1470 assert!(model_with_responses_only.supports_response());
1471
1472 // Only /chat/completions endpoint -> supports_response = false
1473 assert!(!model_with_chat_completions.supports_response());
1474
1475 // Both endpoints (has /chat/completions) -> supports_response = false
1476 assert!(!model_with_both.supports_response());
1477
1478 // Only /v1/messages endpoint -> supports_response = false (doesn't have /responses)
1479 assert!(!model_with_messages.supports_response());
1480 }
1481}