Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ menu-bar layout.
### Fixed
- Kimi: when the Kimi Code CLI credential is stale or rejected and web auth has no token to try, direct the user to run `kimi` or add a Kimi Code API key in Settings, keeping the web fallback and leaving CLI-owned credentials read-only. A rejected web token still reports the web error, and a Kimi Code API 403 reports a permission or quota denial instead of a sign-in problem.
- Kimi Code: while the monthly membership pool is exhausted, show the shorter Code windows as “Blocked by monthly limit” without their own usage bar, reset, or pace forecast, keeping the raw usage in the CLI, tray icon, and float bar; the block lifts at the pool's reset even before the next refresh.
- Kimi Code: accept ratio-pool Code API responses that report only some windows, keep an unreported weekly quota absent instead of showing 0%, and show the monthly Total usage reported by the Code API without browser authentication.
- Claude: when Hide Personal Info is enabled, keep saved account rows distinguishable with stable localized `Account N` labels and matching redacted tooltips.

---
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop-tauri/src-tauri/src/usage_metric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,38 @@ mod tests {
);
}

#[test]
fn kimi_monthly_only_snapshot_selects_the_total_usage_lane() {
// Upstream 0.60.5 #3694: a Code API response may report only the
// monthly Total usage pool. The weekly lane is then an informational
// placeholder, and every metric preference lands on the monthly lane.
let mut snapshot = snapshot();
snapshot.provider_id = "kimi".to_string();
snapshot.primary = RateWindowSnapshot {
is_informational: true,
..window(0.0)
};
snapshot.secondary = None;
snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot {
id: "kimi-monthly".to_string(),
title: "Total usage".to_string(),
window: window(100.0),
fallback_lane: false,
}];

for preference in [
MetricPreference::Automatic,
MetricPreference::Session,
MetricPreference::Weekly,
] {
let mut settings = Settings::default();
settings.set_provider_metric(ProviderId::Kimi, preference);
let selected = selected_usage_window(&snapshot, &settings);
assert!(!selected.is_informational, "{preference:?}");
assert_eq!(selected.used_percent, 100.0, "{preference:?}");
}
}

#[test]
fn opencodego_automatic_prefers_explicitly_exhausted_window_over_higher_percentage() {
let mut snapshot = snapshot();
Expand Down
6 changes: 6 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ z.ai Coding Plans accept both `TOKENS_LIMIT` and `CREDIT_LIMIT` rows. The shorte

Upstream's independent **WidgetKit** provider-widget configuration has no Windows analogue in this repository. Win-CodexBar has no WidgetKit extension; provider cards and tray entries are already independent Windows/Tauri surfaces.

### Kimi Code quotas

Kimi Code API responses can carry the older count-based fields, the newer `usages` ratio pools, or both. Ratio pools take precedence for the 5-hour, weekly, and monthly Total usage windows they provide; an invalid ratio falls back to that lane's legacy counts instead of hiding them. Missing windows stay absent: an unreported weekly quota shows as an informational "No weekly quota reported" row rather than 0%, and percentages keep the API's precision without implying request counts. The monthly Total usage pool comes straight from the Code API, without browser authentication, and optional web enrichment only fills it in when the API did not report one. A response without any supported window is a parse error, so Auto mode moves on to the Kimi Code CLI credential and web auth.

Weekly is the primary lane and the 5-hour rate limit the secondary on every Kimi source. When the monthly pool is the only reported quota, Automatic and the Session or Weekly preferences use it for the tray icon and float bar. An exhausted monthly pool blocks the shorter Code windows until its reset.

## Upstream doc warning

Upstream `docs/providers.md` is a large auto-strategy matrix (60+ providers) for the macOS app. Use it as **inspiration** when porting a provider. For runtime truth on Windows:
Expand Down
5 changes: 3 additions & 2 deletions rust/src/core/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -889,8 +889,9 @@ pub trait Provider: Send + Sync {

/// Whether Automatic metric selection is a dead end when the primary lane
/// is informational and no secondary lane exists. Providers with
/// Automatic-only fallback lanes (seat credits) override this to `false`
/// so the fallback lane can still fill in.
/// Automatic-only fallback lanes (seat credits) or a named extra lane that
/// can be the only reported quota (Kimi's monthly pool) override this to
/// `false` so that lane can still fill in.
fn automatic_metric_missing_core_is_terminal(&self) -> bool {
true
}
Expand Down
168 changes: 102 additions & 66 deletions rust/src/providers/kimi/code_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ use reqwest::Url;
use std::path::{Path, PathBuf};

use super::{
FetchContext, KimiCodeApiUsageResponse, KimiProvider, KimiRegion, ProviderError, UsageSnapshot,
ascii_header_value, cleaned_env, cleaned_owned, kimi_window_minutes,
FetchContext, KimiCodeApiUsageResponse, KimiProvider, KimiRegion, MONTHLY_WINDOW_ID,
ProviderError, RateWindow, UsageSnapshot, ascii_header_value, cleaned_env, cleaned_owned,
kimi_window_minutes,
};
use super::{ratio_pool, web};

Expand All @@ -21,6 +22,12 @@ const KIMI_OAUTH_HOST_ENV: &str = "KIMI_OAUTH_HOST";
const KIMI_CODE_CLI_PLATFORM: &str = "kimi_code_cli";
/// CLI access tokens must remain valid for at least this long to be reused.
const KIMI_CODE_CREDENTIAL_MIN_TTL_SECS: f64 = 60.0;
const SESSION_WINDOW_MINUTES: u32 = 5 * 60;
const WEEKLY_WINDOW_MINUTES: u32 = 7 * 24 * 60;
/// Monthly sentinel shared with the web `Total usage` lane.
const MONTHLY_WINDOW_MINUTES: u32 = 30 * 24 * 60;
/// Placeholder text for a Code API response that reports no weekly quota.
pub(super) const MISSING_WEEKLY_DESCRIPTION: &str = "No weekly quota reported";

#[derive(Debug, serde::Deserialize)]
struct KimiCodeCredentialFile {
Expand Down Expand Up @@ -117,77 +124,77 @@ fn code_api_status_error(status: reqwest::StatusCode) -> ProviderError {
}
}

/// Upstream `KimiUsageSnapshot.toUsageSnapshot` (0.60.5 #3694): weekly is the
/// primary lane and the 5-hour rate limit the secondary, as on the web path.
/// Each ratio pool takes precedence over the legacy counters of its lane; an
/// absent or invalid pool falls back to those counters. The monthly pool is
/// the `Total usage` extra lane. Missing lanes are not invented: an absent
/// weekly quota stays an informational primary, and a response without any
/// supported window is a parse error.
pub(super) fn snapshot_from_code_api_response(
response: KimiCodeApiUsageResponse,
) -> Result<UsageSnapshot, ProviderError> {
let pools_present = response.usages.is_some();
let pools = response.usages.as_ref();
let legacy_limit = response.limits.as_ref().and_then(|limits| limits.first());
let legacy_session_minutes =
let legacy_rate_limit_minutes =
legacy_limit.and_then(|limit| limit.window.as_ref().and_then(kimi_window_minutes));
let session_pool = response
.usages
.as_ref()
.and_then(|pools| pools.session.as_ref())
let weekly = pools
.and_then(|pools| pools.weekly.as_ref())
.and_then(|pool| {
ratio_pool::resolved_ratio_window(
&response,
pool,
legacy_limit.map(|limit| &limit.detail),
300,
legacy_session_minutes,
response.usage.as_ref(),
WEEKLY_WINDOW_MINUTES,
Some(WEEKLY_WINDOW_MINUTES),
)
})
.or_else(|| {
response.usage.as_ref().and_then(|detail| {
KimiProvider::rate_window_from_usage_detail(detail, Some(WEEKLY_WINDOW_MINUTES))
.ok()
})
});
let weekly_pool = response
.usages
.as_ref()
.and_then(|pools| pools.weekly.as_ref())
let rate_limit = pools
.and_then(|pools| pools.session.as_ref())
.and_then(|pool| {
ratio_pool::resolved_ratio_window(
&response,
pool,
response.usage.as_ref(),
10_080,
Some(10_080),
legacy_limit.map(|limit| &limit.detail),
SESSION_WINDOW_MINUTES,
legacy_rate_limit_minutes,
)
})
.or_else(|| {
legacy_limit.and_then(|limit| {
KimiProvider::rate_window_from_usage_detail(
&limit.detail,
legacy_rate_limit_minutes,
)
.ok()
})
});
let monthly_pool = response
.usages
.as_ref()
let monthly = pools
.and_then(|pools| pools.monthly.as_ref())
.and_then(|pool| pool.rate_window(43_200));
let primary = if pools_present {
session_pool.ok_or_else(|| {
ProviderError::Parse("Kimi Code API returned an unusable session quota pool".into())
})?
} else {
response
.usage
.as_ref()
.and_then(|detail| {
KimiProvider::rate_window_from_usage_detail(detail, Some(10_080)).ok()
})
.ok_or_else(|| {
ProviderError::Parse("Kimi Code API has no usable quota window".into())
})?
};
.and_then(|pool| pool.rate_window(MONTHLY_WINDOW_MINUTES));
if weekly.is_none() && rate_limit.is_none() && monthly.is_none() {
return Err(ProviderError::Parse(
"No supported quota windows in Code usage response".into(),
));
}

let primary = weekly.unwrap_or_else(|| RateWindow::informational(MISSING_WEEKLY_DESCRIPTION));
let mut usage = UsageSnapshot::new(primary).with_login_method(
response
.plan_name()
.unwrap_or_else(|| "Code API".to_string()),
);
if let Some(weekly) = weekly_pool {
usage = usage.with_secondary(weekly);
} else if !pools_present
&& let Some(limit) = response.limits.unwrap_or_default().into_iter().next()
{
let window_minutes = limit.window.as_ref().and_then(kimi_window_minutes);
usage = usage.with_secondary(KimiProvider::rate_window_from_usage_detail(
&limit.detail,
window_minutes,
)?);
}
if let Some(monthly) = monthly_pool {
usage = usage.with_tertiary(monthly);
if let Some(rate_limit) = rate_limit {
usage = usage.with_secondary(rate_limit);
}
if let Some(monthly) = monthly {
usage = usage.with_extra_rate_window(MONTHLY_WINDOW_ID, "Total usage", monthly);
}
Ok(usage)
}
Expand Down Expand Up @@ -697,6 +704,7 @@ mod tests {
fn credential_freshness_requires_sixty_second_margin() {
assert!((KIMI_CODE_CREDENTIAL_MIN_TTL_SECS - 60.0).abs() < f64::EPSILON);
}

#[test]
fn ratio_pools_preserve_unknown_weekly_and_explicit_monthly_zero() {
let response: KimiCodeApiUsageResponse = serde_json::from_value(json!({
Expand All @@ -712,42 +720,70 @@ mod tests {
}))
.expect("ratio-pool fixture parses");
let snapshot = snapshot_from_code_api_response(response).expect("ratio pools are usable");
assert_eq!(snapshot.primary.window_minutes, Some(300));
assert_eq!(snapshot.primary.used_percent, 25.0);
assert!(
snapshot.secondary.is_none(),
snapshot.primary.is_informational,
"missing weekly pool stays unknown"
);
let monthly = snapshot.tertiary.expect("explicit monthly zero is known");
assert_eq!(monthly.window_minutes, Some(43_200));
assert_eq!(monthly.used_percent, 0.0);
assert!(monthly.usage_known);
assert_eq!(
snapshot.primary.reset_description.as_deref(),
Some(MISSING_WEEKLY_DESCRIPTION)
);
let rate_limit = snapshot
.secondary
.expect("session pool is the rate-limit lane");
assert_eq!(rate_limit.window_minutes, Some(300));
assert_eq!(rate_limit.used_percent, 25.0);
assert!(snapshot.tertiary.is_none());
let [monthly] = snapshot.extra_rate_windows.as_slice() else {
panic!("explicit monthly zero is the Total usage lane");
};
assert_eq!(monthly.id, MONTHLY_WINDOW_ID);
assert_eq!(monthly.window.window_minutes, Some(43_200));
assert_eq!(monthly.window.used_percent, 0.0);
assert!(monthly.window.usage_known);
}

#[test]
fn ratio_pools_do_not_invent_zero_for_missing_or_invalid_primary() {
fn invalid_ratio_pools_without_counters_fail_to_parse() {
for fixture in [
json!({ "usages": { "limit_5h": {} } }),
json!({ "usages": { "limit_5h": { "used_ratio": -0.1 } } }),
json!({
"usages": {
"limit_7d": { "used_ratio": "abc" },
"limit_month_total": { "used_ratio": null }
}
}),
] {
let response: KimiCodeApiUsageResponse =
serde_json::from_value(fixture).expect("fixture parses");
assert!(snapshot_from_code_api_response(response).is_err());
assert!(matches!(
snapshot_from_code_api_response(response),
Err(ProviderError::Parse(message))
if message == "No supported quota windows in Code usage response"
));
}
}

// Upstream 0.60.5 #3694 replaces the earlier rule that an unusable
// session pool fails the whole response: each lane falls back to its own
// legacy counters, and lanes without any source stay absent.
#[test]
fn unusable_explicit_session_pool_does_not_fall_back_to_legacy_usage() {
fn invalid_session_pool_keeps_the_legacy_weekly_counters() {
let response: KimiCodeApiUsageResponse = serde_json::from_value(json!({
"usages": { "limit_5h": { "used_ratio": -0.1 } },
"usage": { "limit": "100", "used": "20" }
}))
.expect("fixture parses");

assert!(matches!(
snapshot_from_code_api_response(response),
Err(ProviderError::Parse(message))
if message.contains("unusable session quota pool")
));
let snapshot = snapshot_from_code_api_response(response).expect("weekly counters");
assert!(!snapshot.primary.is_informational);
assert_eq!(snapshot.primary.used_percent, 20.0);
assert_eq!(snapshot.primary.window_minutes, Some(10_080));
assert_eq!(
snapshot.primary.reset_description.as_deref(),
Some("20/100 credits")
);
assert!(snapshot.secondary.is_none());
assert!(snapshot.extra_rate_windows.is_empty());
}
}
21 changes: 18 additions & 3 deletions rust/src/providers/kimi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ impl Provider for KimiProvider {
fn supports_oauth(&self) -> bool {
true
}

/// A Code API response may report only the monthly `Total usage` pool
/// (upstream 0.60.5 #3694). Automatic then reads that lane instead of the
/// informational weekly placeholder.
fn automatic_metric_missing_core_is_terminal(&self) -> bool {
false
}
}

fn kimi_window_minutes(window: &KimiWindow) -> Option<u32> {
Expand Down Expand Up @@ -433,7 +440,14 @@ fn apply_subscription_windows(
// Upstream 0.49.0 #2741: the membership pool is the official "Total usage"
// lane — the shared subscription pool (`amountUsedRatio`), not the
// Code-only ratio. Feature-scoped or non-subscription balances are skipped.
if let Some(balance) = subscription.subscription_balance.as_ref()
// Upstream 0.60.5 #3694: a monthly pool reported by the Code API stays
// authoritative; web enrichment only fills in a missing one.
let has_monthly_pool = usage
.extra_rate_windows
.iter()
.any(|extra| extra.id == MONTHLY_WINDOW_ID);
if !has_monthly_pool
&& let Some(balance) = subscription.subscription_balance.as_ref()
&& matches!(balance.feature.as_deref(), None | Some("FEATURE_OMNI"))
&& matches!(balance.balance_type.as_deref(), None | Some("SUBSCRIPTION"))
&& let Some(ratio) =
Expand Down Expand Up @@ -476,9 +490,10 @@ fn apply_subscription_windows(
/// Upstream `isEquivalentToWeeklyWindow` (#2741): suppress the Code 7-day row
/// only on positive evidence — the weekly counter must be reliable (window
/// minutes present), the percentages must agree within 1 point, and both lanes
/// need reset timestamps within 5 minutes of each other.
/// need reset timestamps within 5 minutes of each other. An absent weekly
/// quota (informational primary) is never equivalent.
fn is_equivalent_to_weekly_window(window: &RateWindow, weekly: &RateWindow) -> bool {
if weekly.window_minutes.is_none() {
if weekly.is_informational || weekly.window_minutes.is_none() {
return false;
}
if (window.used_percent - weekly.used_percent).abs() > 1.0 {
Expand Down
Loading