From 73cd6ef92cae8ea074b74fbba44bf102d7f67036 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 6 Jun 2025 18:05:40 +0200 Subject: [PATCH] Add UI for configuring the API Url directly (#32248) Closes #22901 Release Notes: - Copilot Chat endpoint URLs can now be configured via `settings.json` or Configuration View. --- crates/copilot/src/copilot_chat.rs | 162 ++++++++------ .../src/provider/copilot_chat.rs | 200 ++++++++++++++++-- crates/language_models/src/settings.rs | 24 ++- 3 files changed, 306 insertions(+), 80 deletions(-) diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index b92f8e2042..314926ed36 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/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, + pub auth_url: Arc, + pub models_url: Arc, +} // 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, api_token: Option, + settings: CopilotChatSettings, models: Option>, client: Arc, } @@ -373,53 +378,30 @@ impl CopilotChat { .map(|model| model.0.clone()) } - pub fn new(fs: Arc, client: Arc, cx: &App) -> Self { + fn new(fs: Arc, client: Arc, cx: &mut Context) -> Self { let config_paths: HashSet = 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); - 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(); - }); - } - })?; + 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, 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) { + 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) -> Result> { - let all_models = request_models(api_token, client).await?; +async fn get_models( + models_url: Arc, + api_token: String, + client: Arc, +) -> Result> { + let all_models = request_models(models_url, api_token, client).await?; let mut models: Vec = all_models .into_iter() @@ -504,10 +537,14 @@ async fn get_models(api_token: String, client: Arc) -> Result) -> Result> { +async fn request_models( + models_url: Arc, + api_token: String, + client: Arc, +) -> Result> { 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) -> Resul Ok(models) } -async fn request_api_token(oauth_token: &str, client: Arc) -> Result { +async fn request_api_token( + oauth_token: &str, + auth_url: Arc, + client: Arc, +) -> Result { 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 { async fn stream_completion( client: Arc, api_key: String, + completion_url: Arc, request: Request, ) -> Result>> { 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!( diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 25f97ffd59..c9ed413882 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/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, } @@ -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> { @@ -608,15 +612,38 @@ fn into_copilot_chat( struct ConfigurationView { copilot_status: Option, + api_url_editor: Entity, + models_url_editor: Entity, + auth_url_editor: Entity, state: Entity, _subscription: Option, } impl ConfigurationView { - pub fn new(state: Entity, cx: &mut Context) -> Self { + pub fn new(state: Entity, window: &mut Window, cx: &mut Context) -> 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) -> 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) -> 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) -> 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) -> 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::(::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)), diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 2cf549c8f6..3eec480a8e 100644 --- a/crates/language_models/src/settings.rs +++ b/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, + pub auth_url: Option, + pub models_url: Option, +} #[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)