use crate::ocuse::db::Row;
use crate::ocuse::zen::Catalog;
pub const FIVE_HOUR_SECS: i64 = 5 * 3600;
pub const WEEKLY_SECS: i64 = 7 * 86_400;
pub const LOOKBACK_SECS: i64 = 120 * 86_400;
#[derive(Debug, Clone, Default)]
pub struct Totals {
pub cost: f64,
pub requests: u64,
pub input: u64,
pub output: u64,
pub cache_read: u64,
}
impl Totals {
pub fn add(&mut self, row: &Row) {
self.cost += row.cost_usd;
self.requests += 1;
self.input += row.input;
self.output += row.output;
self.cache_read += row.cache_read;
}
pub fn tokens(&self) -> u64 {
self.input + self.output + self.cache_read
}
}
#[derive(Debug, Clone)]
pub struct ModelUsage {
pub id: String,
pub provider: String,
pub limit: Option<f64>,
pub month: Totals,
pub five_hour: Totals,
pub weekly: Totals,
}
impl ModelUsage {
pub fn month_use(&self) -> Option<f64> {
self.limit.filter(|l| *l > 0.0).map(|l| self.month.cost / l)
}
}
pub fn over_window(model: &ModelUsage) -> Option<&'static str> {
let limit = model.limit.filter(|l| *l > 0.0)?;
let shares = &crate::ocuse::zen::catalog().go.window_share;
if model.five_hour.cost > limit * shares.five_hour {
Some("5-hour")
} else if model.weekly.cost > limit * shares.weekly {
Some("weekly")
} else if model.month.cost > limit * shares.monthly {
Some("monthly")
} else {
None
}
}
#[derive(Debug, Clone)]
pub struct Report {
pub period_label: String,
pub now_ms: i64,
pub since_ms: i64,
pub period_end_ms: Option<i64>,
pub month_inferred: bool,
pub account: Option<crate::ocuse::api::Usage>,
pub five_hour: Totals,
pub weekly: Totals,
pub month: Totals,
pub models: Vec<ModelUsage>,
}
fn day_of_month(epoch_secs: i64) -> u32 {
civil(epoch_secs).2 as u32
}
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn civil(epoch_secs: i64) -> (i64, i64, i64) {
let days = epoch_secs.div_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub fn calendar_month_start(now_ms: i64) -> i64 {
let (year, month, _) = civil(now_ms / 1000);
days_from_civil(year, month, 1) * 86_400_000
}
pub fn next_month(ms: i64) -> i64 {
let (year, month, day) = civil(ms / 1000);
let (ny, nm) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
days_from_civil(ny, nm, day.min(28)) * 86_400_000
}
pub fn prev_month(ms: i64) -> i64 {
let (year, month, day) = civil(ms / 1000);
let (py, pm) = if month == 1 {
(year - 1, 12)
} else {
(year, month - 1)
};
days_from_civil(py, pm, day.min(28)) * 86_400_000 + ms.rem_euclid(86_400_000)
}
pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
let now_secs = now_ms / 1000;
let (year, month, _day) = civil(now_secs);
let earliest_go = rows
.iter()
.filter(|r| r.provider == "opencode-go")
.map(|r| r.at_ms)
.min();
let anniversary = earliest_go
.or_else(|| rows.iter().map(|r| r.at_ms).min())
.map(|ms| day_of_month(ms / 1000));
if let Some(day_wanted) = anniversary {
let candidate = days_from_civil(year, month, day_wanted.min(28) as i64) * 86_400;
if candidate <= now_secs {
return (candidate * 1000, true);
}
let (py, pm) = if month == 1 {
(year - 1, 12)
} else {
(year, month - 1)
};
return (
days_from_civil(py, pm, day_wanted.min(28) as i64) * 86_400 * 1000,
true,
);
}
let current = civil(now_secs);
let days = days_from_civil(current.0, current.1, 1);
(days * 86_400 * 1000, false)
}
pub fn build(
rows: &[Row],
catalog: &Catalog,
plan_id: &str,
now_ms: i64,
since_override: Option<i64>,
) -> Report {
let (inferred_start, month_inferred) = month_start(rows, now_ms);
let since_ms = since_override.unwrap_or(inferred_start);
let five_cut = now_ms - FIVE_HOUR_SECS * 1000;
let week_cut = now_ms - WEEKLY_SECS * 1000;
let mut report = Report {
period_label: "calendar month".into(),
now_ms,
since_ms,
period_end_ms: None,
month_inferred: since_override.is_none() && month_inferred,
account: None,
five_hour: Totals::default(),
weekly: Totals::default(),
month: Totals::default(),
models: Vec::new(),
};
let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();
for row in rows {
if row.at_ms < since_ms {
continue; }
report.month.add(row);
if row.at_ms >= week_cut {
report.weekly.add(row);
}
if row.at_ms >= five_cut {
report.five_hour.add(row);
}
let entry = by_model
.entry(row.model.clone())
.or_insert_with(|| ModelUsage {
id: row.model.clone(),
provider: row.provider.clone(),
limit: catalog.go_limit(plan_id, &row.model),
month: Totals::default(),
five_hour: Totals::default(),
weekly: Totals::default(),
});
entry.month.add(row);
if row.at_ms >= week_cut {
entry.weekly.add(row);
}
if row.at_ms >= five_cut {
entry.five_hour.add(row);
}
}
report.models = by_model.into_values().collect();
report.models.sort_by(|a, b| {
b.month
.cost
.partial_cmp(&a.month.cost)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
report
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ocuse::db::Row;
fn row(at_s: i64, model: &str, cost: f64) -> Row {
Row {
at_ms: at_s * 1000,
provider: "opencode-go".into(),
model: model.into(),
cost_usd: cost,
..Default::default()
}
}
#[test]
fn civil_helpers_round_trip() {
let secs = days_from_civil(2026, 9, 27) * 86_400;
assert_eq!(civil(secs), (2026, 9, 27));
assert_eq!(day_of_month(secs), 27);
}
#[test]
fn next_month_keeps_the_day_and_wraps_the_year() {
let dec15 = days_from_civil(2026, 12, 15) * 86_400_000;
assert_eq!(civil(next_month(dec15) / 1000), (2027, 1, 15));
let jan1 = days_from_civil(2026, 1, 1) * 86_400_000;
assert_eq!(civil(next_month(jan1) / 1000), (2026, 2, 1));
let jan31 = days_from_civil(2026, 1, 31) * 86_400_000;
assert_eq!(civil(next_month(jan31) / 1000), (2026, 2, 28));
}
#[test]
fn prev_month_inverts_next_month_and_keeps_the_clock() {
let reset = 1_794_604_475_000; let start = prev_month(reset);
assert_eq!(civil(start / 1000), (2026, 10, 13));
assert_eq!(start % 86_400_000, reset % 86_400_000, "time of day kept");
assert_eq!(civil(next_month(start) / 1000), (2026, 11, 13));
let dec13 = days_from_civil(2026, 12, 13) * 86_400_000;
assert_eq!(civil(prev_month(dec13) / 1000), (2026, 11, 13));
let mar31 = days_from_civil(2026, 3, 31) * 86_400_000;
assert_eq!(civil(prev_month(mar31) / 1000), (2026, 2, 28));
}
#[test]
fn windows_are_rolling_and_the_month_is_inferred() {
let now = days_from_civil(2026, 9, 27) * 86_400 + 12 * 3600;
let rows = vec![
row(now - 60, "glm-5.3-flash", 1.0), row(now - 2 * 3600, "glm-5.3-flash", 2.0), row(now - 3 * 86_400, "kimi-k3", 4.0), row(now - 40 * 86_400, "glm-5.3-flash", 8.0), ];
let catalog = crate::ocuse::zen::catalog();
let report = build(
&rows,
&catalog,
crate::ocuse::zen::DEFAULT_PLAN,
now * 1000,
None,
);
assert!(
report.month_inferred,
"anniversary inferred from the earliest row"
);
assert!((report.five_hour.cost - 3.0).abs() < 1e-9);
assert!((report.weekly.cost - 7.0).abs() < 1e-9);
assert!(
(report.month.cost - 7.0).abs() < 1e-9,
"month cost {}",
report.month.cost
);
let flash = report
.models
.iter()
.find(|m| m.id == "glm-5.3-flash")
.unwrap();
assert_eq!(flash.limit, Some(60.0));
assert!((flash.five_hour.cost - 3.0).abs() < 1e-9);
}
#[test]
fn models_sort_by_month_spend() {
let now = days_from_civil(2026, 9, 27) * 86_400;
let rows = vec![row(now - 60, "cheap", 0.1), row(now - 60, "pricey", 5.0)];
let report = build(
&rows,
&crate::ocuse::zen::catalog(),
crate::ocuse::zen::DEFAULT_PLAN,
now * 1000,
None,
);
assert_eq!(report.models[0].id, "pricey");
}
}