diff --git a/CHANGELOG.md b/CHANGELOG.md index e83f63b1a1..88cbefff3e 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ menu-bar layout. ### Fixed - Kimi: when the Kimi Code CLI credential is stale or rejected and web auth has no token to try, direct the user to run `kimi` or add a Kimi Code API key in Settings, keeping the web fallback and leaving CLI-owned credentials read-only. A rejected web token still reports the web error, and a Kimi Code API 403 reports a permission or quota denial instead of a sign-in problem. - Kimi Code: while the monthly membership pool is exhausted, show the shorter Code windows as “Blocked by monthly limit” without their own usage bar, reset, or pace forecast, keeping the raw usage in the CLI, tray icon, and float bar; the block lifts at the pool's reset even before the next refresh. +- Kimi Code: accept ratio-pool Code API responses that report only some windows, keep an unreported weekly quota absent instead of showing 0%, and show the monthly Total usage reported by the Code API without browser authentication. - Claude: when Hide Personal Info is enabled, keep saved account rows distinguishable with stable localized `Account N` labels and matching redacted tooltips. --- diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 5ffb1958cd..5a356f7bd4 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -484,6 +484,38 @@ mod tests { ); } + #[test] + fn kimi_monthly_only_snapshot_selects_the_total_usage_lane() { + // Upstream 0.60.5 #3694: a Code API response may report only the + // monthly Total usage pool. The weekly lane is then an informational + // placeholder, and every metric preference lands on the monthly lane. + let mut snapshot = snapshot(); + snapshot.provider_id = "kimi".to_string(); + snapshot.primary = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: "kimi-monthly".to_string(), + title: "Total usage".to_string(), + window: window(100.0), + fallback_lane: false, + }]; + + for preference in [ + MetricPreference::Automatic, + MetricPreference::Session, + MetricPreference::Weekly, + ] { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Kimi, preference); + let selected = selected_usage_window(&snapshot, &settings); + assert!(!selected.is_informational, "{preference:?}"); + assert_eq!(selected.used_percent, 100.0, "{preference:?}"); + } + } + #[test] fn opencodego_automatic_prefers_explicitly_exhausted_window_over_higher_percentage() { let mut snapshot = snapshot(); diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 237f7dab3d..faf767be2a 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -93,6 +93,12 @@ z.ai Coding Plans accept both `TOKENS_LIMIT` and `CREDIT_LIMIT` rows. The shorte Upstream's independent **WidgetKit** provider-widget configuration has no Windows analogue in this repository. Win-CodexBar has no WidgetKit extension; provider cards and tray entries are already independent Windows/Tauri surfaces. +### Kimi Code quotas + +Kimi Code API responses can carry the older count-based fields, the newer `usages` ratio pools, or both. Ratio pools take precedence for the 5-hour, weekly, and monthly Total usage windows they provide; an invalid ratio falls back to that lane's legacy counts instead of hiding them. Missing windows stay absent: an unreported weekly quota shows as an informational "No weekly quota reported" row rather than 0%, and percentages keep the API's precision without implying request counts. The monthly Total usage pool comes straight from the Code API, without browser authentication, and optional web enrichment only fills it in when the API did not report one. A response without any supported window is a parse error, so Auto mode moves on to the Kimi Code CLI credential and web auth. + +Weekly is the primary lane and the 5-hour rate limit the secondary on every Kimi source. When the monthly pool is the only reported quota, Automatic and the Session or Weekly preferences use it for the tray icon and float bar. An exhausted monthly pool blocks the shorter Code windows until its reset. + ## Upstream doc warning Upstream `docs/providers.md` is a large auto-strategy matrix (60+ providers) for the macOS app. Use it as **inspiration** when porting a provider. For runtime truth on Windows: diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 94c4b920ad..423c63bd39 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -889,8 +889,9 @@ pub trait Provider: Send + Sync { /// Whether Automatic metric selection is a dead end when the primary lane /// is informational and no secondary lane exists. Providers with - /// Automatic-only fallback lanes (seat credits) override this to `false` - /// so the fallback lane can still fill in. + /// Automatic-only fallback lanes (seat credits) or a named extra lane that + /// can be the only reported quota (Kimi's monthly pool) override this to + /// `false` so that lane can still fill in. fn automatic_metric_missing_core_is_terminal(&self) -> bool { true } diff --git a/rust/src/providers/kimi/code_api.rs b/rust/src/providers/kimi/code_api.rs index 39afa68a01..fdb32885ae 100644 --- a/rust/src/providers/kimi/code_api.rs +++ b/rust/src/providers/kimi/code_api.rs @@ -8,8 +8,9 @@ use reqwest::Url; use std::path::{Path, PathBuf}; use super::{ - FetchContext, KimiCodeApiUsageResponse, KimiProvider, KimiRegion, ProviderError, UsageSnapshot, - ascii_header_value, cleaned_env, cleaned_owned, kimi_window_minutes, + FetchContext, KimiCodeApiUsageResponse, KimiProvider, KimiRegion, MONTHLY_WINDOW_ID, + ProviderError, RateWindow, UsageSnapshot, ascii_header_value, cleaned_env, cleaned_owned, + kimi_window_minutes, }; use super::{ratio_pool, web}; @@ -21,6 +22,12 @@ const KIMI_OAUTH_HOST_ENV: &str = "KIMI_OAUTH_HOST"; const KIMI_CODE_CLI_PLATFORM: &str = "kimi_code_cli"; /// CLI access tokens must remain valid for at least this long to be reused. const KIMI_CODE_CREDENTIAL_MIN_TTL_SECS: f64 = 60.0; +const SESSION_WINDOW_MINUTES: u32 = 5 * 60; +const WEEKLY_WINDOW_MINUTES: u32 = 7 * 24 * 60; +/// Monthly sentinel shared with the web `Total usage` lane. +const MONTHLY_WINDOW_MINUTES: u32 = 30 * 24 * 60; +/// Placeholder text for a Code API response that reports no weekly quota. +pub(super) const MISSING_WEEKLY_DESCRIPTION: &str = "No weekly quota reported"; #[derive(Debug, serde::Deserialize)] struct KimiCodeCredentialFile { @@ -117,77 +124,77 @@ fn code_api_status_error(status: reqwest::StatusCode) -> ProviderError { } } +/// Upstream `KimiUsageSnapshot.toUsageSnapshot` (0.60.5 #3694): weekly is the +/// primary lane and the 5-hour rate limit the secondary, as on the web path. +/// Each ratio pool takes precedence over the legacy counters of its lane; an +/// absent or invalid pool falls back to those counters. The monthly pool is +/// the `Total usage` extra lane. Missing lanes are not invented: an absent +/// weekly quota stays an informational primary, and a response without any +/// supported window is a parse error. pub(super) fn snapshot_from_code_api_response( response: KimiCodeApiUsageResponse, ) -> Result { - let pools_present = response.usages.is_some(); + let pools = response.usages.as_ref(); let legacy_limit = response.limits.as_ref().and_then(|limits| limits.first()); - let legacy_session_minutes = + let legacy_rate_limit_minutes = legacy_limit.and_then(|limit| limit.window.as_ref().and_then(kimi_window_minutes)); - let session_pool = response - .usages - .as_ref() - .and_then(|pools| pools.session.as_ref()) + let weekly = pools + .and_then(|pools| pools.weekly.as_ref()) .and_then(|pool| { ratio_pool::resolved_ratio_window( &response, pool, - legacy_limit.map(|limit| &limit.detail), - 300, - legacy_session_minutes, + response.usage.as_ref(), + WEEKLY_WINDOW_MINUTES, + Some(WEEKLY_WINDOW_MINUTES), ) + }) + .or_else(|| { + response.usage.as_ref().and_then(|detail| { + KimiProvider::rate_window_from_usage_detail(detail, Some(WEEKLY_WINDOW_MINUTES)) + .ok() + }) }); - let weekly_pool = response - .usages - .as_ref() - .and_then(|pools| pools.weekly.as_ref()) + let rate_limit = pools + .and_then(|pools| pools.session.as_ref()) .and_then(|pool| { ratio_pool::resolved_ratio_window( &response, pool, - response.usage.as_ref(), - 10_080, - Some(10_080), + legacy_limit.map(|limit| &limit.detail), + SESSION_WINDOW_MINUTES, + legacy_rate_limit_minutes, ) + }) + .or_else(|| { + legacy_limit.and_then(|limit| { + KimiProvider::rate_window_from_usage_detail( + &limit.detail, + legacy_rate_limit_minutes, + ) + .ok() + }) }); - let monthly_pool = response - .usages - .as_ref() + let monthly = pools .and_then(|pools| pools.monthly.as_ref()) - .and_then(|pool| pool.rate_window(43_200)); - let primary = if pools_present { - session_pool.ok_or_else(|| { - ProviderError::Parse("Kimi Code API returned an unusable session quota pool".into()) - })? - } else { - response - .usage - .as_ref() - .and_then(|detail| { - KimiProvider::rate_window_from_usage_detail(detail, Some(10_080)).ok() - }) - .ok_or_else(|| { - ProviderError::Parse("Kimi Code API has no usable quota window".into()) - })? - }; + .and_then(|pool| pool.rate_window(MONTHLY_WINDOW_MINUTES)); + if weekly.is_none() && rate_limit.is_none() && monthly.is_none() { + return Err(ProviderError::Parse( + "No supported quota windows in Code usage response".into(), + )); + } + + let primary = weekly.unwrap_or_else(|| RateWindow::informational(MISSING_WEEKLY_DESCRIPTION)); let mut usage = UsageSnapshot::new(primary).with_login_method( response .plan_name() .unwrap_or_else(|| "Code API".to_string()), ); - if let Some(weekly) = weekly_pool { - usage = usage.with_secondary(weekly); - } else if !pools_present - && let Some(limit) = response.limits.unwrap_or_default().into_iter().next() - { - let window_minutes = limit.window.as_ref().and_then(kimi_window_minutes); - usage = usage.with_secondary(KimiProvider::rate_window_from_usage_detail( - &limit.detail, - window_minutes, - )?); - } - if let Some(monthly) = monthly_pool { - usage = usage.with_tertiary(monthly); + if let Some(rate_limit) = rate_limit { + usage = usage.with_secondary(rate_limit); + } + if let Some(monthly) = monthly { + usage = usage.with_extra_rate_window(MONTHLY_WINDOW_ID, "Total usage", monthly); } Ok(usage) } @@ -697,6 +704,7 @@ mod tests { fn credential_freshness_requires_sixty_second_margin() { assert!((KIMI_CODE_CREDENTIAL_MIN_TTL_SECS - 60.0).abs() < f64::EPSILON); } + #[test] fn ratio_pools_preserve_unknown_weekly_and_explicit_monthly_zero() { let response: KimiCodeApiUsageResponse = serde_json::from_value(json!({ @@ -712,42 +720,70 @@ mod tests { })) .expect("ratio-pool fixture parses"); let snapshot = snapshot_from_code_api_response(response).expect("ratio pools are usable"); - assert_eq!(snapshot.primary.window_minutes, Some(300)); - assert_eq!(snapshot.primary.used_percent, 25.0); assert!( - snapshot.secondary.is_none(), + snapshot.primary.is_informational, "missing weekly pool stays unknown" ); - let monthly = snapshot.tertiary.expect("explicit monthly zero is known"); - assert_eq!(monthly.window_minutes, Some(43_200)); - assert_eq!(monthly.used_percent, 0.0); - assert!(monthly.usage_known); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some(MISSING_WEEKLY_DESCRIPTION) + ); + let rate_limit = snapshot + .secondary + .expect("session pool is the rate-limit lane"); + assert_eq!(rate_limit.window_minutes, Some(300)); + assert_eq!(rate_limit.used_percent, 25.0); + assert!(snapshot.tertiary.is_none()); + let [monthly] = snapshot.extra_rate_windows.as_slice() else { + panic!("explicit monthly zero is the Total usage lane"); + }; + assert_eq!(monthly.id, MONTHLY_WINDOW_ID); + assert_eq!(monthly.window.window_minutes, Some(43_200)); + assert_eq!(monthly.window.used_percent, 0.0); + assert!(monthly.window.usage_known); } #[test] - fn ratio_pools_do_not_invent_zero_for_missing_or_invalid_primary() { + fn invalid_ratio_pools_without_counters_fail_to_parse() { for fixture in [ - json!({ "usages": { "limit_5h": {} } }), json!({ "usages": { "limit_5h": { "used_ratio": -0.1 } } }), + json!({ + "usages": { + "limit_7d": { "used_ratio": "abc" }, + "limit_month_total": { "used_ratio": null } + } + }), ] { let response: KimiCodeApiUsageResponse = serde_json::from_value(fixture).expect("fixture parses"); - assert!(snapshot_from_code_api_response(response).is_err()); + assert!(matches!( + snapshot_from_code_api_response(response), + Err(ProviderError::Parse(message)) + if message == "No supported quota windows in Code usage response" + )); } } + // Upstream 0.60.5 #3694 replaces the earlier rule that an unusable + // session pool fails the whole response: each lane falls back to its own + // legacy counters, and lanes without any source stay absent. #[test] - fn unusable_explicit_session_pool_does_not_fall_back_to_legacy_usage() { + fn invalid_session_pool_keeps_the_legacy_weekly_counters() { let response: KimiCodeApiUsageResponse = serde_json::from_value(json!({ "usages": { "limit_5h": { "used_ratio": -0.1 } }, "usage": { "limit": "100", "used": "20" } })) .expect("fixture parses"); - assert!(matches!( - snapshot_from_code_api_response(response), - Err(ProviderError::Parse(message)) - if message.contains("unusable session quota pool") - )); + let snapshot = snapshot_from_code_api_response(response).expect("weekly counters"); + assert!(!snapshot.primary.is_informational); + assert_eq!(snapshot.primary.used_percent, 20.0); + assert_eq!(snapshot.primary.window_minutes, Some(10_080)); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("20/100 credits") + ); + assert!(snapshot.secondary.is_none()); + assert!(snapshot.extra_rate_windows.is_empty()); } } diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index ee7a3167e0..177e2dc3a3 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -406,6 +406,13 @@ impl Provider for KimiProvider { fn supports_oauth(&self) -> bool { true } + + /// A Code API response may report only the monthly `Total usage` pool + /// (upstream 0.60.5 #3694). Automatic then reads that lane instead of the + /// informational weekly placeholder. + fn automatic_metric_missing_core_is_terminal(&self) -> bool { + false + } } fn kimi_window_minutes(window: &KimiWindow) -> Option { @@ -433,7 +440,14 @@ fn apply_subscription_windows( // Upstream 0.49.0 #2741: the membership pool is the official "Total usage" // lane — the shared subscription pool (`amountUsedRatio`), not the // Code-only ratio. Feature-scoped or non-subscription balances are skipped. - if let Some(balance) = subscription.subscription_balance.as_ref() + // Upstream 0.60.5 #3694: a monthly pool reported by the Code API stays + // authoritative; web enrichment only fills in a missing one. + let has_monthly_pool = usage + .extra_rate_windows + .iter() + .any(|extra| extra.id == MONTHLY_WINDOW_ID); + if !has_monthly_pool + && let Some(balance) = subscription.subscription_balance.as_ref() && matches!(balance.feature.as_deref(), None | Some("FEATURE_OMNI")) && matches!(balance.balance_type.as_deref(), None | Some("SUBSCRIPTION")) && let Some(ratio) = @@ -476,9 +490,10 @@ fn apply_subscription_windows( /// Upstream `isEquivalentToWeeklyWindow` (#2741): suppress the Code 7-day row /// only on positive evidence — the weekly counter must be reliable (window /// minutes present), the percentages must agree within 1 point, and both lanes -/// need reset timestamps within 5 minutes of each other. +/// need reset timestamps within 5 minutes of each other. An absent weekly +/// quota (informational primary) is never equivalent. fn is_equivalent_to_weekly_window(window: &RateWindow, weekly: &RateWindow) -> bool { - if weekly.window_minutes.is_none() { + if weekly.is_informational || weekly.window_minutes.is_none() { return false; } if (window.used_percent - weekly.used_percent).abs() > 1.0 { diff --git a/rust/src/providers/kimi/monthly_blocking_tests.rs b/rust/src/providers/kimi/monthly_blocking_tests.rs index c82351a809..788ececaa1 100644 --- a/rust/src/providers/kimi/monthly_blocking_tests.rs +++ b/rust/src/providers/kimi/monthly_blocking_tests.rs @@ -1,12 +1,17 @@ //! Upstream 0.69.0 `KimiMonthlyBlockingTests` (#4091) through the Kimi web //! parser: a known, exhausted monthly membership pool blocks the shorter Code //! windows without rewriting their raw usage. The card-level checks (status -//! text, no reset or pace) live with the menu card and settings tests. +//! text, no reset or pace) live with the menu card and settings tests. The +//! Code API monthly pool (upstream 0.60.5 #3694) is the same `Total usage` +//! lane, so it blocks the same way without web auth. use chrono::{DateTime, Duration, SecondsFormat, TimeZone, Utc}; use serde_json::json; -use super::{KimiSubscriptionStatsResponse, KimiWebUsageResponse, MONTHLY_WINDOW_ID, web}; +use super::{ + KimiCodeApiUsageResponse, KimiSubscriptionStatsResponse, KimiWebUsageResponse, + MONTHLY_WINDOW_ID, code_api, web, +}; use crate::core::{BlockedWindows, ProviderId, UsageSnapshot}; fn now() -> DateTime { @@ -95,3 +100,38 @@ fn membership_block_ends_at_the_pool_reset() { ); assert!(!BlockedWindows::evaluate(ProviderId::Kimi, &usage, at_reset).any()); } + +fn code_api_snapshot(pools: serde_json::Value) -> UsageSnapshot { + let response: KimiCodeApiUsageResponse = + serde_json::from_value(json!({ "usages": pools })).unwrap(); + code_api::snapshot_from_code_api_response(response).unwrap() +} + +#[test] +fn exhausted_code_api_monthly_pool_blocks_code_windows() { + let usage = code_api_snapshot(json!({ + "limit_5h": { "used_ratio": 0, "reset_time": iso(Duration::hours(1)) }, + "limit_7d": { "used_ratio": 0, "reset_time": iso(Duration::days(4)) }, + "limit_month_total": { "used_ratio": 1.0, "reset_time": iso(Duration::days(30)) } + })); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + + assert!(blocked.primary && blocked.secondary); + assert!(!extra_flag(&blocked, &usage, MONTHLY_WINDOW_ID)); + assert_eq!(blocked.resets_at, Some(now() + Duration::days(30))); + assert_eq!(usage.primary.used_percent, 0.0); + assert_eq!(usage.secondary.as_ref().unwrap().used_percent, 0.0); +} + +#[test] +fn monthly_only_code_api_response_has_no_code_window_to_block() { + let usage = code_api_snapshot(json!({ + "limit_month_total": { "used_ratio": 1.05, "reset_time": iso(Duration::days(30)) } + })); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + + // The informational weekly placeholder is never blocked, and the pool + // does not block itself. + assert!(usage.primary.is_informational); + assert!(!blocked.any()); +} diff --git a/rust/src/providers/kimi/ratio_pool.rs b/rust/src/providers/kimi/ratio_pool.rs index 5d9a42a138..941f394765 100644 --- a/rust/src/providers/kimi/ratio_pool.rs +++ b/rust/src/providers/kimi/ratio_pool.rs @@ -149,15 +149,28 @@ fn matching_count_window( #[cfg(test)] mod tests { - use super::super::code_api::snapshot_from_code_api_response; - use super::super::{KimiCodeApiUsageResponse, UsageSnapshot}; + //! Mirrors upstream `KimiRatioPoolTests` (0.60.5 #3694 and 0.63.0 + //! `fd2414d`). Upstream reports an absent lane as `nil`; Win-CodexBar keeps + //! an informational weekly placeholder instead (`assert_weekly_absent`). + //! Count descriptions read "credits" where upstream says "requests" or + //! "Rate: N/M per 5 hours". + + use super::super::code_api::{MISSING_WEEKLY_DESCRIPTION, snapshot_from_code_api_response}; + use super::super::{ + KimiCodeApiUsageResponse, KimiSubscriptionStatsResponse, MONTHLY_WINDOW_ID, ProviderError, + UsageSnapshot, apply_subscription_windows, + }; use chrono::{DateTime, Utc}; use serde_json::{Value, json}; - fn parse(value: Value) -> UsageSnapshot { + fn try_parse(value: Value) -> Result { let response: KimiCodeApiUsageResponse = serde_json::from_value(value).expect("fixture parses"); - snapshot_from_code_api_response(response).expect("fixture has usable quota") + snapshot_from_code_api_response(response) + } + + fn parse(value: Value) -> UsageSnapshot { + try_parse(value).expect("fixture has a supported quota window") } fn at(text: &str) -> Option> { @@ -168,26 +181,34 @@ mod tests { ) } - /// Win-CodexBar keeps the session pool primary, so single-lane upstream - /// fixtures add a nonzero session pool that is never reconciled. - fn weekly_fixture(usage: Value, weekly_pool: Value) -> Value { - json!({ - "usage": usage, - "usages": { - "limit_5h": { "used_ratio": 0.5 }, - "limit_7d": weekly_pool - } - }) + fn close(actual: f64, expected: f64) -> bool { + (actual - expected).abs() < 0.000_01 } - fn weekly_percent(snapshot: &UsageSnapshot) -> f64 { + /// Upstream `usage.primary == nil`. + fn assert_weekly_absent(snapshot: &UsageSnapshot) { + let weekly = &snapshot.primary; + assert!(weekly.is_informational, "absent weekly stays informational"); + assert!(!weekly.usage_known); + assert_eq!(weekly.window_minutes, None); + assert_eq!( + weekly.reset_description.as_deref(), + Some(MISSING_WEEKLY_DESCRIPTION) + ); + } + + fn rate_limit_percent(snapshot: &UsageSnapshot) -> f64 { snapshot .secondary .as_ref() - .expect("weekly lane is present") + .expect("rate-limit lane is present") .used_percent } + fn weekly_fixture(usage: Value, weekly_pool: Value) -> Value { + json!({ "usage": usage, "usages": { "limit_7d": weekly_pool } }) + } + fn mixed_international_response() -> Value { json!({ "usage": { @@ -212,6 +233,226 @@ mod tests { }) } + fn subscription_stats(value: Value) -> KimiSubscriptionStatsResponse { + serde_json::from_value(value).expect("subscription fixture parses") + } + + // Upstream: `reported ratio pools retain missing weekly quota and monthly + // identity`. + #[test] + fn reported_ratio_pools_retain_missing_weekly_quota_and_monthly_identity() { + let snapshot = parse(json!({ + "limits": [{ + "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, + "detail": { "limit": "100", "used": "25", "remaining": "75" } + }], + "usages": { + "limit_5h": { "used_ratio": 0, "reset_time": "2026-09-16T20:15:44Z" }, + "limit_month_total": { + "used_ratio": 0.0056, + "reset_time": "2026-10-17T00:00:00Z" + }, + "limit_month_code": { "used_ratio": 0, "reset_time": "2026-10-17T00:00:00Z" } + } + })); + assert_weekly_absent(&snapshot); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.used_percent, 0.0); + assert_eq!(rate_limit.window_minutes, Some(300)); + assert_eq!(rate_limit.resets_at, at("2026-09-16T20:15:44Z")); + assert_eq!(rate_limit.reset_description, None); + assert!(snapshot.tertiary.is_none()); + let [monthly] = snapshot.extra_rate_windows.as_slice() else { + panic!("only the monthly lane is an extra"); + }; + assert_eq!(monthly.id, MONTHLY_WINDOW_ID); + assert_eq!(monthly.title, "Total usage"); + assert!(close(monthly.window.used_percent, 0.56)); + assert_eq!(monthly.window.window_minutes, Some(43_200)); + assert_eq!(monthly.window.resets_at, at("2026-10-17T00:00:00Z")); + // Upstream leaves the login method empty without a plan; the fetch + // replaces this parse-time label with the source label. + assert_eq!(snapshot.login_method.as_deref(), Some("Code API")); + } + + // Upstream: `ratio weekly and session retain established lane ordering`. + #[test] + fn ratio_weekly_and_session_retain_established_lane_ordering() { + let snapshot = parse(json!({ + "usages": { + "limit_7d": { "used_ratio": 0.125, "reset_time": "2026-09-20T00:00:00Z" }, + "limit_5h": { "used_ratio": 0.625 } + } + })); + assert_eq!(snapshot.primary.used_percent, 12.5); + assert_eq!(snapshot.primary.window_minutes, Some(10_080)); + assert_eq!(snapshot.primary.resets_at, at("2026-09-20T00:00:00Z")); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.used_percent, 62.5); + assert_eq!(rate_limit.window_minutes, Some(300)); + assert!(snapshot.extra_rate_windows.is_empty()); + } + + // Audit gap: a response with only the weekly pool shows that lane alone. + #[test] + fn weekly_only_pool_shows_only_the_weekly_lane() { + let snapshot = parse(json!({ "usages": { "limit_7d": { "used_ratio": 0.25 } } })); + assert!(!snapshot.primary.is_informational); + assert_eq!(snapshot.primary.used_percent, 25.0); + assert_eq!(snapshot.primary.window_minutes, Some(10_080)); + assert!(snapshot.secondary.is_none()); + assert!(snapshot.extra_rate_windows.is_empty()); + } + + // Upstream: `count rate window remains usable without legacy weekly + // usage` (upstream text "Rate: 25/100 per 5 hours"). + #[test] + fn count_rate_window_remains_usable_without_legacy_weekly_usage() { + let snapshot = parse(json!({ + "limits": [{ + "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, + "detail": { "limit": "100", "used": "25", "remaining": "75" } + }] + })); + assert_weekly_absent(&snapshot); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.used_percent, 25.0); + assert_eq!(rate_limit.window_minutes, Some(300)); + assert_eq!( + rate_limit.reset_description.as_deref(), + Some("25/100 credits") + ); + } + + // Audit gap: a legacy 5-hour count window next to a monthly-only pool + // keeps both lanes. + #[test] + fn count_rate_window_and_monthly_only_pool_keep_both_lanes() { + let snapshot = parse(json!({ + "limits": [{ + "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, + "detail": { "limit": "100", "used": "25" } + }], + "usages": { "limit_month_total": { "used_ratio": 0.5 } } + })); + assert_weekly_absent(&snapshot); + assert_eq!(rate_limit_percent(&snapshot), 25.0); + let [monthly] = snapshot.extra_rate_windows.as_slice() else { + panic!("monthly lane"); + }; + assert_eq!(monthly.id, MONTHLY_WINDOW_ID); + assert_eq!(monthly.window.used_percent, 50.0); + } + + // Upstream: `monthly only response does not invent code windows`. + #[test] + fn monthly_only_response_does_not_invent_code_windows() { + let snapshot = parse(json!({ "usages": { "limit_month_total": { "used_ratio": 1.05 } } })); + assert_weekly_absent(&snapshot); + assert!(snapshot.secondary.is_none()); + assert!(snapshot.tertiary.is_none()); + let [monthly] = snapshot.extra_rate_windows.as_slice() else { + panic!("monthly lane"); + }; + assert_eq!(monthly.id, MONTHLY_WINDOW_ID); + assert_eq!(monthly.window.used_percent, 100.0); + assert!(monthly.window.usage_known); + } + + // Upstream: `invalid ratio cannot suppress usable legacy counts` (upstream + // text "25/100 requests"). + #[test] + fn invalid_ratio_cannot_suppress_usable_legacy_counts() { + let snapshot = parse(json!({ + "usage": { "limit": "100", "used": "25" }, + "usages": { "limit_7d": { "used_ratio": -0.5 } } + })); + assert!(!snapshot.primary.is_informational); + assert_eq!(snapshot.primary.used_percent, 25.0); + assert_eq!(snapshot.primary.window_minutes, Some(10_080)); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("25/100 credits") + ); + } + + // Upstream: `web enrichment preserves authoritative API pools` for web + // status 200 and 503. A 200 merges the subscription stats; any other + // non-auth status makes `fetch_subscription_for_enrichment_result` return + // `Ok(None)`, so `fetch_via_code_api` keeps the snapshot as parsed. + #[test] + fn web_enrichment_preserves_authoritative_api_pools() { + let api = parse(json!({ + "usages": { + "limit_5h": { "used_ratio": 0.1 }, + "limit_month_total": { "used_ratio": 0.42 } + } + })); + let stats = + subscription_stats(json!({ "subscriptionBalance": { "amountUsedRatio": 0.99 } })); + for (status, usage) in [ + (200, apply_subscription_windows(api.clone(), &stats)), + (503, api), + ] { + assert_weekly_absent(&usage); + assert!( + close(rate_limit_percent(&usage), 10.0), + "web status {status}" + ); + let [monthly] = usage.extra_rate_windows.as_slice() else { + panic!("one monthly lane after web status {status}"); + }; + assert_eq!(monthly.id, MONTHLY_WINDOW_ID); + assert!( + close(monthly.window.used_percent, 42.0), + "web status {status}" + ); + } + } + + // Upstream `codeUsagePools?.monthly?.window(...) ?? subscriptionBalance`: + // without a usable API monthly pool, web enrichment still fills the lane. + #[test] + fn web_enrichment_fills_a_missing_or_invalid_api_monthly_pool() { + let stats = subscription_stats(json!({ + "subscriptionBalance": { + "amountUsedRatio": 0.99, + "expireTime": "2026-10-17T00:00:00Z" + } + })); + for pools in [ + json!({ "limit_5h": { "used_ratio": 0.1 } }), + json!({ "limit_5h": { "used_ratio": 0.1 }, "limit_month_total": { "used_ratio": -1 } }), + ] { + let usage = apply_subscription_windows(parse(json!({ "usages": pools })), &stats); + let [monthly] = usage.extra_rate_windows.as_slice() else { + panic!("subscription monthly lane"); + }; + assert_eq!(monthly.id, MONTHLY_WINDOW_ID); + assert!(close(monthly.window.used_percent, 99.0)); + assert_eq!(monthly.window.resets_at, at("2026-10-17T00:00:00Z")); + } + } + + // Upstream: `unrecognized or empty quotas do not succeed as unused`. + #[test] + fn unrecognized_or_empty_quotas_do_not_succeed_as_unused() { + for fixture in [ + json!({}), + json!({ "usages": {} }), + json!({ "usages": { "limit_5h": {} } }), + ] { + assert!( + matches!( + try_parse(fixture.clone()), + Err(ProviderError::Parse(message)) + if message == "No supported quota windows in Code usage response" + ), + "fixture {fixture}" + ); + } + } + // Upstream: `zero ratio placeholder does not hide matching nonzero counts`. #[test] fn zero_ratio_placeholder_does_not_hide_matching_nonzero_counts() { @@ -225,8 +466,7 @@ mod tests { }), json!({ "used_ratio": 0, "reset_time": reset }), )); - assert_eq!(weekly_percent(&snapshot), 19.0, "ratio reset {reset}"); - assert_eq!(snapshot.primary.used_percent, 50.0); + assert_eq!(snapshot.primary.used_percent, 19.0, "ratio reset {reset}"); } } @@ -242,24 +482,24 @@ mod tests { }), json!({ "used_ratio": 0, "reset_time": "2026-09-26T16:45:59Z" }), )); - assert_eq!(weekly_percent(&snapshot), 0.0); + assert_eq!(snapshot.primary.used_percent, 0.0); } // Upstream: `mixed international response retains the used weekly and - // session quotas` (lanes in Win-CodexBar order). + // session quotas`. #[test] fn mixed_international_response_retains_the_used_weekly_and_session_quotas() { let snapshot = parse(mixed_international_response()); - let weekly = snapshot.secondary.as_ref().expect("weekly lane"); - assert_eq!(weekly.used_percent, 19.0); - assert_eq!(weekly.window_minutes, Some(10_080)); - assert_eq!(weekly.resets_at, at("2026-09-19T16:45:59.449979Z")); - assert_eq!(snapshot.primary.used_percent, 1.0); - assert_eq!(snapshot.primary.window_minutes, Some(300)); + assert_eq!(snapshot.primary.used_percent, 19.0); + assert_eq!(snapshot.primary.window_minutes, Some(10_080)); assert_eq!( snapshot.primary.resets_at, - at("2026-09-19T14:45:59.449979Z") + at("2026-09-19T16:45:59.449979Z") ); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.used_percent, 1.0); + assert_eq!(rate_limit.window_minutes, Some(300)); + assert_eq!(rate_limit.resets_at, at("2026-09-19T14:45:59.449979Z")); assert!(snapshot.tertiary.is_none()); assert!(snapshot.extra_rate_windows.is_empty()); } @@ -272,7 +512,7 @@ mod tests { json!({ "limit": "100", "used": "19", "resetTime": "2026-09-19T16:45:59Z" }), json!({ "used_ratio": ratio, "reset_time": "2026-09-19T16:45:59Z" }), )); - assert!((weekly_percent(&snapshot) - ratio * 100.0).abs() < 0.000_01); + assert!(close(snapshot.primary.used_percent, ratio * 100.0)); } } @@ -283,14 +523,13 @@ mod tests { let snapshot = parse(json!({ "usage": { "limit": "100", "used": "19", "resetTime": "2026-09-19T16:45:59Z" }, "usages": { - "limit_5h": { "used_ratio": 0.5 }, "limit_7d": { "used_ratio": 0, "reset_time": "2026-09-19T16:45:59Z" }, "limit_month_total": { "used_ratio": 0.0313 } } })); - assert_eq!(weekly_percent(&snapshot), 0.0); - let monthly = snapshot.tertiary.expect("monthly pool"); - assert!((monthly.used_percent - 3.13).abs() < 0.000_01); + assert_eq!(snapshot.primary.used_percent, 0.0); + let monthly = snapshot.extra_rate_windows.first().expect("monthly pool"); + assert!(close(monthly.window.used_percent, 3.13)); } // Upstream: `unmatched count resets cannot override a zero ratio`. @@ -301,7 +540,7 @@ mod tests { json!({ "limit": "100", "used": "19", "resetTime": reset }), json!({ "used_ratio": 0, "reset_time": "2026-09-19T16:45:59Z" }), )); - assert_eq!(weekly_percent(&snapshot), 0.0, "count reset {reset}"); + assert_eq!(snapshot.primary.used_percent, 0.0, "count reset {reset}"); } } @@ -313,7 +552,7 @@ mod tests { json!({ "limit": "100", "used": used, "resetTime": "2026-09-19T16:45:59Z" }), json!({ "used_ratio": 0, "reset_time": "2026-09-19T16:45:59Z" }), )); - assert_eq!(weekly_percent(&snapshot), 0.0, "used {used}"); + assert_eq!(snapshot.primary.used_percent, 0.0, "used {used}"); } } @@ -324,9 +563,10 @@ mod tests { let mut response = mixed_international_response(); response["limits"][0]["window"]["duration"] = json!(120); let snapshot = parse(response); - assert_eq!(weekly_percent(&snapshot), 19.0); - assert_eq!(snapshot.primary.used_percent, 0.0); - assert_eq!(snapshot.primary.window_minutes, Some(300)); + assert_eq!(snapshot.primary.used_percent, 19.0); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.used_percent, 0.0); + assert_eq!(rate_limit.window_minutes, Some(300)); } // Upstream `usageCounts`: an invalid `used` falls back to a valid @@ -337,10 +577,11 @@ mod tests { response["usage"]["used"] = json!("invalid"); response["limits"][0]["detail"]["used"] = json!("-1"); let snapshot = parse(response); - assert_eq!(weekly_percent(&snapshot), 19.0); - assert_eq!(snapshot.primary.used_percent, 1.0); + assert_eq!(snapshot.primary.used_percent, 19.0); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.used_percent, 1.0); assert_eq!( - snapshot.primary.reset_description.as_deref(), + rate_limit.reset_description.as_deref(), Some("1/100 credits") ); } @@ -365,7 +606,10 @@ mod tests { } let snapshot = parse(response); assert_eq!(snapshot.primary.used_percent, 0.0); - assert_eq!(snapshot.primary.window_minutes, Some(300)); + assert_eq!(snapshot.primary.window_minutes, Some(10_080)); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.used_percent, 0.0); + assert_eq!(rate_limit.window_minutes, Some(300)); } } @@ -378,13 +622,13 @@ mod tests { json!({ "limit": "100", "used": used, "resetTime": "2026-09-19T16:45:59Z" }), json!({ "used_ratio": 0, "reset_time": "2026-09-19T16:45:59Z" }), )); - assert_eq!(weekly_percent(&snapshot), 0.0, "used {used}"); + assert_eq!(snapshot.primary.used_percent, 0.0, "used {used}"); } let snapshot = parse(weekly_fixture( json!({ "limit": 100.0, "used": 19, "resetTime": "2026-09-19T16:45:59Z" }), json!({ "used_ratio": 0, "reset_time": "2026-09-19T16:45:59Z" }), )); - assert_eq!(weekly_percent(&snapshot), 19.0); + assert_eq!(snapshot.primary.used_percent, 19.0); } fn zero_session_ratio_with_legacy_window(window: Option) -> UsageSnapshot { @@ -406,8 +650,9 @@ mod tests { #[test] fn missing_legacy_window_does_not_override_zero_session_ratio() { let snapshot = zero_session_ratio_with_legacy_window(None); - assert_eq!(snapshot.primary.window_minutes, Some(300)); - assert_eq!(snapshot.primary.used_percent, 0.0); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.window_minutes, Some(300)); + assert_eq!(rate_limit.used_percent, 0.0); } #[test] @@ -416,7 +661,8 @@ mod tests { "duration": 300, "timeUnit": "TIME_UNIT_FORTNIGHT" }))); - assert_eq!(snapshot.primary.window_minutes, Some(300)); - assert_eq!(snapshot.primary.used_percent, 0.0); + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.window_minutes, Some(300)); + assert_eq!(rate_limit.used_percent, 0.0); } }