mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* fix: use checked_sub to prevent Instant duration overflow on Windows (#657) On Windows, Instant starts from system boot time. Subtracting a duration longer than uptime (e.g., 1 hour on a freshly booted system) panics with "overflow when subtracting duration from instant", crashing the tokio worker thread. Replace `Instant::now() - Duration` with `Instant::now().checked_sub()` in cost_guard.rs (production), server.rs and session.rs (tests). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use expect() instead of unwrap_or() in test code Address PR review: unwrap_or(Instant::now()) silently breaks test semantics when checked_sub returns None. Using expect() ensures tests fail explicitly with a clear message about insufficient system uptime. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
660 lines
22 KiB
Rust
660 lines
22 KiB
Rust
//! Cost enforcement guardrails for the agent.
|
||
//!
|
||
//! Tracks LLM spending and action rates, enforcing configurable limits
|
||
//! to prevent runaway agents from burning through API credits. Especially
|
||
//! important for daemon/heartbeat modes where the agent acts autonomously.
|
||
|
||
use std::collections::{HashMap, VecDeque};
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::time::Instant;
|
||
|
||
use rust_decimal::Decimal;
|
||
use rust_decimal_macros::dec;
|
||
use tokio::sync::Mutex;
|
||
|
||
use crate::llm::costs;
|
||
|
||
/// Configuration for cost guardrails.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct CostGuardConfig {
|
||
/// Maximum spend per day in cents (e.g. 10000 = $100). None = unlimited.
|
||
pub max_cost_per_day_cents: Option<u64>,
|
||
/// Maximum LLM calls per hour. None = unlimited.
|
||
pub max_actions_per_hour: Option<u64>,
|
||
}
|
||
|
||
/// Error returned when a cost limit is exceeded.
|
||
#[derive(Debug, Clone)]
|
||
pub enum CostLimitExceeded {
|
||
/// Daily spending cap reached.
|
||
DailyBudget { spent_cents: u64, limit_cents: u64 },
|
||
/// Hourly action rate limit reached.
|
||
HourlyRate { actions: u64, limit: u64 },
|
||
}
|
||
|
||
impl std::fmt::Display for CostLimitExceeded {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::DailyBudget {
|
||
spent_cents,
|
||
limit_cents,
|
||
} => write!(
|
||
f,
|
||
"Daily cost limit exceeded: spent ${:.2} of ${:.2} allowed",
|
||
*spent_cents as f64 / 100.0,
|
||
*limit_cents as f64 / 100.0
|
||
),
|
||
Self::HourlyRate { actions, limit } => write!(
|
||
f,
|
||
"Hourly action limit exceeded: {} actions of {} allowed per hour",
|
||
actions, limit
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Per-model token usage counters.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct ModelTokens {
|
||
pub input_tokens: u64,
|
||
pub output_tokens: u64,
|
||
pub cost: Decimal,
|
||
}
|
||
|
||
/// Tracks costs and action rates, enforcing configurable limits.
|
||
///
|
||
/// Thread-safe; designed to be shared via `Arc<CostGuard>`.
|
||
pub struct CostGuard {
|
||
config: CostGuardConfig,
|
||
|
||
/// Running cost total for the current day (in USD, not cents).
|
||
daily_cost: Mutex<DailyCost>,
|
||
|
||
/// Sliding window of action timestamps for rate limiting.
|
||
action_window: Mutex<VecDeque<Instant>>,
|
||
|
||
/// Flag set when daily budget is exceeded to short-circuit checks.
|
||
budget_exceeded: AtomicBool,
|
||
|
||
/// Per-model token usage since startup.
|
||
model_tokens: Mutex<HashMap<String, ModelTokens>>,
|
||
}
|
||
|
||
struct DailyCost {
|
||
total: Decimal,
|
||
/// Day boundary (midnight UTC) for resetting the counter.
|
||
reset_date: chrono::NaiveDate,
|
||
}
|
||
|
||
impl CostGuard {
|
||
pub fn new(config: CostGuardConfig) -> Self {
|
||
Self {
|
||
config,
|
||
daily_cost: Mutex::new(DailyCost {
|
||
total: Decimal::ZERO,
|
||
reset_date: chrono::Utc::now().date_naive(),
|
||
}),
|
||
action_window: Mutex::new(VecDeque::new()),
|
||
budget_exceeded: AtomicBool::new(false),
|
||
model_tokens: Mutex::new(HashMap::new()),
|
||
}
|
||
}
|
||
|
||
/// Check whether the next action is allowed under the configured limits.
|
||
///
|
||
/// Call this BEFORE making an LLM call. Does NOT record the action yet,
|
||
/// call `record_action` after the action completes.
|
||
pub async fn check_allowed(&self) -> Result<(), CostLimitExceeded> {
|
||
// Fast path: if budget already blown, skip the lock
|
||
if self.budget_exceeded.load(Ordering::Relaxed) {
|
||
let daily = self.daily_cost.lock().await;
|
||
let spent_cents = to_cents(daily.total);
|
||
return Err(CostLimitExceeded::DailyBudget {
|
||
spent_cents,
|
||
limit_cents: self.config.max_cost_per_day_cents.unwrap_or(0),
|
||
});
|
||
}
|
||
|
||
// Check daily budget
|
||
if let Some(limit_cents) = self.config.max_cost_per_day_cents {
|
||
let daily = self.daily_cost.lock().await;
|
||
let spent_cents = to_cents(daily.total);
|
||
if spent_cents >= limit_cents {
|
||
self.budget_exceeded.store(true, Ordering::Relaxed);
|
||
return Err(CostLimitExceeded::DailyBudget {
|
||
spent_cents,
|
||
limit_cents,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Check hourly rate
|
||
if let Some(limit) = self.config.max_actions_per_hour {
|
||
let mut window = self.action_window.lock().await;
|
||
// checked_sub avoids panic when system uptime < 1 hour (Windows)
|
||
if let Some(cutoff) = Instant::now().checked_sub(std::time::Duration::from_secs(3600)) {
|
||
// Drain expired entries
|
||
while window.front().is_some_and(|t| *t < cutoff) {
|
||
window.pop_front();
|
||
}
|
||
}
|
||
let count = window.len() as u64;
|
||
if count >= limit {
|
||
return Err(CostLimitExceeded::HourlyRate {
|
||
actions: count,
|
||
limit,
|
||
});
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Record a completed LLM action: its token costs and the action timestamp.
|
||
///
|
||
/// Call this AFTER an LLM call completes so that costs are tracked.
|
||
/// - `cache_read_input_tokens`: tokens served from cache.
|
||
/// - `cache_creation_input_tokens`: tokens written to cache.
|
||
/// - `cache_read_discount`: divisor for cache-read cost (e.g. 10 for Anthropic 90% off, 2 for OpenAI 50% off).
|
||
/// - `cache_write_multiplier`: cost multiplier for cache writes (1.25 for 5m, 2.0 for 1h).
|
||
///
|
||
/// When `cost_per_token` is `Some`, those rates are used directly (provider-
|
||
/// sourced pricing). When `None`, falls back to the static `costs::model_cost`
|
||
/// lookup table, then `costs::default_cost`.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn record_llm_call(
|
||
&self,
|
||
model: &str,
|
||
input_tokens: u32,
|
||
output_tokens: u32,
|
||
cache_read_input_tokens: u32,
|
||
cache_creation_input_tokens: u32,
|
||
cache_read_discount: Decimal,
|
||
cache_write_multiplier: Decimal,
|
||
cost_per_token: Option<(Decimal, Decimal)>,
|
||
) -> Decimal {
|
||
let (input_rate, output_rate) = cost_per_token
|
||
.unwrap_or_else(|| costs::model_cost(model).unwrap_or_else(costs::default_cost));
|
||
// Cached read tokens cost input_rate / cache_read_discount (provider-specific).
|
||
// Cached write tokens cost write_multiplier × input_rate (e.g. 1.25× for 5m, 2× for 1h).
|
||
// Uncached tokens = total input - cache reads - cache writes.
|
||
let cached_total = cache_read_input_tokens.saturating_add(cache_creation_input_tokens);
|
||
let uncached_input = input_tokens.saturating_sub(cached_total);
|
||
let effective_discount = if cache_read_discount.is_zero() {
|
||
Decimal::ONE
|
||
} else {
|
||
cache_read_discount
|
||
};
|
||
let cache_read_cost =
|
||
input_rate * Decimal::from(cache_read_input_tokens) / effective_discount;
|
||
let cache_write_cost =
|
||
input_rate * Decimal::from(cache_creation_input_tokens) * cache_write_multiplier;
|
||
let cost = input_rate * Decimal::from(uncached_input)
|
||
+ cache_read_cost
|
||
+ cache_write_cost
|
||
+ output_rate * Decimal::from(output_tokens);
|
||
|
||
// Update daily cost (reset if new day)
|
||
{
|
||
let mut daily = self.daily_cost.lock().await;
|
||
let today = chrono::Utc::now().date_naive();
|
||
if today != daily.reset_date {
|
||
daily.total = Decimal::ZERO;
|
||
daily.reset_date = today;
|
||
self.budget_exceeded.store(false, Ordering::Relaxed);
|
||
tracing::info!("Cost guard: daily counter reset for {}", today);
|
||
}
|
||
daily.total += cost;
|
||
|
||
// Check if we just crossed the threshold
|
||
if let Some(limit_cents) = self.config.max_cost_per_day_cents {
|
||
let spent_cents = to_cents(daily.total);
|
||
if spent_cents >= limit_cents {
|
||
self.budget_exceeded.store(true, Ordering::Relaxed);
|
||
tracing::warn!(
|
||
"Daily cost limit reached: ${:.2} of ${:.2}",
|
||
daily.total,
|
||
Decimal::from(limit_cents) / dec!(100)
|
||
);
|
||
}
|
||
// Warn at 80% threshold
|
||
let warn_threshold = limit_cents * 80 / 100;
|
||
if spent_cents >= warn_threshold && spent_cents < limit_cents {
|
||
tracing::warn!(
|
||
"Approaching daily cost limit: ${:.2} of ${:.2} ({}%)",
|
||
daily.total,
|
||
Decimal::from(limit_cents) / dec!(100),
|
||
spent_cents * 100 / limit_cents
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Record action in sliding window
|
||
{
|
||
let mut window = self.action_window.lock().await;
|
||
window.push_back(Instant::now());
|
||
}
|
||
|
||
// Track per-model token usage
|
||
{
|
||
let mut tokens = self.model_tokens.lock().await;
|
||
let entry = tokens.entry(model.to_string()).or_default();
|
||
entry.input_tokens += u64::from(input_tokens);
|
||
entry.output_tokens += u64::from(output_tokens);
|
||
entry.cost += cost;
|
||
}
|
||
|
||
cost
|
||
}
|
||
|
||
/// Current daily spend in USD (as Decimal).
|
||
pub async fn daily_spend(&self) -> Decimal {
|
||
let daily = self.daily_cost.lock().await;
|
||
let today = chrono::Utc::now().date_naive();
|
||
if today != daily.reset_date {
|
||
Decimal::ZERO
|
||
} else {
|
||
daily.total
|
||
}
|
||
}
|
||
|
||
/// Number of actions in the current hourly window.
|
||
pub async fn actions_this_hour(&self) -> u64 {
|
||
let mut window = self.action_window.lock().await;
|
||
// checked_sub avoids panic when system uptime < 1 hour (Windows)
|
||
if let Some(cutoff) = Instant::now().checked_sub(std::time::Duration::from_secs(3600)) {
|
||
while window.front().is_some_and(|t| *t < cutoff) {
|
||
window.pop_front();
|
||
}
|
||
}
|
||
window.len() as u64
|
||
}
|
||
|
||
/// Per-model token usage since startup.
|
||
pub async fn model_usage(&self) -> HashMap<String, ModelTokens> {
|
||
self.model_tokens.lock().await.clone()
|
||
}
|
||
}
|
||
|
||
/// Convert a Decimal USD amount to whole cents (truncated).
|
||
fn to_cents(usd: Decimal) -> u64 {
|
||
let cents = (usd * dec!(100)).trunc();
|
||
cents.to_string().parse::<u64>().unwrap_or(0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[tokio::test]
|
||
async fn test_unlimited_allows_everything() {
|
||
let guard = CostGuard::new(CostGuardConfig::default());
|
||
|
||
// No limits set, should always be allowed
|
||
assert!(guard.check_allowed().await.is_ok());
|
||
|
||
// Record a big call, still allowed
|
||
guard
|
||
.record_llm_call(
|
||
"gpt-4o",
|
||
100_000,
|
||
100_000,
|
||
0,
|
||
0,
|
||
Decimal::ONE,
|
||
Decimal::ONE,
|
||
None,
|
||
)
|
||
.await;
|
||
assert!(guard.check_allowed().await.is_ok());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_daily_budget_enforcement() {
|
||
let guard = CostGuard::new(CostGuardConfig {
|
||
max_cost_per_day_cents: Some(1), // $0.01 limit
|
||
max_actions_per_hour: None,
|
||
});
|
||
|
||
// First call allowed
|
||
assert!(guard.check_allowed().await.is_ok());
|
||
|
||
// Record a call that costs more than $0.01
|
||
// gpt-4o: input=$0.0000025/tok, output=$0.00001/tok
|
||
// 10000 input + 10000 output = $0.025 + $0.10 = $0.125
|
||
guard
|
||
.record_llm_call(
|
||
"gpt-4o",
|
||
10_000,
|
||
10_000,
|
||
0,
|
||
0,
|
||
Decimal::ONE,
|
||
Decimal::ONE,
|
||
None,
|
||
)
|
||
.await;
|
||
|
||
// Now should be blocked
|
||
let result = guard.check_allowed().await;
|
||
assert!(result.is_err());
|
||
match result.unwrap_err() {
|
||
CostLimitExceeded::DailyBudget { limit_cents, .. } => {
|
||
assert_eq!(limit_cents, 1);
|
||
}
|
||
other => panic!("Expected DailyBudget, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_hourly_rate_enforcement() {
|
||
let guard = CostGuard::new(CostGuardConfig {
|
||
max_cost_per_day_cents: None,
|
||
max_actions_per_hour: Some(3),
|
||
});
|
||
|
||
// First 3 actions allowed
|
||
for _ in 0..3 {
|
||
assert!(guard.check_allowed().await.is_ok());
|
||
guard
|
||
.record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||
.await;
|
||
}
|
||
|
||
// 4th should be blocked
|
||
let result = guard.check_allowed().await;
|
||
assert!(result.is_err());
|
||
match result.unwrap_err() {
|
||
CostLimitExceeded::HourlyRate { actions, limit } => {
|
||
assert_eq!(actions, 3);
|
||
assert_eq!(limit, 3);
|
||
}
|
||
other => panic!("Expected HourlyRate, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_daily_spend_tracking() {
|
||
let guard = CostGuard::new(CostGuardConfig::default());
|
||
|
||
assert_eq!(guard.daily_spend().await, Decimal::ZERO);
|
||
|
||
let cost = guard
|
||
.record_llm_call("gpt-4o", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||
.await;
|
||
assert!(cost > Decimal::ZERO);
|
||
assert_eq!(guard.daily_spend().await, cost);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_actions_this_hour() {
|
||
let guard = CostGuard::new(CostGuardConfig::default());
|
||
|
||
assert_eq!(guard.actions_this_hour().await, 0);
|
||
|
||
guard
|
||
.record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||
.await;
|
||
guard
|
||
.record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||
.await;
|
||
|
||
assert_eq!(guard.actions_this_hour().await, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_to_cents() {
|
||
assert_eq!(to_cents(dec!(1.50)), 150);
|
||
assert_eq!(to_cents(dec!(0.01)), 1);
|
||
assert_eq!(to_cents(Decimal::ZERO), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_cost_limit_display() {
|
||
let budget = CostLimitExceeded::DailyBudget {
|
||
spent_cents: 1050,
|
||
limit_cents: 1000,
|
||
};
|
||
assert!(budget.to_string().contains("$10.50"));
|
||
assert!(budget.to_string().contains("$10.00"));
|
||
|
||
let rate = CostLimitExceeded::HourlyRate {
|
||
actions: 101,
|
||
limit: 100,
|
||
};
|
||
assert!(rate.to_string().contains("101 actions"));
|
||
assert!(rate.to_string().contains("100 allowed"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_model_usage_per_model_tracking() {
|
||
let guard = CostGuard::new(CostGuardConfig::default());
|
||
|
||
// Initially empty
|
||
assert!(guard.model_usage().await.is_empty());
|
||
|
||
// Record calls for two different models
|
||
guard
|
||
.record_llm_call("gpt-4o", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||
.await;
|
||
guard
|
||
.record_llm_call("gpt-4o", 2000, 1000, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||
.await;
|
||
guard
|
||
.record_llm_call(
|
||
"claude-3-5-sonnet-20241022",
|
||
500,
|
||
200,
|
||
0,
|
||
0,
|
||
Decimal::ONE,
|
||
Decimal::ONE,
|
||
None,
|
||
)
|
||
.await;
|
||
|
||
let usage = guard.model_usage().await;
|
||
assert_eq!(usage.len(), 2);
|
||
|
||
let gpt = usage.get("gpt-4o").expect("gpt-4o should be tracked");
|
||
assert_eq!(gpt.input_tokens, 3000);
|
||
assert_eq!(gpt.output_tokens, 1500);
|
||
assert!(gpt.cost > Decimal::ZERO);
|
||
|
||
let claude = usage
|
||
.get("claude-3-5-sonnet-20241022")
|
||
.expect("claude should be tracked");
|
||
assert_eq!(claude.input_tokens, 500);
|
||
assert_eq!(claude.output_tokens, 200);
|
||
assert!(claude.cost > Decimal::ZERO);
|
||
|
||
// Costs should differ since models have different pricing
|
||
assert_ne!(gpt.cost, claude.cost);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cache_discount_reduces_cost() {
|
||
let guard = CostGuard::new(CostGuardConfig::default());
|
||
|
||
// Full price: 1000 input + 500 output, no cache
|
||
let full_cost = guard
|
||
.record_llm_call(
|
||
"claude-opus-4-6",
|
||
1000,
|
||
500,
|
||
0,
|
||
0,
|
||
Decimal::ONE,
|
||
Decimal::ONE,
|
||
None,
|
||
)
|
||
.await;
|
||
|
||
let guard2 = CostGuard::new(CostGuardConfig::default());
|
||
|
||
// Same tokens but all input cached (90% discount on input)
|
||
let cached_cost = guard2
|
||
.record_llm_call(
|
||
"claude-opus-4-6",
|
||
1000,
|
||
500,
|
||
1000,
|
||
0,
|
||
dec!(10),
|
||
Decimal::ONE,
|
||
None,
|
||
)
|
||
.await;
|
||
|
||
// Cached cost must be strictly less than full cost
|
||
assert!(
|
||
cached_cost < full_cost,
|
||
"cached_cost ({}) should be less than full_cost ({})",
|
||
cached_cost,
|
||
full_cost
|
||
);
|
||
|
||
// The difference should be exactly 90% of the input cost
|
||
let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap();
|
||
let expected_savings = input_rate * Decimal::from(1000u32) * dec!(9) / dec!(10);
|
||
let actual_savings = full_cost - cached_cost;
|
||
assert_eq!(
|
||
actual_savings, expected_savings,
|
||
"savings should be 90% of input cost for fully-cached request"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cache_write_surcharge_increases_cost() {
|
||
let guard = CostGuard::new(CostGuardConfig::default());
|
||
|
||
// Full price: 1000 input + 500 output, no cache activity
|
||
let full_cost = guard
|
||
.record_llm_call(
|
||
"claude-opus-4-6",
|
||
1000,
|
||
500,
|
||
0,
|
||
0,
|
||
Decimal::ONE,
|
||
Decimal::ONE,
|
||
None,
|
||
)
|
||
.await;
|
||
|
||
let guard2 = CostGuard::new(CostGuardConfig::default());
|
||
|
||
// Same tokens, but all input tokens are cache writes (1.25x surcharge for 5m TTL)
|
||
let short_multiplier = Decimal::new(125, 2); // 1.25
|
||
let write_cost = guard2
|
||
.record_llm_call(
|
||
"claude-opus-4-6",
|
||
1000,
|
||
500,
|
||
0,
|
||
1000,
|
||
Decimal::ONE,
|
||
short_multiplier,
|
||
None,
|
||
)
|
||
.await;
|
||
|
||
// Write cost must be strictly greater than full cost
|
||
assert!(
|
||
write_cost > full_cost,
|
||
"write_cost ({}) should be greater than full_cost ({})",
|
||
write_cost,
|
||
full_cost
|
||
);
|
||
|
||
// The difference should be exactly 25% of the input cost
|
||
let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap();
|
||
let expected_surcharge = input_rate * Decimal::from(1000u32) * dec!(0.25);
|
||
let actual_surcharge = write_cost - full_cost;
|
||
assert_eq!(
|
||
actual_surcharge, expected_surcharge,
|
||
"surcharge should be 25% of input cost for 5m cache writes"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cache_write_surcharge_long_ttl() {
|
||
let guard = CostGuard::new(CostGuardConfig::default());
|
||
|
||
// Full price: 1000 input + 500 output
|
||
let full_cost = guard
|
||
.record_llm_call(
|
||
"claude-opus-4-6",
|
||
1000,
|
||
500,
|
||
0,
|
||
0,
|
||
Decimal::ONE,
|
||
Decimal::ONE,
|
||
None,
|
||
)
|
||
.await;
|
||
|
||
let guard2 = CostGuard::new(CostGuardConfig::default());
|
||
|
||
// All input tokens are cache writes with 2.0x multiplier (1h TTL)
|
||
let long_multiplier = Decimal::TWO;
|
||
let write_cost = guard2
|
||
.record_llm_call(
|
||
"claude-opus-4-6",
|
||
1000,
|
||
500,
|
||
0,
|
||
1000,
|
||
Decimal::ONE,
|
||
long_multiplier,
|
||
None,
|
||
)
|
||
.await;
|
||
|
||
// Write cost > full cost
|
||
assert!(write_cost > full_cost);
|
||
|
||
// Surcharge should be 100% of input cost (2.0x - 1.0x = 1.0x)
|
||
let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap();
|
||
let expected_surcharge = input_rate * Decimal::from(1000u32);
|
||
let actual_surcharge = write_cost - full_cost;
|
||
assert_eq!(
|
||
actual_surcharge, expected_surcharge,
|
||
"surcharge should be 100% of input cost for 1h cache writes"
|
||
);
|
||
}
|
||
|
||
/// Regression test for #657: Instant::now() - Duration panics on Windows
|
||
/// when system uptime is less than the subtracted duration.
|
||
#[tokio::test]
|
||
async fn test_checked_sub_no_panic_on_fresh_guard() {
|
||
// A fresh CostGuard with rate limits should not panic even if
|
||
// checked_sub returns None (simulating short uptime).
|
||
let guard = CostGuard::new(CostGuardConfig {
|
||
max_cost_per_day_cents: None,
|
||
max_actions_per_hour: Some(100),
|
||
});
|
||
|
||
// These must not panic regardless of system uptime
|
||
assert!(guard.check_allowed().await.is_ok());
|
||
assert_eq!(guard.actions_this_hour().await, 0);
|
||
|
||
// Record some actions and verify again
|
||
guard
|
||
.record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||
.await;
|
||
assert!(guard.check_allowed().await.is_ok());
|
||
assert_eq!(guard.actions_this_hour().await, 1);
|
||
}
|
||
|
||
/// Verify that checked_sub itself behaves as expected for the pattern we use.
|
||
#[test]
|
||
fn test_instant_checked_sub_returns_none_for_overflow() {
|
||
// Duration::MAX will always exceed uptime, so checked_sub must return None
|
||
let result = Instant::now().checked_sub(std::time::Duration::MAX);
|
||
assert!(result.is_none());
|
||
}
|
||
}
|