diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d15087dc9..98048a64070 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ## [Windows] Unreleased - Charts: the Muse token chart, the OpenAI API daily history and the Groq console history keep every day of the 30-day window, so idle days show as zero bars instead of disappearing. Unpriced or incomplete days stay unknown (upstream #4689). +- Pace: a monthly allowance shows a per-day target, the remaining allowance divided by the local days left including today, in its own unit (credits, requests or %). It appears on the pace line when pace is on, and shows nothing when usage or the reset is unknown or the reset has passed. Notion's credit allowances and FutureAGI's credit and request allowances report their limits (upstream #4692). - Overview: disabling a provider, in Settings or from the tray menu, removes its card and account rows at once instead of after a relaunch. claude-swap and tcr Overview rows now fill from a background read during refresh (at most every 5 minutes), so you no longer need to open Settings first. - **Claude**: built-in price rows choose one long-context tier per request from input plus all cache tokens and bill the whole request at that tier, as upstream does. Opus 4.6 and Sonnet 4.6 use long-context rates only for usage dated before the upstream cutoff. - Tray and Settings: percentages always say "left" or "used" and follow **Show usage as used** (the tray tooltip, tray status rows, the Claude accounts menu and the claude-swap/tcr list). "1 minute ago" is singular in every language. Under **Hide personal information**, Claude and Grok accounts are numbered in list order, and the claude-swap list masks emails as soon as the setting changes. diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index 99ca76f18d5..6fb06fb46ac 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -497,6 +497,15 @@ { "screenshot": "flyout-kimi-bonus-credits", "clip": "panel" } ] }, + { + "name": "flyout-copilot-daily-target", + "query": "window=flyout&fixture=copilot-daily-target&theme=dark", + "size": [310, 1032], + "steps": [ + { "expectText": ["Copilot", "Premium", "≈ 2.9% / day to last until reset"] }, + { "screenshot": "flyout-copilot-daily-target", "clip": "panel" } + ] + }, { "name": "settings-gemini-usage-source", "query": "window=settings&tab=providers:gemini&fixture=default&theme=dark", diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index dac2150551e..bf14a7db65a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -59,6 +59,12 @@ pub struct RateWindowSnapshot { pub reserve_eta_seconds: Option, #[serde(default)] pub pace: Option, + /// Per-day share of a monthly allowance (upstream `monthlyTargetMetrics`). + /// Set under the same pace gate as `pace`, but also when the lane has no + /// pace (an exhausted allowance). Absent outside monthly windows and when + /// usage or the reset is unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daily_target: 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)] @@ -112,6 +118,7 @@ impl RateWindowSnapshot { reserve_will_last_to_reset: false, reserve_eta_seconds: None, pace: None, + daily_target: None, monthly_limit_block: None, description_is_detail: rw.description_is_detail, reset_regenerates: rw.reset_regenerates, @@ -606,6 +613,9 @@ impl ProviderUsageSnapshot { ) }) .flatten(), + daily_target: allows_pace + .then(|| pace::daily_target(Some(id), window, now)) + .flatten(), ..RateWindowSnapshot::from_rate_window(window).with_quota_block(is_blocked, &blocked) }; let primary_snap = lane( diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index 02db1d6af79..7eed17e1500 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -38,6 +38,24 @@ pub struct WindowPaceSnapshot { pub reset_window: bool, } +/// Remaining allowance divided by the local calendar days left, in the +/// allowance's own unit (`%`, `credits` or `requests`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyTargetSnapshot { + pub amount: f64, + pub unit: String, +} + +impl From for DailyTargetSnapshot { + fn from(target: codexbar::core::MonthlyDailyTarget) -> Self { + Self { + amount: target.amount, + unit: target.unit, + } + } +} + /// Where a window is rendered. Reset-window pace applies to the card's /// primary, secondary and tertiary bars only, as on the Mac; extra windows /// keep plain lane pace. @@ -117,6 +135,24 @@ impl WindowPaceSnapshot { } } +/// Daily target of a monthly window in local calendar days (the Mac uses +/// `Calendar.current`). Upstream `monthlyTargetMetrics` reads the same +/// window, so the target never estimates today's consumption. It does not +/// depend on a lane pace: an exhausted allowance has no pace but still has +/// a target. +pub(crate) fn daily_target( + id: Option, + window: &RateWindow, + now: DateTime, +) -> Option { + let capability = id.map_or(codexbar::core::ProviderPaceCapability::UNSUPPORTED, |id| { + id.pace_capability() + }); + capability + .monthly_daily_target(window, now, &chrono::Local) + .map(DailyTargetSnapshot::from) +} + #[cfg(test)] mod tests { use super::*; @@ -547,6 +583,74 @@ mod tests { assert!(!cached.pace_authoritative); } + /// A calendar-month credit allowance carries its daily target on the card + /// lane: 200 remaining credits over the 14 local days to the Mar 1 reset. + /// Local-day counting varies by zone, so the bound allows one day either way. + #[test] + fn monthly_credit_allowance_carries_a_daily_target_on_the_card_lane() { + let window = monthly(20.0, Some(43_200)).with_quota(250.0, "credits"); + + let snap = snapshot_at(ProviderId::Notion, UsageSnapshot::new(window)); + + let target = snap.primary.daily_target.expect("daily target"); + assert_eq!(target.unit, "credits"); + let days = 200.0 / target.amount; + assert!((13.0..=15.0).contains(&days), "{days}"); + } + + /// An exhausted allowance has no lane pace, but the card still shows its + /// target, as upstream computes the target apart from pace. + #[test] + fn exhausted_monthly_allowance_keeps_a_zero_daily_target_without_pace() { + let window = monthly(100.0, Some(43_200)).with_quota(250.0, "credits"); + + let snap = snapshot_at(ProviderId::Notion, UsageSnapshot::new(window)); + + assert_eq!(snap.primary.pace, None); + assert_eq!( + snap.primary.daily_target, + Some(DailyTargetSnapshot { + amount: 0.0, + unit: "credits".to_string(), + }) + ); + } + + #[test] + fn weekly_window_has_no_daily_target() { + // Reset five days out, inside the seven-day window. + let reset = Utc.with_ymd_and_hms(2026, 2, 20, 0, 0, 0).unwrap(); + let window = RateWindow::with_details(20.0, Some(10_080), Some(reset), None) + .with_quota(250.0, "credits"); + + let snap = snapshot_at(ProviderId::Notion, UsageSnapshot::new(window)); + + assert!(snap.primary.pace.is_some()); + assert_eq!(snap.primary.daily_target, None); + } + + /// A provider that opts out of pace gets no daily target either, so the + /// target follows the same pace visibility as the rest of the lane. + #[test] + fn non_authoritative_monthly_allowance_has_no_daily_target() { + let metadata = instantiate_provider(ProviderId::Notion).metadata().clone(); + let window = monthly(20.0, Some(43_200)).with_quota(250.0, "credits"); + let result = ProviderFetchResult::new(UsageSnapshot::new(window), "web") + .with_non_authoritative_pace(); + + let snap = ProviderUsageSnapshot::from_fetch_result_at( + ProviderId::Notion, + &metadata, + &result, + None, + None, + february_now(), + ); + + assert_eq!(snap.primary.pace, None); + assert_eq!(snap.primary.daily_target, None); + } + #[test] fn exhausted_reset_window_has_no_pace() { let usage = UsageSnapshot::new(monthly(100.0, None)); diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 03d508ac218..8b25b09da09 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -627,7 +627,7 @@ fn fill_window_pace( now: chrono::DateTime, work_days: Option, ) { - if w.pace.is_some() || w.is_informational { + if (w.pace.is_some() && w.daily_target.is_some()) || w.is_informational { return; } let Some(resets_at) = w @@ -637,12 +637,19 @@ fn fill_window_pace( else { return; }; - let window = codexbar::core::RateWindow::with_details( + let mut window = codexbar::core::RateWindow::with_details( w.used_percent, w.window_minutes, Some(resets_at.with_timezone(&chrono::Utc)), w.reset_description.clone(), ); + window.reset_regenerates = w.reset_regenerates; + if w.daily_target.is_none() { + w.daily_target = crate::commands::pace::daily_target(id, &window, now); + } + if w.pace.is_some() { + return; + } w.pace = match id { Some(id) => crate::commands::pace::WindowPaceSnapshot::for_lane( id, @@ -979,6 +986,25 @@ mod tests { assert!(snapshot.extra_rate_windows[0].window.pace.is_none()); } + #[test] + fn seed_daily_target_skips_regenerating_windows() { + use chrono::TimeZone; + let now = chrono::Utc.with_ymd_and_hms(2026, 2, 15, 0, 0, 0).unwrap(); + let seed = |regenerates: bool| { + let json = format!( + r#"{{"providerId":"copilot","displayName":"Copilot", + "primary":{{"usedPercent":60.0,"resetsAt":"2026-03-01T00:00:00Z", + "resetRegenerates":{regenerates}}}}}"# + ); + let mut snapshot: ProviderUsageSnapshot = serde_json::from_str(&json).unwrap(); + normalize_seed_snapshot(&mut snapshot); + fill_seed_pace(&mut snapshot, now, None); + snapshot.primary.daily_target + }; + assert!(seed(false).is_some()); + assert_eq!(seed(true), None); + } + #[test] fn seed_pace_keeps_explicit_pace() { let json = r#"{"providerId":"codex","displayName":"Codex", diff --git a/apps/desktop-tauri/src/i18n/keyGroups/core.ts b/apps/desktop-tauri/src/i18n/keyGroups/core.ts index 842187c7e5e..545f23a605e 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/core.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/core.ts @@ -249,6 +249,9 @@ export const CORE_LOCALE_KEYS = [ "PaceInReserve", "PaceInDeficit", "PaceLastsUntilReset", + "PaceDailyTargetPercent", + "PaceDailyTargetCredits", + "PaceDailyTargetRequests", "PaceRunsOutIn", "PaceRunsOutNow", "PaceProjectedEmptyIn", diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts index 86b2b24131e..2efb0e291a0 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.test.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -510,3 +510,58 @@ describe("metricRowPresentation bar", () => { ).toBe("63%"); }); }); + +describe("monthly daily target", () => { + // A calendar-month allowance on the secondary lane with a synthetic target. + function monthly(dailyTarget: RateWindowSnapshot["dailyTarget"]): RateWindowSnapshot { + return rateWindow({ + usedPercent: 79, + remainingPercent: 21, + windowMinutes: 43_200, + pace: pace(), + dailyTarget, + }); + } + + it("shows the per-day share when the window has no pace", () => { + const snap = { ...monthly({ amount: 0, unit: "%" }), pace: null }; + expect(metricRowPresentation(input({ lane: "secondary", snap }), en).metaText).toContain( + "≈ 0% / day to last until reset", + ); + }); + + it("shows a credit allowance's per-day share in the allowance's unit", () => { + const row = metricRowPresentation( + input({ lane: "secondary", snap: monthly({ amount: 21.0909, unit: "credits" }) }), + en, + ); + expect(row.metaText).toContain("≈ 21.09 credits / day to last until reset"); + }); + + it("formats percent, request and unknown units", () => { + const text = (dailyTarget: RateWindowSnapshot["dailyTarget"]) => + metricRowPresentation(input({ lane: "secondary", snap: monthly(dailyTarget) }), en).metaText ?? ""; + + expect(text({ amount: 2.46, unit: "%" })).toContain("≈ 2.5% / day to last until reset"); + expect(text({ amount: 10.714, unit: "requests" })).toContain( + "≈ 10.71 requests / day to last until reset", + ); + expect(text({ amount: 5, unit: "tokens" })).not.toContain("/ day"); + }); + + it("follows the pace setting and hides on compact rows", () => { + const target = { amount: 21.09, unit: "credits" }; + expect( + metricRowPresentation(input({ lane: "secondary", snap: monthly(target), paceEnabled: false }), en) + .metaText, + ).toBeNull(); + expect( + metricRowPresentation(input({ lane: "secondary", snap: monthly(target), compact: true }), en).metaText, + ).toBeNull(); + }); + + it("shows nothing when the window carries no target", () => { + const row = metricRowPresentation(input({ lane: "secondary", snap: monthly(null) }), en); + expect(row.metaText ?? "").not.toContain("/ day"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 10e333fd7b2..f5a306085de 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -1,6 +1,7 @@ import { normalizeResetDescription } from "../hooks/useFormattedResetTime"; import type { LocaleKey } from "../i18n/keys"; import type { + DailyTargetSnapshot, PaceStage, RateWindowSnapshot, WindowPaceSnapshot, @@ -267,6 +268,26 @@ function paceMetaText(pace: WindowPaceSnapshot, kind: PaceKind, t: Translate): s return right ? `${left} · ${right}` : left; } +const DAILY_TARGET_KEYS: Record = { + "%": "PaceDailyTargetPercent", + credits: "PaceDailyTargetCredits", + requests: "PaceDailyTargetRequests", +}; + +/** + * Upstream `UsagePaceText.dailyTarget`: the per-day share in the allowance's + * own unit. Units without a key (for example a currency) show no target. + */ +function dailyTargetText(target: DailyTargetSnapshot, t: Translate): string | null { + const key = DAILY_TARGET_KEYS[target.unit]; + if (!key || !Number.isFinite(target.amount)) return null; + const digits = target.unit === "%" ? 1 : 2; + const amount = new Intl.NumberFormat(intlLocale(t), { maximumFractionDigits: digits }).format( + Math.max(0, target.amount), + ); + return fill(t(key), amount); +} + function normalizedMarkerPercents(values: number[]): number[] { const kept: number[] = []; for (const value of values.map(clampPercent)) { @@ -342,6 +363,15 @@ export function metricRowPresentation(input: MetricRowInput, t: Translate): Metr input.showResetWhenExhausted && snap.isExhausted && resetTarget > input.now && input.resetText !== null; const visible = visiblePace(input); const markers = barMarkers(input); + // The daily target shares the pace setting and the non-compact meta line. + const dailyTarget = + input.paceEnabled && !input.compact && snap.dailyTarget + ? dailyTargetText(snap.dailyTarget, t) + : null; + const metaParts = [ + visible && !input.compact ? paceMetaText(visible.pace, visible.kind, t) : null, + dailyTarget, + ].filter((part): part is string => part !== null); const valuePercent = Math.round(shown); const pacePercent = visible && PACE_DIRECTION[visible.pace.stage] !== "onPace" @@ -352,7 +382,7 @@ export function metricRowPresentation(input: MetricRowInput, t: Translate): Metr ? null : percentLabel(shown, t(showAsUsed ? "PanelUsedSuffix" : "PanelLeftSuffix")), resetText: input.compact && !replacesPercent ? null : input.resetText, - metaText: visible && !input.compact ? paceMetaText(visible.pace, visible.kind, t) : null, + metaText: metaParts.length > 0 ? metaParts.join(" · ") : null, bar: { fillPercent: renderedFillPercent(shown), valuePercent, diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index 0c5dc71977c..5edb4afec69 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -57,6 +57,7 @@ export const PROOF_SCENARIO_NAMES = [ "vercel-team-id-invalid", "kimi-bonus-credits", "agent-sessions-hosts", + "copilot-daily-target", ] as const; export type ProofScenarioName = (typeof PROOF_SCENARIO_NAMES)[number]; @@ -1152,6 +1153,22 @@ function kimiBonusCreditsScenario(now: Date): ProofScenario { }; } +/** A monthly allowance with a daily target and no pace: 52% left over 18 days. */ +function copilotDailyTargetScenario(now: Date): ProofScenario { + const snapshot = copilotSnapshot(now); + return { + name: "copilot-daily-target", + settings: baseSettings({ enabledProviders: ["copilot"] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [ + { ...snapshot, primary: { ...snapshot.primary, pace: null, dailyTarget: { amount: 52 / 18, unit: "%" } } }, + ], + codexAccounts: emptyCodexAccounts(), + claudeAccounts: [], + localSpend: [], + }; +} + function proofAgentSession( id: string, host: string, @@ -1230,6 +1247,7 @@ const BUILDERS: Record ProofScenario> = { "vercel-team-id-invalid": vercelTeamIdInvalidScenario, "kimi-bonus-credits": kimiBonusCreditsScenario, "agent-sessions-hosts": agentSessionsHostsScenario, + "copilot-daily-target": copilotDailyTargetScenario, }; export function isProofScenarioName(value: string): value is ProofScenarioName { diff --git a/apps/desktop-tauri/src/types/bridge/usage.ts b/apps/desktop-tauri/src/types/bridge/usage.ts index f066e9a61ca..05286e95deb 100644 --- a/apps/desktop-tauri/src/types/bridge/usage.ts +++ b/apps/desktop-tauri/src/types/bridge/usage.ts @@ -31,6 +31,12 @@ export interface RateWindowSnapshot { reserveEtaSeconds?: number | null; /** Pace of this window; absent when the provider's pace is not authoritative or the window is informational. */ pace?: WindowPaceSnapshot | null; + /** + * Per-day share of a monthly allowance (upstream `monthlyTargetMetrics`). + * Independent of pace scoring; omitted outside monthly windows and when + * usage or the reset is unknown. + */ + dailyTarget?: DailyTargetSnapshot | null; /** Set while a longer exhausted pool blocks this window; raw percentages stay the provider data. */ monthlyLimitBlock?: MonthlyLimitBlock | null; } @@ -91,6 +97,12 @@ export interface WindowPaceSnapshot { resetWindow?: boolean; } +/** Remaining allowance over the local days left, in `unit` (`%`, `credits` or `requests`). */ +export interface DailyTargetSnapshot { + amount: number; + unit: string; +} + export interface PaceSnapshot { stage: PaceStage; deltaPercent: number; diff --git a/rust/src/core/pace_capability.rs b/rust/src/core/pace_capability.rs index 0fea5328bfb..9b00b696b17 100644 --- a/rust/src/core/pace_capability.rs +++ b/rust/src/core/pace_capability.rs @@ -13,7 +13,7 @@ //! dispatch is `provider_factory::pace_capability`, reached through //! [`super::ProviderId::pace_capability`]. -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use super::{RateWindow, UsagePace}; @@ -170,6 +170,56 @@ impl ProviderPaceCapability { work_days, ) } + + /// Per-day share of a monthly allowance (upstream + /// `UsageMenuCardView.Model.monthlyTargetMetrics`). The window must be a + /// usable, non-regenerating monthly window: its length is the inferred + /// monthly duration, or 28 to 31 days. A window without an absolute + /// allowance is measured in percent. A reported allowance must be a + /// positive finite number in `credits` or `requests`; other units have no + /// target. Usage that the provider did not report has no target. + pub fn monthly_daily_target( + &self, + window: &RateWindow, + now: DateTime, + tz: &Tz, + ) -> Option { + if window.is_informational || window.reset_regenerates || !window.usage_known() { + return None; + } + let monthly = self.uses_inferred_monthly_duration(window) + || window.window_minutes.is_some_and(|minutes| { + (28.0..=31.0).contains(&(f64::from(minutes) / 1440.0).round()) + }); + if !monthly { + return None; + } + let (limit, unit) = match (window.quota_limit, window.quota_unit.as_deref()) { + (None, _) => (100.0, "%"), + (Some(limit), Some(unit @ ("credits" | "requests"))) + if limit.is_finite() && limit > 0.0 => + { + (limit, unit) + } + _ => return None, + }; + // Upstream `RateWindow.remainingPercent` floors at zero, so an + // over-quota allowance still gets a zero target. + let remaining = limit * window.remaining_percent().max(0.0) / 100.0; + let amount = UsagePace::daily_target_in(remaining, window.resets_at, now, tz)?; + Some(MonthlyDailyTarget { + amount, + unit: unit.to_string(), + }) + } +} + +/// A monthly allowance's per-day share, in the allowance's own unit +/// (`%`, `credits` or `requests`). +#[derive(Debug, Clone, PartialEq)] +pub struct MonthlyDailyTarget { + pub amount: f64, + pub unit: String, } #[cfg(test)] @@ -186,6 +236,145 @@ mod tests { RateWindow::with_details(used, minutes, Some(resets_at), None) } + /// 2026-02-15 12:00 UTC with the Mar 1 reset: 14 whole local days remain. + fn daily_now() -> DateTime { + at(2026, 2, 15, 12) + } + + fn daily_target_of( + window: &RateWindow, + capability: ProviderPaceCapability, + ) -> Option { + capability.monthly_daily_target(window, daily_now(), &chrono::Utc) + } + + fn monthly_reset(used: f64) -> RateWindow { + window( + used, + Some(MONTHLY_WINDOW_SENTINEL_MINUTES), + at(2026, 3, 1, 0), + ) + } + + #[test] + fn percent_only_monthly_window_targets_remaining_percent_per_day() { + let target = daily_target_of( + &monthly_reset(58.0), + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + ) + .expect("daily target"); + assert_eq!(target.unit, "%"); + assert!((target.amount - 3.0).abs() < 1e-9); + } + + #[test] + fn over_quota_monthly_window_targets_zero() { + for used in [100.0, 125.0] { + let target = daily_target_of( + &monthly_reset(used).with_quota(300.0, "requests"), + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + ) + .expect("exhausted allowance keeps a target"); + assert_eq!(target.amount, 0.0, "used {used}%"); + } + } + + #[test] + fn credit_and_request_allowances_keep_their_unit() { + let credits = monthly_reset(40.0).with_quota(250.0, "credits"); + let target = daily_target_of( + &credits, + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + ) + .expect("credit target"); + assert_eq!(target.unit, "credits"); + assert!((target.amount - 150.0 / 14.0).abs() < 1e-9); + + let requests = monthly_reset(0.0).with_quota(150.0, "requests"); + let target = daily_target_of(&requests, ProviderPaceCapability::UNSUPPORTED) + .expect("request target"); + assert_eq!(target.unit, "requests"); + assert!((target.amount - 150.0 / 14.0).abs() < 1e-9); + } + + #[test] + fn length_of_twenty_eight_to_thirty_one_days_counts_as_monthly() { + let twenty_eight_days = window(0.0, Some(28 * 1440), at(2026, 3, 1, 0)); + assert!(daily_target_of(&twenty_eight_days, ProviderPaceCapability::UNSUPPORTED).is_some()); + let twenty_seven_days = window(0.0, Some(27 * 1440), at(2026, 3, 1, 0)); + assert!(daily_target_of(&twenty_seven_days, ProviderPaceCapability::UNSUPPORTED).is_none()); + } + + #[test] + fn weekly_and_session_windows_have_no_daily_target() { + let weekly = window(10.0, Some(10_080), at(2026, 2, 20, 0)); + assert!( + daily_target_of(&weekly, ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW).is_none() + ); + let session = window(10.0, Some(300), at(2026, 2, 15, 17)); + assert!( + daily_target_of( + &session, + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW + ) + .is_none() + ); + } + + #[test] + fn unknown_usage_informational_regenerating_or_missing_reset_has_no_target() { + let capability = ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW; + assert!( + daily_target_of(&monthly_reset(10.0).with_usage_known(false), capability).is_none() + ); + let mut informational = monthly_reset(10.0); + informational.is_informational = true; + assert!(daily_target_of(&informational, capability).is_none()); + assert!( + daily_target_of(&monthly_reset(10.0).with_regenerating_reset(), capability).is_none() + ); + let missing_reset = + RateWindow::with_details(10.0, Some(MONTHLY_WINDOW_SENTINEL_MINUTES), None, None); + assert!(daily_target_of(&missing_reset, capability).is_none()); + let expired = window( + 10.0, + Some(MONTHLY_WINDOW_SENTINEL_MINUTES), + at(2026, 2, 10, 0), + ); + assert!(daily_target_of(&expired, capability).is_none()); + } + + #[test] + fn unit_less_or_unsupported_allowances_have_no_target() { + let capability = ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW; + let mut unitless = monthly_reset(10.0); + unitless.quota_limit = Some(100.0); + assert!(daily_target_of(&unitless, capability).is_none()); + assert!( + daily_target_of(&monthly_reset(10.0).with_quota(100.0, "tokens"), capability).is_none() + ); + assert!( + daily_target_of( + &monthly_reset(10.0).with_quota(f64::INFINITY, "credits"), + capability + ) + .is_none() + ); + assert!( + daily_target_of(&monthly_reset(10.0).with_quota(0.0, "credits"), capability).is_none() + ); + } + + #[test] + fn exhausted_monthly_allowance_has_a_zero_target() { + let target = daily_target_of( + &monthly_reset(125.0), + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + ) + .expect("zero target"); + assert_eq!(target.amount, 0.0); + } + fn resolved_minutes(minutes: Option, resets_at: DateTime) -> Option { ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW .resolved_reset_window_for_pace(&window(10.0, minutes, resets_at)) diff --git a/rust/src/core/rate_window.rs b/rust/src/core/rate_window.rs index 1be153f763c..bdd41dfef03 100755 --- a/rust/src/core/rate_window.rs +++ b/rust/src/core/rate_window.rs @@ -116,6 +116,17 @@ pub struct RateWindow { /// becoming an exported quota measurement. #[serde(default = "rate_window_usage_known_default", skip_serializing)] pub(crate) usage_known: bool, + + /// Absolute allowance reported alongside the percentage (for example 250 + /// credits). Upstream `RateWindow.quotaLimit`; absent for percent-only + /// windows, and kept out of payloads unless set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quota_limit: Option, + + /// Unit of [`Self::quota_limit`]: `credits`, `requests`, or an ISO currency + /// code. Upstream `RateWindow.quotaUnit`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quota_unit: Option, } fn rate_window_usage_known_default() -> bool { @@ -135,6 +146,8 @@ impl RateWindow { reset_regenerates: false, hidden_on_card: false, usage_known: true, + quota_limit: None, + quota_unit: None, } } @@ -178,6 +191,8 @@ impl RateWindow { reset_regenerates: false, hidden_on_card: false, usage_known: true, + quota_limit: None, + quota_unit: None, } } @@ -187,6 +202,13 @@ impl RateWindow { self } + /// Record the absolute allowance behind the percentage, in `unit`. + pub fn with_quota(mut self, limit: f64, unit: impl Into) -> Self { + self.quota_limit = Some(limit); + self.quota_unit = Some(unit.into()); + self + } + /// The reset is the next quota regeneration ("Regenerates in ..."). pub fn with_regenerating_reset(mut self) -> Self { self.reset_regenerates = true; @@ -338,6 +360,27 @@ mod tests { assert!(!window.usage_known()); } + #[test] + fn quota_allowance_serializes_only_when_reported() { + let plain = RateWindow::with_details(10.0, Some(43_200), None, None); + let plain_json = serde_json::to_value(&plain).expect("rate window JSON"); + assert!(plain_json.get("quota_limit").is_none()); + assert!(plain_json.get("quota_unit").is_none()); + + let quoted = plain.with_quota(250.0, "credits"); + let json = serde_json::to_value("ed).expect("rate window JSON"); + assert_eq!(json["quota_limit"], serde_json::json!(250.0)); + assert_eq!(json["quota_unit"], serde_json::json!("credits")); + + let restored: RateWindow = serde_json::from_value(json).expect("round trip"); + assert_eq!(restored.quota_limit, Some(250.0)); + assert_eq!(restored.quota_unit.as_deref(), Some("credits")); + let legacy: RateWindow = + serde_json::from_value(serde_json::json!({ "used_percent": 5.0 })).expect("legacy"); + assert_eq!(legacy.quota_limit, None); + assert_eq!(legacy.quota_unit, None); + } + #[test] fn internal_usage_known_metadata_is_not_serialized() { let window = RateWindow::with_details(0.0, Some(300), None, None).with_usage_known(false); diff --git a/rust/src/core/usage_pace.rs b/rust/src/core/usage_pace.rs index 16c01714ad2..bfd3dcede3b 100755 --- a/rust/src/core/usage_pace.rs +++ b/rust/src/core/usage_pace.rs @@ -3,7 +3,7 @@ //! Calculates whether the user is On Track, Ahead, or Behind their usage quota //! based on elapsed time and consumption rate. -use chrono::{DateTime, Datelike, Duration, Local, LocalResult, TimeZone, Utc}; +use chrono::{DateTime, Datelike, Duration, Local, LocalResult, NaiveDate, TimeZone, Utc}; use super::RateWindow; @@ -106,6 +106,41 @@ impl UsagePace { Self::weekly_in(window, now, default_window_minutes, work_days, &Local) } + /// Per-day share of `remaining` until `resets_at` in the machine's local + /// time zone. See [`UsagePace::daily_target_in`]. + pub fn daily_target( + remaining: f64, + resets_at: Option>, + now: DateTime, + ) -> Option { + Self::daily_target_in(remaining, resets_at, now, &Local) + } + + /// Upstream `UsagePace.dailyTarget`: the remaining allowance divided by the + /// local calendar days that are left, counting today and any partial reset + /// day. A reset exactly at local midnight adds no partial day, and today + /// always counts, so the target is at least `remaining`. It never estimates + /// today's consumption. Returns `None` for a negative or non-finite + /// remainder, or a reset that is missing or not after `now`. + pub fn daily_target_in( + remaining: f64, + resets_at: Option>, + now: DateTime, + tz: &Tz, + ) -> Option { + if !remaining.is_finite() || remaining < 0.0 { + return None; + } + let resets_at = resets_at.filter(|at| *at > now)?; + let reset_date = resets_at.with_timezone(tz).date_naive(); + let reset_day_start = local_midnight_on(reset_date, tz)?; + let today = now.with_timezone(tz).date_naive(); + let whole_days = (reset_date - today).num_days(); + let partial_day = i64::from(resets_at > reset_day_start); + let day_count = u32::try_from((whole_days + partial_day).max(1)).ok()?; + Some(remaining / f64::from(day_count)) + } + /// [`UsagePace::weekly`] with an explicit time zone for local day /// boundaries (upstream passes `Calendar.current`; tests inject one). pub fn weekly_in( @@ -363,7 +398,13 @@ fn seconds_to_millis(seconds: f64) -> Option { /// quarter hour after it is used, as Foundation's `startOfDay` does. fn next_day_boundary(instant: DateTime, tz: &Tz) -> Option> { let next_date = instant.with_timezone(tz).date_naive().succ_opt()?; - let midnight = next_date.and_hms_opt(0, 0, 0)?; + local_midnight_on(next_date, tz) +} + +/// Start of local calendar day `date`, using the first existing local quarter +/// hour when midnight is skipped (see [`next_day_boundary`]). +fn local_midnight_on(date: NaiveDate, tz: &Tz) -> Option> { + let midnight = date.and_hms_opt(0, 0, 0)?; (0..=180).step_by(15).find_map(|offset_minutes| { let candidate = midnight + Duration::minutes(offset_minutes); match tz.from_local_datetime(&candidate) { @@ -431,6 +472,98 @@ mod tests { assert!(pace.delta_percent > 0.0); } + /// Upstream `MonthlyPacingTargetTests` daily-target cases, with the local + /// calendar injected so the results do not depend on the machine. + mod daily_target { + use super::super::*; + use chrono::{FixedOffset, TimeZone, Utc}; + + fn utc() -> FixedOffset { + FixedOffset::east_opt(0).unwrap() + } + + fn at(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(year, month, day, hour, minute, 0) + .unwrap() + } + + /// 2026-02-15 12:00 UTC: 14 whole days remain until the Mar 1 reset. + fn now() -> DateTime { + at(2026, 2, 15, 12, 0) + } + + #[test] + fn midnight_reset_counts_only_the_whole_days_before_it() { + let target = + UsagePace::daily_target_in(60.0, Some(at(2026, 3, 1, 0, 0)), now(), &utc()); + assert!(target.is_some_and(|value| (value - 60.0 / 14.0).abs() < 1e-9)); + } + + #[test] + fn partial_reset_day_counts_toward_the_target() { + let target = + UsagePace::daily_target_in(60.0, Some(at(2026, 3, 1, 12, 0)), now(), &utc()); + assert!(target.is_some_and(|value| (value - 4.0).abs() < 1e-9)); + } + + #[test] + fn today_counts_when_the_reset_is_later_today_or_tomorrow_midnight() { + let later_today = + UsagePace::daily_target_in(42.0, Some(at(2026, 2, 15, 18, 0)), now(), &utc()); + assert_eq!(later_today, Some(42.0)); + let tomorrow_midnight = + UsagePace::daily_target_in(42.0, Some(at(2026, 2, 16, 0, 0)), now(), &utc()); + assert_eq!(tomorrow_midnight, Some(42.0)); + } + + #[test] + fn missing_expired_or_invalid_inputs_have_no_target() { + assert_eq!(UsagePace::daily_target_in(10.0, None, now(), &utc()), None); + assert_eq!( + UsagePace::daily_target_in(10.0, Some(now()), now(), &utc()), + None + ); + let past = now() - chrono::Duration::hours(1); + assert_eq!( + UsagePace::daily_target_in(10.0, Some(past), now(), &utc()), + None + ); + let future = Some(at(2026, 3, 1, 0, 0)); + assert_eq!( + UsagePace::daily_target_in(-1.0, future, now(), &utc()), + None + ); + assert_eq!( + UsagePace::daily_target_in(f64::NAN, future, now(), &utc()), + None + ); + assert_eq!( + UsagePace::daily_target_in(f64::INFINITY, future, now(), &utc()), + None + ); + } + + #[test] + fn exhausted_allowance_has_a_zero_target() { + let target = UsagePace::daily_target_in(0.0, Some(at(2026, 3, 1, 0, 0)), now(), &utc()); + assert_eq!(target, Some(0.0)); + } + + #[test] + fn local_calendar_days_span_a_daylight_saving_change() { + let los_angeles = chrono_tz::America::Los_Angeles; + // Mar 7 23:30 PST to Mar 9 00:30 PDT: three local dates, one day of hours. + let now = at(2026, 3, 8, 7, 30); + let reset = at(2026, 3, 9, 7, 30); + let target = UsagePace::daily_target_in(30.0, Some(reset), now, &los_angeles); + assert!(target.is_some_and(|value| (value - 10.0).abs() < 1e-9)); + // Local midnight on Mar 9 is 07:00 UTC: Mar 7 and Mar 8 remain, no partial day. + let midnight = at(2026, 3, 9, 7, 0); + let target = UsagePace::daily_target_in(30.0, Some(midnight), now, &los_angeles); + assert!(target.is_some_and(|value| (value - 15.0).abs() < 1e-9)); + } + } + // Ported from upstream v0.70.0 Tests/CodexBarTests/UsagePaceTests.swift. // Literal expected values are the oracle. `speedMultiplierToReset` and // `runOutProbability` assertions are omitted: Windows has neither field. diff --git a/rust/src/locale.rs b/rust/src/locale.rs index f54d1794969..05cc7c97867 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -480,6 +480,9 @@ locale_keys! { PaceInReserve, PaceInDeficit, PaceLastsUntilReset, + PaceDailyTargetPercent, + PaceDailyTargetCredits, + PaceDailyTargetRequests, PaceRunsOutIn, PaceRunsOutNow, PaceProjectedEmptyIn, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index abca15d9cd0..3fe8cfb1a03 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -241,6 +241,9 @@ PaceOnPace = On pace PaceInReserve = { "{}" }% in reserve PaceInDeficit = { "{}" }% in deficit PaceLastsUntilReset = Lasts until reset +PaceDailyTargetPercent = ≈ { "{}" }% / day to last until reset +PaceDailyTargetCredits = ≈ { "{}" } credits / day to last until reset +PaceDailyTargetRequests = ≈ { "{}" } requests / day to last until reset PaceRunsOutIn = Runs out in { "{}" } PaceRunsOutNow = Runs out now PaceProjectedEmptyIn = Projected empty in { "{}" } diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 16a2a988d6a..68393023184 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -209,6 +209,9 @@ PaceOnPace = Al ritmo previsto PaceInReserve = { "{}" }% de reserva PaceInDeficit = { "{}" }% de déficit PaceLastsUntilReset = Dura hasta el reinicio +PaceDailyTargetPercent = ≈ { "{}" }% por día para durar hasta el reinicio +PaceDailyTargetCredits = ≈ { "{}" } créditos por día para durar hasta el reinicio +PaceDailyTargetRequests = ≈ { "{}" } solicitudes por día para durar hasta el reinicio PaceRunsOutIn = Se agota en { "{}" } PaceRunsOutNow = Se agota ahora PaceProjectedEmptyIn = Agotamiento previsto en { "{}" } diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 24774cce924..c517c6665f7 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -209,6 +209,9 @@ PaceOnPace = 想定ペース PaceInReserve = { "{}" }% 余裕 PaceInDeficit = { "{}" }% 不足 PaceLastsUntilReset = リセットまで持ちます +PaceDailyTargetPercent = 1日あたり約 { "{}" }%(リセットまで持つ目安) +PaceDailyTargetCredits = 1日あたり約 { "{}" } クレジット(リセットまで持つ目安) +PaceDailyTargetRequests = 1日あたり約 { "{}" } リクエスト(リセットまで持つ目安) PaceRunsOutIn = { "{}" } 後に使い切る見込み PaceRunsOutNow = まもなく使い切ります PaceProjectedEmptyIn = { "{}" } 後に枯渇する見込み diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 63e15ef7346..5fe14aebdc4 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -209,6 +209,9 @@ PaceOnPace = 정상 속도 PaceInReserve = { "{}" }% 여유 PaceInDeficit = { "{}" }% 부족 PaceLastsUntilReset = 리셋까지 지속 +PaceDailyTargetPercent = 하루 약 { "{}" }%, 리셋까지 유지 가능 +PaceDailyTargetCredits = 하루 약 { "{}" } 크레딧, 리셋까지 유지 가능 +PaceDailyTargetRequests = 하루 약 { "{}" } 요청, 리셋까지 유지 가능 PaceRunsOutIn = { "{}" } 후 소진 PaceRunsOutNow = 지금 소진 PaceProjectedEmptyIn = { "{}" } 후 소진 예상 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 19343aac6b3..8f7e531cf23 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -237,6 +237,9 @@ PaceOnPace = No ritmo PaceInReserve = { "{}" }% em reserva PaceInDeficit = { "{}" }% em déficit PaceLastsUntilReset = Dura até a renovação +PaceDailyTargetPercent = ≈ { "{}" }% por dia para durar até a renovação +PaceDailyTargetCredits = ≈ { "{}" } créditos por dia para durar até a renovação +PaceDailyTargetRequests = ≈ { "{}" } solicitações por dia para durar até a renovação PaceRunsOutIn = Esgota em { "{}" } PaceRunsOutNow = Esgota agora PaceProjectedEmptyIn = Esgotamento previsto em { "{}" } diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 0751265f4e5..e647b3a8220 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -241,6 +241,9 @@ PaceOnPace = В темпе PaceInReserve = { "{}" }% в резерве PaceInDeficit = { "{}" }% в дефиците PaceLastsUntilReset = Длится до сброса +PaceDailyTargetPercent = ≈ { "{}" }% в день, хватит до сброса +PaceDailyTargetCredits = ≈ { "{}" } кредитов в день, хватит до сброса +PaceDailyTargetRequests = ≈ { "{}" } запросов в день, хватит до сброса PaceRunsOutIn = Закончится через { "{}" } PaceRunsOutNow = Сейчас заканчивается PaceProjectedEmptyIn = По прогнозу закончится через { "{}" } diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index bb9b5209bff..ea9f3a6644d 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -241,6 +241,9 @@ PaceOnPace = Beklenen tempoda PaceInReserve = %{ "{}" } rezervde PaceInDeficit = %{ "{}" } açıkta PaceLastsUntilReset = Sıfırlamaya kadar sürer +PaceDailyTargetPercent = ≈ günde %{ "{}" }, sıfırlamaya kadar yeter +PaceDailyTargetCredits = ≈ günde { "{}" } kredi, sıfırlamaya kadar yeter +PaceDailyTargetRequests = ≈ günde { "{}" } istek, sıfırlamaya kadar yeter PaceRunsOutIn = { "{}" } içinde biter PaceRunsOutNow = Şimdi biter PaceProjectedEmptyIn = { "{}" } içinde tükenmesi tahmin ediliyor diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index b2ac91d689f..17ed6274f27 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -241,6 +241,9 @@ PaceOnPace = В темпі PaceInReserve = { "{}" }% в резерві PaceInDeficit = { "{}" }% в дефіциті PaceLastsUntilReset = Вистачить до скидання +PaceDailyTargetPercent = ≈ { "{}" }% на день, вистачить до скидання +PaceDailyTargetCredits = ≈ { "{}" } кредитів на день, вистачить до скидання +PaceDailyTargetRequests = ≈ { "{}" } запитів на день, вистачить до скидання PaceRunsOutIn = Закінчується за { "{}" } PaceRunsOutNow = Зараз закінчується PaceProjectedEmptyIn = Передбачається порожній у { "{}" } diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index b3b306d5504..22b1ea16573 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -209,6 +209,9 @@ PaceOnPace = 节奏正常 PaceInReserve = 余量 { "{}" }% PaceInDeficit = 超额 { "{}" }% PaceLastsUntilReset = 持续到重置 +PaceDailyTargetPercent = 每天约 { "{}" }%,可维持到重置 +PaceDailyTargetCredits = 每天约 { "{}" } 额度,可维持到重置 +PaceDailyTargetRequests = 每天约 { "{}" } 次请求,可维持到重置 PaceRunsOutIn = 预计 { "{}" } 后耗尽 PaceRunsOutNow = 即将耗尽 PaceProjectedEmptyIn = 预计 { "{}" } 后耗尽 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index ae66db1b626..8900281df92 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -209,6 +209,9 @@ PaceOnPace = 用量正常 PaceInReserve = 比目前進度少用 { "{}" }% PaceInDeficit = 比目前進度多用 { "{}" }% PaceLastsUntilReset = 持續到重置 +PaceDailyTargetPercent = 每天約 { "{}" }%,可維持到重置 +PaceDailyTargetCredits = 每天約 { "{}" } 額度,可維持到重置 +PaceDailyTargetRequests = 每天約 { "{}" } 次請求,可維持到重置 PaceRunsOutIn = { "{}" } 後用完 PaceRunsOutNow = 現在用完 PaceProjectedEmptyIn = 預估 { "{}" } 後用完 diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index 5bc805ee1b6..742ba034076 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -487,6 +487,8 @@ impl UsableQuota { reset_regenerates: false, hidden_on_card: false, usage_known: true, + quota_limit: None, + quota_unit: None, } } } diff --git a/rust/src/providers/futureagi/mod.rs b/rust/src/providers/futureagi/mod.rs index 1c1707e6798..4af0915379f 100644 --- a/rust/src/providers/futureagi/mod.rs +++ b/rust/src/providers/futureagi/mod.rs @@ -295,8 +295,10 @@ fn parse_overview(body: &[u8]) -> Result { let used = amount(meter.get("current_usage"))?; let allowance = amount(meter.get("free_allowance"))?; if allowance > 0.0 { + // Upstream `futureagi.ts` reports the allowance with its display unit. let window = - RateWindow::with_details(percent(used, allowance), window_minutes, Some(end), None); + RateWindow::with_details(percent(used, allowance), window_minutes, Some(end), None) + .with_quota(allowance, unit.clone()); match key { PRIMARY_KEY => primary = Some(window), SECONDARY_KEY => secondary = Some(window), diff --git a/rust/src/providers/futureagi/tests.rs b/rust/src/providers/futureagi/tests.rs index 22bb94697c7..43e72f7bace 100644 --- a/rust/src/providers/futureagi/tests.rs +++ b/rust/src/providers/futureagi/tests.rs @@ -113,6 +113,11 @@ async fn documented_usage_preserves_allowances_overages_and_billing_dates() { let secondary = result.usage.secondary.as_ref().expect("gateway window"); assert_eq!(secondary.used_percent, 100.0); assert_eq!(secondary.window_minutes, Some(44_640)); + // Upstream `futureagi.ts`: the allowance and its display unit ride along. + assert_eq!(primary.quota_limit, Some(2000.0)); + assert_eq!(primary.quota_unit.as_deref(), Some("credits")); + assert_eq!(secondary.quota_limit, Some(100_000.0)); + assert_eq!(secondary.quota_unit.as_deref(), Some("requests")); assert_eq!( rows_in(&result, USAGE_SECTION), diff --git a/rust/src/providers/notion/mod.rs b/rust/src/providers/notion/mod.rs index b7363d22c1e..4ba31538b67 100644 --- a/rust/src/providers/notion/mod.rs +++ b/rust/src/providers/notion/mod.rs @@ -289,7 +289,10 @@ fn build_usage_snapshot( account: Option<&NotionAccount>, now: DateTime, ) -> Result { + // Upstream `NotionProvider` reports each allowance in credits (`quotaUnit`), + // so a monthly billing window can show its per-day target. let rolling = rate_limit.window.as_ref().and_then(|window| { + let limit = window.limit?; percent(window.used, window.limit).map(|used_percent| { RateWindow::with_details( used_percent, @@ -297,6 +300,7 @@ fn build_usage_snapshot( rolling_reset(rate_limit.resets_in_seconds, now), None, ) + .with_quota(limit, "credits") }) }); @@ -304,6 +308,7 @@ fn build_usage_snapshot( .billing_period_window .as_ref() .and_then(|billing| { + let limit = billing.limit?; percent(billing.used, billing.limit).map(|used_percent| { RateWindow::with_details( used_percent, @@ -311,6 +316,7 @@ fn build_usage_snapshot( date_from_milliseconds(billing.period_end_ms), None, ) + .with_quota(limit, "credits") }) }); @@ -744,6 +750,11 @@ mod tests { secondary.resets_at, date_from_milliseconds(Some(1_735_689_600_000.0)) ); + // Upstream `NotionAllowanceTests`: both allowances are credits. + assert_eq!(snap.primary.quota_limit, Some(100.0)); + assert_eq!(snap.primary.quota_unit.as_deref(), Some("credits")); + assert_eq!(secondary.quota_limit, Some(1000.0)); + assert_eq!(secondary.quota_unit.as_deref(), Some("credits")); // Upstream `NotionProviderDescriptor.swift:32`: "Monthly". assert_eq!( snap.secondary_label_key,