@@ -9,13 +9,20 @@ use fs::Fs;
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
use gpui::{App, AsyncApp, Global, prelude::*};
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
+use itertools::Itertools;
use paths::home_dir;
use serde::{Deserialize, Serialize};
use settings::watch_config_dir;
-use strum::EnumIter;
pub const COPILOT_CHAT_COMPLETION_URL: &str = "https://api.githubcopilot.com/chat/completions";
pub const COPILOT_CHAT_AUTH_URL: &str = "https://api.github.com/copilot_internal/v2/token";
+pub const COPILOT_CHAT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
+
+// Copilot's base model; defined by Microsoft in premium requests table
+// This will be moved to the front of the Copilot model list, and will be used for
+// 'fast' requests (e.g. title generation)
+// https://docs.github.com/en/copilot/managing-copilot/monitoring-usage-and-entitlements/about-premium-requests
+const DEFAULT_MODEL_ID: &str = "gpt-4.1";
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
@@ -25,132 +32,104 @@ pub enum Role {
System,
}
-#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
-pub enum Model {
- #[default]
- #[serde(alias = "gpt-4o", rename = "gpt-4o-2024-05-13")]
- Gpt4o,
- #[serde(alias = "gpt-4", rename = "gpt-4")]
- Gpt4,
- #[serde(alias = "gpt-4.1", rename = "gpt-4.1")]
- Gpt4_1,
- #[serde(alias = "gpt-3.5-turbo", rename = "gpt-3.5-turbo")]
- Gpt3_5Turbo,
- #[serde(alias = "o1", rename = "o1")]
- O1,
- #[serde(alias = "o1-mini", rename = "o3-mini")]
- O3Mini,
- #[serde(alias = "o3", rename = "o3")]
- O3,
- #[serde(alias = "o4-mini", rename = "o4-mini")]
- O4Mini,
- #[serde(alias = "claude-3-5-sonnet", rename = "claude-3.5-sonnet")]
- Claude3_5Sonnet,
- #[serde(alias = "claude-3-7-sonnet", rename = "claude-3.7-sonnet")]
- Claude3_7Sonnet,
- #[serde(
- alias = "claude-3.7-sonnet-thought",
- rename = "claude-3.7-sonnet-thought"
- )]
- Claude3_7SonnetThinking,
- #[serde(alias = "gemini-2.0-flash", rename = "gemini-2.0-flash-001")]
- Gemini20Flash,
- #[serde(alias = "gemini-2.5-pro", rename = "gemini-2.5-pro")]
- Gemini25Pro,
+#[derive(Deserialize)]
+struct ModelSchema {
+ #[serde(deserialize_with = "deserialize_models_skip_errors")]
+ data: Vec<Model>,
+}
+
+fn deserialize_models_skip_errors<'de, D>(deserializer: D) -> Result<Vec<Model>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let raw_values = Vec::<serde_json::Value>::deserialize(deserializer)?;
+ let models = raw_values
+ .into_iter()
+ .filter_map(|value| match serde_json::from_value::<Model>(value) {
+ Ok(model) => Some(model),
+ Err(err) => {
+ log::warn!("GitHub Copilot Chat model failed to deserialize: {:?}", err);
+ None
+ }
+ })
+ .collect();
+
+ Ok(models)
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct Model {
+ capabilities: ModelCapabilities,
+ id: String,
+ name: String,
+ policy: Option<ModelPolicy>,
+ vendor: ModelVendor,
+ model_picker_enabled: bool,
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+struct ModelCapabilities {
+ family: String,
+ #[serde(default)]
+ limits: ModelLimits,
+ supports: ModelSupportedFeatures,
+}
+
+#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+struct ModelLimits {
+ #[serde(default)]
+ max_context_window_tokens: usize,
+ #[serde(default)]
+ max_output_tokens: usize,
+ #[serde(default)]
+ max_prompt_tokens: usize,
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+struct ModelPolicy {
+ state: String,
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+struct ModelSupportedFeatures {
+ #[serde(default)]
+ streaming: bool,
+ #[serde(default)]
+ tool_calls: bool,
+}
+
+#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub enum ModelVendor {
+ // Azure OpenAI should have no functional difference from OpenAI in Copilot Chat
+ #[serde(alias = "Azure OpenAI")]
+ OpenAI,
+ Google,
+ Anthropic,
}
impl Model {
- pub fn default_fast() -> Self {
- Self::Claude3_7Sonnet
+ pub fn uses_streaming(&self) -> bool {
+ self.capabilities.supports.streaming
}
- pub fn uses_streaming(&self) -> bool {
- match self {
- Self::Gpt4o
- | Self::Gpt4
- | Self::Gpt4_1
- | Self::Gpt3_5Turbo
- | Self::O3
- | Self::O4Mini
- | Self::Claude3_5Sonnet
- | Self::Claude3_7Sonnet
- | Self::Claude3_7SonnetThinking => true,
- Self::O3Mini | Self::O1 | Self::Gemini20Flash | Self::Gemini25Pro => false,
- }
+ pub fn id(&self) -> &str {
+ self.id.as_str()
}
- pub fn from_id(id: &str) -> Result<Self> {
- match id {
- "gpt-4o" => Ok(Self::Gpt4o),
- "gpt-4" => Ok(Self::Gpt4),
- "gpt-4.1" => Ok(Self::Gpt4_1),
- "gpt-3.5-turbo" => Ok(Self::Gpt3_5Turbo),
- "o1" => Ok(Self::O1),
- "o3-mini" => Ok(Self::O3Mini),
- "o3" => Ok(Self::O3),
- "o4-mini" => Ok(Self::O4Mini),
- "claude-3-5-sonnet" => Ok(Self::Claude3_5Sonnet),
- "claude-3-7-sonnet" => Ok(Self::Claude3_7Sonnet),
- "claude-3.7-sonnet-thought" => Ok(Self::Claude3_7SonnetThinking),
- "gemini-2.0-flash-001" => Ok(Self::Gemini20Flash),
- "gemini-2.5-pro" => Ok(Self::Gemini25Pro),
- _ => Err(anyhow!("Invalid model id: {}", id)),
- }
+ pub fn display_name(&self) -> &str {
+ self.name.as_str()
}
- pub fn id(&self) -> &'static str {
- match self {
- Self::Gpt3_5Turbo => "gpt-3.5-turbo",
- Self::Gpt4 => "gpt-4",
- Self::Gpt4_1 => "gpt-4.1",
- Self::Gpt4o => "gpt-4o",
- Self::O3Mini => "o3-mini",
- Self::O1 => "o1",
- Self::O3 => "o3",
- Self::O4Mini => "o4-mini",
- Self::Claude3_5Sonnet => "claude-3-5-sonnet",
- Self::Claude3_7Sonnet => "claude-3-7-sonnet",
- Self::Claude3_7SonnetThinking => "claude-3.7-sonnet-thought",
- Self::Gemini20Flash => "gemini-2.0-flash-001",
- Self::Gemini25Pro => "gemini-2.5-pro",
- }
+ pub fn max_token_count(&self) -> usize {
+ self.capabilities.limits.max_prompt_tokens
}
- pub fn display_name(&self) -> &'static str {
- match self {
- Self::Gpt3_5Turbo => "GPT-3.5",
- Self::Gpt4 => "GPT-4",
- Self::Gpt4_1 => "GPT-4.1",
- Self::Gpt4o => "GPT-4o",
- Self::O3Mini => "o3-mini",
- Self::O1 => "o1",
- Self::O3 => "o3",
- Self::O4Mini => "o4-mini",
- Self::Claude3_5Sonnet => "Claude 3.5 Sonnet",
- Self::Claude3_7Sonnet => "Claude 3.7 Sonnet",
- Self::Claude3_7SonnetThinking => "Claude 3.7 Sonnet Thinking",
- Self::Gemini20Flash => "Gemini 2.0 Flash",
- Self::Gemini25Pro => "Gemini 2.5 Pro",
- }
+ pub fn supports_tools(&self) -> bool {
+ self.capabilities.supports.tool_calls
}
- pub fn max_token_count(&self) -> usize {
- match self {
- Self::Gpt4o => 64_000,
- Self::Gpt4 => 32_768,
- Self::Gpt4_1 => 128_000,
- Self::Gpt3_5Turbo => 12_288,
- Self::O3Mini => 64_000,
- Self::O1 => 20_000,
- Self::O3 => 128_000,
- Self::O4Mini => 128_000,
- Self::Claude3_5Sonnet => 200_000,
- Self::Claude3_7Sonnet => 90_000,
- Self::Claude3_7SonnetThinking => 90_000,
- Self::Gemini20Flash => 128_000,
- Self::Gemini25Pro => 128_000,
- }
+ pub fn vendor(&self) -> ModelVendor {
+ self.vendor
}
}
@@ -160,7 +139,7 @@ pub struct Request {
pub n: usize,
pub stream: bool,
pub temperature: f32,
- pub model: Model,
+ pub model: String,
pub messages: Vec<ChatMessage>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<Tool>,
@@ -306,6 +285,7 @@ impl Global for GlobalCopilotChat {}
pub struct CopilotChat {
oauth_token: Option<String>,
api_token: Option<ApiToken>,
+ models: Option<Vec<Model>>,
client: Arc<dyn HttpClient>,
}
@@ -342,31 +322,56 @@ impl CopilotChat {
let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
let dir_path = copilot_chat_config_dir();
- cx.spawn(async move |cx| {
- let mut parent_watch_rx = watch_config_dir(
- cx.background_executor(),
- fs.clone(),
- dir_path.clone(),
- config_paths,
- );
- while let Some(contents) = parent_watch_rx.next().await {
- let oauth_token = extract_oauth_token(contents);
- cx.update(|cx| {
- if let Some(this) = Self::global(cx).as_ref() {
- this.update(cx, |this, cx| {
- this.oauth_token = oauth_token;
- cx.notify();
- });
+ cx.spawn({
+ let client = client.clone();
+ async move |cx| {
+ let mut parent_watch_rx = watch_config_dir(
+ cx.background_executor(),
+ fs.clone(),
+ dir_path.clone(),
+ config_paths,
+ );
+ while let Some(contents) = parent_watch_rx.next().await {
+ let oauth_token = extract_oauth_token(contents);
+ cx.update(|cx| {
+ if let Some(this) = Self::global(cx).as_ref() {
+ this.update(cx, |this, cx| {
+ this.oauth_token = oauth_token.clone();
+ cx.notify();
+ });
+ }
+ })?;
+
+ if let Some(ref oauth_token) = oauth_token {
+ let api_token = request_api_token(oauth_token, client.clone()).await?;
+ cx.update(|cx| {
+ if let Some(this) = Self::global(cx).as_ref() {
+ this.update(cx, |this, cx| {
+ this.api_token = Some(api_token.clone());
+ cx.notify();
+ });
+ }
+ })?;
+ let models = get_models(api_token.api_key, client.clone()).await?;
+ cx.update(|cx| {
+ if let Some(this) = Self::global(cx).as_ref() {
+ this.update(cx, |this, cx| {
+ this.models = Some(models);
+ cx.notify();
+ });
+ }
+ })?;
}
- })?;
+ }
+ anyhow::Ok(())
}
- anyhow::Ok(())
})
.detach_and_log_err(cx);
Self {
oauth_token: None,
api_token: None,
+ models: None,
client,
}
}
@@ -375,6 +380,10 @@ impl CopilotChat {
self.oauth_token.is_some()
}
+ pub fn models(&self) -> Option<&[Model]> {
+ self.models.as_deref()
+ }
+
pub async fn stream_completion(
request: Request,
mut cx: AsyncApp,
@@ -409,6 +418,61 @@ impl CopilotChat {
}
}
+async fn get_models(api_token: String, client: Arc<dyn HttpClient>) -> Result<Vec<Model>> {
+ let all_models = request_models(api_token, client).await?;
+
+ let mut models: Vec<Model> = all_models
+ .into_iter()
+ .filter(|model| {
+ // Ensure user has access to the model; Policy is present only for models that must be
+ // enabled in the GitHub dashboard
+ model.model_picker_enabled
+ && model
+ .policy
+ .as_ref()
+ .is_none_or(|policy| policy.state == "enabled")
+ })
+ // The first model from the API response, in any given family, appear to be the non-tagged
+ // models, which are likely the best choice (e.g. gpt-4o rather than gpt-4o-2024-11-20)
+ .dedup_by(|a, b| a.capabilities.family == b.capabilities.family)
+ .collect();
+
+ if let Some(default_model_position) =
+ models.iter().position(|model| model.id == DEFAULT_MODEL_ID)
+ {
+ let default_model = models.remove(default_model_position);
+ models.insert(0, default_model);
+ }
+
+ Ok(models)
+}
+
+async fn request_models(api_token: String, client: Arc<dyn HttpClient>) -> Result<Vec<Model>> {
+ let request_builder = HttpRequest::builder()
+ .method(Method::GET)
+ .uri(COPILOT_CHAT_MODELS_URL)
+ .header("Authorization", format!("Bearer {}", api_token))
+ .header("Content-Type", "application/json")
+ .header("Copilot-Integration-Id", "vscode-chat");
+
+ let request = request_builder.body(AsyncBody::empty())?;
+
+ let mut response = client.send(request).await?;
+
+ if response.status().is_success() {
+ let mut body = Vec::new();
+ response.body_mut().read_to_end(&mut body).await?;
+
+ let body_str = std::str::from_utf8(&body)?;
+
+ let models = serde_json::from_str::<ModelSchema>(body_str)?.data;
+
+ Ok(models)
+ } else {
+ Err(anyhow!("Failed to request models: {}", response.status()))
+ }
+}
+
async fn request_api_token(oauth_token: &str, client: Arc<dyn HttpClient>) -> Result<ApiToken> {
let request_builder = HttpRequest::builder()
.method(Method::GET)
@@ -527,3 +591,82 @@ async fn stream_completion(
Ok(futures::stream::once(async move { Ok(response) }).boxed())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_resilient_model_schema_deserialize() {
+ let json = r#"{
+ "data": [
+ {
+ "capabilities": {
+ "family": "gpt-4",
+ "limits": {
+ "max_context_window_tokens": 32768,
+ "max_output_tokens": 4096,
+ "max_prompt_tokens": 32768
+ },
+ "object": "model_capabilities",
+ "supports": { "streaming": true, "tool_calls": true },
+ "tokenizer": "cl100k_base",
+ "type": "chat"
+ },
+ "id": "gpt-4",
+ "model_picker_enabled": false,
+ "name": "GPT 4",
+ "object": "model",
+ "preview": false,
+ "vendor": "Azure OpenAI",
+ "version": "gpt-4-0613"
+ },
+ {
+ "some-unknown-field": 123
+ },
+ {
+ "capabilities": {
+ "family": "claude-3.7-sonnet",
+ "limits": {
+ "max_context_window_tokens": 200000,
+ "max_output_tokens": 16384,
+ "max_prompt_tokens": 90000,
+ "vision": {
+ "max_prompt_image_size": 3145728,
+ "max_prompt_images": 1,
+ "supported_media_types": ["image/jpeg", "image/png", "image/webp"]
+ }
+ },
+ "object": "model_capabilities",
+ "supports": {
+ "parallel_tool_calls": true,
+ "streaming": true,
+ "tool_calls": true,
+ "vision": true
+ },
+ "tokenizer": "o200k_base",
+ "type": "chat"
+ },
+ "id": "claude-3.7-sonnet",
+ "model_picker_enabled": true,
+ "name": "Claude 3.7 Sonnet",
+ "object": "model",
+ "policy": {
+ "state": "enabled",
+ "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)."
+ },
+ "preview": false,
+ "vendor": "Anthropic",
+ "version": "claude-3.7-sonnet"
+ }
+ ],
+ "object": "list"
+ }"#;
+
+ let schema: ModelSchema = serde_json::from_str(&json).unwrap();
+
+ assert_eq!(schema.data.len(), 2);
+ assert_eq!(schema.data[0].id, "gpt-4");
+ assert_eq!(schema.data[1].id, "claude-3.7-sonnet");
+ }
+}
@@ -5,8 +5,8 @@ use std::sync::Arc;
use anyhow::{Result, anyhow};
use collections::HashMap;
use copilot::copilot_chat::{
- ChatMessage, CopilotChat, Model as CopilotChatModel, Request as CopilotChatRequest,
- ResponseEvent, Tool, ToolCall,
+ ChatMessage, CopilotChat, Model as CopilotChatModel, ModelVendor,
+ Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall,
};
use copilot::{Copilot, Status};
use futures::future::BoxFuture;
@@ -20,12 +20,11 @@ use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolUse, MessageContent,
- RateLimiter, Role, StopReason,
+ LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolSchemaFormat,
+ LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason,
};
use settings::SettingsStore;
use std::time::Duration;
-use strum::IntoEnumIterator;
use ui::prelude::*;
use super::anthropic::count_anthropic_tokens;
@@ -100,17 +99,26 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
IconName::Copilot
}
- fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
- Some(self.create_language_model(CopilotChatModel::default()))
+ fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
+ let models = CopilotChat::global(cx).and_then(|m| m.read(cx).models())?;
+ models
+ .first()
+ .map(|model| self.create_language_model(model.clone()))
}
- fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
- Some(self.create_language_model(CopilotChatModel::default_fast()))
+ fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
+ // The default model should be Copilot Chat's 'base model', which is likely a relatively fast
+ // model (e.g. 4o) and a sensible choice when considering premium requests
+ self.default_model(cx)
}
- fn provided_models(&self, _cx: &App) -> Vec<Arc<dyn LanguageModel>> {
- CopilotChatModel::iter()
- .map(|model| self.create_language_model(model))
+ fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
+ let Some(models) = CopilotChat::global(cx).and_then(|m| m.read(cx).models()) else {
+ return Vec::new();
+ };
+ models
+ .iter()
+ .map(|model| self.create_language_model(model.clone()))
.collect()
}
@@ -187,13 +195,15 @@ impl LanguageModel for CopilotChatLanguageModel {
}
fn supports_tools(&self) -> bool {
- match self.model {
- CopilotChatModel::Gpt4o
- | CopilotChatModel::Gpt4_1
- | CopilotChatModel::O4Mini
- | CopilotChatModel::Claude3_5Sonnet
- | CopilotChatModel::Claude3_7Sonnet => true,
- _ => false,
+ self.model.supports_tools()
+ }
+
+ fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
+ match self.model.vendor() {
+ ModelVendor::OpenAI | ModelVendor::Anthropic => {
+ LanguageModelToolSchemaFormat::JsonSchema
+ }
+ ModelVendor::Google => LanguageModelToolSchemaFormat::JsonSchemaSubset,
}
}
@@ -218,25 +228,13 @@ impl LanguageModel for CopilotChatLanguageModel {
request: LanguageModelRequest,
cx: &App,
) -> BoxFuture<'static, Result<usize>> {
- match self.model {
- CopilotChatModel::Claude3_5Sonnet
- | CopilotChatModel::Claude3_7Sonnet
- | CopilotChatModel::Claude3_7SonnetThinking => count_anthropic_tokens(request, cx),
- CopilotChatModel::Gemini20Flash | CopilotChatModel::Gemini25Pro => {
- count_google_tokens(request, cx)
+ match self.model.vendor() {
+ ModelVendor::Anthropic => count_anthropic_tokens(request, cx),
+ ModelVendor::Google => count_google_tokens(request, cx),
+ ModelVendor::OpenAI => {
+ let model = open_ai::Model::from_id(self.model.id()).unwrap_or_default();
+ count_open_ai_tokens(request, model, cx)
}
- CopilotChatModel::Gpt4o => count_open_ai_tokens(request, open_ai::Model::FourOmni, cx),
- CopilotChatModel::Gpt4 => count_open_ai_tokens(request, open_ai::Model::Four, cx),
- CopilotChatModel::Gpt4_1 => {
- count_open_ai_tokens(request, open_ai::Model::FourPointOne, cx)
- }
- CopilotChatModel::Gpt3_5Turbo => {
- count_open_ai_tokens(request, open_ai::Model::ThreePointFiveTurbo, cx)
- }
- CopilotChatModel::O1 => count_open_ai_tokens(request, open_ai::Model::O1, cx),
- CopilotChatModel::O3Mini => count_open_ai_tokens(request, open_ai::Model::O3Mini, cx),
- CopilotChatModel::O3 => count_open_ai_tokens(request, open_ai::Model::O3, cx),
- CopilotChatModel::O4Mini => count_open_ai_tokens(request, open_ai::Model::O4Mini, cx),
}
}
@@ -430,8 +428,6 @@ impl CopilotChatLanguageModel {
&self,
request: LanguageModelRequest,
) -> Result<CopilotChatRequest> {
- let model = self.model.clone();
-
let mut request_messages: Vec<LanguageModelRequestMessage> = Vec::new();
for message in request.messages {
if let Some(last_message) = request_messages.last_mut() {
@@ -545,9 +541,9 @@ impl CopilotChatLanguageModel {
Ok(CopilotChatRequest {
intent: true,
n: 1,
- stream: model.uses_streaming(),
+ stream: self.model.uses_streaming(),
temperature: 0.1,
- model,
+ model: self.model.id().to_string(),
messages,
tools,
tool_choice: request.tool_choice.map(|choice| match choice {