diff --git a/crates/goose-provider-types/src/canonical.rs b/crates/goose-provider-types/src/canonical.rs index f444f692f..f87759740 100644 --- a/crates/goose-provider-types/src/canonical.rs +++ b/crates/goose-provider-types/src/canonical.rs @@ -65,10 +65,18 @@ pub fn recommended_models_from_registry(provider: &str) -> Vec { .collect() } -/// Catalog pricing is not valid for local inference or Azure Foundry deployments. -/// Azure billing depends on deployment region, SKU, offer, and contract. +/// Catalog pricing is not valid for local inference: models served via ollama or a +/// local runtime are actually free to run, so any catalog price would be misleading. +/// +/// Azure Foundry is different: it's a meta-provider that proxies models from third-party +/// providers (Anthropic, OpenAI, Meta, etc.). `map_to_canonical_model` already infers the +/// real underlying provider from the model name (e.g. "claude-sonnet-5" -> anthropic, +/// "gpt-5" -> openai) and resolves its public catalog price. That price is a reasonable +/// estimate of the real cost even though it's not guaranteed to match exactly, since Azure +/// billing can vary by deployment region, SKU, offer, and contract/discounts. Callers should +/// treat this as `CostSource::Estimated` rather than `CostSource::ProviderReported`. fn should_clear_catalog_pricing(provider: &str) -> bool { - matches!(provider, "ollama" | "local" | "azure_foundry") + matches!(provider, "ollama" | "local") } pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option { @@ -83,11 +91,38 @@ pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option Option { + let host = name_builder::map_provider_name(provider); + let stripped = name_builder::strip_version_suffix(model); + let cost = registry + .get(host, &stripped) + .or_else(|| registry.get(host, model)) + .or_else(|| registry.get(host, &stripped.to_ascii_lowercase()))? + .cost + .clone(); + (!cost.has_no_usable_rate()).then_some(cost) +} + #[cfg(test)] mod tests { use super::*; @@ -107,12 +142,39 @@ mod tests { } #[test] - fn azure_foundry_models_retain_limits_without_catalog_pricing() { + fn azure_foundry_models_use_inferred_provider_pricing() { let canonical = maybe_get_canonical_model("azure_foundry", "gpt-5") .expect("gpt-5 should resolve through the Azure catalog"); assert_eq!(canonical.limit.context, 400_000); + let openai = maybe_get_canonical_model("openai", "gpt-5") + .expect("gpt-5 should resolve for its first-party provider"); + assert_eq!(canonical.cost.input, openai.cost.input); + assert_eq!(canonical.cost.output, openai.cost.output); + assert!(canonical.cost.input.is_some_and(|price| price > 0.0)); + assert!(canonical.cost.output.is_some_and(|price| price > 0.0)); + } + + #[test] + fn meta_provider_zero_priced_inference_prefers_the_host_catalog_rate() { + // "llama-3.3-70b-instruct" infers to meta-llama/llama-3.3-70b-instruct, priced 0.0/0.0 + // because the weights are free to download — but Azure bills to serve them. The + // azure/llama-3.3-70b-instruct row carries the rate Azure actually charges. + let canonical = maybe_get_canonical_model("azure_foundry", "llama-3.3-70b-instruct") + .expect("llama-3.3-70b-instruct should resolve"); + assert_eq!(canonical.cost.input, Some(0.71)); + assert_eq!(canonical.cost.output, Some(0.71)); + assert!(canonical.limit.context > 0); + } + + #[test] + fn meta_provider_zero_priced_inference_reports_no_cost_without_a_host_rate() { + // Databricks bills for llama-3.3-70b-instruct but publishes no catalog row for it. + // Reporting nothing beats reporting the publisher's 0.0/0.0 as if it were free. + let canonical = maybe_get_canonical_model("databricks", "llama-3.3-70b-instruct") + .expect("llama-3.3-70b-instruct should resolve"); assert_eq!(canonical.cost.input, None); assert_eq!(canonical.cost.output, None); + assert!(canonical.limit.context > 0); } #[test] diff --git a/crates/goose-provider-types/src/canonical/model.rs b/crates/goose-provider-types/src/canonical/model.rs index ad287d68c..01fc91cfa 100644 --- a/crates/goose-provider-types/src/canonical/model.rs +++ b/crates/goose-provider-types/src/canonical/model.rs @@ -55,6 +55,17 @@ pub struct Pricing { } impl Pricing { + /// True when the entry carries no usable rate signal: `estimate_cost` needs both + /// an input and an output price, so an unset or literal-zero value in either field + /// makes the whole estimate wrong-low rather than merely incomplete. Mirrors the + /// `is_price_gap` predicate in `goose::providers::canonical_cost`. + pub fn has_no_usable_rate(&self) -> bool { + fn is_gap(price: Option) -> bool { + matches!(price, None | Some(0.0)) + } + is_gap(self.input) || is_gap(self.output) + } + pub fn estimate_cost(&self, usage: &Usage) -> Option { let input_price = self.input?; let output_price = self.output?; @@ -207,6 +218,20 @@ mod tests { .is_none()); } + #[test] + fn has_no_usable_rate_covers_absent_zero_and_asymmetric_prices() { + assert!(pricing(None, None, None, None).has_no_usable_rate()); + assert!(pricing(Some(0.0), Some(0.0), None, None).has_no_usable_rate()); + // A half-zero rate would bill one side at nothing, so it is not usable either. + assert!(pricing(Some(0.0), Some(5.0), None, None).has_no_usable_rate()); + assert!(pricing(Some(5.0), Some(0.0), None, None).has_no_usable_rate()); + assert!(pricing(None, Some(5.0), None, None).has_no_usable_rate()); + + assert!(!pricing(Some(1.25), Some(10.0), None, None).has_no_usable_rate()); + // Zero cache rates are legitimate: free cache reads on a paid model. + assert!(!pricing(Some(1.25), Some(10.0), Some(0.0), Some(0.0)).has_no_usable_rate()); + } + #[test] fn estimate_cost_clamps_cache_tokens_exceeding_input() { let pricing = pricing(Some(5.0), Some(25.0), Some(0.5), Some(6.25)); diff --git a/crates/goose-provider-types/src/canonical/name_builder.rs b/crates/goose-provider-types/src/canonical/name_builder.rs index a47c204d4..61ec23c45 100644 --- a/crates/goose-provider-types/src/canonical/name_builder.rs +++ b/crates/goose-provider-types/src/canonical/name_builder.rs @@ -34,7 +34,7 @@ pub fn canonical_name(provider: &str, model: &str) -> String { format!("{}/{}", provider, model_base) } -fn is_meta_provider(provider: &str) -> bool { +pub(crate) fn is_meta_provider(provider: &str) -> bool { matches!( provider, "databricks" | "databricks_v2" | "tetrate" | "bedrock" | "azure" | "azure_foundry" diff --git a/crates/goose/src/providers/canonical_cost.rs b/crates/goose/src/providers/canonical_cost.rs index 0370aec1d..fccfbdf48 100644 --- a/crates/goose/src/providers/canonical_cost.rs +++ b/crates/goose/src/providers/canonical_cost.rs @@ -190,6 +190,25 @@ mod tests { } } + /// Both agent loops price a chunk through `estimate_model_cost` and tag the result + /// `CostSource::Estimated` when it returns `Some` — `reply_parts::resolve_chunk_cost` for + /// the legacy path, `state_machine::usage::enrich` for the new one. Pinning the shared + /// helper covers the behaviour both paths inherit. + #[test] + fn azure_foundry_estimates_from_the_azure_catalog_rate() { + let used = usage(Some(1_000_000), Some(1_000_000), None); + + let gpt5 = estimate_model_cost("azure_foundry", "gpt-5", &used) + .expect("gpt-5 prices through the Azure catalog"); + assert!(gpt5 > 0.0); + + // Priced from azure/llama-3.3-70b-instruct ($0.71/M in and out), not from the + // meta-llama publisher row that lists the open weights at 0.0/0.0. + let llama = estimate_model_cost("azure_foundry", "llama-3.3-70b-instruct", &used) + .expect("llama-3.3-70b-instruct prices through the Azure catalog"); + assert!((llama - 1.42).abs() < 1e-9, "got {llama}"); + } + #[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);