feat(providers): custom provider cost fields drive cost tracking (config-declared pricing fallback) (#11220)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zach Nelson
2026-08-20 07:36:28 +00:00
committed by GitHub
parent b87a42dd7f
commit 69a705fc39
7 changed files with 490 additions and 33 deletions
+2 -3
View File
@@ -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<f64> {
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.
+51 -16
View File
@@ -1121,23 +1121,58 @@ impl GooseAcpAgent {
) -> Result<CanonicalModelInfoResponse, agent_client_protocol::Error> {
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 })
}
+3 -4
View File
@@ -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),
}
@@ -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),
}
@@ -113,7 +113,7 @@ pub fn validate_provider_id(id: &str) -> Result<()> {
}
}
fn custom_provider_file_path(id: &str) -> Result<PathBuf> {
pub(crate) fn custom_provider_file_path(id: &str) -> Result<PathBuf> {
if id.is_empty()
|| id
.chars()
@@ -260,7 +260,14 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
let model_infos: Vec<ModelInfo> = 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(&params.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));
}
}
@@ -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<f64> {
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<Pricing> {
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<ModelInfo> {
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<Pricing> {
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<ModelInfo> {
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<ModelInfo> {
static CONFIGS: OnceLock<Vec<DeclarativeProviderConfig>> = 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<f64>) -> bool {
matches!(price, None | Some(0.0))
}
fn full_pricing_model(config: &DeclarativeProviderConfig, model: &str) -> Option<ModelInfo> {
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<i32>, output: Option<i32>, cache_read: Option<i32>) -> 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());
}
}
+1
View File
@@ -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::*;