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.
This commit is contained in:
Piotr Osiewicz 2025-06-06 18:05:40 +02:00 committed by GitHub
parent 019a14bcde
commit 73cd6ef92c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 306 additions and 80 deletions

View file

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

View file

@ -10,12 +10,14 @@ use copilot::copilot_chat::{
ToolCall, ToolCall,
}; };
use copilot::{Copilot, Status}; use copilot::{Copilot, Status};
use editor::{Editor, EditorElement, EditorStyle};
use fs::Fs;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::stream::BoxStream; use futures::stream::BoxStream;
use futures::{FutureExt, Stream, StreamExt}; use futures::{FutureExt, Stream, StreamExt};
use gpui::{ use gpui::{
Action, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, Action, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, FontStyle, Render,
Transformation, percentage, svg, Subscription, Task, TextStyle, Transformation, WhiteSpace, percentage, svg,
}; };
use language_model::{ use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
@ -25,21 +27,22 @@ use language_model::{
LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
StopReason, StopReason,
}; };
use settings::SettingsStore; use settings::{Settings, SettingsStore, update_settings_file};
use std::time::Duration; use std::time::Duration;
use theme::ThemeSettings;
use ui::prelude::*; use ui::prelude::*;
use util::debug_panic; use util::debug_panic;
use crate::{AllLanguageModelSettings, CopilotChatSettingsContent};
use super::anthropic::count_anthropic_tokens; use super::anthropic::count_anthropic_tokens;
use super::google::count_google_tokens; use super::google::count_google_tokens;
use super::open_ai::count_open_ai_tokens; use super::open_ai::count_open_ai_tokens;
pub(crate) use copilot::copilot_chat::CopilotChatSettings;
const PROVIDER_ID: &str = "copilot_chat"; const PROVIDER_ID: &str = "copilot_chat";
const PROVIDER_NAME: &str = "GitHub Copilot Chat"; const PROVIDER_NAME: &str = "GitHub Copilot Chat";
#[derive(Default, Clone, Debug, PartialEq)]
pub struct CopilotChatSettings {}
pub struct CopilotChatLanguageModelProvider { pub struct CopilotChatLanguageModelProvider {
state: Entity<State>, state: Entity<State>,
} }
@ -163,9 +166,10 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
Task::ready(Err(err.into())) 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(); 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<()>> { fn reset_credentials(&self, _cx: &mut App) -> Task<Result<()>> {
@ -608,15 +612,38 @@ fn into_copilot_chat(
struct ConfigurationView { struct ConfigurationView {
copilot_status: Option<copilot::Status>, copilot_status: Option<copilot::Status>,
api_url_editor: Entity<Editor>,
models_url_editor: Entity<Editor>,
auth_url_editor: Entity<Editor>,
state: Entity<State>, state: Entity<State>,
_subscription: Option<Subscription>, _subscription: Option<Subscription>,
} }
impl ConfigurationView { 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 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 { Self {
api_url_editor,
models_url_editor,
auth_url_editor,
copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()), copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
state, state,
_subscription: copilot.as_ref().map(|copilot| { _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 { 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."; 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( v_flex()
Button::new("sign_in", "Sign in to use GitHub Copilot") .gap_2()
.icon_color(Color::Muted) .child(Label::new(LABEL))
.icon(IconName::Github) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
.icon_position(IconPosition::Start) this.update_copilot_settings(cx);
.icon_size(IconSize::Medium) copilot::initiate_sign_in(window, cx);
.full_width() }))
.on_click(|_, window, 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)), None => v_flex().gap_6().child(Label::new(ERROR_LABEL)),

View file

@ -272,7 +272,11 @@ pub struct ZedDotDevSettingsContent {
} }
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] #[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)] #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
pub struct OpenRouterSettingsContent { pub struct OpenRouterSettingsContent {
@ -431,6 +435,24 @@ impl settings::Settings for AllLanguageModelSettings {
.as_ref() .as_ref()
.and_then(|s| s.available_models.clone()), .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) Ok(settings)