use harn_builtin_macros::harn_builtin;
use serde::{Deserialize, Serialize};
use super::{
BillingUsage, LlmUsage, PricingFacts, ProviderReceiptSettlement, ProviderUsageReceipt,
};
use crate::llm::api::PromptCacheTtl;
pub use crate::llm::cost::CallCostBreakdown;
use crate::value::{VmError, VmValue};
#[derive(Clone, Debug)]
pub struct CallCostInput {
pub provider: String,
pub model: String,
pub started_at_ms: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub cache_read_tokens: i64,
pub cache_write_tokens: i64,
pub prompt_cache_ttl: Option<PromptCacheTtl>,
pub served_fast: bool,
pub billing: BillingUsage,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct CallCostSettlement {
pub provider: String,
pub model: String,
pub cost_usd: Option<f64>,
pub known_cost_usd: f64,
pub pricing: Option<Box<PricingFacts>>,
pub unpriced: Vec<CallCostUnpriced>,
pub rates: Option<CallCostRates>,
pub breakdown: Option<CallCostBreakdown>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct CallCostRates {
pub provider: String,
pub model: String,
pub input_per_mtok: f64,
pub output_per_mtok: f64,
pub cache_read_per_mtok: Option<f64>,
pub cache_write_per_mtok: Option<f64>,
pub cache_write_1h_per_mtok: Option<f64>,
pub source: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CallCostUnpriced {
PricingUnknown,
CacheReadRateUnknown,
CacheTtlUnpriced,
HostedTool { name: String },
Modality { name: String },
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("invalid call cost input: {0}")]
pub struct CallCostInputError(String);
pub fn settle_call_cost(input: &CallCostInput) -> Result<CallCostSettlement, CallCostInputError> {
validate(input)?;
let receipt = ProviderUsageReceipt {
input_tokens: Some(input.input_tokens),
reported_input_tokens: Some(input.input_tokens),
output_tokens: Some(input.output_tokens),
cache_read_tokens: input.cache_read_tokens,
cache_write_tokens: input.cache_write_tokens,
started_at_ms: Some(input.started_at_ms),
prompt_cache_ttl: input.prompt_cache_ttl,
served_fast: input.served_fast,
billing: Some(Box::new(input.billing.clone())),
..ProviderUsageReceipt::default()
};
let settled = LlmUsage::settle_provider_receipt(&input.provider, &input.model, &receipt);
project_settlement(input, settled)
}
fn project_settlement(
input: &CallCostInput,
settled: ProviderReceiptSettlement,
) -> Result<CallCostSettlement, CallCostInputError> {
let ProviderReceiptSettlement {
usage,
pricing: detail,
billing,
free_inference,
} = settled;
let token_rate = |rate| if free_inference { 0.0 } else { rate * 1000.0 };
let cache_rate = |rate: Option<f64>| {
if free_inference {
Some(0.0)
} else {
rate.map(token_rate)
}
};
let rates = detail.as_ref().map(|detail| CallCostRates {
provider: input.provider.clone(),
model: input.model.clone(),
input_per_mtok: token_rate(detail.input_per_1k),
output_per_mtok: token_rate(detail.output_per_1k),
cache_read_per_mtok: cache_rate(detail.cache_read_per_1k),
cache_write_per_mtok: cache_rate(detail.cache_write_per_1k),
cache_write_1h_per_mtok: cache_rate(detail.cache_write_1h_per_1k),
source: if free_inference {
crate::llm::cost::PricingSource::ProviderEconomics.as_str()
} else {
detail.source.as_str()
}
.into(),
});
let breakdown = detail.as_ref().map(|detail| {
let mut breakdown = if free_inference {
CallCostBreakdown::default()
} else {
crate::llm::cost::project_call_cost_breakdown(
detail,
input.input_tokens,
input.output_tokens,
input.cache_read_tokens,
input.cache_write_tokens,
input.prompt_cache_ttl,
)
};
breakdown.adjustments_usd = usage.known_cost_usd - breakdown.total();
breakdown
});
let mut unpriced = Vec::new();
if let Some(pricing) = &usage.pricing {
if !free_inference
&& input.cache_read_tokens > 0
&& detail
.as_ref()
.is_some_and(|detail| detail.cache_read_per_1k.is_none())
{
unpriced.push(CallCostUnpriced::CacheReadRateUnknown);
}
if !free_inference && pricing.cache_ttl_unpriced && input.cache_write_tokens > 0 {
unpriced.push(CallCostUnpriced::CacheTtlUnpriced);
}
}
unpriced.extend(
billing
.hosted_tool_unpriced
.iter()
.map(|name| CallCostUnpriced::HostedTool { name: name.clone() }),
);
unpriced.extend(
billing
.modality_unpriced
.iter()
.map(|name| CallCostUnpriced::Modality { name: name.clone() }),
);
if usage.cost_usd.is_none() {
unpriced.push(CallCostUnpriced::PricingUnknown);
}
if !usage.known_cost_usd.is_finite() || usage.cost_usd.is_some_and(|cost| !cost.is_finite()) {
return Err(CallCostInputError(
"settled cost exceeds the supported range".into(),
));
}
Ok(CallCostSettlement {
provider: input.provider.clone(),
model: input.model.clone(),
cost_usd: (usage.unpriced_calls == 0 && unpriced.is_empty())
.then_some(usage.cost_usd)
.flatten(),
known_cost_usd: usage.known_cost_usd,
pricing: usage.pricing,
unpriced,
rates,
breakdown,
})
}
#[cfg(test)]
#[path = "cost_tests.rs"]
mod tests;
fn validate(input: &CallCostInput) -> Result<(), CallCostInputError> {
let invalid = |message: &str| CallCostInputError(message.into());
if input.provider.trim().is_empty() || input.model.trim().is_empty() {
return Err(invalid("provider and model must be nonempty"));
}
if time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(input.started_at_ms) * 1_000_000)
.is_err()
{
return Err(invalid("started_at_ms is outside the supported date range"));
}
for (name, count) in [
("input_tokens", input.input_tokens),
("output_tokens", input.output_tokens),
("cache_read_tokens", input.cache_read_tokens),
("cache_write_tokens", input.cache_write_tokens),
] {
if count < 0 {
return Err(CallCostInputError(format!("{name} must be nonnegative")));
}
}
if input.cache_read_tokens > input.input_tokens
|| input.cache_write_tokens > input.input_tokens - input.cache_read_tokens
{
return Err(invalid(
"cache reads and writes exceed the full input count",
));
}
if !input.billing.counts_fit(
input.input_tokens,
input.output_tokens,
input.cache_read_tokens,
input.cache_write_tokens,
) {
return Err(invalid("audio counts exceed their enclosing token counts"));
}
Ok(())
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CostOptions {
provider: Option<String>,
model: String,
started_at_ms: Option<i64>,
#[serde(default)]
input_tokens: i64,
#[serde(default)]
output_tokens: i64,
#[serde(default)]
cache_read_tokens: i64,
#[serde(default)]
cache_write_tokens: i64,
prompt_cache_ttl: Option<String>,
#[serde(default)]
served_fast: bool,
billing: Option<CostBilling>,
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct CostBilling {
#[serde(default)]
hosted_tool_calls: std::collections::BTreeMap<String, u64>,
audio_input_tokens: Option<u64>,
audio_output_tokens: Option<u64>,
cached_audio_input_tokens: Option<u64>,
}
impl From<CostBilling> for BillingUsage {
fn from(value: CostBilling) -> Self {
Self {
invalid_modality_counts: false,
hosted_tool_calls: value.hosted_tool_calls,
audio_input_tokens: value.audio_input_tokens,
audio_output_tokens: value.audio_output_tokens,
cached_audio_input_tokens: value.cached_audio_input_tokens,
}
}
}
#[harn_builtin(
exposure = "runtime_internal", effects = [],
sig = "__llm_settle_call_cost(input: dict) -> dict", category = "llm.economics",
doc = "Estimate supplied usage through canonical cost settlement at an explicit request instant. Does not dispatch or record a provider call."
)]
fn llm_settle_call_cost_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
let error = |message: String| VmError::Runtime(format!("llm_settle_call_cost: {message}"));
let value = args
.first()
.ok_or_else(|| error("input is required".into()))?;
let options: CostOptions = serde_json::from_value(crate::llm::vm_value_to_json(value))
.map_err(|cause| error(cause.to_string()))?;
let (provider, model) = if let Some(provider) = options.provider {
(provider, options.model)
} else {
let route = crate::llm_config::resolve_model_info(&options.model);
(route.provider, route.id)
};
let prompt_cache_ttl = options
.prompt_cache_ttl
.map(|ttl| {
PromptCacheTtl::parse(&ttl)
.ok_or_else(|| error("prompt_cache_ttl must be 5m or 1h".into()))
})
.transpose()?;
let result = settle_call_cost(&CallCostInput {
provider,
model,
started_at_ms: options.started_at_ms.unwrap_or_else(|| {
(crate::llm::cost::settlement_now().unix_timestamp_nanos() / 1_000_000) as i64
}),
input_tokens: options.input_tokens,
output_tokens: options.output_tokens,
cache_read_tokens: options.cache_read_tokens,
cache_write_tokens: options.cache_write_tokens,
prompt_cache_ttl,
served_fast: options.served_fast,
billing: options.billing.unwrap_or_default().into(),
})
.map_err(|cause| error(cause.to_string()))?;
let json = serde_json::to_value(result).map_err(|cause| error(cause.to_string()))?;
Ok(crate::schema::json_to_vm_value(&json))
}
pub(crate) fn register_settlement_builtin(vm: &mut crate::vm::Vm) {
vm.register_builtin_def(&LLM_SETTLE_CALL_COST_BUILTIN_DEF);
}