Add UI for configuring the API Url directly (#32248)

Piotr Osiewicz created

Closes #22901 

Release Notes:

- Copilot Chat endpoint URLs can now be configured via `settings.json`
or Configuration View.

Change summary

crates/copilot/src/copilot_chat.rs                  | 164 +++++++----
crates/language_models/src/provider/copilot_chat.rs | 200 +++++++++++++-
crates/language_models/src/settings.rs              |  24 +
3 files changed, 307 insertions(+), 81 deletions(-)

Detailed changes

crates/copilot/src/copilot_chat.rs 🔗

@@ -8,6 +8,7 @@ use chrono::DateTime;
 use collections::HashSet;
 use fs::Fs;
 use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
+use gpui::WeakEntity;
 use gpui::{App, AsyncApp, Global, prelude::*};
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
 use itertools::Itertools;
@@ -15,9 +16,12 @@ use paths::home_dir;
 use serde::{Deserialize, Serialize};
 use settings::watch_config_dir;
 
-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";
+#[derive(Default, Clone, Debug, PartialEq)]
+pub struct CopilotChatSettings {
+    pub api_url: Arc<str>,
+    pub auth_url: Arc<str>,
+    pub models_url: Arc<str>,
+}
 
 // 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
@@ -340,6 +344,7 @@ impl Global for GlobalCopilotChat {}
 pub struct CopilotChat {
     oauth_token: Option<String>,
     api_token: Option<ApiToken>,
+    settings: CopilotChatSettings,
     models: Option<Vec<Model>>,
     client: Arc<dyn HttpClient>,
 }
@@ -373,53 +378,30 @@ impl CopilotChat {
             .map(|model| model.0.clone())
     }
 
-    pub fn new(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, cx: &App) -> Self {
+    fn new(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, 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(),
+                fs.clone(),
+                dir_path.clone(),
+                config_paths,
+            );
+            while let Some(contents) = parent_watch_rx.next().await {
+                let oauth_token = extract_oauth_token(contents);
+
+                this.update(cx, |this, cx| {
+                    this.oauth_token = oauth_token.clone();
+                    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();
-                                });
-                            }
-                        })?;
-                    }
+                if oauth_token.is_some() {
+                    Self::update_models(&this, cx).await?;
                 }
-                anyhow::Ok(())
             }
+            anyhow::Ok(())
         })
         .detach_and_log_err(cx);
 
@@ -427,10 +409,42 @@ impl CopilotChat {
             oauth_token: None,
             api_token: None,
             models: None,
+            settings,
             client,
         }
     }
 
+    async fn update_models(this: &WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
+        let (oauth_token, client, auth_url) = this.read_with(cx, |this, _| {
+            (
+                this.oauth_token.clone(),
+                this.client.clone(),
+                this.settings.auth_url.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?;
+
+        this.update(cx, |this, cx| {
+            this.models = Some(models);
+            cx.notify();
+        })?;
+        anyhow::Ok(())
+    }
+
     pub fn is_authenticated(&self) -> bool {
         self.oauth_token.is_some()
     }
@@ -449,20 +463,23 @@ impl CopilotChat {
             .flatten()
             .context("Copilot chat is not enabled")?;
 
-        let (oauth_token, api_token, client) = this.read_with(&cx, |this, _| {
-            (
-                this.oauth_token.clone(),
-                this.api_token.clone(),
-                this.client.clone(),
-            )
-        })?;
+        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 = 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, client.clone()).await?;
+                let token = request_api_token(&oauth_token, auth_url, client.clone()).await?;
                 this.update(&mut cx, |this, cx| {
                     this.api_token = Some(token.clone());
                     cx.notify();
@@ -471,12 +488,28 @@ impl CopilotChat {
             }
         };
 
-        stream_completion(client.clone(), token.api_key, request).await
+        stream_completion(client.clone(), token.api_key, api_url, 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 {
+            cx.spawn(async move |this, cx| {
+                Self::update_models(&this, cx).await?;
+                Ok::<_, anyhow::Error>(())
+            })
+            .detach();
+        }
     }
 }
 
-async fn get_models(api_token: String, client: Arc<dyn HttpClient>) -> Result<Vec<Model>> {
-    let all_models = request_models(api_token, client).await?;
+async fn get_models(
+    models_url: Arc<str>,
+    api_token: String,
+    client: Arc<dyn HttpClient>,
+) -> Result<Vec<Model>> {
+    let all_models = request_models(models_url, api_token, client).await?;
 
     let mut models: Vec<Model> = all_models
         .into_iter()
@@ -504,10 +537,14 @@ async fn get_models(api_token: String, client: Arc<dyn HttpClient>) -> Result<Ve
     Ok(models)
 }
 
-async fn request_models(api_token: String, client: Arc<dyn HttpClient>) -> Result<Vec<Model>> {
+async fn request_models(
+    models_url: Arc<str>,
+    api_token: String,
+    client: Arc<dyn HttpClient>,
+) -> Result<Vec<Model>> {
     let request_builder = HttpRequest::builder()
         .method(Method::GET)
-        .uri(COPILOT_CHAT_MODELS_URL)
+        .uri(models_url.as_ref())
         .header("Authorization", format!("Bearer {}", api_token))
         .header("Content-Type", "application/json")
         .header("Copilot-Integration-Id", "vscode-chat");
@@ -531,10 +568,14 @@ async fn request_models(api_token: String, client: Arc<dyn HttpClient>) -> Resul
     Ok(models)
 }
 
-async fn request_api_token(oauth_token: &str, client: Arc<dyn HttpClient>) -> Result<ApiToken> {
+async fn request_api_token(
+    oauth_token: &str,
+    auth_url: Arc<str>,
+    client: Arc<dyn HttpClient>,
+) -> Result<ApiToken> {
     let request_builder = HttpRequest::builder()
         .method(Method::GET)
-        .uri(COPILOT_CHAT_AUTH_URL)
+        .uri(auth_url.as_ref())
         .header("Authorization", format!("token {}", oauth_token))
         .header("Accept", "application/json");
 
@@ -579,6 +620,7 @@ fn extract_oauth_token(contents: String) -> Option<String> {
 async fn stream_completion(
     client: Arc<dyn HttpClient>,
     api_key: String,
+    completion_url: Arc<str>,
     request: Request,
 ) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
     let is_vision_request = request.messages.last().map_or(false, |message| match message {
@@ -592,7 +634,7 @@ async fn stream_completion(
 
     let request_builder = HttpRequest::builder()
         .method(Method::POST)
-        .uri(COPILOT_CHAT_COMPLETION_URL)
+        .uri(completion_url.as_ref())
         .header(
             "Editor-Version",
             format!(

crates/language_models/src/provider/copilot_chat.rs 🔗

@@ -10,12 +10,14 @@ use copilot::copilot_chat::{
     ToolCall,
 };
 use copilot::{Copilot, Status};
+use editor::{Editor, EditorElement, EditorStyle};
+use fs::Fs;
 use futures::future::BoxFuture;
 use futures::stream::BoxStream;
 use futures::{FutureExt, Stream, StreamExt};
 use gpui::{
-    Action, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, Render, Subscription, Task,
-    Transformation, percentage, svg,
+    Action, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, FontStyle, Render,
+    Subscription, Task, TextStyle, Transformation, WhiteSpace, percentage, svg,
 };
 use language_model::{
     AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
@@ -25,21 +27,22 @@ use language_model::{
     LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
     StopReason,
 };
-use settings::SettingsStore;
+use settings::{Settings, SettingsStore, update_settings_file};
 use std::time::Duration;
+use theme::ThemeSettings;
 use ui::prelude::*;
 use util::debug_panic;
 
+use crate::{AllLanguageModelSettings, CopilotChatSettingsContent};
+
 use super::anthropic::count_anthropic_tokens;
 use super::google::count_google_tokens;
 use super::open_ai::count_open_ai_tokens;
+pub(crate) use copilot::copilot_chat::CopilotChatSettings;
 
 const PROVIDER_ID: &str = "copilot_chat";
 const PROVIDER_NAME: &str = "GitHub Copilot Chat";
 
-#[derive(Default, Clone, Debug, PartialEq)]
-pub struct CopilotChatSettings {}
-
 pub struct CopilotChatLanguageModelProvider {
     state: Entity<State>,
 }
@@ -163,9 +166,10 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
         Task::ready(Err(err.into()))
     }
 
-    fn configuration_view(&self, _: &mut Window, cx: &mut App) -> AnyView {
+    fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView {
         let state = self.state.clone();
-        cx.new(|cx| ConfigurationView::new(state, cx)).into()
+        cx.new(|cx| ConfigurationView::new(state, window, cx))
+            .into()
     }
 
     fn reset_credentials(&self, _cx: &mut App) -> Task<Result<()>> {
@@ -608,15 +612,38 @@ fn into_copilot_chat(
 
 struct ConfigurationView {
     copilot_status: Option<copilot::Status>,
+    api_url_editor: Entity<Editor>,
+    models_url_editor: Entity<Editor>,
+    auth_url_editor: Entity<Editor>,
     state: Entity<State>,
     _subscription: Option<Subscription>,
 }
 
 impl ConfigurationView {
-    pub fn new(state: Entity<State>, cx: &mut Context<Self>) -> Self {
+    pub fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let copilot = Copilot::global(cx);
-
+        let settings = AllLanguageModelSettings::get_global(cx)
+            .copilot_chat
+            .clone();
+        let api_url_editor = cx.new(|cx| Editor::single_line(window, cx));
+        api_url_editor.update(cx, |this, cx| {
+            this.set_text(settings.api_url.clone(), window, cx);
+            this.set_placeholder_text("GitHub Copilot API URL", cx);
+        });
+        let models_url_editor = cx.new(|cx| Editor::single_line(window, cx));
+        models_url_editor.update(cx, |this, cx| {
+            this.set_text(settings.models_url.clone(), window, cx);
+            this.set_placeholder_text("GitHub Copilot Models URL", cx);
+        });
+        let auth_url_editor = cx.new(|cx| Editor::single_line(window, cx));
+        auth_url_editor.update(cx, |this, cx| {
+            this.set_text(settings.auth_url.clone(), window, cx);
+            this.set_placeholder_text("GitHub Copilot Auth URL", cx);
+        });
         Self {
+            api_url_editor,
+            models_url_editor,
+            auth_url_editor,
             copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
             state,
             _subscription: copilot.as_ref().map(|copilot| {
@@ -627,6 +654,104 @@ impl ConfigurationView {
             }),
         }
     }
+    fn make_input_styles(&self, cx: &App) -> Div {
+        let bg_color = cx.theme().colors().editor_background;
+        let border_color = cx.theme().colors().border;
+
+        h_flex()
+            .w_full()
+            .px_2()
+            .py_1()
+            .bg(bg_color)
+            .border_1()
+            .border_color(border_color)
+            .rounded_sm()
+    }
+
+    fn make_text_style(&self, cx: &Context<Self>) -> TextStyle {
+        let settings = ThemeSettings::get_global(cx);
+        TextStyle {
+            color: cx.theme().colors().text,
+            font_family: settings.ui_font.family.clone(),
+            font_features: settings.ui_font.features.clone(),
+            font_fallbacks: settings.ui_font.fallbacks.clone(),
+            font_size: rems(0.875).into(),
+            font_weight: settings.ui_font.weight,
+            font_style: FontStyle::Normal,
+            line_height: relative(1.3),
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+            white_space: WhiteSpace::Normal,
+            text_overflow: None,
+            text_align: Default::default(),
+            line_clamp: None,
+        }
+    }
+
+    fn render_api_url_editor(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        let text_style = self.make_text_style(cx);
+
+        EditorElement::new(
+            &self.api_url_editor,
+            EditorStyle {
+                background: cx.theme().colors().editor_background,
+                local_player: cx.theme().players().local(),
+                text: text_style,
+                ..Default::default()
+            },
+        )
+    }
+
+    fn render_auth_url_editor(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        let text_style = self.make_text_style(cx);
+
+        EditorElement::new(
+            &self.auth_url_editor,
+            EditorStyle {
+                background: cx.theme().colors().editor_background,
+                local_player: cx.theme().players().local(),
+                text: text_style,
+                ..Default::default()
+            },
+        )
+    }
+    fn render_models_editor(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        let text_style = self.make_text_style(cx);
+
+        EditorElement::new(
+            &self.models_url_editor,
+            EditorStyle {
+                background: cx.theme().colors().editor_background,
+                local_player: cx.theme().players().local(),
+                text: text_style,
+                ..Default::default()
+            },
+        )
+    }
+
+    fn update_copilot_settings(&self, cx: &mut Context<'_, Self>) {
+        let settings = CopilotChatSettings {
+            api_url: self.api_url_editor.read(cx).text(cx).into(),
+            models_url: self.models_url_editor.read(cx).text(cx).into(),
+            auth_url: self.auth_url_editor.read(cx).text(cx).into(),
+        };
+        update_settings_file::<AllLanguageModelSettings>(<dyn Fs>::global(cx), cx, {
+            let settings = settings.clone();
+            move |content, _| {
+                content.copilot_chat = Some(CopilotChatSettingsContent {
+                    api_url: Some(settings.api_url.as_ref().into()),
+                    models_url: Some(settings.models_url.as_ref().into()),
+                    auth_url: Some(settings.auth_url.as_ref().into()),
+                });
+            }
+        });
+        if let Some(chat) = CopilotChat::global(cx) {
+            chat.update(cx, |this, cx| {
+                this.set_settings(settings, cx);
+            });
+        }
+    }
 }
 
 impl Render for ConfigurationView {
@@ -684,15 +809,52 @@ impl Render for ConfigurationView {
                     }
                     _ => {
                         const LABEL: &str = "To use Zed's assistant with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription.";
-                        v_flex().gap_2().child(Label::new(LABEL)).child(
-                            Button::new("sign_in", "Sign in to use GitHub Copilot")
-                                .icon_color(Color::Muted)
-                                .icon(IconName::Github)
-                                .icon_position(IconPosition::Start)
-                                .icon_size(IconSize::Medium)
-                                .full_width()
-                                .on_click(|_, window, cx| copilot::initiate_sign_in(window, cx)),
-                        )
+                        v_flex()
+                            .gap_2()
+                            .child(Label::new(LABEL))
+                            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
+                                this.update_copilot_settings(cx);
+                                copilot::initiate_sign_in(window, cx);
+                            }))
+                            .child(
+                                v_flex()
+                                    .gap_0p5()
+                                    .child(Label::new("API URL").size(LabelSize::Small))
+                                    .child(
+                                        self.make_input_styles(cx)
+                                            .child(self.render_api_url_editor(cx)),
+                                    ),
+                            )
+                            .child(
+                                v_flex()
+                                    .gap_0p5()
+                                    .child(Label::new("Auth URL").size(LabelSize::Small))
+                                    .child(
+                                        self.make_input_styles(cx)
+                                            .child(self.render_auth_url_editor(cx)),
+                                    ),
+                            )
+                            .child(
+                                v_flex()
+                                    .gap_0p5()
+                                    .child(Label::new("Models list URL").size(LabelSize::Small))
+                                    .child(
+                                        self.make_input_styles(cx)
+                                            .child(self.render_models_editor(cx)),
+                                    ),
+                            )
+                            .child(
+                                Button::new("sign_in", "Sign in to use GitHub Copilot")
+                                    .icon_color(Color::Muted)
+                                    .icon(IconName::Github)
+                                    .icon_position(IconPosition::Start)
+                                    .icon_size(IconSize::Medium)
+                                    .full_width()
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.update_copilot_settings(cx);
+                                        copilot::initiate_sign_in(window, cx)
+                                    })),
+                            )
                     }
                 },
                 None => v_flex().gap_6().child(Label::new(ERROR_LABEL)),

crates/language_models/src/settings.rs 🔗

@@ -272,7 +272,11 @@ pub struct ZedDotDevSettingsContent {
 }
 
 #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-pub struct CopilotChatSettingsContent {}
+pub struct CopilotChatSettingsContent {
+    pub api_url: Option<String>,
+    pub auth_url: Option<String>,
+    pub models_url: Option<String>,
+}
 
 #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
 pub struct OpenRouterSettingsContent {
@@ -431,6 +435,24 @@ impl settings::Settings for AllLanguageModelSettings {
                     .as_ref()
                     .and_then(|s| s.available_models.clone()),
             );
+
+            // Copilot Chat
+            let copilot_chat = value.copilot_chat.clone().unwrap_or_default();
+
+            settings.copilot_chat.api_url = copilot_chat.api_url.map_or_else(
+                || Arc::from("https://api.githubcopilot.com/chat/completions"),
+                Arc::from,
+            );
+
+            settings.copilot_chat.auth_url = copilot_chat.auth_url.map_or_else(
+                || Arc::from("https://api.github.com/copilot_internal/v2/token"),
+                Arc::from,
+            );
+
+            settings.copilot_chat.models_url = copilot_chat.models_url.map_or_else(
+                || Arc::from("https://api.githubcopilot.com/models"),
+                Arc::from,
+            );
         }
 
         Ok(settings)