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
17pub(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#[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
47pub 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#[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
79pub 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
99pub 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
114pub fn set_llm_token_budget(max_tokens: Option<u64>) {
118 LLM_TOKEN_BUDGET.with(|b| *b.borrow_mut() = max_tokens);
119}
120
121pub fn peek_llm_cost_budget() -> Option<f64> {
125 LLM_BUDGET.with(|b| *b.borrow())
126}
127
128pub fn peek_llm_token_budget() -> Option<u64> {
131 LLM_TOKEN_BUDGET.with(|b| *b.borrow())
132}
133
134#[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#[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 CatalogModel,
472 CatalogFastMode,
475 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
489pub(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
520pub(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
549fn 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
568pub 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
584pub 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
600pub 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 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 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 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 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 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 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 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 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 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()); 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 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 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 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 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 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 LLM_ACCUMULATED_TOKENS.with(|a| *a.borrow_mut() = 50);
1447
1448 {
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 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 let _budget = install_llm_cost_budget(1.0);
1470 LLM_ACCUMULATED_COST.with(|a| *a.borrow_mut() = 0.60);
1471
1472 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 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 set_llm_cost_budget(None);
1485 LLM_BUDGET.with(|b| assert_eq!(*b.borrow(), None));
1486
1487 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 let first =
1521 accumulate_cost_for_provider("anthropic", "claude-sonnet-4-20250514", 5, 0, false);
1522 assert!(first.is_ok());
1523
1524 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}