From 6ecbac11b9c920dad324e74eea49f7341361341a Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sat, 11 Jul 2026 11:56:29 +0700 Subject: [PATCH 1/3] Port provider correctness fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 29 ++ .../src-tauri/src/commands/bridge.rs | 14 + .../src-tauri/src/commands/provider_detail.rs | 35 +- .../src-tauri/src/commands/providers.rs | 24 +- .../src-tauri/src/commands/settings.rs | 32 ++ .../src-tauri/src/commands/tests.rs | 33 +- .../src-tauri/src/tray_bridge.rs | 25 +- apps/desktop-tauri/src/i18n/keys.ts | 2 + .../settings/providers/ProviderDetailPane.tsx | 2 + .../credentials/CodexUsageOptions.tsx | 63 +++ apps/desktop-tauri/src/types/bridge.ts | 2 + rust/Cargo.toml | 1 + rust/src/locale.rs | 2 + rust/src/locale/en-US.ftl | 2 + rust/src/locale/es-MX.ftl | 2 + rust/src/locale/ja-JP.ftl | 2 + rust/src/locale/ko-KR.ftl | 2 + rust/src/locale/zh-CN.ftl | 2 + rust/src/locale/zh-TW.ftl | 2 + rust/src/providers/claude/mod.rs | 432 ++++++++++++++++-- rust/src/providers/kimik2/mod.rs | 68 ++- rust/src/settings.rs | 19 + rust/src/settings/tests.rs | 12 + rust/src/settings/types.rs | 3 + 24 files changed, 740 insertions(+), 70 deletions(-) create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexUsageOptions.tsx diff --git a/Cargo.lock b/Cargo.lock index 518016cbfe..190d5ee6de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -593,6 +593,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -652,6 +662,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "chrono-tz", "clap", "dirs", "fluent-templates", @@ -3192,6 +3203,15 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -3340,6 +3360,15 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.2", +] + [[package]] name = "phf_shared" version = "0.13.1" diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 4c9626e5d5..f80945326c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -106,6 +106,17 @@ pub struct ProviderUsageSnapshot { pub fetch_duration_ms: Option, } +pub(crate) fn filter_hidden_codex_spark_rows( + snapshot: &mut ProviderUsageSnapshot, + spark_usage_visible: bool, +) { + if snapshot.provider_id == "codex" && !spark_usage_visible { + snapshot + .extra_rate_windows + .retain(|extra| !matches!(extra.id.as_str(), "codex-spark" | "codex-spark-weekly")); + } +} + pub(crate) fn pace_stage_str(stage: codexbar::core::PaceStage) -> &'static str { use codexbar::core::PaceStage; match stage { @@ -420,6 +431,7 @@ pub struct SettingsSnapshot { tray_scale_percent: u16, powertoys_status_pipe_enabled: bool, claude_avoid_keychain_prompts: bool, + codex_spark_usage_visible: bool, disable_keychain_access: bool, provider_metrics: std::collections::HashMap, float_bar_enabled: bool, @@ -456,6 +468,7 @@ pub fn get_settings_snapshot() -> SettingsSnapshot { impl From for SettingsSnapshot { fn from(settings: Settings) -> Self { let avoid_keychain_prompts = settings.claude_avoid_keychain_prompts(); + let codex_spark_usage_visible = settings.codex_spark_usage_visible(); let provider_order = settings.provider_display_order_names(); let enabled_providers = provider_order @@ -503,6 +516,7 @@ impl From for SettingsSnapshot { tray_scale_percent: settings.tray_scale_percent, powertoys_status_pipe_enabled: settings.powertoys_status_pipe_enabled, claude_avoid_keychain_prompts: avoid_keychain_prompts, + codex_spark_usage_visible, disable_keychain_access: settings.disable_keychain_access, provider_metrics, float_bar_enabled: settings.float_bar_enabled, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 08eabd5df0..9b7b2cbf8d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -116,25 +116,30 @@ pub fn get_provider_detail( .iter() .find(|s| s.provider_id == detail.id) { - detail.email = snap.account_email.clone(); - detail.plan = snap.plan_name.clone(); - detail.organization = snap.account_organization.clone(); - detail.source_label = if snap.source_label.is_empty() { + let mut snapshot = snap.clone(); + super::filter_hidden_codex_spark_rows( + &mut snapshot, + Settings::load().codex_spark_usage_visible(), + ); + detail.email = snapshot.account_email.clone(); + detail.plan = snapshot.plan_name.clone(); + detail.organization = snapshot.account_organization.clone(); + detail.source_label = if snapshot.source_label.is_empty() { None } else { - Some(snap.source_label.clone()) + Some(snapshot.source_label.clone()) }; - detail.last_updated = Some(snap.updated_at.clone()); - if snap.error.is_none() { - detail.session = Some(snap.primary.clone()); - detail.weekly = snap.secondary.clone(); - detail.model_specific = snap.model_specific.clone(); - detail.tertiary = snap.tertiary.clone(); - detail.extra_rate_windows = snap.extra_rate_windows.clone(); - detail.cost = snap.cost.clone(); - detail.pace = snap.pace.clone(); + detail.last_updated = Some(snapshot.updated_at.clone()); + if snapshot.error.is_none() { + detail.session = Some(snapshot.primary.clone()); + detail.weekly = snapshot.secondary.clone(); + detail.model_specific = snapshot.model_specific.clone(); + detail.tertiary = snapshot.tertiary.clone(); + detail.extra_rate_windows = snapshot.extra_rate_windows.clone(); + detail.cost = snapshot.cost.clone(); + detail.pace = snapshot.pace.clone(); } - detail.last_error = snap.error.clone(); + detail.last_error = snapshot.error.clone(); detail.has_snapshot = true; } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 2ab5d8eed9..fa51ee30f3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -271,10 +271,19 @@ async fn refresh_provider(app: tauri::AppHandle, id: ProviderId, ctx: FetchConte if let Ok(mut guard) = state.lock() { let snapshot = preserve_last_good_transient_failure(&mut guard, id, snapshot); upsert_provider_cache(&mut guard.provider_cache, snapshot.clone()); - drop(guard); - events::emit_provider_updated(&app, &snapshot); + let mut presented = snapshot; + super::filter_hidden_codex_spark_rows( + &mut presented, + Settings::load().codex_spark_usage_visible(), + ); + events::emit_provider_updated(&app, &presented); } else { - events::emit_provider_updated(&app, &snapshot); + let mut presented = snapshot; + super::filter_hidden_codex_spark_rows( + &mut presented, + Settings::load().codex_spark_usage_visible(), + ); + events::emit_provider_updated(&app, &presented); } } @@ -441,8 +450,13 @@ pub async fn refresh_providers_if_stale(app: tauri::AppHandle) -> Result<(), Str pub fn get_cached_providers( state: tauri::State<'_, Mutex>, ) -> Vec { - state + let mut snapshots = state .lock() .map(|guard| guard.provider_cache.clone()) - .unwrap_or_default() + .unwrap_or_default(); + let spark_usage_visible = Settings::load().codex_spark_usage_visible(); + for snapshot in &mut snapshots { + super::filter_hidden_codex_spark_rows(snapshot, spark_usage_visible); + } + snapshots } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 7af6aa638f..689089dde4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -38,6 +38,7 @@ pub struct SettingsUpdate { pub tray_scale_percent: Option, pub powertoys_status_pipe_enabled: Option, pub claude_avoid_keychain_prompts: Option, + pub codex_spark_usage_visible: Option, pub disable_keychain_access: Option, /// Map of provider CLI name → metric preference label. pub provider_metrics: Option>, @@ -76,10 +77,15 @@ impl SettingsUpdate { || self.reset_time_relative.is_some() || self.menu_bar_display_mode.is_some() || self.provider_metrics.is_some() + || self.codex_spark_usage_visible.is_some() || self.enabled_providers.is_some() || self.ui_language.is_some() } + fn refreshes_provider_presentation(&self) -> bool { + self.codex_spark_usage_visible.is_some() + } + fn validate_shortcut_change( &self, app: &tauri::AppHandle, @@ -219,6 +225,9 @@ impl SettingsUpdate { if let Some(v) = self.claude_avoid_keychain_prompts { settings.set_claude_avoid_keychain_prompts(v); } + if let Some(v) = self.codex_spark_usage_visible { + settings.set_codex_spark_usage_visible(v); + } if let Some(v) = self.disable_keychain_access { settings.disable_keychain_access = v; if v { @@ -313,6 +322,7 @@ pub async fn update_settings( let clear_local_usage_cache = patch.codex_custom_sessions_dirs.is_some(); let rebuild_tray_menu = patch.rebuilds_tray_menu(); let refresh_tray_presentation = patch.refreshes_tray_presentation(); + let refresh_provider_presentation = patch.refreshes_provider_presentation(); let previous_language = settings.ui_language; patch.validate_shortcut_change(&app, &settings.global_shortcut)?; @@ -334,6 +344,17 @@ pub async fn update_settings( if refresh_tray_presentation { crate::tray_bridge::refresh_tray_presentation(&app); } + if refresh_provider_presentation && let Some(state) = app.try_state::>() { + let snapshots = state + .lock() + .map_err(|error| error.to_string())? + .provider_cache + .clone(); + for mut snapshot in snapshots { + filter_hidden_codex_spark_rows(&mut snapshot, settings.codex_spark_usage_visible()); + events::emit_provider_updated(&app, &snapshot); + } + } // Notify other windows (PopOut dashboard, tray, float bar) so they re-read // settings live — e.g. the Display tab's window-scale slider takes effect @@ -376,6 +397,17 @@ mod tests { ); } + #[test] + fn spark_visibility_change_refreshes_provider_presentation() { + assert!( + SettingsUpdate { + codex_spark_usage_visible: Some(false), + ..Default::default() + } + .refreshes_provider_presentation() + ); + } + #[test] fn apply_display_settings_clamps_window_scale_percent() { let mut settings = Settings::default(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 412a491764..fff68c06b3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use super::{ - ProviderSummary, ProviderUsageSnapshot, provider_cookie_source_lookup, provider_region_lookup, - validate_external_url, validate_surface_target, + NamedRateWindowSnapshot, ProviderSummary, ProviderUsageSnapshot, provider_cookie_source_lookup, + provider_region_lookup, validate_external_url, validate_surface_target, }; use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; @@ -695,6 +695,35 @@ fn provider_cache_upsert_replaces_existing_provider() { assert_eq!(cache[0].error.as_deref(), Some("new")); } +#[test] +fn hiding_codex_spark_rows_preserves_other_extra_usage() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + cost: None, + source_label: "CLI".to_string(), + }; + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result); + snapshot.extra_rate_windows = vec![ + NamedRateWindowSnapshot { + id: "codex-spark".to_string(), + title: "Codex Spark 5-hour".to_string(), + window: snapshot.primary.clone(), + }, + NamedRateWindowSnapshot { + id: "credits".to_string(), + title: "Credits".to_string(), + window: snapshot.primary.clone(), + }, + ]; + + super::filter_hidden_codex_spark_rows(&mut snapshot, false); + + assert_eq!(snapshot.extra_rate_windows.len(), 1); + assert_eq!(snapshot.extra_rate_windows[0].id, "credits"); +} + #[test] fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index a4df7a1d21..1c1e06e29c 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -370,7 +370,9 @@ pub(crate) fn rebuild_tray_menu(app: &AppHandle) { let settings = Settings::load(); let status_labels = if let Some(st) = app.try_state::>() { let guard = st.lock().unwrap(); - status_labels_for_settings(&settings, &guard.provider_cache, settings.ui_language) + let snapshots = + presentation_snapshots(&guard.provider_cache, settings.codex_spark_usage_visible()); + status_labels_for_settings(&settings, &snapshots, settings.ui_language) } else { vec![] }; @@ -388,7 +390,8 @@ pub fn update_tray_status_items( ) { let catalog = crate::commands::get_provider_catalog(); let settings = Settings::load(); - let status_labels = status_labels_for_settings(&settings, snapshots, settings.ui_language); + let snapshots = presentation_snapshots(snapshots, settings.codex_spark_usage_visible()); + let status_labels = status_labels_for_settings(&settings, &snapshots, settings.ui_language); if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) && let Some(tray) = app.tray_by_id("codexbar-main") @@ -403,11 +406,24 @@ pub(crate) fn refresh_tray_presentation(app: &AppHandle) { .try_state::>() .map(|st| st.lock().unwrap().provider_cache.clone()) .unwrap_or_default(); + let snapshots = + presentation_snapshots(&snapshots, Settings::load().codex_spark_usage_visible()); update_tray_status_items(app, &snapshots); update_tray_icon_and_tooltip(app, &snapshots); } +fn presentation_snapshots( + snapshots: &[crate::commands::ProviderUsageSnapshot], + spark_usage_visible: bool, +) -> Vec { + let mut snapshots = snapshots.to_vec(); + for snapshot in &mut snapshots { + crate::commands::filter_hidden_codex_spark_rows(snapshot, spark_usage_visible); + } + snapshots +} + /// Update the tray icon pixels and tooltip text to reflect current provider usage. /// /// Behaviour mirrors egui's `choose_tray_update_plan` (rust/src/native_ui/app.rs): @@ -429,7 +445,8 @@ pub fn update_tray_icon_and_tooltip( // ── Icon ───────────────────────────────────────────────────────────── let settings = Settings::load(); - let ordered_snapshots = ordered_snapshot_refs(&settings, snapshots); + let snapshots = presentation_snapshots(snapshots, settings.codex_spark_usage_visible()); + let ordered_snapshots = ordered_snapshot_refs(&settings, &snapshots); let ok_snapshots: Vec<_> = ordered_snapshots .iter() .copied() @@ -458,7 +475,7 @@ pub fn update_tray_icon_and_tooltip( let _ = tray.set_icon(Some(icon)); // ── Tooltip ─────────────────────────────────────────────────────────── - let tooltip = build_tooltip(snapshots, settings.ui_language); + let tooltip = build_tooltip(&snapshots, settings.ui_language); let _ = tray.set_tooltip(Some(tooltip)); } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 43cc8563c5..4fb4d007c9 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -243,6 +243,8 @@ export const ALL_LOCALE_KEYS = [ "ProviderClaudeCookiesHelp", "ProviderClaudeAvoidKeychainPrompts", "ProviderClaudeAvoidKeychainPromptsHelp", + "ProviderCodexSparkUsage", + "ProviderCodexSparkUsageHelp", "ProviderCursorCookieSourceHelp", "ProviderCursorCreditsHelp", "AutoFallbackHelp", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index f778a98013..fb498d4fae 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -35,6 +35,7 @@ import { VertexAiCreds } from "./sections/credentials/VertexAiCreds"; import { JetBrainsCreds } from "./sections/credentials/JetBrainsCreds"; import { KiroCreds } from "./sections/credentials/KiroCreds"; import { ClaudeCreds } from "./sections/credentials/ClaudeCreds"; +import { CodexUsageOptions } from "./sections/credentials/CodexUsageOptions"; import { OpenAiExtras } from "./sections/credentials/OpenAiExtras"; import { TokenAccountsPanel } from "../tokens/TokenAccountsPanel"; import { ApiKeySection } from "./ApiKeySection"; @@ -299,6 +300,7 @@ export function ProviderDetailPane({ onChanged={() => void load(detail.id)} /> + {detail.id === "codex" && } string; +} + +export function CodexUsageOptions({ t }: Props) { + const [value, setValue] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + getSettingsSnapshot() + .then( + (settings) => + !cancelled && setValue(settings.codexSparkUsageVisible ?? true), + ) + .catch((e) => !cancelled && setError(String(e))); + return () => { + cancelled = true; + }; + }, []); + + const toggle = async (next: boolean) => { + setSaving(true); + try { + const updated = await updateSettings({ codexSparkUsageVisible: next }); + setValue(updated.codexSparkUsageVisible ?? true); + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + }; + + if (value === null) return null; + + return ( +
+

{t("ProviderOptionsTitle")}

+ + {error &&
{error}
} +
+ ); +} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index b82a7ccf1b..2142b7c004 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -200,6 +200,7 @@ export interface SettingsSnapshot { trayScalePercent: number; powertoysStatusPipeEnabled: boolean; claudeAvoidKeychainPrompts: boolean; + codexSparkUsageVisible?: boolean; disableKeychainAccess: boolean; providerMetrics: Record; floatBarEnabled: boolean; @@ -251,6 +252,7 @@ export interface SettingsUpdate { trayScalePercent?: number; powertoysStatusPipeEnabled?: boolean; claudeAvoidKeychainPrompts?: boolean; + codexSparkUsageVisible?: boolean; disableKeychainAccess?: boolean; /** Map of provider CLI name → metric preference label. */ providerMetrics?: Record; diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9d8278dbbb..433d7c6579 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -40,6 +40,7 @@ anyhow = "1" # Date/time chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.10" # Async utilities async-trait = "0.1" diff --git a/rust/src/locale.rs b/rust/src/locale.rs index ed89eea39a..c1657667c1 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -457,6 +457,8 @@ locale_keys! { ProviderClaudeCookiesHelp, ProviderClaudeAvoidKeychainPrompts, ProviderClaudeAvoidKeychainPromptsHelp, + ProviderCodexSparkUsage, + ProviderCodexSparkUsageHelp, ProviderCursorCookieSourceHelp, ProviderCursorCreditsHelp, AutoFallbackHelp, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 7d3d34af57..13a4239518 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude cookies ProviderClaudeCookiesHelp = Browser cookies/sessionKey are preferred because they match Claude's settings usage page. ProviderClaudeAvoidKeychainPrompts = Avoid Keychain prompts ProviderClaudeAvoidKeychainPromptsHelp = Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts. +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = Automatic imports browser cookies or stored sessions. ProviderCursorCreditsHelp = On-demand usage beyond included plan limits. AutoFallbackHelp = Auto falls back to the next source if the preferred one fails. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 051ca3b867..d7a67d206f 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Cookies de Claude ProviderClaudeCookiesHelp = Se prefieren cookies/sessionKey del navegador porque coinciden con la página de uso de configuración de Claude. ProviderClaudeAvoidKeychainPrompts = Evitar avisos de llavero ProviderClaudeAvoidKeychainPromptsHelp = Usar /usr/bin/security para leer credenciales de Claude y evitar avisos de llavero de CodexBar. +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = Importa automáticamente cookies del navegador o sesiones almacenadas. ProviderCursorCreditsHelp = Uso bajo demanda más allá de los límites del plan incluido. AutoFallbackHelp = Auto recurre a la siguiente fuente si la preferida falla. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 68b5ca0f9c..60657acd06 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude cookies ProviderClaudeCookiesHelp = Browser cookies/sessionKey are preferred because they match Claude's settings usage page. ProviderClaudeAvoidKeychainPrompts = Avoid Keychain prompts ProviderClaudeAvoidKeychainPromptsHelp = Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts. +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = Automatic imports browser cookies or stored sessions. ProviderCursorCreditsHelp = On-demand usage beyond included plan limits. AutoFallbackHelp = Auto falls back to the next source if the preferred one fails. diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 20e85c2ddb..455528798a 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude 쿠키 ProviderClaudeCookiesHelp = 브라우저 쿠키/`sessionKey`가 Claude의 설정 사용량 페이지와 일치하므로 권장됩니다. ProviderClaudeAvoidKeychainPrompts = 키체인 메시지 표시 방지 ProviderClaudeAvoidKeychainPromptsHelp = Claude 자격 증명을 읽을 때 `/usr/bin/security`를 사용하여 CodexBar 키체인 권한 창이 뜨지 않도록 합니다. +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = 브라우저 쿠키 또는 저장된 세션을 자동으로 가져옵니다. ProviderCursorCreditsHelp = 기본 요금제 한도를 초과하는 온디맨드 사용량. AutoFallbackHelp = 기본 소스가 실패하면 자동으로 다음 소스로 전환합니다. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 15b8464e7d..4be46d15f5 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude Cookie ProviderClaudeCookiesHelp = 优先使用浏览器 Cookie/sessionKey,因为它与 Claude 设置页的用量一致。 ProviderClaudeAvoidKeychainPrompts = 避免钥匙串提示 ProviderClaudeAvoidKeychainPromptsHelp = 使用 /usr/bin/security 读取 Claude 凭据,避免 CodexBar 的钥匙串提示。 +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = 自动导入浏览器 Cookie 或已保存会话。 ProviderCursorCreditsHelp = 包含计划额度之外的按量计费用量。 AutoFallbackHelp = 当首选来源失败时自动回退到下一个来源。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 4c698f748f..d043630fa4 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude Cookie ProviderClaudeCookiesHelp = 優先使用瀏覽器 Cookie/sessionKey,因為它與 Claude 設定頁的用量一致。 ProviderClaudeAvoidKeychainPrompts = 避免鑰匙串提示 ProviderClaudeAvoidKeychainPromptsHelp = 使用 /usr/bin/security 讀取 Claude 憑據,避免 CodexBar 的鑰匙串提示。 +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = 自動匯入瀏覽器 Cookie 或已儲存會話。 ProviderCursorCreditsHelp = 包含計劃額度之外的按量計費用量。 AutoFallbackHelp = 當首選來源失敗時自動回退到下一個來源。 diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index dd0f3ea6bb..4527e040e9 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -5,16 +5,19 @@ mod oauth; mod web_api; use async_trait::async_trait; +use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; use regex_lite::Regex; #[cfg(windows)] use std::os::windows::process::CommandExt; #[cfg(windows)] use std::process::{Command as StdCommand, Stdio}; +use std::str::FromStr; use crate::cli::tty_runner::{TtyCommandOptions, TtyCommandRunner}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, NamedRateWindow, Provider, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; use admin_api::ClaudeAdminApiFetcher; @@ -444,7 +447,6 @@ impl ClaudeProvider { // Parse session percent: "X% used" or "X% left" let mut session_percent: Option = None; let mut weekly_percent: Option = None; - let mut opus_percent: Option = None; // Look for "Current session" section if let Some(session_pct) = extract_percent_near_label(&clean, "current session") { @@ -458,13 +460,6 @@ impl ClaudeProvider { weekly_percent = Some(weekly_pct); } - // Look for Opus/Sonnet specific - if let Some(opus_pct) = extract_percent_near_label(&clean, "opus") { - opus_percent = Some(opus_pct); - } else if let Some(sonnet_pct) = extract_percent_near_label(&clean, "sonnet") { - opus_percent = Some(sonnet_pct); - } - // Fallback: collect all percentages in order if session_percent.is_none() { let all_percents = extract_all_percents(&clean); @@ -474,14 +469,10 @@ impl ClaudeProvider { if all_percents.len() > 1 && weekly_percent.is_none() { weekly_percent = Some(all_percents[1]); } - if all_percents.len() > 2 && opus_percent.is_none() { - opus_percent = Some(all_percents[2]); - } } if session_percent.is_none() && weekly_percent.is_none() - && opus_percent.is_none() && !is_exhausted_short_form(&clean_lower) { return Err(ProviderError::Parse( @@ -503,6 +494,8 @@ impl ClaudeProvider { None }; let session_reset = session_reset.or(short_form_reset); + let now = Utc::now(); + let scoped_weekly_limits = extract_cli_scoped_weekly_limits(&clean, now); if session_percent.is_none() && is_exhausted_short_form(&clean_lower) { session_percent = Some(100.0); @@ -513,7 +506,9 @@ impl ClaudeProvider { let primary = RateWindow::with_details( session_used, Some(300), // 5 hour session window - None, // Could parse reset time + session_reset + .as_deref() + .and_then(|reset| parse_claude_reset_date(reset, now, Some(300))), session_reset, ); @@ -523,15 +518,16 @@ impl ClaudeProvider { let secondary = RateWindow::with_details( weekly_used, Some(10080), // weekly (7 * 24 * 60) - None, + weekly_reset + .as_deref() + .and_then(|reset| parse_claude_reset_date(reset, now, Some(10080))), weekly_reset, ); usage = usage.with_secondary(secondary); } - if let Some(opus_used) = opus_percent { - let model_specific = RateWindow::with_details(opus_used, Some(10080), None, None); - usage = usage.with_model_specific(model_specific); + for limit in scoped_weekly_limits { + usage.extra_rate_windows.push(limit); } if let Some(method) = login_method { @@ -851,6 +847,320 @@ fn starts_next_usage_section(line: &str, current_label: &str) -> bool { normalized.starts_with("current") && !normalized.contains(current_label) } +fn extract_cli_scoped_weekly_limits(text: &str, now: DateTime) -> Vec { + let label_re = match Regex::new(r"(?i)current\s*week\s*\(([^)]+)\)") { + Ok(re) => re, + Err(_) => return Vec::new(), + }; + let lines: Vec<&str> = text.lines().collect(); + let mut limits = Vec::new(); + + for (idx, line) in lines.iter().enumerate() { + let Some(captures) = label_re.captures(line) else { + continue; + }; + let Some(label_match) = captures.get(1) else { + continue; + }; + let title = normalize_scoped_weekly_title(label_match.as_str()); + if title.is_empty() || normalized_for_label_search(&title) == "allmodels" { + continue; + } + let id = format!( + "claude-weekly-scoped-{}", + slug_claude_model(title.trim_end_matches(" only").trim()) + ); + if id == "claude-weekly-scoped-" + || limits.iter().any(|limit: &NamedRateWindow| limit.id == id) + { + continue; + } + + let mut used_percent = None; + let mut reset_description = None; + let current_label = normalized_for_label_search(line); + for (offset, section_line) in lines.iter().skip(idx).take(14).enumerate() { + if offset > 0 && starts_next_usage_section(section_line, ¤t_label) { + break; + } + if used_percent.is_none() { + used_percent = parse_percent_line(section_line); + } + if reset_description.is_none() { + let lower = section_line.to_lowercase(); + if let Some(position) = lower.find("resets") { + reset_description = Some(section_line[position..].trim().to_string()); + } + } + } + let Some(used_percent) = used_percent else { + continue; + }; + let resets_at = reset_description + .as_deref() + .and_then(|reset| parse_claude_reset_date(reset, now, Some(10080))); + limits.push(NamedRateWindow::new( + id, + title, + RateWindow::with_details(used_percent, Some(10080), resets_at, reset_description), + )); + } + + limits +} + +fn slug_claude_model(label: &str) -> String { + let mut slug = String::new(); + let mut previous_dash = false; + for character in label.chars() { + if character.is_ascii_alphanumeric() { + slug.push(character.to_ascii_lowercase()); + previous_dash = false; + } else if !slug.is_empty() && !previous_dash { + slug.push('-'); + previous_dash = true; + } + } + slug.trim_matches('-').to_string() +} + +fn normalize_scoped_weekly_title(label: &str) -> String { + let label = label.trim(); + if label.len() > 4 && label[label.len() - 4..].eq_ignore_ascii_case("only") { + let model = label[..label.len() - 4].trim_end(); + if !model.is_empty() { + return format!("{model} only"); + } + } + label.to_string() +} + +fn parse_claude_reset_date( + text: &str, + now: DateTime, + expected_window_minutes: Option, +) -> Option> { + let (raw, timezone) = normalize_claude_reset_text(text)?; + let components = parse_claude_reset_components(&raw)?; + let now_local = now.with_timezone(&timezone); + let candidates = match (components.year, components.month, components.day) { + (Some(year), Some(month), Some(day)) => local_reset_occurrences( + timezone, + year, + month, + day, + components.hour, + components.minute, + ), + (None, Some(month), Some(day)) => (now_local.year() - 8..=now_local.year() + 8) + .flat_map(|year| { + local_reset_occurrences( + timezone, + year, + month, + day, + components.hour, + components.minute, + ) + }) + .collect(), + (None, None, None) => (-1..=1) + .flat_map(|offset| { + let date = now_local.date_naive() + Duration::days(offset); + local_reset_occurrences( + timezone, + date.year(), + date.month(), + date.day(), + components.hour, + components.minute, + ) + }) + .collect(), + _ => return None, + }; + + resolve_claude_reset_occurrence(candidates, now, expected_window_minutes) +} + +fn normalize_claude_reset_text(text: &str) -> Option<(String, Tz)> { + let mut raw = text + .trim() + .strip_prefix("Resets") + .or_else(|| text.trim().strip_prefix("resets")) + .unwrap_or(text) + .trim() + .to_string(); + let timezone = raw + .rfind('(') + .filter(|_| raw.ends_with(')')) + .and_then(|start| { + let timezone = Tz::from_str(raw[start + 1..raw.len() - 1].trim()).ok(); + raw.truncate(start); + timezone + }) + .unwrap_or(chrono_tz::UTC); + raw = raw.replace(" at ", " "); + raw = Regex::new(r"(?i)([a-z]{3})(\d)") + .ok()? + .replace(&raw, "$1 $2") + .into_owned(); + raw = Regex::new(r"(?i)(\d)at(\d)") + .ok()? + .replace(&raw, "$1 $2") + .into_owned(); + (!raw.trim().is_empty()).then(|| (raw.trim().to_string(), timezone)) +} + +struct ClaudeResetComponents { + year: Option, + month: Option, + day: Option, + hour: u32, + minute: u32, +} + +fn parse_claude_reset_components(raw: &str) -> Option { + let date_time = Regex::new( + r"(?i)^([a-z]{3})\s+(\d{1,2})(?:,\s*|\s+)(?:(\d{4})(?:,\s*|\s+))?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$", + ) + .ok()?; + if let Some(captures) = date_time.captures(raw) { + let month = claude_month(captures.get(1)?.as_str())?; + let day = captures.get(2)?.as_str().parse().ok()?; + let year = captures + .get(3) + .and_then(|value| value.as_str().parse().ok()); + let hour = parse_claude_hour( + captures.get(4)?.as_str(), + captures.get(5).map(|value| value.as_str()), + captures.get(6).map(|value| value.as_str()), + )?; + return Some(ClaudeResetComponents { + year, + month: Some(month), + day: Some(day), + hour: hour.0, + minute: hour.1, + }); + } + + let time = Regex::new(r"(?i)^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$").ok()?; + let captures = time.captures(raw)?; + let (hour, minute) = parse_claude_hour( + captures.get(1)?.as_str(), + captures.get(2).map(|value| value.as_str()), + captures.get(3).map(|value| value.as_str()), + )?; + Some(ClaudeResetComponents { + year: None, + month: None, + day: None, + hour, + minute, + }) +} + +fn parse_claude_hour( + hour: &str, + minute: Option<&str>, + meridiem: Option<&str>, +) -> Option<(u32, u32)> { + let mut hour = hour.parse::().ok()?; + let minute = minute.unwrap_or("0").parse::().ok()?; + if minute > 59 { + return None; + } + match meridiem.map(str::to_ascii_lowercase).as_deref() { + Some("am") if (1..=12).contains(&hour) => { + if hour == 12 { + hour = 0; + } + } + Some("pm") if (1..=12).contains(&hour) => { + if hour != 12 { + hour += 12; + } + } + Some(_) => return None, + None if hour > 23 => return None, + None => {} + } + Some((hour, minute)) +} + +fn claude_month(month: &str) -> Option { + match month.to_ascii_lowercase().as_str() { + "jan" => Some(1), + "feb" => Some(2), + "mar" => Some(3), + "apr" => Some(4), + "may" => Some(5), + "jun" => Some(6), + "jul" => Some(7), + "aug" => Some(8), + "sep" => Some(9), + "oct" => Some(10), + "nov" => Some(11), + "dec" => Some(12), + _ => None, + } +} + +fn local_reset_occurrences( + timezone: Tz, + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, +) -> Vec> { + let Some(naive) = NaiveDate::from_ymd_opt(year, month, day) + .and_then(|date| date.and_hms_opt(hour, minute, 0)) + else { + return Vec::new(); + }; + match timezone.from_local_datetime(&naive) { + LocalResult::Single(value) => vec![value.with_timezone(&Utc)], + LocalResult::Ambiguous(first, second) => { + vec![first.with_timezone(&Utc), second.with_timezone(&Utc)] + } + LocalResult::None => Vec::new(), + } +} + +fn resolve_claude_reset_occurrence( + mut candidates: Vec>, + now: DateTime, + expected_window_minutes: Option, +) -> Option> { + candidates.sort_unstable(); + candidates.dedup(); + let future = candidates + .iter() + .copied() + .find(|candidate| *candidate >= now); + let fallback = candidates.last().copied(); + let future = future.or(fallback)?; + let Some(expected_window) = + expected_window_minutes.map(|minutes| Duration::minutes(minutes.into())) + else { + return Some(future); + }; + let past = candidates + .iter() + .copied() + .rev() + .find(|candidate| *candidate < now); + let past_is_plausible = past.is_some_and(|candidate| now - candidate <= expected_window); + let future_is_plausible = future - now <= expected_window; + if past_is_plausible && !future_is_plausible { + past + } else { + Some(future) + } +} + fn is_exhausted_short_form(clean_lower: &str) -> bool { clean_lower.contains("out of extra usage") || clean_lower.contains("hit your limit") } @@ -948,6 +1258,8 @@ fn clean_plan_name(text: &str) -> String { #[cfg(test)] mod tests { + use chrono::{DateTime, Utc}; + use super::*; #[test] @@ -1055,9 +1367,70 @@ Status Config Usage let sonnet = result .usage - .model_specific + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-weekly-scoped-sonnet") .expect("sonnet usage should be present"); - assert_eq!(sonnet.used_percent, 1.0); + assert_eq!(sonnet.window.used_percent, 1.0); + } + + #[test] + fn parses_all_cli_model_scoped_weekly_limits() { + let provider = ClaudeProvider::new(); + let output = r#" +Current session +10% used +Resets 12pm (America/Bogota) + +Current week (all models) +20% used +Resets Apr 3, 2pm (America/Bogota) + +Current week (Sonnet only) +30% used +Resets Apr 4, 2pm (America/Bogota) + +Current week (Opus only) +40% used +Resets Apr 5, 2pm (America/Bogota) +"#; + + let result = provider.parse_cli_output(output).expect("should parse"); + + assert_eq!(result.usage.extra_rate_windows.len(), 2); + assert_eq!( + result.usage.extra_rate_windows[0].id, + "claude-weekly-scoped-sonnet" + ); + assert_eq!(result.usage.extra_rate_windows[0].title, "Sonnet only"); + assert_eq!(result.usage.extra_rate_windows[0].window.used_percent, 30.0); + assert_eq!( + result.usage.extra_rate_windows[1].id, + "claude-weekly-scoped-opus" + ); + assert!(result.usage.model_specific.is_none()); + } + + #[test] + fn resolves_cli_reset_occurrences_in_the_reported_timezone() { + let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); + + assert_eq!( + parse_claude_reset_date("Resets Apr 3, 2027, 2pm (America/Bogota)", now, None), + Some("2027-04-03T19:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("Resets Apr 3, 2pm (America/Bogota)", now, None), + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("Resets 12pm (America/Bogota)", now, None), + Some("2026-04-03T17:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("ResetsApr3at2pm(America/Bogota)", now, None), + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); } #[test] @@ -1092,14 +1465,15 @@ ResetsFeb12at1:29pm(Asia/Calcutta) .used_percent, 4.0 ); - assert_eq!( - result - .usage - .model_specific - .expect("sonnet usage should be present") - .used_percent, - 1.0 - ); + let sonnet = result + .usage + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-weekly-scoped-sonnet") + .expect("sonnet usage should be present"); + assert_eq!(result.usage.extra_rate_windows.len(), 1); + assert_eq!(sonnet.title, "Sonnet only"); + assert_eq!(sonnet.window.used_percent, 1.0); } #[test] diff --git a/rust/src/providers/kimik2/mod.rs b/rust/src/providers/kimik2/mod.rs index 50bef07fc4..cd583981c0 100755 --- a/rust/src/providers/kimik2/mod.rs +++ b/rust/src/providers/kimik2/mod.rs @@ -30,7 +30,7 @@ impl KimiK2Provider { supports_credits: true, default_enabled: false, is_primary: false, - dashboard_url: Some("https://platform.moonshot.ai/console/account"), + dashboard_url: Some("https://kimrel.com/my-credits"), status_page_url: None, }, } @@ -39,23 +39,23 @@ impl KimiK2Provider { /// Get API key from environment or config fn get_api_key(api_key: Option<&str>) -> Option { if let Some(key) = api_key - && !key.is_empty() + && !key.trim().is_empty() { - return Some(key.to_string()); + return Some(key.trim().to_string()); } // Check environment variable first if let Ok(key) = std::env::var("MOONSHOT_API_KEY") - && !key.is_empty() + && !key.trim().is_empty() { - return Some(key); + return Some(key.trim().to_string()); } // Check KIMI_API_KEY if let Ok(key) = std::env::var("KIMI_API_KEY") - && !key.is_empty() + && !key.trim().is_empty() { - return Some(key); + return Some(key.trim().to_string()); } // Check config file @@ -65,14 +65,29 @@ impl KimiK2Provider { && let Ok(content) = std::fs::read_to_string(&config_file) && let Ok(json) = serde_json::from_str::(&content) && let Some(key) = json.get("api_key").and_then(|v| v.as_str()) + && !key.trim().is_empty() { - return Some(key.to_string()); + return Some(key.trim().to_string()); } } None } + fn missing_api_key_error() -> ProviderError { + ProviderError::Other( + "Kimi K2 API key is missing or blank. Set it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." + .to_string(), + ) + } + + fn rejected_api_key_error() -> ProviderError { + ProviderError::Other( + "Kimi K2 API key was rejected or expired. Update it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." + .to_string(), + ) + } + fn api_bases_from_region(region: Option<&str>) -> &'static [&'static str] { match region.unwrap_or_default().trim().to_lowercase().as_str() { "cn" | "china" => &[KIMIK2_API_BASE_CHINA], @@ -83,12 +98,8 @@ impl KimiK2Provider { /// Fetch usage via Moonshot API async fn fetch_via_api(&self, ctx: &FetchContext) -> Result { - let api_key = Self::get_api_key(ctx.api_key.as_deref()).ok_or_else(|| { - ProviderError::NotInstalled( - "Moonshot API key not found. Set it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." - .to_string(), - ) - })?; + let api_key = + Self::get_api_key(ctx.api_key.as_deref()).ok_or_else(Self::missing_api_key_error)?; let client = crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) @@ -125,7 +136,7 @@ impl KimiK2Provider { } if auth_error { - Err(ProviderError::AuthRequired) + Err(Self::rejected_api_key_error()) } else { Err(ProviderError::Other( "Moonshot API endpoint not configured".to_string(), @@ -274,8 +285,18 @@ impl Provider for KimiK2Provider { #[cfg(test)] mod tests { + use crate::core::Provider; + use super::KimiK2Provider; + #[test] + fn kimi_uses_legacy_credits_dashboard() { + assert_eq!( + KimiK2Provider::new().metadata().dashboard_url, + Some("https://kimrel.com/my-credits") + ); + } + #[test] fn explicit_api_key_overrides_environment_lookup() { assert_eq!( @@ -284,6 +305,23 @@ mod tests { ); } + #[test] + fn blank_explicit_api_key_is_not_accepted() { + assert_eq!(KimiK2Provider::get_api_key(Some(" \t ")), None); + } + + #[test] + fn kimi_key_errors_distinguish_missing_and_rejected_keys() { + assert_eq!( + KimiK2Provider::missing_api_key_error().to_string(), + "Kimi K2 API key is missing or blank. Set it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." + ); + assert_eq!( + KimiK2Provider::rejected_api_key_error().to_string(), + "Kimi K2 API key was rejected or expired. Update it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." + ); + } + #[test] fn kimi_api_region_default_tries_both_regions() { assert_eq!( diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 2a46e6bffb..95e70000d4 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -329,6 +329,7 @@ fn default_api_region(id: ProviderId) -> &'static str { /// Default for the codex `openai_web_extras` boolean (true = show extras). const DEFAULT_CODEX_OPENAI_WEB_EXTRAS: bool = true; +const DEFAULT_CODEX_SPARK_USAGE_VISIBLE: bool = true; impl Default for Settings { fn default() -> Self { @@ -733,6 +734,18 @@ impl Settings { self.provider_config_mut(id).openai_web_extras = Some(value); } + /// Codex Spark rows are visible by default. + pub fn spark_usage_visible(&self, id: ProviderId) -> bool { + self.provider_configs + .get(&id) + .and_then(|c| c.spark_usage_visible) + .unwrap_or(DEFAULT_CODEX_SPARK_USAGE_VISIBLE) + } + + pub fn set_spark_usage_visible(&mut self, id: ProviderId, value: bool) { + self.provider_config_mut(id).spark_usage_visible = Some(value); + } + /// Per-provider historical-tracking toggle (currently codex-only). pub fn historical_tracking(&self, id: ProviderId) -> bool { self.provider_configs @@ -925,6 +938,12 @@ impl Settings { pub fn set_codex_openai_web_extras(&mut self, v: bool) { self.set_openai_web_extras(ProviderId::Codex, v) } + pub fn codex_spark_usage_visible(&self) -> bool { + self.spark_usage_visible(ProviderId::Codex) + } + pub fn set_codex_spark_usage_visible(&mut self, v: bool) { + self.set_spark_usage_visible(ProviderId::Codex, v) + } pub fn codex_historical_tracking(&self) -> bool { self.historical_tracking(ProviderId::Codex) } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index f3f26ac556..e9ff1f0768 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -788,3 +788,15 @@ fn test_per_provider_defaults_applied() { assert!(!settings.historical_tracking(ProviderId::Codex)); assert!(!settings.avoid_keychain_prompts(ProviderId::Claude)); } + +#[test] +fn codex_spark_usage_visibility_defaults_to_visible_and_roundtrips() { + let mut settings = Settings::default(); + assert!(settings.codex_spark_usage_visible()); + + settings.set_codex_spark_usage_visible(false); + let serialized = serde_json::to_string(&settings).unwrap(); + let loaded: Settings = serde_json::from_str(&serialized).unwrap(); + + assert!(!loaded.codex_spark_usage_visible()); +} diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index f038ea2955..c0b124d4ff 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -262,6 +262,9 @@ pub struct ProviderConfig { /// Codex-only: opt out of OpenAI web "extras" surfaces. #[serde(skip_serializing_if = "Option::is_none")] pub openai_web_extras: Option, + /// Codex-only: show or hide Codex Spark quota rows in presentation. + #[serde(skip_serializing_if = "Option::is_none")] + pub spark_usage_visible: Option, /// Codex-only: enable historical usage tracking in UI. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub historical_tracking: bool, From f12f229fcebfd1ee92f242e45d238c13041d0f16 Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sat, 11 Jul 2026 12:21:09 +0700 Subject: [PATCH 2/3] Simplify provider presentation flow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src-tauri/src/commands/providers.rs | 19 +- .../src-tauri/src/commands/settings.rs | 28 -- apps/desktop-tauri/src-tauri/src/events.rs | 5 + .../src-tauri/src/tray_bridge.rs | 3 - apps/desktop-tauri/src/App.test.tsx | 1 + .../src/floatbar/FloatBar.test.tsx | 1 + .../src/hooks/useProviders.test.tsx | 21 + apps/desktop-tauri/src/hooks/useProviders.ts | 7 + .../src/surfaces/PopOutPanel.test.tsx | 1 + .../src/surfaces/TrayPanel.test.tsx | 1 + .../credentials/CodexUsageOptions.tsx | 4 +- .../surfaces/settings/tabs/AboutTab.test.tsx | 1 + .../settings/tabs/GeneralTab.test.tsx | 1 + apps/desktop-tauri/src/types/bridge.test.ts | 1 + apps/desktop-tauri/src/types/bridge.ts | 2 +- rust/src/providers/claude/cli_reset.rs | 351 ++++++++++++++++ rust/src/providers/claude/mod.rs | 381 ++---------------- 17 files changed, 423 insertions(+), 405 deletions(-) create mode 100644 rust/src/providers/claude/cli_reset.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index fa51ee30f3..ff0b1c8d93 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -268,23 +268,14 @@ async fn refresh_provider(app: tauri::AppHandle, id: ProviderId, ctx: FetchConte let snapshot = fetch_provider_snapshot(id, ctx).await; let state = app.state::>(); - if let Ok(mut guard) = state.lock() { + let snapshot = if let Ok(mut guard) = state.lock() { let snapshot = preserve_last_good_transient_failure(&mut guard, id, snapshot); upsert_provider_cache(&mut guard.provider_cache, snapshot.clone()); - let mut presented = snapshot; - super::filter_hidden_codex_spark_rows( - &mut presented, - Settings::load().codex_spark_usage_visible(), - ); - events::emit_provider_updated(&app, &presented); + snapshot } else { - let mut presented = snapshot; - super::filter_hidden_codex_spark_rows( - &mut presented, - Settings::load().codex_spark_usage_visible(), - ); - events::emit_provider_updated(&app, &presented); - } + snapshot + }; + events::emit_provider_updated(&app, &snapshot); } pub(super) fn preserve_last_good_transient_failure( diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 689089dde4..115a796297 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -82,10 +82,6 @@ impl SettingsUpdate { || self.ui_language.is_some() } - fn refreshes_provider_presentation(&self) -> bool { - self.codex_spark_usage_visible.is_some() - } - fn validate_shortcut_change( &self, app: &tauri::AppHandle, @@ -322,7 +318,6 @@ pub async fn update_settings( let clear_local_usage_cache = patch.codex_custom_sessions_dirs.is_some(); let rebuild_tray_menu = patch.rebuilds_tray_menu(); let refresh_tray_presentation = patch.refreshes_tray_presentation(); - let refresh_provider_presentation = patch.refreshes_provider_presentation(); let previous_language = settings.ui_language; patch.validate_shortcut_change(&app, &settings.global_shortcut)?; @@ -344,18 +339,6 @@ pub async fn update_settings( if refresh_tray_presentation { crate::tray_bridge::refresh_tray_presentation(&app); } - if refresh_provider_presentation && let Some(state) = app.try_state::>() { - let snapshots = state - .lock() - .map_err(|error| error.to_string())? - .provider_cache - .clone(); - for mut snapshot in snapshots { - filter_hidden_codex_spark_rows(&mut snapshot, settings.codex_spark_usage_visible()); - events::emit_provider_updated(&app, &snapshot); - } - } - // Notify other windows (PopOut dashboard, tray, float bar) so they re-read // settings live — e.g. the Display tab's window-scale slider takes effect // immediately instead of only after the PopOut is reopened. @@ -397,17 +380,6 @@ mod tests { ); } - #[test] - fn spark_visibility_change_refreshes_provider_presentation() { - assert!( - SettingsUpdate { - codex_spark_usage_visible: Some(false), - ..Default::default() - } - .refreshes_provider_presentation() - ); - } - #[test] fn apply_display_settings_clamps_window_scale_percent() { let mut settings = Settings::default(); diff --git a/apps/desktop-tauri/src-tauri/src/events.rs b/apps/desktop-tauri/src-tauri/src/events.rs index 64ea19db68..13bde5f6b6 100644 --- a/apps/desktop-tauri/src-tauri/src/events.rs +++ b/apps/desktop-tauri/src-tauri/src/events.rs @@ -58,6 +58,11 @@ pub fn emit_surface_mode_changed( } pub fn emit_provider_updated(app: &AppHandle, snapshot: &ProviderUsageSnapshot) { + let mut snapshot = snapshot.clone(); + crate::commands::filter_hidden_codex_spark_rows( + &mut snapshot, + codexbar::settings::Settings::load().codex_spark_usage_visible(), + ); let _ = app.emit(PROVIDER_UPDATED, snapshot); } diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 1c1e06e29c..74c34cc8b5 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -406,9 +406,6 @@ pub(crate) fn refresh_tray_presentation(app: &AppHandle) { .try_state::>() .map(|st| st.lock().unwrap().provider_cache.clone()) .unwrap_or_default(); - let snapshots = - presentation_snapshots(&snapshots, Settings::load().codex_spark_usage_visible()); - update_tray_status_items(app, &snapshots); update_tray_icon_and_tooltip(app, &snapshots); } diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 45dcade04c..68444ba103 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -94,6 +94,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: false, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 8a124be175..cfed699510 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -121,6 +121,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: false, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: true, diff --git a/apps/desktop-tauri/src/hooks/useProviders.test.tsx b/apps/desktop-tauri/src/hooks/useProviders.test.tsx index 176fba99f3..91395f58dd 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviders.test.tsx @@ -130,6 +130,27 @@ describe("useProviders", () => { ]); }); + it("reloads cached provider presentation when settings change", async () => { + const visible = provider("codex"); + visible.extraRateWindows = [ + { + id: "codex-spark", + title: "Spark", + window: visible.primary, + }, + ]; + tauriMocks.getCachedProviders.mockResolvedValueOnce([visible]); + + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await waitFor(() => expect(result.current.providers[0]?.extraRateWindows).toHaveLength(1)); + + tauriMocks.getCachedProviders.mockResolvedValueOnce([provider("codex")]); + act(() => emitProviderEvent("settings-changed", undefined)); + + await waitFor(() => expect(result.current.providers[0]?.extraRateWindows).toHaveLength(0)); + expect(tauriMocks.getCachedProviders).toHaveBeenCalledTimes(2); + }); + it("manual refresh uses forced refresh", async () => { const { result } = renderHook(() => useProviders()); diff --git a/apps/desktop-tauri/src/hooks/useProviders.ts b/apps/desktop-tauri/src/hooks/useProviders.ts index f255938ec5..55f7a19ec3 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.ts +++ b/apps/desktop-tauri/src/hooks/useProviders.ts @@ -135,6 +135,12 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes }, ); + const unlistenSettings = listen("settings-changed", () => { + getCachedProviders().then((cached) => { + if (!cancelled) mergeSnapshots(cached); + }); + }); + const unlistenStarted = listen("refresh-started", () => { if (!cancelled) { refreshingRef.current = true; @@ -193,6 +199,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes } pendingSnapshotsRef.current.clear(); unlistenUpdated.then((fn) => fn()); + unlistenSettings.then((fn) => fn()); unlistenStarted.then((fn) => fn()); unlistenComplete.then((fn) => fn()); }; diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 85975420f2..f51d1edf90 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -147,6 +147,7 @@ function settings(): SettingsSnapshot { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: false, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 0e8be18e98..1d8663efa1 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -133,6 +133,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: false, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexUsageOptions.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexUsageOptions.tsx index c193f4971b..e30662fb0b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexUsageOptions.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexUsageOptions.tsx @@ -16,7 +16,7 @@ export function CodexUsageOptions({ t }: Props) { getSettingsSnapshot() .then( (settings) => - !cancelled && setValue(settings.codexSparkUsageVisible ?? true), + !cancelled && setValue(settings.codexSparkUsageVisible), ) .catch((e) => !cancelled && setError(String(e))); return () => { @@ -28,7 +28,7 @@ export function CodexUsageOptions({ t }: Props) { setSaving(true); try { const updated = await updateSettings({ codexSparkUsageVisible: next }); - setValue(updated.codexSparkUsageVisible ?? true); + setValue(updated.codexSparkUsageVisible); } catch (e) { setError(String(e)); } finally { diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 698cc2552a..d6cd1bba04 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -69,6 +69,7 @@ const settings: SettingsSnapshot = { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: true, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 319c332dd5..9699f8083e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -52,6 +52,7 @@ const settings: SettingsSnapshot = { uiLanguage: "english", theme: "dark", claudeAvoidKeychainPrompts: true, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index 4c8c46adb1..bd8e5f4cdd 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -70,6 +70,7 @@ describe("Language type", () => { uiLanguage: "spanish", theme: "dark", claudeAvoidKeychainPrompts: true, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 2142b7c004..2179f5de49 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -200,7 +200,7 @@ export interface SettingsSnapshot { trayScalePercent: number; powertoysStatusPipeEnabled: boolean; claudeAvoidKeychainPrompts: boolean; - codexSparkUsageVisible?: boolean; + codexSparkUsageVisible: boolean; disableKeychainAccess: boolean; providerMetrics: Record; floatBarEnabled: boolean; diff --git a/rust/src/providers/claude/cli_reset.rs b/rust/src/providers/claude/cli_reset.rs new file mode 100644 index 0000000000..7d853df0b3 --- /dev/null +++ b/rust/src/providers/claude/cli_reset.rs @@ -0,0 +1,351 @@ +use std::str::FromStr; +use std::sync::OnceLock; + +use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; +use regex_lite::Regex; + +use crate::core::{NamedRateWindow, RateWindow}; + +fn regex(cell: &'static OnceLock, pattern: &'static str) -> &'static Regex { + cell.get_or_init(|| Regex::new(pattern).expect("valid Claude CLI regex")) +} + +pub(super) fn parse_percent_line(line: &str) -> Option { + static PERCENT: OnceLock = OnceLock::new(); + let captures = regex( + &PERCENT, + r"(?i)(\d{1,3}(?:\.\d+)?)\s*%\s*(used|spent|consumed|left|remaining|available)", + ) + .captures(line)?; + let value: f64 = captures.get(1)?.as_str().parse().ok()?; + match captures.get(2)?.as_str().to_ascii_lowercase().as_str() { + "left" | "remaining" | "available" => Some((100.0 - value).max(0.0)), + _ => Some(value.min(100.0)), + } +} + +pub(super) fn normalized_for_label_search(text: &str) -> String { + text.chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +pub(super) fn starts_next_usage_section(line: &str, current_label: &str) -> bool { + let normalized = normalized_for_label_search(line); + normalized.starts_with("current") && !normalized.contains(current_label) +} + +pub(super) fn extract_cli_scoped_weekly_limits( + text: &str, + now: DateTime, +) -> Vec { + static LABEL: OnceLock = OnceLock::new(); + static RESET: OnceLock = OnceLock::new(); + let label_re = regex(&LABEL, r"(?i)current\s*week\s*\(([^)]+)\)"); + let reset_re = regex(&RESET, r"(?i)resets.*$"); + let lines: Vec<&str> = text.lines().collect(); + let mut limits = Vec::new(); + + for (idx, line) in lines.iter().enumerate() { + let Some(label_match) = label_re.captures(line).and_then(|captures| captures.get(1)) else { + continue; + }; + let title = normalize_scoped_weekly_title(label_match.as_str()); + if title.is_empty() || normalized_for_label_search(&title) == "allmodels" { + continue; + } + let id = format!( + "claude-weekly-scoped-{}", + slug_claude_model(title.trim_end_matches(" only").trim()) + ); + if id == "claude-weekly-scoped-" + || limits.iter().any(|limit: &NamedRateWindow| limit.id == id) + { + continue; + } + + let mut used_percent = None; + let mut reset_description = None; + let current_label = normalized_for_label_search(line); + for (offset, section_line) in lines.iter().skip(idx).take(14).enumerate() { + if offset > 0 && starts_next_usage_section(section_line, ¤t_label) { + break; + } + used_percent = used_percent.or_else(|| parse_percent_line(section_line)); + reset_description = reset_description.or_else(|| { + reset_re + .find(section_line) + .map(|value| value.as_str().trim().to_string()) + }); + } + let Some(used_percent) = used_percent else { + continue; + }; + let resets_at = reset_description + .as_deref() + .and_then(|reset| parse_claude_reset_date(reset, now, Some(10080))); + limits.push(NamedRateWindow::new( + id, + title, + RateWindow::with_details(used_percent, Some(10080), resets_at, reset_description), + )); + } + + limits +} + +fn slug_claude_model(label: &str) -> String { + let mut slug = String::new(); + let mut previous_dash = false; + for character in label.chars() { + if character.is_ascii_alphanumeric() { + slug.push(character.to_ascii_lowercase()); + previous_dash = false; + } else if !slug.is_empty() && !previous_dash { + slug.push('-'); + previous_dash = true; + } + } + slug.trim_matches('-').to_string() +} + +fn normalize_scoped_weekly_title(label: &str) -> String { + static ONLY_SUFFIX: OnceLock = OnceLock::new(); + let label = label.trim(); + if let Some(suffix) = regex(&ONLY_SUFFIX, r"(?i)only$").find(label) { + let model = label[..suffix.start()].trim_end(); + if !model.is_empty() { + return format!("{model} only"); + } + } + label.to_string() +} + +pub(super) fn parse_claude_reset_date( + text: &str, + now: DateTime, + expected_window_minutes: Option, +) -> Option> { + let (raw, timezone) = normalize_claude_reset_text(text)?; + let components = parse_claude_reset_components(&raw)?; + let now_local = now.with_timezone(&timezone); + let candidates = match (components.year, components.month, components.day) { + (Some(year), Some(month), Some(day)) => local_reset_occurrences( + timezone, + year, + month, + day, + components.hour, + components.minute, + ), + (None, Some(month), Some(day)) => (now_local.year() - 8..=now_local.year() + 8) + .flat_map(|year| { + local_reset_occurrences( + timezone, + year, + month, + day, + components.hour, + components.minute, + ) + }) + .collect(), + (None, None, None) => (-1..=1) + .flat_map(|offset| { + let date = now_local.date_naive() + Duration::days(offset); + local_reset_occurrences( + timezone, + date.year(), + date.month(), + date.day(), + components.hour, + components.minute, + ) + }) + .collect(), + _ => return None, + }; + + resolve_claude_reset_occurrence(candidates, now, expected_window_minutes) +} + +fn normalize_claude_reset_text(text: &str) -> Option<(String, Tz)> { + static MONTH_BOUNDARY: OnceLock = OnceLock::new(); + static COMPACT_AT: OnceLock = OnceLock::new(); + let mut raw = text + .trim() + .strip_prefix("Resets") + .or_else(|| text.trim().strip_prefix("resets")) + .unwrap_or(text) + .trim() + .to_string(); + let timezone = raw + .rfind('(') + .filter(|_| raw.ends_with(')')) + .and_then(|start| { + let timezone = Tz::from_str(raw[start + 1..raw.len() - 1].trim()).ok(); + raw.truncate(start); + timezone + }) + .unwrap_or(chrono_tz::UTC); + raw = raw.replace(" at ", " "); + raw = regex(&MONTH_BOUNDARY, r"(?i)([a-z]{3})(\d)") + .replace(&raw, "$1 $2") + .into_owned(); + raw = regex(&COMPACT_AT, r"(?i)(\d)at(\d)") + .replace(&raw, "$1 $2") + .into_owned(); + (!raw.trim().is_empty()).then(|| (raw.trim().to_string(), timezone)) +} + +struct ClaudeResetComponents { + year: Option, + month: Option, + day: Option, + hour: u32, + minute: u32, +} + +fn parse_claude_reset_components(raw: &str) -> Option { + static DATE_TIME: OnceLock = OnceLock::new(); + static TIME: OnceLock = OnceLock::new(); + let date_time = regex( + &DATE_TIME, + r"(?i)^([a-z]{3})\s+(\d{1,2})(?:,\s*|\s+)(?:(\d{4})(?:,\s*|\s+))?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$", + ); + if let Some(captures) = date_time.captures(raw) { + let month = claude_month(captures.get(1)?.as_str())?; + let day = captures.get(2)?.as_str().parse().ok()?; + let year = captures + .get(3) + .and_then(|value| value.as_str().parse().ok()); + let (hour, minute) = parse_claude_hour( + captures.get(4)?.as_str(), + captures.get(5).map(|value| value.as_str()), + captures.get(6).map(|value| value.as_str()), + )?; + return Some(ClaudeResetComponents { + year, + month: Some(month), + day: Some(day), + hour, + minute, + }); + } + + let captures = regex(&TIME, r"(?i)^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$").captures(raw)?; + let (hour, minute) = parse_claude_hour( + captures.get(1)?.as_str(), + captures.get(2).map(|value| value.as_str()), + captures.get(3).map(|value| value.as_str()), + )?; + Some(ClaudeResetComponents { + year: None, + month: None, + day: None, + hour, + minute, + }) +} + +fn parse_claude_hour( + hour: &str, + minute: Option<&str>, + meridiem: Option<&str>, +) -> Option<(u32, u32)> { + let mut hour = hour.parse::().ok()?; + let minute = minute.unwrap_or("0").parse::().ok()?; + if minute > 59 { + return None; + } + match meridiem.map(str::to_ascii_lowercase).as_deref() { + Some("am") if (1..=12).contains(&hour) => { + if hour == 12 { + hour = 0; + } + } + Some("pm") if (1..=12).contains(&hour) => { + if hour != 12 { + hour += 12; + } + } + Some(_) => return None, + None if hour > 23 => return None, + None => {} + } + Some((hour, minute)) +} + +fn claude_month(month: &str) -> Option { + match month.to_ascii_lowercase().as_str() { + "jan" => Some(1), + "feb" => Some(2), + "mar" => Some(3), + "apr" => Some(4), + "may" => Some(5), + "jun" => Some(6), + "jul" => Some(7), + "aug" => Some(8), + "sep" => Some(9), + "oct" => Some(10), + "nov" => Some(11), + "dec" => Some(12), + _ => None, + } +} + +fn local_reset_occurrences( + timezone: Tz, + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, +) -> Vec> { + let Some(naive) = NaiveDate::from_ymd_opt(year, month, day) + .and_then(|date| date.and_hms_opt(hour, minute, 0)) + else { + return Vec::new(); + }; + match timezone.from_local_datetime(&naive) { + LocalResult::Single(value) => vec![value.with_timezone(&Utc)], + LocalResult::Ambiguous(first, second) => { + vec![first.with_timezone(&Utc), second.with_timezone(&Utc)] + } + LocalResult::None => Vec::new(), + } +} + +fn resolve_claude_reset_occurrence( + mut candidates: Vec>, + now: DateTime, + expected_window_minutes: Option, +) -> Option> { + candidates.sort_unstable(); + candidates.dedup(); + let future = candidates + .iter() + .copied() + .find(|candidate| *candidate >= now); + let fallback = candidates.last().copied(); + let future = future.or(fallback)?; + let Some(expected_window) = + expected_window_minutes.map(|minutes| Duration::minutes(minutes.into())) + else { + return Some(future); + }; + let past = candidates + .iter() + .copied() + .rev() + .find(|candidate| *candidate < now); + let past_is_plausible = past.is_some_and(|candidate| now - candidate <= expected_window); + let future_is_plausible = future - now <= expected_window; + if past_is_plausible && !future_is_plausible { + past + } else { + Some(future) + } +} diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 4527e040e9..7b43004c1a 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -1,26 +1,29 @@ //! Claude provider implementation mod admin_api; +mod cli_reset; mod oauth; mod web_api; use async_trait::async_trait; -use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveDate, TimeZone, Utc}; -use chrono_tz::Tz; +use chrono::Utc; use regex_lite::Regex; #[cfg(windows)] use std::os::windows::process::CommandExt; #[cfg(windows)] use std::process::{Command as StdCommand, Stdio}; -use std::str::FromStr; use crate::cli::tty_runner::{TtyCommandOptions, TtyCommandRunner}; use crate::core::{ - FetchContext, NamedRateWindow, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, }; use admin_api::ClaudeAdminApiFetcher; +use cli_reset::{ + extract_cli_scoped_weekly_limits, normalized_for_label_search, parse_claude_reset_date, + parse_percent_line, starts_next_usage_section, +}; pub use oauth::ClaudeOAuthFetcher; pub use web_api::ClaudeWebApiFetcher; @@ -780,32 +783,6 @@ fn extract_percent_near_label(text: &str, label: &str) -> Option { None } -/// Parse a line containing "X% used", "X% left", "X% remaining", etc. -/// Returns the percentage as used (converts "left" to used) -fn parse_percent_line(line: &str) -> Option { - // Match patterns like "45% used", "55% left", "55% remaining", or "12.5% available". - let re = - Regex::new(r"(\d{1,3}(?:\.\d+)?)\s*%\s*(used|spent|consumed|left|remaining|available)") - .ok()?; - - if let Some(caps) = re.captures(&line.to_lowercase()) - && let Some(value_match) = caps.get(1) - && let Some(kind_match) = caps.get(2) - { - let value: f64 = value_match.as_str().parse().ok()?; - let kind = kind_match.as_str(); - - // Convert to "used" percentage - if matches!(kind, "left" | "remaining" | "available") { - Some((100.0 - value).max(0.0)) - } else { - Some(value.min(100.0)) - } - } else { - None - } -} - /// Extract all percentages from text in order fn extract_all_percents(text: &str) -> Vec { let re = match Regex::new( @@ -835,332 +812,6 @@ fn extract_all_percents(text: &str) -> Vec { results } -fn normalized_for_label_search(text: &str) -> String { - text.chars() - .filter(|c| c.is_alphanumeric()) - .flat_map(|c| c.to_lowercase()) - .collect() -} - -fn starts_next_usage_section(line: &str, current_label: &str) -> bool { - let normalized = normalized_for_label_search(line); - normalized.starts_with("current") && !normalized.contains(current_label) -} - -fn extract_cli_scoped_weekly_limits(text: &str, now: DateTime) -> Vec { - let label_re = match Regex::new(r"(?i)current\s*week\s*\(([^)]+)\)") { - Ok(re) => re, - Err(_) => return Vec::new(), - }; - let lines: Vec<&str> = text.lines().collect(); - let mut limits = Vec::new(); - - for (idx, line) in lines.iter().enumerate() { - let Some(captures) = label_re.captures(line) else { - continue; - }; - let Some(label_match) = captures.get(1) else { - continue; - }; - let title = normalize_scoped_weekly_title(label_match.as_str()); - if title.is_empty() || normalized_for_label_search(&title) == "allmodels" { - continue; - } - let id = format!( - "claude-weekly-scoped-{}", - slug_claude_model(title.trim_end_matches(" only").trim()) - ); - if id == "claude-weekly-scoped-" - || limits.iter().any(|limit: &NamedRateWindow| limit.id == id) - { - continue; - } - - let mut used_percent = None; - let mut reset_description = None; - let current_label = normalized_for_label_search(line); - for (offset, section_line) in lines.iter().skip(idx).take(14).enumerate() { - if offset > 0 && starts_next_usage_section(section_line, ¤t_label) { - break; - } - if used_percent.is_none() { - used_percent = parse_percent_line(section_line); - } - if reset_description.is_none() { - let lower = section_line.to_lowercase(); - if let Some(position) = lower.find("resets") { - reset_description = Some(section_line[position..].trim().to_string()); - } - } - } - let Some(used_percent) = used_percent else { - continue; - }; - let resets_at = reset_description - .as_deref() - .and_then(|reset| parse_claude_reset_date(reset, now, Some(10080))); - limits.push(NamedRateWindow::new( - id, - title, - RateWindow::with_details(used_percent, Some(10080), resets_at, reset_description), - )); - } - - limits -} - -fn slug_claude_model(label: &str) -> String { - let mut slug = String::new(); - let mut previous_dash = false; - for character in label.chars() { - if character.is_ascii_alphanumeric() { - slug.push(character.to_ascii_lowercase()); - previous_dash = false; - } else if !slug.is_empty() && !previous_dash { - slug.push('-'); - previous_dash = true; - } - } - slug.trim_matches('-').to_string() -} - -fn normalize_scoped_weekly_title(label: &str) -> String { - let label = label.trim(); - if label.len() > 4 && label[label.len() - 4..].eq_ignore_ascii_case("only") { - let model = label[..label.len() - 4].trim_end(); - if !model.is_empty() { - return format!("{model} only"); - } - } - label.to_string() -} - -fn parse_claude_reset_date( - text: &str, - now: DateTime, - expected_window_minutes: Option, -) -> Option> { - let (raw, timezone) = normalize_claude_reset_text(text)?; - let components = parse_claude_reset_components(&raw)?; - let now_local = now.with_timezone(&timezone); - let candidates = match (components.year, components.month, components.day) { - (Some(year), Some(month), Some(day)) => local_reset_occurrences( - timezone, - year, - month, - day, - components.hour, - components.minute, - ), - (None, Some(month), Some(day)) => (now_local.year() - 8..=now_local.year() + 8) - .flat_map(|year| { - local_reset_occurrences( - timezone, - year, - month, - day, - components.hour, - components.minute, - ) - }) - .collect(), - (None, None, None) => (-1..=1) - .flat_map(|offset| { - let date = now_local.date_naive() + Duration::days(offset); - local_reset_occurrences( - timezone, - date.year(), - date.month(), - date.day(), - components.hour, - components.minute, - ) - }) - .collect(), - _ => return None, - }; - - resolve_claude_reset_occurrence(candidates, now, expected_window_minutes) -} - -fn normalize_claude_reset_text(text: &str) -> Option<(String, Tz)> { - let mut raw = text - .trim() - .strip_prefix("Resets") - .or_else(|| text.trim().strip_prefix("resets")) - .unwrap_or(text) - .trim() - .to_string(); - let timezone = raw - .rfind('(') - .filter(|_| raw.ends_with(')')) - .and_then(|start| { - let timezone = Tz::from_str(raw[start + 1..raw.len() - 1].trim()).ok(); - raw.truncate(start); - timezone - }) - .unwrap_or(chrono_tz::UTC); - raw = raw.replace(" at ", " "); - raw = Regex::new(r"(?i)([a-z]{3})(\d)") - .ok()? - .replace(&raw, "$1 $2") - .into_owned(); - raw = Regex::new(r"(?i)(\d)at(\d)") - .ok()? - .replace(&raw, "$1 $2") - .into_owned(); - (!raw.trim().is_empty()).then(|| (raw.trim().to_string(), timezone)) -} - -struct ClaudeResetComponents { - year: Option, - month: Option, - day: Option, - hour: u32, - minute: u32, -} - -fn parse_claude_reset_components(raw: &str) -> Option { - let date_time = Regex::new( - r"(?i)^([a-z]{3})\s+(\d{1,2})(?:,\s*|\s+)(?:(\d{4})(?:,\s*|\s+))?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$", - ) - .ok()?; - if let Some(captures) = date_time.captures(raw) { - let month = claude_month(captures.get(1)?.as_str())?; - let day = captures.get(2)?.as_str().parse().ok()?; - let year = captures - .get(3) - .and_then(|value| value.as_str().parse().ok()); - let hour = parse_claude_hour( - captures.get(4)?.as_str(), - captures.get(5).map(|value| value.as_str()), - captures.get(6).map(|value| value.as_str()), - )?; - return Some(ClaudeResetComponents { - year, - month: Some(month), - day: Some(day), - hour: hour.0, - minute: hour.1, - }); - } - - let time = Regex::new(r"(?i)^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$").ok()?; - let captures = time.captures(raw)?; - let (hour, minute) = parse_claude_hour( - captures.get(1)?.as_str(), - captures.get(2).map(|value| value.as_str()), - captures.get(3).map(|value| value.as_str()), - )?; - Some(ClaudeResetComponents { - year: None, - month: None, - day: None, - hour, - minute, - }) -} - -fn parse_claude_hour( - hour: &str, - minute: Option<&str>, - meridiem: Option<&str>, -) -> Option<(u32, u32)> { - let mut hour = hour.parse::().ok()?; - let minute = minute.unwrap_or("0").parse::().ok()?; - if minute > 59 { - return None; - } - match meridiem.map(str::to_ascii_lowercase).as_deref() { - Some("am") if (1..=12).contains(&hour) => { - if hour == 12 { - hour = 0; - } - } - Some("pm") if (1..=12).contains(&hour) => { - if hour != 12 { - hour += 12; - } - } - Some(_) => return None, - None if hour > 23 => return None, - None => {} - } - Some((hour, minute)) -} - -fn claude_month(month: &str) -> Option { - match month.to_ascii_lowercase().as_str() { - "jan" => Some(1), - "feb" => Some(2), - "mar" => Some(3), - "apr" => Some(4), - "may" => Some(5), - "jun" => Some(6), - "jul" => Some(7), - "aug" => Some(8), - "sep" => Some(9), - "oct" => Some(10), - "nov" => Some(11), - "dec" => Some(12), - _ => None, - } -} - -fn local_reset_occurrences( - timezone: Tz, - year: i32, - month: u32, - day: u32, - hour: u32, - minute: u32, -) -> Vec> { - let Some(naive) = NaiveDate::from_ymd_opt(year, month, day) - .and_then(|date| date.and_hms_opt(hour, minute, 0)) - else { - return Vec::new(); - }; - match timezone.from_local_datetime(&naive) { - LocalResult::Single(value) => vec![value.with_timezone(&Utc)], - LocalResult::Ambiguous(first, second) => { - vec![first.with_timezone(&Utc), second.with_timezone(&Utc)] - } - LocalResult::None => Vec::new(), - } -} - -fn resolve_claude_reset_occurrence( - mut candidates: Vec>, - now: DateTime, - expected_window_minutes: Option, -) -> Option> { - candidates.sort_unstable(); - candidates.dedup(); - let future = candidates - .iter() - .copied() - .find(|candidate| *candidate >= now); - let fallback = candidates.last().copied(); - let future = future.or(fallback)?; - let Some(expected_window) = - expected_window_minutes.map(|minutes| Duration::minutes(minutes.into())) - else { - return Some(future); - }; - let past = candidates - .iter() - .copied() - .rev() - .find(|candidate| *candidate < now); - let past_is_plausible = past.is_some_and(|candidate| now - candidate <= expected_window); - let future_is_plausible = future - now <= expected_window; - if past_is_plausible && !future_is_plausible { - past - } else { - Some(future) - } -} - fn is_exhausted_short_form(clean_lower: &str) -> bool { clean_lower.contains("out of extra usage") || clean_lower.contains("hit your limit") } @@ -1411,6 +1062,22 @@ Resets Apr 5, 2pm (America/Bogota) assert!(result.usage.model_specific.is_none()); } + #[test] + fn scoped_weekly_parser_handles_non_ascii_labels_and_reset_prefixes() { + let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); + let limits = extract_cli_scoped_weekly_limits( + "Current week (A€€)\n10% used\nİResets Apr 3 at 2pm (America/Bogota)", + now, + ); + + assert_eq!(limits.len(), 1); + assert_eq!(limits[0].title, "A€€"); + assert_eq!( + limits[0].window.resets_at, + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); + } + #[test] fn resolves_cli_reset_occurrences_in_the_reported_timezone() { let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); From adbd56ed69c4a636359d28e401390a1c0eeec045 Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sat, 11 Jul 2026 13:20:09 +0700 Subject: [PATCH 3/3] Fix provider final gate findings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + .../src/hooks/useProviders.test.tsx | 111 ++++++++++++++++++ apps/desktop-tauri/src/hooks/useProviders.ts | 37 +++++- rust/Cargo.toml | 1 + rust/src/providers/claude/cli_reset.rs | 19 ++- rust/src/providers/claude/mod.rs | 26 ++++ rust/src/providers/kimik2/mod.rs | 68 +++-------- 7 files changed, 201 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 190d5ee6de..9a13634079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,6 +668,7 @@ dependencies = [ "fluent-templates", "futures", "global-hotkey", + "iana-time-zone", "image", "keyring", "mockito", diff --git a/apps/desktop-tauri/src/hooks/useProviders.test.tsx b/apps/desktop-tauri/src/hooks/useProviders.test.tsx index 91395f58dd..81c1f9f336 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviders.test.tsx @@ -151,6 +151,117 @@ describe("useProviders", () => { expect(tauriMocks.getCachedProviders).toHaveBeenCalledTimes(2); }); + it("discards queued pre-change snapshots before reloading settings presentation", async () => { + vi.useFakeTimers(); + try { + tauriMocks.getCachedProviders.mockResolvedValueOnce([provider("codex")]); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await act(async () => {}); + + const visible = provider("codex", 30); + visible.extraRateWindows = [ + { id: "codex-spark", title: "Spark", window: visible.primary }, + ]; + tauriMocks.getCachedProviders.mockResolvedValueOnce([provider("codex", 40)]); + + await act(async () => { + emitProviderEvent("provider-updated", visible); + emitProviderEvent("settings-changed", undefined); + }); + await act(async () => { + vi.advanceTimersByTime(80); + }); + + expect(result.current.providers[0]?.primary.usedPercent).toBe(40); + expect(result.current.providers[0]?.extraRateWindows).toHaveLength(0); + + act(() => emitProviderEvent("provider-updated", provider("codex", 50))); + act(() => vi.advanceTimersByTime(80)); + expect(result.current.providers[0]?.primary.usedPercent).toBe(50); + } finally { + vi.useRealTimers(); + } + }); + + it("applies post-change events after a pending settings cache reload", async () => { + let resolveReload!: (snapshots: ProviderUsageSnapshot[]) => void; + tauriMocks.getCachedProviders + .mockResolvedValueOnce([provider("codex", 20)]) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveReload = resolve; + }), + ); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await waitFor(() => expect(result.current.providers[0]?.primary.usedPercent).toBe(20)); + + act(() => { + emitProviderEvent("settings-changed", undefined); + emitProviderEvent("provider-updated", provider("codex", 50)); + }); + await act(async () => { + resolveReload([provider("codex", 40)]); + }); + + expect(result.current.providers[0]?.primary.usedPercent).toBe(50); + }); + + it("ignores an initial cache response superseded by a settings reload", async () => { + let resolveInitial!: (snapshots: ProviderUsageSnapshot[]) => void; + tauriMocks.getCachedProviders + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + ) + .mockResolvedValueOnce([provider("codex", 40)]); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await waitFor(() => + expect(eventMocks.listeners.get("settings-changed")).toHaveLength(1), + ); + + await act(async () => { + emitProviderEvent("settings-changed", undefined); + emitProviderEvent("provider-updated", provider("codex", 50)); + }); + await act(async () => { + resolveInitial([provider("codex", 20)]); + }); + + expect(result.current.providers[0]?.primary.usedPercent).toBe(50); + }); + + it("does not leave event batching paused when an effect restarts during reload", async () => { + vi.useFakeTimers(); + try { + tauriMocks.getCachedProviders + .mockResolvedValueOnce([provider("codex", 20)]) + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce([provider("codex", 30)]); + const { result, rerender } = renderHook( + ({ delay }) => + useProviders({ + refreshOnMount: false, + initialRefreshDelayMs: delay, + }), + { initialProps: { delay: 0 } }, + ); + await act(async () => {}); + + act(() => emitProviderEvent("settings-changed", undefined)); + rerender({ delay: 1 }); + await act(async () => {}); + act(() => emitProviderEvent("provider-updated", provider("codex", 50))); + act(() => vi.advanceTimersByTime(80)); + + expect(result.current.providers[0]?.primary.usedPercent).toBe(50); + } finally { + vi.useRealTimers(); + } + }); + it("manual refresh uses forced refresh", async () => { const { result } = renderHook(() => useProviders()); diff --git a/apps/desktop-tauri/src/hooks/useProviders.ts b/apps/desktop-tauri/src/hooks/useProviders.ts index 55f7a19ec3..c4a8c06d14 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.ts +++ b/apps/desktop-tauri/src/hooks/useProviders.ts @@ -66,6 +66,8 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes const pendingSnapshotsRef = useRef>(new Map()); const flushTimerRef = useRef(undefined); const resetRefreshTimerRef = useRef(undefined); + const settingsReloadEpochRef = useRef(0); + const settingsReloadingRef = useRef(false); const mergeSnapshots = useCallback((snapshots: ProviderUsageSnapshot[]) => { if (snapshots.length === 0) return; @@ -97,7 +99,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes const queueSnapshot = useCallback((snapshot: ProviderUsageSnapshot) => { pendingSnapshotsRef.current.set(snapshot.providerId, snapshot); - if (flushTimerRef.current !== undefined) return; + if (settingsReloadingRef.current || flushTimerRef.current !== undefined) return; flushTimerRef.current = window.setTimeout(flushPendingSnapshots, 80); }, [flushPendingSnapshots]); @@ -115,9 +117,14 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes let cancelled = false; // Load existing cache first. + const initialEpoch = settingsReloadEpochRef.current; getCachedProviders() .then((cached) => { - if (!cancelled && cached.length > 0) { + if ( + !cancelled && + initialEpoch === settingsReloadEpochRef.current && + cached.length > 0 + ) { mergeSnapshots(cached); } }) @@ -136,9 +143,25 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes ); const unlistenSettings = listen("settings-changed", () => { - getCachedProviders().then((cached) => { - if (!cancelled) mergeSnapshots(cached); - }); + const epoch = ++settingsReloadEpochRef.current; + settingsReloadingRef.current = true; + if (flushTimerRef.current !== undefined) { + window.clearTimeout(flushTimerRef.current); + flushTimerRef.current = undefined; + } + pendingSnapshotsRef.current.clear(); + getCachedProviders() + .then((cached) => { + if (!cancelled && epoch === settingsReloadEpochRef.current) { + mergeSnapshots(cached); + } + }) + .finally(() => { + if (!cancelled && epoch === settingsReloadEpochRef.current) { + settingsReloadingRef.current = false; + flushPendingSnapshots(); + } + }); }); const unlistenStarted = listen("refresh-started", () => { @@ -152,7 +175,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes "refresh-complete", (event) => { if (!cancelled) { - flushPendingSnapshots(); + if (!settingsReloadingRef.current) flushPendingSnapshots(); refreshingRef.current = false; setIsRefreshing(false); setLastRefresh(event.payload); @@ -186,6 +209,8 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes return () => { cancelled = true; + settingsReloadEpochRef.current += 1; + settingsReloadingRef.current = false; if (initialRefreshTimer !== undefined) { window.clearTimeout(initialRefreshTimer); } diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 433d7c6579..596e075ee0 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -41,6 +41,7 @@ anyhow = "1" # Date/time chrono = { version = "0.4", features = ["serde"] } chrono-tz = "0.10" +iana-time-zone = "0.1" # Async utilities async-trait = "0.1" diff --git a/rust/src/providers/claude/cli_reset.rs b/rust/src/providers/claude/cli_reset.rs index 7d853df0b3..4026265434 100644 --- a/rust/src/providers/claude/cli_reset.rs +++ b/rust/src/providers/claude/cli_reset.rs @@ -128,7 +128,20 @@ pub(super) fn parse_claude_reset_date( now: DateTime, expected_window_minutes: Option, ) -> Option> { - let (raw, timezone) = normalize_claude_reset_text(text)?; + let system_timezone = iana_time_zone::get_timezone() + .ok() + .and_then(|name| Tz::from_str(&name).ok()) + .unwrap_or(chrono_tz::UTC); + parse_claude_reset_date_in_system_zone(text, now, expected_window_minutes, system_timezone) +} + +pub(super) fn parse_claude_reset_date_in_system_zone( + text: &str, + now: DateTime, + expected_window_minutes: Option, + system_timezone: Tz, +) -> Option> { + let (raw, timezone) = normalize_claude_reset_text(text, system_timezone)?; let components = parse_claude_reset_components(&raw)?; let now_local = now.with_timezone(&timezone); let candidates = match (components.year, components.month, components.day) { @@ -171,7 +184,7 @@ pub(super) fn parse_claude_reset_date( resolve_claude_reset_occurrence(candidates, now, expected_window_minutes) } -fn normalize_claude_reset_text(text: &str) -> Option<(String, Tz)> { +fn normalize_claude_reset_text(text: &str, system_timezone: Tz) -> Option<(String, Tz)> { static MONTH_BOUNDARY: OnceLock = OnceLock::new(); static COMPACT_AT: OnceLock = OnceLock::new(); let mut raw = text @@ -189,7 +202,7 @@ fn normalize_claude_reset_text(text: &str) -> Option<(String, Tz)> { raw.truncate(start); timezone }) - .unwrap_or(chrono_tz::UTC); + .unwrap_or(system_timezone); raw = raw.replace(" at ", " "); raw = regex(&MONTH_BOUNDARY, r"(?i)([a-z]{3})(\d)") .replace(&raw, "$1 $2") diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 7b43004c1a..83e25f0a03 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -20,6 +20,8 @@ use crate::core::{ }; use admin_api::ClaudeAdminApiFetcher; +#[cfg(test)] +use cli_reset::parse_claude_reset_date_in_system_zone; use cli_reset::{ extract_cli_scoped_weekly_limits, normalized_for_label_search, parse_claude_reset_date, parse_percent_line, starts_next_usage_section, @@ -1100,6 +1102,30 @@ Resets Apr 5, 2pm (America/Bogota) ); } + #[test] + fn timezone_less_resets_use_the_supplied_system_zone() { + let now = "2026-03-07T18:00:00Z".parse::>().unwrap(); + + assert_eq!( + parse_claude_reset_date_in_system_zone( + "Resets Mar 8 at 3:30am", + now, + None, + "America/New_York".parse().unwrap(), + ), + Some("2026-03-08T07:30:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date_in_system_zone( + "Resets Mar 8 at 3:30am (America/Los_Angeles)", + now, + None, + "America/New_York".parse().unwrap(), + ), + Some("2026-03-08T10:30:00Z".parse().unwrap()) + ); + } + #[test] fn parses_compact_usage_screen() { let provider = ClaudeProvider::new(); diff --git a/rust/src/providers/kimik2/mod.rs b/rust/src/providers/kimik2/mod.rs index cd583981c0..50bef07fc4 100755 --- a/rust/src/providers/kimik2/mod.rs +++ b/rust/src/providers/kimik2/mod.rs @@ -30,7 +30,7 @@ impl KimiK2Provider { supports_credits: true, default_enabled: false, is_primary: false, - dashboard_url: Some("https://kimrel.com/my-credits"), + dashboard_url: Some("https://platform.moonshot.ai/console/account"), status_page_url: None, }, } @@ -39,23 +39,23 @@ impl KimiK2Provider { /// Get API key from environment or config fn get_api_key(api_key: Option<&str>) -> Option { if let Some(key) = api_key - && !key.trim().is_empty() + && !key.is_empty() { - return Some(key.trim().to_string()); + return Some(key.to_string()); } // Check environment variable first if let Ok(key) = std::env::var("MOONSHOT_API_KEY") - && !key.trim().is_empty() + && !key.is_empty() { - return Some(key.trim().to_string()); + return Some(key); } // Check KIMI_API_KEY if let Ok(key) = std::env::var("KIMI_API_KEY") - && !key.trim().is_empty() + && !key.is_empty() { - return Some(key.trim().to_string()); + return Some(key); } // Check config file @@ -65,29 +65,14 @@ impl KimiK2Provider { && let Ok(content) = std::fs::read_to_string(&config_file) && let Ok(json) = serde_json::from_str::(&content) && let Some(key) = json.get("api_key").and_then(|v| v.as_str()) - && !key.trim().is_empty() { - return Some(key.trim().to_string()); + return Some(key.to_string()); } } None } - fn missing_api_key_error() -> ProviderError { - ProviderError::Other( - "Kimi K2 API key is missing or blank. Set it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." - .to_string(), - ) - } - - fn rejected_api_key_error() -> ProviderError { - ProviderError::Other( - "Kimi K2 API key was rejected or expired. Update it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." - .to_string(), - ) - } - fn api_bases_from_region(region: Option<&str>) -> &'static [&'static str] { match region.unwrap_or_default().trim().to_lowercase().as_str() { "cn" | "china" => &[KIMIK2_API_BASE_CHINA], @@ -98,8 +83,12 @@ impl KimiK2Provider { /// Fetch usage via Moonshot API async fn fetch_via_api(&self, ctx: &FetchContext) -> Result { - let api_key = - Self::get_api_key(ctx.api_key.as_deref()).ok_or_else(Self::missing_api_key_error)?; + let api_key = Self::get_api_key(ctx.api_key.as_deref()).ok_or_else(|| { + ProviderError::NotInstalled( + "Moonshot API key not found. Set it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." + .to_string(), + ) + })?; let client = crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) @@ -136,7 +125,7 @@ impl KimiK2Provider { } if auth_error { - Err(Self::rejected_api_key_error()) + Err(ProviderError::AuthRequired) } else { Err(ProviderError::Other( "Moonshot API endpoint not configured".to_string(), @@ -285,18 +274,8 @@ impl Provider for KimiK2Provider { #[cfg(test)] mod tests { - use crate::core::Provider; - use super::KimiK2Provider; - #[test] - fn kimi_uses_legacy_credits_dashboard() { - assert_eq!( - KimiK2Provider::new().metadata().dashboard_url, - Some("https://kimrel.com/my-credits") - ); - } - #[test] fn explicit_api_key_overrides_environment_lookup() { assert_eq!( @@ -305,23 +284,6 @@ mod tests { ); } - #[test] - fn blank_explicit_api_key_is_not_accepted() { - assert_eq!(KimiK2Provider::get_api_key(Some(" \t ")), None); - } - - #[test] - fn kimi_key_errors_distinguish_missing_and_rejected_keys() { - assert_eq!( - KimiK2Provider::missing_api_key_error().to_string(), - "Kimi K2 API key is missing or blank. Set it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." - ); - assert_eq!( - KimiK2Provider::rejected_api_key_error().to_string(), - "Kimi K2 API key was rejected or expired. Update it in Preferences → Providers, MOONSHOT_API_KEY, or KIMI_API_KEY." - ); - } - #[test] fn kimi_api_region_default_tries_both_regions() { assert_eq!(