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