Skip to main content

zeph_core/
cost.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use parking_lot::Mutex;
8
9use thiserror::Error;
10
11#[derive(Debug, Error)]
12#[error(
13    "daily budget exhausted: spent {spent_cents:.2} / {budget_cents:.2} cents \
14     (raise or disable via `[cost] max_daily_cents` in config.toml; 0 = unlimited)"
15)]
16pub struct BudgetExhausted {
17    pub spent_cents: f64,
18    pub budget_cents: f64,
19}
20
21/// Per-provider usage and cost breakdown for the current session/day.
22#[derive(Debug, Clone, Default)]
23pub struct ProviderUsage {
24    pub input_tokens: u64,
25    pub cache_read_tokens: u64,
26    pub cache_write_tokens: u64,
27    pub output_tokens: u64,
28    pub cost_cents: f64,
29    pub request_count: u64,
30    /// Last model seen for this provider (informational only — may change per-call).
31    pub model: String,
32}
33
34#[derive(Debug, Clone)]
35pub struct ModelPricing {
36    pub prompt_cents_per_1k: f64,
37    pub completion_cents_per_1k: f64,
38    /// Cache read (cache hit) price. Claude: 10% of prompt; `OpenAI`: 50%; others: 0%.
39    pub cache_read_cents_per_1k: f64,
40    /// Cache write (cache creation) price. Claude: 125% of prompt; others: 0%.
41    pub cache_write_cents_per_1k: f64,
42}
43
44struct CostState {
45    spent_cents: f64,
46    day: u32,
47    providers: HashMap<String, ProviderUsage>,
48    successful_tasks: u64,
49}
50
51pub struct CostTracker {
52    pricing: HashMap<String, ModelPricing>,
53    state: Arc<Mutex<CostState>>,
54    max_daily_cents: f64,
55    enabled: bool,
56}
57
58fn current_day() -> u32 {
59    use std::time::{SystemTime, UNIX_EPOCH};
60    let secs = SystemTime::now()
61        .duration_since(UNIX_EPOCH)
62        .unwrap_or_default()
63        .as_secs();
64    // UTC day number (days since epoch)
65    u32::try_from(secs / 86_400).unwrap_or(0)
66}
67
68fn claude_pricing(prompt: f64, completion: f64) -> ModelPricing {
69    ModelPricing {
70        prompt_cents_per_1k: prompt,
71        completion_cents_per_1k: completion,
72        // Claude: cache read = 10% of prompt, cache write = 125% of prompt
73        cache_read_cents_per_1k: prompt * 0.1,
74        cache_write_cents_per_1k: prompt * 1.25,
75    }
76}
77
78fn openai_pricing(prompt: f64, completion: f64) -> ModelPricing {
79    ModelPricing {
80        prompt_cents_per_1k: prompt,
81        completion_cents_per_1k: completion,
82        // OpenAI: cache read = 50% of prompt, no cache write charge
83        cache_read_cents_per_1k: prompt * 0.5,
84        cache_write_cents_per_1k: 0.0,
85    }
86}
87
88fn default_pricing() -> HashMap<String, ModelPricing> {
89    let mut m = HashMap::new();
90    // Claude 4 (sonnet-4 / opus-4 base releases)
91    m.insert("claude-sonnet-4-20250514".into(), claude_pricing(0.3, 1.5));
92    m.insert("claude-opus-4-20250514".into(), claude_pricing(1.5, 7.5));
93    // Claude 4.1 Opus ($15/$75 per 1M tokens)
94    m.insert("claude-opus-4-1-20250805".into(), claude_pricing(1.5, 7.5));
95    // Claude 4.5 family
96    m.insert("claude-haiku-4-5-20251001".into(), claude_pricing(0.1, 0.5));
97    m.insert(
98        "claude-sonnet-4-5-20250929".into(),
99        claude_pricing(0.3, 1.5),
100    );
101    m.insert("claude-opus-4-5-20251101".into(), claude_pricing(0.5, 2.5));
102    // Claude 4.6 family
103    m.insert("claude-sonnet-4-6".into(), claude_pricing(0.3, 1.5));
104    m.insert("claude-opus-4-6".into(), claude_pricing(0.5, 2.5));
105    // Claude 5 / Opus 4.8 family
106    m.insert("claude-sonnet-5".into(), claude_pricing(0.3, 1.5));
107    m.insert("claude-opus-4-8".into(), claude_pricing(0.5, 2.5));
108    // OpenAI
109    m.insert("gpt-4o".into(), openai_pricing(0.25, 1.0));
110    m.insert("gpt-4o-mini".into(), openai_pricing(0.015, 0.06));
111    // GPT-5 family ($1.25/$10 per 1M tokens)
112    m.insert("gpt-5".into(), openai_pricing(0.125, 1.0));
113    // GPT-5 mini ($0.25/$2 per 1M tokens)
114    m.insert("gpt-5-mini".into(), openai_pricing(0.025, 0.2));
115    m
116}
117
118fn reset_if_new_day(state: &mut CostState) {
119    let today = current_day();
120    if state.day != today {
121        state.spent_cents = 0.0;
122        state.day = today;
123        state.providers.clear();
124        state.successful_tasks = 0;
125    }
126}
127
128impl CostTracker {
129    #[must_use]
130    pub fn new(enabled: bool, max_daily_cents: f64) -> Self {
131        Self {
132            pricing: default_pricing(),
133            state: Arc::new(Mutex::new(CostState {
134                spent_cents: 0.0,
135                day: current_day(),
136                providers: HashMap::new(),
137                successful_tasks: 0,
138            })),
139            max_daily_cents,
140            enabled,
141        }
142    }
143
144    #[must_use]
145    pub fn with_pricing(mut self, model: &str, pricing: ModelPricing) -> Self {
146        self.pricing.insert(model.to_owned(), pricing);
147        self
148    }
149
150    /// Compute the cost in cents for a single LLM call's token counts, without recording it.
151    ///
152    /// Pure pricing arithmetic, extracted from [`Self::record_usage`] (issue #6549) so the
153    /// per-message usage ledger can compute the exact same cost as the live daily aggregate
154    /// for the same call — single pricing source of truth. Replicates `record_usage`'s
155    /// missing-model fallback: an unknown model prices at zero, silently. `record_usage`
156    /// additionally emits a WARN/debug log for that case (it has `provider_kind`, which this
157    /// pure signature does not); callers that want the log must check pricing themselves.
158    #[must_use]
159    pub fn price_of(
160        &self,
161        model: &str,
162        input_tokens: u64,
163        cache_read_tokens: u64,
164        cache_write_tokens: u64,
165        output_tokens: u64,
166    ) -> f64 {
167        let pricing = self.pricing.get(model).cloned().unwrap_or(ModelPricing {
168            prompt_cents_per_1k: 0.0,
169            completion_cents_per_1k: 0.0,
170            cache_read_cents_per_1k: 0.0,
171            cache_write_cents_per_1k: 0.0,
172        });
173        #[allow(clippy::cast_precision_loss)]
174        {
175            pricing.prompt_cents_per_1k * (input_tokens as f64) / 1000.0
176                + pricing.completion_cents_per_1k * (output_tokens as f64) / 1000.0
177                + pricing.cache_read_cents_per_1k * (cache_read_tokens as f64) / 1000.0
178                + pricing.cache_write_cents_per_1k * (cache_write_tokens as f64) / 1000.0
179        }
180    }
181
182    /// Record token usage for a single LLM call, attributed to `provider_name`.
183    ///
184    /// `provider_kind` must be the value returned by `AnyProvider::provider_kind_str()`:
185    /// `"ollama"` or `"candle"` for local providers, `"cloud"` for API providers.
186    /// Local providers always have zero cost by design; the missing-pricing WARN is
187    /// suppressed for them to avoid log floods on every Ollama call.
188    ///
189    /// Cache token counts are optional (pass 0 when not available). Cost is computed
190    /// using model-specific pricing including cache read/write rates.
191    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
192    pub fn record_usage(
193        &self,
194        provider_name: &str,
195        provider_kind: &str,
196        model: &str,
197        input_tokens: u64,
198        cache_read_tokens: u64,
199        cache_write_tokens: u64,
200        output_tokens: u64,
201    ) {
202        if !self.enabled {
203            return;
204        }
205        if !self.pricing.contains_key(model) {
206            let is_local = matches!(provider_kind, "ollama" | "candle" | "local");
207            if is_local {
208                tracing::debug!(model, "local model; cost recorded as zero");
209            } else {
210                tracing::warn!(
211                    model,
212                    "model not found in pricing table; cost recorded as zero"
213                );
214            }
215        }
216        let cost = self.price_of(
217            model,
218            input_tokens,
219            cache_read_tokens,
220            cache_write_tokens,
221            output_tokens,
222        );
223
224        let mut state = self.state.lock();
225        reset_if_new_day(&mut state);
226        state.spent_cents += cost;
227
228        let entry = state.providers.entry(provider_name.to_owned()).or_default();
229        entry.input_tokens += input_tokens;
230        entry.cache_read_tokens += cache_read_tokens;
231        entry.cache_write_tokens += cache_write_tokens;
232        entry.output_tokens += output_tokens;
233        entry.cost_cents += cost;
234        entry.request_count += 1;
235        model.clone_into(&mut entry.model);
236    }
237
238    /// # Errors
239    ///
240    /// Returns `BudgetExhausted` when daily spend exceeds the configured limit.
241    pub fn check_budget(&self) -> Result<(), BudgetExhausted> {
242        if !self.enabled {
243            return Ok(());
244        }
245        let mut state = self.state.lock();
246        reset_if_new_day(&mut state);
247        if self.max_daily_cents > 0.0 && state.spent_cents >= self.max_daily_cents {
248            return Err(BudgetExhausted {
249                spent_cents: state.spent_cents,
250                budget_cents: self.max_daily_cents,
251            });
252        }
253        Ok(())
254    }
255
256    /// Returns the configured daily budget in cents. Zero means unlimited.
257    #[must_use]
258    pub fn max_daily_cents(&self) -> f64 {
259        self.max_daily_cents
260    }
261
262    #[must_use]
263    pub fn current_spend(&self) -> f64 {
264        let state = self.state.lock();
265        state.spent_cents
266    }
267
268    /// Increment the successful-task counter.
269    ///
270    /// Call after each turn that completes without error and produces a usable agent response.
271    pub fn record_successful_task(&self) {
272        if !self.enabled {
273            return;
274        }
275        let mut state = self.state.lock();
276        reset_if_new_day(&mut state);
277        state.successful_tasks += 1;
278    }
279
280    /// Returns cost-per-successful-task in cents, or `None` if no tasks recorded yet.
281    #[must_use]
282    pub fn cps(&self) -> Option<f64> {
283        let state = self.state.lock();
284        if state.successful_tasks == 0 {
285            return None;
286        }
287        #[allow(clippy::cast_precision_loss)]
288        Some(state.spent_cents / state.successful_tasks as f64)
289    }
290
291    /// Returns total number of successful tasks recorded today.
292    #[must_use]
293    pub fn successful_tasks(&self) -> u64 {
294        self.state.lock().successful_tasks
295    }
296
297    /// Returns per-provider breakdown sorted by cost descending.
298    #[must_use]
299    pub fn provider_breakdown(&self) -> Vec<(String, ProviderUsage)> {
300        let state = self.state.lock();
301        let mut breakdown: Vec<(String, ProviderUsage)> = state
302            .providers
303            .iter()
304            .map(|(k, v)| (k.clone(), v.clone()))
305            .collect();
306        breakdown.sort_by(|a, b| {
307            b.1.cost_cents
308                .partial_cmp(&a.1.cost_cents)
309                .unwrap_or(std::cmp::Ordering::Equal)
310        });
311        breakdown
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn record(tracker: &CostTracker, provider: &str, model: &str, input: u64, output: u64) {
320        tracker.record_usage(provider, "cloud", model, input, 0, 0, output);
321    }
322
323    #[test]
324    fn cost_tracker_records_usage_and_calculates_cost() {
325        let tracker = CostTracker::new(true, 1000.0);
326        record(&tracker, "openai", "gpt-4o", 1000, 1000);
327        // 0.25 + 1.0 = 1.25
328        let spend = tracker.current_spend();
329        assert!((spend - 1.25).abs() < 0.001);
330    }
331
332    #[test]
333    fn check_budget_passes_when_under_limit() {
334        let tracker = CostTracker::new(true, 100.0);
335        record(&tracker, "openai", "gpt-4o-mini", 100, 100);
336        assert!(tracker.check_budget().is_ok());
337    }
338
339    #[test]
340    fn check_budget_fails_when_over_limit() {
341        let tracker = CostTracker::new(true, 0.01);
342        record(&tracker, "claude", "claude-opus-4-20250514", 10000, 10000);
343        assert!(tracker.check_budget().is_err());
344    }
345
346    #[test]
347    fn daily_reset_clears_spending() {
348        let tracker = CostTracker::new(true, 100.0);
349        record(&tracker, "openai", "gpt-4o", 1000, 1000);
350        assert!(tracker.current_spend() > 0.0);
351        // Simulate day change
352        {
353            let mut state = tracker.state.lock();
354            state.day = 0; // force a past day
355        }
356        // check_budget should reset
357        assert!(tracker.check_budget().is_ok());
358        assert!((tracker.current_spend() - 0.0).abs() < 0.001);
359    }
360
361    #[test]
362    fn daily_reset_clears_provider_breakdown() {
363        let tracker = CostTracker::new(true, 100.0);
364        record(&tracker, "openai", "gpt-4o", 1000, 1000);
365        assert!(!tracker.provider_breakdown().is_empty());
366        // Simulate day change
367        {
368            let mut state = tracker.state.lock();
369            state.day = 0;
370        }
371        assert!(tracker.check_budget().is_ok());
372        assert!(tracker.provider_breakdown().is_empty());
373    }
374
375    #[test]
376    fn ollama_zero_cost() {
377        let tracker = CostTracker::new(true, 100.0);
378        record(&tracker, "ollama", "llama3:8b", 10000, 10000);
379        assert!((tracker.current_spend() - 0.0).abs() < 0.001);
380    }
381
382    #[test]
383    fn ollama_unknown_model_no_warn_no_panic() {
384        // Local providers should silently record zero cost for unknown models.
385        let tracker = CostTracker::new(true, 100.0);
386        tracker.record_usage(
387            "local",
388            "ollama",
389            "totally-unknown-ollama-model",
390            5000,
391            0,
392            0,
393            5000,
394        );
395        assert!((tracker.current_spend() - 0.0).abs() < 0.001);
396    }
397
398    #[test]
399    fn cloud_unknown_model_still_records_zero_cost() {
400        // Cloud providers record zero cost for unknown models (WARN emitted separately).
401        let tracker = CostTracker::new(true, 100.0);
402        tracker.record_usage(
403            "openai",
404            "cloud",
405            "totally-unknown-cloud-model",
406            5000,
407            0,
408            0,
409            5000,
410        );
411        assert!((tracker.current_spend() - 0.0).abs() < 0.001);
412    }
413
414    #[test]
415    fn unknown_model_zero_cost() {
416        let tracker = CostTracker::new(true, 100.0);
417        record(&tracker, "unknown", "totally-unknown-model", 5000, 5000);
418        assert!((tracker.current_spend() - 0.0).abs() < 0.001);
419    }
420
421    /// M2 (issue #6549): `price_of` must be the exact pricing source of truth `record_usage`
422    /// itself uses — a per-message ledger row computed via `price_of` must always match the
423    /// cost `record_usage` folded into the daily aggregate for the same call, including the
424    /// missing-model fallback (unpriced/local models price at zero in both places).
425    #[test]
426    fn price_of_matches_record_usage_for_known_model() {
427        let tracker = CostTracker::new(true, 1000.0);
428        let expected = tracker.price_of("gpt-4o", 1000, 0, 0, 1000);
429        record(&tracker, "openai", "gpt-4o", 1000, 1000);
430        assert!(
431            (tracker.current_spend() - expected).abs() < 1e-9,
432            "price_of={expected} current_spend={}",
433            tracker.current_spend()
434        );
435    }
436
437    #[test]
438    fn price_of_zero_for_unpriced_model_matches_record_usage_fallback() {
439        let tracker = CostTracker::new(true, 1000.0);
440        let price = tracker.price_of("totally-unknown-model", 5000, 0, 0, 5000);
441        assert!(
442            (price - 0.0).abs() < 1e-9,
443            "unpriced model must price at zero"
444        );
445
446        record(&tracker, "unknown", "totally-unknown-model", 5000, 5000);
447        assert!((tracker.current_spend() - price).abs() < 1e-9);
448    }
449
450    #[test]
451    fn price_of_includes_cache_tokens() {
452        let tracker = CostTracker::new(true, 1000.0);
453        // Mirrors cache_write_cost_included_in_total: claude-opus-4-6 cache_write = 125% of
454        // prompt price (0.5 cents/1k) -> 1000 tokens = 0.625 cents.
455        let price = tracker.price_of("claude-opus-4-6", 0, 0, 1000, 0);
456        assert!((price - 0.625).abs() < 0.001);
457    }
458
459    #[test]
460    fn known_claude_model_has_nonzero_cost() {
461        let tracker = CostTracker::new(true, 1000.0);
462        record(&tracker, "claude", "claude-haiku-4-5-20251001", 1000, 1000);
463        assert!(tracker.current_spend() > 0.0);
464    }
465
466    #[test]
467    fn gpt5_pricing_is_correct() {
468        let tracker = CostTracker::new(true, 1000.0);
469        record(&tracker, "openai", "gpt-5", 1000, 1000);
470        // 0.125 + 1.0 = 1.125
471        let spend = tracker.current_spend();
472        assert!((spend - 1.125).abs() < 0.001);
473    }
474
475    #[test]
476    fn gpt5_mini_pricing_is_correct() {
477        let tracker = CostTracker::new(true, 1000.0);
478        record(&tracker, "openai", "gpt-5-mini", 1000, 1000);
479        // 0.025 + 0.2 = 0.225
480        let spend = tracker.current_spend();
481        assert!((spend - 0.225).abs() < 0.001);
482    }
483
484    #[test]
485    fn disabled_tracker_always_passes() {
486        let tracker = CostTracker::new(false, 0.0);
487        record(
488            &tracker,
489            "claude",
490            "claude-opus-4-20250514",
491            1_000_000,
492            1_000_000,
493        );
494        assert!(tracker.check_budget().is_ok());
495        assert!((tracker.current_spend() - 0.0).abs() < 0.001);
496    }
497
498    #[test]
499    fn check_budget_unlimited_when_max_daily_cents_is_zero() {
500        let tracker = CostTracker::new(true, 0.0);
501        record(
502            &tracker,
503            "claude",
504            "claude-opus-4-20250514",
505            100_000,
506            100_000,
507        );
508        assert!(tracker.check_budget().is_ok());
509    }
510
511    #[test]
512    fn per_provider_accumulation() {
513        let tracker = CostTracker::new(true, 1000.0);
514        record(&tracker, "claude", "claude-haiku-4-5-20251001", 1000, 500);
515        record(&tracker, "openai", "gpt-4o", 2000, 1000);
516        record(&tracker, "claude", "claude-haiku-4-5-20251001", 500, 200);
517
518        let breakdown = tracker.provider_breakdown();
519        assert_eq!(breakdown.len(), 2);
520
521        let claude = breakdown.iter().find(|(n, _)| n == "claude").unwrap();
522        assert_eq!(claude.1.request_count, 2);
523        assert_eq!(claude.1.input_tokens, 1500);
524        assert_eq!(claude.1.output_tokens, 700);
525
526        let openai = breakdown.iter().find(|(n, _)| n == "openai").unwrap();
527        assert_eq!(openai.1.request_count, 1);
528        assert_eq!(openai.1.input_tokens, 2000);
529    }
530
531    #[test]
532    fn provider_breakdown_sorted_by_cost_desc() {
533        let tracker = CostTracker::new(true, 1000.0);
534        // gpt-4o: cheap; claude-opus: expensive
535        record(&tracker, "cheap", "gpt-4o-mini", 100, 100);
536        record(&tracker, "expensive", "claude-opus-4-20250514", 10000, 5000);
537
538        let breakdown = tracker.provider_breakdown();
539        assert_eq!(breakdown[0].0, "expensive");
540    }
541
542    #[test]
543    fn cache_tokens_included_in_cost() {
544        let tracker = CostTracker::new(true, 1000.0);
545        // claude-haiku prompt=0.1, cache_read=0.01 per 1k
546        // 1000 cache_read tokens = 0.01 cents; 0 input/output for isolation
547        tracker.record_usage(
548            "claude",
549            "cloud",
550            "claude-haiku-4-5-20251001",
551            0,
552            1000,
553            0,
554            0,
555        );
556        let spend = tracker.current_spend();
557        assert!(spend > 0.0, "cache read should contribute to cost");
558    }
559
560    #[test]
561    fn cache_write_cost_included_in_total() {
562        let tracker = CostTracker::new(true, 1000.0);
563        // Claude pricing: cache_write = 125% of prompt price
564        // claude-opus-4-6: prompt = 0.5 cents/1k
565        // 1000 cache_write tokens = (0.5 * 1.25 * 1000) / 1000 = 0.625 cents
566        tracker.record_usage("claude-provider", "cloud", "claude-opus-4-6", 0, 0, 1000, 0);
567        let cost = tracker.current_spend();
568        assert!((cost - 0.625).abs() < 0.001);
569    }
570
571    #[test]
572    fn claude_5_generation_pricing_matches_retained_4_6_rows() {
573        // The new claude-sonnet-5/claude-opus-4-8 rows must price identically to the
574        // retained claude-sonnet-4-6/claude-opus-4-6 rows (#5889: same rates, new IDs).
575        let sonnet_5 = CostTracker::new(true, 1000.0);
576        sonnet_5.record_usage(
577            "claude-provider",
578            "cloud",
579            "claude-sonnet-5",
580            1000,
581            0,
582            0,
583            1000,
584        );
585        let sonnet_4_6 = CostTracker::new(true, 1000.0);
586        sonnet_4_6.record_usage(
587            "claude-provider",
588            "cloud",
589            "claude-sonnet-4-6",
590            1000,
591            0,
592            0,
593            1000,
594        );
595        assert!((sonnet_5.current_spend() - sonnet_4_6.current_spend()).abs() < f64::EPSILON);
596        assert!(
597            sonnet_5.current_spend() > 0.0,
598            "must not fall back to zero-cost"
599        );
600
601        let opus_4_8 = CostTracker::new(true, 1000.0);
602        opus_4_8.record_usage(
603            "claude-provider",
604            "cloud",
605            "claude-opus-4-8",
606            1000,
607            0,
608            0,
609            1000,
610        );
611        let opus_4_6 = CostTracker::new(true, 1000.0);
612        opus_4_6.record_usage(
613            "claude-provider",
614            "cloud",
615            "claude-opus-4-6",
616            1000,
617            0,
618            0,
619            1000,
620        );
621        assert!((opus_4_8.current_spend() - opus_4_6.current_spend()).abs() < f64::EPSILON);
622        assert!(
623            opus_4_8.current_spend() > 0.0,
624            "must not fall back to zero-cost"
625        );
626    }
627
628    #[test]
629    fn provider_breakdown_empty_when_disabled() {
630        let tracker = CostTracker::new(false, 100.0);
631        tracker.record_usage(
632            "claude",
633            "cloud",
634            "claude-haiku-4-5-20251001",
635            1000,
636            0,
637            0,
638            1000,
639        );
640        assert!(tracker.provider_breakdown().is_empty());
641    }
642
643    #[test]
644    fn cps_none_when_no_tasks() {
645        let tracker = CostTracker::new(true, 100.0);
646        assert!(tracker.cps().is_none());
647        assert_eq!(tracker.successful_tasks(), 0);
648    }
649
650    #[test]
651    fn cps_calculated_correctly() {
652        let tracker = CostTracker::new(true, 100.0);
653        // 0.25 (input) + 1.0 (output) = 1.25 cents
654        record(&tracker, "openai", "gpt-4o", 1000, 1000);
655        tracker.record_successful_task();
656        tracker.record_successful_task();
657        assert_eq!(tracker.successful_tasks(), 2);
658        let cps = tracker.cps().expect("cps should be Some after tasks");
659        // 1.25 / 2 = 0.625
660        assert!((cps - 0.625).abs() < 0.001, "cps={cps}");
661    }
662
663    #[test]
664    fn cps_resets_on_new_day() {
665        let tracker = CostTracker::new(true, 100.0);
666        record(&tracker, "openai", "gpt-4o", 1000, 1000);
667        tracker.record_successful_task();
668        assert_eq!(tracker.successful_tasks(), 1);
669        // Force day change
670        {
671            let mut state = tracker.state.lock();
672            state.day = 0;
673        }
674        // Any state-touching call triggers reset
675        assert!(tracker.check_budget().is_ok());
676        assert_eq!(tracker.successful_tasks(), 0);
677        assert!(tracker.cps().is_none());
678    }
679}