1use std::future;
2use std::sync::Arc;
3
4use anyhow::{anyhow, Result};
5use copilot::copilot_chat::{
6 ChatMessage, CopilotChat, Model as CopilotChatModel, Request as CopilotChatRequest,
7 Role as CopilotChatRole,
8};
9use copilot::{Copilot, Status};
10use futures::future::BoxFuture;
11use futures::stream::BoxStream;
12use futures::{FutureExt, StreamExt};
13use gpui::{
14 percentage, svg, Animation, AnimationExt, AnyView, AppContext, AsyncAppContext, FocusHandle,
15 Model, Render, Subscription, Task, Transformation,
16};
17use settings::{Settings, SettingsStore};
18use std::time::Duration;
19use strum::IntoEnumIterator;
20use ui::{
21 div, h_flex, v_flex, Button, ButtonCommon, Clickable, Color, Context, FixedWidth, IconName,
22 IconPosition, IconSize, Indicator, IntoElement, Label, LabelCommon, ParentElement, Styled,
23 ViewContext, VisualContext, WindowContext,
24};
25
26use crate::settings::AllLanguageModelSettings;
27use crate::LanguageModelProviderState;
28use crate::{
29 LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
30 LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, RateLimiter, Role,
31};
32
33use super::open_ai::count_open_ai_tokens;
34
35const PROVIDER_ID: &str = "copilot_chat";
36const PROVIDER_NAME: &str = "GitHub Copilot Chat";
37
38#[derive(Default, Clone, Debug, PartialEq)]
39pub struct CopilotChatSettings {
40 pub low_speed_timeout: Option<Duration>,
41}
42
43pub struct CopilotChatLanguageModelProvider {
44 state: Model<State>,
45}
46
47pub struct State {
48 _copilot_chat_subscription: Option<Subscription>,
49 _settings_subscription: Subscription,
50}
51
52impl State {
53 fn is_authenticated(&self, cx: &AppContext) -> bool {
54 CopilotChat::global(cx)
55 .map(|m| m.read(cx).is_authenticated())
56 .unwrap_or(false)
57 }
58}
59
60impl CopilotChatLanguageModelProvider {
61 pub fn new(cx: &mut AppContext) -> Self {
62 let state = cx.new_model(|cx| {
63 let _copilot_chat_subscription = CopilotChat::global(cx)
64 .map(|copilot_chat| cx.observe(&copilot_chat, |_, _, cx| cx.notify()));
65 State {
66 _copilot_chat_subscription,
67 _settings_subscription: cx.observe_global::<SettingsStore>(|_, cx| {
68 cx.notify();
69 }),
70 }
71 });
72
73 Self { state }
74 }
75}
76
77impl LanguageModelProviderState for CopilotChatLanguageModelProvider {
78 type ObservableEntity = State;
79
80 fn observable_entity(&self) -> Option<gpui::Model<Self::ObservableEntity>> {
81 Some(self.state.clone())
82 }
83}
84
85impl LanguageModelProvider for CopilotChatLanguageModelProvider {
86 fn id(&self) -> LanguageModelProviderId {
87 LanguageModelProviderId(PROVIDER_ID.into())
88 }
89
90 fn name(&self) -> LanguageModelProviderName {
91 LanguageModelProviderName(PROVIDER_NAME.into())
92 }
93
94 fn provided_models(&self, _cx: &AppContext) -> Vec<Arc<dyn LanguageModel>> {
95 CopilotChatModel::iter()
96 .map(|model| {
97 Arc::new(CopilotChatLanguageModel {
98 model,
99 request_limiter: RateLimiter::new(4),
100 }) as Arc<dyn LanguageModel>
101 })
102 .collect()
103 }
104
105 fn is_authenticated(&self, cx: &AppContext) -> bool {
106 self.state.read(cx).is_authenticated(cx)
107 }
108
109 fn authenticate(&self, cx: &mut AppContext) -> Task<Result<()>> {
110 let result = if self.is_authenticated(cx) {
111 Ok(())
112 } else if let Some(copilot) = Copilot::global(cx) {
113 let error_msg = match copilot.read(cx).status() {
114 Status::Disabled => anyhow::anyhow!("Copilot must be enabled for Copilot Chat to work. Please enable Copilot and try again."),
115 Status::Error(e) => anyhow::anyhow!(format!("Received the following error while signing into Copilot: {e}")),
116 Status::Starting { task: _ } => anyhow::anyhow!("Copilot is still starting, please wait for Copilot to start then try again"),
117 Status::Unauthorized => anyhow::anyhow!("Unable to authorize with Copilot. Please make sure that you have an active Copilot and Copilot Chat subscription."),
118 Status::Authorized => return Task::ready(Ok(())),
119 Status::SignedOut => anyhow::anyhow!("You have signed out of Copilot. Please sign in to Copilot and try again."),
120 Status::SigningIn { prompt: _ } => anyhow::anyhow!("Still signing into Copilot..."),
121 };
122 Err(error_msg)
123 } else {
124 Err(anyhow::anyhow!(
125 "Copilot must be enabled for Copilot Chat to work. Please enable Copilot and try again."
126 ))
127 };
128 Task::ready(result)
129 }
130
131 fn configuration_view(&self, cx: &mut WindowContext) -> (AnyView, Option<FocusHandle>) {
132 let state = self.state.clone();
133 let view = cx.new_view(|cx| ConfigurationView::new(state, cx)).into();
134 (view, None)
135 }
136
137 fn reset_credentials(&self, _cx: &mut AppContext) -> Task<Result<()>> {
138 Task::ready(Err(anyhow!(
139 "Signing out of GitHub Copilot Chat is currently not supported."
140 )))
141 }
142}
143
144pub struct CopilotChatLanguageModel {
145 model: CopilotChatModel,
146 request_limiter: RateLimiter,
147}
148
149impl LanguageModel for CopilotChatLanguageModel {
150 fn id(&self) -> LanguageModelId {
151 LanguageModelId::from(self.model.id().to_string())
152 }
153
154 fn name(&self) -> LanguageModelName {
155 LanguageModelName::from(self.model.display_name().to_string())
156 }
157
158 fn provider_id(&self) -> LanguageModelProviderId {
159 LanguageModelProviderId(PROVIDER_ID.into())
160 }
161
162 fn provider_name(&self) -> LanguageModelProviderName {
163 LanguageModelProviderName(PROVIDER_NAME.into())
164 }
165
166 fn telemetry_id(&self) -> String {
167 format!("copilot_chat/{}", self.model.id())
168 }
169
170 fn max_token_count(&self) -> usize {
171 self.model.max_token_count()
172 }
173
174 fn count_tokens(
175 &self,
176 request: LanguageModelRequest,
177 cx: &AppContext,
178 ) -> BoxFuture<'static, Result<usize>> {
179 let model = match self.model {
180 CopilotChatModel::Gpt4 => open_ai::Model::Four,
181 CopilotChatModel::Gpt3_5Turbo => open_ai::Model::ThreePointFiveTurbo,
182 };
183
184 count_open_ai_tokens(request, model, cx)
185 }
186
187 fn stream_completion(
188 &self,
189 request: LanguageModelRequest,
190 cx: &AsyncAppContext,
191 ) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>> {
192 if let Some(message) = request.messages.last() {
193 if message.content.trim().is_empty() {
194 const EMPTY_PROMPT_MSG: &str =
195 "Empty prompts aren't allowed. Please provide a non-empty prompt.";
196 return futures::future::ready(Err(anyhow::anyhow!(EMPTY_PROMPT_MSG))).boxed();
197 }
198
199 // Copilot Chat has a restriction that the final message must be from the user.
200 // While their API does return an error message for this, we can catch it earlier
201 // and provide a more helpful error message.
202 if !matches!(message.role, Role::User) {
203 const USER_ROLE_MSG: &str = "The final message must be from the user. To provide a system prompt, you must provide the system prompt followed by a user prompt.";
204 return futures::future::ready(Err(anyhow::anyhow!(USER_ROLE_MSG))).boxed();
205 }
206 }
207
208 let request = self.to_copilot_chat_request(request);
209 let Ok(low_speed_timeout) = cx.update(|cx| {
210 AllLanguageModelSettings::get_global(cx)
211 .copilot_chat
212 .low_speed_timeout
213 }) else {
214 return futures::future::ready(Err(anyhow::anyhow!("App state dropped"))).boxed();
215 };
216
217 let request_limiter = self.request_limiter.clone();
218 let future = cx.spawn(|cx| async move {
219 let response = CopilotChat::stream_completion(request, low_speed_timeout, cx);
220 request_limiter.stream(async move {
221 let response = response.await?;
222 let stream = response
223 .filter_map(|response| async move {
224 match response {
225 Ok(result) => {
226 let choice = result.choices.first();
227 match choice {
228 Some(choice) => Some(Ok(choice.delta.content.clone().unwrap_or_default())),
229 None => Some(Err(anyhow::anyhow!(
230 "The Copilot Chat API returned a response with no choices, but hadn't finished the message yet. Please try again."
231 ))),
232 }
233 }
234 Err(err) => Some(Err(err)),
235 }
236 })
237 .boxed();
238 Ok(stream)
239 }).await
240 });
241
242 async move { Ok(future.await?.boxed()) }.boxed()
243 }
244
245 fn use_any_tool(
246 &self,
247 _request: LanguageModelRequest,
248 _name: String,
249 _description: String,
250 _schema: serde_json::Value,
251 _cx: &AsyncAppContext,
252 ) -> BoxFuture<'static, Result<serde_json::Value>> {
253 future::ready(Err(anyhow!("not implemented"))).boxed()
254 }
255}
256
257impl CopilotChatLanguageModel {
258 pub fn to_copilot_chat_request(&self, request: LanguageModelRequest) -> CopilotChatRequest {
259 CopilotChatRequest::new(
260 self.model.clone(),
261 request
262 .messages
263 .into_iter()
264 .map(|msg| ChatMessage {
265 role: match msg.role {
266 Role::User => CopilotChatRole::User,
267 Role::Assistant => CopilotChatRole::Assistant,
268 Role::System => CopilotChatRole::System,
269 },
270 content: msg.content,
271 })
272 .collect(),
273 )
274 }
275}
276
277struct ConfigurationView {
278 copilot_status: Option<copilot::Status>,
279 state: Model<State>,
280 _subscription: Option<Subscription>,
281}
282
283impl ConfigurationView {
284 pub fn new(state: Model<State>, cx: &mut ViewContext<Self>) -> Self {
285 let copilot = Copilot::global(cx);
286
287 Self {
288 copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
289 state,
290 _subscription: copilot.as_ref().map(|copilot| {
291 cx.observe(copilot, |this, model, cx| {
292 this.copilot_status = Some(model.read(cx).status());
293 cx.notify();
294 })
295 }),
296 }
297 }
298}
299
300impl Render for ConfigurationView {
301 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
302 if self.state.read(cx).is_authenticated(cx) {
303 const LABEL: &str = "Authorized.";
304 h_flex()
305 .gap_2()
306 .child(Indicator::dot().color(Color::Success))
307 .child(Label::new(LABEL))
308 } else {
309 let loading_icon = svg()
310 .size_8()
311 .path(IconName::ArrowCircle.path())
312 .text_color(cx.text_style().color)
313 .with_animation(
314 "icon_circle_arrow",
315 Animation::new(Duration::from_secs(2)).repeat(),
316 |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))),
317 );
318
319 const ERROR_LABEL: &str = "Copilot Chat requires the Copilot plugin to be available and running. Please ensure Copilot is running and try again, or use a different Assistant provider.";
320
321 match &self.copilot_status {
322 Some(status) => match status {
323 Status::Disabled => v_flex().gap_6().p_4().child(Label::new(ERROR_LABEL)),
324 Status::Starting { task: _ } => {
325 const LABEL: &str = "Starting Copilot...";
326 v_flex()
327 .gap_6()
328 .justify_center()
329 .items_center()
330 .child(Label::new(LABEL))
331 .child(loading_icon)
332 }
333 Status::SigningIn { prompt: _ } => {
334 const LABEL: &str = "Signing in to Copilot...";
335 v_flex()
336 .gap_6()
337 .justify_center()
338 .items_center()
339 .child(Label::new(LABEL))
340 .child(loading_icon)
341 }
342 Status::Error(_) => {
343 const LABEL: &str = "Copilot had issues starting. Please try restarting it. If the issue persists, try reinstalling Copilot.";
344 v_flex()
345 .gap_6()
346 .child(Label::new(LABEL))
347 .child(svg().size_8().path(IconName::CopilotError.path()))
348 }
349 _ => {
350 const LABEL: &str =
351 "To use the assistant panel or inline assistant, you must login to GitHub Copilot. Your GitHub account must have an active Copilot Chat subscription.";
352 v_flex().gap_6().child(Label::new(LABEL)).child(
353 v_flex()
354 .gap_2()
355 .child(
356 Button::new("sign_in", "Sign In")
357 .icon_color(Color::Muted)
358 .icon(IconName::Github)
359 .icon_position(IconPosition::Start)
360 .icon_size(IconSize::Medium)
361 .style(ui::ButtonStyle::Filled)
362 .full_width()
363 .on_click(|_, cx| {
364 inline_completion_button::initiate_sign_in(cx)
365 }),
366 )
367 .child(
368 div().flex().w_full().items_center().child(
369 Label::new("Sign in to start using Github Copilot Chat.")
370 .color(Color::Muted)
371 .size(ui::LabelSize::Small),
372 ),
373 ),
374 )
375 }
376 },
377 None => v_flex().gap_6().child(Label::new(ERROR_LABEL)),
378 }
379 }
380 }
381}