diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 33588a171..725bd0fc8 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -6,7 +6,7 @@ use goose::conversation::message::{ ActionRequiredData, Message, MessageContent, SystemNotificationContent, SystemNotificationType, ToolNameParts, ToolRequest, ToolResponse, }; -use goose::providers::canonical::maybe_get_canonical_model; +use goose::providers::canonical_cost::estimate_model_cost; #[cfg(target_os = "windows")] use goose::subprocess::SubprocessExt; use goose::utils::safe_truncate; @@ -1494,8 +1494,7 @@ pub fn display_context_usage(total_tokens: usize, context_limit: usize) { } fn estimate_cost_usd(provider: &str, model: &str, usage: &Usage) -> Option { - let canonical_model = maybe_get_canonical_model(provider, model)?; - canonical_model.cost.estimate_cost(usage) + estimate_model_cost(provider, model, usage) } /// Display cost information, if price data is available. diff --git a/crates/goose/src/acp/server/providers.rs b/crates/goose/src/acp/server/providers.rs index 235aaae9d..5f697c7a8 100644 --- a/crates/goose/src/acp/server/providers.rs +++ b/crates/goose/src/acp/server/providers.rs @@ -1121,23 +1121,58 @@ impl GooseAcpAgent { ) -> Result { use goose_providers::model::ModelConfig; + let config_info = + crate::providers::canonical_cost::configured_model_info(&req.provider, &req.model); + // Config-declared prices carry the config's currency; without them the + // response reports registry rates, which are USD. + let currency = crate::providers::canonical_cost::display_currency(config_info.as_ref()); let model_info = - crate::providers::canonical::maybe_get_canonical_model(&req.provider, &req.model).map( - |canonical_model| CanonicalModelInfoDto { - provider: req.provider.clone(), - model: req.model.clone(), - context_limit: canonical_model.limit.context, - max_output_tokens: canonical_model.limit.output, - reasoning: canonical_model - .reasoning - .unwrap_or_else(|| ModelConfig::new(&req.model).is_reasoning_model()), - input_token_cost: canonical_model.cost.input, - output_token_cost: canonical_model.cost.output, - cache_read_token_cost: canonical_model.cost.cache_read, - cache_write_token_cost: canonical_model.cost.cache_write, - currency: "$".to_string(), - }, - ); + crate::providers::canonical::maybe_get_canonical_model(&req.provider, &req.model) + .map(|canonical_model| { + // Config-declared prices outrank the registry's catalog rates; + // registry cache rates survive, so tooltip math matches + // estimate_model_cost exactly. + let pricing = crate::providers::canonical_cost::resolve_pricing( + &req.provider, + &req.model, + ) + .unwrap_or_else(|| canonical_model.cost.clone()); + CanonicalModelInfoDto { + provider: req.provider.clone(), + model: req.model.clone(), + context_limit: canonical_model.limit.context, + max_output_tokens: canonical_model.limit.output, + reasoning: canonical_model + .reasoning + .unwrap_or_else(|| ModelConfig::new(&req.model).is_reasoning_model()), + input_token_cost: pricing.input, + output_token_cost: pricing.output, + cache_read_token_cost: pricing.cache_read, + cache_write_token_cost: pricing.cache_write, + currency: currency.clone(), + } + }) + .or_else(|| { + crate::providers::canonical_cost::resolve_pricing(&req.provider, &req.model) + .and_then(|pricing| { + config_info.map(|info| CanonicalModelInfoDto { + provider: req.provider.clone(), + model: req.model.clone(), + context_limit: info.context_limit, + // ModelInfo carries no max-output limit. + max_output_tokens: None, + // Configs deserialize a missing `reasoning` as false; keep + // name-based detection for accustomed reasoning models. + reasoning: info.reasoning + || ModelConfig::new(&req.model).is_reasoning_model(), + input_token_cost: pricing.input, + output_token_cost: pricing.output, + cache_read_token_cost: pricing.cache_read, + cache_write_token_cost: pricing.cache_write, + currency: currency.clone(), + }) + }) + }); Ok(CanonicalModelInfoResponse { model_info }) } diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 446526a13..5629aea8f 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -786,10 +786,9 @@ impl Agent { if let Some(cost) = usage.cost { return (Some(cost), Some(CostSource::ProviderReported)); } - match provider_name - .and_then(|pn| crate::providers::canonical::maybe_get_canonical_model(pn, &usage.model)) - .and_then(|canonical| canonical.cost.estimate_cost(&usage.usage)) - { + match provider_name.and_then(|pn| { + crate::providers::canonical_cost::estimate_model_cost(pn, &usage.model, &usage.usage) + }) { Some(cost) => (Some(cost), Some(CostSource::Estimated)), None => (None, None), } diff --git a/crates/goose/src/agents/state_machine/usage.rs b/crates/goose/src/agents/state_machine/usage.rs index a5bd5c677..3254540c1 100644 --- a/crates/goose/src/agents/state_machine/usage.rs +++ b/crates/goose/src/agents/state_machine/usage.rs @@ -32,14 +32,13 @@ pub(super) fn enrich(session: &Session, effects: &mut [GooseEffect]) { let (cost, cost_source) = if let Some(cost) = usage.cost { (Some(cost), Some(CostSource::ProviderReported)) } else { - match session - .provider_name - .as_deref() - .and_then(|provider| { - crate::providers::canonical::maybe_get_canonical_model(provider, &usage.model) - }) - .and_then(|canonical| canonical.cost.estimate_cost(&usage.usage)) - { + match session.provider_name.as_deref().and_then(|provider| { + crate::providers::canonical_cost::estimate_model_cost( + provider, + &usage.model, + &usage.usage, + ) + }) { Some(cost) => (Some(cost), Some(CostSource::Estimated)), None => (None, None), } diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 04a98f9d6..e32d9a81d 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -113,7 +113,7 @@ pub fn validate_provider_id(id: &str) -> Result<()> { } } -fn custom_provider_file_path(id: &str) -> Result { +pub(crate) fn custom_provider_file_path(id: &str) -> Result { if id.is_empty() || id .chars() @@ -260,7 +260,14 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> let model_infos: Vec = params .models .into_iter() - .map(|name| ModelInfo::new(name, 128000)) + .map(|name| { + existing_config + .models + .iter() + .find(|existing| existing.name == name) + .cloned() + .unwrap_or_else(|| ModelInfo::new(name, 128000)) + }) .collect(); let engine = ProviderEngine::from_str(¶ms.engine)?; @@ -892,4 +899,63 @@ mod tests { let result = expand_env_vars("${TEST_EXPAND_OVERRIDE}/path", &env_vars).unwrap(); assert_eq!(result, "https://from-env.com/path"); } + + #[test] + fn test_update_custom_provider_preserves_model_pricing_and_context_limits() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_root = temp_dir.path().display().to_string(); + let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(temp_root.as_str()))]); + + let custom_dir = custom_providers_dir(); + std::fs::create_dir_all(&custom_dir).unwrap(); + std::fs::write( + custom_dir.join("priced_provider.json"), + r#"{ + "name": "priced_provider", + "engine": "openai", + "display_name": "Priced", + "description": null, + "api_key_env": "", + "base_url": "https://example.invalid/v1", + "models": [ + { + "name": "kept-model", + "context_limit": 262144, + "input_token_cost": 0.000002, + "output_token_cost": 0.000006 + } + ], + "requires_auth": false + }"#, + ) + .unwrap(); + + update_custom_provider(UpdateCustomProviderParams { + id: "priced_provider".to_string(), + engine: "openai".to_string(), + display_name: "Renamed".to_string(), + api_url: "https://example.invalid/v1".to_string(), + api_key: None, + models: vec!["kept-model".to_string()], + supports_streaming: None, + headers: None, + requires_auth: false, + catalog_provider_id: None, + base_path: None, + preserves_thinking: None, + }) + .unwrap(); + + let loaded = load_provider("priced_provider").unwrap(); + let model = loaded + .config + .models + .iter() + .find(|m| m.name == "kept-model") + .expect("model survives update"); + assert_eq!(loaded.config.display_name, "Renamed"); + assert_eq!(model.context_limit, 262144); + assert_eq!(model.input_token_cost, Some(0.000002)); + assert_eq!(model.output_token_cost, Some(0.000006)); + } } diff --git a/crates/goose/src/providers/canonical_cost.rs b/crates/goose/src/providers/canonical_cost.rs new file mode 100644 index 000000000..0370aec1d --- /dev/null +++ b/crates/goose/src/providers/canonical_cost.rs @@ -0,0 +1,358 @@ +//! Cost estimation for model usage. +//! +//! Price resolution precedence (highest first): +//! 1. provider-reported costs (handled by callers before this module) +//! 2. prices the user declared in a custom provider config file — users of +//! custom endpoints (negotiated rates, gateways, self-hosting) know their +//! real prices better than a name-matched catalog entry +//! 3. the bundled canonical registry +//! 4. prices declared in bundled declarative provider definitions, which only +//! fill registry gaps — they are vendored and may lag registry syncs. A +//! registry price that is unset or zero (e.g. a name-inferred cross-provider +//! match against a free listing) counts as a gap here. +//! +//! Note: a non-zero price the registry finds by cross-provider name inference +//! still outranks bundled provider-declared prices (a host may declare a +//! higher negotiated rate than the inferred catalog row). Demoting inferred +//! matches belongs in the canonical mapping layer, not here. +//! +//! Canonical cache rates are kept whenever the winning source does not declare +//! cache pricing, so cached tokens are not overestimated at the full input +//! rate. Config files are read live (price edits take effect immediately); +//! bundled definitions are immutable and cached for the process. + +use crate::config::declarative_providers::{ + custom_provider_file_path, deserialize_provider_config, fixed_provider_configs, + DeclarativeProviderConfig, +}; +use crate::providers::base::ModelInfo; +use goose_providers::canonical::{maybe_get_canonical_model, Pricing}; +use goose_providers::conversation::token_usage::Usage; +use std::sync::OnceLock; +use tracing::warn; + +const DEFAULT_CURRENCY: &str = "$"; + +/// Estimate the USD cost of a model invocation. +pub fn estimate_model_cost(provider: &str, model: &str, usage: &Usage) -> Option { + resolve_pricing(provider, model).and_then(|pricing| pricing.estimate_cost(usage)) +} + +/// Resolve the pricing for a provider/model honoring the precedence described +/// in this module's documentation. +pub(crate) fn resolve_pricing(provider: &str, model: &str) -> Option { + let canonical = maybe_get_canonical_model(provider, model).map(|c| c.cost); + let bundled = + bundled_model_info(provider, model).and_then(|info| pricing_from_model_info(&info)); + let base = match (canonical, bundled) { + (Some(mut canonical), Some(bundled)) => { + if is_price_gap(canonical.input) { + canonical.input = bundled.input; + } + if is_price_gap(canonical.output) { + canonical.output = bundled.output; + } + Some(canonical) + } + (Some(canonical), None) => Some(canonical), + (None, bundled) => bundled, + }; + + let declared = + custom_file_model_info(provider, model).and_then(|info| pricing_from_model_info(&info)); + match (declared, base) { + (Some(declared), Some(base)) => Some(merge_pricing(declared, &base)), + (Some(declared), None) => Some(declared), + (None, base) => base, + } +} + +/// [`ModelInfo`] for a pricing-declared model — custom provider config file +/// first, then bundled definitions. Only models declaring both input and +/// output prices qualify: partial pricing cannot be estimated correctly +/// without silently pricing one direction at zero. +pub(crate) fn configured_model_info(provider: &str, model: &str) -> Option { + custom_file_model_info(provider, model).or_else(|| bundled_model_info(provider, model)) +} + +/// The currency clients render alongside config-declared prices. Clients print +/// this verbatim as a symbol, so the ISO codes configs commonly use (bundled +/// definitions declare `USD`) are mapped to their symbol; anything else is +/// shown exactly as declared. +pub(crate) fn display_currency(info: Option<&ModelInfo>) -> String { + info.and_then(|info| info.currency.as_deref()) + .map(currency_symbol) + .unwrap_or_else(|| DEFAULT_CURRENCY.to_string()) +} + +fn currency_symbol(declared: &str) -> String { + let declared = declared.trim(); + match declared.to_ascii_uppercase().as_str() { + "" | "USD" => DEFAULT_CURRENCY.to_string(), + "EUR" => "€".to_string(), + "GBP" => "£".to_string(), + "JPY" => "¥".to_string(), + _ => declared.to_string(), + } +} + +/// Merge user-declared pricing with registry/bundled pricing: declared +/// input/output prices win; canonical cache rates fill the gaps so cached +/// tokens are not overestimated at the full input rate. +fn merge_pricing(mut declared: Pricing, base: &Pricing) -> Pricing { + // A declared zero price means "free here": registry cache rates must not + // reintroduce cost for cached tokens. + if matches!(declared.input, Some(0.0)) && matches!(declared.output, Some(0.0)) { + return declared; + } + if declared.cache_read.is_none() { + declared.cache_read = base.cache_read; + } + if declared.cache_write.is_none() { + declared.cache_write = base.cache_write; + } + declared +} + +/// Convert a declarative [`ModelInfo`]'s per-token USD costs into canonical +/// [`Pricing`] (per-million-token USD). +fn pricing_from_model_info(info: &ModelInfo) -> Option { + Some(Pricing { + input: Some(info.input_token_cost.map(|c| c * 1_000_000.0)?), + output: Some(info.output_token_cost.map(|c| c * 1_000_000.0)?), + cache_read: None, + cache_write: None, + }) +} + +/// Read the custom provider config file for `provider` from disk. Reads are +/// live: price edits take effect without a restart. +fn custom_file_model_info(provider: &str, model: &str) -> Option { + let path = custom_provider_file_path(provider).ok()?; + if !path.exists() { + return None; + } + let content = match std::fs::read_to_string(&path) { + Ok(content) => content, + Err(e) => { + warn!(path = %path.display(), error = %e, "custom provider config unreadable; cost fallback disabled"); + return None; + } + }; + let config = match deserialize_provider_config(&content) { + Ok(config) => config, + Err(e) => { + warn!(provider = %provider, error = %e, "custom provider config failed to parse; cost fallback disabled"); + return None; + } + }; + full_pricing_model(&config, model) +} + +/// Bundled declarative provider definitions are embedded and immutable, so +/// they are cached for the lifetime of the process. +fn bundled_model_info(provider: &str, model: &str) -> Option { + static CONFIGS: OnceLock> = OnceLock::new(); + let configs = CONFIGS.get_or_init(|| fixed_provider_configs().unwrap_or_default()); + configs + .iter() + .find(|config| config.name == provider) + .and_then(|config| full_pricing_model(config, model)) +} + +/// A registry price that is unset or zero provides no usable rate signal +/// (e.g. a name-inferred cross-provider match against a free listing). +fn is_price_gap(price: Option) -> bool { + matches!(price, None | Some(0.0)) +} + +fn full_pricing_model(config: &DeclarativeProviderConfig, model: &str) -> Option { + config + .models + .iter() + .find(|m| m.name == model) + .filter(|info| info.input_token_cost.is_some() && info.output_token_cost.is_some()) + .cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::declarative_providers::custom_providers_dir; + + fn usage(input: Option, output: Option, cache_read: Option) -> Usage { + Usage { + input_tokens: input, + output_tokens: output, + total_tokens: None, + cache_read_input_tokens: cache_read, + cache_write_input_tokens: None, + } + } + + #[test] + fn pricing_from_model_info_converts_per_token_to_per_million_usd() { + let info = ModelInfo::with_cost("m", 262_144, 0.000002, 0.000006); + let pricing = pricing_from_model_info(&info).unwrap(); + assert!((pricing.input.unwrap() - 2.0).abs() < 1e-9); + assert!((pricing.output.unwrap() - 6.0).abs() < 1e-9); + } + + #[test] + fn pricing_from_model_info_returns_none_without_prices() { + let info = ModelInfo::new("m", 1_000); + assert!(pricing_from_model_info(&info).is_none()); + } + + #[test] + fn partial_pricing_is_rejected() { + let mut info = ModelInfo::new("m", 1_000); + info.input_token_cost = Some(0.000002); + assert!(pricing_from_model_info(&info).is_none()); + } + + #[test] + fn declared_input_output_win_and_canonical_cache_rates_fill_the_gap() { + let merged = merge_pricing( + Pricing { + input: Some(2.0), + output: Some(6.0), + cache_read: None, + cache_write: None, + }, + &Pricing { + input: Some(3.0), + output: Some(15.0), + cache_read: Some(0.3), + cache_write: Some(3.75), + }, + ); + assert_eq!(merged.input, Some(2.0)); + assert_eq!(merged.output, Some(6.0)); + assert_eq!(merged.cache_read, Some(0.3)); + assert_eq!(merged.cache_write, Some(3.75)); + } + + #[test] + fn declared_zero_price_suppresses_registry_cache_rates() { + // A model explicitly declared free in a custom config must not pick up + // catalog cache pricing from a canonical name-match. + let merged = merge_pricing( + Pricing { + input: Some(0.0), + output: Some(0.0), + cache_read: None, + cache_write: None, + }, + &Pricing { + input: Some(3.0), + output: Some(15.0), + cache_read: Some(0.3), + cache_write: Some(3.75), + }, + ); + assert_eq!(merged.cache_read, None); + assert_eq!(merged.cache_write, None); + let usage = usage(Some(1_000_000), Some(0), Some(1_000_000)); + assert_eq!(merged.estimate_cost(&usage), Some(0.0)); + } + + #[test] + fn cached_tokens_price_at_cache_rate_not_full_input_rate() { + // A canonical model proxied through a gateway with declared + // input/output prices but no cache prices. + let merged = merge_pricing( + Pricing { + input: Some(3.0), + output: Some(15.0), + cache_read: None, + cache_write: None, + }, + &Pricing { + input: Some(3.0), + output: Some(15.0), + cache_read: Some(0.3), + cache_write: None, + }, + ); + let usage = usage(Some(1_000_000), Some(0), Some(1_000_000)); + assert!((merged.estimate_cost(&usage).unwrap() - 0.3).abs() < 1e-9); + } + + #[test] + fn bundled_provider_prices_are_respected() { + let info = configured_model_info("ovhcloud", "Qwen3-32B").unwrap(); + assert!((info.input_token_cost.unwrap() - 9e-8).abs() < 1e-12); + let pricing = resolve_pricing("ovhcloud", "Qwen3-32B").unwrap(); + assert!((pricing.input.unwrap() - 0.09).abs() < 1e-9); + assert!((pricing.output.unwrap() - 0.25).abs() < 1e-9); + } + + #[test] + fn custom_file_prices_drive_estimation() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_root = temp_dir.path().display().to_string(); + let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(temp_root.as_str()))]); + + let dir = custom_providers_dir(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("priced_gateway.json"), + r#"{ + "name": "priced_gateway", + "engine": "openai", + "display_name": "Gateway", + "description": null, + "api_key_env": "", + "base_url": "https://example.invalid/v1", + "models": [ + { + "name": "claude-sonnet-4-5", + "context_limit": 200000, + "input_token_cost": 0.000001, + "output_token_cost": 0.000004 + } + ], + "requires_auth": false + }"#, + ) + .unwrap(); + + let used = usage(Some(10_000), Some(100), None); + let cost = estimate_model_cost("priced_gateway", "claude-sonnet-4-5", &used).unwrap(); + let expected = (10_000.0 * 1.0 + 100.0 * 4.0) / 1_000_000.0; + assert!((cost - expected).abs() < 1e-12); + } + + #[test] + fn declared_currency_is_rendered_as_a_symbol() { + let mut info = ModelInfo::with_cost("m", 1_000, 0.000001, 0.000004); + info.currency = Some("EUR".to_string()); + assert_eq!(display_currency(Some(&info)), "€"); + + info.currency = Some("¥".to_string()); + assert_eq!(display_currency(Some(&info)), "¥"); + } + + #[test] + fn missing_or_usd_currency_falls_back_to_the_dollar_symbol() { + let mut info = ModelInfo::new("m", 1_000); + assert_eq!(display_currency(Some(&info)), "$"); + assert_eq!(display_currency(None), "$"); + + // Bundled definitions declare the ISO code, which clients would + // otherwise print verbatim as "USD0.01". + info.currency = Some("usd".to_string()); + assert_eq!(display_currency(Some(&info)), "$"); + } + + #[test] + fn unpriced_everywhere_yields_none() { + assert!(estimate_model_cost( + "definitely-not-a-registry-provider", + "nope-xqzj-model", + &usage(Some(1), Some(1), None), + ) + .is_none()); + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 11243e5e8..75e6713b2 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -17,6 +17,7 @@ pub mod bedrock; pub mod canonical { pub use goose_providers::canonical::*; } +pub mod canonical_cost; mod catalog_util; pub mod catalog { pub use super::catalog_util::*;