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 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);
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<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!(

View file

@ -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)),

View file

@ -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)