1use std::collections::BTreeMap;
4
5use chrono::{DateTime, Datelike, Duration, Local, LocalResult, NaiveDate, TimeZone, Utc};
6use costroid_focus::{
7 to_csv_string, to_json_string, FocusAccessPath, FocusError, FocusRecord, TokenType,
8 UnpricedUsage, DEFAULT_BILLING_CURRENCY, PRICING_CATEGORY_STANDARD,
9 PRICING_STATUS_MISSING_PRICE, PRICING_UNIT_TOKENS,
10};
11use costroid_providers::{
12 default_providers, read_cursor_config, AccessPath, CursorConfig, HostEnv, LimitKind,
13 LimitWindow, Provider, ProviderId, UsageEvent,
14};
15use rust_decimal::prelude::ToPrimitive;
16use rust_decimal::Decimal;
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20mod bench;
21pub use bench::{
22 bench_view, BenchDisclaimer, BenchFrontier, BenchView, FrontierPoint, FrontierStanding,
23 OverlayAppearance, OverlayModel, RepricingDelta, RepricingStatus,
24};
25
26const PRICING_STATUS_PRICED: &str = "priced";
27const PRICING_STATUS_UNKNOWN_MODEL: &str = "unknown_model";
28const PRICING_SCHEMA_VERSION: &str = "1";
29const PRICING_UNIT_1M_TOKENS: &str = "1M_tokens";
30const UNKNOWN_GROUP_VALUE: &str = "unknown";
31const TOTAL_GROUP_VALUE: &str = "total";
32
33pub fn bundled_pricing_json() -> &'static str {
34 include_str!("../pricing/pricing.v1.json")
37}
38
39pub fn bundled_pricing_value() -> Result<serde_json::Value, CoreError> {
40 serde_json::from_str(bundled_pricing_json()).map_err(CoreError::from)
41}
42
43pub fn collect_local_snapshot(env: &HostEnv) -> Result<EngineSnapshot, CoreError> {
44 collect_snapshot_from_providers(env, default_providers(), Utc::now())
45}
46
47pub fn local_snapshot(env: &HostEnv) -> Snapshot {
49 match collect_local_snapshot(env) {
50 Ok(snapshot) => snapshot,
51 Err(_) => EngineSnapshot::empty(Utc::now()),
52 }
53}
54
55pub fn focus_records_from_usage(events: &[UsageEvent]) -> Result<Vec<FocusRecord>, CoreError> {
56 let pricing = PricingCatalog::bundled()?;
57 let mut records = Vec::new();
58 for event in events {
59 push_meter_records(event, &pricing, &mut records)?;
60 }
61 Ok(records)
62}
63
64pub fn focus_records_from_local_logs(env: &HostEnv) -> Result<Vec<FocusRecord>, CoreError> {
65 Ok(collect_local_snapshot(env)?.focus_rows)
66}
67
68pub fn export_focus_json(rows: Vec<FocusRecord>) -> Result<String, CoreError> {
69 to_json_string(rows).map_err(CoreError::from)
70}
71
72pub fn export_focus_csv(rows: &[FocusRecord]) -> Result<String, CoreError> {
73 to_csv_string(rows).map_err(CoreError::from)
74}
75
76pub fn now_summary(snapshot: &EngineSnapshot, options: NowOptions) -> NowSummary {
77 let cost_period = period_range_for(options.cost_period, snapshot.generated_at);
78 let current_costs = summarize_rows(
79 snapshot
80 .focus_rows
81 .iter()
82 .filter(|row| cost_period.contains(row.charge_period_start)),
83 options.group_by,
84 );
85 let limits = snapshot
86 .limit_windows
87 .iter()
88 .map(|limit| limit_summary(limit, snapshot.generated_at))
89 .collect();
90
91 NowSummary {
92 generated_at: snapshot.generated_at,
93 cost_period,
94 group_by: options.group_by,
95 limits,
96 current_costs,
97 providers: snapshot.providers.clone(),
98 }
99}
100
101pub fn trends_summary(snapshot: &EngineSnapshot, options: TrendsOptions) -> TrendsSummary {
102 let mut buckets = BTreeMap::<(PeriodRange, CostLane, GroupKey), AggregateTotals>::new();
103
104 for row in &snapshot.focus_rows {
105 let range = period_range_for(options.period, row.charge_period_start);
106 let lane = CostLane::from_access_path(&row.x_access_path);
107 let group = group_key(row, options.group_by);
108 buckets
109 .entry((range, lane, group))
110 .or_default()
111 .add_row(row);
112 }
113
114 let buckets = buckets
115 .into_iter()
116 .map(|((period, lane, group), totals)| TrendBucket {
117 period,
118 group,
119 lane,
120 totals,
121 })
122 .collect();
123
124 TrendsSummary {
125 generated_at: snapshot.generated_at,
126 period: options.period,
127 group_by: options.group_by,
128 buckets,
129 totals: summarize_rows(snapshot.focus_rows.iter(), options.group_by),
130 providers: snapshot.providers.clone(),
131 }
132}
133
134pub fn period_range_for(period: Period, anchor: DateTime<Utc>) -> PeriodRange {
135 let local_anchor = anchor.with_timezone(&Local);
136 let local_start = start_of_period_local(period, local_anchor);
137 let local_end = add_period_local(period, local_start);
138
139 PeriodRange {
140 start: local_start.with_timezone(&Utc),
141 end: local_end.with_timezone(&Utc),
142 }
143}
144
145fn collect_snapshot_from_providers(
146 env: &HostEnv,
147 providers: Vec<Box<dyn Provider>>,
148 generated_at: DateTime<Utc>,
149) -> Result<EngineSnapshot, CoreError> {
150 let mut snapshot = EngineSnapshot::empty(generated_at);
151
152 for provider in providers {
153 let provider_id = provider.id();
154 let location = match provider.discover(env) {
155 Ok(Some(location)) => location,
156 Ok(None) => {
157 snapshot.providers.push(ProviderStatus {
158 provider: provider_id,
159 status: ProviderStatusKind::Missing,
160 files: 0,
161 usage_events: 0,
162 focus_rows: 0,
163 limit_windows: 0,
164 message: Some("no local data found".to_string()),
165 });
166 continue;
167 }
168 Err(err) => {
169 snapshot.providers.push(ProviderStatus {
170 provider: provider_id,
171 status: ProviderStatusKind::Error,
172 files: 0,
173 usage_events: 0,
174 focus_rows: 0,
175 limit_windows: 0,
176 message: Some(err.to_string()),
177 });
178 continue;
179 }
180 };
181
182 let files = location.files.len();
183 let mut messages = Vec::new();
184 let mut usage_events = Vec::new();
185 let mut limit_windows = Vec::new();
186 let mut usage_ok = true;
187 let mut limits_ok = true;
188
189 match provider.parse_usage(&location) {
190 Ok(events) => usage_events = events,
191 Err(err) => {
192 usage_ok = false;
193 messages.push(err.to_string());
194 }
195 }
196
197 match provider.parse_limits(&location) {
198 Ok(limits) => limit_windows = limits,
199 Err(err) => {
200 limits_ok = false;
201 messages.push(err.to_string());
202 }
203 }
204
205 let focus_rows = focus_records_from_usage(&usage_events)?;
206 let (status, message) = if provider_id == ProviderId::Cursor {
212 (
213 ProviderStatusKind::Detected,
214 Some(cursor_detected_message(&read_cursor_config(&location))),
215 )
216 } else {
217 let status = provider_status_kind(usage_ok, limits_ok);
218 let message = if messages.is_empty() {
219 None
220 } else {
221 Some(messages.join("; "))
222 };
223 (status, message)
224 };
225
226 snapshot.providers.push(ProviderStatus {
227 provider: provider_id,
228 status,
229 files,
230 usage_events: usage_events.len(),
231 focus_rows: focus_rows.len(),
232 limit_windows: limit_windows.len(),
233 message,
234 });
235 snapshot.usage_events.append(&mut usage_events);
236 snapshot.limit_windows.append(&mut limit_windows);
237 snapshot.focus_rows.extend(focus_rows);
238 }
239
240 Ok(snapshot)
241}
242
243fn provider_status_kind(usage_ok: bool, limits_ok: bool) -> ProviderStatusKind {
244 match (usage_ok, limits_ok) {
245 (true, true) => ProviderStatusKind::Available,
246 (false, false) => ProviderStatusKind::Error,
247 (true, false) | (false, true) => ProviderStatusKind::Partial,
248 }
249}
250
251fn cursor_detected_message(config: &CursorConfig) -> String {
256 let model = match (&config.display_name, &config.model_id) {
257 (Some(display), Some(id)) => format!("model {display} ({id})"),
258 (Some(display), None) => format!("model {display}"),
259 (None, Some(id)) => format!("model {id}"),
260 (None, None) => "model unknown".to_string(),
261 };
262 let login = if config.logged_in {
263 "logged in"
264 } else {
265 "login unknown"
266 };
267 format!(
268 "BETA — {model}, {login}; usage unavailable — live (Phase 2); \
269 quota unavailable — live (Phase 2)"
270 )
271}
272
273fn push_meter_records(
274 event: &UsageEvent,
275 pricing: &PricingCatalog,
276 records: &mut Vec<FocusRecord>,
277) -> Result<(), CoreError> {
278 let meters = [
279 (TokenType::Input, event.input_tokens),
280 (TokenType::Output, event.output_tokens),
281 (TokenType::CacheRead, event.cache_read_tokens),
282 (TokenType::CacheWrite, event.cache_write_tokens),
283 ];
284 let resolved = pricing.resolve_key(&event.model);
290 let model = resolved.and_then(|key| pricing.model(key));
291
292 for (token_type, token_count) in meters {
293 if token_count == 0 {
294 continue;
295 }
296 let mut row = FocusRecord::unpriced_usage(UnpricedUsage {
297 timestamp: event.timestamp,
298 tool: event.tool.to_string(),
299 model: event.model.clone(),
300 token_type,
301 token_count,
302 project: event.project.clone(),
303 access_path: focus_access_path(event.access_path),
304 service_name: model
305 .map(|model| model.service_name.clone())
306 .unwrap_or_else(|| service_name(event.tool).to_string()),
307 service_provider_name: vendor_name(event.tool).to_string(),
308 host_provider_name: vendor_name(event.tool).to_string(),
309 invoice_issuer_name: vendor_name(event.tool).to_string(),
310 billing_currency: model
311 .map(|_| pricing.currency.clone())
312 .unwrap_or_else(|| DEFAULT_BILLING_CURRENCY.to_string()),
313 })?;
314
315 match resolved.and_then(|key| pricing.rate(key, token_type)) {
316 Some(rate) => apply_pricing(&mut row, rate, pricing),
317 None if model.is_none() => {
318 row.x_pricing_status = PRICING_STATUS_UNKNOWN_MODEL.to_string();
319 }
320 None => {}
321 }
322
323 records.push(row);
324 }
325
326 Ok(())
327}
328
329fn apply_pricing(row: &mut FocusRecord, rate: &CatalogRate, pricing: &PricingCatalog) {
330 let per_token = rate.price / Decimal::from(1_000_000_u64);
336 let quantity = row.x_consumed_tokens;
337 let cost = per_token * quantity;
338 row.billed_cost = cost;
339 row.effective_cost = cost;
340 row.list_cost = cost;
341 row.contracted_cost = cost;
342 row.consumed_quantity = Some(quantity);
344 row.pricing_quantity = Some(quantity);
345 row.pricing_category = Some(PRICING_CATEGORY_STANDARD.to_string());
346 row.pricing_unit = Some(PRICING_UNIT_TOKENS.to_string());
347 row.sku_price_id = Some(pricing.sku_price_id(rate));
348 row.list_unit_price = Some(per_token);
349 row.contracted_unit_price = Some(per_token);
350 row.pricing_currency_effective_cost = cost;
353 row.pricing_currency_list_unit_price = Some(per_token);
354 row.pricing_currency_contracted_unit_price = Some(per_token);
355 row.x_pricing_status = PRICING_STATUS_PRICED.to_string();
356}
357
358fn strip_date_suffix(model: &str) -> Option<&str> {
368 strip_dashed_date(model).or_else(|| strip_compact_date(model))
369}
370
371fn strip_dashed_date(model: &str) -> Option<&str> {
373 let head = model.get(..model.len().checked_sub(11)?)?;
374 let tail = model.get(head.len()..)?.as_bytes();
375 let ok = tail[0] == b'-'
376 && tail[1..5].iter().all(u8::is_ascii_digit)
377 && tail[5] == b'-'
378 && tail[6..8].iter().all(u8::is_ascii_digit)
379 && tail[8] == b'-'
380 && tail[9..11].iter().all(u8::is_ascii_digit);
381 (ok && !head.is_empty()).then_some(head)
382}
383
384fn strip_compact_date(model: &str) -> Option<&str> {
386 let head = model.get(..model.len().checked_sub(9)?)?;
387 let tail = model.get(head.len()..)?.as_bytes();
388 let ok = tail[0] == b'-' && tail[1..].iter().all(u8::is_ascii_digit);
389 (ok && !head.is_empty()).then_some(head)
390}
391
392#[derive(Debug, Deserialize)]
393struct PricingTable {
394 schema_version: String,
395 as_of: String,
396 currency: String,
397 #[serde(default)]
398 models: Vec<PricingModel>,
399}
400
401#[derive(Debug, Deserialize)]
402struct PricingModel {
403 provider: String,
404 model: String,
405 service_name: String,
406 #[serde(default)]
407 rates: Vec<PricingRate>,
408}
409
410#[derive(Debug, Deserialize)]
411struct PricingRate {
412 meter: String,
413 unit: String,
414 price: Decimal,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq)]
418struct PricingModelInfo {
419 service_name: String,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
423struct CatalogRate {
424 provider: String,
425 model: String,
426 meter: String,
427 unit: String,
428 price: Decimal,
429}
430
431#[derive(Debug, Clone, PartialEq, Eq)]
432struct PricingCatalog {
433 as_of: String,
434 currency: String,
435 models: BTreeMap<String, PricingModelInfo>,
436 rates: BTreeMap<(String, String), CatalogRate>,
437}
438
439impl PricingCatalog {
440 fn bundled() -> Result<Self, CoreError> {
441 Self::from_json(bundled_pricing_json())
442 }
443
444 fn from_json(value: &str) -> Result<Self, CoreError> {
445 let table = serde_json::from_str::<PricingTable>(value)?;
446 Self::from_table(table)
447 }
448
449 fn model(&self, model: &str) -> Option<&PricingModelInfo> {
450 self.models.get(model)
451 }
452
453 fn rate(&self, model: &str, token_type: TokenType) -> Option<&CatalogRate> {
454 self.rates
455 .get(&(model.to_string(), token_type.as_str().to_string()))
456 }
457
458 fn meter_price(&self, model: &str, meter: &str) -> Option<Decimal> {
462 self.rates
463 .get(&(model.to_string(), meter.to_string()))
464 .map(|rate| rate.price)
465 }
466
467 fn resolve_key<'a>(&'a self, model: &'a str) -> Option<&'a str> {
477 if self.models.contains_key(model) {
478 return Some(model);
479 }
480 let base = strip_date_suffix(model)?;
481 if self.models.contains_key(base) {
482 return Some(base);
483 }
484 None
485 }
486
487 fn sku_price_id(&self, rate: &CatalogRate) -> String {
488 format!(
492 "{}:{}:{}:{}:{}",
493 rate.provider, rate.model, rate.meter, PRICING_UNIT_TOKENS, self.as_of
494 )
495 }
496
497 fn from_table(table: PricingTable) -> Result<Self, CoreError> {
498 if table.schema_version != PRICING_SCHEMA_VERSION {
499 return Err(CoreError::PricingValidation(format!(
500 "unsupported schema_version {}; expected {}",
501 table.schema_version, PRICING_SCHEMA_VERSION
502 )));
503 }
504 if table.currency != DEFAULT_BILLING_CURRENCY {
505 return Err(CoreError::PricingValidation(format!(
506 "unsupported currency {}; expected {}",
507 table.currency, DEFAULT_BILLING_CURRENCY
508 )));
509 }
510
511 let mut catalog = Self {
512 as_of: table.as_of,
513 currency: table.currency,
514 models: BTreeMap::new(),
515 rates: BTreeMap::new(),
516 };
517
518 for model in table.models {
519 if catalog
520 .models
521 .insert(
522 model.model.clone(),
523 PricingModelInfo {
524 service_name: model.service_name.clone(),
525 },
526 )
527 .is_some()
528 {
529 return Err(CoreError::PricingValidation(format!(
530 "duplicate pricing model {}",
531 model.model
532 )));
533 }
534
535 for rate in model.rates {
536 if rate.unit != PRICING_UNIT_1M_TOKENS {
537 return Err(CoreError::PricingValidation(format!(
538 "unsupported pricing unit {} for {}:{}",
539 rate.unit, model.model, rate.meter
540 )));
541 }
542 if !is_supported_meter(&rate.meter) {
543 return Err(CoreError::PricingValidation(format!(
544 "unsupported pricing meter {} for {}",
545 rate.meter, model.model
546 )));
547 }
548
549 let key = (model.model.clone(), rate.meter.clone());
550 if catalog.rates.contains_key(&key) {
551 return Err(CoreError::PricingValidation(format!(
552 "duplicate pricing rate {}:{}",
553 model.model, rate.meter
554 )));
555 }
556
557 catalog.rates.insert(
558 key,
559 CatalogRate {
560 provider: model.provider.clone(),
561 model: model.model.clone(),
562 meter: rate.meter,
563 unit: rate.unit,
564 price: rate.price,
565 },
566 );
567 }
568 }
569
570 Ok(catalog)
571 }
572}
573
574fn is_supported_meter(value: &str) -> bool {
575 matches!(value, "input" | "output" | "cache_read" | "cache_write")
576}
577
578fn focus_access_path(access_path: AccessPath) -> FocusAccessPath {
579 match access_path {
580 AccessPath::Api => FocusAccessPath::Api,
581 AccessPath::Subscription => FocusAccessPath::Subscription,
582 AccessPath::Unknown => FocusAccessPath::Unknown,
583 }
584}
585
586fn service_name(provider: ProviderId) -> &'static str {
587 match provider {
588 ProviderId::ClaudeCode => "Claude Code",
589 ProviderId::Codex => "Codex",
590 ProviderId::Cursor => "Cursor",
591 }
592}
593
594fn vendor_name(provider: ProviderId) -> &'static str {
595 match provider {
596 ProviderId::ClaudeCode => "Anthropic",
597 ProviderId::Codex => "OpenAI",
598 ProviderId::Cursor => "Anysphere",
599 }
600}
601
602fn summarize_rows<'a, I>(rows: I, group_by: GroupBy) -> Vec<CostLaneSummary>
603where
604 I: IntoIterator<Item = &'a FocusRecord>,
605{
606 let mut summaries = BTreeMap::<(CostLane, GroupKey), AggregateTotals>::new();
607 for row in rows {
608 let lane = CostLane::from_access_path(&row.x_access_path);
609 let group = group_key(row, group_by);
610 summaries.entry((lane, group)).or_default().add_row(row);
611 }
612
613 summaries
614 .into_iter()
615 .map(|((lane, group), totals)| CostLaneSummary {
616 group,
617 lane,
618 totals,
619 })
620 .collect()
621}
622
623fn group_key(row: &FocusRecord, group_by: GroupBy) -> GroupKey {
624 let value = match group_by {
625 GroupBy::Model => non_empty_value(&row.x_model),
626 GroupBy::App => row
627 .x_project
628 .as_deref()
629 .map(non_empty_value)
630 .unwrap_or_else(|| UNKNOWN_GROUP_VALUE.to_string()),
631 GroupBy::Total => TOTAL_GROUP_VALUE.to_string(),
632 };
633
634 GroupKey {
635 kind: group_by,
636 value,
637 }
638}
639
640fn non_empty_value(value: &str) -> String {
641 let trimmed = value.trim();
642 if trimmed.is_empty() {
643 UNKNOWN_GROUP_VALUE.to_string()
644 } else {
645 trimmed.to_string()
646 }
647}
648
649fn limit_summary(limit: &LimitWindow, generated_at: DateTime<Utc>) -> LimitSummary {
650 LimitSummary {
651 tool: limit.tool,
652 plan: limit.plan.clone(),
653 kind: limit.kind,
654 label: limit.label.clone(),
655 availability: limit_availability(limit, generated_at),
656 }
657}
658
659fn limit_availability(limit: &LimitWindow, generated_at: DateTime<Utc>) -> LimitAvailability {
660 let reset_in_seconds = limit
661 .resets_at
662 .map(|resets_at| clamp_reset_seconds(resets_at, generated_at));
663 let is_stale = limit
664 .resets_at
665 .map(|resets_at| resets_at < generated_at)
666 .unwrap_or(false);
667
668 match (limit.used_fraction, limit.resets_at, is_stale) {
669 (Some(used_fraction), Some(resets_at), false) => LimitAvailability::Available {
670 used_fraction,
671 resets_at,
672 reset_in_seconds: reset_in_seconds.unwrap_or(0),
673 },
674 (None, None, _) => LimitAvailability::Unavailable {
675 reason: limit
676 .label
677 .clone()
678 .unwrap_or_else(|| "limit data unavailable from local logs".to_string()),
679 },
680 _ => LimitAvailability::Partial {
681 used_fraction: limit.used_fraction,
682 resets_at: limit.resets_at,
683 reset_in_seconds,
684 reason: if is_stale {
685 "data may be stale".to_string()
686 } else {
687 "limit data incomplete".to_string()
688 },
689 },
690 }
691}
692
693fn clamp_reset_seconds(resets_at: DateTime<Utc>, generated_at: DateTime<Utc>) -> i64 {
694 resets_at
695 .signed_duration_since(generated_at)
696 .num_seconds()
697 .max(0)
698}
699
700fn start_of_period_local(period: Period, anchor: DateTime<Local>) -> DateTime<Local> {
701 match period {
702 Period::Day => local_start_of_day(anchor.date_naive(), anchor),
703 Period::Week => {
704 let days_from_monday = i64::from(anchor.weekday().num_days_from_monday());
705 let date = match anchor
706 .date_naive()
707 .checked_sub_signed(Duration::days(days_from_monday))
708 {
709 Some(value) => value,
710 None => anchor.date_naive(),
711 };
712 local_start_of_day(date, anchor)
713 }
714 Period::Month => local_start_for_ymd(anchor.year(), anchor.month(), 1, anchor),
715 Period::Year => local_start_for_ymd(anchor.year(), 1, 1, anchor),
716 }
717}
718
719fn add_period_local(period: Period, start: DateTime<Local>) -> DateTime<Local> {
720 match period {
721 Period::Day => add_days_local(start, 1),
722 Period::Week => add_days_local(start, 7),
723 Period::Month => {
724 let (year, month) = if start.month() == 12 {
725 match start.year().checked_add(1) {
726 Some(year) => (year, 1),
727 None => return add_days_local(start, 31),
728 }
729 } else {
730 (start.year(), start.month() + 1)
731 };
732 local_start_for_ymd(year, month, 1, add_days_local(start, 31))
733 }
734 Period::Year => match start.year().checked_add(1) {
735 Some(year) => local_start_for_ymd(year, 1, 1, add_days_local(start, 366)),
736 None => add_days_local(start, 366),
737 },
738 }
739}
740
741fn add_days_local(start: DateTime<Local>, days: i64) -> DateTime<Local> {
742 let fallback = start + Duration::days(days);
743 match start.date_naive().checked_add_signed(Duration::days(days)) {
744 Some(date) => local_start_of_day(date, fallback),
745 None => fallback,
746 }
747}
748
749fn local_start_for_ymd(
750 year: i32,
751 month: u32,
752 day: u32,
753 fallback: DateTime<Local>,
754) -> DateTime<Local> {
755 match NaiveDate::from_ymd_opt(year, month, day) {
756 Some(date) => local_start_of_day(date, fallback),
757 None => fallback,
758 }
759}
760
761fn local_start_of_day(date: NaiveDate, fallback: DateTime<Local>) -> DateTime<Local> {
762 let midnight = match date.and_hms_opt(0, 0, 0) {
763 Some(value) => value,
764 None => return fallback,
765 };
766
767 match Local.from_local_datetime(&midnight) {
768 LocalResult::Single(value) => value,
769 LocalResult::Ambiguous(first, _) => first,
770 LocalResult::None => first_valid_local_after(midnight, fallback),
771 }
772}
773
774fn first_valid_local_after(
775 start: chrono::NaiveDateTime,
776 fallback: DateTime<Local>,
777) -> DateTime<Local> {
778 for minutes_after in 1_i64..=180 {
779 let candidate = match start.checked_add_signed(Duration::minutes(minutes_after)) {
780 Some(value) => value,
781 None => return fallback,
782 };
783 match Local.from_local_datetime(&candidate) {
784 LocalResult::Single(value) => return value,
785 LocalResult::Ambiguous(first, _) => return first,
786 LocalResult::None => {}
787 }
788 }
789 fallback
790}
791
792pub type Snapshot = EngineSnapshot;
793
794#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
795pub struct EngineSnapshot {
796 pub generated_at: DateTime<Utc>,
797 pub usage_events: Vec<UsageEvent>,
798 pub focus_rows: Vec<FocusRecord>,
799 pub limit_windows: Vec<LimitWindow>,
800 pub providers: Vec<ProviderStatus>,
801}
802
803impl EngineSnapshot {
804 fn empty(generated_at: DateTime<Utc>) -> Self {
805 Self {
806 generated_at,
807 usage_events: Vec::new(),
808 focus_rows: Vec::new(),
809 limit_windows: Vec::new(),
810 providers: Vec::new(),
811 }
812 }
813}
814
815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
816pub struct ProviderStatus {
817 pub provider: ProviderId,
818 pub status: ProviderStatusKind,
819 pub files: usize,
820 pub usage_events: usize,
821 pub focus_rows: usize,
822 pub limit_windows: usize,
823 pub message: Option<String>,
824}
825
826#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
827#[serde(rename_all = "snake_case")]
828pub enum ProviderStatusKind {
829 Available,
830 Detected,
835 Partial,
836 Missing,
837 Error,
838}
839
840#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
841#[serde(rename_all = "kebab-case")]
842pub enum Period {
843 Day,
844 Week,
845 Month,
846 Year,
847}
848
849#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
850#[serde(rename_all = "kebab-case")]
851pub enum GroupBy {
852 Model,
853 App,
854 Total,
855}
856
857#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
858#[serde(rename_all = "snake_case")]
859pub enum CostLane {
860 Api,
861 SubscriptionEstimate,
862 UnknownAccess,
863}
864
865impl CostLane {
866 fn from_access_path(value: &str) -> Self {
867 match value {
868 "api" => Self::Api,
869 "subscription" => Self::SubscriptionEstimate,
870 _ => Self::UnknownAccess,
871 }
872 }
873}
874
875#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
876pub struct PeriodRange {
877 pub start: DateTime<Utc>,
878 pub end: DateTime<Utc>,
879}
880
881impl PeriodRange {
882 pub fn contains(&self, timestamp: DateTime<Utc>) -> bool {
883 timestamp >= self.start && timestamp < self.end
884 }
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
888pub struct GroupKey {
889 pub kind: GroupBy,
890 pub value: String,
891}
892
893#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
894pub struct TokenTotals {
895 pub input: u64,
896 pub output: u64,
897 pub cache_read: u64,
898 pub cache_write: u64,
899}
900
901impl TokenTotals {
902 pub fn total(&self) -> u64 {
903 self.input + self.output + self.cache_read + self.cache_write
904 }
905
906 fn add(&mut self, token_type: &str, tokens: u64) {
907 match token_type {
908 "input" => self.input += tokens,
909 "output" => self.output += tokens,
910 "cache_read" => self.cache_read += tokens,
911 "cache_write" => self.cache_write += tokens,
912 _ => {}
913 }
914 }
915}
916
917#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
918pub struct PricingCoverage {
919 pub priced_rows: usize,
920 pub missing_price_rows: usize,
921 pub unknown_model_rows: usize,
922}
923
924impl PricingCoverage {
925 fn add(&mut self, status: &str) {
926 match status {
927 PRICING_STATUS_PRICED => self.priced_rows += 1,
928 PRICING_STATUS_UNKNOWN_MODEL => self.unknown_model_rows += 1,
929 PRICING_STATUS_MISSING_PRICE => self.missing_price_rows += 1,
930 _ => self.missing_price_rows += 1,
931 }
932 }
933}
934
935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
936pub struct AggregateTotals {
937 pub row_count: usize,
938 pub billed_cost: Decimal,
939 pub effective_cost: Decimal,
940 pub currency: Option<String>,
941 pub multiple_currencies: bool,
942 pub tokens: TokenTotals,
943 pub pricing_coverage: PricingCoverage,
944 pub estimated_rows: usize,
945}
946
947impl AggregateTotals {
948 fn add_row(&mut self, row: &FocusRecord) {
949 self.row_count += 1;
950 self.billed_cost += row.billed_cost;
951 self.effective_cost += row.effective_cost;
952 self.add_currency(&row.billing_currency);
953 self.tokens
956 .add(&row.x_token_type, decimal_to_u64(row.x_consumed_tokens));
957 self.pricing_coverage.add(&row.x_pricing_status);
958 if row.x_estimated {
959 self.estimated_rows += 1;
960 }
961 }
962
963 fn add_currency(&mut self, currency: &str) {
964 match &self.currency {
965 None => self.currency = Some(currency.to_string()),
966 Some(current) if current == currency => {}
967 Some(_) => self.multiple_currencies = true,
968 }
969 }
970}
971
972impl Default for AggregateTotals {
973 fn default() -> Self {
974 Self {
975 row_count: 0,
976 billed_cost: Decimal::from(0),
977 effective_cost: Decimal::from(0),
978 currency: None,
979 multiple_currencies: false,
980 tokens: TokenTotals::default(),
981 pricing_coverage: PricingCoverage::default(),
982 estimated_rows: 0,
983 }
984 }
985}
986
987fn decimal_to_u64(value: Decimal) -> u64 {
988 value.to_u64().unwrap_or_default()
989}
990
991#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992pub struct CostLaneSummary {
993 pub group: GroupKey,
994 pub lane: CostLane,
995 pub totals: AggregateTotals,
996}
997
998#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
999pub struct LimitSummary {
1000 pub tool: ProviderId,
1001 pub plan: Option<String>,
1002 pub kind: LimitKind,
1003 pub label: Option<String>,
1004 pub availability: LimitAvailability,
1005}
1006
1007#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1008#[serde(rename_all = "snake_case")]
1009pub enum LimitAvailability {
1010 Available {
1011 used_fraction: f64,
1012 resets_at: DateTime<Utc>,
1013 reset_in_seconds: i64,
1014 },
1015 Partial {
1016 used_fraction: Option<f64>,
1017 resets_at: Option<DateTime<Utc>>,
1018 reset_in_seconds: Option<i64>,
1019 reason: String,
1020 },
1021 Unavailable {
1022 reason: String,
1023 },
1024}
1025
1026#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1027pub struct NowOptions {
1028 pub cost_period: Period,
1029 pub group_by: GroupBy,
1030}
1031
1032impl Default for NowOptions {
1033 fn default() -> Self {
1034 Self {
1035 cost_period: Period::Week,
1036 group_by: GroupBy::Model,
1037 }
1038 }
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1042pub struct TrendsOptions {
1043 pub period: Period,
1044 pub group_by: GroupBy,
1045}
1046
1047impl Default for TrendsOptions {
1048 fn default() -> Self {
1049 Self {
1050 period: Period::Week,
1051 group_by: GroupBy::Model,
1052 }
1053 }
1054}
1055
1056#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1057pub struct EngineOptions {
1058 pub period: Period,
1059 pub group_by: GroupBy,
1060}
1061
1062impl Default for EngineOptions {
1063 fn default() -> Self {
1064 Self {
1065 period: Period::Week,
1066 group_by: GroupBy::Model,
1067 }
1068 }
1069}
1070
1071#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1072pub struct NowSummary {
1073 pub generated_at: DateTime<Utc>,
1074 pub cost_period: PeriodRange,
1075 pub group_by: GroupBy,
1076 pub limits: Vec<LimitSummary>,
1077 pub current_costs: Vec<CostLaneSummary>,
1078 pub providers: Vec<ProviderStatus>,
1079}
1080
1081#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1082pub struct TrendsSummary {
1083 pub generated_at: DateTime<Utc>,
1084 pub period: Period,
1085 pub group_by: GroupBy,
1086 pub buckets: Vec<TrendBucket>,
1087 pub totals: Vec<CostLaneSummary>,
1088 pub providers: Vec<ProviderStatus>,
1089}
1090
1091#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1092pub struct TrendBucket {
1093 pub period: PeriodRange,
1094 pub group: GroupKey,
1095 pub lane: CostLane,
1096 pub totals: AggregateTotals,
1097}
1098
1099#[derive(Debug, Error)]
1100pub enum CoreError {
1101 #[error("bundled pricing JSON is invalid: {0}")]
1102 PricingJson(#[from] serde_json::Error),
1103
1104 #[error("bundled pricing table is invalid: {0}")]
1105 PricingValidation(String),
1106
1107 #[error("bundled benchmark data is invalid: {0}")]
1108 BenchValidation(String),
1109
1110 #[error("FOCUS export failed: {0}")]
1111 Focus(#[from] FocusError),
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117 use chrono::{LocalResult, TimeZone, Timelike, Weekday};
1118 use costroid_focus::{PRICING_CATEGORY_STANDARD, PRICING_STATUS_MISSING_PRICE};
1119 use costroid_providers::{CursorProvider, DataLocation, ProviderError};
1120 use std::path::PathBuf;
1121
1122 fn fixture_path(parts: &[&str]) -> PathBuf {
1123 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1124 path.push("..");
1125 path.push("..");
1126 path.push("fixtures");
1127 for part in parts {
1128 path.push(part);
1129 }
1130 path
1131 }
1132
1133 fn timestamp() -> DateTime<Utc> {
1134 utc_datetime(2026, 1, 1, 10, 0, 0)
1135 }
1136
1137 fn utc_datetime(
1138 year: i32,
1139 month: u32,
1140 day: u32,
1141 hour: u32,
1142 minute: u32,
1143 second: u32,
1144 ) -> DateTime<Utc> {
1145 match Utc.with_ymd_and_hms(year, month, day, hour, minute, second) {
1146 LocalResult::Single(value) => value,
1147 LocalResult::Ambiguous(_, _) | LocalResult::None => {
1148 panic!("test timestamp should be valid")
1149 }
1150 }
1151 }
1152
1153 fn usage_event(
1154 tool: ProviderId,
1155 access_path: AccessPath,
1156 timestamp: DateTime<Utc>,
1157 ) -> UsageEvent {
1158 UsageEvent {
1159 tool,
1160 model: "gpt-5.5".to_string(),
1161 timestamp,
1162 input_tokens: 10,
1163 output_tokens: 20,
1164 cache_read_tokens: 30,
1165 cache_write_tokens: 0,
1166 project: Some("/work/project".to_string()),
1167 access_path,
1168 }
1169 }
1170
1171 fn record(
1172 access_path: FocusAccessPath,
1173 timestamp: DateTime<Utc>,
1174 model: &str,
1175 project: Option<&str>,
1176 token_type: TokenType,
1177 token_count: u64,
1178 ) -> FocusRecord {
1179 match FocusRecord::unpriced_usage(UnpricedUsage {
1180 timestamp,
1181 tool: "codex".to_string(),
1182 model: model.to_string(),
1183 token_type,
1184 token_count,
1185 project: project.map(ToString::to_string),
1186 access_path,
1187 service_name: "Codex".to_string(),
1188 service_provider_name: "OpenAI".to_string(),
1189 host_provider_name: "OpenAI".to_string(),
1190 invoice_issuer_name: "OpenAI".to_string(),
1191 billing_currency: DEFAULT_BILLING_CURRENCY.to_string(),
1192 }) {
1193 Ok(value) => value,
1194 Err(err) => panic!("record should build: {err}"),
1195 }
1196 }
1197
1198 fn snapshot_with_rows(
1199 generated_at: DateTime<Utc>,
1200 focus_rows: Vec<FocusRecord>,
1201 limit_windows: Vec<LimitWindow>,
1202 ) -> EngineSnapshot {
1203 EngineSnapshot {
1204 generated_at,
1205 usage_events: Vec::new(),
1206 focus_rows,
1207 limit_windows,
1208 providers: Vec::new(),
1209 }
1210 }
1211
1212 fn cost_for(rows: &[FocusRecord], token_type: &str) -> Decimal {
1213 match rows.iter().find(|row| row.x_token_type == token_type) {
1214 Some(row) => row.billed_cost,
1215 None => panic!("{token_type} row should exist"),
1216 }
1217 }
1218
1219 #[test]
1220 fn bundled_pricing_is_valid_json() {
1221 assert!(bundled_pricing_value().is_ok());
1222 }
1223
1224 #[test]
1225 fn bundled_pricing_deserializes_decimal_string_rates() {
1226 let catalog = match PricingCatalog::bundled() {
1227 Ok(value) => value,
1228 Err(err) => panic!("bundled pricing should parse: {err}"),
1229 };
1230
1231 let rate = match catalog.rate("gpt-5.5", TokenType::Input) {
1232 Some(value) => value,
1233 None => panic!("gpt-5.5 input rate should exist"),
1234 };
1235
1236 assert_eq!(rate.price, Decimal::new(500, 2));
1237 assert_eq!(catalog.currency, "USD");
1238 assert_eq!(
1239 catalog.sku_price_id(rate),
1240 "openai:gpt-5.5:input:tokens:2026-06-02"
1241 );
1242 }
1243
1244 #[test]
1245 fn default_options_match_now_screen_defaults() {
1246 let options = EngineOptions::default();
1247 let now_options = NowOptions::default();
1248 let trends_options = TrendsOptions::default();
1249
1250 assert_eq!(options.period, Period::Week);
1251 assert_eq!(options.group_by, GroupBy::Model);
1252 assert_eq!(now_options.cost_period, Period::Week);
1253 assert_eq!(now_options.group_by, GroupBy::Model);
1254 assert_eq!(trends_options.period, Period::Week);
1255 assert_eq!(trends_options.group_by, GroupBy::Model);
1256 }
1257
1258 #[test]
1259 fn usage_events_convert_to_one_record_per_nonzero_meter() {
1260 let event = usage_event(ProviderId::Codex, AccessPath::Subscription, timestamp());
1261 let rows = match focus_records_from_usage(&[event]) {
1262 Ok(value) => value,
1263 Err(err) => panic!("conversion should succeed: {err}"),
1264 };
1265
1266 assert_eq!(rows.len(), 3);
1267 assert!(rows.iter().all(|row| row.x_estimated));
1268 assert!(rows
1269 .iter()
1270 .all(|row| row.pricing_category.as_deref() == Some(PRICING_CATEGORY_STANDARD)));
1271 assert!(rows
1272 .iter()
1273 .all(|row| row.x_pricing_status == PRICING_STATUS_PRICED));
1274 }
1275
1276 #[test]
1277 fn priced_usage_applies_costs_per_model_meter() {
1278 let event = UsageEvent {
1279 tool: ProviderId::Codex,
1280 model: "gpt-5.5".to_string(),
1281 timestamp: timestamp(),
1282 input_tokens: 10,
1283 output_tokens: 20,
1284 cache_read_tokens: 30,
1285 cache_write_tokens: 0,
1286 project: Some("/work/project".to_string()),
1287 access_path: AccessPath::Api,
1288 };
1289
1290 let rows = match focus_records_from_usage(&[event]) {
1291 Ok(value) => value,
1292 Err(err) => panic!("conversion should succeed: {err}"),
1293 };
1294
1295 let input = match rows.iter().find(|row| row.x_token_type == "input") {
1296 Some(value) => value,
1297 None => panic!("input row should exist"),
1298 };
1299 let output = match rows.iter().find(|row| row.x_token_type == "output") {
1300 Some(value) => value,
1301 None => panic!("output row should exist"),
1302 };
1303 let cache_read = match rows.iter().find(|row| row.x_token_type == "cache_read") {
1304 Some(value) => value,
1305 None => panic!("cache_read row should exist"),
1306 };
1307
1308 assert_eq!(input.pricing_quantity, Some(Decimal::from(10)));
1311 assert_eq!(input.consumed_quantity, Some(Decimal::from(10)));
1312 assert_eq!(input.pricing_unit.as_deref(), Some("tokens"));
1313 assert_eq!(
1314 input.pricing_category.as_deref(),
1315 Some(PRICING_CATEGORY_STANDARD)
1316 );
1317 assert_eq!(input.list_unit_price, Some(Decimal::new(5, 6)));
1318 assert_eq!(input.contracted_unit_price, Some(Decimal::new(5, 6)));
1319 assert_eq!(input.billed_cost, Decimal::new(5, 5));
1321 assert_eq!(input.effective_cost, input.billed_cost);
1322 assert_eq!(input.list_cost, input.billed_cost);
1323 assert_eq!(input.contracted_cost, input.billed_cost);
1324 assert_eq!(
1325 input.sku_price_id.as_deref(),
1326 Some("openai:gpt-5.5:input:tokens:2026-06-02")
1327 );
1328 assert_eq!(input.service_name, "OpenAI API");
1329 assert_eq!(input.billing_currency, "USD");
1330 assert_eq!(input.x_pricing_status, PRICING_STATUS_PRICED);
1331
1332 assert_eq!(output.billed_cost, Decimal::new(6, 4));
1333 assert_eq!(cache_read.billed_cost, Decimal::new(15, 6));
1334 }
1335
1336 #[test]
1337 fn cost_equals_per_token_price_times_quantity_and_matches_legacy_formula() {
1338 let event = UsageEvent {
1339 tool: ProviderId::Codex,
1340 model: "gpt-5.5".to_string(),
1341 timestamp: timestamp(),
1342 input_tokens: 1_234_567,
1343 output_tokens: 20,
1344 cache_read_tokens: 30,
1345 cache_write_tokens: 0,
1346 project: None,
1347 access_path: AccessPath::Api,
1348 };
1349 let rows = match focus_records_from_usage(&[event]) {
1350 Ok(value) => value,
1351 Err(err) => panic!("conversion should succeed: {err}"),
1352 };
1353 let million = Decimal::from(1_000_000_u64);
1354 let legacy =
1355 |tokens: u64, per_million: Decimal| (Decimal::from(tokens) / million) * per_million;
1356 for row in &rows {
1357 let unit = match row.list_unit_price {
1358 Some(value) => value,
1359 None => panic!("priced row should have a unit price"),
1360 };
1361 let quantity = match row.pricing_quantity {
1362 Some(value) => value,
1363 None => panic!("priced row should have a pricing quantity"),
1364 };
1365 assert_eq!(row.list_cost, unit * quantity);
1367 assert_eq!(row.billed_cost, row.list_cost);
1368 }
1369 assert_eq!(
1371 cost_for(&rows, "input"),
1372 legacy(1_234_567, Decimal::new(500, 2))
1373 );
1374 assert_eq!(cost_for(&rows, "output"), legacy(20, Decimal::new(3000, 2)));
1375 assert_eq!(
1376 cost_for(&rows, "cache_read"),
1377 legacy(30, Decimal::new(50, 2))
1378 );
1379 }
1380
1381 #[test]
1382 fn claude_sonnet_prices_all_token_meters() {
1383 let event = UsageEvent {
1384 tool: ProviderId::ClaudeCode,
1385 model: "claude-sonnet-4-6".to_string(),
1386 timestamp: timestamp(),
1387 input_tokens: 1_000_000,
1388 output_tokens: 1_000_000,
1389 cache_read_tokens: 1_000_000,
1390 cache_write_tokens: 1_000_000,
1391 project: None,
1392 access_path: AccessPath::Subscription,
1393 };
1394
1395 let rows = match focus_records_from_usage(&[event]) {
1396 Ok(value) => value,
1397 Err(err) => panic!("conversion should succeed: {err}"),
1398 };
1399
1400 assert_eq!(rows.len(), 4);
1401 assert!(rows
1402 .iter()
1403 .all(|row| row.x_pricing_status == PRICING_STATUS_PRICED));
1404 assert!(rows.iter().all(|row| row.x_estimated));
1405 assert!(rows.iter().all(|row| row.x_access_path == "subscription"));
1406 assert_eq!(cost_for(&rows, "input"), Decimal::new(3, 0));
1407 assert_eq!(cost_for(&rows, "output"), Decimal::new(15, 0));
1408 assert_eq!(cost_for(&rows, "cache_read"), Decimal::new(30, 2));
1409 assert_eq!(cost_for(&rows, "cache_write"), Decimal::new(375, 2));
1410 }
1411
1412 #[test]
1413 fn known_model_missing_meter_keeps_unpriced_convention() {
1414 let event = UsageEvent {
1415 tool: ProviderId::Codex,
1416 model: "gpt-5.5".to_string(),
1417 timestamp: timestamp(),
1418 input_tokens: 0,
1419 output_tokens: 0,
1420 cache_read_tokens: 0,
1421 cache_write_tokens: 1_000_000,
1422 project: None,
1423 access_path: AccessPath::Api,
1424 };
1425
1426 let rows = match focus_records_from_usage(&[event]) {
1427 Ok(value) => value,
1428 Err(err) => panic!("conversion should succeed: {err}"),
1429 };
1430 let row = &rows[0];
1431
1432 assert_eq!(row.x_pricing_status, PRICING_STATUS_MISSING_PRICE);
1433 assert_eq!(row.billed_cost, Decimal::from(0));
1434 assert_eq!(row.list_unit_price, None);
1435 assert_eq!(row.contracted_unit_price, None);
1436 assert_eq!(row.sku_price_id, None);
1437 assert_eq!(row.pricing_category, None);
1439 assert_eq!(row.pricing_quantity, None);
1440 assert_eq!(row.pricing_unit, None);
1441 assert_eq!(row.consumed_quantity, None);
1442 assert_eq!(row.x_consumed_tokens, Decimal::from(1_000_000));
1443 assert_eq!(row.service_name, "OpenAI API");
1444 assert_eq!(row.billing_currency, "USD");
1445 }
1446
1447 #[test]
1448 fn unknown_model_keeps_unpriced_convention_with_unknown_status() {
1449 let event = UsageEvent {
1450 tool: ProviderId::Cursor,
1451 model: "mystery-model".to_string(),
1452 timestamp: timestamp(),
1453 input_tokens: 1_000_000,
1454 output_tokens: 0,
1455 cache_read_tokens: 0,
1456 cache_write_tokens: 0,
1457 project: None,
1458 access_path: AccessPath::Unknown,
1459 };
1460
1461 let rows = match focus_records_from_usage(&[event]) {
1462 Ok(value) => value,
1463 Err(err) => panic!("conversion should succeed: {err}"),
1464 };
1465 let row = &rows[0];
1466
1467 assert_eq!(row.x_pricing_status, PRICING_STATUS_UNKNOWN_MODEL);
1468 assert_eq!(row.billed_cost, Decimal::from(0));
1469 assert_eq!(row.list_unit_price, None);
1470 assert_eq!(row.contracted_unit_price, None);
1471 assert_eq!(row.sku_price_id, None);
1472 assert_eq!(row.pricing_category, None);
1474 assert_eq!(row.pricing_quantity, None);
1475 assert_eq!(row.pricing_unit, None);
1476 assert_eq!(row.consumed_quantity, None);
1477 assert_eq!(row.x_consumed_tokens, Decimal::from(1_000_000));
1478 assert_eq!(row.service_name, "Cursor");
1479 assert_eq!(row.billing_currency, DEFAULT_BILLING_CURRENCY);
1480 }
1481
1482 #[test]
1483 fn exact_match_priced_costs_are_invariant_under_resolution() {
1484 let events = vec![
1487 UsageEvent {
1488 tool: ProviderId::Codex,
1489 model: "gpt-5.5".to_string(),
1490 timestamp: timestamp(),
1491 input_tokens: 1_000_000,
1492 output_tokens: 1_000_000,
1493 cache_read_tokens: 1_000_000,
1494 cache_write_tokens: 0,
1495 project: None,
1496 access_path: AccessPath::Api,
1497 },
1498 UsageEvent {
1499 tool: ProviderId::ClaudeCode,
1500 model: "claude-sonnet-4-6".to_string(),
1501 timestamp: timestamp(),
1502 input_tokens: 1_000_000,
1503 output_tokens: 1_000_000,
1504 cache_read_tokens: 1_000_000,
1505 cache_write_tokens: 1_000_000,
1506 project: None,
1507 access_path: AccessPath::Subscription,
1508 },
1509 ];
1510 let rows = match focus_records_from_usage(&events) {
1511 Ok(value) => value,
1512 Err(err) => panic!("conversion should succeed: {err}"),
1513 };
1514 let cost_of = |model: &str, meter: &str| -> Decimal {
1515 match rows
1516 .iter()
1517 .find(|r| r.x_model == model && r.x_token_type == meter)
1518 {
1519 Some(r) => r.billed_cost,
1520 None => panic!("missing row for {model}/{meter}"),
1521 }
1522 };
1523 assert_eq!(cost_of("gpt-5.5", "input"), Decimal::new(5, 0));
1525 assert_eq!(cost_of("gpt-5.5", "output"), Decimal::new(30, 0));
1526 assert_eq!(cost_of("gpt-5.5", "cache_read"), Decimal::new(50, 2));
1527 assert_eq!(cost_of("claude-sonnet-4-6", "input"), Decimal::new(3, 0));
1529 assert_eq!(cost_of("claude-sonnet-4-6", "output"), Decimal::new(15, 0));
1530 assert_eq!(
1531 cost_of("claude-sonnet-4-6", "cache_read"),
1532 Decimal::new(30, 2)
1533 );
1534 assert_eq!(
1535 cost_of("claude-sonnet-4-6", "cache_write"),
1536 Decimal::new(375, 2)
1537 );
1538 for row in rows
1540 .iter()
1541 .filter(|r| r.x_pricing_status == PRICING_STATUS_PRICED)
1542 {
1543 let sku_price_id = match &row.sku_price_id {
1544 Some(value) => value,
1545 None => panic!("priced row should carry a SkuPriceId"),
1546 };
1547 assert!(
1548 sku_price_id.contains(&row.x_model),
1549 "exact-match SkuPriceId should embed the model id: {sku_price_id}"
1550 );
1551 }
1552 }
1553
1554 #[test]
1555 fn dated_haiku_snapshot_resolves_to_base_rate_with_honest_ids() {
1556 let event = UsageEvent {
1561 tool: ProviderId::ClaudeCode,
1562 model: "claude-haiku-4-5-20251001".to_string(),
1563 timestamp: timestamp(),
1564 input_tokens: 1_000_000,
1565 output_tokens: 1_000_000,
1566 cache_read_tokens: 1_000_000,
1567 cache_write_tokens: 1_000_000,
1568 project: None,
1569 access_path: AccessPath::Subscription,
1570 };
1571 let rows = match focus_records_from_usage(&[event]) {
1572 Ok(value) => value,
1573 Err(err) => panic!("conversion should succeed: {err}"),
1574 };
1575 assert_eq!(rows.len(), 4);
1576 assert!(rows
1577 .iter()
1578 .all(|r| r.x_pricing_status == PRICING_STATUS_PRICED));
1579 let cost_of = |meter: &str| -> Decimal {
1580 match rows.iter().find(|r| r.x_token_type == meter) {
1581 Some(r) => r.billed_cost,
1582 None => panic!("missing meter {meter}"),
1583 }
1584 };
1585 assert_eq!(cost_of("input"), Decimal::new(1, 0));
1587 assert_eq!(cost_of("output"), Decimal::new(5, 0));
1588 assert_eq!(cost_of("cache_read"), Decimal::new(10, 2));
1589 assert_eq!(cost_of("cache_write"), Decimal::new(125, 2));
1590 for row in &rows {
1591 assert_eq!(row.x_model, "claude-haiku-4-5-20251001");
1593 let expected_sku_id = format!("claude-haiku-4-5-20251001:{}", row.x_token_type);
1594 assert_eq!(row.sku_id.as_deref(), Some(expected_sku_id.as_str()));
1595 let sku_price_id = match &row.sku_price_id {
1597 Some(value) => value,
1598 None => panic!("priced row should carry a SkuPriceId"),
1599 };
1600 assert!(
1601 sku_price_id.starts_with("anthropic:claude-haiku-4-5:"),
1602 "SkuPriceId should reference the base rate: {sku_price_id}"
1603 );
1604 assert!(
1605 !sku_price_id.contains("20251001"),
1606 "SkuPriceId must not embed the dated id: {sku_price_id}"
1607 );
1608 }
1609 }
1610
1611 #[test]
1612 fn openai_dashed_date_snapshot_resolves_to_base() {
1613 let event = UsageEvent {
1615 tool: ProviderId::Codex,
1616 model: "gpt-5.5-2025-10-01".to_string(),
1617 timestamp: timestamp(),
1618 input_tokens: 1_000_000,
1619 output_tokens: 0,
1620 cache_read_tokens: 0,
1621 cache_write_tokens: 0,
1622 project: None,
1623 access_path: AccessPath::Api,
1624 };
1625 let rows = match focus_records_from_usage(&[event]) {
1626 Ok(value) => value,
1627 Err(err) => panic!("conversion should succeed: {err}"),
1628 };
1629 assert_eq!(rows.len(), 1);
1630 let row = &rows[0];
1631 assert_eq!(row.x_pricing_status, PRICING_STATUS_PRICED);
1632 assert_eq!(row.billed_cost, Decimal::new(5, 0));
1633 assert_eq!(row.x_model, "gpt-5.5-2025-10-01");
1634 let sku_price_id = match &row.sku_price_id {
1635 Some(value) => value,
1636 None => panic!("priced row should carry a SkuPriceId"),
1637 };
1638 assert!(
1639 sku_price_id.starts_with("openai:gpt-5.5:"),
1640 "{sku_price_id}"
1641 );
1642 }
1643
1644 #[test]
1645 fn genuinely_new_or_fake_models_stay_unknown_not_missing_price() {
1646 for model in [
1651 "claude-opus-4-9",
1652 "made-up-model-20251001",
1653 "totally-fake-xyz",
1654 ] {
1655 let event = UsageEvent {
1656 tool: ProviderId::ClaudeCode,
1657 model: model.to_string(),
1658 timestamp: timestamp(),
1659 input_tokens: 1_000_000,
1660 output_tokens: 0,
1661 cache_read_tokens: 0,
1662 cache_write_tokens: 0,
1663 project: None,
1664 access_path: AccessPath::Subscription,
1665 };
1666 let rows = match focus_records_from_usage(&[event]) {
1667 Ok(value) => value,
1668 Err(err) => panic!("conversion should succeed: {err}"),
1669 };
1670 assert_eq!(rows.len(), 1);
1671 let row = &rows[0];
1672 assert_eq!(
1673 row.x_pricing_status, PRICING_STATUS_UNKNOWN_MODEL,
1674 "{model} must be unknown_model, not missing_price"
1675 );
1676 assert_eq!(row.billed_cost, Decimal::from(0));
1677 assert_eq!(row.sku_price_id, None);
1678 assert_eq!(row.pricing_quantity, None);
1679 }
1680 }
1681
1682 #[test]
1683 fn opus_4_8_prices_at_published_rates() {
1684 let event = UsageEvent {
1687 tool: ProviderId::ClaudeCode,
1688 model: "claude-opus-4-8".to_string(),
1689 timestamp: timestamp(),
1690 input_tokens: 1_000_000,
1691 output_tokens: 1_000_000,
1692 cache_read_tokens: 1_000_000,
1693 cache_write_tokens: 1_000_000,
1694 project: None,
1695 access_path: AccessPath::Subscription,
1696 };
1697 let rows = match focus_records_from_usage(&[event]) {
1698 Ok(value) => value,
1699 Err(err) => panic!("conversion should succeed: {err}"),
1700 };
1701 assert_eq!(rows.len(), 4);
1702 assert!(rows
1703 .iter()
1704 .all(|r| r.x_pricing_status == PRICING_STATUS_PRICED));
1705 let cost_of = |meter: &str| -> Decimal {
1706 match rows.iter().find(|r| r.x_token_type == meter) {
1707 Some(r) => r.billed_cost,
1708 None => panic!("missing meter {meter}"),
1709 }
1710 };
1711 assert_eq!(cost_of("input"), Decimal::new(5, 0));
1713 assert_eq!(cost_of("output"), Decimal::new(25, 0));
1714 assert_eq!(cost_of("cache_read"), Decimal::new(50, 2));
1715 assert_eq!(cost_of("cache_write"), Decimal::new(625, 2));
1716 for row in &rows {
1717 let sku_price_id = match &row.sku_price_id {
1719 Some(value) => value,
1720 None => panic!("priced row should carry a SkuPriceId"),
1721 };
1722 assert!(
1723 sku_price_id.starts_with("anthropic:claude-opus-4-8:"),
1724 "{sku_price_id}"
1725 );
1726 }
1727 }
1728
1729 #[test]
1730 fn strip_date_suffix_only_strips_real_snapshots() {
1731 assert_eq!(strip_date_suffix("claude-opus-4-8"), None);
1733 assert_eq!(strip_date_suffix("gpt-5.5"), None);
1734 assert_eq!(strip_date_suffix("gpt-5.4"), None);
1735 assert_eq!(strip_date_suffix("claude-haiku-4-5"), None);
1736 assert_eq!(strip_date_suffix("mystery-model"), None);
1737 assert_eq!(
1739 strip_date_suffix("claude-haiku-4-5-20251001"),
1740 Some("claude-haiku-4-5")
1741 );
1742 assert_eq!(
1743 strip_date_suffix("claude-3-5-sonnet-20241022"),
1744 Some("claude-3-5-sonnet")
1745 );
1746 assert_eq!(strip_date_suffix("gpt-5.5-2025-10-01"), Some("gpt-5.5"));
1748 assert_eq!(strip_date_suffix("gpt-4o-2024-08-06"), Some("gpt-4o"));
1749 assert_eq!(strip_date_suffix("claude-haiku-4-5-2025100"), None); assert_eq!(strip_date_suffix("claude-haiku-4-5-202510011"), None); assert_eq!(strip_date_suffix("claude-haiku-4-5-2025"), None); assert_eq!(strip_date_suffix("-20251001"), None);
1755 assert_eq!(strip_date_suffix("20251001"), None);
1756 assert_eq!(strip_date_suffix(""), None);
1757 }
1758
1759 #[test]
1760 fn resolve_key_treats_absent_version_as_unknown_until_an_entry_exists() {
1761 let without = r#"{"schema_version":"1","as_of":"2026-06-02","currency":"USD","models":[{"provider":"anthropic","model":"claude-opus-4-7","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"5.00"}]}]}"#;
1765 let with = r#"{"schema_version":"1","as_of":"2026-06-02","currency":"USD","models":[{"provider":"anthropic","model":"claude-opus-4-7","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"5.00"}]},{"provider":"anthropic","model":"claude-opus-4-8","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"5.00"}]}]}"#;
1766 let absent = match PricingCatalog::from_json(without) {
1767 Ok(value) => value,
1768 Err(err) => panic!("parse should succeed: {err}"),
1769 };
1770 let present = match PricingCatalog::from_json(with) {
1771 Ok(value) => value,
1772 Err(err) => panic!("parse should succeed: {err}"),
1773 };
1774 assert_eq!(absent.resolve_key("claude-opus-4-8"), None);
1776 assert_eq!(
1777 present.resolve_key("claude-opus-4-8"),
1778 Some("claude-opus-4-8")
1779 );
1780 }
1781
1782 #[test]
1783 fn explicit_dated_entry_overrides_base_alias_fallback() {
1784 let json = r#"{"schema_version":"1","as_of":"2026-06-02","currency":"USD","models":[{"provider":"anthropic","model":"claude-haiku-4-5","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"1.00"}]},{"provider":"anthropic","model":"claude-haiku-4-5-20251001","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"9.99"}]}]}"#;
1787 let catalog = match PricingCatalog::from_json(json) {
1788 Ok(value) => value,
1789 Err(err) => panic!("parse should succeed: {err}"),
1790 };
1791 assert_eq!(
1792 catalog.resolve_key("claude-haiku-4-5-20251001"),
1793 Some("claude-haiku-4-5-20251001")
1794 );
1795 let rate = match catalog.rate("claude-haiku-4-5-20251001", TokenType::Input) {
1796 Some(value) => value,
1797 None => panic!("explicit dated entry should have a rate"),
1798 };
1799 assert_eq!(rate.price, Decimal::new(999, 2));
1800 }
1801
1802 #[test]
1803 fn api_and_subscription_priced_rows_stay_in_separate_lanes() {
1804 let rows = match focus_records_from_usage(&[
1805 UsageEvent {
1806 tool: ProviderId::Codex,
1807 model: "gpt-5.5".to_string(),
1808 timestamp: timestamp(),
1809 input_tokens: 1_000_000,
1810 output_tokens: 0,
1811 cache_read_tokens: 0,
1812 cache_write_tokens: 0,
1813 project: None,
1814 access_path: AccessPath::Api,
1815 },
1816 UsageEvent {
1817 tool: ProviderId::Codex,
1818 model: "gpt-5.5".to_string(),
1819 timestamp: timestamp(),
1820 input_tokens: 0,
1821 output_tokens: 1_000_000,
1822 cache_read_tokens: 0,
1823 cache_write_tokens: 0,
1824 project: None,
1825 access_path: AccessPath::Subscription,
1826 },
1827 ]) {
1828 Ok(value) => value,
1829 Err(err) => panic!("conversion should succeed: {err}"),
1830 };
1831 let snapshot = snapshot_with_rows(timestamp(), rows, Vec::new());
1832
1833 let summary = now_summary(&snapshot, NowOptions::default());
1834 let api = match summary
1835 .current_costs
1836 .iter()
1837 .find(|summary| summary.lane == CostLane::Api)
1838 {
1839 Some(value) => value,
1840 None => panic!("api lane should exist"),
1841 };
1842 let subscription = match summary
1843 .current_costs
1844 .iter()
1845 .find(|summary| summary.lane == CostLane::SubscriptionEstimate)
1846 {
1847 Some(value) => value,
1848 None => panic!("subscription lane should exist"),
1849 };
1850
1851 assert_eq!(api.totals.billed_cost, Decimal::new(5, 0));
1852 assert_eq!(subscription.totals.billed_cost, Decimal::new(30, 0));
1853 assert_eq!(api.totals.pricing_coverage.priced_rows, 1);
1854 assert_eq!(subscription.totals.pricing_coverage.priced_rows, 1);
1855 assert_eq!(api.totals.estimated_rows, 1);
1856 assert_eq!(subscription.totals.estimated_rows, 1);
1857 }
1858
1859 #[test]
1860 fn export_helpers_emit_json_and_csv() {
1861 let rows = match focus_records_from_usage(&[usage_event(
1862 ProviderId::ClaudeCode,
1863 AccessPath::Unknown,
1864 timestamp(),
1865 )]) {
1866 Ok(value) => value,
1867 Err(err) => panic!("conversion should succeed: {err}"),
1868 };
1869 let json = match export_focus_json(rows.clone()) {
1870 Ok(value) => value,
1871 Err(err) => panic!("json export should succeed: {err}"),
1872 };
1873 let csv = match export_focus_csv(&rows) {
1874 Ok(value) => value,
1875 Err(err) => panic!("csv export should succeed: {err}"),
1876 };
1877
1878 assert!(json.contains("\"focusVersion\": \"1.3\""));
1879 assert!(csv.starts_with("BilledCost,EffectiveCost,ListCost,ContractedCost"));
1880 }
1881
1882 #[test]
1883 fn now_summary_keeps_access_path_lanes_separate() {
1884 let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
1885 let rows = vec![
1886 record(
1887 FocusAccessPath::Api,
1888 generated_at,
1889 "shared-model",
1890 Some("/work/a"),
1891 TokenType::Input,
1892 10,
1893 ),
1894 record(
1895 FocusAccessPath::Subscription,
1896 generated_at,
1897 "shared-model",
1898 Some("/work/a"),
1899 TokenType::Output,
1900 20,
1901 ),
1902 record(
1903 FocusAccessPath::Unknown,
1904 generated_at,
1905 "shared-model",
1906 Some("/work/a"),
1907 TokenType::CacheRead,
1908 30,
1909 ),
1910 ];
1911 let snapshot = snapshot_with_rows(generated_at, rows, Vec::new());
1912
1913 let summary = now_summary(&snapshot, NowOptions::default());
1914
1915 assert_eq!(summary.current_costs.len(), 3);
1916 assert!(summary
1917 .current_costs
1918 .iter()
1919 .any(|summary| summary.lane == CostLane::Api && summary.totals.tokens.input == 10));
1920 assert!(summary.current_costs.iter().any(|summary| {
1921 summary.lane == CostLane::SubscriptionEstimate && summary.totals.tokens.output == 20
1922 }));
1923 assert!(summary.current_costs.iter().any(|summary| {
1924 summary.lane == CostLane::UnknownAccess && summary.totals.tokens.cache_read == 30
1925 }));
1926 }
1927
1928 #[test]
1929 fn engine_totals_tokens_from_unpriced_rows_via_x_consumed_tokens() {
1930 let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
1933 let row = record(
1934 FocusAccessPath::Api,
1935 generated_at,
1936 "unpriced-model",
1937 Some("/work/a"),
1938 TokenType::Input,
1939 4_242,
1940 );
1941 assert_eq!(
1942 row.consumed_quantity, None,
1943 "unpriced row nulls ConsumedQuantity"
1944 );
1945 assert_eq!(row.x_consumed_tokens, Decimal::from(4_242));
1946 let snapshot = snapshot_with_rows(generated_at, vec![row], Vec::new());
1947
1948 let summary = now_summary(&snapshot, NowOptions::default());
1949 let total: u64 = summary
1950 .current_costs
1951 .iter()
1952 .map(|summary| summary.totals.tokens.input)
1953 .sum();
1954 assert_eq!(total, 4_242);
1955 }
1956
1957 #[test]
1958 fn now_summary_uses_configurable_cost_period() {
1959 let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
1960 let previous_week = utc_datetime(2026, 1, 2, 12, 0, 0);
1961 let previous_month = utc_datetime(2025, 12, 31, 12, 0, 0);
1962 let rows = vec![
1963 record(
1964 FocusAccessPath::Api,
1965 previous_week,
1966 "old-model",
1967 Some("/work/a"),
1968 TokenType::Input,
1969 10,
1970 ),
1971 record(
1972 FocusAccessPath::Api,
1973 generated_at,
1974 "new-model",
1975 Some("/work/a"),
1976 TokenType::Input,
1977 20,
1978 ),
1979 record(
1980 FocusAccessPath::Api,
1981 previous_month,
1982 "last-month-model",
1983 Some("/work/a"),
1984 TokenType::Input,
1985 30,
1986 ),
1987 ];
1988 let snapshot = snapshot_with_rows(generated_at, rows, Vec::new());
1989
1990 let week = now_summary(&snapshot, NowOptions::default());
1991 let month = now_summary(
1992 &snapshot,
1993 NowOptions {
1994 cost_period: Period::Month,
1995 group_by: GroupBy::Model,
1996 },
1997 );
1998
1999 assert_eq!(week.current_costs.len(), 1);
2000 assert_eq!(week.current_costs[0].totals.tokens.input, 20);
2001 assert_eq!(month.current_costs.len(), 2);
2002 assert_eq!(
2003 month
2004 .current_costs
2005 .iter()
2006 .map(|summary| summary.totals.tokens.input)
2007 .sum::<u64>(),
2008 30
2009 );
2010 assert!(!month
2011 .current_costs
2012 .iter()
2013 .any(|summary| summary.group.value == "last-month-model"));
2014 }
2015
2016 #[test]
2017 fn trends_group_by_model_app_and_total() {
2018 let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
2019 let rows = vec![
2020 record(
2021 FocusAccessPath::Api,
2022 generated_at,
2023 "model-a",
2024 Some("/work/a"),
2025 TokenType::Input,
2026 10,
2027 ),
2028 record(
2029 FocusAccessPath::Api,
2030 generated_at,
2031 "model-b",
2032 Some("/work/b"),
2033 TokenType::Input,
2034 20,
2035 ),
2036 record(
2037 FocusAccessPath::Api,
2038 generated_at,
2039 "model-b",
2040 None,
2041 TokenType::Input,
2042 30,
2043 ),
2044 ];
2045 let snapshot = snapshot_with_rows(generated_at, rows, Vec::new());
2046
2047 let by_model = trends_summary(
2048 &snapshot,
2049 TrendsOptions {
2050 period: Period::Week,
2051 group_by: GroupBy::Model,
2052 },
2053 );
2054 let by_app = trends_summary(
2055 &snapshot,
2056 TrendsOptions {
2057 period: Period::Week,
2058 group_by: GroupBy::App,
2059 },
2060 );
2061 let total = trends_summary(
2062 &snapshot,
2063 TrendsOptions {
2064 period: Period::Week,
2065 group_by: GroupBy::Total,
2066 },
2067 );
2068
2069 assert_eq!(by_model.totals.len(), 2);
2070 assert!(by_model
2071 .totals
2072 .iter()
2073 .any(|summary| summary.group.value == "model-a"));
2074 assert_eq!(by_app.totals.len(), 3);
2075 assert!(by_app
2076 .totals
2077 .iter()
2078 .any(|summary| summary.group.value == UNKNOWN_GROUP_VALUE));
2079 assert_eq!(total.totals.len(), 1);
2080 assert_eq!(total.totals[0].group.value, TOTAL_GROUP_VALUE);
2081 assert_eq!(total.totals[0].totals.tokens.input, 60);
2082 }
2083
2084 #[test]
2085 fn trends_buckets_by_selected_local_periods() {
2086 let monday_local = local_datetime(2026, 1, 5, 12, 0, 0);
2087 let sunday_local = local_datetime(2026, 1, 11, 12, 0, 0);
2088 let next_monday_local = local_datetime(2026, 1, 12, 12, 0, 0);
2089 let rows = vec![
2090 record(
2091 FocusAccessPath::Api,
2092 monday_local.with_timezone(&Utc),
2093 "model",
2094 Some("/work/a"),
2095 TokenType::Input,
2096 10,
2097 ),
2098 record(
2099 FocusAccessPath::Api,
2100 sunday_local.with_timezone(&Utc),
2101 "model",
2102 Some("/work/a"),
2103 TokenType::Input,
2104 20,
2105 ),
2106 record(
2107 FocusAccessPath::Api,
2108 next_monday_local.with_timezone(&Utc),
2109 "model",
2110 Some("/work/a"),
2111 TokenType::Input,
2112 30,
2113 ),
2114 ];
2115 let snapshot = snapshot_with_rows(monday_local.with_timezone(&Utc), rows, Vec::new());
2116
2117 let week = trends_summary(
2118 &snapshot,
2119 TrendsOptions {
2120 period: Period::Week,
2121 group_by: GroupBy::Total,
2122 },
2123 );
2124
2125 assert_eq!(week.buckets.len(), 2);
2126 assert_eq!(
2127 week.buckets[0].period.start.with_timezone(&Local).weekday(),
2128 Weekday::Mon
2129 );
2130 assert_eq!(
2131 week.buckets[1].period.start.with_timezone(&Local).weekday(),
2132 Weekday::Mon
2133 );
2134 assert_ne!(week.buckets[0].period.start, week.buckets[1].period.start);
2135 assert_eq!(week.buckets[0].totals.tokens.input, 30);
2136 assert_eq!(week.buckets[1].totals.tokens.input, 30);
2137 }
2138
2139 #[test]
2140 fn trends_day_buckets_split_at_local_midnight() {
2141 let before_midnight = local_datetime(2026, 1, 5, 23, 59, 59);
2142 let after_midnight = local_datetime(2026, 1, 6, 0, 0, 0);
2143 let rows = vec![
2144 record(
2145 FocusAccessPath::Api,
2146 before_midnight.with_timezone(&Utc),
2147 "model",
2148 Some("/work/a"),
2149 TokenType::Input,
2150 10,
2151 ),
2152 record(
2153 FocusAccessPath::Api,
2154 after_midnight.with_timezone(&Utc),
2155 "model",
2156 Some("/work/a"),
2157 TokenType::Input,
2158 20,
2159 ),
2160 ];
2161 let snapshot = snapshot_with_rows(after_midnight.with_timezone(&Utc), rows, Vec::new());
2162
2163 let day = trends_summary(
2164 &snapshot,
2165 TrendsOptions {
2166 period: Period::Day,
2167 group_by: GroupBy::Total,
2168 },
2169 );
2170
2171 assert_eq!(day.buckets.len(), 2);
2172 assert_ne!(day.buckets[0].period.start, day.buckets[1].period.start);
2173 assert_eq!(day.buckets[0].totals.row_count, 1);
2174 assert_eq!(day.buckets[0].totals.tokens.input, 10);
2175 assert_eq!(day.buckets[1].totals.row_count, 1);
2176 assert_eq!(day.buckets[1].totals.tokens.input, 20);
2177 }
2178
2179 #[test]
2180 fn trends_month_buckets_split_by_local_month() {
2181 let january = local_datetime(2025, 1, 31, 12, 0, 0);
2182 let february = local_datetime(2025, 2, 1, 12, 0, 0);
2183 let rows = vec![
2184 record(
2185 FocusAccessPath::Api,
2186 january.with_timezone(&Utc),
2187 "model",
2188 Some("/work/a"),
2189 TokenType::Input,
2190 10,
2191 ),
2192 record(
2193 FocusAccessPath::Api,
2194 february.with_timezone(&Utc),
2195 "model",
2196 Some("/work/a"),
2197 TokenType::Input,
2198 20,
2199 ),
2200 ];
2201 let snapshot = snapshot_with_rows(february.with_timezone(&Utc), rows, Vec::new());
2202
2203 let month = trends_summary(
2204 &snapshot,
2205 TrendsOptions {
2206 period: Period::Month,
2207 group_by: GroupBy::Total,
2208 },
2209 );
2210
2211 assert_eq!(month.buckets.len(), 2);
2212 assert_eq!(
2213 month.buckets[0].period.start.with_timezone(&Local).month(),
2214 1
2215 );
2216 assert_eq!(month.buckets[0].totals.row_count, 1);
2217 assert_eq!(month.buckets[0].totals.tokens.input, 10);
2218 assert_eq!(
2219 month.buckets[1].period.start.with_timezone(&Local).month(),
2220 2
2221 );
2222 assert_eq!(month.buckets[1].totals.row_count, 1);
2223 assert_eq!(month.buckets[1].totals.tokens.input, 20);
2224 }
2225
2226 #[test]
2227 fn trends_year_buckets_split_by_local_year() {
2228 let current_year = local_datetime(2025, 12, 31, 12, 0, 0);
2229 let next_year = local_datetime(2026, 1, 1, 12, 0, 0);
2230 let rows = vec![
2231 record(
2232 FocusAccessPath::Api,
2233 current_year.with_timezone(&Utc),
2234 "model",
2235 Some("/work/a"),
2236 TokenType::Input,
2237 10,
2238 ),
2239 record(
2240 FocusAccessPath::Api,
2241 next_year.with_timezone(&Utc),
2242 "model",
2243 Some("/work/a"),
2244 TokenType::Input,
2245 30,
2246 ),
2247 ];
2248 let snapshot = snapshot_with_rows(next_year.with_timezone(&Utc), rows, Vec::new());
2249
2250 let year = trends_summary(
2251 &snapshot,
2252 TrendsOptions {
2253 period: Period::Year,
2254 group_by: GroupBy::Total,
2255 },
2256 );
2257
2258 assert_eq!(year.buckets.len(), 2);
2259 assert_eq!(
2260 year.buckets[0].period.start.with_timezone(&Local).year(),
2261 2025
2262 );
2263 assert_eq!(year.buckets[0].totals.row_count, 1);
2264 assert_eq!(year.buckets[0].totals.tokens.input, 10);
2265 assert_eq!(
2266 year.buckets[1].period.start.with_timezone(&Local).year(),
2267 2026
2268 );
2269 assert_eq!(year.buckets[1].totals.row_count, 1);
2270 assert_eq!(year.buckets[1].totals.tokens.input, 30);
2271 }
2272
2273 #[test]
2274 fn local_period_boundaries_start_at_local_midnight() {
2275 let local = local_datetime(2026, 1, 15, 0, 30, 0);
2276
2277 let day = period_range_for(Period::Day, local.with_timezone(&Utc));
2278 let month = period_range_for(Period::Month, local.with_timezone(&Utc));
2279 let year = period_range_for(Period::Year, local.with_timezone(&Utc));
2280
2281 assert_eq!(day.start.with_timezone(&Local).hour(), 0);
2282 assert_eq!(day.start.with_timezone(&Local).minute(), 0);
2283 assert_eq!(month.start.with_timezone(&Local).day(), 1);
2284 assert_eq!(year.start.with_timezone(&Local).month(), 1);
2285 assert_eq!(year.start.with_timezone(&Local).day(), 1);
2286 }
2287
2288 #[test]
2289 fn placeholder_pricing_reports_missing_price_and_tokens() {
2290 let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
2291 let rows = vec![record(
2292 FocusAccessPath::Api,
2293 generated_at,
2294 "model",
2295 Some("/work/a"),
2296 TokenType::Input,
2297 10,
2298 )];
2299 let snapshot = snapshot_with_rows(generated_at, rows, Vec::new());
2300
2301 let summary = now_summary(&snapshot, NowOptions::default());
2302 let totals = &summary.current_costs[0].totals;
2303
2304 assert_eq!(totals.billed_cost, Decimal::from(0));
2305 assert_eq!(totals.effective_cost, Decimal::from(0));
2306 assert_eq!(totals.tokens.input, 10);
2307 assert_eq!(totals.pricing_coverage.missing_price_rows, 1);
2308 assert_eq!(totals.estimated_rows, 1);
2309 }
2310
2311 #[test]
2312 fn pricing_coverage_tracks_unknown_models_separately() {
2313 let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
2314 let mut row = record(
2315 FocusAccessPath::Api,
2316 generated_at,
2317 "model",
2318 Some("/work/a"),
2319 TokenType::Input,
2320 10,
2321 );
2322 row.x_pricing_status = PRICING_STATUS_UNKNOWN_MODEL.to_string();
2323 let snapshot = snapshot_with_rows(generated_at, vec![row], Vec::new());
2324
2325 let summary = now_summary(&snapshot, NowOptions::default());
2326
2327 assert_eq!(
2328 summary.current_costs[0]
2329 .totals
2330 .pricing_coverage
2331 .unknown_model_rows,
2332 1
2333 );
2334 }
2335
2336 #[test]
2337 fn limit_availability_distinguishes_unavailable_available_partial_and_stale() {
2338 let now = utc_datetime(2026, 1, 7, 12, 0, 0);
2339 let limits = vec![
2340 LimitWindow {
2341 tool: ProviderId::ClaudeCode,
2342 plan: None,
2343 kind: LimitKind::FiveHour,
2344 used_fraction: None,
2345 resets_at: None,
2346 label: Some("unavailable".to_string()),
2347 },
2348 LimitWindow {
2349 tool: ProviderId::Codex,
2350 plan: Some("plus".to_string()),
2351 kind: LimitKind::FiveHour,
2352 used_fraction: Some(0.5),
2353 resets_at: Some(now + Duration::minutes(30)),
2354 label: None,
2355 },
2356 LimitWindow {
2357 tool: ProviderId::Codex,
2358 plan: Some("plus".to_string()),
2359 kind: LimitKind::Weekly,
2360 used_fraction: Some(0.6),
2361 resets_at: None,
2362 label: None,
2363 },
2364 LimitWindow {
2365 tool: ProviderId::Codex,
2366 plan: Some("plus".to_string()),
2367 kind: LimitKind::Weekly,
2368 used_fraction: Some(0.7),
2369 resets_at: Some(now - Duration::minutes(5)),
2370 label: None,
2371 },
2372 ];
2373 let snapshot = snapshot_with_rows(now, Vec::new(), limits);
2374
2375 let summary = now_summary(&snapshot, NowOptions::default());
2376
2377 assert!(matches!(
2378 summary.limits[0].availability,
2379 LimitAvailability::Unavailable { .. }
2380 ));
2381 assert!(matches!(
2382 summary.limits[1].availability,
2383 LimitAvailability::Available {
2384 reset_in_seconds: 1800,
2385 ..
2386 }
2387 ));
2388 assert!(matches!(
2389 summary.limits[2].availability,
2390 LimitAvailability::Partial { .. }
2391 ));
2392 match &summary.limits[3].availability {
2393 LimitAvailability::Partial {
2394 reset_in_seconds,
2395 reason,
2396 ..
2397 } => {
2398 assert_eq!(*reset_in_seconds, Some(0));
2399 assert!(reason.contains("stale"));
2400 }
2401 _ => panic!("expired reset should be partial stale data"),
2402 }
2403 }
2404
2405 #[test]
2406 fn snapshot_collection_degrades_provider_errors() {
2407 let env = HostEnv::new(PathBuf::from("/home/example"), Vec::new(), false);
2408 let now = timestamp();
2409 let providers: Vec<Box<dyn Provider>> = vec![
2414 Box::new(FakeProvider::failing_usage(ProviderId::ClaudeCode)),
2415 Box::new(FakeProvider::missing(ProviderId::Cursor)),
2416 Box::new(FakeProvider::available(
2417 ProviderId::Codex,
2418 vec![usage_event(
2419 ProviderId::Codex,
2420 AccessPath::Subscription,
2421 now,
2422 )],
2423 vec![LimitWindow {
2424 tool: ProviderId::Codex,
2425 plan: Some("plus".to_string()),
2426 kind: LimitKind::FiveHour,
2427 used_fraction: Some(0.4),
2428 resets_at: Some(now + Duration::hours(1)),
2429 label: None,
2430 }],
2431 )),
2432 ];
2433
2434 let snapshot = match collect_snapshot_from_providers(&env, providers, now) {
2435 Ok(value) => value,
2436 Err(err) => panic!("snapshot should collect with non-fatal provider errors: {err}"),
2437 };
2438
2439 assert_eq!(snapshot.providers.len(), 3);
2440 assert!(snapshot.providers.iter().any(|status| {
2441 status.provider == ProviderId::ClaudeCode
2442 && status.status == ProviderStatusKind::Partial
2443 }));
2444 assert!(snapshot.providers.iter().any(|status| {
2445 status.provider == ProviderId::Cursor && status.status == ProviderStatusKind::Missing
2446 }));
2447 assert!(snapshot.providers.iter().any(|status| {
2448 status.provider == ProviderId::Codex
2449 && status.status == ProviderStatusKind::Available
2450 && status.focus_rows == 3
2451 }));
2452 assert_eq!(snapshot.focus_rows.len(), 3);
2453 assert_eq!(snapshot.limit_windows.len(), 1);
2454 }
2455
2456 #[test]
2457 fn cursor_detected_status_defers_usage_and_quota() {
2458 let env = HostEnv::new(fixture_path(&["cursor", "home"]), Vec::new(), false);
2462 let providers: Vec<Box<dyn Provider>> = vec![Box::new(CursorProvider)];
2463
2464 let snapshot = match collect_snapshot_from_providers(&env, providers, timestamp()) {
2465 Ok(value) => value,
2466 Err(err) => panic!("cursor detect-and-defer should collect: {err}"),
2467 };
2468
2469 assert_eq!(snapshot.providers.len(), 1);
2470 let status = &snapshot.providers[0];
2471 assert_eq!(status.provider, ProviderId::Cursor);
2472 assert_eq!(status.status, ProviderStatusKind::Detected);
2473 assert_eq!(status.usage_events, 0);
2474 assert_eq!(status.focus_rows, 0);
2475 assert_eq!(status.limit_windows, 0);
2476
2477 let message = status.message.as_deref().unwrap_or_default();
2478 for needle in [
2479 "BETA",
2480 "composer-2.5",
2481 "Composer 2.5 Fast",
2482 "logged in",
2483 "live (Phase 2)",
2484 ] {
2485 assert!(
2486 message.contains(needle),
2487 "detected message {message:?} should contain {needle:?}"
2488 );
2489 }
2490
2491 assert!(snapshot.focus_rows.is_empty());
2492 assert!(snapshot.limit_windows.is_empty());
2493 assert!(snapshot.usage_events.is_empty());
2495 }
2496
2497 fn local_datetime(
2498 year: i32,
2499 month: u32,
2500 day: u32,
2501 hour: u32,
2502 minute: u32,
2503 second: u32,
2504 ) -> DateTime<Local> {
2505 match Local.with_ymd_and_hms(year, month, day, hour, minute, second) {
2506 LocalResult::Single(value) => value,
2507 LocalResult::Ambiguous(first, _) => first,
2508 LocalResult::None => {
2509 panic!("test local timestamp should be valid in the host timezone")
2510 }
2511 }
2512 }
2513
2514 struct FakeProvider {
2515 provider: ProviderId,
2516 discoverable: bool,
2517 usage_error: bool,
2518 usage: Vec<UsageEvent>,
2519 limits: Vec<LimitWindow>,
2520 }
2521
2522 impl FakeProvider {
2523 fn missing(provider: ProviderId) -> Self {
2524 Self {
2525 provider,
2526 discoverable: false,
2527 usage_error: false,
2528 usage: Vec::new(),
2529 limits: Vec::new(),
2530 }
2531 }
2532
2533 fn failing_usage(provider: ProviderId) -> Self {
2534 Self {
2535 provider,
2536 discoverable: true,
2537 usage_error: true,
2538 usage: Vec::new(),
2539 limits: Vec::new(),
2540 }
2541 }
2542
2543 fn available(
2544 provider: ProviderId,
2545 usage: Vec<UsageEvent>,
2546 limits: Vec<LimitWindow>,
2547 ) -> Self {
2548 Self {
2549 provider,
2550 discoverable: true,
2551 usage_error: false,
2552 usage,
2553 limits,
2554 }
2555 }
2556 }
2557
2558 impl Provider for FakeProvider {
2559 fn id(&self) -> ProviderId {
2560 self.provider
2561 }
2562
2563 fn discover(&self, _env: &HostEnv) -> Result<Option<DataLocation>, ProviderError> {
2564 if self.discoverable {
2565 Ok(Some(DataLocation {
2566 provider: self.provider,
2567 root: PathBuf::from("/fake"),
2568 files: vec![PathBuf::from("/fake/data.jsonl")],
2569 }))
2570 } else {
2571 Ok(None)
2572 }
2573 }
2574
2575 fn parse_usage(&self, _loc: &DataLocation) -> Result<Vec<UsageEvent>, ProviderError> {
2576 if self.usage_error {
2577 Err(ProviderError::DataUnavailable {
2578 provider: self.provider,
2579 message: "synthetic usage failure".to_string(),
2580 })
2581 } else {
2582 Ok(self.usage.clone())
2583 }
2584 }
2585
2586 fn parse_limits(&self, _loc: &DataLocation) -> Result<Vec<LimitWindow>, ProviderError> {
2587 Ok(self.limits.clone())
2588 }
2589 }
2590}