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}
37
38impl Totals {
39 pub fn add(&mut self, row: &Row, afp: Option<f64>) {
43 self.afp += afp.unwrap_or(0.0);
44 self.requests += 1;
45 self.input += row.input;
46 self.output += row.output;
47 self.cache_read += row.cache_read;
48 self.cache_write += row.cache_write;
49 }
50
51 pub fn tokens(&self) -> u64 {
52 self.input + self.output + self.cache_read
53 }
54}
55
56#[derive(Debug, Clone)]
57pub struct ModelUsage {
58 pub id: String,
59 pub provider: String,
60 pub coef: f64,
62 pub month: Totals,
63 pub five_hour: Totals,
64 pub weekly: Totals,
65}
66
67#[derive(Debug, Clone)]
68pub struct Report {
69 pub period_label: String,
70 pub now_ms: i64,
71 pub since_ms: i64,
72 pub period_end_ms: Option<i64>,
73 pub month_inferred: bool,
74 pub account: Option<Usage>,
76 pub account_error: Option<String>,
79 pub tier: String,
82 pub profile: Option<String>,
84 pub offset_secs: i64,
87 pub five_hour: Totals,
88 pub weekly: Totals,
89 pub month: Totals,
90 pub daily: Option<Totals>,
93 pub models: Vec<ModelUsage>,
95}
96
97impl Report {
98 pub fn afp_per_request(&self) -> Option<f64> {
102 let account = self.account.as_ref()?;
103 let requests = self.five_hour.requests;
104 (requests > 0).then(|| account.five_hour.used / requests as f64)
105 }
106
107 pub fn reconcile(&self) -> Option<f64> {
112 let account = self.account.as_ref()?;
113 if account.five_hour.used <= 0.0 || self.five_hour.afp <= 0.0 {
114 return None;
115 }
116 Some((self.five_hour.afp - account.five_hour.used) / account.five_hour.used * 100.0)
117 }
118
119 pub fn burn_per_day(&self) -> Option<f64> {
122 let account = self.account.as_ref()?;
123 (account.five_hour.used > 0.0).then(|| account.five_hour.used / 5.0 * 24.0)
124 }
125}
126
127fn day_of_month(epoch_secs: i64) -> u32 {
130 civil(epoch_secs).2 as u32
131}
132
133pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
134 let y = if m <= 2 { y - 1 } else { y };
135 let era = y.div_euclid(400);
136 let yoe = y - era * 400;
137 let mp = if m > 2 { m - 3 } else { m + 9 };
138 let doy = (153 * mp + 2) / 5 + d - 1;
139 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
140 era * 146_097 + doe - 719_468
141}
142
143fn civil(epoch_secs: i64) -> (i64, i64, i64) {
144 let days = epoch_secs.div_euclid(DAY_SECS);
145 let z = days + 719_468;
146 let era = z.div_euclid(146_097);
147 let doe = z.rem_euclid(146_097);
148 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
149 let y = yoe + era * 400;
150 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
151 let mp = (5 * doy + 2) / 153;
152 let d = doy - (153 * mp + 2) / 5 + 1;
153 let m = if mp < 10 { mp + 3 } else { mp - 9 };
154 (if m <= 2 { y + 1 } else { y }, m, d)
155}
156
157pub fn now_ms() -> i64 {
159 std::time::SystemTime::now()
160 .duration_since(std::time::UNIX_EPOCH)
161 .map(|d| d.as_millis() as i64)
162 .unwrap_or(0)
163}
164
165pub fn calendar_month_start(now_ms: i64) -> i64 {
168 let (year, month, _) = civil(now_ms / 1000);
169 days_from_civil(year, month, 1) * 86_400_000
170}
171
172pub fn next_month(ms: i64) -> i64 {
176 let (year, month, day) = civil(ms / 1000);
177 let (ny, nm) = if month == 12 {
178 (year + 1, 1)
179 } else {
180 (year, month + 1)
181 };
182 days_from_civil(ny, nm, day.min(28)) * 86_400_000
183}
184
185pub fn month_start_before(reset_ms: i64, offset_secs: i64) -> i64 {
193 let local = reset_ms + offset_secs * 1000;
194 let (year, month, day) = civil(local.div_euclid(1000));
195 let (py, pm) = if month == 1 {
196 (year - 1, 12)
197 } else {
198 (year, month - 1)
199 };
200 days_from_civil(py, pm, day.min(28)) * 86_400_000 - offset_secs * 1000
201}
202
203pub fn before_day_end(at_ms: i64, day: &str) -> bool {
206 let mut parts = day.split('-');
207 let (Some(y), Some(m), Some(d)) = (parts.next(), parts.next(), parts.next()) else {
208 return false;
209 };
210 let (Ok(y), Ok(m), Ok(d)) = (y.parse::<i64>(), m.parse::<i64>(), d.parse::<i64>()) else {
211 return false;
212 };
213 let end = (days_from_civil(y, m, d) + 1) * 86_400_000;
214 at_ms < end
215}
216
217pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
220 let now_secs = now_ms / 1000;
221 let (year, month, _) = civil(now_secs);
222 if let Some(ms) = rows.iter().map(|r| r.at_ms).min() {
223 let wanted = day_of_month(ms / 1000).min(28) as i64;
224 let candidate = days_from_civil(year, month, wanted) * DAY_SECS;
225 if candidate <= now_secs {
226 return (candidate * 1000, true);
227 }
228 let (py, pm) = if month == 1 {
229 (year - 1, 12)
230 } else {
231 (year, month - 1)
232 };
233 return (days_from_civil(py, pm, wanted) * DAY_SECS * 1000, true);
234 }
235 (days_from_civil(year, month, 1) * DAY_SECS * 1000, false)
236}
237
238pub fn build(
240 rows: &[Row],
241 catalog: &Catalog,
242 plan_id: &str,
243 now_ms: i64,
244 since_override: Option<i64>,
245 account: Option<&Usage>,
246) -> Report {
247 let (inferred_start, month_inferred) = month_start(rows, now_ms);
248 let since_ms = since_override.unwrap_or(inferred_start);
249 let (five_start, five_end) = match account.and_then(|a| a.five_hour.reset_ms()) {
254 Some(reset) => (reset - FIVE_HOUR_SECS * 1000, reset),
255 None => (now_ms - FIVE_HOUR_SECS * 1000, i64::MAX),
256 };
257 let (week_start, week_end) = match account.and_then(|a| a.weekly.reset_ms()) {
258 Some(reset) => (reset - WEEKLY_SECS * 1000, reset),
259 None => (now_ms - WEEKLY_SECS * 1000, i64::MAX),
260 };
261 let day_bounds = account
264 .and_then(|a| a.daily.as_ref())
265 .and_then(|m| m.reset_ms())
266 .map(|reset| (reset - DAY_SECS * 1000, reset));
267
268 let mut report = Report {
269 period_label: "calendar month".into(),
270 now_ms,
271 since_ms,
272 period_end_ms: None,
273 month_inferred: since_override.is_none() && month_inferred,
274 account: None,
275 account_error: None,
276 tier: plan_id.to_string(),
277 profile: account.map(|a| a.profile.clone()),
278 offset_secs: account.map_or(0, |a| a.monthly.offset_secs()),
279 five_hour: Totals::default(),
280 weekly: Totals::default(),
281 month: Totals::default(),
282 daily: day_bounds.map(|_| Totals::default()),
283 models: Vec::new(),
284 };
285 let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();
286
287 for row in rows {
288 let in_month = row.at_ms >= since_ms;
289 let in_week = row.at_ms >= week_start && row.at_ms < week_end;
290 let in_five = row.at_ms >= five_start && row.at_ms < five_end;
291 let in_day = day_bounds.is_some_and(|(start, end)| row.at_ms >= start && row.at_ms < end);
292 if !in_month && !in_week && !in_five && !in_day {
293 continue;
294 }
295 let afp = catalog.afp(&row.model, row.at_ms, row.input, row.cache_read, row.output);
296 if in_month {
297 report.month.add(row, afp);
298 }
299 if in_day {
300 report
301 .daily
302 .get_or_insert_with(Totals::default)
303 .add(row, afp);
304 }
305 if in_week {
306 report.weekly.add(row, afp);
307 }
308 if in_five {
309 report.five_hour.add(row, afp);
310 }
311 let coef = catalog
312 .models
313 .iter()
314 .find(|m| m.id == row.model)
315 .map(|m| crate::arkuse::ark::effective_coef(m, row.at_ms))
316 .unwrap_or(0.0);
317 let entry = by_model
318 .entry(row.model.clone())
319 .or_insert_with(|| ModelUsage {
320 id: row.model.clone(),
321 provider: row.provider.clone(),
322 coef,
323 month: Totals::default(),
324 five_hour: Totals::default(),
325 weekly: Totals::default(),
326 });
327 if in_month {
328 entry.month.add(row, afp);
329 }
330 if in_week {
331 entry.weekly.add(row, afp);
332 }
333 if in_five {
334 entry.five_hour.add(row, afp);
335 }
336 }
337
338 report.models = by_model.into_values().collect();
339 report.models.sort_by(|a, b| {
340 b.month
341 .afp
342 .partial_cmp(&a.month.afp)
343 .unwrap_or(std::cmp::Ordering::Equal)
344 .then_with(|| a.id.cmp(&b.id))
345 });
346 report
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::arkuse::db::Row;
353
354 fn row(at_s: i64, model: &str) -> Row {
355 Row {
356 at_ms: at_s * 1000,
357 provider: "agent-plan".into(),
358 model: model.into(),
359 input: 10_000,
360 ..Default::default()
361 }
362 }
363
364 fn meter(percent: f64, used: f64, resets_at: &str) -> crate::arkuse::api::Meter {
365 crate::arkuse::api::Meter::from_parts("ok", percent, used, 0.0, Some(resets_at), None)
366 }
367
368 #[test]
369 fn civil_helpers_round_trip() {
370 let secs = days_from_civil(2026, 9, 27) * 86_400;
371 assert_eq!(civil(secs), (2026, 9, 27));
372 assert_eq!(day_of_month(secs), 27);
373 }
374
375 #[test]
379 fn month_start_follows_the_servers_anniversary_and_offset() {
380 let reset = (days_from_civil(2026, 11, 19) * 86_400 + 23 * 3600 + 59 * 60 + 59) * 1000
382 - 8 * 3600 * 1000;
383 let start = month_start_before(reset, 8 * 3600);
384 let local = (start + 8 * 3600 * 1000) / 1000;
385 assert_eq!(civil(local), (2026, 10, 19), "00:00 on the 19th, +08:00");
386 assert_eq!(local.rem_euclid(DAY_SECS), 0);
387 }
388
389 #[test]
390 fn promo_dates_end_at_the_days_close() {
391 let day = days_from_civil(2026, 12, 30) * 86_400_000;
392 assert!(before_day_end(day + 3_600_000, "2026-12-30"));
393 assert!(!before_day_end(day + 86_400_000, "2026-12-30"));
394 }
395
396 #[test]
397 fn the_meters_bounds_cut_the_windows_when_they_answered() {
398 let now = days_from_civil(2026, 10, 19) * 86_400 + 12 * 3600;
400 let account = crate::arkuse::api::Usage {
401 five_hour: meter(6.0, 600.0, "2026-10-19T23:00:00+08:00"),
403 daily: None,
404 weekly: meter(3.0, 1_050.0, "2026-10-26T00:00:00+08:00"),
406 monthly: meter(51.0, 51_000.0, "2026-11-19T23:59:59+08:00"),
407 tier: Some("medium".into()),
408 profile: "agent-plan_cn-beijing_medium".into(),
409 };
410 let rows = vec![
411 row(now - 8 * 86_400, "deepseek-v4.1-flash"),
413 row(now - 6 * 3600, "deepseek-v4.1-flash"),
415 row(now - 60, "deepseek-v4.1-flash"),
417 ];
418 let catalog = crate::arkuse::ark::catalog();
419 let month_start = days_from_civil(2026, 10, 1) * 86_400;
420 let report = build(
421 &rows,
422 &catalog,
423 "medium",
424 now * 1000,
425 Some(month_start * 1000),
426 Some(&account),
427 );
428 assert_eq!(report.five_hour.requests, 1, "only the in-block row");
429 assert_eq!(report.weekly.requests, 2, "the block plus six hours back");
430 assert_eq!(report.month.requests, 3, "all of them inside October");
431 assert!(report.five_hour.afp > 0.0);
432 }
433
434 #[test]
435 fn a_daily_meter_gets_its_own_cut_and_a_text_plan_has_none() {
436 let now = days_from_civil(2026, 10, 19) * 86_400 + 12 * 3600;
438 let account = crate::arkuse::api::Usage {
439 five_hour: meter(6.0, 600.0, "2026-10-19T23:00:00+08:00"),
440 daily: Some(meter(1.0, 100.0, "2026-10-19T15:00:00+08:00")),
442 weekly: meter(3.0, 1_050.0, "2026-10-26T00:00:00+08:00"),
443 monthly: meter(51.0, 51_000.0, "2026-11-19T23:59:59+08:00"),
444 tier: Some("medium".into()),
445 profile: "agent-plan_cn-beijing_medium".into(),
446 };
447 let rows = vec![
448 row(now - 10 * 3600, "deepseek-v4.1-flash"), row(now - 3600, "deepseek-v4.1-flash"), ];
451 let catalog = crate::arkuse::ark::catalog();
452 let report = build(&rows, &catalog, "medium", now * 1000, None, Some(&account));
453 assert_eq!(report.daily.expect("media plan exposes a day").requests, 1);
454 let mut text_only = account;
456 text_only.daily = None;
457 let report = build(
458 &rows,
459 &catalog,
460 "medium",
461 now * 1000,
462 None,
463 Some(&text_only),
464 );
465 assert!(report.daily.is_none());
466 }
467
468 #[test]
469 fn models_sort_by_month_afp() {
470 let now = days_from_civil(2026, 9, 27) * 86_400;
471 let rows = vec![
472 row(now - 60, "glm-5.3-flash"),
473 row(now - 60, "deepseek-v4.1-flash"),
474 ];
475 let catalog = crate::arkuse::ark::catalog();
476 let report = build(&rows, &catalog, "medium", now * 1000, None, None);
477 assert_eq!(report.models[0].id, "deepseek-v4.1-flash", "2.5 > 0.5");
478 }
479
480 #[test]
481 fn an_unknown_model_counts_a_request_but_no_afp() {
482 let now = days_from_civil(2026, 9, 27) * 86_400;
483 let rows = vec![row(now - 60, "not-a-model")];
484 let catalog = crate::arkuse::ark::catalog();
485 let report = build(&rows, &catalog, "medium", now * 1000, None, None);
486 assert_eq!(report.month.requests, 1);
487 assert_eq!(report.month.afp, 0.0);
488 }
489}