copilot: Allow enterprise to sign in and use copilot (#32296)

This addresses:
https://github.com/zed-industries/zed/pull/32248#issuecomment-2952060834.

This PR address two main things one allowing enterprise users to use
copilot chat and completion while also introducing the new way to handle
copilot url specific their subscription. Simplifying the UX around the
github copilot and removes the burden of users figuring out what url to
use for their subscription.

- [x] Pass enterprise_uri to copilot lsp so that it can redirect users
to their enterprise server. Ref:
https://github.com/github/copilot-language-server-release#configuration-management
- [x] Remove the old ui and config language_models.copilot which allowed
users to specify their copilot_chat specific endpoint. We now derive
that automatically using token endpoint for copilot so that we can send
the requests to specific copilot endpoint for depending upon the url
returned by copilot server.
- [x] Tested this for checking the both enterprise and non-enterprise
flow work. Thanks to @theherk for the help to debug and test it.
- [ ] Udpdate the zed.dev/docs to refelect how to setup enterprise
copilot.

What this doesn't do at the moment:

* Currently zed doesn't allow to have two seperate accounts as the token
used in chat is same as the one generated by lsp. After this changes
also this behaviour remains same and users can't have both enterprise
and personal copilot installed.

P.S: Might need to do some bit of code cleanup and other things but
overall I felt this PR was ready for atleast first pass of review to
gather feedback around the implementation and code itself.


Release Notes:

- Add enterprise support for GitHub copilot

---------

Signed-off-by: Umesh Yadav <git@umesh.dev>
This commit is contained in:
Umesh Yadav 2025-06-17 15:06:53 +05:30 committed by GitHub
parent c4355d2905
commit b13144eb1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 214 additions and 283 deletions

View file

@ -24,6 +24,7 @@ use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServer
use node_runtime::NodeRuntime;
use parking_lot::Mutex;
use request::StatusNotification;
use serde_json::json;
use settings::SettingsStore;
use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace};
use std::collections::hash_map::Entry;
@ -61,7 +62,15 @@ pub fn init(
node_runtime: NodeRuntime,
cx: &mut App,
) {
copilot_chat::init(fs.clone(), http.clone(), cx);
let language_settings = all_language_settings(None, cx);
let configuration = copilot_chat::CopilotChatConfiguration {
enterprise_uri: language_settings
.edit_predictions
.copilot
.enterprise_uri
.clone(),
};
copilot_chat::init(fs.clone(), http.clone(), configuration, cx);
let copilot = cx.new({
let node_runtime = node_runtime.clone();
@ -347,8 +356,11 @@ impl Copilot {
_subscription: cx.on_app_quit(Self::shutdown_language_server),
};
this.start_copilot(true, false, cx);
cx.observe_global::<SettingsStore>(move |this, cx| this.start_copilot(true, false, cx))
.detach();
cx.observe_global::<SettingsStore>(move |this, cx| {
this.start_copilot(true, false, cx);
this.send_configuration_update(cx);
})
.detach();
this
}
@ -435,6 +447,43 @@ impl Copilot {
if env.is_empty() { None } else { Some(env) }
}
fn send_configuration_update(&mut self, cx: &mut Context<Self>) {
let copilot_settings = all_language_settings(None, cx)
.edit_predictions
.copilot
.clone();
let settings = json!({
"http": {
"proxy": copilot_settings.proxy,
"proxyStrictSSL": !copilot_settings.proxy_no_verify.unwrap_or(false)
},
"github-enterprise": {
"uri": copilot_settings.enterprise_uri
}
});
if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) {
copilot_chat.update(cx, |chat, cx| {
chat.set_configuration(
copilot_chat::CopilotChatConfiguration {
enterprise_uri: copilot_settings.enterprise_uri.clone(),
},
cx,
);
});
}
if let Ok(server) = self.server.as_running() {
server
.lsp
.notify::<lsp::notification::DidChangeConfiguration>(
&lsp::DidChangeConfigurationParams { settings },
)
.log_err();
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn fake(cx: &mut gpui::TestAppContext) -> (Entity<Self>, lsp::FakeLanguageServer) {
use fs::FakeFs;
@ -541,12 +590,6 @@ impl Copilot {
.into_response()
.context("copilot: check status")?;
server
.request::<request::SetEditorInfo>(editor_info)
.await
.into_response()
.context("copilot: set editor info")?;
anyhow::Ok((server, status))
};
@ -564,6 +607,8 @@ impl Copilot {
});
cx.emit(Event::CopilotLanguageServerStarted);
this.update_sign_in_status(status, cx);
// Send configuration now that the LSP is fully started
this.send_configuration_update(cx);
}
Err(error) => {
this.server = CopilotServer::Error(error.to_string().into());

View file

@ -19,10 +19,47 @@ use settings::watch_config_dir;
pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN";
#[derive(Default, Clone, Debug, PartialEq)]
pub struct CopilotChatSettings {
pub api_url: Arc<str>,
pub auth_url: Arc<str>,
pub models_url: Arc<str>,
pub struct CopilotChatConfiguration {
pub enterprise_uri: Option<String>,
}
impl CopilotChatConfiguration {
pub fn token_url(&self) -> String {
if let Some(enterprise_uri) = &self.enterprise_uri {
let domain = Self::parse_domain(enterprise_uri);
format!("https://api.{}/copilot_internal/v2/token", domain)
} else {
"https://api.github.com/copilot_internal/v2/token".to_string()
}
}
pub fn oauth_domain(&self) -> String {
if let Some(enterprise_uri) = &self.enterprise_uri {
Self::parse_domain(enterprise_uri)
} else {
"github.com".to_string()
}
}
pub fn api_url_from_endpoint(&self, endpoint: &str) -> String {
format!("{}/chat/completions", endpoint)
}
pub fn models_url_from_endpoint(&self, endpoint: &str) -> String {
format!("{}/models", endpoint)
}
fn parse_domain(enterprise_uri: &str) -> String {
let uri = enterprise_uri.trim_end_matches('/');
if let Some(domain) = uri.strip_prefix("https://") {
domain.split('/').next().unwrap_or(domain).to_string()
} else if let Some(domain) = uri.strip_prefix("http://") {
domain.split('/').next().unwrap_or(domain).to_string()
} else {
uri.split('/').next().unwrap_or(uri).to_string()
}
}
}
// Copilot's base model; defined by Microsoft in premium requests table
@ -309,12 +346,19 @@ pub struct FunctionChunk {
struct ApiTokenResponse {
token: String,
expires_at: i64,
endpoints: ApiTokenResponseEndpoints,
}
#[derive(Deserialize)]
struct ApiTokenResponseEndpoints {
api: String,
}
#[derive(Clone)]
struct ApiToken {
api_key: String,
expires_at: DateTime<chrono::Utc>,
api_endpoint: String,
}
impl ApiToken {
@ -335,6 +379,7 @@ impl TryFrom<ApiTokenResponse> for ApiToken {
Ok(Self {
api_key: response.token,
expires_at,
api_endpoint: response.endpoints.api,
})
}
}
@ -346,13 +391,18 @@ impl Global for GlobalCopilotChat {}
pub struct CopilotChat {
oauth_token: Option<String>,
api_token: Option<ApiToken>,
settings: CopilotChatSettings,
configuration: CopilotChatConfiguration,
models: Option<Vec<Model>>,
client: Arc<dyn HttpClient>,
}
pub fn init(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, cx: &mut App) {
let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, cx));
pub fn init(
fs: Arc<dyn Fs>,
client: Arc<dyn HttpClient>,
configuration: CopilotChatConfiguration,
cx: &mut App,
) {
let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, configuration, cx));
cx.set_global(GlobalCopilotChat(copilot_chat));
}
@ -380,10 +430,15 @@ impl CopilotChat {
.map(|model| model.0.clone())
}
fn new(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, cx: &mut Context<Self>) -> Self {
fn new(
fs: Arc<dyn Fs>,
client: Arc<dyn HttpClient>,
configuration: CopilotChatConfiguration,
cx: &mut Context<Self>,
) -> Self {
let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
let dir_path = copilot_chat_config_dir();
let settings = CopilotChatSettings::default();
cx.spawn(async move |this, cx| {
let mut parent_watch_rx = watch_config_dir(
cx.background_executor(),
@ -392,7 +447,9 @@ impl CopilotChat {
config_paths,
);
while let Some(contents) = parent_watch_rx.next().await {
let oauth_token = extract_oauth_token(contents);
let oauth_domain =
this.read_with(cx, |this, _| this.configuration.oauth_domain())?;
let oauth_token = extract_oauth_token(contents, &oauth_domain);
this.update(cx, |this, cx| {
this.oauth_token = oauth_token.clone();
@ -411,9 +468,10 @@ impl CopilotChat {
oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR).ok(),
api_token: None,
models: None,
settings,
configuration,
client,
};
if this.oauth_token.is_some() {
cx.spawn(async move |this, mut cx| Self::update_models(&this, &mut cx).await)
.detach_and_log_err(cx);
@ -423,30 +481,26 @@ impl CopilotChat {
}
async fn update_models(this: &WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
let (oauth_token, client, auth_url) = this.read_with(cx, |this, _| {
let (oauth_token, client, configuration) = this.read_with(cx, |this, _| {
(
this.oauth_token.clone(),
this.client.clone(),
this.settings.auth_url.clone(),
this.configuration.clone(),
)
})?;
let api_token = request_api_token(
&oauth_token.ok_or_else(|| {
anyhow!("OAuth token is missing while updating Copilot Chat models")
})?,
auth_url,
client.clone(),
)
.await?;
let models_url = this.update(cx, |this, cx| {
this.api_token = Some(api_token.clone());
cx.notify();
this.settings.models_url.clone()
})?;
let models = get_models(models_url, api_token.api_key, client.clone()).await?;
let oauth_token = oauth_token
.ok_or_else(|| anyhow!("OAuth token is missing while updating Copilot Chat models"))?;
let token_url = configuration.token_url();
let api_token = request_api_token(&oauth_token, token_url.into(), client.clone()).await?;
let models_url = configuration.models_url_from_endpoint(&api_token.api_endpoint);
let models =
get_models(models_url.into(), api_token.api_key.clone(), client.clone()).await?;
this.update(cx, |this, cx| {
this.api_token = Some(api_token);
this.models = Some(models);
cx.notify();
})?;
@ -471,23 +525,23 @@ impl CopilotChat {
.flatten()
.context("Copilot chat is not enabled")?;
let (oauth_token, api_token, client, api_url, auth_url) =
this.read_with(&cx, |this, _| {
(
this.oauth_token.clone(),
this.api_token.clone(),
this.client.clone(),
this.settings.api_url.clone(),
this.settings.auth_url.clone(),
)
})?;
let (oauth_token, api_token, client, configuration) = this.read_with(&cx, |this, _| {
(
this.oauth_token.clone(),
this.api_token.clone(),
this.client.clone(),
this.configuration.clone(),
)
})?;
let oauth_token = oauth_token.context("No OAuth token available")?;
let token = match api_token {
Some(api_token) if api_token.remaining_seconds() > 5 * 60 => api_token.clone(),
_ => {
let token = request_api_token(&oauth_token, auth_url, client.clone()).await?;
let token_url = configuration.token_url();
let token =
request_api_token(&oauth_token, token_url.into(), client.clone()).await?;
this.update(&mut cx, |this, cx| {
this.api_token = Some(token.clone());
cx.notify();
@ -496,13 +550,19 @@ impl CopilotChat {
}
};
stream_completion(client.clone(), token.api_key, api_url, request).await
let api_url = configuration.api_url_from_endpoint(&token.api_endpoint);
stream_completion(client.clone(), token.api_key, api_url.into(), request).await
}
pub fn set_settings(&mut self, settings: CopilotChatSettings, cx: &mut Context<Self>) {
let same_settings = self.settings == settings;
self.settings = settings;
if !same_settings {
pub fn set_configuration(
&mut self,
configuration: CopilotChatConfiguration,
cx: &mut Context<Self>,
) {
let same_configuration = self.configuration == configuration;
self.configuration = configuration;
if !same_configuration {
self.api_token = None;
cx.spawn(async move |this, cx| {
Self::update_models(&this, cx).await?;
Ok::<_, anyhow::Error>(())
@ -522,16 +582,12 @@ async fn get_models(
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();
@ -608,12 +664,12 @@ async fn request_api_token(
}
}
fn extract_oauth_token(contents: String) -> Option<String> {
fn extract_oauth_token(contents: String, domain: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(&contents)
.map(|v| {
v.as_object().and_then(|obj| {
obj.iter().find_map(|(key, value)| {
if key.starts_with("github.com") {
if key.starts_with(domain) {
value["oauth_token"].as_str().map(|v| v.to_string())
} else {
None