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:
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(¶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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user