copilot: Support HTTP/HTTPS proxy for Copilot language server (#24364)

Eli Kaplan and Marshall Bowers created

Closes #6701 (one of the top ranking issues as of writing)

Adds the ability to specify an HTTP/HTTPS proxy to route Copilot code
completion API requests through. This should fix copilot functionality
in restricted network environments (where such a proxy is required) but
also opens up the ability to point copilot code completion requests at
your own local LLM, using e.g.:
- https://github.com/jjleng/copilot-proxy
- https://github.com/bernardo-bruning/ollama-copilot/tree/master

External MITM-proxy tools permitting, this can serve as a stop-gap to
allow local LLM code completion in Zed until a proper OpenAI-compatible
local code completions provider is implemented. With this in mind, in
this PR I've added separate `settings.json` variables to configure a
proxy server _specific to the code completions provider_ instead of
using the global `proxy` setting, to allow for cases like this where we
_only_ want to proxy e.g. the Copilot requests, but not all outgoing
traffic from the application.

Currently, two new settings are added:
- `inline_completions.copilot.proxy`: Proxy server URL (HTTP and HTTPS
schemes supported)
- `inline_completions.copilot.proxy_no_verify`: Whether to disable
certificate verification through the proxy

Example:
```js
"features": {
  "inline_completion_provider": "copilot"
},
"show_completions_on_input": true,
// New:
"inline_completions": {
  "copilot": {
    "proxy": "http://example.com:15432",
    "proxy_no_verify": true
  }
}
```


Release Notes:

- Added the ability to specify an HTTP/HTTPS proxy for Copilot.

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>

Change summary

Cargo.lock                               |  3 +
crates/copilot/Cargo.toml                |  3 +
crates/copilot/src/copilot.rs            | 49 ++++++++++++++++++++---
crates/language/src/language_settings.rs | 54 +++++++++++++++++++++++++
4 files changed, 102 insertions(+), 7 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3112,7 +3112,9 @@ dependencies = [
  "clock",
  "collections",
  "command_palette_hooks",
+ "ctor",
  "editor",
+ "env_logger 0.11.6",
  "fs",
  "futures 0.3.31",
  "gpui",
@@ -3120,6 +3122,7 @@ dependencies = [
  "indoc",
  "inline_completion",
  "language",
+ "log",
  "lsp",
  "menu",
  "node_runtime",

crates/copilot/Cargo.toml 🔗

@@ -38,6 +38,7 @@ gpui.workspace = true
 http_client.workspace = true
 inline_completion.workspace = true
 language.workspace = true
+log.workspace = true
 lsp.workspace = true
 menu.workspace = true
 node_runtime.workspace = true
@@ -62,7 +63,9 @@ async-std = { version = "1.12.0", features = ["unstable"] }
 client = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
+ctor.workspace = true
 editor = { workspace = true, features = ["test-support"] }
+env_logger.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }

crates/copilot/src/copilot.rs 🔗

@@ -16,6 +16,7 @@ use gpui::{
 };
 use http_client::github::get_release_by_tag_name;
 use http_client::HttpClient;
+use language::language_settings::CopilotSettings;
 use language::{
     language_settings::{all_language_settings, language_settings, EditPredictionProvider},
     point_from_lsp, point_to_lsp, Anchor, Bias, Buffer, BufferSnapshot, Language, PointUtf16,
@@ -367,13 +368,13 @@ impl Copilot {
         let server_id = self.server_id;
         let http = self.http.clone();
         let node_runtime = self.node_runtime.clone();
-        if all_language_settings(None, cx).edit_predictions.provider
-            == EditPredictionProvider::Copilot
-        {
+        let language_settings = all_language_settings(None, cx);
+        if language_settings.edit_predictions.provider == EditPredictionProvider::Copilot {
             if matches!(self.server, CopilotServer::Disabled) {
+                let env = self.build_env(&language_settings.edit_predictions.copilot);
                 let start_task = cx
                     .spawn(move |this, cx| {
-                        Self::start_language_server(server_id, http, node_runtime, this, cx)
+                        Self::start_language_server(server_id, http, node_runtime, env, this, cx)
                     })
                     .shared();
                 self.server = CopilotServer::Starting { task: start_task };
@@ -385,6 +386,30 @@ impl Copilot {
         }
     }
 
+    fn build_env(&self, copilot_settings: &CopilotSettings) -> Option<HashMap<String, String>> {
+        let proxy_url = copilot_settings.proxy.clone()?;
+        let no_verify = copilot_settings.proxy_no_verify;
+        let http_or_https_proxy = if proxy_url.starts_with("http:") {
+            "HTTP_PROXY"
+        } else if proxy_url.starts_with("https:") {
+            "HTTPS_PROXY"
+        } else {
+            log::error!(
+                "Unsupported protocol scheme for language server proxy (must be http or https)"
+            );
+            return None;
+        };
+
+        let mut env = HashMap::default();
+        env.insert(http_or_https_proxy.to_string(), proxy_url);
+
+        if let Some(true) = no_verify {
+            env.insert("NODE_TLS_REJECT_UNAUTHORIZED".to_string(), "0".to_string());
+        };
+
+        Some(env)
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn fake(cx: &mut gpui::TestAppContext) -> (Entity<Self>, lsp::FakeLanguageServer) {
         use lsp::FakeLanguageServer;
@@ -422,6 +447,7 @@ impl Copilot {
         new_server_id: LanguageServerId,
         http: Arc<dyn HttpClient>,
         node_runtime: NodeRuntime,
+        env: Option<HashMap<String, String>>,
         this: WeakEntity<Self>,
         mut cx: AsyncApp,
     ) {
@@ -432,8 +458,7 @@ impl Copilot {
             let binary = LanguageServerBinary {
                 path: node_path,
                 arguments,
-                // TODO: We could set HTTP_PROXY etc here and fix the copilot issue.
-                env: None,
+                env,
             };
 
             let root_path = if cfg!(target_os = "windows") {
@@ -611,6 +636,8 @@ impl Copilot {
     }
 
     pub fn reinstall(&mut self, cx: &mut Context<Self>) -> Task<()> {
+        let language_settings = all_language_settings(None, cx);
+        let env = self.build_env(&language_settings.edit_predictions.copilot);
         let start_task = cx
             .spawn({
                 let http = self.http.clone();
@@ -618,7 +645,7 @@ impl Copilot {
                 let server_id = self.server_id;
                 move |this, cx| async move {
                     clear_copilot_dir().await;
-                    Self::start_language_server(server_id, http, node_runtime, this, cx).await
+                    Self::start_language_server(server_id, http, node_runtime, env, this, cx).await
                 }
             })
             .shared();
@@ -1279,3 +1306,11 @@ mod tests {
         }
     }
 }
+
+#[cfg(test)]
+#[ctor::ctor]
+fn init_logger() {
+    if std::env::var("RUST_LOG").is_ok() {
+        env_logger::init();
+    }
+}

crates/language/src/language_settings.rs 🔗

@@ -234,6 +234,8 @@ pub struct EditPredictionSettings {
     pub disabled_globs: Vec<GlobMatcher>,
     /// Configures how edit predictions are displayed in the buffer.
     pub mode: EditPredictionsMode,
+    /// Settings specific to GitHub Copilot.
+    pub copilot: CopilotSettings,
 }
 
 /// The mode in which edit predictions should be displayed.
@@ -248,6 +250,14 @@ pub enum EditPredictionsMode {
     EagerPreview,
 }
 
+#[derive(Clone, Debug, Default)]
+pub struct CopilotSettings {
+    /// HTTP/HTTPS proxy to use for Copilot.
+    pub proxy: Option<String>,
+    /// Disable certificate verification for proxy (not recommended).
+    pub proxy_no_verify: Option<bool>,
+}
+
 /// The settings for all languages.
 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct AllLanguageSettingsContent {
@@ -465,6 +475,23 @@ pub struct EditPredictionSettingsContent {
     /// Provider support required.
     #[serde(default)]
     pub mode: EditPredictionsMode,
+    /// Settings specific to GitHub Copilot.
+    #[serde(default)]
+    pub copilot: CopilotSettingsContent,
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
+pub struct CopilotSettingsContent {
+    /// HTTP/HTTPS proxy to use for Copilot.
+    ///
+    /// Default: none
+    #[serde(default)]
+    pub proxy: Option<String>,
+    /// Disable certificate verification for the proxy (not recommended).
+    ///
+    /// Default: false
+    #[serde(default)]
+    pub proxy_no_verify: Option<bool>,
 }
 
 /// The settings for enabling/disabling features.
@@ -1064,6 +1091,16 @@ impl settings::Settings for AllLanguageSettings {
             .map(|globs| globs.iter().collect())
             .ok_or_else(Self::missing_default)?;
 
+        let mut copilot_settings = default_value
+            .edit_predictions
+            .as_ref()
+            .map(|settings| settings.copilot.clone())
+            .map(|copilot| CopilotSettings {
+                proxy: copilot.proxy,
+                proxy_no_verify: copilot.proxy_no_verify,
+            })
+            .unwrap_or_default();
+
         let mut file_types: HashMap<Arc<str>, GlobSet> = HashMap::default();
 
         for (language, suffixes) in &default_value.file_types {
@@ -1096,6 +1133,22 @@ impl settings::Settings for AllLanguageSettings {
                 }
             }
 
+            if let Some(proxy) = user_settings
+                .edit_predictions
+                .as_ref()
+                .and_then(|settings| settings.copilot.proxy.clone())
+            {
+                copilot_settings.proxy = Some(proxy);
+            }
+
+            if let Some(proxy_no_verify) = user_settings
+                .edit_predictions
+                .as_ref()
+                .and_then(|settings| settings.copilot.proxy_no_verify)
+            {
+                copilot_settings.proxy_no_verify = Some(proxy_no_verify);
+            }
+
             // A user's global settings override the default global settings and
             // all default language-specific settings.
             merge_settings(&mut defaults, &user_settings.defaults);
@@ -1147,6 +1200,7 @@ impl settings::Settings for AllLanguageSettings {
                     .filter_map(|g| Some(globset::Glob::new(g).ok()?.compile_matcher()))
                     .collect(),
                 mode: edit_predictions_mode,
+                copilot: copilot_settings,
             },
             defaults,
             languages,