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

### 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>
This commit is contained in:
Sathiyaraman M 2026-02-16 23:54:59 +05:30 committed by GitHub
parent 0e7d63348b
commit 6e33d838c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 109 additions and 9 deletions

View file

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

View file

@ -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,
}]
)])
);

View file

@ -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"]);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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