diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 6a63239fcfbee5f97cb820d7b3e7ce0dfbc2e785..f8f9afc4c0d738c4775981ba48c0c617bfdb897b 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -393,6 +393,7 @@ pub struct AgentModelInfo { pub description: Option, pub icon: Option, pub is_latest: bool, + pub cost: Option, } impl From for AgentModelInfo { @@ -403,6 +404,7 @@ impl From 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, })), } } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index a663494a1bdeecea8d2d164fe4a210cbb0bd5534..5a318bbe3981302b235138a43466d0692bec88d3 100644 --- a/crates/agent/src/agent.rs +++ b/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, }] )]) ); diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 6ac2c2ce0657365e461422d32233ee6f75589dba..43a39e61088219605d2ee7ab65e610b43137576c 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/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::>(), ) @@ -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"]); diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index e3216466bd721a5fae61899834fcfb0cfd590891..9205e21be1ab796fae50a26d31aca514756e2bc2 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/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(), ) diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs index f2ffd53a4b5d29e46b4bb657a2f537258d12aa0a..01ba6c4511854e83b97b1fc053e41e5d0e82ff1e 100644 --- a/crates/agent_ui/src/ui/model_selector_components.rs +++ b/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>, + cost_info: Option, } 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) -> 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)) diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index 86247bce793b136f189854599f5c9ef57c5fe0c5..6ac7167c94f0b85e6470b2a20bbf3a17fe190b43 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/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)] diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index e63a5537ff966ae95d75db31bf0ad3fae342dbde..f9687a0c820b6df25d24370704de05e9b594c332 100644 --- a/crates/language_model/src/language_model.rs +++ b/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 { + 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)) diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index e6b9973299d15e78955efd79282b75de48e924f0..55ca0e526243dbbcb9504ea3948b192d79a02da1 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/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::RequestCost { + cost_per_request: self.model.multiplier(), + } + .into() + } + fn telemetry_id(&self) -> String { format!("copilot_chat/{}", self.model.id()) } diff --git a/crates/ui/src/components/chip.rs b/crates/ui/src/components/chip.rs index 8d0000db949fdf77353aa9a381076567f0b320f4..f9e52c877c442a39d068b2ce8fcc4c8dcb63c3dc 100644 --- a/crates/ui/src/components/chip.rs +++ b/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, + tooltip: Option 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)) } }