copilot: Display cost multiplier for Github Copilot models (#44800)

Sathiyaraman M and Danilo Leal created

### Description

Related Discussions: #44499, #35742, #31851

Display cost multiplier for GitHub Copilot models in the model selectors
(Both in Chat Panel and Inline Assistant)

<img width="436" height="800" alt="image"
src="https://github.com/user-attachments/assets/c9ebd8fa-4d55-4be8-b3e1-f46dbf1f0145"
/>


### Some technical notes

Although this PR's primary intent is to show the cost multiplier for
GitHub Copilot models alone, I have included some necessary plumbing to
allow specifying costs for other providers in future. I have introduced
an enum called `LanguageModelCostInfo` for showing cost in different
ways for different models. Now, this enum is used in `LanguageModel`
trait to get the cost info.

For now to begin with, in `LanguageModelCostInfo`, I have specified two
ways of pricing: Request-based (1 Agent request - GitHub Copilot uses
this) and Token-based (1M Input tokens / 1M Output tokens). I had
initially thought about adding a `Free` type, especially for Ollama but
didn't do it after realizing that Ollama has paid plans. Right now, only
the Request-based pricing is implemented and used for Copilot models.

Feel free to suggest changes on how to improve this design better.

Release Notes:

- Show cost multiplier for GitHub Copilot models

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

crates/acp_thread/src/connection.rs                 |  3 +
crates/agent/src/agent.rs                           |  2 
crates/agent_ui/src/acp/model_selector.rs           | 10 +++
crates/agent_ui/src/language_model_selector.rs      |  6 ++
crates/agent_ui/src/ui/model_selector_components.rs | 20 ++++++
crates/copilot_chat/src/copilot_chat.rs             |  4 +
crates/language_model/src/language_model.rs         | 43 +++++++++++++++
crates/language_models/src/provider/copilot_chat.rs | 17 ++++-
crates/ui/src/components/chip.rs                    | 13 +++
9 files changed, 109 insertions(+), 9 deletions(-)

Detailed changes

crates/acp_thread/src/connection.rs 🔗

@@ -393,6 +393,7 @@ pub struct AgentModelInfo {
     pub description: Option<SharedString>,
     pub icon: Option<AgentModelIcon>,
     pub is_latest: bool,
+    pub cost: Option<SharedString>,
 }
 
 impl From<acp::ModelInfo> for AgentModelInfo {
@@ -403,6 +404,7 @@ impl From<acp::ModelInfo> for AgentModelInfo {
             description: info.description.map(|desc| desc.into()),
             icon: None,
             is_latest: false,
+            cost: None,
         }
     }
 }
@@ -778,6 +780,7 @@ mod test_support {
                     description: Some("A stub model for visual testing".into()),
                     icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)),
                     is_latest: false,
+                    cost: None,
                 })),
             }
         }

crates/agent/src/agent.rs 🔗

@@ -167,6 +167,7 @@ impl LanguageModels {
                 IconOrSvg::Icon(name) => acp_thread::AgentModelIcon::Named(name),
             }),
             is_latest: model.is_latest(),
+            cost: model.model_cost_info().map(|cost| cost.to_shared_string()),
         }
     }
 
@@ -1989,6 +1990,7 @@ mod internal_tests {
                         ui::IconName::ZedAssistant
                     )),
                     is_latest: false,
+                    cost: None,
                 }]
             )])
         );

crates/agent_ui/src/acp/model_selector.rs 🔗

@@ -344,6 +344,8 @@ impl PickerDelegate for AcpModelPickerDelegate {
                     })
                 };
 
+                let model_cost = model_info.cost.clone();
+
                 Some(
                     div()
                         .id(("model-picker-menu-child", ix))
@@ -369,7 +371,8 @@ impl PickerDelegate for AcpModelPickerDelegate {
                                 .is_focused(selected)
                                 .is_latest(model_info.is_latest)
                                 .is_favorite(is_favorite)
-                                .on_toggle_favorite(handle_action_click),
+                                .on_toggle_favorite(handle_action_click)
+                                .cost_info(model_cost)
                         )
                         .into_any_element(),
                 )
@@ -554,6 +557,7 @@ mod tests {
                             description: None,
                             icon: None,
                             is_latest: false,
+                            cost: None,
                         })
                         .collect::<Vec<_>>(),
                 )
@@ -768,6 +772,7 @@ mod tests {
                 description: None,
                 icon: None,
                 is_latest: false,
+                cost: None,
             },
             acp_thread::AgentModelInfo {
                 id: acp::ModelId::new("zed/gemini".to_string()),
@@ -775,6 +780,7 @@ mod tests {
                 description: None,
                 icon: None,
                 is_latest: false,
+                cost: None,
             },
         ]);
         let favorites = create_favorites(vec!["zed/gemini"]);
@@ -816,6 +822,7 @@ mod tests {
                 description: None,
                 icon: None,
                 is_latest: false,
+                cost: None,
             },
             acp_thread::AgentModelInfo {
                 id: acp::ModelId::new("regular-model".to_string()),
@@ -823,6 +830,7 @@ mod tests {
                 description: None,
                 icon: None,
                 is_latest: false,
+                cost: None,
             },
         ]);
         let favorites = create_favorites(vec!["favorite-model"]);

crates/agent_ui/src/language_model_selector.rs 🔗

@@ -571,6 +571,11 @@ impl PickerDelegate for LanguageModelPickerDelegate {
                 let is_selected = Some(model_info.model.provider_id()) == active_provider_id
                     && Some(model_info.model.id()) == active_model_id;
 
+                let model_cost = model_info
+                    .model
+                    .model_cost_info()
+                    .map(|cost| cost.to_shared_string());
+
                 let is_favorite = model_info.is_favorite;
                 let handle_action_click = {
                     let model = model_info.model.clone();
@@ -591,6 +596,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
                         .is_focused(selected)
                         .is_latest(model_info.model.is_latest())
                         .is_favorite(is_favorite)
+                        .cost_info(model_cost)
                         .on_toggle_favorite(handle_action_click)
                         .into_any_element(),
                 )

crates/agent_ui/src/ui/model_selector_components.rs 🔗

@@ -53,6 +53,7 @@ pub struct ModelSelectorListItem {
     is_latest: bool,
     is_favorite: bool,
     on_toggle_favorite: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+    cost_info: Option<SharedString>,
 }
 
 impl ModelSelectorListItem {
@@ -66,6 +67,7 @@ impl ModelSelectorListItem {
             is_latest: false,
             is_favorite: false,
             on_toggle_favorite: None,
+            cost_info: None,
         }
     }
 
@@ -106,6 +108,11 @@ impl ModelSelectorListItem {
         self.on_toggle_favorite = Some(Box::new(handler));
         self
     }
+
+    pub fn cost_info(mut self, cost_info: Option<SharedString>) -> Self {
+        self.cost_info = cost_info;
+        self
+    }
 }
 
 impl RenderOnce for ModelSelectorListItem {
@@ -137,7 +144,18 @@ impl RenderOnce for ModelSelectorListItem {
                         )
                     })
                     .child(Label::new(self.title).truncate())
-                    .when(self.is_latest, |parent| parent.child(Chip::new("Latest"))),
+                    .when(self.is_latest, |parent| parent.child(Chip::new("Latest")))
+                    .when_some(self.cost_info, |this, cost_info| {
+                        let tooltip_text = if cost_info.ends_with('×') {
+                            format!("Cost Multiplier: {}", cost_info)
+                        } else if cost_info.contains('$') {
+                            format!("Cost per Million Tokens: {}", cost_info)
+                        } else {
+                            format!("Cost: {}", cost_info)
+                        };
+
+                        this.child(Chip::new(cost_info).tooltip(Tooltip::text(tooltip_text)))
+                    }),
             )
             .end_slot(div().pr_2().when(self.is_selected, |this| {
                 this.child(Icon::new(IconName::Check).color(Color::Accent))

crates/copilot_chat/src/copilot_chat.rs 🔗

@@ -255,6 +255,10 @@ impl Model {
                 .supported_endpoints
                 .contains(&ModelSupportedEndpoint::Responses)
     }
+
+    pub fn multiplier(&self) -> f64 {
+        self.billing.multiplier
+    }
 }
 
 #[derive(Serialize, Deserialize)]

crates/language_model/src/language_model.rs 🔗

@@ -607,6 +607,11 @@ pub trait LanguageModel: Send + Sync {
         None
     }
 
+    /// Information about the cost of using this model, if available.
+    fn model_cost_info(&self) -> Option<LanguageModelCostInfo> {
+        None
+    }
+
     /// Whether this model supports thinking.
     fn supports_thinking(&self) -> bool {
         false
@@ -890,6 +895,44 @@ pub struct LanguageModelProviderId(pub SharedString);
 #[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
 pub struct LanguageModelProviderName(pub SharedString);
 
+#[derive(Clone, Debug, PartialEq)]
+pub enum LanguageModelCostInfo {
+    /// Cost per 1,000 input and output tokens
+    TokenCost {
+        input_token_cost_per_1m: f64,
+        output_token_cost_per_1m: f64,
+    },
+    /// Cost per request
+    RequestCost { cost_per_request: f64 },
+}
+
+impl LanguageModelCostInfo {
+    pub fn to_shared_string(&self) -> SharedString {
+        match self {
+            LanguageModelCostInfo::RequestCost { cost_per_request } => {
+                let cost_str = format!("{}×", Self::cost_value_to_string(cost_per_request));
+                SharedString::from(cost_str)
+            }
+            LanguageModelCostInfo::TokenCost {
+                input_token_cost_per_1m,
+                output_token_cost_per_1m,
+            } => {
+                let input_cost = Self::cost_value_to_string(input_token_cost_per_1m);
+                let output_cost = Self::cost_value_to_string(output_token_cost_per_1m);
+                SharedString::from(format!("{}$/{}$", input_cost, output_cost))
+            }
+        }
+    }
+
+    fn cost_value_to_string(cost: &f64) -> SharedString {
+        if (cost.fract() - 0.0).abs() < std::f64::EPSILON {
+            SharedString::from(format!("{:.0}", cost))
+        } else {
+            SharedString::from(format!("{:.2}", cost))
+        }
+    }
+}
+
 impl LanguageModelProviderId {
     pub const fn new(id: &'static str) -> Self {
         Self(SharedString::new_static(id))

crates/language_models/src/provider/copilot_chat.rs 🔗

@@ -20,11 +20,11 @@ use http_client::StatusCode;
 use language::language_settings::all_language_settings;
 use language_model::{
     AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice,
-    LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse,
-    MessageContent, RateLimiter, Role, StopReason, TokenUsage,
+    LanguageModelCompletionEvent, LanguageModelCostInfo, LanguageModelId, LanguageModelName,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
+    LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
 };
 use settings::SettingsStore;
 use ui::prelude::*;
@@ -269,6 +269,13 @@ impl LanguageModel for CopilotChatLanguageModel {
         }
     }
 
+    fn model_cost_info(&self) -> Option<LanguageModelCostInfo> {
+        LanguageModelCostInfo::RequestCost {
+            cost_per_request: self.model.multiplier(),
+        }
+        .into()
+    }
+
     fn telemetry_id(&self) -> String {
         format!("copilot_chat/{}", self.model.id())
     }

crates/ui/src/components/chip.rs 🔗

@@ -1,5 +1,5 @@
 use crate::prelude::*;
-use gpui::{AnyElement, Hsla, IntoElement, ParentElement, Styled};
+use gpui::{AnyElement, AnyView, Hsla, IntoElement, ParentElement, Styled};
 
 /// Chips provide a container for an informative label.
 ///
@@ -16,6 +16,7 @@ pub struct Chip {
     label_color: Color,
     label_size: LabelSize,
     bg_color: Option<Hsla>,
+    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 }
 
 impl Chip {
@@ -26,6 +27,7 @@ impl Chip {
             label_color: Color::Default,
             label_size: LabelSize::XSmall,
             bg_color: None,
+            tooltip: None,
         }
     }
 
@@ -46,6 +48,11 @@ impl Chip {
         self.bg_color = Some(color);
         self
     }
+
+    pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
+        self.tooltip = Some(Box::new(tooltip));
+        self
+    }
 }
 
 impl RenderOnce for Chip {
@@ -64,11 +71,13 @@ impl RenderOnce for Chip {
             .bg(bg_color)
             .overflow_hidden()
             .child(
-                Label::new(self.label)
+                Label::new(self.label.clone())
                     .size(self.label_size)
                     .color(self.label_color)
                     .buffer_font(cx),
             )
+            .id(self.label.clone())
+            .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
     }
 }