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