1use crate::arkuse::api::Usage;
17use crate::arkuse::ark::Catalog;
18use crate::arkuse::db::Row;
19
20pub const FIVE_HOUR_SECS: i64 = 5 * 3600;
21pub const DAY_SECS: i64 = 86_400;
22pub const WEEKLY_SECS: i64 = 7 * 86_400;
23pub const LOOKBACK_SECS: i64 = 120 * 86_400;
25
26#[derive(Debug, Clone, Default)]
27pub struct Totals {
28 pub afp: f64,
31 pub requests: u64,
32 pub input: u64,
33 pub output: u64,
34 pub cache_read: u64,
35 pub cache_write: u64,
36 pub oldest_ms: Option<i64>,
39}
40
41impl Totals {
42 pub fn add(&mut self, row: &Row, afp: Option<f64>) {
46 self.afp += afp.unwrap_or(0.0);
47 self.requests += 1;
48 self.input += row.input;
49 self.output += row.output;
50 self.cache_read += row.cache_read;
51 self.cache_write += row.cache_write;
52 self.oldest_ms = Some(self.oldest_ms.map_or(row.at_ms, |at| at.min(row.at_ms)));
53 }
54
55 pub fn tokens(&self) -> u64 {
56 self.input + self.output + self.cache_read
57 }
58}
59
60#[derive(Debug, Clone)]
61pub struct ModelUsage {
62 pub id: String,
63 pub provider: String,
64 pub coef: f64,
66 pub month: Totals,
67 pub five_hour: Totals,
68 pub weekly: Totals,
69}
70
71#[derive(Debug, Clone)]
72pub struct Report {
73 pub period_label: String,
74 pub now_ms: i64,
75 pub since_ms: i64,
76 pub period_end_ms: Option<i64>,
77 pub month_inferred: bool,
78 pub account: Option<Usage>,
80 pub account_error: Option<String>,
83 pub clients: Vec<String>,
87 pub tier: String,
90 pub profile: Option<String>,
92 pub offset_secs: i64,
95 pub five_hour: Totals,
96 pub weekly: Totals,
97 pub month: Totals,
98 pub daily: Option<Totals>,
101 pub models: Vec<ModelUsage>,
103}
104
105impl Report {
106 pub fn afp_per_request(&self) -> Option<f64> {
110 let account = self.account.as_ref()?;
111 let requests = self.five_hour.requests;
112 (requests > 0).then(|| account.five_hour.used / requests as f64)
113 }
114
115 pub fn reconcile(&self) -> Option<f64> {
120 let account = self.account.as_ref()?;
121 if account.five_hour.used <= 0.0 || self.five_hour.afp <= 0.0 {
122 return None;
123 }
124 Some((self.five_hour.afp - account.five_hour.used) / account.five_hour.used * 100.0)
125 }
126
127 pub fn burn_per_day(&self) -> Option<f64> {
130 let account = self.account.as_ref()?;
131 (account.five_hour.used > 0.0).then(|| account.five_hour.used / 5.0 * 24.0)
132 }
133
134 pub fn window_quota(&self, catalog: &Catalog, share: f64) -> Option<f64> {
139 catalog.plan(&self.tier).map(|plan| plan.afp * share)
140 }
141
142 pub fn rolling_reset(&self, totals: &Totals, dur_secs: i64) -> Option<i64> {
146 totals.oldest_ms.map(|at| at + dur_secs * 1000)
147 }
148}
149
150fn day_of_month(epoch_secs: i64) -> u32 {
153 civil(epoch_secs).2 as u32
154}
155
156pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
157 let y = if m <= 2 { y - 1 } else { y };
158 let era = y.div_euclid(400);
159 let yoe = y - era * 400;
160 let mp = if m > 2 { m - 3 } else { m + 9 };
161 let doy = (153 * mp + 2) / 5 + d - 1;
162 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
163 era * 146_097 + doe - 719_468
164}
165
166fn civil(epoch_secs: i64) -> (i64, i64, i64) {
167 let days = epoch_secs.div_euclid(DAY_SECS);
168 let z = days + 719_468;
169 let era = z.div_euclid(146_097);
170 let doe = z.rem_euclid(146_097);
171 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
172 let y = yoe + era * 400;
173 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
174 let mp = (5 * doy + 2) / 153;
175 let d = doy - (153 * mp + 2) / 5 + 1;
176 let m = if mp < 10 { mp + 3 } else { mp - 9 };
177 (if m <= 2 { y + 1 } else { y }, m, d)
178}
179
180pub fn now_ms() -> i64 {
182 std::time::SystemTime::now()
183 .duration_since(std::time::UNIX_EPOCH)
184 .map(|d| d.as_millis() as i64)
185 .unwrap_or(0)
186}
187
188pub fn calendar_month_start(now_ms: i64) -> i64 {
191 let (year, month, _) = civil(now_ms / 1000);
192 days_from_civil(year, month, 1) * 86_400_000
193}
194
195pub fn next_month(ms: i64) -> i64 {
199 let (year, month, day) = civil(ms / 1000);
200 let (ny, nm) = if month == 12 {
201 (year + 1, 1)
202 } else {
203 (year, month + 1)
204 };
205 days_from_civil(ny, nm, day.min(28)) * 86_400_000
206}
207
208pub fn month_start_before(reset_ms: i64, offset_secs: i64) -> i64 {
216 let local = reset_ms + offset_secs * 1000;
217 let (year, month, day) = civil(local.div_euclid(1000));
218 let (py, pm) = if month == 1 {
219 (year - 1, 12)
220 } else {
221 (year, month - 1)
222 };
223 days_from_civil(py, pm, day.min(28)) * 86_400_000 - offset_secs * 1000
224}
225
226pub fn before_day_end(at_ms: i64, day: &str) -> bool {
229 let mut parts = day.split('-');
230 let (Some(y), Some(m), Some(d)) = (parts.next(), parts.next(), parts.next()) else {
231 return false;
232 };
233 let (Ok(y), Ok(m), Ok(d)) = (y.parse::<i64>(), m.parse::<i64>(), d.parse::<i64>()) else {
234 return false;
235 };
236 let end = (days_from_civil(y, m, d) + 1) * 86_400_000;
237 at_ms < end
238}
239
240pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
243 let now_secs = now_ms / 1000;
244 let (year, month, _) = civil(now_secs);
245 if let Some(ms) = rows.iter().map(|r| r.at_ms).min() {
246 let wanted = day_of_month(ms / 1000).min(28) as i64;
247 let candidate = days_from_civil(year, month, wanted) * DAY_SECS;
248 if candidate <= now_secs {
249 return (candidate * 1000, true);
250 }
251 let (py, pm) = if month == 1 {
252 (year - 1, 12)
253 } else {
254 (year, month - 1)
255 };
256 return (days_from_civil(py, pm, wanted) * DAY_SECS * 1000, true);
257 }
258 (days_from_civil(year, month, 1) * DAY_SECS * 1000, false)
259}
260
261pub fn build(
263 rows: &[Row],
264 catalog: &Catalog,
265 plan_id: &str,
266 now_ms: i64,
267 since_override: Option<i64>,
268 account: Option<&Usage>,
269) -> Report {
270 let (inferred_start, month_inferred) = month_start(rows, now_ms);
271 let since_ms = since_override.unwrap_or(inferred_start);
272 let (five_start, five_end) = match account.and_then(|a| a.five_hour.reset_ms()) {
277 Some(reset) => (reset - FIVE_HOUR_SECS * 1000, reset),
278 None => (now_ms - FIVE_HOUR_SECS * 1000, i64::MAX),
279 };
280 let (week_start, week_end) = match account.and_then(|a| a.weekly.reset_ms()) {
281 Some(reset) => (reset - WEEKLY_SECS * 1000, reset),
282 None => (now_ms - WEEKLY_SECS * 1000, i64::MAX),
283 };
284 let day_bounds = account
287 .and_then(|a| a.daily.as_ref())
288 .and_then(|m| m.reset_ms())
289 .map(|reset| (reset - DAY_SECS * 1000, reset));
290
291 let mut report = Report {
292 period_label: "calendar month".into(),
293 now_ms,
294 since_ms,
295 period_end_ms: None,
296 month_inferred: since_override.is_none() && month_inferred,
297 account: None,
298 account_error: None,
299 clients: Vec::new(),
300 tier: plan_id.to_string(),
301 profile: account.map(|a| a.profile.clone()),
302 offset_secs: account.map_or(0, |a| a.monthly.offset_secs()),
303 five_hour: Totals::default(),
304 weekly: Totals::default(),
305 month: Totals::default(),
306 daily: day_bounds.map(|_| Totals::default()),
307 models: Vec::new(),
308 };
309 let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();
310
311 for row in rows {
314 if row.client.is_empty() {
315 continue; }
317 if !report.clients.iter().any(|client| client == row.client) {
318 report.clients.push(row.client.to_string());
319 }
320 }
321
322 for row in rows {
323 let in_month = row.at_ms >= since_ms;
324 let in_week = row.at_ms >= week_start && row.at_ms < week_end;
325 let in_five = row.at_ms >= five_start && row.at_ms < five_end;
326 let in_day = day_bounds.is_some_and(|(start, end)| row.at_ms >= start && row.at_ms < end);
327 if !in_month && !in_week && !in_five && !in_day {
328 continue;
329 }
330 let afp = catalog.afp(&row.model, row.at_ms, row.input, row.cache_read, row.output);
331 if in_month {
332 report.month.add(row, afp);
333 }
334 if in_day {
335 report
336 .daily
337 .get_or_insert_with(Totals::default)
338 .add(row, afp);
339 }
340 if in_week {
341 report.weekly.add(row, afp);
342 }
343 if in_five {
344 report.five_hour.add(row, afp);
345 }
346 let coef = catalog
347 .models
348 .iter()
349 .find(|m| m.id == row.model)
350 .map(|m| crate::arkuse::ark::effective_coef(m, row.at_ms))
351 .unwrap_or(0.0);
352 let entry = by_model
353 .entry(row.model.clone())
354 .or_insert_with(|| ModelUsage {
355 id: row.model.clone(),
356 provider: row.provider.clone(),
357 coef,
358 month: Totals::default(),
359 five_hour: Totals::default(),
360 weekly: Totals::default(),
361 });
362 if in_month {
363 entry.month.add(row, afp);
364 }
365 if in_week {
366 entry.weekly.add(row, afp);
367 }
368 if in_five {
369 entry.five_hour.add(row, afp);
370 }
371 }
372
373 report.models = by_model.into_values().collect();
374 report.models.sort_by(|a, b| {
375 b.month
376 .afp
377 .partial_cmp(&a.month.afp)
378 .unwrap_or(std::cmp::Ordering::Equal)
379 .then_with(|| a.id.cmp(&b.id))
380 });
381 report
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::arkuse::db::Row;
388
389 fn row(at_s: i64, model: &str) -> Row {
390 Row {
391 at_ms: at_s * 1000,
392 provider: "agent-plan".into(),
393 model: model.into(),
394 input: 10_000,
395 ..Default::default()
396 }
397 }
398
399 fn meter(percent: f64, used: f64, resets_at: &str) -> crate::arkuse::api::Meter {
400 crate::arkuse::api::Meter::from_parts("ok", percent, used, 0.0, Some(resets_at), None)
401 }
402
403 #[test]
404 fn civil_helpers_round_trip() {
405 let secs = days_from_civil(2026, 9, 27) * 86_400;
406 assert_eq!(civil(secs), (2026, 9, 27));
407 assert_eq!(day_of_month(secs), 27);
408 }
409
410 #[test]
414 fn month_start_follows_the_servers_anniversary_and_offset() {
415 let reset = (days_from_civil(2026, 11, 19) * 86_400 + 23 * 3600 + 59 * 60 + 59) * 1000
417 - 8 * 3600 * 1000;
418 let start = month_start_before(reset, 8 * 3600);
419 let local = (start + 8 * 3600 * 1000) / 1000;
420 assert_eq!(civil(local), (2026, 10, 19), "00:00 on the 19th, +08:00");
421 assert_eq!(local.rem_euclid(DAY_SECS), 0);
422 }
423
424 #[test]
425 fn promo_dates_end_at_the_days_close() {
426 let day = days_from_civil(2026, 12, 30) * 86_400_000;
427 assert!(before_day_end(day + 3_600_000, "2026-12-30"));
428 assert!(!before_day_end(day + 86_400_000, "2026-12-30"));
429 }
430
431 #[test]
432 fn the_meters_bounds_cut_the_windows_when_they_answered() {
433 let now = days_from_civil(2026, 10, 19) * 86_400 + 12 * 3600;
435 let account = crate::arkuse::api::Usage {
436 five_hour: meter(6.0, 600.0, "2026-10-19T23:00:00+08:00"),
438 daily: None,
439 weekly: meter(3.0, 1_050.0, "2026-10-26T00:00:00+08:00"),
441 monthly: meter(51.0, 51_000.0, "2026-11-19T23:59:59+08:00"),
442 tier: Some("medium".into()),
443 profile: "agent-plan_cn-beijing_medium".into(),
444 };
445 let rows = vec![
446 row(now - 8 * 86_400, "deepseek-v4.1-flash"),
448 row(now - 6 * 3600, "deepseek-v4.1-flash"),
450 row(now - 60, "deepseek-v4.1-flash"),
452 ];
453 let catalog = crate::arkuse::ark::catalog();
454 let month_start = days_from_civil(2026, 10, 1) * 86_400;
455 let report = build(
456 &rows,
457 &catalog,
458 "medium",
459 now * 1000,
460 Some(month_start * 1000),
461 Some(&account),
462 );
463 assert_eq!(report.five_hour.requests, 1, "only the in-block row");
464 assert_eq!(report.weekly.requests, 2, "the block plus six hours back");
465 assert_eq!(report.month.requests, 3, "all of them inside October");
466 assert!(report.five_hour.afp > 0.0);
467 }
468
469 #[test]
470 fn a_rolling_window_borrows_its_boundary_from_its_oldest_row() {
471 let now = days_from_civil(2026, 10, 19) * 86_400;
475 let rows = vec![
476 row(now - 4 * 3600, "deepseek-v4.1-flash"),
477 row(now - 60, "deepseek-v4.1-flash"),
478 ];
479 let catalog = crate::arkuse::ark::catalog();
480 let report = build(&rows, &catalog, "medium", now * 1000, None, None);
481 assert_eq!(report.five_hour.requests, 2);
482 assert_eq!(report.five_hour.oldest_ms, Some((now - 4 * 3600) * 1000));
483 assert_eq!(
484 report.rolling_reset(&report.five_hour, FIVE_HOUR_SECS),
485 Some((now + 3600) * 1000),
486 "four hours of the block already spent"
487 );
488 assert_eq!(
489 report.window_quota(&catalog, catalog.windows.five_hour),
490 Some(10_000.0),
491 "medium's published 5-hour split"
492 );
493 assert_eq!(
494 report.rolling_reset(&Totals::default(), FIVE_HOUR_SECS),
495 None
496 );
497 }
498
499 #[test]
500 fn a_daily_meter_gets_its_own_cut_and_a_text_plan_has_none() {
501 let now = days_from_civil(2026, 10, 19) * 86_400 + 12 * 3600;
503 let account = crate::arkuse::api::Usage {
504 five_hour: meter(6.0, 600.0, "2026-10-19T23:00:00+08:00"),
505 daily: Some(meter(1.0, 100.0, "2026-10-19T15:00:00+08:00")),
507 weekly: meter(3.0, 1_050.0, "2026-10-26T00:00:00+08:00"),
508 monthly: meter(51.0, 51_000.0, "2026-11-19T23:59:59+08:00"),
509 tier: Some("medium".into()),
510 profile: "agent-plan_cn-beijing_medium".into(),
511 };
512 let rows = vec![
513 row(now - 10 * 3600, "deepseek-v4.1-flash"), row(now - 3600, "deepseek-v4.1-flash"), ];
516 let catalog = crate::arkuse::ark::catalog();
517 let report = build(&rows, &catalog, "medium", now * 1000, None, Some(&account));
518 assert_eq!(report.daily.expect("media plan exposes a day").requests, 1);
519 let mut text_only = account;
521 text_only.daily = None;
522 let report = build(
523 &rows,
524 &catalog,
525 "medium",
526 now * 1000,
527 None,
528 Some(&text_only),
529 );
530 assert!(report.daily.is_none());
531 }
532
533 #[test]
534 fn models_sort_by_month_afp() {
535 let now = days_from_civil(2026, 9, 27) * 86_400;
536 let rows = vec![
537 row(now - 60, "glm-5.3-flash"),
538 row(now - 60, "deepseek-v4.1-flash"),
539 ];
540 let catalog = crate::arkuse::ark::catalog();
541 let report = build(&rows, &catalog, "medium", now * 1000, None, None);
542 assert_eq!(report.models[0].id, "deepseek-v4.1-flash", "2.5 > 0.5");
543 }
544
545 #[test]
546 fn an_unknown_model_counts_a_request_but_no_afp() {
547 let now = days_from_civil(2026, 9, 27) * 86_400;
548 let rows = vec![row(now - 60, "not-a-model")];
549 let catalog = crate::arkuse::ark::catalog();
550 let report = build(&rows, &catalog, "medium", now * 1000, None, None);
551 assert_eq!(report.month.requests, 1);
552 assert_eq!(report.month.afp, 0.0);
553 }
554}