Skip to main content

harn_vm/llm/
cost.rs

1use crate::value::VmDictExt;
2use rust_decimal::Decimal;
3use std::cell::RefCell;
4use std::collections::BTreeMap;
5use std::str::FromStr;
6
7use crate::value::{categorized_error, ErrorCategory, VmError, VmValue};
8use crate::vm::{Vm, VmBuiltinArity, VmBuiltinMetadata};
9
10thread_local! {
11    static LLM_BUDGET: RefCell<Option<f64>> = const { RefCell::new(None) };
12    static LLM_ACCUMULATED_COST: RefCell<f64> = const { RefCell::new(0.0) };
13    static LLM_TOKEN_BUDGET: RefCell<Option<u64>> = const { RefCell::new(None) };
14    static LLM_ACCUMULATED_TOKENS: RefCell<u64> = const { RefCell::new(0) };
15}
16
17/// Reset thread-local cost state. Call between test runs to avoid leaking.
18pub(crate) fn reset_cost_state() {
19    LLM_BUDGET.with(|b| *b.borrow_mut() = None);
20    LLM_ACCUMULATED_COST.with(|a| *a.borrow_mut() = 0.0);
21    LLM_TOKEN_BUDGET.with(|b| *b.borrow_mut() = None);
22    LLM_ACCUMULATED_TOKENS.with(|a| *a.borrow_mut() = 0);
23}
24
25pub fn peek_total_cost() -> f64 {
26    LLM_ACCUMULATED_COST.with(|acc| *acc.borrow())
27}
28
29/// RAII guard installed by [`install_llm_cost_budget`]. Restores the
30/// prior ceiling (and accumulated total) on drop so nested dispatches
31/// (a handler that re-enters the dispatcher) cannot leak a tighter
32/// budget into the outer scope, or a wider one back into a finished
33/// inner scope.
34#[must_use = "dropping the guard immediately restores the prior LLM cost budget"]
35pub struct LlmBudgetGuard {
36    previous_budget: Option<f64>,
37    previous_accumulated: f64,
38}
39
40impl Drop for LlmBudgetGuard {
41    fn drop(&mut self) {
42        LLM_BUDGET.with(|b| *b.borrow_mut() = self.previous_budget);
43        LLM_ACCUMULATED_COST.with(|a| *a.borrow_mut() = self.previous_accumulated);
44    }
45}
46
47/// Pin the per-call LLM cost ceiling at `max_cost_usd` for the lifetime
48/// of the returned guard. Sourced from `@budget(llm_cost_usd = …)` on
49/// `.harn` handlers in `harn-serve`; mid-call exhaustion raises a
50/// `BudgetExceeded`-categorised error which adapter codecs render as
51/// HTTP 429.
52pub fn install_llm_cost_budget(max_cost_usd: f64) -> LlmBudgetGuard {
53    let previous_budget = LLM_BUDGET.with(|b| b.borrow().to_owned());
54    let previous_accumulated = LLM_ACCUMULATED_COST.with(|a| *a.borrow());
55    LLM_BUDGET.with(|b| *b.borrow_mut() = Some(max_cost_usd.max(0.0)));
56    LLM_ACCUMULATED_COST.with(|a| *a.borrow_mut() = 0.0);
57    LlmBudgetGuard {
58        previous_budget,
59        previous_accumulated,
60    }
61}
62
63/// RAII guard for [`install_llm_token_budget`]. Pairs the dispatch-level
64/// token cap with the cost-cap guard so `@budget(llm_tokens, llm_cost_usd)`
65/// both restore on drop.
66#[must_use = "dropping the guard immediately restores the prior LLM token budget"]
67pub struct LlmTokenBudgetGuard {
68    previous_budget: Option<u64>,
69    previous_accumulated: u64,
70}
71
72impl Drop for LlmTokenBudgetGuard {
73    fn drop(&mut self) {
74        LLM_TOKEN_BUDGET.with(|b| *b.borrow_mut() = self.previous_budget);
75        LLM_ACCUMULATED_TOKENS.with(|a| *a.borrow_mut() = self.previous_accumulated);
76    }
77}
78
79/// Pin the per-dispatch LLM token ceiling (input + output combined) at
80/// `max_tokens` for the lifetime of the returned guard. Sourced from
81/// `@budget(llm_tokens: …)` on `.harn` handlers in `harn-serve`. Like
82/// the cost-cap variant, mid-stream exhaustion raises a
83/// `BudgetExceeded`-categorised error that adapters render as HTTP 429.
84pub fn install_llm_token_budget(max_tokens: u64) -> LlmTokenBudgetGuard {
85    let previous_budget = LLM_TOKEN_BUDGET.with(|b| *b.borrow());
86    let previous_accumulated = LLM_ACCUMULATED_TOKENS.with(|a| *a.borrow());
87    LLM_TOKEN_BUDGET.with(|b| *b.borrow_mut() = Some(max_tokens));
88    LLM_ACCUMULATED_TOKENS.with(|a| *a.borrow_mut() = 0);
89    LlmTokenBudgetGuard {
90        previous_budget,
91        previous_accumulated,
92    }
93}
94
95pub fn peek_total_tokens() -> u64 {
96    LLM_ACCUMULATED_TOKENS.with(|acc| *acc.borrow())
97}
98
99/// Re-arm the live per-thread LLM **cost** ceiling in place, preserving the
100/// accumulated total. `None` clears the cap.
101///
102/// Unlike [`install_llm_cost_budget`] this returns no guard and does not reset
103/// the running total: it mutates the same `LLM_BUDGET` thread-local a dispatch
104/// already consults at preflight ([`check_llm_preflight_budget`]) and after
105/// each call ([`accumulate_cost_for_provider`]). A supervisor on the dispatch
106/// thread can therefore tighten or loosen the ceiling mid-run and have the next
107/// LLM call observe it — the basis for ACP `session/set_budget` re-arm
108/// (burin-labs/burin-code#1561). Callers that want fresh per-scope accounting
109/// (HTTP `@budget`, per-turn guards) keep using `install_*` instead.
110pub fn set_llm_cost_budget(max_cost_usd: Option<f64>) {
111    LLM_BUDGET.with(|b| *b.borrow_mut() = max_cost_usd.map(|max| max.max(0.0)));
112}
113
114/// Re-arm the live per-thread LLM **token** ceiling in place, preserving the
115/// accumulated total. `None` clears the cap. The token counterpart to
116/// [`set_llm_cost_budget`] — see that function for the re-arm semantics.
117pub fn set_llm_token_budget(max_tokens: Option<u64>) {
118    LLM_TOKEN_BUDGET.with(|b| *b.borrow_mut() = max_tokens);
119}
120
121/// The live per-thread LLM cost ceiling, or `None` when uncapped. Pairs with
122/// [`peek_total_cost`] so a supervisor (or a re-arm acknowledgement) can read
123/// back the ceiling it just set.
124pub fn peek_llm_cost_budget() -> Option<f64> {
125    LLM_BUDGET.with(|b| *b.borrow())
126}
127
128/// The live per-thread LLM token ceiling, or `None` when uncapped. The token
129/// counterpart to [`peek_llm_cost_budget`].
130pub fn peek_llm_token_budget() -> Option<u64> {
131    LLM_TOKEN_BUDGET.with(|b| *b.borrow())
132}
133
134// `Serialize` exists for the typed-options parity test
135// (`orchestration/typed_options_parity.rs`), which compares this struct's
136// serialized default key set against the `LlmBudget` alias in
137// `std/llm/options.harn`.
138#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
139pub(crate) struct LlmBudgetEnvelope {
140    pub max_cost_usd: Option<f64>,
141    pub total_budget_usd: Option<f64>,
142    pub max_input_tokens: Option<i64>,
143    pub max_output_tokens: Option<i64>,
144}
145
146impl LlmBudgetEnvelope {
147    pub(crate) fn is_empty(&self) -> bool {
148        self.max_cost_usd.is_none()
149            && self.total_budget_usd.is_none()
150            && self.max_input_tokens.is_none()
151            && self.max_output_tokens.is_none()
152    }
153}
154
155#[derive(Clone, Debug)]
156pub(crate) struct LlmBudgetProjection {
157    pub provider: String,
158    pub model: String,
159    pub projected_input_tokens: i64,
160    pub projected_output_tokens: i64,
161    pub projected_cost_usd: f64,
162    pub session_cost_usd: f64,
163}
164
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub(crate) enum BudgetLimitKind {
167    PerCallCost,
168    TotalCost,
169    InputTokens,
170    OutputTokens,
171}
172
173impl BudgetLimitKind {
174    fn as_str(self) -> &'static str {
175        match self {
176            BudgetLimitKind::PerCallCost => "max_cost_usd",
177            BudgetLimitKind::TotalCost => "total_budget_usd",
178            BudgetLimitKind::InputTokens => "max_input_tokens",
179            BudgetLimitKind::OutputTokens => "max_output_tokens",
180        }
181    }
182}
183
184fn numeric_value(value: &VmValue, key: &str) -> Result<f64, VmError> {
185    let value = match value {
186        VmValue::Float(f) => *f,
187        VmValue::Int(n) => *n as f64,
188        _ => {
189            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
190                format!("budget.{key}: expected a non-negative number"),
191            ))));
192        }
193    };
194    if !value.is_finite() || value < 0.0 {
195        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
196            format!("budget.{key}: expected a non-negative finite number"),
197        ))));
198    }
199    Ok(value)
200}
201
202fn integer_value(value: &VmValue, key: &str) -> Result<i64, VmError> {
203    let value = match value {
204        VmValue::Int(n) => *n,
205        VmValue::Float(f) if f.is_finite() && f.fract() == 0.0 => *f as i64,
206        _ => {
207            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
208                format!("budget.{key}: expected a non-negative integer"),
209            ))));
210        }
211    };
212    if value < 0 {
213        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
214            format!("budget.{key}: expected a non-negative integer"),
215        ))));
216    }
217    Ok(value)
218}
219
220fn parse_budget_fields(
221    fields: &crate::value::DictMap,
222    envelope: &mut LlmBudgetEnvelope,
223) -> Result<(), VmError> {
224    if let Some(value) = fields.get("max_cost_usd") {
225        envelope.max_cost_usd = Some(numeric_value(value, "max_cost_usd")?);
226    }
227    if let Some(value) = fields.get("total_budget_usd") {
228        envelope.total_budget_usd = Some(numeric_value(value, "total_budget_usd")?);
229    }
230    if let Some(value) = fields.get("max_input_tokens") {
231        envelope.max_input_tokens = Some(integer_value(value, "max_input_tokens")?);
232    }
233    if let Some(value) = fields.get("max_output_tokens") {
234        envelope.max_output_tokens = Some(integer_value(value, "max_output_tokens")?);
235    }
236    Ok(())
237}
238
239pub(crate) fn parse_budget_envelope(
240    options: Option<&crate::value::DictMap>,
241) -> Result<Option<LlmBudgetEnvelope>, VmError> {
242    let Some(options) = options else {
243        return Ok(None);
244    };
245    let mut envelope = LlmBudgetEnvelope::default();
246    if let Some(value) = options.get("budget") {
247        match value {
248            VmValue::Nil => {}
249            VmValue::Dict(fields) => parse_budget_fields(fields, &mut envelope)?,
250            _ => {
251                return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
252                    "budget: expected a dict {max_cost_usd?, total_budget_usd?, max_input_tokens?, max_output_tokens?}",
253                ))));
254            }
255        }
256    }
257    parse_budget_fields(options, &mut envelope)?;
258    Ok((!envelope.is_empty()).then_some(envelope))
259}
260
261fn estimate_json_tokens(value: &serde_json::Value, model: &str) -> i64 {
262    match value {
263        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => 1,
264        serde_json::Value::String(s) => estimate_text_tokens_for_model(s, model),
265        serde_json::Value::Array(items) => items
266            .iter()
267            .map(|item| estimate_json_tokens(item, model))
268            .sum(),
269        serde_json::Value::Object(map) => map
270            .iter()
271            .map(|(key, value)| {
272                estimate_text_tokens_for_model(key, model) + estimate_json_tokens(value, model)
273            })
274            .sum(),
275    }
276}
277
278fn estimate_text_tokens_for_model(text: &str, model: &str) -> i64 {
279    super::token_count::estimate_text_tokens(text, Some(model)).tokens
280}
281
282pub(crate) fn project_llm_call_tokens(opts: &super::api::LlmCallOptions) -> (i64, i64) {
283    let system_tokens = opts
284        .system
285        .as_deref()
286        .map(|system| estimate_text_tokens_for_model(system, &opts.model))
287        .unwrap_or(0);
288    let message_tokens: i64 = opts
289        .messages
290        .iter()
291        .map(|message| estimate_json_tokens(message, &opts.model))
292        .sum();
293    let tool_tokens: i64 = opts
294        .native_tools
295        .as_ref()
296        .map(|tools| {
297            tools
298                .iter()
299                .map(|tool| {
300                    estimate_text_tokens_for_model(
301                        &serde_json::to_string(tool).unwrap_or_default(),
302                        &opts.model,
303                    )
304                })
305                .sum()
306        })
307        .unwrap_or(0);
308    let projected_input_tokens = system_tokens
309        .saturating_add(message_tokens)
310        .saturating_add(tool_tokens);
311    let projected_output_tokens = opts.max_tokens.max(0);
312    (projected_input_tokens, projected_output_tokens)
313}
314
315pub(crate) fn project_llm_call_context_tokens(opts: &super::api::LlmCallOptions) -> u64 {
316    let (input, output) = project_llm_call_tokens(opts);
317    input.max(0) as u64 + output.max(0) as u64
318}
319
320pub(crate) fn project_llm_call_cost(
321    opts: &super::api::LlmCallOptions,
322    session_cost_usd: f64,
323) -> LlmBudgetProjection {
324    let (projected_input_tokens, projected_output_tokens) = project_llm_call_tokens(opts);
325    let projected_cost_usd = calculate_cost_for_provider(
326        &opts.provider,
327        &opts.model,
328        projected_input_tokens,
329        projected_output_tokens,
330    );
331    LlmBudgetProjection {
332        provider: opts.provider.clone(),
333        model: opts.model.clone(),
334        projected_input_tokens,
335        projected_output_tokens,
336        projected_cost_usd,
337        session_cost_usd,
338    }
339}
340
341pub(crate) fn budget_exceeded_error(
342    projection: &LlmBudgetProjection,
343    limit_kind: BudgetLimitKind,
344    limit_value: f64,
345) -> VmError {
346    let mut dict = BTreeMap::new();
347    dict.put_str("category", "budget_exceeded");
348    dict.put_str("kind", "terminal");
349    dict.put_str("reason", "budget_exceeded");
350    dict.put_str("limit", limit_kind.as_str());
351    dict.insert("limit_value".to_string(), VmValue::Float(limit_value));
352    dict.insert(
353        "projected_cost_usd".to_string(),
354        VmValue::Float(projection.projected_cost_usd),
355    );
356    dict.insert(
357        "session_cost_usd".to_string(),
358        VmValue::Float(projection.session_cost_usd),
359    );
360    dict.insert(
361        "projected_input_tokens".to_string(),
362        VmValue::Int(projection.projected_input_tokens),
363    );
364    dict.insert(
365        "projected_output_tokens".to_string(),
366        VmValue::Int(projection.projected_output_tokens),
367    );
368    dict.put_str("provider", projection.provider.clone());
369    dict.put_str("model", projection.model.clone());
370    dict.put_str(
371        "message",
372        format!(
373            "LLM budget exceeded before provider call: {} would exceed {}",
374            match limit_kind {
375                BudgetLimitKind::PerCallCost =>
376                    format!("projected cost ${:.6}", projection.projected_cost_usd),
377                BudgetLimitKind::TotalCost => format!(
378                    "projected session cost ${:.6}",
379                    projection.session_cost_usd + projection.projected_cost_usd
380                ),
381                BudgetLimitKind::InputTokens => format!(
382                    "projected input tokens {}",
383                    projection.projected_input_tokens
384                ),
385                BudgetLimitKind::OutputTokens => format!(
386                    "projected output tokens {}",
387                    projection.projected_output_tokens
388                ),
389            },
390            limit_kind.as_str(),
391        ),
392    );
393    VmError::Thrown(VmValue::dict(dict))
394}
395
396pub(crate) fn budget_exceeded_limit(
397    envelope: &LlmBudgetEnvelope,
398    projection: &LlmBudgetProjection,
399) -> Option<(BudgetLimitKind, f64)> {
400    if let Some(max) = envelope.max_input_tokens {
401        if projection.projected_input_tokens > max {
402            return Some((BudgetLimitKind::InputTokens, max as f64));
403        }
404    }
405    if let Some(max) = envelope.max_output_tokens {
406        if projection.projected_output_tokens > max {
407            return Some((BudgetLimitKind::OutputTokens, max as f64));
408        }
409    }
410    if let Some(max) = envelope.max_cost_usd {
411        if projection.projected_cost_usd > max {
412            return Some((BudgetLimitKind::PerCallCost, max));
413        }
414    }
415    if let Some(max) = envelope.total_budget_usd {
416        if projection.session_cost_usd + projection.projected_cost_usd > max {
417            return Some((BudgetLimitKind::TotalCost, max));
418        }
419    }
420    None
421}
422
423pub(crate) fn check_budget_envelope(
424    envelope: &LlmBudgetEnvelope,
425    projection: &LlmBudgetProjection,
426) -> Result<(), VmError> {
427    if let Some((kind, limit)) = budget_exceeded_limit(envelope, projection) {
428        return Err(budget_exceeded_error(projection, kind, limit));
429    }
430    Ok(())
431}
432
433pub(crate) fn check_llm_preflight_budget(
434    opts: &super::api::LlmCallOptions,
435) -> Result<LlmBudgetProjection, VmError> {
436    let session_cost_usd = peek_total_cost();
437    let projection = project_llm_call_cost(opts, session_cost_usd);
438    if let Some(envelope) = opts.budget.as_ref() {
439        check_budget_envelope(envelope, &projection)?;
440    }
441    LLM_BUDGET.with(|budget| {
442        if let Some(max) = *budget.borrow() {
443            if session_cost_usd + projection.projected_cost_usd > max {
444                return Err(budget_exceeded_error(
445                    &projection,
446                    BudgetLimitKind::TotalCost,
447                    max,
448                ));
449            }
450        }
451        Ok(())
452    })?;
453    Ok(projection)
454}
455
456/// Resolved pricing for a (provider, model) pair, expressed per 1k tokens.
457/// The `source` discriminates how the rate was found so callers (CLI cost
458/// explanation, economics helpers, `cost_route` summaries) can report it.
459#[derive(Clone, Copy, Debug, PartialEq)]
460pub(crate) struct PricingDetail {
461    pub input_per_1k: f64,
462    pub output_per_1k: f64,
463    pub cache_read_per_1k: Option<f64>,
464    pub cache_write_per_1k: Option<f64>,
465    pub source: PricingSource,
466}
467
468#[derive(Clone, Copy, Debug, PartialEq, Eq)]
469pub(crate) enum PricingSource {
470    /// Exact model entry in the catalog (configured `[llm.models.<id>]`).
471    CatalogModel,
472    /// The model's accelerated-serving tier (`fast_mode.pricing`), used when
473    /// the provider confirmed it served the request fast.
474    CatalogFastMode,
475    /// Provider-level catalog economics (`[llm.providers.<name>]`).
476    ProviderEconomics,
477}
478
479impl PricingSource {
480    pub(crate) fn as_str(self) -> &'static str {
481        match self {
482            PricingSource::CatalogModel => "catalog_model",
483            PricingSource::CatalogFastMode => "catalog_fast_mode",
484            PricingSource::ProviderEconomics => "provider_economics",
485        }
486    }
487}
488
489/// Resolve full pricing detail for a (provider, model) pair. Prefers the
490/// exact-id catalog entry, then falls back to provider-level economics.
491/// Returns `None` for unknown pricing — callers must decide whether to
492/// surface that explicitly or coerce to 0.0.
493pub(crate) fn pricing_detail_for(provider: &str, model: &str) -> Option<PricingDetail> {
494    if let Some(pricing) = crate::llm_config::model_pricing_per_mtok(model) {
495        return Some(PricingDetail {
496            input_per_1k: pricing.input_per_mtok / 1000.0,
497            output_per_1k: pricing.output_per_mtok / 1000.0,
498            cache_read_per_1k: pricing.cache_read_per_mtok.map(|rate| rate / 1000.0),
499            cache_write_per_1k: pricing.cache_write_per_mtok.map(|rate| rate / 1000.0),
500            source: PricingSource::CatalogModel,
501        });
502    }
503    let (input, output, _) = crate::llm_config::provider_economics(provider);
504    match (input, output) {
505        (Some(input_per_1k), Some(output_per_1k)) => Some(PricingDetail {
506            input_per_1k,
507            output_per_1k,
508            cache_read_per_1k: None,
509            cache_write_per_1k: None,
510            source: PricingSource::ProviderEconomics,
511        }),
512        _ => None,
513    }
514}
515
516pub(crate) fn pricing_per_1k_for(provider: &str, model: &str) -> Option<(f64, f64)> {
517    pricing_detail_for(provider, model).map(|p| (p.input_per_1k, p.output_per_1k))
518}
519
520/// Resolve pricing for a (provider, model) pair, billing at the premium
521/// accelerated-serving tier when `served_fast` is set and the catalog
522/// declares `fast_mode.pricing`. Falls back to standard pricing when the
523/// request was served at the standard tier (e.g. a capacity downgrade) or
524/// the fast tier omits explicit rates.
525pub(crate) fn pricing_detail_for_tier(
526    provider: &str,
527    model: &str,
528    served_fast: bool,
529) -> Option<PricingDetail> {
530    if served_fast {
531        if let Some(pricing) = crate::llm_config::model_fast_pricing_per_mtok(model) {
532            return Some(PricingDetail {
533                input_per_1k: pricing.input_per_mtok / 1000.0,
534                output_per_1k: pricing.output_per_mtok / 1000.0,
535                cache_read_per_1k: pricing.cache_read_per_mtok.map(|rate| rate / 1000.0),
536                cache_write_per_1k: pricing.cache_write_per_mtok.map(|rate| rate / 1000.0),
537                source: PricingSource::CatalogFastMode,
538            });
539        }
540    }
541    pricing_detail_for(provider, model)
542}
543
544pub(crate) fn latency_p50_ms_for(provider: &str) -> Option<u64> {
545    let (_, _, latency) = crate::llm_config::provider_economics(provider);
546    latency
547}
548
549/// Recover the *authored* decimal value of a catalog rate that was parsed
550/// from a TOML float literal. The pricing catalog
551/// (`crates/harn-vm/src/llm/providers.toml`) writes short, human-authored
552/// decimals like `input_per_mtok = 0.15`; TOML deserializes them to `f64`,
553/// which cannot store `0.15` exactly. Rust's `{}` float formatter emits the
554/// *shortest* decimal string that round-trips to the same `f64` — for these
555/// short literals that is exactly the digits the author wrote (`"0.15"`,
556/// not `0.150000000000000008…`). Parsing that string straight into a
557/// `Decimal` therefore reconstructs the intended exact value without ever
558/// laundering the float's binary rounding error into false precision (which
559/// `Decimal::from_f64_retain` would). The `from_f64_retain` fallback only
560/// fires for non-finite inputs, which the catalog never contains.
561fn authored_rate_decimal(rate: f64) -> Decimal {
562    Decimal::from_str(&format!("{rate}"))
563        .ok()
564        .or_else(|| Decimal::from_f64_retain(rate))
565        .unwrap_or(Decimal::ZERO)
566}
567
568/// Compute the per-call USD cost for a model and token counts as an exact
569/// `Decimal`. Sources each rate directly from the per-MTok catalog literal
570/// via [`authored_rate_decimal`] (never through the derived per-1k rate,
571/// which would round-trip the value through an extra `f64` multiply) and
572/// does all arithmetic in `Decimal`. Division by 1,000,000 is an exact
573/// base-10 rescale, so the result carries no representational error.
574/// Returns `Decimal::ZERO` when the model has no catalog entry.
575pub fn calculate_cost_decimal(model: &str, input_tokens: i64, output_tokens: i64) -> Decimal {
576    let Some(pricing) = crate::llm_config::model_pricing_per_mtok(model) else {
577        return Decimal::ZERO;
578    };
579    let gross = Decimal::from(input_tokens) * authored_rate_decimal(pricing.input_per_mtok)
580        + Decimal::from(output_tokens) * authored_rate_decimal(pricing.output_per_mtok);
581    gross / Decimal::from(1_000_000i64)
582}
583
584/// Calculate cost using catalog model pricing first, then provider catalog
585/// economics when the model has no exact catalog entry. Returns 0.0 when
586/// pricing is unknown (use `pricing_detail_for` to distinguish unknown).
587pub fn calculate_cost_for_provider(
588    provider: &str,
589    model: &str,
590    input_tokens: i64,
591    output_tokens: i64,
592) -> f64 {
593    let Some(detail) = pricing_detail_for(provider, model) else {
594        return 0.0;
595    };
596    (input_tokens as f64 * detail.input_per_1k + output_tokens as f64 * detail.output_per_1k)
597        / 1000.0
598}
599
600/// Per-call USD cost for trace attribution, or `None` when the
601/// (provider, model) pair has no catalog pricing. Unlike
602/// [`calculate_cost_for_provider`] (which coerces unknown pricing to
603/// `0.0` for budget arithmetic), this preserves the distinction so a
604/// `cost_usd` span field can honestly report "unpriced" rather than a
605/// misleading zero. Pricing resolution matches `calculate_cost_for_provider`:
606/// catalog model rate first, then provider-level economics.
607pub fn pricing_aware_call_cost(
608    provider: &str,
609    model: &str,
610    input_tokens: i64,
611    output_tokens: i64,
612) -> Option<f64> {
613    let detail = pricing_detail_for(provider, model)?;
614    Some(
615        (input_tokens as f64 * detail.input_per_1k + output_tokens as f64 * detail.output_per_1k)
616            / 1000.0,
617    )
618}
619
620pub(crate) fn cache_hit_ratio(
621    input_tokens: i64,
622    cache_read_tokens: i64,
623    cache_write_tokens: i64,
624) -> f64 {
625    let input_tokens = input_tokens.max(0);
626    let cache_read_tokens = cache_read_tokens.max(0);
627    let cache_write_tokens = cache_write_tokens.max(0);
628    let reported_cache_tokens = cache_read_tokens.saturating_add(cache_write_tokens);
629    let total_prompt_tokens = if reported_cache_tokens <= input_tokens {
630        input_tokens
631    } else {
632        input_tokens.saturating_add(reported_cache_tokens)
633    };
634    if total_prompt_tokens == 0 {
635        0.0
636    } else {
637        cache_read_tokens as f64 / total_prompt_tokens as f64
638    }
639}
640
641pub(crate) fn cache_savings_usd_for_provider(
642    provider: &str,
643    model: &str,
644    cache_read_tokens: i64,
645    cache_write_tokens: i64,
646) -> f64 {
647    let Some(detail) = pricing_detail_for(provider, model) else {
648        return 0.0;
649    };
650    let input_rate = detail.input_per_1k;
651    let cache_read_rate = detail.cache_read_per_1k.unwrap_or(input_rate);
652    let cache_write_rate = detail.cache_write_per_1k.unwrap_or(input_rate);
653    let cache_read_savings =
654        cache_read_tokens.max(0) as f64 * (input_rate - cache_read_rate) / 1000.0;
655    let cache_write_savings =
656        cache_write_tokens.max(0) as f64 * (input_rate - cache_write_rate) / 1000.0;
657    cache_read_savings + cache_write_savings
658}
659
660pub(crate) fn accumulate_cost_for_provider(
661    provider: &str,
662    model: &str,
663    input_tokens: i64,
664    output_tokens: i64,
665    served_fast: bool,
666) -> Result<(), VmError> {
667    let cost = pricing_detail_for_tier(provider, model, served_fast)
668        .map(|detail| {
669            (input_tokens as f64 * detail.input_per_1k
670                + output_tokens as f64 * detail.output_per_1k)
671                / 1000.0
672        })
673        .unwrap_or(0.0);
674    // Always attribute usage to the active `@step` (if any), even when
675    // the per-call cost is zero — token-only step budgets need the
676    // count regardless of pricing.
677    crate::step_runtime::record_step_llm_usage(model, input_tokens, output_tokens, cost)?;
678    let total_tokens = input_tokens.max(0) as u64 + output_tokens.max(0) as u64;
679    if total_tokens > 0 {
680        LLM_ACCUMULATED_TOKENS.with(|acc| {
681            let mut slot = acc.borrow_mut();
682            *slot = slot.saturating_add(total_tokens);
683        });
684        LLM_TOKEN_BUDGET.with(|budget| {
685            if let Some(max) = *budget.borrow() {
686                let total = LLM_ACCUMULATED_TOKENS.with(|acc| *acc.borrow());
687                if total > max {
688                    return Err(categorized_error(
689                        format!("LLM token budget exceeded: spent {total} of {max} tokens"),
690                        ErrorCategory::BudgetExceeded,
691                    ));
692                }
693            }
694            Ok(())
695        })?;
696    }
697    if cost == 0.0 {
698        return Ok(());
699    }
700    LLM_ACCUMULATED_COST.with(|acc| {
701        *acc.borrow_mut() += cost;
702    });
703    LLM_BUDGET.with(|budget| {
704        if let Some(max) = *budget.borrow() {
705            let total = LLM_ACCUMULATED_COST.with(|acc| *acc.borrow());
706            if total > max {
707                return Err(categorized_error(
708                    format!("LLM budget exceeded: spent ${total:.4} of ${max:.4} budget"),
709                    ErrorCategory::BudgetExceeded,
710                ));
711            }
712        }
713        Ok(())
714    })
715}
716
717pub(crate) fn record_llm_usage_for_provider(
718    provider: &str,
719    model: &str,
720    input_tokens: i64,
721    output_tokens: i64,
722    served_fast: bool,
723) -> Result<(), VmError> {
724    accumulate_cost_for_provider(provider, model, input_tokens, output_tokens, served_fast)
725}
726
727pub(crate) fn register_cost_builtins(vm: &mut Vm) {
728    vm.register_builtin("llm_cost", |args, _out| {
729        let model = args.first().map(|a| a.display()).unwrap_or_default();
730        let input_tokens = args.get(1).and_then(|a| a.as_int()).unwrap_or(0);
731        let output_tokens = args.get(2).and_then(|a| a.as_int()).unwrap_or(0);
732        // Return the cost as an exact `decimal` (not a binary `float`): the
733        // value names money, summing it should not drift, and the catalog
734        // rates are recovered exactly. Callers that want a formatted string
735        // pass the result to `llm_format_usd`, which accepts decimals.
736        let cost = calculate_cost_decimal(&model, input_tokens, output_tokens);
737        Ok(VmValue::decimal(cost))
738    });
739
740    vm.register_builtin_with_metadata(
741        VmBuiltinMetadata::sync_static("llm_pricing")
742            .signature_static("llm_pricing(model_or_dict, model?)")
743            .arity(VmBuiltinArity::Range { min: 1, max: 2 })
744            .category_static("llm.economics")
745            .doc_static(
746                "Return catalog pricing for a model: \
747                {input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, \
748                provider, model, source} or nil if the model has no priced entry.",
749            ),
750        llm_pricing_builtin,
751    );
752
753    vm.register_builtin_with_metadata(
754        VmBuiltinMetadata::sync_static("llm_format_usd")
755            .signature_static("llm_format_usd(amount, options?)")
756            .arity(VmBuiltinArity::Range { min: 1, max: 2 })
757            .category_static("llm.economics")
758            .doc_static(
759                "Format a USD amount as a string. Default precision auto-scales: 6 decimals \
760                under $1, 4 decimals under $100, 2 decimals otherwise; pass {precision: N} to override.",
761            ),
762        llm_format_usd_builtin,
763    );
764
765    vm.register_builtin_with_metadata(
766        VmBuiltinMetadata::sync_static("llm_compare_costs")
767            .signature_static("llm_compare_costs(candidates, opts)")
768            .arity(VmBuiltinArity::Exact(2))
769            .category_static("llm.economics")
770            .doc_static(
771                "Project a per-call cost across a list of {provider?, model} candidates given \
772                {input_tokens, output_tokens, cache_read_tokens?, cache_write_tokens?, calls?}. \
773                Returns a list sorted ascending by projected cost (unknown pricing trails).",
774            ),
775        llm_compare_costs_builtin,
776    );
777
778    vm.register_builtin("llm_session_cost", |_args, _out| {
779        let (total_input, total_output, _duration, call_count) = super::trace::peek_trace_summary();
780        let total_cost = LLM_ACCUMULATED_COST.with(|acc| *acc.borrow());
781        let mut result = BTreeMap::new();
782        result.insert("total_cost".to_string(), VmValue::Float(total_cost));
783        result.insert("input_tokens".to_string(), VmValue::Int(total_input));
784        result.insert("output_tokens".to_string(), VmValue::Int(total_output));
785        result.insert("call_count".to_string(), VmValue::Int(call_count));
786        Ok(VmValue::dict(result))
787    });
788
789    vm.register_builtin("llm_budget", |args, _out| {
790        let max_cost = match args.first() {
791            Some(VmValue::Float(f)) => *f,
792            Some(VmValue::Int(n)) => *n as f64,
793            _ => {
794                return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
795                    "llm_budget: requires a numeric argument",
796                ))));
797            }
798        };
799        set_llm_cost_budget(Some(max_cost));
800        Ok(VmValue::Nil)
801    });
802
803    vm.register_builtin("llm_budget_remaining", |_args, _out| {
804        let remaining = LLM_BUDGET.with(|budget| {
805            budget.borrow().map(|max| {
806                let spent = LLM_ACCUMULATED_COST.with(|acc| *acc.borrow());
807                max - spent
808            })
809        });
810        match remaining {
811            Some(r) => Ok(VmValue::Float(r)),
812            None => Ok(VmValue::Nil),
813        }
814    });
815
816    vm.register_builtin_with_metadata(
817        VmBuiltinMetadata::sync_static("tiktoken_count_tokens")
818            .signature_static("tiktoken_count_tokens(text, model)")
819            .arity(VmBuiltinArity::Exact(2))
820            .category_static("llm.budget")
821            .doc_static("Count text tokens with the tiktoken encoder selected for a model."),
822        |args, _out| {
823            let text = args.first().map(|arg| arg.display()).unwrap_or_default();
824            let model = args.get(1).map(|arg| arg.display()).unwrap_or_default();
825            if model.trim().is_empty() {
826                return Err(VmError::Runtime(
827                    "tiktoken_count_tokens: model is required".to_string(),
828                ));
829            }
830            let estimate = super::token_count::tiktoken_count_text(&text, &model)
831                .map_err(|error| VmError::Runtime(format!("tiktoken_count_tokens: {error}")))?;
832            Ok(VmValue::Int(estimate.tokens))
833        },
834    );
835
836    vm.register_builtin_with_metadata(
837        VmBuiltinMetadata::sync_static("tiktoken_tokenizer_info")
838            .signature_static("tiktoken_tokenizer_info(model)")
839            .arity(VmBuiltinArity::Exact(1))
840            .category_static("llm.budget")
841            .doc_static("Return the tiktoken encoder metadata used for a model token count."),
842        |args, _out| {
843            let model = args.first().map(|arg| arg.display()).unwrap_or_default();
844            Ok(tokenizer_info_to_vm_value(
845                &model,
846                super::token_count::tokenizer_info_for_model(&model),
847            ))
848        },
849    );
850}
851
852fn pricing_detail_to_vm_value(provider: &str, model: &str, detail: &PricingDetail) -> VmValue {
853    let mut dict = BTreeMap::new();
854    dict.put_str("provider", provider);
855    dict.put_str("model", model);
856    dict.insert(
857        "input_per_mtok".to_string(),
858        VmValue::Float(detail.input_per_1k * 1000.0),
859    );
860    dict.insert(
861        "output_per_mtok".to_string(),
862        VmValue::Float(detail.output_per_1k * 1000.0),
863    );
864    dict.insert(
865        "cache_read_per_mtok".to_string(),
866        detail
867            .cache_read_per_1k
868            .map(|rate| VmValue::Float(rate * 1000.0))
869            .unwrap_or(VmValue::Nil),
870    );
871    dict.insert(
872        "cache_write_per_mtok".to_string(),
873        detail
874            .cache_write_per_1k
875            .map(|rate| VmValue::Float(rate * 1000.0))
876            .unwrap_or(VmValue::Nil),
877    );
878    dict.put_str("source", detail.source.as_str());
879    VmValue::dict(dict)
880}
881
882fn resolve_pricing_args(args: &[VmValue]) -> (String, String) {
883    if let Some(VmValue::Dict(dict)) = args.first() {
884        let provider = dict
885            .get("provider")
886            .map(|value| value.display())
887            .unwrap_or_default();
888        let model = dict
889            .get("model")
890            .map(|value| value.display())
891            .unwrap_or_default();
892        if !provider.is_empty() && !model.is_empty() {
893            return (provider, model);
894        }
895        if !model.is_empty() {
896            let resolved = crate::llm_config::resolve_model_info(&model);
897            return (resolved.provider, resolved.id);
898        }
899    }
900    let first = args.first().map(|a| a.display()).unwrap_or_default();
901    let second = args.get(1).map(|a| a.display()).unwrap_or_default();
902    match (first.is_empty(), second.is_empty()) {
903        (false, false) => (first, second),
904        (false, true) => {
905            let resolved = crate::llm_config::resolve_model_info(&first);
906            (resolved.provider, resolved.id)
907        }
908        _ => (String::new(), String::new()),
909    }
910}
911
912fn llm_pricing_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
913    let (provider, model) = resolve_pricing_args(args);
914    if model.trim().is_empty() {
915        return Err(VmError::Runtime(
916            "llm_pricing: model is required".to_string(),
917        ));
918    }
919    Ok(pricing_detail_for(&provider, &model)
920        .map(|detail| pricing_detail_to_vm_value(&provider, &model, &detail))
921        .unwrap_or(VmValue::Nil))
922}
923
924fn llm_format_usd_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
925    let amount = match args.first() {
926        Some(VmValue::Float(value)) => *value,
927        Some(VmValue::Int(value)) => *value as f64,
928        // Accept exact `decimal` amounts (e.g. the result of `llm_cost`).
929        // Formatting rounds to a fixed number of display decimals anyway, so
930        // converting to `f64` for the digit layout is lossless at that
931        // precision; the exact value never feeds back into money math here.
932        Some(VmValue::Decimal(value)) => {
933            use rust_decimal::prelude::ToPrimitive;
934            value.to_f64().unwrap_or(0.0)
935        }
936        Some(VmValue::Nil) | None => 0.0,
937        Some(other) => {
938            return Err(VmError::Runtime(format!(
939                "llm_format_usd: amount must be a number (got {})",
940                other.type_name(),
941            )))
942        }
943    };
944    let options = args.get(1).and_then(|v| v.as_dict());
945    let explicit_precision = options
946        .and_then(|opts| opts.get("precision"))
947        .and_then(|value| match value {
948            VmValue::Int(n) if *n >= 0 => Some(*n as usize),
949            VmValue::Float(f) if f.is_finite() && *f >= 0.0 => Some(*f as usize),
950            _ => None,
951        });
952    let sign_always = options
953        .and_then(|opts| opts.get("sign"))
954        .and_then(|value| match value {
955            VmValue::Bool(b) => Some(*b),
956            _ => None,
957        })
958        .unwrap_or(false);
959    let formatted = format_usd_amount(amount, explicit_precision, sign_always);
960    Ok(VmValue::String(arcstr::ArcStr::from(formatted)))
961}
962
963fn format_usd_amount(amount: f64, precision: Option<usize>, sign_always: bool) -> String {
964    if !amount.is_finite() {
965        return "$NaN".to_string();
966    }
967    let precision = precision.unwrap_or_else(|| {
968        let abs = amount.abs();
969        if abs == 0.0 || abs >= 100.0 {
970            2
971        } else if abs >= 1.0 {
972            4
973        } else {
974            6
975        }
976    });
977    let sign = if amount < 0.0 {
978        "-"
979    } else if sign_always {
980        "+"
981    } else {
982        ""
983    };
984    // Defer rounding to the libc formatter so that values like 81.0 that
985    // arrive as 80.999… don't split into "$80." + "1.0000".
986    let rounded = format!("{:.*}", precision, amount.abs());
987    let (whole_str, frac_part) = match rounded.find('.') {
988        Some(idx) => (&rounded[..idx], &rounded[idx + 1..]),
989        None => (rounded.as_str(), ""),
990    };
991    let mut grouped = String::new();
992    for (idx, ch) in whole_str.chars().enumerate() {
993        if idx > 0 && (whole_str.len() - idx) % 3 == 0 {
994            grouped.push(',');
995        }
996        grouped.push(ch);
997    }
998    if precision == 0 || frac_part.is_empty() {
999        format!("{sign}${grouped}")
1000    } else {
1001        format!("{sign}${grouped}.{frac_part}")
1002    }
1003}
1004
1005fn llm_compare_costs_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1006    let candidates = match args.first() {
1007        Some(VmValue::List(items)) => items.clone(),
1008        _ => {
1009            return Err(VmError::Runtime(
1010                "llm_compare_costs: candidates must be a list".to_string(),
1011            ))
1012        }
1013    };
1014    let opts = match args.get(1) {
1015        Some(VmValue::Dict(dict)) => dict.clone(),
1016        _ => {
1017            return Err(VmError::Runtime(
1018                "llm_compare_costs: options dict is required".to_string(),
1019            ))
1020        }
1021    };
1022    let input_tokens = opts
1023        .get("input_tokens")
1024        .and_then(|v| v.as_int())
1025        .unwrap_or(0)
1026        .max(0);
1027    let output_tokens = opts
1028        .get("output_tokens")
1029        .and_then(|v| v.as_int())
1030        .unwrap_or(0)
1031        .max(0);
1032    let cache_read_tokens = opts
1033        .get("cache_read_tokens")
1034        .and_then(|v| v.as_int())
1035        .unwrap_or(0)
1036        .max(0);
1037    let cache_write_tokens = opts
1038        .get("cache_write_tokens")
1039        .and_then(|v| v.as_int())
1040        .unwrap_or(0)
1041        .max(0);
1042    let calls = opts
1043        .get("calls")
1044        .and_then(|v| v.as_int())
1045        .unwrap_or(1)
1046        .max(1);
1047
1048    let mut rows: Vec<(Option<f64>, VmValue)> = Vec::with_capacity(candidates.len());
1049    for candidate in candidates.iter() {
1050        let (provider, model) = match candidate {
1051            VmValue::Dict(dict) => {
1052                let provider = dict
1053                    .get("provider")
1054                    .map(|v| v.display())
1055                    .unwrap_or_default();
1056                let model = dict.get("model").map(|v| v.display()).unwrap_or_default();
1057                if model.is_empty() {
1058                    return Err(VmError::Runtime(
1059                        "llm_compare_costs: each candidate dict must include `model`".to_string(),
1060                    ));
1061                }
1062                if provider.is_empty() {
1063                    let resolved = crate::llm_config::resolve_model_info(&model);
1064                    (resolved.provider, resolved.id)
1065                } else {
1066                    (provider, model)
1067                }
1068            }
1069            VmValue::String(s) => {
1070                let resolved = crate::llm_config::resolve_model_info(s);
1071                (resolved.provider, resolved.id)
1072            }
1073            _ => {
1074                return Err(VmError::Runtime(format!(
1075                    "llm_compare_costs: candidates must be strings or dicts (got {})",
1076                    candidate.type_name(),
1077                )))
1078            }
1079        };
1080        let detail = pricing_detail_for(&provider, &model);
1081        let projection = detail.map(|d| {
1082            project_call_cost(
1083                &d,
1084                input_tokens,
1085                output_tokens,
1086                cache_read_tokens,
1087                cache_write_tokens,
1088            ) * calls as f64
1089        });
1090        let mut row = BTreeMap::new();
1091        row.put_str("provider", provider.clone());
1092        row.put_str("model", model.clone());
1093        row.insert(
1094            "pricing".to_string(),
1095            detail
1096                .as_ref()
1097                .map(|d| pricing_detail_to_vm_value(&provider, &model, d))
1098                .unwrap_or(VmValue::Nil),
1099        );
1100        row.insert(
1101            "cost_usd".to_string(),
1102            projection.map(VmValue::Float).unwrap_or(VmValue::Nil),
1103        );
1104        row.insert("calls".to_string(), VmValue::Int(calls));
1105        row.insert("pricing_known".to_string(), VmValue::Bool(detail.is_some()));
1106        rows.push((projection, VmValue::dict(row)));
1107    }
1108
1109    rows.sort_by(|left, right| match (left.0, right.0) {
1110        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal),
1111        (Some(_), None) => std::cmp::Ordering::Less,
1112        (None, Some(_)) => std::cmp::Ordering::Greater,
1113        (None, None) => std::cmp::Ordering::Equal,
1114    });
1115    Ok(VmValue::List(std::sync::Arc::new(
1116        rows.into_iter().map(|(_, value)| value).collect(),
1117    )))
1118}
1119
1120pub(crate) fn project_call_cost(
1121    detail: &PricingDetail,
1122    input_tokens: i64,
1123    output_tokens: i64,
1124    cache_read_tokens: i64,
1125    cache_write_tokens: i64,
1126) -> f64 {
1127    let cache_read_rate = detail.cache_read_per_1k.unwrap_or(detail.input_per_1k);
1128    let cache_write_rate = detail.cache_write_per_1k.unwrap_or(detail.input_per_1k);
1129    let billable_input = (input_tokens - cache_read_tokens - cache_write_tokens).max(0);
1130    (billable_input as f64 * detail.input_per_1k
1131        + output_tokens as f64 * detail.output_per_1k
1132        + cache_read_tokens as f64 * cache_read_rate
1133        + cache_write_tokens as f64 * cache_write_rate)
1134        / 1000.0
1135}
1136
1137fn tokenizer_info_to_vm_value(model: &str, info: super::token_count::TokenizerInfo) -> VmValue {
1138    let mut result = BTreeMap::new();
1139    result.put_str("model", model);
1140    result.put_str("model_family", info.model_family);
1141    result.put_str("source", info.source.as_str());
1142    result.insert("exact".to_string(), VmValue::Bool(info.exact));
1143    result.insert(
1144        "known_model_family".to_string(),
1145        VmValue::Bool(info.known_model_family),
1146    );
1147    result.insert(
1148        "encoder".to_string(),
1149        info.encoder
1150            .map(|encoder| VmValue::String(arcstr::ArcStr::from(encoder)))
1151            .unwrap_or(VmValue::Nil),
1152    );
1153    VmValue::dict(result)
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158    use super::*;
1159
1160    #[test]
1161    fn calculate_cost_uses_catalog_model_pricing() {
1162        let _guard = crate::llm::env_guard();
1163        let mut overlay = crate::llm_config::ProvidersConfig::default();
1164        overlay.models.insert(
1165            "gpt-4o-mini".to_string(),
1166            crate::llm_config::ModelDef {
1167                name: "Test GPT-4o Mini".to_string(),
1168                provider: "openai".to_string(),
1169                context_window: 128_000,
1170                logical_model: None,
1171                equivalence_group: None,
1172                served_variant: None,
1173                wire_model: None,
1174                api_dialect: None,
1175                rate_limits: None,
1176                performance: None,
1177                architecture: None,
1178                local_memory: None,
1179                runtime_context_window: None,
1180                stream_timeout: None,
1181                capabilities: Vec::new(),
1182                pricing: Some(crate::llm_config::ModelPricing {
1183                    input_per_mtok: 10.0,
1184                    output_per_mtok: 20.0,
1185                    cache_read_per_mtok: None,
1186                    cache_write_per_mtok: None,
1187                }),
1188                deprecated: false,
1189                deprecation_note: None,
1190                superseded_by: None,
1191                fast_mode: None,
1192                quality_tags: Vec::new(),
1193                availability: crate::llm_config::ModelAvailability::default(),
1194                tier: None,
1195                open_weight: None,
1196                strengths: Vec::new(),
1197                benchmarks: std::collections::BTreeMap::new(),
1198                family: None,
1199                lineage: None,
1200                complementary_with: Vec::new(),
1201                avoid_as_reviewer_for: Vec::new(),
1202            },
1203        );
1204        crate::llm_config::set_user_overrides(Some(overlay));
1205
1206        // 1000*10 + 1000*20 = 30000; /1e6 = 0.03, exactly.
1207        assert_eq!(
1208            calculate_cost_decimal("gpt-4o-mini", 1000, 1000),
1209            Decimal::from_str("0.03").unwrap()
1210        );
1211
1212        crate::llm_config::clear_user_overrides();
1213    }
1214
1215    #[test]
1216    fn calculate_cost_is_zero_for_unknown_model() {
1217        let _guard = crate::llm::env_guard();
1218        crate::llm_config::clear_user_overrides();
1219        assert_eq!(
1220            calculate_cost_decimal("definitely-unpriced-model", 1_000, 1_000),
1221            Decimal::ZERO
1222        );
1223    }
1224
1225    #[test]
1226    fn authored_rate_decimal_recovers_the_written_literal_not_float_noise() {
1227        // Catalog rates are short literals parsed from TOML into f64; many
1228        // (0.15, 0.8, 0.08) are not exactly representable in binary. The
1229        // recovery must reconstruct the *authored* decimal, never the f64's
1230        // binary-rounding tail that `from_f64_retain` would expose.
1231        for (raw, written) in [
1232            (0.15_f64, "0.15"),
1233            (0.8, "0.8"),
1234            (0.08, "0.08"),
1235            (4.0, "4"),
1236            (0.0, "0"),
1237            (3.75, "3.75"),
1238        ] {
1239            let recovered = authored_rate_decimal(raw);
1240            assert_eq!(
1241                recovered,
1242                Decimal::from_str(written).unwrap(),
1243                "rate {raw} should recover as {written}"
1244            );
1245        }
1246        // Guard the whole point of the helper: it must NOT equal the lossy
1247        // `from_f64_retain` decimal for an inexact literal.
1248        assert_ne!(
1249            authored_rate_decimal(0.1),
1250            Decimal::from_f64_retain(0.1).unwrap()
1251        );
1252    }
1253
1254    #[test]
1255    fn calculate_cost_decimal_is_exact_for_inexact_catalog_rates() {
1256        let _guard = crate::llm::env_guard();
1257        let mut overlay = crate::llm_config::ProvidersConfig::default();
1258        overlay.models.insert(
1259            "gpt-4o-mini".to_string(),
1260            crate::llm_config::ModelDef {
1261                name: "Test GPT-4o Mini".to_string(),
1262                provider: "openai".to_string(),
1263                context_window: 128_000,
1264                logical_model: None,
1265                equivalence_group: None,
1266                served_variant: None,
1267                wire_model: None,
1268                api_dialect: None,
1269                rate_limits: None,
1270                performance: None,
1271                architecture: None,
1272                local_memory: None,
1273                runtime_context_window: None,
1274                stream_timeout: None,
1275                capabilities: Vec::new(),
1276                // Inexact-in-binary rates, like the real catalog.
1277                pricing: Some(crate::llm_config::ModelPricing {
1278                    input_per_mtok: 0.15,
1279                    output_per_mtok: 0.60,
1280                    cache_read_per_mtok: None,
1281                    cache_write_per_mtok: None,
1282                }),
1283                deprecated: false,
1284                deprecation_note: None,
1285                superseded_by: None,
1286                fast_mode: None,
1287                quality_tags: Vec::new(),
1288                availability: crate::llm_config::ModelAvailability::default(),
1289                tier: None,
1290                open_weight: None,
1291                strengths: Vec::new(),
1292                benchmarks: std::collections::BTreeMap::new(),
1293                family: None,
1294                lineage: None,
1295                complementary_with: Vec::new(),
1296                avoid_as_reviewer_for: Vec::new(),
1297            },
1298        );
1299        crate::llm_config::set_user_overrides(Some(overlay));
1300
1301        // 1000 * 0.15 + 500 * 0.60 = 150 + 300 = 450; /1e6 = 0.00045 exactly.
1302        assert_eq!(
1303            calculate_cost_decimal("gpt-4o-mini", 1000, 500),
1304            Decimal::from_str("0.00045").unwrap()
1305        );
1306
1307        crate::llm_config::clear_user_overrides();
1308    }
1309
1310    #[test]
1311    fn calculate_cost_for_provider_falls_back_to_provider_economics() {
1312        let _guard = crate::llm::env_guard();
1313        crate::llm_config::clear_user_overrides();
1314        let cost =
1315            calculate_cost_for_provider("openai", "some-bespoke-openai-deployment", 1_000, 1_000);
1316        let (input_per_1k, output_per_1k, _) = crate::llm_config::provider_economics("openai");
1317        let expected =
1318            (1_000.0 * input_per_1k.unwrap() + 1_000.0 * output_per_1k.unwrap()) / 1_000.0;
1319        assert!(
1320            (cost - expected).abs() < 1e-9,
1321            "cost={cost}, expected={expected}"
1322        );
1323    }
1324
1325    #[test]
1326    fn pricing_detail_reports_source() {
1327        let _guard = crate::llm::env_guard();
1328        crate::llm_config::clear_user_overrides();
1329        let exact = pricing_detail_for("anthropic", "claude-sonnet-4-20250514").unwrap();
1330        assert_eq!(exact.source, PricingSource::CatalogModel);
1331        assert!(exact.cache_read_per_1k.is_some());
1332
1333        let provider_only = pricing_detail_for("openai", "some-bespoke-openai-deployment").unwrap();
1334        assert_eq!(provider_only.source, PricingSource::ProviderEconomics);
1335        assert!(provider_only.cache_read_per_1k.is_none());
1336
1337        assert!(pricing_detail_for("local", "no-such-local-model").is_some()); // local has 0/0
1338        assert!(pricing_detail_for("nonexistent_provider", "ghost-model").is_none());
1339    }
1340
1341    #[test]
1342    fn pricing_aware_call_cost_distinguishes_unpriced_from_zero() {
1343        let _guard = crate::llm::env_guard();
1344        crate::llm_config::clear_user_overrides();
1345
1346        // Known catalog model: Some(cost) matching the priced arithmetic.
1347        let priced = pricing_aware_call_cost("anthropic", "claude-sonnet-4-20250514", 1_000, 1_000);
1348        let expected =
1349            calculate_cost_for_provider("anthropic", "claude-sonnet-4-20250514", 1_000, 1_000);
1350        assert!(priced.is_some());
1351        assert!((priced.unwrap() - expected).abs() < 1e-9);
1352
1353        // Genuinely unpriced (provider not in the catalog economics table):
1354        // None, not a misleading 0.0. `calculate_cost_for_provider` coerces
1355        // the same case to 0.0, which is exactly the ambiguity this helper
1356        // exists to remove.
1357        assert_eq!(
1358            pricing_aware_call_cost("nonexistent_provider", "ghost-model", 1_000, 1_000),
1359            None
1360        );
1361        assert_eq!(
1362            calculate_cost_for_provider("nonexistent_provider", "ghost-model", 1_000, 1_000),
1363            0.0
1364        );
1365    }
1366
1367    #[test]
1368    fn format_usd_amount_auto_precision_and_grouping() {
1369        assert_eq!(format_usd_amount(0.000_045, None, false), "$0.000045");
1370        assert_eq!(format_usd_amount(1.234_5, None, false), "$1.2345");
1371        assert_eq!(format_usd_amount(1234.5, None, false), "$1,234.50");
1372        assert_eq!(format_usd_amount(-1234.5, None, false), "-$1,234.50");
1373        assert_eq!(format_usd_amount(1234.5, None, true), "+$1,234.50");
1374        assert_eq!(format_usd_amount(0.123_456_789, Some(2), false), "$0.12");
1375        assert_eq!(format_usd_amount(1.0, Some(0), false), "$1");
1376    }
1377
1378    #[test]
1379    fn format_usd_handles_fractional_carry_into_whole() {
1380        // 0.00027 * 300_000 produces 80.999… in IEEE-754; the formatter
1381        // must round-then-render rather than splitting the rounded fraction
1382        // back into a separate component (regression: "$80.1.0000").
1383        let amount = 0.000_27_f64 * 300_000.0;
1384        assert!((amount - 81.0).abs() < 1e-6);
1385        assert_eq!(format_usd_amount(amount, None, false), "$81.0000");
1386    }
1387
1388    #[test]
1389    fn fast_tier_bills_premium_pricing_when_served_fast() {
1390        let _guard = crate::llm::env_guard();
1391        crate::llm_config::clear_user_overrides();
1392
1393        // Opus 4.8 fast mode is 2x standard ($5/$25 -> $10/$50 per MTok).
1394        let standard = pricing_detail_for_tier("anthropic", "claude-opus-4-8", false).unwrap();
1395        let fast = pricing_detail_for_tier("anthropic", "claude-opus-4-8", true).unwrap();
1396        assert_eq!(standard.source, PricingSource::CatalogModel);
1397        assert_eq!(fast.source, PricingSource::CatalogFastMode);
1398        assert!((fast.input_per_1k - 2.0 * standard.input_per_1k).abs() < 1e-9);
1399        assert!((fast.output_per_1k - 2.0 * standard.output_per_1k).abs() < 1e-9);
1400
1401        // A model with no fast tier ignores the flag and bills standard.
1402        let no_fast =
1403            pricing_detail_for_tier("anthropic", "claude-sonnet-4-20250514", true).unwrap();
1404        assert_eq!(no_fast.source, PricingSource::CatalogModel);
1405    }
1406
1407    #[test]
1408    fn project_call_cost_excludes_cached_input_from_full_rate() {
1409        let detail = pricing_detail_for("anthropic", "claude-sonnet-4-20250514").unwrap();
1410        let with_cache = project_call_cost(&detail, 10_000, 500, 8_000, 0);
1411        let no_cache = project_call_cost(&detail, 10_000, 500, 0, 0);
1412        assert!(with_cache < no_cache);
1413    }
1414
1415    #[test]
1416    fn cache_savings_uses_catalog_cache_pricing() {
1417        let _guard = crate::llm::env_guard();
1418        crate::llm_config::clear_user_overrides();
1419
1420        let savings =
1421            cache_savings_usd_for_provider("anthropic", "claude-sonnet-4-20250514", 1000, 0);
1422        assert!((savings - 0.0027).abs() < 0.0000001);
1423
1424        let write_delta =
1425            cache_savings_usd_for_provider("anthropic", "claude-sonnet-4-20250514", 0, 1000);
1426        assert!((write_delta + 0.00075).abs() < 0.0000001);
1427
1428        crate::llm_config::clear_user_overrides();
1429    }
1430
1431    #[test]
1432    fn cache_hit_ratio_handles_subset_and_separate_anthropic_counts() {
1433        assert!((cache_hit_ratio(1000, 250, 0) - 0.25).abs() < f64::EPSILON);
1434        assert!((cache_hit_ratio(100, 900, 0) - 0.9).abs() < f64::EPSILON);
1435        assert_eq!(cache_hit_ratio(0, 0, 0), 0.0);
1436    }
1437
1438    #[test]
1439    fn token_budget_guard_restores_prior_state_on_drop() {
1440        let _guard_outer = crate::llm::env_guard();
1441        reset_cost_state();
1442
1443        let outer = install_llm_token_budget(100);
1444        assert_eq!(peek_total_tokens(), 0);
1445        // Simulate accumulation by writing the thread-local directly.
1446        LLM_ACCUMULATED_TOKENS.with(|a| *a.borrow_mut() = 50);
1447
1448        // Nested guard wipes accumulation and installs a tighter cap.
1449        {
1450            let _inner = install_llm_token_budget(10);
1451            assert_eq!(peek_total_tokens(), 0);
1452            LLM_ACCUMULATED_TOKENS.with(|a| *a.borrow_mut() = 5);
1453        }
1454
1455        // Outer scope restored on inner drop.
1456        assert_eq!(peek_total_tokens(), 50);
1457        drop(outer);
1458        assert_eq!(peek_total_tokens(), 0);
1459
1460        reset_cost_state();
1461    }
1462
1463    #[test]
1464    fn set_budget_rearms_in_place_without_resetting_accumulation() {
1465        let _guard_outer = crate::llm::env_guard();
1466        reset_cost_state();
1467
1468        // Install a $1.00 cap and spend $0.60 against it.
1469        let _budget = install_llm_cost_budget(1.0);
1470        LLM_ACCUMULATED_COST.with(|a| *a.borrow_mut() = 0.60);
1471
1472        // Tighten the cap below current spend: the next preflight must trip,
1473        // and the already-accumulated total must be preserved (not reset).
1474        set_llm_cost_budget(Some(0.50));
1475        assert!((peek_total_cost() - 0.60).abs() < f64::EPSILON);
1476        LLM_BUDGET.with(|b| assert_eq!(*b.borrow(), Some(0.50)));
1477
1478        // Loosen the cap: spend stays, ceiling rises, room reopens.
1479        set_llm_cost_budget(Some(2.0));
1480        assert!((peek_total_cost() - 0.60).abs() < f64::EPSILON);
1481        LLM_BUDGET.with(|b| assert_eq!(*b.borrow(), Some(2.0)));
1482
1483        // Clear the cap entirely.
1484        set_llm_cost_budget(None);
1485        LLM_BUDGET.with(|b| assert_eq!(*b.borrow(), None));
1486
1487        // Negative ceilings clamp to zero (a hard stop), matching `install_*`.
1488        set_llm_cost_budget(Some(-5.0));
1489        LLM_BUDGET.with(|b| assert_eq!(*b.borrow(), Some(0.0)));
1490
1491        reset_cost_state();
1492    }
1493
1494    #[test]
1495    fn set_token_budget_rearms_in_place_without_resetting_accumulation() {
1496        let _guard_outer = crate::llm::env_guard();
1497        reset_cost_state();
1498
1499        let _budget = install_llm_token_budget(100);
1500        LLM_ACCUMULATED_TOKENS.with(|a| *a.borrow_mut() = 60);
1501
1502        set_llm_token_budget(Some(50));
1503        assert_eq!(peek_total_tokens(), 60);
1504        LLM_TOKEN_BUDGET.with(|b| assert_eq!(*b.borrow(), Some(50)));
1505
1506        set_llm_token_budget(None);
1507        assert_eq!(peek_total_tokens(), 60);
1508        LLM_TOKEN_BUDGET.with(|b| assert_eq!(*b.borrow(), None));
1509
1510        reset_cost_state();
1511    }
1512
1513    #[test]
1514    fn token_budget_raises_categorized_error_when_exhausted() {
1515        let _guard_outer = crate::llm::env_guard();
1516        reset_cost_state();
1517        let _budget = install_llm_token_budget(10);
1518
1519        // First call within budget — admits.
1520        let first =
1521            accumulate_cost_for_provider("anthropic", "claude-sonnet-4-20250514", 5, 0, false);
1522        assert!(first.is_ok());
1523
1524        // Second call pushes over — raises BudgetExceeded.
1525        let second =
1526            accumulate_cost_for_provider("anthropic", "claude-sonnet-4-20250514", 8, 0, false);
1527        match second {
1528            Err(VmError::CategorizedError { category, message }) => {
1529                assert_eq!(category, ErrorCategory::BudgetExceeded);
1530                assert!(message.contains("token budget"), "got: {message}");
1531            }
1532            other => panic!("expected BudgetExceeded, got {other:?}"),
1533        }
1534
1535        reset_cost_state();
1536    }
1537}