Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions rust/src/providers/minimax/coding_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,17 @@ fn resets_at(
remains as f64
};
// Display/rounding conversion of an epoch value; sub-second precision is
// intentionally dropped.
// intentionally dropped. An offset that cannot be represented as a
// timestamp (oversized `remains`) yields no reset line and keeps the usage
// percentage (upstream #3758/#3764: safely handle unrepresentable
// timestamps).
#[allow(
clippy::cast_possible_truncation,
reason = "epoch seconds truncated to whole seconds by design"
)]
Some(now + Duration::seconds(seconds as i64))
let total_seconds = Duration::try_seconds(seconds as i64)?;
now.checked_add_signed(total_seconds)
.filter(|reset| *reset > now)
}

/// Upstream `mapModelNameToServiceType`.
Expand Down Expand Up @@ -693,6 +698,36 @@ pub(crate) fn is_token_plan_without_coding_plan(err: &ProviderError) -> bool {
mod tests {
use super::*;

#[test]
fn oversized_remains_yields_no_reset_line_and_keeps_usage() {
// `remains` above ~8.3e15 ms cannot be represented as a timestamp:
// `Duration::try_seconds` must return None, so `resets_at` is None and
// the usage percentage survives (upstream #3758/#3764).
assert_eq!(resets_at(None, Some(i64::MAX), now()), None);
}

#[test]
fn negative_remains_yields_no_reset_line() {
assert_eq!(resets_at(None, Some(-5), now()), None);
assert_eq!(resets_at(None, Some(0), now()), None);
}

#[test]
fn positive_remains_keeps_the_reset_line() {
// 90_000 > 1_000_000? No: below the heuristic, treated as seconds.
let reset = resets_at(None, Some(60), now()).expect("small remains");
assert_eq!(reset, now() + Duration::seconds(60));
// Above the heuristic: treated as milliseconds.
let reset_ms = resets_at(None, Some(2_000_000), now()).expect("ms remains");
assert_eq!(reset_ms, now() + Duration::seconds(2_000));
}

#[test]
fn end_time_in_the_future_wins_over_remains() {
let end = now() + Duration::seconds(120);
assert_eq!(resets_at(Some(end), Some(60), now()), Some(end));
}

fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 8, 3, 12, 0, 0).unwrap()
}
Expand Down
35 changes: 31 additions & 4 deletions rust/src/providers/minimax/coding_plan_html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,20 @@ fn parse_resets_at_from_text(text: &str, now: DateTime<Utc>) -> Option<DateTime<
let unit = &caps[1];
let seconds = seconds_from_duration(value, unit);
// "Resets in N <unit>" values are parsed from dashboard text and
// rounded to whole seconds.
// rounded to whole seconds. An offset that cannot be represented as a
// timestamp (oversized `N`, `seconds as i64` saturating) yields no reset
// line and keeps the usage percentage (upstream #3758/#3764: safely
// handle unrepresentable timestamps).
#[allow(
clippy::cast_possible_truncation,
reason = "reset countdown truncated to whole seconds by design"
)]
let total_seconds = seconds as i64;
return Some(now + Duration::seconds(total_seconds));
let total_seconds = Duration::try_seconds(seconds as i64)?;
let reset = now.checked_add_signed(total_seconds)?;
if reset <= now {
return None;
}
return Some(reset);
}

// "Resets at HH:mm (zone hint)"
Expand Down Expand Up @@ -482,12 +489,32 @@ pub(super) fn to_usage_snapshot(
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone};
use chrono::TimeZone;

fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 8, 3, 12, 0, 0).unwrap()
}

#[test]
fn oversized_days_text_yields_no_reset_line() {
// "Resets in 99999999999999999999 days": `seconds as i64` saturates
// and `Duration::seconds` would panic; the guard returns None.
let text = "Resets in 99999999999999999999 days";
assert_eq!(parse_resets_at_from_text(text, now()), None);
}

#[test]
fn normal_days_text_keeps_the_reset_line() {
let text = "Resets in 2 days";
let reset = parse_resets_at_from_text(text, now()).expect("2 days");
assert_eq!(reset, now() + Duration::seconds(2 * 86_400));
}

#[test]
fn text_without_a_reset_count_is_unmatched() {
assert_eq!(parse_resets_at_from_text("no reset info", now()), None);
}

#[test]
fn parses_camelcase_next_data_html() {
let entry = serde_json::json!({
Expand Down