diff --git a/CHANGELOG.md b/CHANGELOG.md index 276ae97c97..e83f63b1a1 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,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. - 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/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs index 07b289498f..a2d4043702 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -42,6 +42,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 96955cf740..d1370880ab 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -1,8 +1,11 @@ pub(crate) mod pace; +mod quota_block; mod status; +pub use quota_block::MonthlyLimitBlockSnapshot; pub(crate) use status::{compact_tray_status_label, friendly_provider_error}; use super::*; +use codexbar::core::BlockedWindows; // ── Bridge snapshot types ──────────────────────────────────────────── @@ -31,6 +34,10 @@ pub struct RateWindowSnapshot { pub reserve_will_last_to_reset: bool, #[serde(default)] pub reserve_eta_seconds: Option, + /// Set while a longer exhausted pool (Kimi's monthly membership) blocks + /// this window; presentation only, the raw percentages above stay as is. + #[serde(default)] + pub monthly_limit_block: Option, } /// Serde default for [`RateWindowSnapshot::remaining_percent`] — the common @@ -53,6 +60,7 @@ impl RateWindowSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, } } @@ -159,6 +167,9 @@ pub struct PaceSnapshot { pub expected_used_percent: f64, #[serde(default)] pub actual_used_percent: f64, + /// Block of the window this pace comes from (upstream hides its pace). + #[serde(default)] + pub monthly_limit_block: Option, } /// Session-equivalent weekly forecast for Claude/Codex menu secondary line. @@ -337,6 +348,7 @@ impl ProviderUsageSnapshot { }); let primary_pace = primary_pace.flatten(); + let blocked = BlockedWindows::evaluate(id, usage, chrono::Utc::now()); let pace = primary_pace.as_ref().map(|p| PaceSnapshot { stage: pace::stage_str(p.stage).to_string(), delta_percent: p.delta_percent, @@ -344,6 +356,7 @@ impl ProviderUsageSnapshot { eta_seconds: p.eta_seconds, expected_used_percent: p.expected_used_percent, actual_used_percent: p.actual_used_percent, + monthly_limit_block: MonthlyLimitBlockSnapshot::for_pace(usage, &blocked), }); // Compute pace for secondary window (weekly) to derive reserve info @@ -355,10 +368,12 @@ impl ProviderUsageSnapshot { }); let secondary_pace = secondary_pace.flatten(); - let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary); + let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary) + .with_quota_block(blocked.primary, &blocked); let secondary_snap = usage.secondary.as_ref().map(|sw| { - let mut s = RateWindowSnapshot::from_rate_window(sw); + let mut s = RateWindowSnapshot::from_rate_window(sw) + .with_quota_block(blocked.secondary, &blocked); if let Some(ref p) = secondary_pace { s = s.with_pace_reserve(p); } @@ -394,14 +409,13 @@ impl ProviderUsageSnapshot { .clone() .unwrap_or_else(|| metadata.weekly_label.to_string()) }), - model_specific: usage - .model_specific - .as_ref() - .map(RateWindowSnapshot::from_rate_window), - tertiary: usage - .tertiary - .as_ref() - .map(RateWindowSnapshot::from_rate_window), + model_specific: usage.model_specific.as_ref().map(|w| { + RateWindowSnapshot::from_rate_window(w) + .with_quota_block(blocked.model_specific, &blocked) + }), + tertiary: usage.tertiary.as_ref().map(|w| { + RateWindowSnapshot::from_rate_window(w).with_quota_block(blocked.tertiary, &blocked) + }), // 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. @@ -416,10 +430,12 @@ impl ProviderUsageSnapshot { extra_rate_windows: usage .extra_rate_windows .iter() - .map(|extra| NamedRateWindowSnapshot { + .zip(&blocked.extra) + .map(|(extra, &is_blocked)| NamedRateWindowSnapshot { id: extra.id.clone(), title: extra.title.clone(), - window: RateWindowSnapshot::from_rate_window(&extra.window), + window: RateWindowSnapshot::from_rate_window(&extra.window) + .with_quota_block(is_blocked, &blocked), fallback_lane: extra.fallback_lane, }) .collect(), @@ -518,6 +534,7 @@ impl ProviderUsageSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, }, primary_label: Some(metadata.session_label.to_string()), secondary: None, @@ -985,6 +1002,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs new file mode 100644 index 0000000000..91865dfc23 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs @@ -0,0 +1,155 @@ +//! Bridge shape of a longer exhausted quota that blocks a shorter window +//! (upstream 0.69.0 #4091, Kimi's monthly membership pool). +//! +//! Upstream evaluates `menuCard.blockingQuota` whenever it builds the menu +//! card, so a blocked row recovers as soon as the pool resets. Bridge +//! snapshots outlive their fetch (the provider cache keeps the last good +//! snapshot through failed refreshes), so each blocked window carries the +//! pool's reset and the frontend re-checks it at render time. + +use super::*; + +/// Set on a window while a longer exhausted pool blocks it. Presentation only: +/// the window's raw percentages stay the provider's data. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MonthlyLimitBlockSnapshot { + /// RFC 3339 reset of the exhausted pool; the block lifts then. `None` when + /// the pool's reset is unknown, so the block holds until a refresh. + #[serde(default)] + pub resets_at: Option, +} + +impl MonthlyLimitBlockSnapshot { + pub(super) fn for_window(blocked: bool, blocks: &BlockedWindows) -> Option { + blocked.then(|| Self { + resets_at: blocks.resets_at.map(|reset| reset.to_rfc3339()), + }) + } + + /// Block of the window the provider-level pace derives from: the weekly + /// lane when the primary is an informational stand-in, else the primary. + pub(super) fn for_pace( + usage: &codexbar::core::UsageSnapshot, + blocks: &BlockedWindows, + ) -> Option { + let blocked = if usage.primary.is_informational { + blocks.secondary + } else { + blocks.primary + }; + Self::for_window(blocked, blocks) + } +} + +impl RateWindowSnapshot { + pub(super) fn with_quota_block(mut self, blocked: bool, blocks: &BlockedWindows) -> Self { + self.monthly_limit_block = MonthlyLimitBlockSnapshot::for_window(blocked, blocks); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + use codexbar::core::UsageSnapshot; + + fn monthly_pool(reset: Option>) -> RateWindow { + RateWindow::with_details(100.0, Some(43_200), reset, None) + } + + fn bridge_snapshot(id: ProviderId, usage: UsageSnapshot) -> ProviderUsageSnapshot { + let result = ProviderFetchResult::new(usage, "web"); + let metadata = instantiate_provider(id).metadata().clone(); + ProviderUsageSnapshot::from_fetch_result(id, &metadata, &result, None) + } + + #[test] + fn exhausted_kimi_monthly_pool_marks_shorter_windows_without_changing_raw_usage() { + let reset = Utc::now() + Duration::days(20); + let primary = RateWindow::with_details( + 0.0, + Some(10_080), + Some(Utc::now() + Duration::days(3)), + None, + ); + let usage = UsageSnapshot::new(primary).with_extra_rate_window( + "kimi-monthly", + "Total usage", + monthly_pool(Some(reset)), + ); + + let snapshot = bridge_snapshot(ProviderId::Kimi, usage.clone()); + let block = MonthlyLimitBlockSnapshot { + resets_at: Some(reset.to_rfc3339()), + }; + assert_eq!(snapshot.primary.monthly_limit_block, Some(block.clone())); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert_eq!( + snapshot.extra_rate_windows[0].window.monthly_limit_block, + None + ); + // Upstream drops the blocked metric's pace forecast; the provider-level + // pace comes from the primary here, so it carries the same block. + let pace = snapshot.pace.as_ref().expect("primary pace"); + assert_eq!(pace.monthly_limit_block, Some(block)); + + let json = serde_json::to_value(&snapshot).unwrap(); + assert_eq!( + json["primary"]["monthlyLimitBlock"]["resetsAt"], + reset.to_rfc3339() + ); + assert!(json["extraRateWindows"][0]["window"]["monthlyLimitBlock"].is_null()); + assert!(json["pace"]["monthlyLimitBlock"].is_object()); + + // Other providers never derive a blocker from a same-named window. + let snapshot = bridge_snapshot(ProviderId::Codex, usage); + assert_eq!(snapshot.primary.monthly_limit_block, None); + assert_eq!(snapshot.pace.as_ref().unwrap().monthly_limit_block, None); + } + + #[test] + fn unknown_monthly_reset_blocks_without_a_lift_time() { + let usage = UsageSnapshot::new(RateWindow::with_details(0.0, Some(10_080), None, None)) + .with_extra_rate_window("kimi-monthly", "Total usage", monthly_pool(None)); + + let snapshot = bridge_snapshot(ProviderId::Kimi, usage); + assert_eq!( + snapshot.primary.monthly_limit_block, + Some(MonthlyLimitBlockSnapshot { resets_at: None }) + ); + let json = serde_json::to_value(&snapshot).unwrap(); + assert!(json["primary"]["monthlyLimitBlock"].is_object()); + assert!(json["primary"]["monthlyLimitBlock"]["resetsAt"].is_null()); + } + + #[test] + fn informational_primary_takes_the_pace_block_from_the_weekly_lane() { + let reset = Utc::now() + Duration::days(20); + let mut usage = UsageSnapshot::new(RateWindow::informational("No active session")) + .with_extra_rate_window("kimi-monthly", "Total usage", monthly_pool(Some(reset))); + usage.secondary = Some(RateWindow::with_details( + 10.0, + Some(10_080), + Some(Utc::now() + Duration::days(3)), + None, + )); + + let snapshot = bridge_snapshot(ProviderId::Kimi, usage); + assert_eq!(snapshot.primary.monthly_limit_block, None); + assert!(snapshot.secondary.unwrap().monthly_limit_block.is_some()); + assert!(snapshot.pace.unwrap().monthly_limit_block.is_some()); + } + + #[test] + fn snapshots_without_the_field_deserialize_unblocked() { + let window: RateWindowSnapshot = + serde_json::from_value(serde_json::json!({ "usedPercent": 40.0 })).unwrap(); + assert_eq!(window.monthly_limit_block, None); + let pace: PaceSnapshot = + serde_json::from_value(serde_json::json!({ "stage": "on_track", "deltaPercent": 0.0 })) + .unwrap(); + assert_eq!(pace.monthly_limit_block, None); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..8698cdd18a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1320,6 +1320,7 @@ mod reset_backfill_tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, } } diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 99012ae7c3..793b276be3 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -178,6 +178,7 @@ mod tests { reserve_percent: None, reserve_description: None, reserve_eta_seconds: None, + monthly_limit_block: None, reserve_will_last_to_reset: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 40b038547a..760b6518a3 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -674,6 +674,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, }, primary_label: None, secondary: secondary_percent.map(|pct| crate::commands::RateWindowSnapshot { @@ -688,6 +689,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, }), secondary_label: None, model_specific: None, @@ -703,6 +705,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, }), tertiary_label: None, extra_rate_windows: Vec::new(), diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 3fce3f03d7..4cdaae6c3f 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -25,6 +25,7 @@ fn fake_snapshot_with( reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, }; ProviderUsageSnapshot { @@ -375,6 +376,7 @@ fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, }, } } @@ -596,6 +598,7 @@ fn claude_automatic_prefers_weekly_when_model_exhausted() { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, }); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -639,6 +642,7 @@ fn automatic_picks_highest_among_model_and_extra_windows() { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, }); snapshot.extra_rate_windows.push(fake_extra_window(90.0)); diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index f6e8f5f532..5ffb1958cd 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -267,6 +267,7 @@ fn derived_window(used_percent: f64, resets_at: Option) -> RateWindowSna reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, } } diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index c8ec795546..d50e8bed33 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const tauriMocks = vi.hoisted(() => ({ @@ -125,6 +125,7 @@ describe("MenuCard", () => { PanelNow: "now", PanelOneHour: "1h", PanelFiveHours: "5h", + PanelBlockedByMonthlyLimit: "Blocked by monthly limit", PanelOnPaceBudget: "On-pace budget", PanelReserveSuffix: "in reserve", PanelThirtyDayCost: "30d cost", @@ -585,6 +586,148 @@ describe("MenuCard", () => { expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); }); + function kimiBlockedByMonthlyPool(blockResetsAt: string | null, poolResetsAt: string) { + const hour = 60 * 60 * 1000; + const shortReset = new Date(Date.now() + 3 * hour).toISOString(); + const block = { resetsAt: blockResetsAt }; + const snapshot = provider(null); + snapshot.providerId = "kimi"; + snapshot.displayName = "Kimi"; + snapshot.primaryLabel = "Code 7-day"; + // Raw provider percentages stay untouched (0% used): the block alone + // decides the presentation, as in upstream `blockingQuotaMetrics`. + snapshot.primary = { + ...rateWindow(0, { + windowMinutes: 7 * 24 * 60, + resetsAt: shortReset, + reservePercent: 30, + reserveWillLastToReset: true, + }), + monthlyLimitBlock: block, + }; + snapshot.secondaryLabel = "Code 5-hour"; + snapshot.secondary = { + ...rateWindow(0, { windowMinutes: 5 * 60, resetsAt: shortReset }), + monthlyLimitBlock: block, + }; + snapshot.sessionEquivalentForecast = { + estimatedWindowsToExhaustWeekly: 4, + windowsUntilReset: 6, + availableWindowsUntilReset: 6, + sampleCount: 3, + weeklyResetsAt: shortReset, + weeklyUsedPercent: 0, + }; + snapshot.extraRateWindows = [ + { + id: "kimi-monthly", + title: "Total usage", + window: rateWindow(100, { + windowMinutes: 30 * 24 * 60, + exhausted: true, + resetsAt: poolResetsAt, + }), + }, + ]; + snapshot.pace = { + stage: "far_behind", + deltaPercent: -40, + expectedUsedPercent: 40, + actualUsedPercent: 0, + etaSeconds: null, + willLastToReset: true, + monthlyLimitBlock: block, + }; + return snapshot; + } + + it("shows only the title and status for windows blocked by an exhausted monthly pool", async () => { + const poolReset = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + const { container } = renderCard(kimiBlockedByMonthlyPool(poolReset, poolReset), { + showAsUsed: true, + }); + + expect(await screen.findAllByText("Blocked by monthly limit")).toHaveLength(2); + const blockedRows = container.querySelectorAll(".menu-metric--blocked"); + expect(blockedRows).toHaveLength(2); + expect(blockedRows[0]).toHaveTextContent(/^Code 7-dayBlocked by monthly limit$/); + expect(blockedRows[1]).toHaveTextContent(/^Code 5-hourBlocked by monthly limit$/); + for (const row of blockedRows) { + expect(row.querySelector(".menu-metric__bar")).toBeNull(); + expect(row.querySelector(".menu-metric__pct")).toBeNull(); + expect(row.querySelector(".menu-metric__reset")).toBeNull(); + } + // The pool row keeps its own bar, percent, reset and exhausted label. + expect(screen.getByText("Total usage")).toBeInTheDocument(); + expect(screen.getAllByText("100% used")).toHaveLength(1); + expect(screen.queryByText("0% used")).not.toBeInTheDocument(); + expect(container.querySelectorAll(".menu-metric__reset")).toHaveLength(1); + expect(container.querySelectorAll(".menu-metric__exhausted")).toHaveLength(1); + // No pace, reserve, budget or session forecast for blocked windows. + expect(screen.queryByText(/in reserve/)).not.toBeInTheDocument(); + expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); + }); + + it("keeps a block without a known pool reset", async () => { + const poolReset = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + const { container } = renderCard(kimiBlockedByMonthlyPool(null, poolReset), { + showAsUsed: true, + }); + + expect(await screen.findAllByText("Blocked by monthly limit")).toHaveLength(2); + expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); + }); + + it("ignores a cached block whose monthly pool reset already passed", async () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + const { container } = renderCard(kimiBlockedByMonthlyPool(past, past), { + showAsUsed: true, + }); + + expect(await screen.findAllByText("0% used")).toHaveLength(2); + expect(screen.queryByText("Blocked by monthly limit")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric--blocked")).toBeNull(); + expect(container.querySelector(".menu-card__pace")).toBeInTheDocument(); + }); + + it("lifts the block when the monthly pool resets while the card stays open", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-01T00:00:00Z")); + const poolReset = new Date("2026-06-01T00:10:00Z").toISOString(); + const { container } = renderCard(kimiBlockedByMonthlyPool(poolReset, poolReset), { + showAsUsed: true, + }); + await act(async () => {}); + expect(screen.getAllByText("Blocked by monthly limit")).toHaveLength(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10 * 60 * 1000 - 1000); + }); + expect(screen.getAllByText("Blocked by monthly limit")).toHaveLength(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.queryByText("Blocked by monthly limit")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric--blocked")).toBeNull(); + expect(screen.getAllByText("0% used")).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps windows that are not blocked untouched", async () => { + const snapshot = provider(null, 0, {}); + snapshot.providerId = "kimi"; + renderCard(snapshot, { showAsUsed: true }); + + expect(await screen.findByText("0% used")).toBeInTheDocument(); + expect(screen.queryByText("Blocked by monthly limit")).not.toBeInTheDocument(); + }); + it("renders local token and cost totals after chart data loads", async () => { const { container } = renderCard(provider(null)); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 493ff08e98..3471fe669b 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -17,6 +17,7 @@ import { DEEPSEEK_PRICING_EVENT } from "../hooks/useDeepSeekPricingStatus"; import { getDeepSeekPricingStatus } from "../lib/tauri"; import type { DeepSeekPricingStatus } from "../types/bridge"; import { isUsageItemVisible } from "../lib/usageItemVisibility"; +import { useMonthlyLimitBlockNow } from "../hooks/useMonthlyLimitBlockNow"; /** Small copy-to-clipboard button matching macOS CopyIconButton (doc.on.doc → checkmark). */ function CopyIconButton({ text }: { text: string }) { @@ -241,6 +242,12 @@ export default function MenuCard({ const visibleMetrics = metrics .filter((metric) => isUsageItemVisible(provider.hiddenUsageItemIds, metric.id)) .slice(0, compactOverview ? 2 : metrics.length); + // Snapshots are cached across failed refreshes, so monthly-limit blocks are + // re-checked against the clock and lift when the pool's reset passes. + const monthlyLimitBlockNow = useMonthlyLimitBlockNow([ + ...visibleMetrics.map((metric) => metric.snap.monthlyLimitBlock), + provider.pace?.monthlyLimitBlock, + ]); const presence = describeCard( provider, @@ -249,6 +256,7 @@ export default function MenuCard({ costSummaryDisplayStyle, showPace, compactOverview, + monthlyLimitBlockNow, ); const { hasDetails } = presence; const cardClassName = [ @@ -322,6 +330,7 @@ export default function MenuCard({ showAsUsed, compactOverview, costSummaryDisplayStyle, + monthlyLimitBlockNow, }} metrics={visibleMetrics} chartData={chartData} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..dc55078e80 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -24,6 +24,7 @@ import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; import { InventoryItemRow } from "./InventoryRows"; import { QuotaWindowHistory } from "./QuotaWindowHistory"; import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; +import { isMonthlyLimitBlockActive } from "../lib/monthlyLimitBlock"; import PaceDetailsChart from "./PaceDetailsChart"; /** Format a reserve description from raw pace data at render time. */ @@ -294,6 +295,8 @@ type MetricRowDisplay = { showAsUsed?: boolean; compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; + /** Epoch ms that monthly-limit blocks are checked against (defaults to now). */ + monthlyLimitBlockNow?: number; }; /** @@ -328,7 +331,14 @@ function MetricRow({ showPace = true, showAsUsed = false, compactOverview = false, + monthlyLimitBlockNow, } = display; + // Upstream 0.69.0 #4091: a longer exhausted pool (Kimi's monthly membership) + // blocks this window until the pool resets. Raw percentages stay untouched. + const blocked = isMonthlyLimitBlockActive( + snap.monthlyLimitBlock, + monthlyLimitBlockNow ?? Date.now(), + ); const isInformational = snap.isInformational === true; const usedPct = Number.isFinite(snap.usedPercent) ? Math.max(0, snap.usedPercent) : 0; const barPct = Math.min(100, usedPct); @@ -338,8 +348,8 @@ function MetricRow({ const displayLabel = showAsUsed ? t("PanelUsedSuffix") : t("PanelLeftSuffix"); const level = levelOf(remain, snap.isExhausted); const resetText = useFormattedResetTime( - snap.resetsAt, - isInformational ? null : snap.resetDescription, + blocked ? null : snap.resetsAt, + isInformational || blocked ? null : snap.resetDescription, resetTimeRelative, resetFormatMode ?? "reset", ); @@ -354,6 +364,17 @@ function MetricRow({ const paceView = showPace ? getMetricPaceView(snap) : { kind: "none" as const }; const reserveDescription = formatReserveDescription(snap, t); const forecastText = formatSessionEquivalentEstimate(sessionEquivalentForecast); + if (blocked) { + // Upstream `MetricRow` status layout: title plus one secondary status line, + // no bar, percent, reset, pace, reserve, or forecast. The pool's own row + // keeps its reset; the shorter resets cannot restore access. + return ( +
+ {title} + {t("PanelBlockedByMonthlyLimit")} +
+ ); + } return (
{title} @@ -463,6 +484,7 @@ export function describeCard( costSummaryDisplayStyle: CostSummaryDisplayStyle = "detailed", showPace = true, compactOverview = false, + monthlyLimitBlockNow: number = Date.now(), ): MenuCardPresence { const hasCostHistory = chartData !== null && chartData.costHistory.some((point) => point.value != null); @@ -483,10 +505,13 @@ export function describeCard( const hasCost = !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); + // Upstream drops the pace forecast of a window blocked by a longer exhausted + // pool; the provider-level pace comes from such a window when it is blocked. const hasPace = showPace && providerAllowsPace(provider.providerId, provider.sourceLabel) && - !!provider.pace; + !!provider.pace && + !isMonthlyLimitBlockActive(provider.pace.monthlyLimitBlock, monthlyLimitBlockNow); const hasDetails = !provider.error && (hasMetrics || diff --git a/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.test.tsx b/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.test.tsx new file mode 100644 index 0000000000..27c7d48084 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.test.tsx @@ -0,0 +1,49 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MonthlyLimitBlock } from "../types/bridge"; +import { isMonthlyLimitBlockActive } from "../lib/monthlyLimitBlock"; +import { useMonthlyLimitBlockNow } from "./useMonthlyLimitBlockNow"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function Probe({ block }: { block: MonthlyLimitBlock | null }) { + const now = useMonthlyLimitBlockNow([block]); + return ( + {isMonthlyLimitBlockActive(block, now) ? "blocked" : "open"} + ); +} + +describe("useMonthlyLimitBlockNow", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-01T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("re-renders exactly when a weeks-away pool reset passes", async () => { + render(); + expect(screen.getByTestId("state")).toHaveTextContent("blocked"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20 * DAY_MS - 1); + }); + expect(screen.getByTestId("state")).toHaveTextContent("blocked"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.getByTestId("state")).toHaveTextContent("open"); + }); + + it("keeps a block without a known reset", async () => { + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(365 * DAY_MS); + }); + expect(screen.getByTestId("state")).toHaveTextContent("blocked"); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.ts b/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.ts new file mode 100644 index 0000000000..c186738aca --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import type { MonthlyLimitBlock } from "../types/bridge"; +import { monthlyLimitBlockResetMs } from "../lib/monthlyLimitBlock"; + +// Browsers clamp timers above 2^31-1 ms (about 24.8 days); re-arm before that. +const MAX_WAIT_MS = 6 * 60 * 60 * 1000; + +/** + * Current time for monthly-limit block checks. Schedules a re-render when the + * earliest future block reset passes, so a cached snapshot unblocks on time + * instead of waiting for the next refresh. + */ +export function useMonthlyLimitBlockNow( + blocks: readonly (MonthlyLimitBlock | null | undefined)[], +): number { + const [tick, setTick] = useState(0); + const now = Date.now(); + let nextReset: number | null = null; + for (const block of blocks) { + const reset = monthlyLimitBlockResetMs(block); + if (reset !== null && reset > now && (nextReset === null || reset < nextReset)) { + nextReset = reset; + } + } + + useEffect(() => { + if (nextReset === null) return; + const wait = Math.min(Math.max(nextReset - Date.now(), 0), MAX_WAIT_MS); + const id = window.setTimeout(() => setTick((value) => value + 1), wait); + return () => window.clearTimeout(id); + }, [nextReset, tick]); + + return now; +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 0ad26af95b..89439f47c8 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -655,6 +655,7 @@ export const ALL_LOCALE_KEYS = [ "PanelFiveHours", "PanelTodayBudget", "PanelReserveSuffix", + "PanelBlockedByMonthlyLimit", "PanelReserveLastsUntilReset", "PanelReserveRunsOutInDaysHours", "PanelReserveRunsOutInHours", diff --git a/apps/desktop-tauri/src/lib/monthlyLimitBlock.test.ts b/apps/desktop-tauri/src/lib/monthlyLimitBlock.test.ts new file mode 100644 index 0000000000..94600734a3 --- /dev/null +++ b/apps/desktop-tauri/src/lib/monthlyLimitBlock.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isMonthlyLimitBlockActive, monthlyLimitBlockResetMs } from "./monthlyLimitBlock"; + +const NOW = Date.parse("2026-06-01T00:00:00Z"); + +describe("monthlyLimitBlock", () => { + it("is inactive without a block", () => { + expect(isMonthlyLimitBlockActive(undefined, NOW)).toBe(false); + expect(isMonthlyLimitBlockActive(null, NOW)).toBe(false); + }); + + it("holds until the monthly pool reset passes", () => { + const block = { resetsAt: "2026-06-01T00:00:01Z" }; + expect(isMonthlyLimitBlockActive(block, NOW)).toBe(true); + expect(isMonthlyLimitBlockActive(block, NOW + 1000)).toBe(false); + expect(isMonthlyLimitBlockActive(block, NOW + 60_000)).toBe(false); + }); + + it("holds while the pool reset is unknown or unreadable", () => { + expect(isMonthlyLimitBlockActive({ resetsAt: null }, NOW)).toBe(true); + expect(isMonthlyLimitBlockActive({ resetsAt: "not a date" }, NOW)).toBe(true); + expect(monthlyLimitBlockResetMs({ resetsAt: null })).toBeNull(); + expect(monthlyLimitBlockResetMs({ resetsAt: "not a date" })).toBeNull(); + }); + + it("parses the RFC 3339 reset the backend sends", () => { + expect(monthlyLimitBlockResetMs({ resetsAt: "2026-06-30T12:00:00+00:00" })).toBe( + Date.parse("2026-06-30T12:00:00Z"), + ); + }); +}); diff --git a/apps/desktop-tauri/src/lib/monthlyLimitBlock.ts b/apps/desktop-tauri/src/lib/monthlyLimitBlock.ts new file mode 100644 index 0000000000..cc0dd21526 --- /dev/null +++ b/apps/desktop-tauri/src/lib/monthlyLimitBlock.ts @@ -0,0 +1,24 @@ +import type { MonthlyLimitBlock } from "../types/bridge"; + +/** Reset of a block in epoch ms; `null` when unknown or unparseable. */ +export function monthlyLimitBlockResetMs( + block: MonthlyLimitBlock | null | undefined, +): number | null { + if (!block?.resetsAt) return null; + const reset = Date.parse(block.resetsAt); + return Number.isNaN(reset) ? null : reset; +} + +/** + * Whether a longer exhausted pool still blocks a window at `now` (upstream + * 0.69.0 #4091). A block without a known reset holds, as upstream does for an + * exhausted pool with no reset; a known reset lifts it once reached. + */ +export function isMonthlyLimitBlockActive( + block: MonthlyLimitBlock | null | undefined, + now: number, +): boolean { + if (!block) return false; + const reset = monthlyLimitBlockResetMs(block); + return reset === null || reset > now; +} diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 36a304a58c..4aa5fbb657 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -2037,6 +2037,10 @@ body:has(.tray-panel-reveal) { font-weight: 600; } +.provider-usage-bar__status { + color: var(--text-secondary); +} + .provider-usage-bar__track { height: 5px; border-radius: 3px; @@ -3533,7 +3537,8 @@ html:has(.menu-surface--tray) { max-width: 100%; } .menu-surface--tray .menu-metric__pct, -.menu-surface--tray .menu-metric__reset { +.menu-surface--tray .menu-metric__reset, +.menu-surface--tray .menu-metric__status { font-size: 10px; } .menu-surface--tray .menu-metric__reserve { @@ -4083,6 +4088,17 @@ html:has(.menu-surface--tray) { font-weight: 600; color: var(--provider-status-error); } + +/* Upstream 0.69.0 #4091 status row: a window blocked by a longer exhausted + pool shows its title and one secondary footnote line instead of a bar. */ +.menu-metric__status { + font-size: 11px; + color: var(--text-secondary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .menu-metric__reserve { opacity: 0.7; font-size: 10px; @@ -4796,7 +4812,8 @@ html:has(.menu-surface--tray) { } .menu-metric__pct, -.menu-metric__reset { +.menu-metric__reset, +.menu-metric__status { font-size: var(--font-caption); } .menu-card__cost-line, diff --git a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css index fa2baae290..b6ad16857e 100644 --- a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css +++ b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css @@ -61,7 +61,8 @@ } .menu-surface--tray .menu-metric__pct, -.menu-surface--tray .menu-metric__reset { +.menu-surface--tray .menu-metric__reset, +.menu-surface--tray .menu-metric__status { font-size: 11px; line-height: 1.4; } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx new file mode 100644 index 0000000000..e14d476759 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { PaceSnapshot } from "../../../../types/bridge"; +import { PaceSection } from "./PaceSection"; + +const pace: PaceSnapshot = { + stage: "behind", + deltaPercent: -10, + willLastToReset: true, + etaSeconds: null, + expectedUsedPercent: 40, + actualUsedPercent: 30, +}; + +describe("PaceSection", () => { + it("renders the pace of an unblocked window", () => { + render( key} />); + + expect(screen.getByText("DetailPaceTitle")).toBeInTheDocument(); + expect(screen.getByText("DetailPaceBehind")).toBeInTheDocument(); + }); + + it("hides the pace of a window blocked by an exhausted monthly pool", () => { + const future = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + const { container } = render( + key} />, + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("shows the pace again once the monthly pool reset has passed", () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + render( + key} />, + ); + + expect(screen.getByText("DetailPaceTitle")).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx index ea1f30009f..6e1893cdce 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx @@ -1,6 +1,8 @@ import type { PaceSnapshot } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; import { formatEta } from "../../../../lib/formatEta"; +import { useMonthlyLimitBlockNow } from "../../../../hooks/useMonthlyLimitBlockNow"; +import { isMonthlyLimitBlockActive } from "../../../../lib/monthlyLimitBlock"; interface Props { pace: PaceSnapshot | null; @@ -19,7 +21,12 @@ const STAGE_TO_KEY: Record = { /** 展示配额节奏状态及其辅助说明。 */ export function PaceSection({ pace, t }: Props) { - if (!pace) return null; + const monthlyLimitBlockNow = useMonthlyLimitBlockNow([pace?.monthlyLimitBlock]); + // Upstream 0.69.0 #4091 drops the pace of a window blocked by a longer + // exhausted pool; its own resets cannot restore access. + if (!pace || isMonthlyLimitBlockActive(pace.monthlyLimitBlock, monthlyLimitBlockNow)) { + return null; + } const stageLabel = t(STAGE_TO_KEY[pace.stage]); const aux = pace.willLastToReset diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index cf7ca9b884..9d36da316d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -124,6 +124,70 @@ describe("UsageSection", () => { expect(label.parentElement?.querySelector(".provider-usage-bar__track")).toBeNull(); }); + function kimiDetailBlockedUntil(blockResetsAt: string) { + const detail = provider(); + detail.id = "kimi"; + detail.displayName = "Kimi"; + detail.session = { + ...rateWindow(0), + resetsAt: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(), + monthlyLimitBlock: { resetsAt: blockResetsAt }, + }; + detail.extraRateWindows = [ + { + id: "kimi-monthly", + title: "Total usage", + window: { ...rateWindow(100), isExhausted: true, resetsAt: blockResetsAt }, + }, + ]; + return detail; + } + + it("shows a window blocked by the monthly pool as title and status only", async () => { + const poolReset = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + + render( + + key} + /> + , + ); + + const label = await screen.findByText("ProviderSessionLabel"); + const row = label.closest(".provider-usage-bar"); + expect(row).toHaveTextContent(/^ProviderSessionLabelPanelBlockedByMonthlyLimit$/); + expect(row?.querySelector(".provider-usage-bar__track")).toBeNull(); + expect(row?.querySelector(".provider-usage-bar__reset")).toBeNull(); + // The pool's own row keeps its bar, exhausted state and reset. + const pool = screen.getByText("Total usage").closest(".provider-usage-bar"); + expect(pool?.querySelector(".provider-usage-bar__track")).not.toBeNull(); + expect(pool).toHaveTextContent("DetailWindowExhausted"); + expect(pool?.querySelector(".provider-usage-bar__reset")).not.toBeNull(); + }); + + it("shows the raw window again once the monthly pool reset has passed", async () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + + render( + + key} + /> + , + ); + + const label = await screen.findByText("ProviderSessionLabel"); + const row = label.closest(".provider-usage-bar"); + expect(row).toHaveTextContent("0%"); + expect(row).not.toHaveTextContent("PanelBlockedByMonthlyLimit"); + expect(row?.querySelector(".provider-usage-bar__track")).not.toBeNull(); + }); + it("renders discrete inventory without turning it into a quota bar", async () => { const detail = provider(); detail.session = null; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index b2d24dbdfc..86b67688d4 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -8,6 +8,8 @@ import { InventoryItemRow } from "../../../../components/InventoryRows"; import { ProviderDisplayRow } from "../../../../components/ProviderDisplayRow"; import type { LocaleKey } from "../../../../i18n/keys"; import { useFormattedResetTime } from "../../../../hooks/useFormattedResetTime"; +import { useMonthlyLimitBlockNow } from "../../../../hooks/useMonthlyLimitBlockNow"; +import { isMonthlyLimitBlockActive } from "../../../../lib/monthlyLimitBlock"; import { isUsageItemVisible } from "../../../../lib/usageItemVisibility"; interface Props { @@ -68,6 +70,10 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { }); } + // Cached snapshots outlive the pool reset; re-check blocks on the clock. + const monthlyLimitBlockNow = useMonthlyLimitBlockNow( + bars.map((bar) => bar.rate.monthlyLimitBlock), + ); const inventory = provider.inventory ?? []; const displayDetails = provider.displayDetails ?? []; if (bars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { @@ -82,6 +88,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { key={b.key} label={b.label} rate={b.rate} + blocked={isMonthlyLimitBlockActive(b.rate.monthlyLimitBlock, monthlyLimitBlockNow)} resetTimeRelative={resetTimeRelative} t={t} /> @@ -110,11 +117,13 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { function UsageBar({ label, rate, + blocked, resetTimeRelative, t, }: { label: string; rate: RateWindowSnapshot; + blocked: boolean; resetTimeRelative: boolean; t: (key: LocaleKey) => string; }) { @@ -122,10 +131,21 @@ function UsageBar({ const pct = Math.min(100, usedPct); const isInformational = rate.isInformational === true; const formattedReset = useFormattedResetTime( - rate.resetsAt, - rate.resetDescription, + blocked ? null : rate.resetsAt, + blocked ? null : rate.resetDescription, resetTimeRelative, ); + if (blocked) { + // Upstream 0.69.0 #4091 status row: title and status, no bar or reset. + return ( +
+
+ {label} + {t("PanelBlockedByMonthlyLimit")} +
+
+ ); + } const resetHint = formattedReset ? resetTimeRelative ? formattedReset diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index fd8dd55109..717d824f6f 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -555,6 +555,15 @@ export interface BootstrapState { // ── Provider usage snapshot types ──────────────────────────────────── +/** + * A longer exhausted pool (Kimi monthly, upstream 0.69.0 #4091) blocks the + * window until `resetsAt`; `null` means the reset is unknown and the block + * holds. Surfaces re-check it at render time because snapshots are cached. + */ +export interface MonthlyLimitBlock { + resetsAt: string | null; +} + export interface RateWindowSnapshot { usedPercent: number; remainingPercent: number; @@ -567,6 +576,8 @@ export interface RateWindowSnapshot { reserveDescription: string | null; reserveWillLastToReset?: boolean; reserveEtaSeconds?: number | null; + /** Set while a longer exhausted pool blocks this window; raw percentages stay the provider data. */ + monthlyLimitBlock?: MonthlyLimitBlock | null; } export interface CostDailyPoint { @@ -603,6 +614,8 @@ export interface PaceSnapshot { etaSeconds: number | null; expectedUsedPercent: number; actualUsedPercent: number; + /** Block of the window this pace comes from; no pace is shown while it is active. */ + monthlyLimitBlock?: MonthlyLimitBlock | null; } export interface SessionEquivalentForecastSnapshot { diff --git a/rust/src/core/blocking_quota.rs b/rust/src/core/blocking_quota.rs new file mode 100644 index 0000000000..3f20d47720 --- /dev/null +++ b/rust/src/core/blocking_quota.rs @@ -0,0 +1,191 @@ +//! Longer-window quota that blocks shorter windows (upstream 0.69.0 #4091). +//! +//! When a provider's known longer pool (Kimi's monthly membership) is +//! exhausted, its shorter windows still report fresh capacity from their own +//! counters even though nothing can be used. Surfaces mark those rows as +//! blocked while the raw percentages stay untouched, so notifications, the tray +//! icon, and explicit menu-bar selections keep reading the provider's data. +//! Mirrors upstream `RateWindow.bindingQuotaProjection` plus +//! `MenuCardView.blockingQuotaMetrics`; the blocker's own reset owns the reset +//! text, so no projected reset is computed here. Upstream evaluates the block +//! whenever it builds the card, so [`BlockedWindows::resets_at`] carries the +//! blocker's reset for surfaces that show a snapshot after it was fetched. + +use chrono::{DateTime, Utc}; + +use super::{ProviderId, RateWindow, SESSION_WINDOW_MINUTES, UsageSnapshot}; + +/// Which windows of one snapshot a longer exhausted quota blocks. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BlockedWindows { + pub primary: bool, + pub secondary: bool, + pub model_specific: bool, + pub tertiary: bool, + /// Parallel to [`UsageSnapshot::extra_rate_windows`]. + pub extra: Vec, + /// Reset of the exhausted blocker; the block lifts then. `None` when the + /// reset is unknown (the block holds) or nothing is blocked. + pub resets_at: Option>, +} + +impl BlockedWindows { + /// Evaluate `usage` against the provider's declared blocking quota. + pub fn evaluate(id: ProviderId, usage: &UsageSnapshot, now: DateTime) -> Self { + let mut blocked = Self { + extra: vec![false; usage.extra_rate_windows.len()], + ..Self::default() + }; + let Some(blocker_id) = id.blocking_quota_window_id() else { + return blocked; + }; + let Some(blocker) = usage + .extra_rate_windows + .iter() + .find(|extra| extra.id == blocker_id && extra.usage_known) + .map(|extra| &extra.window) + else { + return blocked; + }; + let blocks = |window: &RateWindow| is_blocked_by(window, blocker, now); + blocked.primary = blocks(&usage.primary); + blocked.secondary = usage.secondary.as_ref().is_some_and(blocks); + blocked.model_specific = usage.model_specific.as_ref().is_some_and(blocks); + blocked.tertiary = usage.tertiary.as_ref().is_some_and(blocks); + for (flag, extra) in blocked.extra.iter_mut().zip(&usage.extra_rate_windows) { + *flag = extra.usage_known && blocks(&extra.window); + } + if blocked.any() { + blocked.resets_at = blocker.resets_at; + } + blocked + } + + /// True when at least one window is blocked. + pub fn any(&self) -> bool { + self.primary + || self.secondary + || self.model_specific + || self.tertiary + || self.extra.iter().any(|flag| *flag) + } +} + +/// True when `blocker` is a longer, actively exhausted lane than `window`. +/// A window without duration metadata counts as the 5-hour session lane. +fn is_blocked_by(window: &RateWindow, blocker: &RateWindow, now: DateTime) -> bool { + if window.is_informational { + return false; + } + let window_minutes = window.window_minutes.unwrap_or(SESSION_WINDOW_MINUTES); + blocker + .window_minutes + .is_some_and(|minutes| minutes > window_minutes) + && blocker.remaining_percent() <= 0.0 + && blocker.resets_at.is_none_or(|reset| reset > now) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, TimeZone}; + + const MONTH: u32 = 30 * 24 * 60; + + fn now() -> DateTime { + Utc.timestamp_opt(1_788_000_000, 0).single().unwrap() + } + + fn window(used: f64, minutes: Option, reset: Option>) -> RateWindow { + RateWindow::with_details(used, minutes, reset, None) + } + + fn kimi_snapshot(monthly: RateWindow) -> UsageSnapshot { + let mut usage = UsageSnapshot::new(window(0.0, Some(10_080), None)) + .with_extra_rate_window("kimi-monthly", "Total usage", monthly) + .with_extra_rate_window( + "kimi-code-7d", + "Code 7-day", + window(25.0, Some(10_080), None), + ); + usage.secondary = Some(window(0.0, None, None)); + usage + } + + #[test] + fn exhausted_monthly_blocks_shorter_windows_but_not_itself() { + let usage = kimi_snapshot(window(100.0, Some(MONTH), Some(now() + Duration::days(30)))); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + assert!(blocked.primary && blocked.secondary); + assert_eq!(blocked.extra, vec![false, true]); + assert!(!blocked.tertiary && !blocked.model_specific); + // The block lifts at the pool's reset, not at the shorter windows' resets. + assert_eq!(blocked.resets_at, Some(now() + Duration::days(30))); + + // Tertiary and model-specific lanes follow the same rule as the primary. + let mut usage = usage; + usage.tertiary = Some(window(10.0, Some(10_080), None)); + usage.model_specific = Some(window(10.0, Some(60), None)); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + assert!(blocked.tertiary && blocked.model_specific); + // Evaluation never rewrites the raw percentages. + assert_eq!(usage.primary.used_percent, 0.0); + } + + #[test] + fn unknown_reset_still_blocks() { + let mut usage = kimi_snapshot(window(100.0, Some(MONTH), None)); + usage.primary.resets_at = Some(now() + Duration::hours(1)); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + // Upstream: an unknown monthly reset does not promise the shorter reset. + assert!(blocked.primary && blocked.any()); + assert_eq!(blocked.resets_at, None); + } + + #[test] + fn available_expired_or_unknown_usage_never_blocks() { + let unblocked = BlockedWindows { + extra: vec![false, false], + ..Default::default() + }; + for monthly in [ + window(99.9, Some(MONTH), Some(now() + Duration::days(30))), + window(100.0, Some(MONTH), Some(now())), + window(100.0, Some(MONTH), Some(now() - Duration::days(1))), + ] { + let usage = kimi_snapshot(monthly); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + // Nothing blocked: no reset is carried either. + assert_eq!(blocked, unblocked); + assert!(!blocked.any()); + } + + let mut usage = kimi_snapshot(window(100.0, Some(MONTH), None)); + usage.extra_rate_windows[0].usage_known = false; + assert_eq!( + BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()), + unblocked + ); + } + + #[test] + fn missing_minutes_default_to_the_session_lane_and_informational_rows_are_skipped() { + let mut usage = kimi_snapshot(window(100.0, Some(MONTH), None)); + usage.secondary = Some(RateWindow::informational("No active session")); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + assert!(blocked.primary && !blocked.secondary); + + // A blocker no longer than the window (or without minutes) cannot block it. + let usage = kimi_snapshot(window(100.0, Some(10_080), None)); + assert!(!BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()).primary); + let usage = kimi_snapshot(window(100.0, None, None)); + assert!(!BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()).primary); + } + + #[test] + fn providers_without_a_declared_blocker_are_untouched() { + let usage = kimi_snapshot(window(100.0, Some(MONTH), None)); + let blocked = BlockedWindows::evaluate(ProviderId::Codex, &usage, now()); + assert!(!blocked.primary && blocked.extra.iter().all(|flag| !flag)); + } +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 436c7d5e0a..a163963b56 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -2,6 +2,7 @@ mod adaptive_refresh; mod aws_signing; +mod blocking_quota; mod claude_routed_pricing; mod codex_routed_pricing; mod cost_cache_budget; @@ -32,6 +33,7 @@ mod widget_snapshot; pub use adaptive_refresh::*; pub use aws_signing::*; +pub use blocking_quota::*; pub use cost_cache_budget::*; pub use cost_pricing::*; pub use curl_capture::*; diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 218901b770..94c4b920ad 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -442,6 +442,15 @@ impl ProviderId { } } + /// Id of the longer pool that blocks this provider's shorter windows once + /// exhausted (upstream 0.69.0 #4091). See [`super::BlockedWindows`]. + pub fn blocking_quota_window_id(&self) -> Option<&'static str> { + match self { + ProviderId::Kimi => Some(crate::providers::kimi::MONTHLY_WINDOW_ID), + _ => None, + } + } + /// Parse from CLI name string pub fn from_cli_name(name: &str) -> Option { match name.to_lowercase().as_str() { diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 6b43e88960..7ceedb1b3c 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -929,6 +929,7 @@ locale_keys! { PanelFiveHours, PanelTodayBudget, PanelReserveSuffix, + PanelBlockedByMonthlyLimit, PanelReserveLastsUntilReset, PanelReserveRunsOutInDaysHours, PanelReserveRunsOutInHours, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 1a99574daf..d134c248cf 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -592,6 +592,7 @@ PanelOneHour = 1h PanelFiveHours = 5h PanelTodayBudget = today PanelReserveSuffix = in reserve +PanelBlockedByMonthlyLimit = Blocked by monthly limit PanelReserveLastsUntilReset = Lasts until reset PanelReserveRunsOutInDaysHours = Runs out in { "{}" }d { "{}" }h PanelReserveRunsOutInHours = Runs out in { "{}" }h diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 5ba578c95f..ad694bf53b 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -547,6 +547,7 @@ PanelOneHour = 1h PanelFiveHours = 5h PanelTodayBudget = hoy PanelReserveSuffix = en reserva +PanelBlockedByMonthlyLimit = Bloqueado por el límite mensual PanelReserveLastsUntilReset = Dura hasta el reinicio PanelReserveRunsOutInDaysHours = Se agota en { "{}" }d { "{}" }h PanelReserveRunsOutInHours = Se agota en { "{}" }h diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index c91cef6370..e0f1f53eb5 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -529,6 +529,7 @@ PanelOneHour = 1時間 PanelFiveHours = 5時間 PanelTodayBudget = 今日 PanelReserveSuffix = 予備 +PanelBlockedByMonthlyLimit = 月間上限によりブロック中 PanelReserveLastsUntilReset = リセットまで持ちます PanelReserveRunsOutInDaysHours = 残り { "{}" }日 { "{}" }時間 PanelReserveRunsOutInHours = 残り { "{}" }時間 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index cf67c452cf..3b2cf04941 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -534,6 +534,7 @@ PanelOneHour = 1시간 PanelFiveHours = 5시간 PanelTodayBudget = 오늘 PanelReserveSuffix = 예비 +PanelBlockedByMonthlyLimit = 월간 한도로 차단됨 PanelReserveLastsUntilReset = 리셋까지 지속 PanelReserveRunsOutInDaysHours = { "{}" }일 { "{}" }시간 후 소진 PanelReserveRunsOutInHours = { "{}" }시간 후 소진 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index e0129c89de..0c7b92497e 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -513,6 +513,7 @@ PanelOneHour = 1 час PanelFiveHours = 5 часов PanelTodayBudget = сегодня PanelReserveSuffix = в резерве +PanelBlockedByMonthlyLimit = Заблокировано месячным лимитом PanelReserveLastsUntilReset = Длится до сброса PanelReserveRunsOutInDaysHours = Заканчивается через { "{}" }d { "{}" }h PanelReserveRunsOutInHours = Заканчивается через { "{}" }h diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 2abf387409..ef07baaf88 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -548,6 +548,7 @@ PanelOneHour = 1 sa PanelFiveHours = 5 sa PanelTodayBudget = bugün PanelReserveSuffix = yedekte +PanelBlockedByMonthlyLimit = Aylık limit nedeniyle engellendi PanelReserveLastsUntilReset = Sıfırlamaya kadar sürer PanelReserveRunsOutInDaysHours = { "{}" } g { "{}" } sa içinde tükenir PanelReserveRunsOutInHours = { "{}" } sa içinde tükenir diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 60e9e7a74d..2495ed043e 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -528,6 +528,7 @@ PanelOneHour = 1小时 PanelFiveHours = 5小时 PanelTodayBudget = 今日 PanelReserveSuffix = 储备 +PanelBlockedByMonthlyLimit = 已被月度限额阻止 PanelReserveLastsUntilReset = 持续到重置 PanelReserveRunsOutInDaysHours = { "{}" }天{ "{}" }小时后耗尽 PanelReserveRunsOutInHours = { "{}" }小时后耗尽 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 5977e37435..642d70e2fd 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -528,6 +528,7 @@ PanelOneHour = 1小時 PanelFiveHours = 5小時 PanelTodayBudget = 今日 PanelReserveSuffix = 儲備 +PanelBlockedByMonthlyLimit = 已被月度限額阻擋 PanelReserveLastsUntilReset = 持續到重置 PanelReserveRunsOutInDaysHours = { "{}" }天{ "{}" }小時後耗盡 PanelReserveRunsOutInHours = { "{}" }小時後耗盡 diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index 87332eae06..ee7a3167e0 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -19,6 +19,8 @@ mod auto; mod code_api; pub mod desktop_token; +#[cfg(test)] +mod monthly_blocking_tests; mod ratio_pool; mod region; mod web; @@ -36,6 +38,9 @@ use crate::core::{ RateWindow, SourceMode, UsageSnapshot, }; +/// Extra-window id of the monthly membership pool (`Total usage`). +pub const MONTHLY_WINDOW_ID: &str = "kimi-monthly"; + const KIMI_WEB_USAGE_SERVICE: &str = "kimi.gateway.billing.v1.BillingService/GetUsages"; const KIMI_SUBSCRIPTION_STATS_SERVICE: &str = "kimi.gateway.membership.v2.MembershipService/GetSubscriptionStats"; @@ -436,7 +441,7 @@ fn apply_subscription_windows( { // Verified monthly sentinel (#2431 / #2566). usage = usage.with_extra_rate_window( - "kimi-monthly", + MONTHLY_WINDOW_ID, "Total usage", RateWindow::with_details( ratio * 100.0, diff --git a/rust/src/providers/kimi/monthly_blocking_tests.rs b/rust/src/providers/kimi/monthly_blocking_tests.rs new file mode 100644 index 0000000000..c82351a809 --- /dev/null +++ b/rust/src/providers/kimi/monthly_blocking_tests.rs @@ -0,0 +1,97 @@ +//! 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. + +use chrono::{DateTime, Duration, SecondsFormat, TimeZone, Utc}; +use serde_json::json; + +use super::{KimiSubscriptionStatsResponse, KimiWebUsageResponse, MONTHLY_WINDOW_ID, web}; +use crate::core::{BlockedWindows, ProviderId, UsageSnapshot}; + +fn now() -> DateTime { + Utc.timestamp_opt(1_788_000_000, 0).single().unwrap() +} + +fn iso(offset: Duration) -> String { + (now() + offset).to_rfc3339_opts(SecondsFormat::Secs, true) +} + +/// Upstream `snapshot(ratio:)`: #3536 reports `amountUsedRatio = 1` while both +/// Code counters are zero. +fn snapshot(ratio: f64) -> UsageSnapshot { + let usage: KimiWebUsageResponse = serde_json::from_value(json!({ + "usages": [{ + "scope": "FEATURE_CODING", + "detail": { + "limit": "100", + "used": "0", + "remaining": "100", + "resetTime": iso(Duration::days(4) + Duration::hours(9)) + }, + "limits": [{ + "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, + "detail": { + "limit": "100", + "used": "0", + "remaining": "100", + "resetTime": iso(Duration::hours(1)) + } + }] + }] + })) + .unwrap(); + let stats: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ + "subscriptionBalance": { + "amountUsedRatio": ratio, + "expireTime": iso(Duration::days(30)), + "overdrawn": true + }, + "ratelimitCode7d": { "ratio": 0.25, "enabled": true } + })) + .unwrap(); + web::snapshot_from_web_usage_response(usage, Some(stats)).unwrap() +} + +fn extra_flag(blocked: &BlockedWindows, usage: &UsageSnapshot, id: &str) -> bool { + let index = usage + .extra_rate_windows + .iter() + .position(|window| window.id == id) + .unwrap_or_else(|| panic!("missing extra window {id}")); + blocked.extra[index] +} + +#[test] +fn exhausted_membership_blocks_fresh_code_windows_without_changing_raw_usage() { + let usage = snapshot(1.0); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + + assert!(blocked.primary && blocked.secondary); + assert!(extra_flag(&blocked, &usage, "kimi-code-7d")); + assert!(!extra_flag(&blocked, &usage, MONTHLY_WINDOW_ID)); + // The pool's reset lifts the block; the shorter resets cannot restore access. + 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 available_membership_preserves_code_windows() { + for ratio in [0.5, 0.999] { + let usage = snapshot(ratio); + let blocked = BlockedWindows::evaluate(ProviderId::Kimi, &usage, now()); + assert!(!blocked.any(), "ratio {ratio} must not block"); + assert_eq!(blocked.resets_at, None); + } +} + +#[test] +fn membership_block_ends_at_the_pool_reset() { + let usage = snapshot(1.0); + let at_reset = now() + Duration::days(30); + assert!( + BlockedWindows::evaluate(ProviderId::Kimi, &usage, at_reset - Duration::seconds(1)).primary + ); + assert!(!BlockedWindows::evaluate(ProviderId::Kimi, &usage, at_reset).any()); +}