fix(providers): estimate cost for Azure Foundry models via inferred catalog pricing (#11264)

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
jonathan MERCIER
2026-08-21 01:52:41 +00:00
committed by GitHub
parent 8e8b674cf0
commit 48d480f911
4 changed files with 111 additions and 5 deletions
+66 -4
View File
@@ -65,10 +65,18 @@ pub fn recommended_models_from_registry(provider: &str) -> Vec<String> {
.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<CanonicalModel> {
@@ -83,11 +91,38 @@ pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option<Canonica
if should_clear_catalog_pricing(provider) {
canonical.cost = Pricing::default();
} else if name_builder::is_meta_provider(provider) && canonical.cost.has_no_usable_rate() {
// A meta-provider model can infer to a first-party catalog entry that carries literal
// 0.0 prices (open-weights publishers such as meta-llama do). The host's own catalog
// row carries the rate it actually charges to proxy that model, so prefer it. Where
// there is no such row, report nothing: billing paid proxied inference as free is
// worse than showing no estimate at all.
canonical.cost = host_catalog_pricing(provider, model, registry).unwrap_or_default();
}
Some(canonical)
}
/// Pricing from the meta-provider's own catalog rows (`azure/*`, `databricks/*`,
/// `amazon-bedrock/*`), which price proxied inference directly rather than by inferring the
/// upstream publisher. Only the rate is taken: model identity and capabilities stay on the
/// canonical entry that `map_to_canonical_model` resolved.
fn host_catalog_pricing(
provider: &str,
model: &str,
registry: &CanonicalModelRegistry,
) -> Option<Pricing> {
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]
@@ -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<f64>) -> bool {
matches!(price, None | Some(0.0))
}
is_gap(self.input) || is_gap(self.output)
}
pub fn estimate_cost(&self, usage: &Usage) -> Option<f64> {
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));
@@ -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"
@@ -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);