## Stack Part 3 of the split. #64047 and #64194 are merged. This PR targets `main`, which has been merged into the branch without rewriting history; the already-merged output-limit and counting changes are preserved. ## Summary Expose Copilot's actual prompt, combined-context, and output limits through the shared model interface. Preserve `max_token_count()` as the existing context capacity; add `max_input_tokens()` for independent prompt limits, defaulting to context capacity for other providers. Existing total-display and telemetry callers keep using `max_token_count()` without fallback expressions. The values come from the provider's model feed, not hard-coded model tables. Missing prompt metadata falls back to context capacity; missing output metadata remains unknown. Use the independent prompt limit when deciding when to auto-compact, while retaining the combined window for total usage display and telemetry. For example, a 90k prompt limit within a 200k context window is not treated as 200k input and does not lose a second output reservation. The existing split-display ring and tooltip use the same usable input capacity. This PR does not enable split display for Copilot, so it does not introduce a visible Copilot input-limit display. No counting endpoints, consent changes, or Delta changes are included. ## Validation - After the review follow-up, all 47 selected Copilot and agent compaction tests passed. Local testing used the existing `gpui_platform/runtime_shaders` feature because the Apple Metal compiler is unavailable. - Scoped script/clippy passed for copilot_chat, agent, and agent_ui. - Formatting and whitespace checks passed. - Fixtures cover unequal prompt/context/output limits, absent/zero metadata, and existing compaction workflows. - Reviewed the complete diff against current main: only the input-limit API, Copilot metadata, its compaction/display consumers, and tests remain. The live tooltip workflow was not exercised. - No live provider calls were made. Downstream Delta integration must use `max_input_tokens()` for input admission when updating its Zed dependency; that pin update remains separate. ## Review follow-up - Added a real Thread regression with 200k context, 90k input, and 16,384 output: compaction changes from inactive at 80,999 tokens to active at 81,000; total displayed capacity remains 200k. - Changed the split input ring to use the same capacity as its tooltip. This does not enable split display for Copilot. - All 47 selected tests passed locally, along with scoped Clippy, formatting, and whitespace checks. Temporarily restoring the incorrect context-limit call made the new regression fail; that mutation was removed. Release Notes: - Fixed auto-compaction thresholds for GitHub Copilot models with a prompt limit below their context window.
589 lines
19 KiB
Rust
589 lines
19 KiB
Rust
mod api_key;
|
||
mod registry;
|
||
mod request;
|
||
|
||
#[cfg(any(test, feature = "test-support"))]
|
||
pub mod fake_provider;
|
||
|
||
pub use language_model_core::*;
|
||
|
||
use anyhow::Result;
|
||
use futures::{FutureExt, SinkExt};
|
||
use futures::{StreamExt, channel::mpsc, future::BoxFuture, stream::BoxStream};
|
||
use gpui::{AnyView, App, AsyncApp, BackgroundExecutor, Task, Window};
|
||
use icons::IconName;
|
||
use parking_lot::Mutex;
|
||
use std::sync::Arc;
|
||
|
||
pub type CreateProviderSettingsView = Arc<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>;
|
||
|
||
pub use crate::api_key::{ApiKey, ApiKeyState};
|
||
pub use crate::registry::*;
|
||
pub use crate::request::{LanguageModelImageExt, gpui_size_to_image_size, image_size_to_gpui};
|
||
pub use env_var::{EnvVar, env_var};
|
||
|
||
const BACKGROUND_STREAM_BUFFER_SIZE: usize = 32;
|
||
|
||
pub fn init(cx: &mut App) {
|
||
registry::init(cx);
|
||
}
|
||
|
||
pub fn stream_in_background<Output>(
|
||
mut events: BoxStream<'static, Output>,
|
||
executor: BackgroundExecutor,
|
||
) -> BoxStream<'static, Output>
|
||
where
|
||
Output: Send + 'static,
|
||
{
|
||
let (mut sender, receiver) = mpsc::channel(BACKGROUND_STREAM_BUFFER_SIZE);
|
||
let task = executor.spawn(async move {
|
||
while let Some(event) = events.next().await {
|
||
if sender.send(event).await.is_err() {
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
|
||
receiver
|
||
.map(move |event| {
|
||
let _task = &task;
|
||
event
|
||
})
|
||
.boxed()
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct DisabledReason(pub SharedString);
|
||
|
||
impl DisabledReason {
|
||
pub fn new(reason: impl Into<SharedString>) -> Self {
|
||
Self(reason.into())
|
||
}
|
||
}
|
||
|
||
/// The outcome of an explicit [`LanguageModel::compact`] request.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct CompactionResult {
|
||
/// The replacement context to persist and use in subsequent requests.
|
||
pub context: CompactedContext,
|
||
/// Token usage of the compaction request itself, as reported by the
|
||
/// provider.
|
||
pub usage: TokenUsage,
|
||
}
|
||
|
||
pub struct LanguageModelTextStream {
|
||
pub message_id: Option<String>,
|
||
pub stream: BoxStream<'static, Result<String, LanguageModelCompletionError>>,
|
||
// Has complete token usage after the stream has finished
|
||
pub last_token_usage: Arc<Mutex<TokenUsage>>,
|
||
}
|
||
|
||
impl Default for LanguageModelTextStream {
|
||
fn default() -> Self {
|
||
Self {
|
||
message_id: None,
|
||
stream: Box::pin(futures::stream::empty()),
|
||
last_token_usage: Arc::new(Mutex::new(TokenUsage::default())),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub trait LanguageModel: Send + Sync {
|
||
fn id(&self) -> LanguageModelId;
|
||
fn name(&self) -> LanguageModelName;
|
||
fn provider_id(&self) -> LanguageModelProviderId;
|
||
fn provider_name(&self) -> LanguageModelProviderName;
|
||
fn upstream_provider_id(&self) -> LanguageModelProviderId {
|
||
self.provider_id()
|
||
}
|
||
fn upstream_provider_name(&self) -> LanguageModelProviderName {
|
||
self.provider_name()
|
||
}
|
||
|
||
/// Returns whether this model is the "latest", so we can highlight it in the UI.
|
||
fn is_latest(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Whether the model is currently disabled and, if so, why this is the case.
|
||
fn is_disabled(&self) -> Option<DisabledReason> {
|
||
None
|
||
}
|
||
|
||
/// Whether requests to this model require the user to consent to the
|
||
/// upstream provider retaining inference logs (i.e. the model cannot be
|
||
/// offered with Zero Data Retention).
|
||
fn requires_data_retention(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// When this model refuses a request, the model ID to fall back to (same provider).
|
||
fn refusal_fallback_model_id(&self) -> Option<&'static str> {
|
||
None
|
||
}
|
||
|
||
fn telemetry_id(&self) -> String;
|
||
|
||
fn api_key(&self, _cx: &App) -> Option<String> {
|
||
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
|
||
}
|
||
|
||
/// Whether thinking can be turned off entirely for this model. Some
|
||
/// models (e.g. Claude Fable 5) always think and cannot honor an "off"
|
||
/// request. Only meaningful when `supports_thinking` returns `true`.
|
||
fn supports_disabling_thinking(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
fn supports_fast_mode(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Returns the list of supported effort levels that can be used when thinking.
|
||
fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
|
||
Vec::new()
|
||
}
|
||
|
||
/// Returns the default effort level to use when thinking.
|
||
fn default_effort_level(&self) -> Option<LanguageModelEffortLevel> {
|
||
self.supported_effort_levels()
|
||
.into_iter()
|
||
.find(|effort_level| effort_level.is_default)
|
||
}
|
||
|
||
/// Whether this model supports provider-side automatic context
|
||
/// compaction (requested via `LanguageModelRequest::compact_at_tokens`).
|
||
fn supports_server_side_compaction(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
fn supports_explicit_compaction(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Whether native compaction honors `LanguageModelRequest::max_output_tokens`.
|
||
fn supports_explicit_compaction_output_limit(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// The provider-enforced input size required for explicit compaction.
|
||
fn minimum_explicit_compaction_input_tokens(&self) -> Option<u64> {
|
||
None
|
||
}
|
||
|
||
fn compact(
|
||
&self,
|
||
_request: LanguageModelRequest,
|
||
_cx: &AsyncApp,
|
||
) -> BoxFuture<'static, Result<CompactionResult, LanguageModelCompletionError>> {
|
||
let provider = self.provider_name();
|
||
async move {
|
||
Err(LanguageModelCompletionError::Other(anyhow::anyhow!(
|
||
"{provider} does not support explicit compaction"
|
||
)))
|
||
}
|
||
.boxed()
|
||
}
|
||
|
||
/// Whether this model supports images
|
||
fn supports_images(&self) -> bool;
|
||
|
||
/// Whether this model supports tools.
|
||
fn supports_tools(&self) -> bool;
|
||
|
||
/// Whether this model supports choosing which tool to use.
|
||
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool;
|
||
|
||
/// Returns whether this model or provider supports streaming tool calls;
|
||
fn supports_streaming_tools(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Returns whether this model/provider reports accurate split input/output token counts.
|
||
/// When true, the UI may show separate input/output token indicators.
|
||
fn supports_split_token_display(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Returns the model's context-window capacity.
|
||
fn max_token_count(&self) -> u64;
|
||
|
||
/// Returns the input ceiling before reserving output from any shared window.
|
||
///
|
||
/// Models with a separate prompt limit override the context-window default.
|
||
fn max_input_tokens(&self) -> u64 {
|
||
self.max_token_count()
|
||
}
|
||
|
||
/// Counts request input without generating output, when supported by the provider.
|
||
///
|
||
/// Counts may be estimates and differ from subsequent measured usage. Callers
|
||
/// choose the content to count; this does not infer which input is already
|
||
/// covered by a previous usage report. Unsupported providers return `None`.
|
||
fn count_input_tokens(
|
||
&self,
|
||
_request: LanguageModelRequest,
|
||
_cx: &AsyncApp,
|
||
) -> BoxFuture<'static, Result<Option<u64>, LanguageModelCompletionError>> {
|
||
async { Ok(None) }.boxed()
|
||
}
|
||
|
||
/// Returns the combined input and output ceiling, if one applies.
|
||
///
|
||
/// The conservative default shares the context window with output. `None`
|
||
/// means generation does not consume that window, not merely that the API
|
||
/// validates input separately or stops generation at the window boundary.
|
||
fn max_total_tokens(&self) -> Option<u64> {
|
||
Some(self.max_token_count())
|
||
}
|
||
|
||
fn max_output_tokens(&self) -> Option<u64> {
|
||
None
|
||
}
|
||
|
||
fn stream_completion(
|
||
&self,
|
||
request: LanguageModelRequest,
|
||
cx: &AsyncApp,
|
||
) -> BoxFuture<
|
||
'static,
|
||
Result<
|
||
BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
|
||
LanguageModelCompletionError,
|
||
>,
|
||
>;
|
||
|
||
fn stream_completion_text(
|
||
&self,
|
||
request: LanguageModelRequest,
|
||
cx: &AsyncApp,
|
||
) -> BoxFuture<'static, Result<LanguageModelTextStream, LanguageModelCompletionError>> {
|
||
let future = self.stream_completion(request, cx);
|
||
|
||
async move {
|
||
let events = future.await?;
|
||
let mut events = events.fuse();
|
||
let mut message_id = None;
|
||
let mut first_item_text = None;
|
||
let last_token_usage = Arc::new(Mutex::new(TokenUsage::default()));
|
||
|
||
if let Some(first_event) = events.next().await {
|
||
match first_event {
|
||
Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => {
|
||
message_id = Some(id);
|
||
}
|
||
Ok(LanguageModelCompletionEvent::Text(text)) => {
|
||
first_item_text = Some(text);
|
||
}
|
||
_ => (),
|
||
}
|
||
}
|
||
|
||
let stream = futures::stream::iter(first_item_text.map(Ok))
|
||
.chain(events.filter_map({
|
||
let last_token_usage = last_token_usage.clone();
|
||
move |result| {
|
||
let last_token_usage = last_token_usage.clone();
|
||
async move {
|
||
match result {
|
||
Ok(LanguageModelCompletionEvent::Queued { .. }) => None,
|
||
Ok(LanguageModelCompletionEvent::Started) => None,
|
||
Ok(LanguageModelCompletionEvent::StartMessage { .. }) => None,
|
||
Ok(LanguageModelCompletionEvent::Text(text)) => Some(Ok(text)),
|
||
Ok(LanguageModelCompletionEvent::Thinking { .. }) => None,
|
||
Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) => None,
|
||
Ok(LanguageModelCompletionEvent::ReasoningDetails(_)) => None,
|
||
Ok(LanguageModelCompletionEvent::Stop(_)) => None,
|
||
Ok(LanguageModelCompletionEvent::ToolUse(_)) => None,
|
||
Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
|
||
..
|
||
}) => None,
|
||
Ok(LanguageModelCompletionEvent::Compaction(_)) => None,
|
||
Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => {
|
||
*last_token_usage.lock() = token_usage;
|
||
None
|
||
}
|
||
Err(err) => Some(Err(err)),
|
||
}
|
||
}
|
||
}
|
||
}))
|
||
.boxed();
|
||
|
||
Ok(LanguageModelTextStream {
|
||
message_id,
|
||
stream,
|
||
last_token_usage,
|
||
})
|
||
}
|
||
.boxed()
|
||
}
|
||
|
||
fn stream_completion_tool(
|
||
&self,
|
||
request: LanguageModelRequest,
|
||
cx: &AsyncApp,
|
||
) -> BoxFuture<'static, Result<LanguageModelToolUse, LanguageModelCompletionError>> {
|
||
let future = self.stream_completion(request, cx);
|
||
|
||
async move {
|
||
let events = future.await?;
|
||
let mut events = events.fuse();
|
||
|
||
// Iterate through events until we find a complete ToolUse
|
||
while let Some(event) = events.next().await {
|
||
match event {
|
||
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
|
||
if tool_use.is_input_complete =>
|
||
{
|
||
return Ok(tool_use);
|
||
}
|
||
Err(err) => {
|
||
return Err(err);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Stream ended without a complete tool use
|
||
Err(LanguageModelCompletionError::Other(anyhow::anyhow!(
|
||
"Stream ended without receiving a complete tool use"
|
||
)))
|
||
}
|
||
.boxed()
|
||
}
|
||
|
||
#[cfg(any(test, feature = "test-support"))]
|
||
fn as_fake(&self) -> &fake_provider::FakeLanguageModel {
|
||
unimplemented!()
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Debug for dyn LanguageModel {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("<dyn LanguageModel>")
|
||
.field("id", &self.id())
|
||
.field("name", &self.name())
|
||
.field("provider_id", &self.provider_id())
|
||
.field("provider_name", &self.provider_name())
|
||
.field("upstream_provider_name", &self.upstream_provider_name())
|
||
.field("upstream_provider_id", &self.upstream_provider_id())
|
||
.field("upstream_provider_id", &self.upstream_provider_id())
|
||
.field("supports_streaming_tools", &self.supports_streaming_tools())
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
/// Either a built-in icon name or a path to an external SVG.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum IconOrSvg {
|
||
/// A built-in icon from Zed's icon set.
|
||
Icon(IconName),
|
||
/// Path to a custom SVG icon file.
|
||
Svg(SharedString),
|
||
}
|
||
|
||
impl Default for IconOrSvg {
|
||
fn default() -> Self {
|
||
Self::Icon(IconName::ZedAssistant)
|
||
}
|
||
}
|
||
|
||
pub trait LanguageModelProvider: 'static {
|
||
fn id(&self) -> LanguageModelProviderId;
|
||
fn name(&self) -> LanguageModelProviderName;
|
||
fn icon(&self) -> IconOrSvg {
|
||
IconOrSvg::default()
|
||
}
|
||
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
|
||
fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
|
||
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>>;
|
||
fn recommended_models(&self, _cx: &App) -> Vec<Arc<dyn LanguageModel>> {
|
||
Vec::new()
|
||
}
|
||
fn is_authenticated(&self, cx: &App) -> bool;
|
||
fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>>;
|
||
fn settings_view(&self, cx: &mut App) -> Option<ProviderSettingsView>;
|
||
|
||
fn set_api_key(&self, _key: Option<String>, _cx: &mut App) -> Task<Result<()>> {
|
||
Task::ready(Ok(()))
|
||
}
|
||
|
||
/// Copy shown when this provider rejects a request as unauthenticated
|
||
/// (HTTP 401). The default assumes API-key authentication; providers using
|
||
/// other mechanisms (account or subscription based auth) should override
|
||
/// this so users aren't told to check an API key they don't have.
|
||
fn authentication_error_message(&self) -> SharedString {
|
||
format!(
|
||
"The API key for {} is invalid or has expired. \
|
||
Update your key in Settings > AI > LLM Providers to continue.",
|
||
self.name().0
|
||
)
|
||
.into()
|
||
}
|
||
|
||
/// Copy shown when a request fails because no credentials are configured
|
||
/// for this provider. The default assumes API-key authentication;
|
||
/// providers using other mechanisms (account or subscription based auth)
|
||
/// should override this.
|
||
fn missing_credentials_error_message(&self) -> SharedString {
|
||
format!(
|
||
"No API key is configured for {}. \
|
||
Add your key in Settings > AI > LLM Providers to continue.",
|
||
self.name().0
|
||
)
|
||
.into()
|
||
}
|
||
|
||
/// Copy shown the first time a user enables fast mode for a model from
|
||
/// this provider. Returning `None` skips the confirmation prompt and lets
|
||
/// the toggle apply silently.
|
||
fn fast_mode_confirmation(&self, _cx: &App) -> Option<FastModeConfirmation> {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// A provider's settings UI, modeled as mutually exclusive presentation modes.
|
||
#[derive(Clone)]
|
||
pub enum ProviderSettingsView {
|
||
ApiKey(ApiKeyConfiguration),
|
||
Inline(InlineProviderSettings),
|
||
SubPage(SubPageProviderSettings),
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct InlineProviderSettings {
|
||
pub title: Option<SharedString>,
|
||
pub description: Option<InlineDescription>,
|
||
pub create_view: CreateProviderSettingsView,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct SubPageProviderSettings {
|
||
pub description: Option<InlineDescription>,
|
||
pub create_view: CreateProviderSettingsView,
|
||
}
|
||
|
||
impl SubPageProviderSettings {
|
||
pub fn new(create_view: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
|
||
Self {
|
||
description: None,
|
||
create_view: Arc::new(create_view),
|
||
}
|
||
}
|
||
|
||
pub fn description(mut self, description: InlineDescription) -> Self {
|
||
self.description = Some(description);
|
||
self
|
||
}
|
||
}
|
||
|
||
impl ApiKeyConfiguration {
|
||
pub fn new(
|
||
has_key: bool,
|
||
is_from_env_var: bool,
|
||
env_var_name: SharedString,
|
||
api_key_url: SharedString,
|
||
) -> Self {
|
||
Self {
|
||
has_key,
|
||
is_from_env_var,
|
||
env_var_name,
|
||
api_key_url,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A live snapshot of a single-API-key provider's credential state, used by the
|
||
/// settings UI to render the provider's "API Key" section.
|
||
#[derive(Clone)]
|
||
pub struct ApiKeyConfiguration {
|
||
pub has_key: bool,
|
||
pub is_from_env_var: bool,
|
||
pub env_var_name: SharedString,
|
||
pub api_key_url: SharedString,
|
||
}
|
||
|
||
/// The subtitle rendered beneath a provider's name when its configuration is
|
||
/// shown inline.
|
||
#[derive(Clone)]
|
||
pub enum InlineDescription {
|
||
/// A clickable "Where to find key" link pointing at the given URL, for
|
||
/// API-key based providers.
|
||
ApiKeyUrl(SharedString),
|
||
/// Plain descriptive text, e.g. explaining a sign-in based provider.
|
||
Text(SharedString),
|
||
}
|
||
|
||
/// Provider-specific copy shown the first time a user enables fast mode.
|
||
#[derive(Debug, Clone)]
|
||
pub struct FastModeConfirmation {
|
||
pub title: SharedString,
|
||
pub message: SharedString,
|
||
}
|
||
|
||
pub trait LanguageModelProviderState: 'static {
|
||
type ObservableEntity;
|
||
|
||
fn observable_entity(&self) -> Option<gpui::Entity<Self::ObservableEntity>>;
|
||
|
||
fn subscribe<T: 'static>(
|
||
&self,
|
||
cx: &mut gpui::Context<T>,
|
||
callback: impl Fn(&mut T, &mut gpui::Context<T>) + 'static,
|
||
) -> Option<gpui::Subscription> {
|
||
let entity = self.observable_entity()?;
|
||
Some(cx.observe(&entity, move |this, _, cx| {
|
||
callback(this, cx);
|
||
}))
|
||
}
|
||
}
|
||
|
||
#[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))
|
||
}
|
||
}
|
||
}
|