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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user