diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 8a6e74eeaf..e4a1c82e12 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -394,6 +394,24 @@ impl ProviderUsageSnapshot { result: &ProviderFetchResult, token_account_id: Option, pace_work_days: Option, + ) -> Self { + Self::from_fetch_result_at( + id, + metadata, + result, + token_account_id, + pace_work_days, + chrono::Utc::now(), + ) + } + + pub(super) fn from_fetch_result_at( + id: ProviderId, + metadata: &ProviderMetadata, + result: &ProviderFetchResult, + token_account_id: Option, + pace_work_days: Option, + now: chrono::DateTime, ) -> Self { let usage = &result.usage; let allows_pace = result.pace_authoritative; @@ -406,14 +424,21 @@ impl ProviderUsageSnapshot { } else { Some(&usage.primary) }; + // Upstream `UsageStore.weeklyPace`: a window with a length expands a + // monthly sentinel to the real calendar cycle before scoring, so the + // tray agrees with the card. let primary_pace = allows_pace.then(|| { primary_pace_window.and_then(|window| { - codexbar::core::UsagePace::weekly(window, None, 10080, pace_work_days) + let pace_window = if window.window_minutes.is_some() { + id.pace_capability().resolved_reset_window_for_pace(window) + } else { + window.clone() + }; + codexbar::core::UsagePace::weekly(&pace_window, Some(now), 10080, pace_work_days) }) }); let primary_pace = primary_pace.flatten(); - let now = chrono::Utc::now(); let blocked = BlockedWindows::evaluate(id, usage, now); let pace = primary_pace.as_ref().map(|p| PaceSnapshot { stage: pace::stage_str(p.stage).to_string(), @@ -425,38 +450,54 @@ impl ProviderUsageSnapshot { monthly_limit_block: MonthlyLimitBlockSnapshot::for_pace(usage, &blocked), }); - // Compute pace for secondary window (weekly) to derive reserve info + // Pace of the secondary lane, which also derives the row's reserve. let secondary_pace = allows_pace.then(|| { - usage - .secondary - .as_ref() - .and_then(|sw| codexbar::core::UsagePace::weekly(sw, None, 10080, pace_work_days)) + usage.secondary.as_ref().and_then(|sw| { + pace::lane_usage_pace( + id, + pace::PaceLane::Card, + sw, + pace::WEEKLY_FALLBACK_MINUTES, + now, + pace_work_days, + ) + .map(|(pace, _)| pace) + }) }); let secondary_pace = secondary_pace.flatten(); - let lane = - |window: &RateWindow, is_blocked: bool, default_minutes: u32| RateWindowSnapshot { - pace: allows_pace - .then(|| { - pace::WindowPaceSnapshot::for_window( - window, - default_minutes, - now, - pace_work_days, - ) - }) - .flatten(), - ..RateWindowSnapshot::from_rate_window(window) - .with_quota_block(is_blocked, &blocked) - }; + let lane = |window: &RateWindow, + is_blocked: bool, + default_minutes: u32, + pace_lane: pace::PaceLane| RateWindowSnapshot { + pace: allows_pace + .then(|| { + pace::WindowPaceSnapshot::for_lane( + id, + pace_lane, + window, + default_minutes, + now, + pace_work_days, + ) + }) + .flatten(), + ..RateWindowSnapshot::from_rate_window(window).with_quota_block(is_blocked, &blocked) + }; let primary_snap = lane( &usage.primary, blocked.primary, pace::SESSION_FALLBACK_MINUTES, + pace::PaceLane::Card, ); let secondary_snap = usage.secondary.as_ref().map(|sw| { - let mut s = lane(sw, blocked.secondary, pace::WEEKLY_FALLBACK_MINUTES); + let mut s = lane( + sw, + blocked.secondary, + pace::WEEKLY_FALLBACK_MINUTES, + pace::PaceLane::Card, + ); if let Some(ref p) = secondary_pace { s = s.with_pace_reserve(p); } @@ -498,14 +539,22 @@ impl ProviderUsageSnapshot { .clone() .unwrap_or_else(|| metadata.weekly_label.to_string()) }), - model_specific: usage - .model_specific - .as_ref() - .map(|w| lane(w, blocked.model_specific, pace::WEEKLY_FALLBACK_MINUTES)), - tertiary: usage - .tertiary - .as_ref() - .map(|w| lane(w, blocked.tertiary, pace::WEEKLY_FALLBACK_MINUTES)), + model_specific: usage.model_specific.as_ref().map(|w| { + lane( + w, + blocked.model_specific, + pace::WEEKLY_FALLBACK_MINUTES, + pace::PaceLane::Card, + ) + }), + tertiary: usage.tertiary.as_ref().map(|w| { + lane( + w, + blocked.tertiary, + pace::WEEKLY_FALLBACK_MINUTES, + pace::PaceLane::Card, + ) + }), // F5 (upstream 0.48.0): label the tertiary lane by its duration cadence // so surfaces (MenuCard, CLI, tray) can show "Monthly" instead of the // generic "DetailWindowTertiary" slot key. @@ -524,7 +573,12 @@ impl ProviderUsageSnapshot { .map(|(extra, &is_blocked)| NamedRateWindowSnapshot { id: extra.id.clone(), title: extra.title.clone(), - window: lane(&extra.window, is_blocked, pace::WEEKLY_FALLBACK_MINUTES), + window: lane( + &extra.window, + is_blocked, + pace::WEEKLY_FALLBACK_MINUTES, + pace::PaceLane::Extra, + ), fallback_lane: extra.fallback_lane, icon_fallback: extra.icon_fallback, }) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index 5d1b79cc04..2fdd70443d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -31,6 +31,41 @@ pub struct WindowPaceSnapshot { pub eta_seconds: Option, #[serde(default)] pub will_last_to_reset: bool, + /// Scored against the provider's reset window (upstream + /// `ProviderPaceCapability.resetWindowPace`): cards show it as weekly + /// pace on any card lane, whatever the window's length. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reset_window: bool, +} + +/// Where a window is rendered. Reset-window pace applies to the card's +/// primary, secondary and tertiary bars only, as on the Mac; extra windows +/// keep plain lane pace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PaceLane { + Card, + Extra, +} + +/// Pace of one window and whether it came from the provider's reset-window +/// capability. The monthly sentinel and Copilot's missing length resolve to +/// the calendar month ending at the reset before scoring. +pub(crate) fn lane_usage_pace( + id: ProviderId, + lane: PaceLane, + window: &RateWindow, + default_window_minutes: u32, + now: DateTime, + work_days: Option, +) -> Option<(codexbar::core::UsagePace, bool)> { + let capability = id.pace_capability(); + if lane == PaceLane::Card && capability.supports_reset_window_pace(window, now) { + return capability + .reset_window_pace(window, now, work_days) + .map(|pace| (pace, true)); + } + codexbar::core::UsagePace::weekly(window, Some(now), default_window_minutes, work_days) + .map(|pace| (pace, false)) } impl WindowPaceSnapshot { @@ -49,14 +84,36 @@ impl WindowPaceSnapshot { default_window_minutes, work_days, )?; - Some(Self { + Some(Self::from_pace(&pace, false)) + } + + /// Lane pace with the provider's reset-window capability applied. + pub(crate) fn for_lane( + id: ProviderId, + lane: PaceLane, + window: &RateWindow, + default_window_minutes: u32, + now: DateTime, + work_days: Option, + ) -> Option { + if window.is_informational { + return None; + } + let (pace, reset_window) = + lane_usage_pace(id, lane, window, default_window_minutes, now, work_days)?; + Some(Self::from_pace(&pace, reset_window)) + } + + fn from_pace(pace: &codexbar::core::UsagePace, reset_window: bool) -> Self { + Self { stage: stage_str(pace.stage).to_string(), delta_percent: pace.delta_percent, expected_used_percent: pace.expected_used_percent, actual_used_percent: pace.actual_used_percent, eta_seconds: pace.eta_seconds, will_last_to_reset: pace.will_last_to_reset, - }) + reset_window, + } } } @@ -109,6 +166,7 @@ mod tests { actual_used_percent: 30.0, eta_seconds: None, will_last_to_reset: true, + reset_window: false, }) ); } @@ -262,4 +320,189 @@ mod tests { assert!((expected(Some(7)) - linear).abs() < 0.01); assert!(expected(Some(5)) > linear); } + + // --- Reset-window pace (upstream `ProviderPaceCapability`) --- + + /// Feb 15 00:00 UTC in a billing cycle that resets Mar 1 00:00 UTC: + /// 14 of the 28 February days have elapsed. + fn february_now() -> DateTime { + Utc.with_ymd_and_hms(2026, 2, 15, 0, 0, 0).unwrap() + } + + fn march_reset() -> DateTime { + Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap() + } + + fn monthly(used: f64, minutes: Option) -> RateWindow { + RateWindow::with_details(used, minutes, Some(march_reset()), None) + } + + fn snapshot_at(id: ProviderId, usage: UsageSnapshot) -> ProviderUsageSnapshot { + let metadata = instantiate_provider(id).metadata().clone(); + let result = ProviderFetchResult::new(usage, "web"); + ProviderUsageSnapshot::from_fetch_result_at( + id, + &metadata, + &result, + None, + None, + february_now(), + ) + } + + fn lane_pace(window: &RateWindowSnapshot) -> &WindowPaceSnapshot { + window.pace.as_ref().expect("lane pace") + } + + /// Copilot windows carry a reset date and no length. The Mac resolves + /// them to the calendar month ending at the reset (28 days here). + #[test] + fn copilot_monthly_lanes_get_reset_window_pace() { + let usage = UsageSnapshot::new(monthly(60.0, None)) + .with_secondary(monthly(20.0, None)) + .with_extra_rate_window("copilot-seat", "Seat credits", monthly(60.0, None)); + + let snap = snapshot_at(ProviderId::Copilot, usage); + + let premium = lane_pace(&snap.primary); + assert_eq!(premium.expected_used_percent, 50.0); + assert_eq!(premium.delta_percent, 10.0); + assert_eq!(premium.stage, "ahead"); + // 40% left at 60% per 14 days runs out in 9 days 8 hours. + assert!((premium.eta_seconds.expect("eta") - 806_400.0).abs() < 1e-6); + assert!(!premium.will_last_to_reset); + assert!(premium.reset_window); + + let chat_snap = snap.secondary.as_ref().expect("chat"); + let chat = lane_pace(chat_snap); + assert_eq!(chat.expected_used_percent, 50.0); + assert_eq!(chat.delta_percent, -30.0); + assert!(chat.will_last_to_reset); + assert!(chat.reset_window); + // The row's reserve comes from the same resolved pace. + assert_eq!(chat_snap.reserve_percent, Some(30.0)); + assert!(chat_snap.reserve_will_last_to_reset); + + // Extras keep plain lane pace, as on the Mac: a 14-day reset does not + // fit the 7-day fallback, so the seat-credit window shows none. + assert_eq!(snap.extra_rate_windows[0].window.pace, None); + // The provider-level pace (tray colour) needs a window length, as + // upstream `UsageStore.weeklyPace` does, so it stays unset here. + assert!(snap.pace.is_none()); + } + + /// Cursor's plan bars carry the billing-cycle length; the API bar is the + /// model-specific lane, which the card renders on the tertiary lane. + #[test] + fn cursor_plan_lanes_get_reset_window_pace_including_the_api_bar() { + let mut usage = UsageSnapshot::new(monthly(30.0, Some(40_320))) + .with_secondary(monthly(70.0, Some(40_320))); + usage.model_specific = Some(monthly(10.0, Some(40_320))); + + let snap = snapshot_at(ProviderId::Cursor, usage); + + let total = lane_pace(&snap.primary); + assert_eq!(total.expected_used_percent, 50.0); + assert_eq!(total.delta_percent, -20.0); + assert!(total.reset_window); + let auto = lane_pace(snap.secondary.as_ref().expect("auto")); + assert_eq!(auto.delta_percent, 20.0); + assert!(auto.reset_window); + let api = lane_pace(snap.model_specific.as_ref().expect("api")); + assert_eq!(api.expected_used_percent, 50.0); + assert_eq!(api.delta_percent, -40.0); + assert!(api.reset_window); + let provider_pace = snap.pace.as_ref().expect("provider pace"); + assert_eq!(provider_pace.expected_used_percent, 50.0); + } + + /// A 30-day sentinel resolves to February's 28 days: 50% expected, not + /// the flat-30-day 53.33%. The provider-level pace resolves too. + #[test] + fn notion_sentinel_window_is_scored_against_the_real_month() { + let usage = UsageSnapshot::new(monthly(50.0, Some(43_200))); + + let snap = snapshot_at(ProviderId::Notion, usage); + + let pace = lane_pace(&snap.primary); + assert_eq!(pace.expected_used_percent, 50.0); + assert_eq!(pace.delta_percent, 0.0); + assert_eq!(pace.stage, "on_track"); + assert!(pace.reset_window); + let provider_pace = snap.pace.as_ref().expect("provider pace"); + assert_eq!(provider_pace.expected_used_percent, 50.0); + } + + /// Alibaba's monthly quota sits on the tertiary lane, which has no pace + /// of its own, and the provider module already expands the 30-day + /// sentinel to the real cycle (31 days ending Mar 31 here). The lane + /// still gets reset-window pace outside 30-day months. + #[test] + fn alibaba_monthly_tertiary_gets_reset_window_pace_in_a_31_day_cycle() { + let resets_at = Utc.with_ymd_and_hms(2026, 3, 31, 0, 0, 0).unwrap(); + let now = Utc.with_ymd_and_hms(2026, 3, 15, 12, 0, 0).unwrap(); + let monthly = RateWindow::with_details(40.0, Some(44_640), Some(resets_at), None); + let usage = UsageSnapshot::new(RateWindow::with_details( + 10.0, + Some(300), + Some(now + Duration::hours(2)), + None, + )) + .with_tertiary(monthly); + let metadata = instantiate_provider(ProviderId::Alibaba).metadata().clone(); + let result = ProviderFetchResult::new(usage, "web"); + + let snap = ProviderUsageSnapshot::from_fetch_result_at( + ProviderId::Alibaba, + &metadata, + &result, + None, + None, + now, + ); + + let pace = lane_pace(snap.tertiary.as_ref().expect("monthly lane")); + // 15.5 of 31 days elapsed. + assert_eq!(pace.expected_used_percent, 50.0); + assert_eq!(pace.delta_percent, -10.0); + assert_eq!(pace.stage, "behind"); + assert!(pace.will_last_to_reset); + assert!(pace.reset_window); + } + + /// Providers upstream does not opt in keep the existing flat window. + #[test] + fn providers_without_reset_window_pace_keep_the_raw_window() { + let usage = + UsageSnapshot::new(RateWindow::new(10.0)).with_secondary(monthly(50.0, Some(43_200))); + + let snap = snapshot_at(ProviderId::Claude, usage); + + let pace = lane_pace(snap.secondary.as_ref().expect("weekly lane")); + assert!((pace.expected_used_percent - 160.0 / 3.0).abs() < 1e-9); + assert!(!pace.reset_window); + } + + #[test] + fn exhausted_reset_window_has_no_pace() { + let usage = UsageSnapshot::new(monthly(100.0, None)); + + let snap = snapshot_at(ProviderId::Copilot, usage); + + assert_eq!(snap.primary.pace, None); + } + + #[test] + fn reset_window_flag_serializes_only_when_set() { + let usage = UsageSnapshot::new(monthly(60.0, None)); + let snap = snapshot_at(ProviderId::Copilot, usage); + + let json = serde_json::to_value(snap.primary.pace.as_ref().unwrap()).unwrap(); + assert_eq!(json["resetWindow"], serde_json::Value::Bool(true)); + let cached: WindowPaceSnapshot = serde_json::from_str( + r#"{"stage":"on_track","deltaPercent":0.0,"expectedUsedPercent":50.0,"actualUsedPercent":50.0}"#, + ) + .unwrap(); + assert!(!cached.reset_window); + } } diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index da64f6d180..8c81281548 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -419,15 +419,18 @@ fn normalize_seed_snapshot(snapshot: &mut ProviderUsageSnapshot) { } /// Compute lane pace for seeded windows that omit it, with the same fallback -/// window lengths as `ProviderUsageSnapshot::from_fetch_result`. +/// window lengths and reset-window capability as +/// `ProviderUsageSnapshot::from_fetch_result`. fn fill_seed_pace( snapshot: &mut ProviderUsageSnapshot, now: chrono::DateTime, work_days: Option, ) { - use crate::commands::pace::{SESSION_FALLBACK_MINUTES, WEEKLY_FALLBACK_MINUTES}; + use crate::commands::pace::{PaceLane, SESSION_FALLBACK_MINUTES, WEEKLY_FALLBACK_MINUTES}; + let id = ProviderId::from_cli_name(&snapshot.provider_id); fill_window_pace( &mut snapshot.primary, + (id, PaceLane::Card), SESSION_FALLBACK_MINUTES, now, work_days, @@ -440,15 +443,28 @@ fn fill_seed_pace( .into_iter() .flatten() { - fill_window_pace(window, WEEKLY_FALLBACK_MINUTES, now, work_days); + fill_window_pace( + window, + (id, PaceLane::Card), + WEEKLY_FALLBACK_MINUTES, + now, + work_days, + ); } for extra in &mut snapshot.extra_rate_windows { - fill_window_pace(&mut extra.window, WEEKLY_FALLBACK_MINUTES, now, work_days); + fill_window_pace( + &mut extra.window, + (id, PaceLane::Extra), + WEEKLY_FALLBACK_MINUTES, + now, + work_days, + ); } } fn fill_window_pace( w: &mut RateWindowSnapshot, + (id, pace_lane): (Option, crate::commands::pace::PaceLane), default_minutes: u32, now: chrono::DateTime, work_days: Option, @@ -467,14 +483,24 @@ fn fill_window_pace( w.used_percent, w.window_minutes, Some(resets_at.with_timezone(&chrono::Utc)), - None, - ); - w.pace = crate::commands::pace::WindowPaceSnapshot::for_window( - &window, - default_minutes, - now, - work_days, + w.reset_description.clone(), ); + w.pace = match id { + Some(id) => crate::commands::pace::WindowPaceSnapshot::for_lane( + id, + pace_lane, + &window, + default_minutes, + now, + work_days, + ), + None => crate::commands::pace::WindowPaceSnapshot::for_window( + &window, + default_minutes, + now, + work_days, + ), + }; } fn is_valid_proof_config(config: &ProofConfig) -> bool { @@ -773,6 +799,28 @@ mod tests { assert!(linear.primary.pace.is_none()); } + #[test] + fn seed_pace_uses_the_providers_reset_window_capability() { + use chrono::TimeZone; + // Copilot: no window length, monthly reset on Mar 1, half of Feb gone. + let now = chrono::Utc.with_ymd_and_hms(2026, 2, 15, 0, 0, 0).unwrap(); + let json = r#"{"providerId":"copilot","displayName":"Copilot", + "primary":{"usedPercent":60.0,"resetsAt":"2026-03-01T00:00:00Z"}, + "extraRateWindows":[{"id":"copilot-seat","title":"Seat", + "window":{"usedPercent":60.0,"resetsAt":"2026-03-01T00:00:00Z"}}]}"#; + // Non-Codex seeds arrive in a proof-mode array; parse the element directly. + let mut snapshot: ProviderUsageSnapshot = serde_json::from_str(json).unwrap(); + normalize_seed_snapshot(&mut snapshot); + fill_seed_pace(&mut snapshot, now, None); + let pace = snapshot.primary.pace.expect("copilot primary pace"); + assert_eq!(pace.expected_used_percent, 50.0); + assert_eq!(pace.delta_percent, 10.0); + assert_eq!(pace.eta_seconds, Some(806_400.0)); + assert!(pace.reset_window); + // Extras keep plain lane pace: no length means no pace. + assert!(snapshot.extra_rate_windows[0].window.pace.is_none()); + } + #[test] fn seed_pace_keeps_explicit_pace() { let json = r#"{"providerId":"codex","displayName":"Codex", diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts index 8b9df812f4..2903090795 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.test.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -264,6 +264,51 @@ describe("metricRowPresentation pace", () => { expect(row.metaText).toBe("23% in reserve · Lasts until reset"); }); + describe("reset-window pace (upstream ProviderPaceCapability)", () => { + // Copilot premium requests: no window length, 60% used half way through + // a 28-day cycle, scored by the bridge against the resolved month. + const copilot = (overrides: Partial = {}) => + rateWindow({ + usedPercent: 60, + remainingPercent: 40, + windowMinutes: null, + pace: pace({ + stage: "ahead", + deltaPercent: 10, + expectedUsedPercent: 50, + actualUsedPercent: 60, + etaSeconds: 806_400, + willLastToReset: false, + resetWindow: true, + ...overrides, + }), + }); + + it("shows weekly pace on a primary window with no length", () => { + const row = metricRowPresentation(input({ providerId: "copilot", snap: copilot() }), en); + expect(row.metaText).toBe("10% in deficit · Runs out in 9d 8h"); + expect(row.bar.pacePercent).toBe(50); + expect(row.bar.paceDeficit).toBe(true); + }); + + it("shows pace on the tertiary lane", () => { + const snap = { ...copilot(), windowMinutes: 40_320 }; + const row = metricRowPresentation(input({ providerId: "cursor", lane: "tertiary", snap }), en); + expect(row.metaText).toBe("10% in deficit · Runs out in 9d 8h"); + }); + + it("keeps the 3% expected floor", () => { + const snap = copilot({ expectedUsedPercent: 2, etaSeconds: 3600 }); + expect(metricRowPresentation(input({ providerId: "copilot", snap }), en).metaText).toBeNull(); + }); + + it("leaves extra lanes on their exact-window rule", () => { + const snap = { ...copilot(), windowMinutes: 40_320 }; + const row = metricRowPresentation(input({ providerId: "copilot", lane: "extra", snap }), en); + expect(row.metaText).toBeNull(); + }); + }); + it("hides the pace line in compact rows but keeps the bar marker", () => { const row = metricRowPresentation(input({ lane: "secondary", snap: weekly(), compact: true }), en); expect(row.metaText).toBeNull(); diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 40f72c8714..e017279a45 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -197,7 +197,10 @@ export function metricResetText( return formatCountdown((target - now) / 1000, RESET_KEYS, t) ?? t("ProviderTextResetsNow"); } -function paceKind(lane: MetricLane, windowMinutes: number | null): PaceKind | null { +function paceKind(lane: MetricLane, windowMinutes: number | null, resetWindow: boolean): PaceKind | null { + // Reset-window pace is weekly pace on every card lane, as on the Mac; + // extra windows keep their exact-window rule. + if (resetWindow && lane !== "extra") return "weekly"; const rule = LANE_POLICY[lane].pace; if (rule === "never" || windowMinutes == null) return null; if (rule === "exactWindow") { @@ -212,7 +215,7 @@ function visiblePace(input: MetricRowInput): { pace: WindowPaceSnapshot; kind: P const pace = snap.pace; if (!input.paceEnabled || !pace || !(snap.usedPercent < 100)) return null; if (!Number.isFinite(pace.expectedUsedPercent) || !Number.isFinite(pace.actualUsedPercent)) return null; - const kind = paceKind(input.lane, snap.windowMinutes); + const kind = paceKind(input.lane, snap.windowMinutes, pace.resetWindow === true); if (!kind) return null; const expectsEnough = pace.expectedUsedPercent >= 3; if (kind === "session" ? !expectsEnough : !expectsEnough && pace.etaSeconds !== 0) return null; diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index a9ae6fdb1f..6d35c423a4 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -729,6 +729,12 @@ export interface WindowPaceSnapshot { actualUsedPercent: number; etaSeconds?: number | null; willLastToReset: boolean; + /** + * Scored against the provider's reset window (upstream + * `ProviderPaceCapability.resetWindowPace`). Card lanes show it as weekly + * pace whatever the window's length. Omitted when false. + */ + resetWindow?: boolean; } export interface PaceSnapshot { diff --git a/rust/src/cli/usage/render.rs b/rust/src/cli/usage/render.rs index 6d993e1995..5bc3f37a8f 100644 --- a/rust/src/cli/usage/render.rs +++ b/rust/src/cli/usage/render.rs @@ -31,24 +31,30 @@ pub fn render_json_result( pace_work_days: Option, ) -> serde_json::Value { let usage = &result.usage; - let primary_pace = usage - .primary - .window_minutes - .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) - .then(|| { - UsagePace::weekly( - &usage.primary, - None, - crate::core::SESSION_WINDOW_MINUTES, - pace_work_days, - ) - }) - .flatten() - .map(pace_json); + let primary_pace = lane_pace(provider_id, &usage.primary, pace_work_days, || { + usage + .primary + .window_minutes + .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) + .then(|| { + UsagePace::weekly( + &usage.primary, + None, + crate::core::SESSION_WINDOW_MINUTES, + pace_work_days, + ) + }) + .flatten() + }) + .map(pace_json); let secondary_pace = usage .secondary .as_ref() - .and_then(|w| UsagePace::weekly(w, None, w.window_minutes.unwrap_or(10080), pace_work_days)) + .and_then(|w| { + lane_pace(provider_id, w, pace_work_days, || { + UsagePace::weekly(w, None, w.window_minutes.unwrap_or(10080), pace_work_days) + }) + }) .map(pace_json); let mut json_result = serde_json::json!({ @@ -155,6 +161,40 @@ pub(super) fn is_terminal() -> bool { std::io::stdout().is_terminal() } +/// Pace of one card lane. Providers that opt into reset-window pace score the +/// lane against their resolved reset window (upstream `CLIRenderer.computePace`); +/// every other window keeps the lane's own rule, `fallback`. +fn lane_pace( + provider: ProviderId, + window: &RateWindow, + pace_work_days: Option, + fallback: impl FnOnce() -> Option, +) -> Option { + lane_pace_at(provider, window, Utc::now(), pace_work_days, fallback) +} + +/// Upstream `CLIRenderer.paceMinimumExpectedPercent`: reset-window pace is +/// hidden until at least 3% of the window is expected to be used. +const RESET_WINDOW_MINIMUM_EXPECTED_PERCENT: f64 = 3.0; + +/// `lane_pace` at a fixed clock. +pub(super) fn lane_pace_at( + provider: ProviderId, + window: &RateWindow, + now: chrono::DateTime, + pace_work_days: Option, + fallback: impl FnOnce() -> Option, +) -> Option { + let capability = provider.pace_capability(); + if capability.supports_reset_window_pace(window, now) { + capability + .reset_window_pace(window, now, pace_work_days) + .filter(|pace| pace.expected_used_percent >= RESET_WINDOW_MINIMUM_EXPECTED_PERCENT) + } else { + fallback() + } +} + /// Render usage as text with optional status. `pace_work_days` is /// `Settings::weekly_progress_work_days` (weekly pace only). pub fn render_text_with_status( @@ -172,6 +212,7 @@ pub fn render_text_with_status( append_account_lines(&mut lines, &result.usage); append_usage_window_lines( &mut lines, + provider, &result.usage, &metadata, use_color, @@ -248,6 +289,7 @@ fn append_account_lines(lines: &mut Vec, usage: &UsageSnapshot) { fn append_usage_window_lines( lines: &mut Vec, + provider: ProviderId, usage: &UsageSnapshot, metadata: &crate::core::ProviderMetadata, use_color: bool, @@ -263,9 +305,11 @@ fn append_usage_window_lines( *minutes == crate::core::SESSION_WINDOW_MINUTES || *minutes >= crate::core::WEEKLY_WINDOW_MINUTES }); - if let Some(minutes) = pace_minutes - && let Some(pace) = UsagePace::weekly(&usage.primary, None, minutes, pace_work_days) - { + let primary_pace = lane_pace(provider, &usage.primary, pace_work_days, || { + pace_minutes + .and_then(|minutes| UsagePace::weekly(&usage.primary, None, minutes, pace_work_days)) + }); + if let Some(pace) = primary_pace { lines.push(format!( " Pace: {} {}", pace.stage.emoji(), @@ -274,6 +318,7 @@ fn append_usage_window_lines( } append_secondary_window_line( lines, + provider, usage.secondary.as_ref(), usage .secondary_label @@ -386,6 +431,7 @@ fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, fn append_secondary_window_line( lines: &mut Vec, + provider: ProviderId, secondary: Option<&RateWindow>, label: &str, use_color: bool, @@ -394,7 +440,10 @@ fn append_secondary_window_line( if let Some(secondary) = secondary { append_window_line(lines, label, secondary, use_color); let window_minutes = secondary.window_minutes.unwrap_or(10080); - if let Some(pace) = UsagePace::weekly(secondary, None, window_minutes, pace_work_days) { + let pace = lane_pace(provider, secondary, pace_work_days, || { + UsagePace::weekly(secondary, None, window_minutes, pace_work_days) + }); + if let Some(pace) = pace { lines.push(format!( " Pace: {} {}", pace.stage.emoji(), diff --git a/rust/src/cli/usage_tests.rs b/rust/src/cli/usage_tests.rs index bbf3640fd9..626e8f7f24 100644 --- a/rust/src/cli/usage_tests.rs +++ b/rust/src/cli/usage_tests.rs @@ -704,6 +704,108 @@ fn detail_backed_flag_stays_out_of_json_output() { assert!(windows[0]["window"].get("description_is_detail").is_none()); } +#[test] +fn cli_lane_pace_scores_copilot_against_the_february_cycle() { + use chrono::TimeZone; + // Copilot window with no length, reset Mar 1, now Feb 15: half of the + // 28-day cycle has elapsed. + let now = Utc.with_ymd_and_hms(2026, 2, 15, 0, 0, 0).unwrap(); + let reset = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); + let window = RateWindow::with_details(60.0, None, Some(reset), None); + + let pace = render::lane_pace_at(ProviderId::Copilot, &window, now, None, || None) + .expect("copilot reset-window pace"); + assert_eq!(pace.expected_used_percent, 50.0); + assert_eq!(pace.delta_percent, 10.0); + assert_eq!(pace.eta_seconds, Some(806_400.0)); + + // Providers without the capability use the lane's own rule. + let mut fallback_used = false; + let codex = render::lane_pace_at(ProviderId::Codex, &window, now, None, || { + fallback_used = true; + None + }); + assert!(codex.is_none()); + assert!(fallback_used); +} + +#[test] +fn cli_lane_pace_keeps_reset_window_pace_for_a_resolved_31_day_cycle() { + use chrono::TimeZone; + // StepFun already expands the 30-day sentinel to the real cycle: 31 + // days ending Mar 31. Mar 15 12:00 is 15.5 days in. + let now = Utc.with_ymd_and_hms(2026, 3, 15, 12, 0, 0).unwrap(); + let reset = Utc.with_ymd_and_hms(2026, 3, 31, 0, 0, 0).unwrap(); + let window = RateWindow::with_details(70.0, Some(44_640), Some(reset), None); + + let pace = render::lane_pace_at(ProviderId::StepFun, &window, now, None, || { + panic!("a calendar-month window must use the reset-window rule") + }) + .expect("stepfun reset-window pace"); + assert_eq!(pace.expected_used_percent, 50.0); + assert_eq!(pace.delta_percent, 20.0); +} + +#[test] +fn cli_reset_window_pace_needs_three_percent_expected() { + use chrono::TimeZone; + // Upstream `CLIRenderer.paceMinimumExpectedPercent` (3). Copilot's cycle + // ending Mar 31 is 31 days long; 12 hours in, 0.5 / 31 = 1.61% is + // expected, so the CLI shows no pace and does not fall back. + let reset = Utc.with_ymd_and_hms(2026, 3, 31, 0, 0, 0).unwrap(); + let window = RateWindow::with_details(5.0, None, Some(reset), None); + let early = Utc.with_ymd_and_hms(2026, 2, 28, 12, 0, 0).unwrap(); + let pace = render::lane_pace_at(ProviderId::Copilot, &window, early, None, || { + panic!("reset-window lanes never fall back") + }); + assert!(pace.is_none()); + // One day in (3.23%), the pace shows. + let day_one = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); + let pace = render::lane_pace_at(ProviderId::Copilot, &window, day_one, None, || None) + .expect("pace after 3% expected"); + assert_eq!(pace.expected_used_percent, 100.0 / 31.0); +} + +#[test] +fn copilot_monthly_windows_get_reset_window_pace_in_json_and_text() { + use chrono::Duration; + // Copilot windows have a reset date and no length: the CLI scores them + // against the calendar month ending at the reset, as the Mac does. + let reset = Utc::now() + Duration::days(10); + let cycle_minutes = f64::from(RateWindow::calendar_month_window_minutes(reset).unwrap()); + let expected = 100.0 * (cycle_minutes - 10.0 * 1440.0) / cycle_minutes; + let usage = UsageSnapshot::new(RateWindow::with_details(5.0, None, Some(reset), None)) + .with_secondary(RateWindow::with_details(5.0, None, Some(reset), None)); + + let json = render_json_result(ProviderId::Copilot, fetch_result(usage.clone()), None, None); + let primary = json["pace"]["primary"]["expectedUsedPercent"] + .as_f64() + .expect("copilot primary pace"); + assert!((primary - expected).abs() < 0.01, "{primary} vs {expected}"); + let secondary = json["pace"]["secondary"]["expectedUsedPercent"] + .as_f64() + .expect("copilot secondary pace"); + // Without the capability the secondary would use a 7-day fallback and a + // 10-day reset would give no pace at all. + assert!( + (secondary - expected).abs() < 0.01, + "{secondary} vs {expected}" + ); + + let text = render_text_with_status( + ProviderId::Copilot, + &fetch_result(usage.clone()), + None, + false, + None, + ); + assert_eq!(text.matches(" Pace:").count(), 2, "{text}"); + + // Codex has no reset-window capability: a length-less primary has no pace. + let codex = render_json_result(ProviderId::Codex, fetch_result(usage), None, None); + assert!(codex["pace"].is_null() || codex["pace"]["primary"].is_null()); +} + #[test] fn json_pace_follows_weekly_work_days() { use chrono::{Datelike, Duration, Local, TimeZone}; diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 8f0ae2bdca..50482944ef 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -19,6 +19,7 @@ mod last_good_owner; mod models_dev_pricing; mod models_dev_targets; mod openai_dashboard; +mod pace_capability; mod provider; mod provider_factory; mod provider_state; @@ -51,6 +52,7 @@ pub use last_good_owner::{FailureOwnership, LastGoodOwner}; pub use models_dev_pricing::*; pub use models_dev_targets::*; pub use openai_dashboard::*; +pub use pace_capability::*; pub use provider::*; pub use provider_factory::instantiate as instantiate_provider; pub use provider_state::*; diff --git a/rust/src/core/pace_capability.rs b/rust/src/core/pace_capability.rs new file mode 100644 index 0000000000..cd5f61ee16 --- /dev/null +++ b/rust/src/core/pace_capability.rs @@ -0,0 +1,618 @@ +//! Provider reset-window pace (upstream `ProviderPaceCapability`, v0.70.0 +//! `Sources/CodexBarCore/Providers/ProviderDescriptor.swift`). +//! +//! Some providers report a quota that refills on a billing date (Copilot, +//! Cursor, Notion, ...) rather than on a fixed session or weekly cadence. +//! Upstream opts those providers in to "reset-window pace": the card scores +//! the window as weekly-style pace against its real length. A 30-day +//! sentinel (or a missing length, for Copilot) is replaced by the calendar +//! month that ends at the reset, so a February cycle is scored as 28 days. +//! +//! Each provider declares its rule in [`super::ProviderId::pace_capability`]; +//! rules that need provider knowledge are function pointers owned by that +//! provider's module. + +use chrono::{DateTime, Utc}; + +use super::{RateWindow, UsagePace}; + +/// Upstream `ProviderPaceCapability.monthlyWindowSentinelMinutes` (30 days). +pub const MONTHLY_WINDOW_SENTINEL_MINUTES: u32 = 30 * 24 * 60; + +/// Weekly fallback length used when a reset window has no duration +/// (upstream `UsagePace.weekly(..., defaultWindowMinutes: 10080, ...)`). +const RESET_WINDOW_DEFAULT_MINUTES: u32 = 7 * 24 * 60; + +/// Provider-owned predicate over a window at an instant. +pub type PaceWindowPredicate = fn(&RateWindow, DateTime) -> bool; +/// Provider-owned predicate over a window. +pub type PaceDurationPredicate = fn(&RateWindow) -> bool; + +/// A monthly billing window: upstream's 30-day sentinel, or the calendar +/// month ending at the reset. Windows provider modules (Alibaba, +/// CommandCode, Doubao, MiMo, StepFun, OpenCodeGo, ...) already expand the +/// sentinel to that real length via `RateWindow::monthly_window_minutes`, so +/// matching the sentinel alone would drop pace in every 28-, 29- and 31-day +/// cycle. +fn is_calendar_month_window(window: &RateWindow) -> bool { + match window.window_minutes { + Some(MONTHLY_WINDOW_SENTINEL_MINUTES) => true, + Some(minutes) => { + window + .resets_at + .and_then(RateWindow::calendar_month_window_minutes) + == Some(minutes) + } + None => false, + } +} + +/// Which windows get reset-window pace (upstream `ProviderPaceWindowRule`). +#[derive(Debug, Clone, Copy)] +pub enum PaceWindowRule { + Unsupported, + ResetDatePresent, + WindowDurationPresent, + WindowDuration(u32), + /// Upstream `.calendarMonthResetWindow`'s `.windowDuration(minutes: + /// monthlyWindowSentinelMinutes)`. See [`is_calendar_month_window`]. + CalendarMonth, + Custom(PaceWindowPredicate), +} + +impl PaceWindowRule { + pub fn matches(&self, window: &RateWindow, now: DateTime) -> bool { + match self { + Self::Unsupported => false, + Self::ResetDatePresent => window.resets_at.is_some(), + Self::WindowDurationPresent => window.window_minutes.is_some(), + Self::WindowDuration(minutes) => window.window_minutes == Some(*minutes), + Self::CalendarMonth => is_calendar_month_window(window), + Self::Custom(predicate) => predicate(window, now), + } + } +} + +/// Which windows take their length from the calendar month ending at the +/// reset (upstream `ProviderPaceDurationRule`). +#[derive(Debug, Clone, Copy)] +pub enum PaceDurationRule { + Unsupported, + WindowDurationMissing, + WindowDuration(u32), + /// See [`PaceWindowRule::CalendarMonth`]. + CalendarMonth, + Custom(PaceDurationPredicate), +} + +impl PaceDurationRule { + pub fn matches(&self, window: &RateWindow) -> bool { + match self { + Self::Unsupported => false, + Self::WindowDurationMissing => window.window_minutes.is_none(), + Self::WindowDuration(minutes) => window.window_minutes == Some(*minutes), + Self::CalendarMonth => is_calendar_month_window(window), + Self::Custom(predicate) => predicate(window), + } + } +} + +/// Reset-window part of upstream `ProviderPaceCapability`. The standard +/// session/weekly lanes stay with the existing per-lane pace on Windows. +#[derive(Debug, Clone, Copy)] +pub struct ProviderPaceCapability { + pub reset_window_pace: PaceWindowRule, + pub inferred_monthly_duration: PaceDurationRule, +} + +impl ProviderPaceCapability { + pub const UNSUPPORTED: Self = Self { + reset_window_pace: PaceWindowRule::Unsupported, + inferred_monthly_duration: PaceDurationRule::Unsupported, + }; + + /// Upstream `.calendarMonthResetWindow`. + pub const CALENDAR_MONTH_RESET_WINDOW: Self = Self { + reset_window_pace: PaceWindowRule::CalendarMonth, + inferred_monthly_duration: PaceDurationRule::CalendarMonth, + }; + + pub const fn reset_window(rule: PaceWindowRule) -> Self { + Self { + reset_window_pace: rule, + inferred_monthly_duration: PaceDurationRule::Unsupported, + } + } + + pub fn supports_reset_window_pace(&self, window: &RateWindow, now: DateTime) -> bool { + self.reset_window_pace.matches(window, now) + } + + pub fn uses_inferred_monthly_duration(&self, window: &RateWindow) -> bool { + self.inferred_monthly_duration.matches(window) + } + + /// Upstream `resolvedResetWindowForPace`: a window matching the inferred + /// monthly rule takes the length of the UTC calendar month ending at its + /// reset (reset minus one month, day clamped). Anything else is unchanged. + pub fn resolved_reset_window_for_pace(&self, window: &RateWindow) -> RateWindow { + let mut resolved = window.clone(); + if self.uses_inferred_monthly_duration(window) + && let Some(minutes) = window + .resets_at + .and_then(RateWindow::calendar_month_window_minutes) + { + resolved.window_minutes = Some(minutes); + } + resolved + } + + /// Weekly-style pace for a reset window, or `None` when the provider does + /// not opt this window in. Mirrors upstream `resetWindowPaceDetail` + /// (MenuCardView+ModelHelpers.swift) and `CLIRenderer.computePace`: + /// the window must have quota left, is resolved first, and is scored with + /// the weekly fallback and the configured work days. + pub fn reset_window_pace( + &self, + window: &RateWindow, + now: DateTime, + work_days: Option, + ) -> Option { + if !self.supports_reset_window_pace(window, now) || window.remaining_percent() <= 0.0 { + return None; + } + let pace_window = self.resolved_reset_window_for_pace(window); + UsagePace::weekly( + &pace_window, + Some(now), + RESET_WINDOW_DEFAULT_MINUTES, + work_days, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ProviderId; + use chrono::TimeZone; + + fn at(y: i32, m: u32, d: u32, h: u32) -> DateTime { + Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap() + } + + fn window(used: f64, minutes: Option, resets_at: DateTime) -> RateWindow { + RateWindow::with_details(used, minutes, Some(resets_at), None) + } + + fn resolved_minutes(minutes: Option, resets_at: DateTime) -> Option { + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW + .resolved_reset_window_for_pace(&window(10.0, minutes, resets_at)) + .window_minutes + } + + #[test] + fn sentinel_resolves_to_the_calendar_month_ending_at_the_reset() { + // Feb 2026 has 28 days. + assert_eq!( + resolved_minutes(Some(43_200), at(2026, 3, 1, 0)), + Some(40_320) + ); + // Mar 31 minus one month clamps to Feb 28: 31 days. + assert_eq!( + resolved_minutes(Some(43_200), at(2026, 3, 31, 0)), + Some(44_640) + ); + // Leap February 2028: 29 days. + assert_eq!( + resolved_minutes(Some(43_200), at(2028, 3, 1, 0)), + Some(41_760) + ); + // Jan 15 → Dec 15: 31 days. + assert_eq!( + resolved_minutes(Some(43_200), at(2026, 1, 15, 9)), + Some(44_640) + ); + } + + #[test] + fn resolution_leaves_non_matching_windows_unchanged() { + assert_eq!( + resolved_minutes(Some(10_080), at(2026, 3, 1, 0)), + Some(10_080) + ); + assert_eq!(resolved_minutes(None, at(2026, 3, 1, 0)), None); + let no_reset = RateWindow::with_details(10.0, Some(43_200), None, None); + assert_eq!( + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW + .resolved_reset_window_for_pace(&no_reset) + .window_minutes, + Some(43_200) + ); + } + + #[test] + fn copilot_resolves_a_window_without_a_duration() { + let copilot = ProviderId::Copilot.pace_capability(); + let w = window(60.0, None, at(2026, 3, 1, 0)); + assert!(copilot.supports_reset_window_pace(&w, at(2026, 2, 15, 0))); + assert_eq!( + copilot.resolved_reset_window_for_pace(&w).window_minutes, + Some(40_320) + ); + // An explicit duration is kept. + let explicit = window(60.0, Some(43_200), at(2026, 3, 1, 0)); + assert_eq!( + copilot + .resolved_reset_window_for_pace(&explicit) + .window_minutes, + Some(43_200) + ); + } + + #[test] + fn copilot_pace_scores_the_february_cycle_as_28_days() { + // Feb 15 00:00 of a cycle ending Mar 1: 14 of 28 days elapsed. + let pace = ProviderId::Copilot + .pace_capability() + .reset_window_pace( + &window(60.0, None, at(2026, 3, 1, 0)), + at(2026, 2, 15, 0), + None, + ) + .expect("copilot reset-window pace"); + assert!((pace.expected_used_percent - 50.0).abs() < 1e-9); + assert!((pace.delta_percent - 10.0).abs() < 1e-9); + // 40% left at 60% per 14 days: 9 days 8 hours. + let eta = pace.eta_seconds.expect("eta"); + assert!((eta - 806_400.0).abs() < 1e-6, "eta {eta}"); + assert!(!pace.will_last_to_reset); + } + + #[test] + fn a_31_day_cycle_keeps_pace_on_its_first_day() { + // Reset Mar 31, now Feb 28 12:00: 30.5 days left, longer than the + // 30-day sentinel, so the raw window has no pace at all. + let now = at(2026, 2, 28, 12); + let raw = window(1.0, Some(43_200), at(2026, 3, 31, 0)); + assert!(UsagePace::weekly(&raw, Some(now), 10_080, None).is_none()); + let pace = ProviderId::Notion + .pace_capability() + .reset_window_pace(&raw, now, None) + .expect("resolved 31-day pace"); + // 0.5 of 31 days elapsed. + assert!( + (pace.expected_used_percent - 50.0 / 31.0).abs() < 1e-9, + "expected {}", + pace.expected_used_percent + ); + } + + #[test] + fn notion_sentinel_pace_uses_the_real_month_not_30_days() { + let now = at(2026, 2, 15, 0); + let raw = window(50.0, Some(43_200), at(2026, 3, 1, 0)); + let flat = UsagePace::weekly(&raw, Some(now), 10_080, None).expect("flat"); + // A flat 30-day window: 16 of 30 days elapsed. + assert!((flat.expected_used_percent - 53.333_333_333).abs() < 1e-6); + let pace = ProviderId::Notion + .pace_capability() + .reset_window_pace(&raw, now, None) + .expect("resolved"); + assert!((pace.expected_used_percent - 50.0).abs() < 1e-9); + } + + #[test] + fn exhausted_windows_and_unsupported_providers_get_no_reset_window_pace() { + let now = at(2026, 2, 15, 0); + let copilot = ProviderId::Copilot.pace_capability(); + assert!( + copilot + .reset_window_pace(&window(100.0, None, at(2026, 3, 1, 0)), now, None) + .is_none() + ); + let no_reset = RateWindow::with_details(10.0, None, None, None); + assert!(!copilot.supports_reset_window_pace(&no_reset, now)); + for provider in [ProviderId::Codex, ProviderId::Claude, ProviderId::Gemini] { + assert!( + provider + .pace_capability() + .reset_window_pace(&window(10.0, Some(43_200), at(2026, 3, 1, 0)), now, None) + .is_none(), + "{provider:?}" + ); + } + } + + #[test] + fn cursor_needs_a_window_duration() { + let cursor = ProviderId::Cursor.pace_capability(); + let now = at(2026, 2, 15, 0); + assert!(!cursor.supports_reset_window_pace(&window(10.0, None, at(2026, 3, 1, 0)), now)); + let w = window(10.0, Some(40_320), at(2026, 3, 1, 0)); + assert!(cursor.supports_reset_window_pace(&w, now)); + // Cursor's duration comes from the billing cycle and is never inferred. + assert_eq!( + cursor + .resolved_reset_window_for_pace(&window(10.0, Some(43_200), at(2026, 3, 1, 0))) + .window_minutes, + Some(43_200) + ); + } + + #[test] + fn kimi_opts_in_only_its_weekly_window() { + let kimi = ProviderId::Kimi.pace_capability(); + let now = at(2026, 2, 26, 0); + assert!( + kimi.supports_reset_window_pace(&window(10.0, Some(10_080), at(2026, 3, 1, 0)), now) + ); + assert!(!kimi.supports_reset_window_pace(&window(10.0, Some(300), at(2026, 3, 1, 0)), now)); + assert!( + !kimi.supports_reset_window_pace(&window(10.0, Some(43_200), at(2026, 3, 1, 0)), now) + ); + } + + #[test] + fn calendar_month_providers_match_upstream_opt_ins() { + let now = at(2026, 2, 15, 0); + let sentinel = window(10.0, Some(43_200), at(2026, 3, 1, 0)); + let weekly = window(10.0, Some(10_080), at(2026, 2, 20, 0)); + for provider in [ + ProviderId::Alibaba, + ProviderId::AlibabaTokenPlan, + ProviderId::CommandCode, + ProviderId::Doubao, + ProviderId::MiMo, + ProviderId::StepFun, + ProviderId::Notion, + ProviderId::Ollama, + ProviderId::OpenCodeGo, + ] { + let capability = provider.pace_capability(); + assert!( + capability.supports_reset_window_pace(&sentinel, now), + "{provider:?}" + ); + assert_eq!( + capability + .resolved_reset_window_for_pace(&sentinel) + .window_minutes, + Some(40_320), + "{provider:?}" + ); + assert!( + !capability.supports_reset_window_pace(&weekly, now), + "{provider:?}" + ); + } + } + + /// Windows provider modules (Alibaba, CommandCode, Doubao, MiMo, + /// StepFun, OpenCodeGo, ...) already expand upstream's 30-day sentinel to + /// the calendar month ending at the reset. That shape is the same monthly + /// window, so it keeps reset-window pace in 28-, 29- and 31-day cycles. + #[test] + fn calendar_month_rule_accepts_an_already_resolved_cycle() { + let alibaba = ProviderId::Alibaba.pace_capability(); + // 31-day cycle ending Mar 31 (Feb 28 -> Mar 31 clamps to 31 days). + let march = window(50.0, Some(44_640), at(2026, 3, 31, 0)); + let now = at(2026, 3, 15, 12); + assert!(alibaba.supports_reset_window_pace(&march, now)); + assert!(alibaba.uses_inferred_monthly_duration(&march)); + assert_eq!( + alibaba + .resolved_reset_window_for_pace(&march) + .window_minutes, + Some(44_640) + ); + let pace = alibaba + .reset_window_pace(&march, now, None) + .expect("31-day cycle pace"); + // 15.5 of 31 days elapsed. + assert_eq!(pace.expected_used_percent, 50.0); + assert_eq!(pace.delta_percent, 0.0); + // 28-day February cycle ending Mar 1. + let february = window(60.0, Some(40_320), at(2026, 3, 1, 0)); + let pace = alibaba + .reset_window_pace(&february, at(2026, 2, 15, 0), None) + .expect("28-day cycle pace"); + assert_eq!(pace.expected_used_percent, 50.0); + assert_eq!(pace.delta_percent, 10.0); + // A 31-day length that is not the month ending at this reset is not + // a calendar-month window. + let mismatched = window(50.0, Some(44_640), at(2026, 3, 1, 0)); + assert!(!alibaba.supports_reset_window_pace(&mismatched, at(2026, 2, 15, 0))); + assert!(!alibaba.uses_inferred_monthly_duration(&mismatched)); + // Without a reset only the sentinel identifies the monthly window. + let unknown_reset = RateWindow::with_details(50.0, Some(44_640), None, None); + assert!(!alibaba.uses_inferred_monthly_duration(&unknown_reset)); + // Providers upstream does not opt in still ignore it. + assert!( + !ProviderId::Claude + .pace_capability() + .supports_reset_window_pace(&march, now) + ); + } + + /// Port of upstream `ProviderPaceCapabilityTests` "descriptor pace + /// capabilities match the supported provider mapping" (v0.70.0), over + /// every Windows provider. + #[test] + fn descriptor_pace_capabilities_match_the_supported_provider_mapping() { + const WEEKLY: u32 = 7 * 24 * 60; + const MONTHLY: u32 = MONTHLY_WINDOW_SENTINEL_MINUTES; + let now = DateTime::::from_timestamp(1_750_000_000, 0).unwrap(); + let in_secs = |seconds: i64| Some(now + chrono::Duration::seconds(seconds)); + let day = 24 * 60 * 60; + let w = |minutes: Option, resets_at: Option>, text: Option<&str>| { + RateWindow::with_details(50.0, minutes, resets_at, text.map(str::to_string)) + }; + let fixtures = [ + w(None, None, None), + w(None, in_secs(4 * day), None), + w(None, in_secs(8 * day), None), + w(None, in_secs(30 * day), None), + w(Some(60), in_secs(30 * 60), None), + w(Some(WEEKLY), None, None), + w(Some(WEEKLY), in_secs(4 * day), None), + w(Some(WEEKLY), in_secs(8 * day), None), + w(Some(MONTHLY), None, None), + w(Some(MONTHLY), in_secs(20 * day), None), + w(Some(MONTHLY), in_secs(20 * day), Some("MCP")), + w(Some(0), in_secs(60), None), + w(Some(WEEKLY), in_secs(-60), None), + ]; + // Upstream `GrokProviderDescriptor.primaryLabel(window:now:)`. + let grok_is_weekly = |window: &RateWindow| { + let seconds = match window.window_minutes { + Some(minutes) => f64::from(minutes) * 60.0, + None => match window.resets_at { + Some(resets_at) => (resets_at - now).num_seconds() as f64, + None => return false, + }, + }; + seconds > 3600.0 && (4.0..=12.0).contains(&(seconds / 86_400.0).round()) + }; + let calendar_month = |window: &RateWindow| window.window_minutes == Some(MONTHLY); + let expected_reset_window = |provider: ProviderId, window: &RateWindow| match provider { + ProviderId::Copilot => window.resets_at.is_some(), + ProviderId::Cursor => window.window_minutes.is_some(), + ProviderId::Grok => { + let Some(resets_at) = window.resets_at else { + return false; + }; + let minutes = window.window_minutes.unwrap_or(WEEKLY); + let until = (resets_at - now).num_seconds(); + grok_is_weekly(window) + && minutes > 0 + && until > 0 + && until <= i64::from(minutes) * 60 + } + ProviderId::Kimi => window.window_minutes == Some(WEEKLY), + ProviderId::Zai => { + window.window_minutes == Some(MONTHLY) + && window.reset_description.as_deref() == Some("MCP") + } + ProviderId::Amp => { + window.window_minutes.is_some() + && window + .reset_description + .as_deref() + .is_some_and(|text| text.starts_with("renews in ")) + } + ProviderId::Alibaba + | ProviderId::AlibabaTokenPlan + | ProviderId::CommandCode + | ProviderId::Doubao + | ProviderId::MiMo + | ProviderId::Notion + | ProviderId::Ollama + | ProviderId::OpenCodeGo + | ProviderId::StepFun => calendar_month(window), + _ => false, + }; + let expected_monthly_inference = |provider: ProviderId, window: &RateWindow| match provider + { + ProviderId::Copilot => window.window_minutes.is_none(), + ProviderId::Zai => { + window.window_minutes == Some(MONTHLY) + && window.reset_description.as_deref() == Some("MCP") + } + ProviderId::Alibaba + | ProviderId::AlibabaTokenPlan + | ProviderId::CommandCode + | ProviderId::Doubao + | ProviderId::MiMo + | ProviderId::Notion + | ProviderId::Ollama + | ProviderId::OpenCodeGo + | ProviderId::StepFun => calendar_month(window), + _ => false, + }; + for &provider in ProviderId::all() { + let capability = provider.pace_capability(); + for window in &fixtures { + assert_eq!( + capability.supports_reset_window_pace(window, now), + expected_reset_window(provider, window), + "reset-window pace for {provider:?}, {window:?}" + ); + assert_eq!( + capability.uses_inferred_monthly_duration(window), + expected_monthly_inference(provider, window), + "monthly inference for {provider:?}, {window:?}" + ); + } + } + } + + #[test] + fn zai_opts_in_only_the_monthly_mcp_window() { + let zai = ProviderId::Zai.pace_capability(); + let now = at(2026, 2, 15, 0); + let mcp = RateWindow::with_details( + 10.0, + Some(43_200), + Some(at(2026, 3, 1, 0)), + Some("MCP".to_string()), + ); + assert!(zai.supports_reset_window_pace(&mcp, now)); + assert_eq!( + zai.resolved_reset_window_for_pace(&mcp).window_minutes, + Some(40_320) + ); + let tokens = window(10.0, Some(43_200), at(2026, 3, 1, 0)); + assert!(!zai.supports_reset_window_pace(&tokens, now)); + let mcp_without_duration = + RateWindow::with_details(10.0, None, Some(at(2026, 3, 1, 0)), Some("MCP".to_string())); + assert!(!zai.supports_reset_window_pace(&mcp_without_duration, now)); + } + + #[test] + fn amp_opts_in_only_renewing_subscription_windows() { + let amp = ProviderId::Amp.pace_capability(); + let now = at(2026, 2, 15, 0); + let renewing = RateWindow::with_details( + 10.0, + Some(40_320), + Some(at(2026, 3, 1, 0)), + Some("renews in 14 days".to_string()), + ); + assert!(amp.supports_reset_window_pace(&renewing, now)); + let free = RateWindow::with_details( + 10.0, + Some(1_440), + Some(at(2026, 2, 16, 0)), + Some("resets daily".to_string()), + ); + assert!(!amp.supports_reset_window_pace(&free, now)); + } + + #[test] + fn grok_opts_in_weekly_windows_inside_their_length() { + let grok = ProviderId::Grok.pace_capability(); + let now = at(2026, 2, 15, 0); + // Explicit weekly duration, reset 3 days away. + assert!( + grok.supports_reset_window_pace(&window(10.0, Some(10_080), at(2026, 2, 18, 0)), now) + ); + // Monthly duration is labelled "Monthly", not "Weekly". + assert!( + !grok.supports_reset_window_pace(&window(10.0, Some(43_200), at(2026, 2, 18, 0)), now) + ); + // No duration: the label comes from the distance to the reset. + // 6 days away rounds to "Weekly" and fits the 7-day fallback. + assert!(grok.supports_reset_window_pace(&window(10.0, None, at(2026, 2, 21, 0)), now)); + // 3 days away rounds to 3 days, which has no label. + assert!(!grok.supports_reset_window_pace(&window(10.0, None, at(2026, 2, 18, 0)), now)); + // Reset in the past. + assert!( + !grok.supports_reset_window_pace(&window(10.0, Some(10_080), at(2026, 2, 14, 0)), now) + ); + // Weekly label but the reset is further away than the window. + assert!( + !grok.supports_reset_window_pace(&window(10.0, Some(7_200), at(2026, 2, 22, 0)), now) + ); + } +} diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index be910207d4..a78bf64a58 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -539,6 +539,49 @@ impl ProviderId { } } + /// Reset-window pace rule (upstream `ProviderDescriptor.pace`, v0.70.0). + /// Only the providers upstream opts in get reset-window pace; rules that + /// need provider knowledge live in that provider's module. + pub fn pace_capability(&self) -> super::ProviderPaceCapability { + use super::{PaceDurationRule, PaceWindowRule, ProviderPaceCapability}; + match self { + ProviderId::Copilot => ProviderPaceCapability { + reset_window_pace: PaceWindowRule::ResetDatePresent, + inferred_monthly_duration: PaceDurationRule::WindowDurationMissing, + }, + ProviderId::Cursor => { + ProviderPaceCapability::reset_window(PaceWindowRule::WindowDurationPresent) + } + ProviderId::Grok => ProviderPaceCapability::reset_window(PaceWindowRule::Custom( + crate::providers::grok::reset_window_pace_matches, + )), + ProviderId::Kimi => { + ProviderPaceCapability::reset_window(PaceWindowRule::WindowDuration(7 * 24 * 60)) + } + ProviderId::Zai => ProviderPaceCapability { + reset_window_pace: PaceWindowRule::Custom( + crate::providers::zai::reset_window_pace_matches, + ), + inferred_monthly_duration: PaceDurationRule::Custom( + crate::providers::zai::is_monthly_mcp_window, + ), + }, + ProviderId::Amp => ProviderPaceCapability::reset_window(PaceWindowRule::Custom( + crate::providers::amp::reset_window_pace_matches, + )), + ProviderId::Alibaba + | ProviderId::AlibabaTokenPlan + | ProviderId::CommandCode + | ProviderId::Doubao + | ProviderId::MiMo + | ProviderId::StepFun + | ProviderId::Notion + | ProviderId::Ollama + | ProviderId::OpenCodeGo => ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + _ => ProviderPaceCapability::UNSUPPORTED, + } + } + /// Parse from CLI name string pub fn from_cli_name(name: &str) -> Option { match name.to_lowercase().as_str() { diff --git a/rust/src/core/rate_window.rs b/rust/src/core/rate_window.rs index cf250ef73d..70ecab7b41 100755 --- a/rust/src/core/rate_window.rs +++ b/rust/src/core/rate_window.rs @@ -243,11 +243,9 @@ fn subtract_one_calendar_month(dt: DateTime) -> Option> { let (py, pm) = if m == 1 { (y - 1, 12) } else { (y, m - 1) }; let max_day = days_in_month(py, pm); let day = dt.day().min(max_day); - dt.date_naive() - .with_year(py)? - .with_month(pm)? - .with_day(day) - .map(|d| d.and_time(dt.time()).and_utc()) + // Build the date in one step: chaining `with_month` on the 29th-31st fails + // when the previous month is shorter (Mar 31 -> Feb 31). + chrono::NaiveDate::from_ymd_opt(py, pm, day).map(|d| d.and_time(dt.time()).and_utc()) } fn days_in_month(year: i32, month: u32) -> u32 { @@ -376,6 +374,28 @@ mod tests { assert_eq!(RateWindow::monthly_window_minutes(None), None); } + #[test] + fn calendar_month_window_clamps_the_day_into_a_shorter_month() { + // Mar 31 minus one month is Feb 28 (Foundation clamps the day): 31 days. + let resets = Utc.with_ymd_and_hms(2026, 3, 31, 0, 0, 0).unwrap(); + assert_eq!( + RateWindow::calendar_month_window_minutes(resets), + Some(44_640) + ); + // May 31 minus one month is Apr 30: 31 days. + let resets = Utc.with_ymd_and_hms(2026, 5, 31, 12, 0, 0).unwrap(); + assert_eq!( + RateWindow::calendar_month_window_minutes(resets), + Some(44_640) + ); + // Mar 30 2028 minus one month is Feb 29 (leap year): 30 days. + let resets = Utc.with_ymd_and_hms(2028, 3, 30, 0, 0, 0).unwrap(); + assert_eq!( + RateWindow::calendar_month_window_minutes(resets), + Some(43_200) + ); + } + #[test] fn cadence_boundary_session_exactly_300() { assert_eq!( diff --git a/rust/src/providers/amp/mod.rs b/rust/src/providers/amp/mod.rs index 07e9aba75d..30fee16f51 100755 --- a/rust/src/providers/amp/mod.rs +++ b/rust/src/providers/amp/mod.rs @@ -19,6 +19,12 @@ use crate::core::{ RateWindow, SourceMode, UsageSnapshot, }; +/// Upstream Amp `ProviderPaceCapability.resetWindowPace`: subscription +/// windows with a known length and a "renews in ..." reset. +pub(crate) fn reset_window_pace_matches(window: &RateWindow, _now: chrono::DateTime) -> bool { + display::window_allows_pace(window) +} + /// Amp provider (Sourcegraph) pub struct AmpProvider { metadata: ProviderMetadata, diff --git a/rust/src/providers/cursor/api.rs b/rust/src/providers/cursor/api.rs index 85cc73f379..c067b10ae4 100755 --- a/rust/src/providers/cursor/api.rs +++ b/rust/src/providers/cursor/api.rs @@ -190,6 +190,15 @@ impl CursorApi { .billing_cycle_end .as_ref() .and_then(|s| parse_iso_date(s)); + // Upstream `CursorStatusProbe`: plan lanes carry the billing cycle's + // length so reset-window pace scores against the real cycle. + let cycle_minutes = billing_cycle_window_minutes( + summary + .billing_cycle_start + .as_deref() + .and_then(parse_iso_date), + billing_end, + ); let (percent_used, secondary, model_specific, cost_snapshot) = if let Some(team_budget) = team_budget { @@ -235,11 +244,11 @@ impl CursorApi { }; let secondary = plan.auto_percent_used.map(|v| { - RateWindow::with_details(clamp_percent(v), None, billing_end, None) + RateWindow::with_details(clamp_percent(v), cycle_minutes, billing_end, None) }); let model_specific = plan.api_percent_used.map(|v| { - RateWindow::with_details(clamp_percent(v), None, billing_end, None) + RateWindow::with_details(clamp_percent(v), cycle_minutes, billing_end, None) }); let cost = Self::on_demand_cost(individual.on_demand.as_ref(), billing_end) @@ -278,7 +287,7 @@ impl CursorApi { (0.0, None, None, None) }; - let primary = RateWindow::with_details(percent_used, None, billing_end, None); + let primary = RateWindow::with_details(percent_used, cycle_minutes, billing_end, None); let plan_type = summary .membership_type @@ -547,6 +556,20 @@ impl UserInfo { // --- Helper functions --- +/// Billing cycle length in whole minutes (rounded), or `None` when either end +/// is missing or the cycle is empty or reversed. +fn billing_cycle_window_minutes( + start: Option>, + end: Option>, +) -> Option { + let seconds = (end? - start?).num_seconds(); + if seconds <= 0 { + return None; + } + let minutes = (seconds + 30) / 60; + u32::try_from(minutes).ok().filter(|m| *m > 0) +} + fn parse_iso_date(s: &str) -> Option> { // Try with fractional seconds if let Ok(dt) = DateTime::parse_from_rfc3339(s) { @@ -710,6 +733,61 @@ mod tests { assert_eq!(result.plan_type.as_deref(), Some("Cursor Pro")); } + /// Upstream `CursorSandUsageStatus.windowMinutes(start:end:)`: the plan + /// bars carry the billing-cycle length so reset-window pace can score them. + #[test] + fn plan_lanes_carry_the_billing_cycle_length() { + let json = r#"{ + "billingCycleStart": "2026-02-01T00:00:00Z", + "billingCycleEnd": "2026-03-01T00:00:00Z", + "membershipType": "pro", + "individualUsage": { + "plan": { + "used": 1500, + "limit": 5000, + "totalPercentUsed": 30.0, + "autoPercentUsed": 20.0, + "apiPercentUsed": 10.0 + } + } + }"#; + let result = api() + .build_result_with_team_budget(parse_summary(json), None, None) + .unwrap(); + assert_eq!(result.primary.window_minutes, Some(40_320)); + assert_eq!(result.secondary.expect("auto").window_minutes, Some(40_320)); + assert_eq!( + result.model_specific.expect("api").window_minutes, + Some(40_320) + ); + } + + #[test] + fn billing_cycle_length_rounds_to_the_nearest_minute_and_needs_both_ends() { + let start = parse_iso_date("2026-02-01T00:00:00Z"); + let end = parse_iso_date("2026-03-01T00:00:31Z"); + assert_eq!(billing_cycle_window_minutes(start, end), Some(40_321)); + let end = parse_iso_date("2026-03-01T00:00:29Z"); + assert_eq!(billing_cycle_window_minutes(start, end), Some(40_320)); + assert_eq!(billing_cycle_window_minutes(None, end), None); + assert_eq!(billing_cycle_window_minutes(start, None), None); + // A cycle that ends before it starts has no length. + assert_eq!(billing_cycle_window_minutes(end, start), None); + } + + #[test] + fn plan_lanes_without_a_cycle_start_have_no_length() { + let json = r#"{ + "billingCycleEnd": "2026-03-01T00:00:00Z", + "membershipType": "pro", + "individualUsage": { "plan": { "used": 1500, "limit": 5000 } } + }"#; + let result = api() + .build_result_with_team_budget(parse_summary(json), None, None) + .unwrap(); + assert_eq!(result.primary.window_minutes, None); + } + #[test] fn clamps_plan_usage_percent_at_100_when_over_limit() { // Upstream #2255: included usage past limit must not paint >100%. diff --git a/rust/src/providers/grok/mod.rs b/rust/src/providers/grok/mod.rs index abe5fb8d1c..aab66a145d 100644 --- a/rust/src/providers/grok/mod.rs +++ b/rust/src/providers/grok/mod.rs @@ -759,6 +759,44 @@ fn primary_label_for_cycle_minutes(minutes: u32) -> Option<&'static str> { } } +/// Upstream `GrokProviderDescriptor.primaryLabel(window:now:)`: the label of +/// the window's duration, or of the distance to its reset when the duration +/// is unknown. +fn primary_label_for_window(window: &RateWindow, now: DateTime) -> Option<&'static str> { + if let Some(minutes) = window.window_minutes { + return primary_label_for_cycle_minutes(minutes); + } + let resets_at = window.resets_at?; + let seconds = (resets_at - now).num_milliseconds() as f64 / 1_000.0; + if seconds <= 3_600.0 { + return None; + } + let days = (seconds / 86_400.0).round(); + if (4.0..=12.0).contains(&days) { + Some("Weekly") + } else if (20.0..=45.0).contains(&days) { + Some("Monthly") + } else { + None + } +} + +/// Upstream Grok `ProviderPaceCapability.resetWindowPace`: a window labelled +/// "Weekly" whose reset falls inside its length (7 days when unknown). +pub(crate) fn reset_window_pace_matches(window: &RateWindow, now: DateTime) -> bool { + if primary_label_for_window(window, now) != Some("Weekly") { + return false; + } + let Some(resets_at) = window.resets_at else { + return false; + }; + let window_minutes = window.window_minutes.unwrap_or(7 * 24 * 60); + let until_reset = resets_at - now; + window_minutes > 0 + && until_reset > chrono::Duration::zero() + && until_reset <= chrono::Duration::minutes(i64::from(window_minutes)) +} + fn result_from_cookie_billing(billing: GrokBillingSnapshot) -> ProviderFetchResult { result_from_billing(billing, "grok-browser", None, None, None) } diff --git a/rust/src/providers/zai/mod.rs b/rust/src/providers/zai/mod.rs index 6d423d3559..e541753ad7 100755 --- a/rust/src/providers/zai/mod.rs +++ b/rust/src/providers/zai/mod.rs @@ -491,12 +491,16 @@ impl ZaiProvider { // Upstream 0.48.0 `rateWindow`/`resetDescription`: only token-type // windows keep duration minutes; TIME_LIMIT (MCP) carries the "MCP" - // label and no window duration; 5-hour token windows are labeled - // "5-hour"; otherwise the explicit window label is used. + // label and no window duration, except upstream v0.70.0 zai.js's + // monthly MCP marker (unit 5, number 1), which gets the 30-day + // sentinel; 5-hour token windows are labeled "5-hour"; otherwise the + // explicit window label is used. let now = Utc::now(); let make_window = |l: &ZaiLimit| -> RateWindow { let window_mins = if is_token_limit_type(l.limit_type.as_deref()) { ZaiProvider::window_minutes(l) + } else if is_monthly_mcp_marker(l) { + Some(crate::core::MONTHLY_WINDOW_SENTINEL_MINUTES) } else { None }; @@ -578,6 +582,22 @@ impl ZaiProvider { } } +fn is_monthly_mcp_marker(l: &ZaiLimit) -> bool { + is_time_limit_type(l.limit_type.as_deref()) && l.unit == Some(5) && l.number == Some(1) +} + +/// Upstream `ZaiProviderDescriptor.isMonthlyMCPWindow`: the MCP window with +/// the 30-day sentinel duration. Both its reset-window pace and its inferred +/// monthly length follow this rule. +pub(crate) fn is_monthly_mcp_window(window: &RateWindow) -> bool { + window.window_minutes == Some(crate::core::MONTHLY_WINDOW_SENTINEL_MINUTES) + && window.reset_description.as_deref() == Some("MCP") +} + +pub(crate) fn reset_window_pace_matches(window: &RateWindow, _now: DateTime) -> bool { + is_monthly_mcp_window(window) +} + /// Upstream 0.48.0 `resetDescription`: MCP (TIME_LIMIT) → "MCP"; 5-hour /// token window → "5-hour"; else the explicit window label, if any. fn rate_window_reset_description(l: &ZaiLimit, window_mins: Option) -> Option { diff --git a/rust/src/providers/zai/tests.rs b/rust/src/providers/zai/tests.rs index b02ecd2781..8a68039e6d 100644 --- a/rust/src/providers/zai/tests.rs +++ b/rust/src/providers/zai/tests.rs @@ -305,9 +305,9 @@ fn bare_time_limit_primary_has_no_window_duration() { #[test] fn mcp_limit_renders_separate_named_window() { - // Upstream 0.48.0 GLM Coding Plan layout: coding-limit primary + - // MCP as a named extra window; MCP 1-minute marker no longer maps - // to a monthly sentinel secondary. + // Upstream GLM Coding Plan layout: coding-limit primary + MCP as a + // named extra window, not a secondary. Upstream v0.70.0 zai.js gives + // the MCP 1-minute marker the 30-day sentinel duration. let provider = ZaiProvider::new(); let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ "code": 200, @@ -343,7 +343,7 @@ fn mcp_limit_renders_separate_named_window() { .find(|window| window.id == "zai-mcp") .expect("MCP extra window"); assert_eq!(mcp.title, "MCP"); - assert_eq!(mcp.window.window_minutes, None); + assert_eq!(mcp.window.window_minutes, Some(43_200)); assert_eq!(mcp.window.reset_description.as_deref(), Some("MCP")); assert_eq!(mcp.window.used_percent, 10.0); } @@ -574,3 +574,34 @@ fn recognized_entry_without_any_quota_signal_is_malformed() { ); } } + +#[test] +fn monthly_mcp_marker_gets_the_sentinel_and_reset_window_pace() { + let reset = DateTime::parse_from_rfc3339("2026-03-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let parsed = parse_data(serde_json::json!({"limits": [ + {"type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10}, + {"type": "TIME_LIMIT", "unit": 5, "number": 1, "percentage": 25, + "nextResetTime": reset.timestamp_millis()} + ]})) + .unwrap(); + + let mcp = &parsed.usage.extra_rate_windows[0]; + assert_eq!(mcp.id, "zai-mcp"); + assert_eq!(mcp.window.window_minutes, Some(43_200)); + assert_eq!(mcp.window.reset_description.as_deref(), Some("MCP")); + assert_eq!(mcp.window.resets_at, Some(reset)); + + let zai = crate::core::ProviderId::Zai.pace_capability(); + let now = DateTime::parse_from_rfc3339("2026-02-15T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert!(zai.supports_reset_window_pace(&mcp.window, now)); + assert_eq!( + zai.resolved_reset_window_for_pace(&mcp.window) + .window_minutes, + Some(40_320) + ); + assert_eq!(parsed.usage.primary.window_minutes, Some(300)); +}