use crate::types::{BudgetLimitKind, TokenUsage, UsageLimits};
use agent_sdk_foundation::audit::AuditProvenance;
use agent_sdk_foundation::llm::Usage;
#[must_use]
pub(super) fn estimate_cost_usd(provenance: &AuditProvenance, usage: &TokenUsage) -> Option<f64> {
let caps = lookup_capabilities(&provenance.provider, &provenance.model)?;
caps.estimate_cost_usd(&Usage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_input_tokens: usage.cached_input_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
})
}
fn lookup_capabilities(
provider: &str,
model: &str,
) -> Option<&'static crate::model_capabilities::ModelCapabilities> {
crate::model_capabilities::get_model_capabilities(provider, model).or_else(|| match provider {
"openai-responses" | "openai-codex" => {
crate::model_capabilities::get_model_capabilities("openai", model)
}
"vertex" if model.starts_with("claude-") => {
crate::model_capabilities::get_model_capabilities("anthropic", model)
}
"vertex" => crate::model_capabilities::get_model_capabilities("gemini", model),
"cloudflare-ai-gateway" | "record-replay" => ["anthropic", "openai", "gemini"]
.into_iter()
.find_map(|backend| crate::model_capabilities::get_model_capabilities(backend, model)),
_ => None,
})
}
#[must_use]
pub(super) const fn usage_is_zero(usage: &TokenUsage) -> bool {
usage.input_tokens == 0
&& usage.output_tokens == 0
&& usage.cached_input_tokens == 0
&& usage.cache_creation_input_tokens == 0
}
pub(super) fn accumulate_cost(
state: &mut crate::types::AgentState,
provenance: &AuditProvenance,
pre_delta_total: &TokenUsage,
delta: &TokenUsage,
) {
if state.accumulated_cost_usd.is_none() && !usage_is_zero(pre_delta_total) {
state.accumulated_cost_usd = estimate_cost_usd(provenance, pre_delta_total);
}
if let Some(delta_cost) = estimate_cost_usd(provenance, delta) {
state.accumulated_cost_usd = Some(state.accumulated_cost_usd.unwrap_or(0.0) + delta_cost);
}
}
#[must_use]
pub(super) fn run_cost_usd(
accumulated: Option<f64>,
provenance: &AuditProvenance,
usage: &TokenUsage,
) -> Option<f64> {
accumulated.or_else(|| estimate_cost_usd(provenance, usage))
}
#[must_use]
pub(super) fn status(
usage_limits: Option<&UsageLimits>,
provenance: &AuditProvenance,
usage: &TokenUsage,
accumulated_cost_usd: Option<f64>,
) -> Option<(BudgetLimitKind, Option<f64>)> {
let limits = usage_limits?;
let cost = run_cost_usd(accumulated_cost_usd, provenance, usage);
let limit = check_budget(limits, usage, cost)?;
Some((limit, cost))
}
#[must_use]
fn total_tokens(usage: &TokenUsage) -> u64 {
u64::from(usage.input_tokens).saturating_add(u64::from(usage.output_tokens))
}
#[must_use]
pub(super) fn check_budget(
limits: &UsageLimits,
usage: &TokenUsage,
cost: Option<f64>,
) -> Option<BudgetLimitKind> {
if let Some(max_total_tokens) = limits.max_total_tokens
&& total_tokens(usage) > max_total_tokens
{
return Some(BudgetLimitKind::TotalTokens);
}
if let (Some(max_cost_usd), Some(cost)) = (limits.max_cost_usd, cost)
&& cost > max_cost_usd
{
return Some(BudgetLimitKind::CostUsd);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn estimate_cost_for_gpt_4o_matches_pricing() -> anyhow::Result<()> {
use anyhow::Context;
let provenance = AuditProvenance::new("openai", "gpt-4o");
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let cost = estimate_cost_usd(&provenance, &usage).context("gpt-4o has pricing")?;
assert!((cost - 0.0075).abs() < 1e-9, "unexpected cost: {cost}");
Ok(())
}
#[test]
fn estimate_cost_resolves_openai_responses_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical = estimate_cost_usd(&AuditProvenance::new("openai", "gpt-4o"), &usage)
.context("gpt-4o has pricing")?;
let aliased =
estimate_cost_usd(&AuditProvenance::new("openai-responses", "gpt-4o"), &usage)
.context("openai-responses must resolve to the openai catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_resolves_openai_codex_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical = estimate_cost_usd(&AuditProvenance::new("openai", "gpt-4o"), &usage)
.context("gpt-4o has pricing")?;
let aliased = estimate_cost_usd(&AuditProvenance::new("openai-codex", "gpt-4o"), &usage)
.context("openai-codex must resolve to the openai catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_resolves_vertex_claude_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical =
estimate_cost_usd(&AuditProvenance::new("anthropic", "claude-fable-5"), &usage)
.context("claude-fable-5 has pricing")?;
let aliased = estimate_cost_usd(&AuditProvenance::new("vertex", "claude-fable-5"), &usage)
.context("vertex claude-* must resolve to the anthropic catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_resolves_vertex_gemini_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical =
estimate_cost_usd(&AuditProvenance::new("gemini", "gemini-3.1-pro"), &usage)
.context("gemini-3.1-pro has pricing")?;
let aliased = estimate_cost_usd(&AuditProvenance::new("vertex", "gemini-3.1-pro"), &usage)
.context("vertex non-claude must resolve to the gemini catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_resolves_cloudflare_gateway_claude_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical =
estimate_cost_usd(&AuditProvenance::new("anthropic", "claude-fable-5"), &usage)
.context("claude-fable-5 has pricing")?;
let aliased = estimate_cost_usd(
&AuditProvenance::new("cloudflare-ai-gateway", "claude-fable-5"),
&usage,
)
.context("gateway claude-* must resolve to the anthropic catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_resolves_cloudflare_gateway_openai_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical = estimate_cost_usd(&AuditProvenance::new("openai", "gpt-4o"), &usage)
.context("gpt-4o has pricing")?;
let aliased = estimate_cost_usd(
&AuditProvenance::new("cloudflare-ai-gateway", "gpt-4o"),
&usage,
)
.context("gateway gpt-* must resolve to the openai catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_resolves_record_replay_anthropic_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical =
estimate_cost_usd(&AuditProvenance::new("anthropic", "claude-fable-5"), &usage)
.context("claude-fable-5 has pricing")?;
let aliased = estimate_cost_usd(
&AuditProvenance::new("record-replay", "claude-fable-5"),
&usage,
)
.context("record-replay claude-* must resolve to the anthropic catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_resolves_record_replay_openai_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical = estimate_cost_usd(&AuditProvenance::new("openai", "gpt-4o"), &usage)
.context("gpt-4o has pricing")?;
let aliased = estimate_cost_usd(&AuditProvenance::new("record-replay", "gpt-4o"), &usage)
.context("record-replay gpt-* must resolve to the openai catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_resolves_record_replay_gemini_alias() -> anyhow::Result<()> {
use anyhow::Context;
let usage = TokenUsage {
input_tokens: 2_000,
output_tokens: 1_000,
..Default::default()
};
let canonical =
estimate_cost_usd(&AuditProvenance::new("gemini", "gemini-3.1-pro"), &usage)
.context("gemini-3.1-pro has pricing")?;
let aliased = estimate_cost_usd(
&AuditProvenance::new("record-replay", "gemini-3.1-pro"),
&usage,
)
.context("record-replay gemini-* must resolve to the gemini catalog entry")?;
assert!((aliased - canonical).abs() < 1e-12);
Ok(())
}
#[test]
fn estimate_cost_is_none_for_unknown_model() {
let provenance = AuditProvenance::new("mock", "mock-model");
let usage = TokenUsage {
input_tokens: 100,
output_tokens: 100,
..Default::default()
};
assert!(estimate_cost_usd(&provenance, &usage).is_none());
}
#[test]
fn token_budget_trips_when_exceeded() {
let limits = UsageLimits {
max_total_tokens: Some(100),
..Default::default()
};
let under = TokenUsage {
input_tokens: 50,
output_tokens: 50,
..Default::default()
};
assert!(check_budget(&limits, &under, None).is_none());
let over = TokenUsage {
input_tokens: 60,
output_tokens: 60,
..Default::default()
};
assert_eq!(
check_budget(&limits, &over, None),
Some(BudgetLimitKind::TotalTokens)
);
}
#[test]
fn cost_budget_trips_when_exceeded() {
let limits = UsageLimits {
max_cost_usd: Some(1.0),
..Default::default()
};
let usage = TokenUsage::default();
assert!(check_budget(&limits, &usage, Some(0.5)).is_none());
assert_eq!(
check_budget(&limits, &usage, Some(1.5)),
Some(BudgetLimitKind::CostUsd)
);
assert!(check_budget(&limits, &usage, None).is_none());
}
}