1use super::StatSort;
2use crate::limits::RateLimitDiagnostics;
3use crate::pricing::TokenUsage as PricingTokenUsage;
4use crate::session_scan::SessionScanDiagnostics;
5use crate::time::{local_to_utc, StatGroupBy};
6use chrono::{DateTime, Datelike, Local, SecondsFormat, Timelike, Utc};
7use serde::Serialize;
8
9#[derive(Debug, Clone)]
10pub struct UsageRecordsReadOptions {
11 pub start: DateTime<Utc>,
12 pub end: DateTime<Utc>,
13 pub sessions_dir: std::path::PathBuf,
14 pub scan_all_files: bool,
15 pub account_history_file: Option<std::path::PathBuf>,
16 pub account_id: Option<String>,
17}
18
19#[derive(Clone, Debug)]
20pub struct UsageRecordsReport {
21 pub start: DateTime<Utc>,
22 pub end: DateTime<Utc>,
23 pub sessions_dir: String,
24 pub records: Vec<UsageRecord>,
25 pub diagnostics: UsageDiagnostics,
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub(super) enum LimitUsageGroupBy {
30 Window,
31 Model,
32 Cwd,
33 Account,
34}
35
36impl LimitUsageGroupBy {
37 pub(super) fn from_stat(value: Option<StatGroupBy>) -> Self {
38 match value {
39 Some(StatGroupBy::Model) => Self::Model,
40 Some(StatGroupBy::Cwd) => Self::Cwd,
41 Some(StatGroupBy::Account) => Self::Account,
42 _ => Self::Window,
43 }
44 }
45
46 pub(super) fn as_str(self) -> &'static str {
47 match self {
48 Self::Window => "window",
49 Self::Model => "model",
50 Self::Cwd => "cwd",
51 Self::Account => "account",
52 }
53 }
54
55 pub(super) fn as_stat(self) -> Option<StatGroupBy> {
56 match self {
57 Self::Window => None,
58 Self::Model => Some(StatGroupBy::Model),
59 Self::Cwd => Some(StatGroupBy::Cwd),
60 Self::Account => Some(StatGroupBy::Account),
61 }
62 }
63}
64
65#[derive(Clone, Debug, Default, Serialize, PartialEq)]
66#[serde(rename_all = "camelCase")]
67pub struct TokenUsage {
68 pub input_tokens: i64,
69 pub cached_input_tokens: i64,
70 pub output_tokens: i64,
71 pub reasoning_output_tokens: i64,
72 pub total_tokens: i64,
73}
74
75impl TokenUsage {
76 pub(super) fn add(&mut self, other: &TokenUsage) {
77 self.input_tokens += other.input_tokens;
78 self.cached_input_tokens += other.cached_input_tokens;
79 self.output_tokens += other.output_tokens;
80 self.reasoning_output_tokens += other.reasoning_output_tokens;
81 self.total_tokens += other.total_tokens;
82 }
83
84 pub(super) fn is_empty(&self) -> bool {
85 self.input_tokens == 0
86 && self.cached_input_tokens == 0
87 && self.output_tokens == 0
88 && self.reasoning_output_tokens == 0
89 && self.total_tokens == 0
90 }
91
92 pub(super) fn pricing_usage(&self) -> PricingTokenUsage {
93 PricingTokenUsage {
94 input_tokens: self.input_tokens.max(0) as u64,
95 cached_input_tokens: self.cached_input_tokens.max(0) as u64,
96 output_tokens: self.output_tokens.max(0) as u64,
97 }
98 }
99}
100
101#[derive(Clone, Debug)]
102pub struct UsageRecord {
103 pub timestamp: DateTime<Utc>,
104 pub session_id: String,
105 pub model: String,
106 pub reasoning_effort: Option<String>,
107 pub cwd: String,
108 pub account_id: Option<String>,
109 pub file_path: String,
110 pub usage: TokenUsage,
111}
112
113#[derive(Clone, Copy)]
114pub(super) struct UsageRecordView<'a> {
115 pub(super) timestamp: DateTime<Utc>,
116 pub(super) session_id: &'a str,
117 pub(super) model: &'a str,
118 pub(super) reasoning_effort: Option<&'a str>,
119 pub(super) cwd: &'a str,
120 pub(super) account_id: Option<&'a str>,
121 pub(super) file_path: &'a str,
122 pub(super) usage: &'a TokenUsage,
123}
124
125impl UsageRecordView<'_> {
126 pub(super) fn to_owned_record(self) -> UsageRecord {
127 UsageRecord {
128 timestamp: self.timestamp,
129 session_id: self.session_id.to_string(),
130 model: self.model.to_string(),
131 reasoning_effort: self.reasoning_effort.map(str::to_string),
132 cwd: self.cwd.to_string(),
133 account_id: self.account_id.map(str::to_string),
134 file_path: self.file_path.to_string(),
135 usage: self.usage.clone(),
136 }
137 }
138}
139
140#[derive(Clone, Debug, Serialize, PartialEq)]
141#[serde(rename_all = "camelCase")]
142pub struct UsageStatRow {
143 pub(super) key: String,
144 pub(super) sessions: usize,
145 pub(super) calls: i64,
146 pub(super) usage: TokenUsage,
147 pub(super) credits: f64,
148 pub(super) usd: f64,
149 pub(super) priced_calls: i64,
150 pub(super) unpriced_calls: i64,
151}
152
153#[derive(Clone, Debug, Serialize, PartialEq)]
154#[serde(rename_all = "camelCase")]
155pub struct UsageUnpricedModelRow {
156 pub(super) model: String,
157 pub(super) pricing_key: String,
158 pub(super) calls: i64,
159 pub(super) total_tokens: i64,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub(super) note: Option<String>,
162 pub(super) pricing_stub: String,
163}
164
165#[derive(Clone, Debug, Serialize, PartialEq)]
166#[serde(rename_all = "camelCase")]
167pub struct UsageDiagnostics {
168 pub scan_all_files: bool,
169 pub scanned_directories: i64,
170 pub skipped_directories: i64,
171 pub read_files: i64,
172 pub skipped_files: i64,
173 pub prefiltered_files: i64,
174 pub tail_read_files: i64,
175 pub tail_read_hits: i64,
176 pub mtime_read_files: i64,
177 pub mtime_tail_hits: i64,
178 pub mtime_read_hits: i64,
179 pub fork_files: i64,
180 pub fork_parent_missing: i64,
181 pub fork_replay_lines: i64,
182 pub read_lines: i64,
183 pub invalid_json_lines: i64,
184 pub token_count_events: i64,
185 pub included_usage_events: i64,
186 pub skipped_events: SkippedEvents,
187 pub file_read_concurrency: i64,
188}
189
190#[derive(Clone, Debug, Default, Serialize, PartialEq)]
191#[serde(rename_all = "camelCase")]
192pub struct SkippedEvents {
193 pub missing_metadata: i64,
194 pub missing_usage: i64,
195 pub empty_usage: i64,
196 pub out_of_range: i64,
197 pub account_mismatch: i64,
198 pub fork_replay: i64,
199}
200
201impl UsageDiagnostics {
202 pub(super) fn new(file_read_concurrency: i64, scan_all_files: bool) -> Self {
203 Self {
204 scan_all_files,
205 scanned_directories: 0,
206 skipped_directories: 0,
207 read_files: 0,
208 skipped_files: 0,
209 prefiltered_files: 0,
210 tail_read_files: 0,
211 tail_read_hits: 0,
212 mtime_read_files: 0,
213 mtime_tail_hits: 0,
214 mtime_read_hits: 0,
215 fork_files: 0,
216 fork_parent_missing: 0,
217 fork_replay_lines: 0,
218 read_lines: 0,
219 invalid_json_lines: 0,
220 token_count_events: 0,
221 included_usage_events: 0,
222 skipped_events: SkippedEvents::default(),
223 file_read_concurrency,
224 }
225 }
226
227 pub(super) fn merge_file_scan(&mut self, other: &UsageDiagnostics) {
228 self.prefiltered_files += other.prefiltered_files;
229 self.tail_read_files += other.tail_read_files;
230 self.tail_read_hits += other.tail_read_hits;
231 self.mtime_read_files += other.mtime_read_files;
232 self.mtime_tail_hits += other.mtime_tail_hits;
233 self.mtime_read_hits += other.mtime_read_hits;
234 self.fork_files += other.fork_files;
235 self.fork_parent_missing += other.fork_parent_missing;
236 self.fork_replay_lines += other.fork_replay_lines;
237 self.read_lines += other.read_lines;
238 self.invalid_json_lines += other.invalid_json_lines;
239 self.token_count_events += other.token_count_events;
240 self.included_usage_events += other.included_usage_events;
241 self.skipped_events.missing_metadata += other.skipped_events.missing_metadata;
242 self.skipped_events.missing_usage += other.skipped_events.missing_usage;
243 self.skipped_events.empty_usage += other.skipped_events.empty_usage;
244 self.skipped_events.out_of_range += other.skipped_events.out_of_range;
245 self.skipped_events.account_mismatch += other.skipped_events.account_mismatch;
246 self.skipped_events.fork_replay += other.skipped_events.fork_replay;
247 }
248
249 pub(crate) fn merge_session_scan(&mut self, other: &SessionScanDiagnostics) {
250 self.scanned_directories += other.scanned_directories;
251 self.skipped_directories += other.skipped_directories;
252 self.read_files += other.read_files;
253 self.skipped_files += other.skipped_files;
254 self.prefiltered_files += other.prefiltered_files;
255 self.tail_read_files += other.tail_read_files;
256 self.tail_read_hits += other.tail_read_hits;
257 self.mtime_read_files += other.mtime_read_files;
258 self.mtime_tail_hits += other.mtime_tail_hits;
259 self.mtime_read_hits += other.mtime_read_hits;
260 self.fork_files += other.fork_files;
261 self.fork_parent_missing += other.fork_parent_missing;
262 self.fork_replay_lines += other.fork_replay_lines;
263 }
264}
265
266#[derive(Clone, Debug)]
267pub(super) struct UsageStatsReport {
268 pub(super) start: DateTime<Utc>,
269 pub(super) end: DateTime<Utc>,
270 pub(super) group_by: StatGroupBy,
271 pub(super) include_reasoning_effort: bool,
272 pub(super) sort_by: Option<StatSort>,
273 pub(super) limit: Option<usize>,
274 pub(super) sessions_dir: String,
275 pub(super) rows: Vec<UsageStatRow>,
276 pub(super) totals: UsageStatRow,
277 pub(super) unpriced_models: Vec<UsageUnpricedModelRow>,
278 pub(super) diagnostics: Option<UsageDiagnostics>,
279}
280
281#[derive(Clone, Debug, Serialize, PartialEq)]
282#[serde(rename_all = "camelCase")]
283pub(super) struct LimitUsageRow {
284 pub(super) window_id: String,
285 pub(super) window: String,
286 pub(super) window_minutes: i64,
287 pub(super) window_start: Option<DateTime<Utc>>,
288 pub(super) reset_at: Option<DateTime<Utc>>,
289 pub(super) observed: bool,
290 pub(super) group_by: &'static str,
291 pub(super) group_key: String,
292 pub(super) sessions: usize,
293 pub(super) calls: i64,
294 pub(super) usage: TokenUsage,
295 pub(super) credits: f64,
296 pub(super) usd: f64,
297 pub(super) priced_calls: i64,
298 pub(super) unpriced_calls: i64,
299}
300
301#[derive(Clone, Debug, Serialize, PartialEq)]
302#[serde(rename_all = "camelCase")]
303pub(super) struct LimitUsageDiagnostics {
304 pub(super) observed_windows: i64,
305 pub(super) unobserved_usage_events: i64,
306 pub(super) usage: UsageDiagnostics,
307 pub(super) rate_limits: RateLimitDiagnostics,
308}
309
310#[derive(Clone, Debug)]
311pub(super) struct LimitUsageReport {
312 pub(super) start: DateTime<Utc>,
313 pub(super) end: DateTime<Utc>,
314 pub(super) limit_window: &'static str,
315 pub(super) window_minutes: i64,
316 pub(super) group_by: LimitUsageGroupBy,
317 pub(super) include_reasoning_effort: bool,
318 pub(super) sort_by: Option<StatSort>,
319 pub(super) limit: Option<usize>,
320 pub(super) sessions_dir: String,
321 pub(super) rows: Vec<LimitUsageRow>,
322 pub(super) totals: UsageStatRow,
323 pub(super) unpriced_models: Vec<UsageUnpricedModelRow>,
324 pub(super) diagnostics: Option<LimitUsageDiagnostics>,
325}
326
327#[derive(Clone, Debug)]
328pub(super) struct UsageSessionRow {
329 pub(super) session_id: String,
330 pub(super) model: String,
331 pub(super) cwd: String,
332 pub(super) first_seen: DateTime<Utc>,
333 pub(super) last_seen: DateTime<Utc>,
334 pub(super) calls: i64,
335 pub(super) usage: TokenUsage,
336 pub(super) credits: f64,
337 pub(super) usd: f64,
338 pub(super) priced_calls: i64,
339 pub(super) unpriced_calls: i64,
340 pub(super) file_path: String,
341}
342
343#[derive(Clone, Debug)]
344pub(super) struct UsageSessionEventRow {
345 pub(super) timestamp: DateTime<Utc>,
346 pub(super) model: String,
347 pub(super) reasoning_effort: Option<String>,
348 pub(super) cwd: String,
349 pub(super) usage: TokenUsage,
350 pub(super) credits: f64,
351 pub(super) usd: f64,
352 pub(super) priced: bool,
353 pub(super) file_path: String,
354}
355
356#[derive(Clone, Debug)]
357pub(super) struct UsageSessionCompactRow {
358 pub(super) start: DateTime<Utc>,
359 pub(super) end: DateTime<Utc>,
360 pub(super) events: usize,
361 pub(super) model: String,
362 pub(super) reasoning_effort: Option<String>,
363 pub(super) usage: TokenUsage,
364 pub(super) credits: f64,
365 pub(super) usd: f64,
366 pub(super) unpriced_calls: i64,
367}
368
369#[derive(Clone, Debug)]
370pub(super) struct UsageSessionsReport {
371 pub(super) start: DateTime<Utc>,
372 pub(super) end: DateTime<Utc>,
373 pub(super) sort_by: Option<StatSort>,
374 pub(super) limit: usize,
375 pub(super) sessions_dir: String,
376 pub(super) rows: Vec<UsageSessionRow>,
377 pub(super) totals: UsageStatRow,
378 pub(super) unpriced_models: Vec<UsageUnpricedModelRow>,
379 pub(super) diagnostics: Option<UsageDiagnostics>,
380}
381
382#[derive(Clone, Debug)]
383pub(super) struct UsageSessionDetailReport {
384 pub(super) start: DateTime<Utc>,
385 pub(super) end: DateTime<Utc>,
386 pub(super) session_id: String,
387 pub(super) limit: Option<usize>,
388 pub(super) sessions_dir: String,
389 pub(super) summary: Option<UsageSessionRow>,
390 pub(super) rows: Vec<UsageSessionEventRow>,
391 pub(super) by_model: Vec<UsageStatRow>,
392 pub(super) by_cwd: Vec<UsageStatRow>,
393 pub(super) by_reasoning_effort: Vec<UsageStatRow>,
394 pub(super) model_switches: i64,
395 pub(super) cwd_switches: i64,
396 pub(super) reasoning_effort_switches: i64,
397 pub(super) totals: UsageStatRow,
398 pub(super) unpriced_models: Vec<UsageUnpricedModelRow>,
399 pub(super) diagnostics: Option<UsageDiagnostics>,
400}
401
402#[derive(Serialize)]
403#[serde(rename_all = "camelCase")]
404pub(super) struct UsageStatsJson<'a> {
405 start: String,
406 end: String,
407 group_by: &'static str,
408 include_reasoning_effort: bool,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 sort_by: Option<&'static str>,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 limit: Option<usize>,
413 sessions_dir: &'a str,
414 rows: &'a [UsageStatRow],
415 totals: &'a UsageStatRow,
416 unpriced_models: &'a [UsageUnpricedModelRow],
417 warnings: Vec<String>,
418 #[serde(skip_serializing_if = "Option::is_none")]
419 diagnostics: Option<&'a UsageDiagnostics>,
420}
421
422#[derive(Serialize)]
423#[serde(rename_all = "camelCase")]
424pub(super) struct LimitUsageJson<'a> {
425 start: String,
426 end: String,
427 limit_window: &'static str,
428 window_minutes: i64,
429 group_by: &'static str,
430 include_reasoning_effort: bool,
431 #[serde(skip_serializing_if = "Option::is_none")]
432 sort_by: Option<&'static str>,
433 #[serde(skip_serializing_if = "Option::is_none")]
434 limit: Option<usize>,
435 sessions_dir: &'a str,
436 rows: &'a [LimitUsageRow],
437 totals: &'a UsageStatRow,
438 unpriced_models: &'a [UsageUnpricedModelRow],
439 warnings: Vec<String>,
440 #[serde(skip_serializing_if = "Option::is_none")]
441 diagnostics: Option<&'a LimitUsageDiagnostics>,
442}
443
444#[derive(Serialize)]
445#[serde(rename_all = "camelCase")]
446pub(super) struct UsageSessionsJson<'a> {
447 start: String,
448 end: String,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 sort_by: Option<&'static str>,
451 limit: usize,
452 sessions_dir: &'a str,
453 rows: Vec<UsageSessionRowJson<'a>>,
454 totals: &'a UsageStatRow,
455 unpriced_models: &'a [UsageUnpricedModelRow],
456 warnings: Vec<String>,
457 #[serde(skip_serializing_if = "Option::is_none")]
458 diagnostics: Option<&'a UsageDiagnostics>,
459}
460
461#[derive(Serialize)]
462#[serde(rename_all = "camelCase")]
463pub(super) struct UsageSessionRowJson<'a> {
464 session_id: &'a str,
465 model: &'a str,
466 cwd: &'a str,
467 first_seen: String,
468 last_seen: String,
469 calls: i64,
470 usage: &'a TokenUsage,
471 credits: f64,
472 usd: f64,
473 priced_calls: i64,
474 unpriced_calls: i64,
475 file_path: &'a str,
476}
477
478#[derive(Serialize)]
479#[serde(rename_all = "camelCase")]
480pub(super) struct UsageSessionDetailJson<'a> {
481 start: String,
482 end: String,
483 session_id: &'a str,
484 #[serde(skip_serializing_if = "Option::is_none")]
485 limit: Option<usize>,
486 sessions_dir: &'a str,
487 #[serde(skip_serializing_if = "Option::is_none")]
488 summary: Option<UsageSessionRowJson<'a>>,
489 rows: Vec<UsageSessionEventRowJson<'a>>,
490 by_model: &'a [UsageStatRow],
491 by_cwd: &'a [UsageStatRow],
492 by_reasoning_effort: &'a [UsageStatRow],
493 model_switches: i64,
494 cwd_switches: i64,
495 reasoning_effort_switches: i64,
496 totals: &'a UsageStatRow,
497 unpriced_models: &'a [UsageUnpricedModelRow],
498 warnings: Vec<String>,
499 #[serde(skip_serializing_if = "Option::is_none")]
500 diagnostics: Option<&'a UsageDiagnostics>,
501}
502
503#[derive(Serialize)]
504#[serde(rename_all = "camelCase")]
505pub(super) struct UsageSessionEventRowJson<'a> {
506 timestamp: String,
507 model: &'a str,
508 #[serde(skip_serializing_if = "Option::is_none")]
509 reasoning_effort: Option<&'a str>,
510 cwd: &'a str,
511 usage: &'a TokenUsage,
512 credits: f64,
513 usd: f64,
514 priced: bool,
515 file_path: &'a str,
516}
517
518pub(super) fn to_usage_stats_json(report: &UsageStatsReport) -> UsageStatsJson<'_> {
519 UsageStatsJson {
520 start: iso_string(report.start),
521 end: iso_string(report.end),
522 group_by: report.group_by.as_str(),
523 include_reasoning_effort: report.include_reasoning_effort,
524 sort_by: report.sort_by.map(StatSort::as_str),
525 limit: report.limit,
526 sessions_dir: &report.sessions_dir,
527 rows: &report.rows,
528 totals: &report.totals,
529 unpriced_models: &report.unpriced_models,
530 warnings: usage_warnings(report.start, report.end, report.diagnostics.as_ref()),
531 diagnostics: report.diagnostics.as_ref(),
532 }
533}
534
535pub(super) fn to_limit_usage_json(report: &LimitUsageReport) -> LimitUsageJson<'_> {
536 LimitUsageJson {
537 start: iso_string(report.start),
538 end: iso_string(report.end),
539 limit_window: report.limit_window,
540 window_minutes: report.window_minutes,
541 group_by: report.group_by.as_str(),
542 include_reasoning_effort: report.include_reasoning_effort,
543 sort_by: report.sort_by.map(StatSort::as_str),
544 limit: report.limit,
545 sessions_dir: &report.sessions_dir,
546 rows: &report.rows,
547 totals: &report.totals,
548 unpriced_models: &report.unpriced_models,
549 warnings: limit_usage_warnings(report),
550 diagnostics: report.diagnostics.as_ref(),
551 }
552}
553
554pub(super) fn to_usage_sessions_json(report: &UsageSessionsReport) -> UsageSessionsJson<'_> {
555 UsageSessionsJson {
556 start: iso_string(report.start),
557 end: iso_string(report.end),
558 sort_by: report.sort_by.map(StatSort::as_str),
559 limit: report.limit,
560 sessions_dir: &report.sessions_dir,
561 rows: report.rows.iter().map(to_session_row_json).collect(),
562 totals: &report.totals,
563 unpriced_models: &report.unpriced_models,
564 warnings: usage_warnings(report.start, report.end, report.diagnostics.as_ref()),
565 diagnostics: report.diagnostics.as_ref(),
566 }
567}
568
569pub(super) fn to_usage_session_detail_json(
570 report: &UsageSessionDetailReport,
571) -> UsageSessionDetailJson<'_> {
572 UsageSessionDetailJson {
573 start: iso_string(report.start),
574 end: iso_string(report.end),
575 session_id: &report.session_id,
576 limit: report.limit,
577 sessions_dir: &report.sessions_dir,
578 summary: report.summary.as_ref().map(to_session_row_json),
579 rows: report.rows.iter().map(to_session_event_row_json).collect(),
580 by_model: &report.by_model,
581 by_cwd: &report.by_cwd,
582 by_reasoning_effort: &report.by_reasoning_effort,
583 model_switches: report.model_switches,
584 cwd_switches: report.cwd_switches,
585 reasoning_effort_switches: report.reasoning_effort_switches,
586 totals: &report.totals,
587 unpriced_models: &report.unpriced_models,
588 warnings: usage_warnings(report.start, report.end, report.diagnostics.as_ref()),
589 diagnostics: report.diagnostics.as_ref(),
590 }
591}
592
593fn to_session_row_json(row: &UsageSessionRow) -> UsageSessionRowJson<'_> {
594 UsageSessionRowJson {
595 session_id: &row.session_id,
596 model: &row.model,
597 cwd: &row.cwd,
598 first_seen: iso_string(row.first_seen),
599 last_seen: iso_string(row.last_seen),
600 calls: row.calls,
601 usage: &row.usage,
602 credits: row.credits,
603 usd: row.usd,
604 priced_calls: row.priced_calls,
605 unpriced_calls: row.unpriced_calls,
606 file_path: &row.file_path,
607 }
608}
609
610fn to_session_event_row_json(row: &UsageSessionEventRow) -> UsageSessionEventRowJson<'_> {
611 UsageSessionEventRowJson {
612 timestamp: iso_string(row.timestamp),
613 model: &row.model,
614 reasoning_effort: row.reasoning_effort.as_deref(),
615 cwd: &row.cwd,
616 usage: &row.usage,
617 credits: row.credits,
618 usd: row.usd,
619 priced: row.priced,
620 file_path: &row.file_path,
621 }
622}
623
624pub(super) fn usage_warnings(
625 _start: DateTime<Utc>,
626 _end: DateTime<Utc>,
627 _diagnostics: Option<&UsageDiagnostics>,
628) -> Vec<String> {
629 Vec::new()
630}
631
632pub(super) fn limit_usage_warnings(report: &LimitUsageReport) -> Vec<String> {
633 let mut warnings = Vec::new();
634 if report
635 .diagnostics
636 .as_ref()
637 .is_some_and(|diagnostics| diagnostics.unobserved_usage_events > 0)
638 {
639 warnings.push(
640 "Some usage events were not inside an observed rate-limit window and are grouped as unobserved.".to_string(),
641 );
642 }
643 warnings
644}
645
646pub(super) fn is_all_usage_range(start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
647 start == local_to_utc(1900, 1, 1, 0, 0, 0, 0)
648 && end == local_to_utc(9999, 12, 31, 23, 59, 59, 999)
649}
650
651pub(super) fn format_report_range(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
652 if is_all_usage_range(start, end) {
653 "all".to_string()
654 } else {
655 format!("{} to {}", format_date_time(start), format_date_time(end))
656 }
657}
658
659pub(super) fn format_group_by(report: &UsageStatsReport) -> String {
660 if report.group_by == StatGroupBy::Model && report.include_reasoning_effort {
661 "model + reasoning_effort".to_string()
662 } else {
663 report.group_by.as_str().to_string()
664 }
665}
666
667pub(super) fn format_date_time(date: DateTime<Utc>) -> String {
668 let local = date.with_timezone(&Local);
669 format!(
670 "{}-{:02}-{:02} {:02}:{:02}:{:02}",
671 local.year(),
672 local.month(),
673 local.day(),
674 local.hour(),
675 local.minute(),
676 local.second()
677 )
678}
679
680fn iso_string(value: DateTime<Utc>) -> String {
681 value.to_rfc3339_opts(SecondsFormat::Millis, true)
682}