diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index ad5dfad7617..d6f75fad0b7 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -466,6 +466,16 @@ { "screenshot": "settings-vercel-team-id-error-cleared" } ] }, + { + "name": "flyout-kimi-bonus-credits", + "query": "window=flyout&fixture=kimi-bonus-credits&theme=dark", + "size": [310, 1032], + "steps": [ + { "expectText": ["Kimi", "Total usage", "Bonus credits", "Invite to Earn Credit", "75% left", "Expires Dec 31, 2026", "Welcome Credit", "40% left"] }, + { "expectNoText": ["Reservation Bonus", "Expired grant"] }, + { "screenshot": "flyout-kimi-bonus-credits", "clip": "panel" } + ] + }, { "name": "settings-gemini-usage-source", "query": "window=settings&tab=providers:gemini&fixture=default&theme=dark", diff --git a/apps/desktop-tauri/src/proof/fixtures/common.ts b/apps/desktop-tauri/src/proof/fixtures/common.ts index 96fc1d9d7e6..edf08a505d1 100644 --- a/apps/desktop-tauri/src/proof/fixtures/common.ts +++ b/apps/desktop-tauri/src/proof/fixtures/common.ts @@ -98,6 +98,7 @@ export const PROOF_PROVIDER_CATALOG: ProviderCatalogEntry[] = [ { id: "mistral", displayName: "Mistral", cookieDomain: null }, { id: "openaiapi", displayName: "OpenAI API", cookieDomain: null }, { id: "vercel", displayName: "Vercel", cookieDomain: null }, + { id: "kimi", displayName: "Kimi", cookieDomain: "kimi.com" }, ]; export function catalogOrder(): string[] { diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index 18c59befa10..57a8f1e5de9 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -52,6 +52,7 @@ export const PROOF_SCENARIO_NAMES = [ "cookie-no-browsers", "vercel", "vercel-team-id-invalid", + "kimi-bonus-credits", ] as const; export type ProofScenarioName = (typeof PROOF_SCENARIO_NAMES)[number]; @@ -284,6 +285,52 @@ function vercelSnapshot(now: Date): ProviderUsageSnapshot { ); } +/** + * Lapsed Kimi plan (upstream #4700): no Code pool, so the weekly lane is + * informational. The membership "Total usage" pool and two live gift pools + * (upstream #4691) remain. Expired and malformed gifts never reach the fixture. + */ +function kimiSnapshot(now: Date): ProviderUsageSnapshot { + return providerSnapshot( + { + providerId: "kimi", + displayName: "Kimi", + primary: { ...rateWindow(now, 0, null, null), isInformational: true, resetDescription: "No weekly quota reported" }, + primaryLabel: "Weekly", + updatedAt: isoBefore(now, 1), + }, + { + extraRateWindows: [ + { + id: "kimi-monthly", + title: "Total usage", + window: rateWindow(now, 36, MONTH_MINUTES, 14 * DAY_MINUTES), + }, + ], + accountEmail: null, + sourceLabel: "web", + displayDetails: [ + { + id: "kimi-gift-0", + sectionTitle: "Bonus credits", + title: "Invite to Earn Credit", + value: "75% left", + secondaryValue: "Expires Dec 31, 2026 at 11:59 PM", + progress: { used: 0.25, total: 1 }, + }, + { + id: "kimi-gift-1", + sectionTitle: "Bonus credits", + title: "Welcome Credit", + value: "40% left", + secondaryValue: "Expires Jan 15, 2027 at 9:00 AM", + progress: { used: 0.6, total: 1 }, + }, + ], + }, + ); +} + function codexAccount( now: Date, id: string, @@ -1041,6 +1088,19 @@ function cookieNoBrowsersScenario(now: Date): ProofScenario { }; } +/** Kimi on a lapsed plan, showing the "Bonus credits" rows beside the membership pool. */ +function kimiBonusCreditsScenario(now: Date): ProofScenario { + return { + name: "kimi-bonus-credits", + settings: baseSettings({ enabledProviders: ["kimi"] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [kimiSnapshot(now)], + codexAccounts: emptyCodexAccounts(), + claudeAccounts: [], + localSpend: [], + }; +} + const BUILDERS: Record ProofScenario> = { default: defaultScenario, "two-codex-accounts": twoCodexAccountsScenario, @@ -1067,6 +1127,7 @@ const BUILDERS: Record ProofScenario> = { "cookie-no-browsers": cookieNoBrowsersScenario, vercel: vercelScenario, "vercel-team-id-invalid": vercelTeamIdInvalidScenario, + "kimi-bonus-credits": kimiBonusCreditsScenario, }; export function isProofScenarioName(value: string): value is ProofScenarioName { diff --git a/rust/src/providers/kimi/auto.rs b/rust/src/providers/kimi/auto.rs index 1654659c05e..9dd1a8a68bf 100644 --- a/rust/src/providers/kimi/auto.rs +++ b/rust/src/providers/kimi/auto.rs @@ -9,7 +9,7 @@ use super::code_api::{KimiCliCredential, kimi_cli_credential_error}; use super::web::WebFetchFailure; -use crate::core::{ProviderError, ProviderFetchResult, UsageSnapshot}; +use crate::core::{ProviderError, ProviderFetchResult}; pub(super) async fn fetch_cli_then_web( cli_credential: KimiCliCredential, @@ -18,9 +18,9 @@ pub(super) async fn fetch_cli_then_web( ) -> Result where C: FnOnce(String) -> CF, - CF: Future>, + CF: Future>, W: FnOnce() -> WF, - WF: Future>, + WF: Future>, { let cli_failure = match cli_credential { KimiCliCredential::Unavailable => None, @@ -29,7 +29,7 @@ where Some(kimi_cli_credential_error()) } KimiCliCredential::Fresh(token) => match fetch_cli(token).await { - Ok(usage) => return Ok(ProviderFetchResult::new(usage, "code-cli")), + Ok(fetched) => return Ok(labeled(fetched, "code-cli")), Err(error) => { tracing::debug!( error = %error, @@ -41,7 +41,7 @@ where }; match fetch_web().await { - Ok(usage) => Ok(ProviderFetchResult::new(usage, "web")), + Ok(fetched) => Ok(labeled(fetched, "web")), Err(failure) => match cli_failure { Some(cli_error) if !failure.had_token => Err(cli_error), _ => Err(failure.error), @@ -49,6 +49,13 @@ where } } +/// Stamps the Auto source that produced `fetched`. The usage and its display +/// rows are kept exactly as the source returned them. +fn labeled(mut fetched: ProviderFetchResult, source_label: &str) -> ProviderFetchResult { + fetched.source_label = source_label.to_string(); + fetched +} + /// Upstream `normalizedCodeAPIError`: a CLI token the Code API rejects (401) /// is reported as the renewal guidance; any other failure keeps its error. fn cli_attempt_error(error: ProviderError) -> ProviderError { @@ -63,23 +70,23 @@ mod tests { use std::cell::{Cell, RefCell}; use super::*; - use crate::core::RateWindow; + use crate::core::{RateWindow, UsageSnapshot}; const OFF_SOURCE_ERROR: &str = "Kimi cookie source is Off; provide a manual cookie header or enable browser import."; - fn usage(percent: f64) -> UsageSnapshot { - UsageSnapshot::new(RateWindow::new(percent)) + fn usage(percent: f64) -> ProviderFetchResult { + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(percent)), "test") } fn web_failure( error: ProviderError, had_token: bool, - ) -> Result { + ) -> Result { Err(WebFetchFailure { error, had_token }) } - fn web_without_token() -> Result { + fn web_without_token() -> Result { web_failure(ProviderError::Other(OFF_SOURCE_ERROR.into()), false) } @@ -105,8 +112,8 @@ mod tests { /// Runs the Auto CLI-then-web order with scripted sources. async fn run( credential: KimiCliCredential, - cli: Result, - web: Result, + cli: Result, + web: Result, ) -> Outcome { let cli_tokens = RefCell::new(Vec::new()); let web_ran = Cell::new(false); diff --git a/rust/src/providers/kimi/code_api.rs b/rust/src/providers/kimi/code_api.rs index 19d9be0a261..d6ff2dc39f5 100644 --- a/rust/src/providers/kimi/code_api.rs +++ b/rust/src/providers/kimi/code_api.rs @@ -11,9 +11,10 @@ use std::path::{Path, PathBuf}; use super::{ FetchContext, KimiCodeApiUsageResponse, KimiProvider, KimiRegion, MONTHLY_WINDOW_ID, ProviderError, RateWindow, UsageSnapshot, ascii_header_value, cleaned_env, cleaned_owned, - kimi_window_minutes, + gift_display_details, kimi_window_minutes, }; use super::{ratio_pool, web}; +use crate::core::ProviderFetchResult; use crate::providers::json; const KIMI_CODE_API_KEY_ENV: &str = "KIMI_CODE_API_KEY"; @@ -46,14 +47,15 @@ struct KimiCodeCredentialFile { /// Fetch usage via the Kimi Code API; optionally enrich the snapshot with the /// web membership pool (upstream #2622). Enrichment failures degrade silently -/// to the un-enriched snapshot. +/// to the un-enriched snapshot. The gift "Bonus credits" rows (upstream +/// #4691) come from the same membership statistics as the enrichment. pub(crate) async fn fetch_via_code_api( ctx: &FetchContext, region: KimiRegion, api_key_override: Option<&str>, identity_headers_override: Option<&[(&str, String)]>, login_method: &str, -) -> Result { +) -> Result { let api_key = code_api_key(api_key_override.or(ctx.api_key.as_deref()))?; let base_url = code_api_base_url(region)?; let endpoint = code_api_usage_endpoint(&base_url)?; @@ -82,6 +84,7 @@ pub(crate) async fn fetch_via_code_api( let has_plan_name = plan_name.is_some(); let mut snapshot = snapshot_from_code_api_response(json)?; snapshot.login_method = Some(plan_name.unwrap_or_else(|| login_method.to_string())); + let mut details = Vec::new(); // Upstream #2622: enrich Code API + CLI usage with the monthly membership // pool from a signed-in Kimi Desktop (or browser/manual) session. A @@ -92,6 +95,7 @@ pub(crate) async fn fetch_via_code_api( { Ok(subscription) => { if let Some(subscription) = subscription { + details = gift_display_details(&subscription, snapshot.updated_at); snapshot = super::apply_subscription_windows(snapshot, &subscription); } if !has_plan_name @@ -110,7 +114,7 @@ pub(crate) async fn fetch_via_code_api( } } - Ok(snapshot) + Ok(ProviderFetchResult::new(snapshot, "api").with_display_details(details)) } /// Upstream `KimiUsageFetcher.codeAPIError`: only 401 means the API key or diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index 4050d2ee74e..af4925eaeee 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -39,16 +39,17 @@ pub use region::KimiRegion; use credential::KimiWebCredential; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Local, Utc}; use reqwest::Client; use serde::Deserialize; use std::time::{SystemTime, UNIX_EPOCH}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, - UsageSnapshot, + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::json; +use crate::settings::Language; /// Extra-window id of the monthly membership pool (`Total usage`). pub const MONTHLY_WINDOW_ID: &str = "kimi-monthly"; @@ -150,7 +151,10 @@ impl KimiCodeApiUsageResponse { #[derive(Debug, Deserialize)] struct KimiWebUsageResponse { - usages: Vec, + /// Upstream #4700: a lapsed plan can answer without any Code pools (for + /// example `{"totalQuota": ...}`), so the list is optional. + #[serde(default)] + usages: Option>, } #[derive(Debug, Deserialize)] @@ -164,13 +168,30 @@ struct KimiUsage { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KimiSubscriptionStatsResponse { - subscription_balance: Option, + subscription_balance: Option, ratelimit_code7d: Option, + /// Promotional gift pools (upstream #4691). Kept as raw JSON so a + /// malformed gift list never rejects the subscription statistics; see + /// [`KimiSubscriptionStatsResponse::gift_balances`]. + #[serde(default)] + gift_balances: Option, } +impl KimiSubscriptionStatsResponse { + /// Upstream `giftBalances` is decoded with `try?`: a list that does not + /// decode as a whole yields no gifts, and the subscription pools survive. + fn gift_balances(&self) -> Vec { + self.gift_balances + .as_ref() + .and_then(|value| Vec::::deserialize(value).ok()) + .unwrap_or_default() + } +} + +/// A subscription or gift credit pool (upstream `KimiCreditBalance`). #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct KimiSubscriptionBalance { +struct KimiCreditBalance { amount_used_ratio: Option, expire_time: Option, /// Pool scoping (upstream 0.49.0 #2741): only the omni/subscription pool @@ -179,6 +200,10 @@ struct KimiSubscriptionBalance { feature: Option, #[serde(default, rename = "type")] balance_type: Option, + /// Server-provided gift name. Read leniently: an unusable name falls back + /// to the generic "Bonus credits" label instead of dropping the gift. + #[serde(default)] + display_name: Option, } #[derive(Debug, Deserialize)] @@ -349,8 +374,8 @@ impl Provider for KimiProvider { SourceMode::Auto => { if code_api::code_api_key(ctx.api_key.as_deref()).is_ok() { match code_api::fetch_via_code_api(ctx, region, None, None, "Code API").await { - Ok(usage) => { - return Ok(ProviderFetchResult::new(usage, "code-api")); + Ok(fetched) => { + return Ok(fetched); } Err(err) => { tracing::debug!( @@ -391,18 +416,15 @@ impl Provider for KimiProvider { .await } SourceMode::OAuth => { - let usage = - code_api::fetch_via_code_api(ctx, region, None, None, "Code API").await?; - Ok(ProviderFetchResult::new(usage, "code-api")) + code_api::fetch_via_code_api(ctx, region, None, None, "Code API").await } SourceMode::Web => { - let usage = web::fetch_via_web( + web::fetch_via_web( ctx.manual_cookie_header.as_deref(), region, ctx.token_account_isolated, ) - .await?; - Ok(ProviderFetchResult::new(usage, "web")) + .await } SourceMode::Cli => Err(ProviderError::UnsupportedSource(SourceMode::Cli)), } @@ -517,6 +539,16 @@ fn is_equivalent_to_weekly_window(window: &RateWindow, weekly: &RateWindow) -> b } } +/// Upstream `KimiUsageFetcher.webLanguage` (#4691): Kimi serves gift names in +/// Chinese for unsupported languages, so only the Chinese UI asks for `zh-CN` +/// and every other UI language keeps English names. +fn web_language(language: Language) -> &'static str { + match language { + Language::Chinese | Language::ChineseTraditional => "zh-CN", + _ => "en-US", + } +} + async fn kimi_web_post( client: &Client, url: &str, @@ -536,6 +568,7 @@ fn kimi_web_request( token: &str, body: serde_json::Value, ) -> Result { + let language = web_language(crate::locale::current_language()); client .post(url) .bearer_auth(token) @@ -544,6 +577,7 @@ fn kimi_web_request( .header("Content-Type", "application/json") .header("Origin", region.web_base_url()) .header("Referer", region.console_url()) + .header("x-language", language) .header( "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", @@ -553,6 +587,62 @@ fn kimi_web_request( .map_err(ProviderError::from) } +/// Heading of the gift rows (upstream `KimiUsageSnapshot.giftDetails`). +const GIFT_SECTION_TITLE: &str = "Bonus credits"; + +/// Upstream `KimiUsageSnapshot.giftDetails` (#4691): promotional gift pools as +/// one "Bonus credits" section with one row per live gift: remaining share, +/// progress and expiry. Gifts that are not omni gift pools, carry an unusable +/// ratio, or expired at or before `updated_at` are skipped. The rows are +/// independent of the monthly quota, so they stay visible when it is spent. +fn gift_display_details( + subscription: &KimiSubscriptionStatsResponse, + updated_at: DateTime, +) -> Vec { + subscription + .gift_balances() + .iter() + .enumerate() + .filter_map(|(index, gift)| { + if !matches!(gift.feature.as_deref(), None | Some("FEATURE_OMNI")) + || !matches!(gift.balance_type.as_deref(), None | Some("GIFT")) + { + return None; + } + let used = json::lenient_f64(gift.amount_used_ratio.as_ref()) + .filter(|ratio| ratio.is_finite() && *ratio >= 0.0)?; + let expires_at = gift.expire_time.as_ref().and_then(parse_kimi_timestamp); + if expires_at.is_some_and(|expires_at| expires_at <= updated_at) { + return None; + } + let title = gift + .display_name + .as_ref() + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|name| ProviderDisplayDetail::is_valid_title(name)) + .unwrap_or(GIFT_SECTION_TITLE); + let remaining = ((1.0 - used) * 100.0).clamp(0.0, 100.0); + let row = ProviderDisplayDetail::new( + format!("kimi-gift-{index}"), + title, + format!("{remaining:.0}% left"), + )? + .with_section_title(GIFT_SECTION_TITLE)? + .with_progress(used, 1.0)?; + match expires_at { + Some(expires_at) => row.with_secondary_value(format!( + "Expires {}", + expires_at + .with_timezone(&Local) + .format("%b %-d, %Y at %-I:%M %p") + )), + None => Some(row), + } + }) + .collect() +} + fn parse_kimi_timestamp(value: &serde_json::Value) -> Option> { match value { serde_json::Value::String(text) => parse_kimi_timestamp_str(text), @@ -900,4 +990,220 @@ mod tests { assert_eq!(kimi_window_minutes(&window), None); } + + /// Upstream #4700: a lapsed plan answers with `totalQuota` and no `usages`. + fn lapsed_usage() -> Value { + json!({ "totalQuota": { "limit": "100", "used": "36", "remaining": "64" } }) + } + + fn lapsed_membership_stats() -> Value { + json!({ + "subscriptionBalance": { + "feature": "FEATURE_OMNI", + "type": "SUBSCRIPTION", + "amountUsedRatio": 0, + "expireTime": "2026-11-22T00:00:00Z" + } + }) + } + + #[test] + fn lapsed_web_usage_decodes_without_code_pools() { + let response: KimiWebUsageResponse = serde_json::from_value(lapsed_usage()).unwrap(); + assert!(response.usages.is_none()); + } + + #[test] + fn lapsed_web_session_keeps_membership_balance_without_code_pools() { + // Upstream #4700: membership pools supply usage; no invented Code bars. + let snapshot = web_snapshot(lapsed_usage(), lapsed_membership_stats()); + + assert!(snapshot.primary.is_informational); + assert!(snapshot.secondary.is_none()); + let monthly = snapshot + .extra_rate_windows + .iter() + .find(|window| window.id == MONTHLY_WINDOW_ID) + .unwrap(); + assert!((monthly.window.used_percent - 0.0).abs() < f64::EPSILON); + assert_eq!(monthly.window.window_minutes, Some(30 * 24 * 60)); + } + + #[test] + fn lapsed_web_session_without_membership_balances_keeps_scope_error() { + // Upstream #4700: neither Code usage nor a membership quota is an error. + let usage: KimiWebUsageResponse = serde_json::from_value(lapsed_usage()).unwrap(); + let subscription: KimiSubscriptionStatsResponse = + serde_json::from_value(json!({})).unwrap(); + + let error = web::snapshot_from_web_usage_response(usage, Some(subscription)).unwrap_err(); + assert!(matches!( + error, + ProviderError::Parse(ref message) + if message == "FEATURE_CODING scope not found in response" + )); + } + + fn gift_stats() -> KimiSubscriptionStatsResponse { + // Synthetic pools shaped like upstream #4691's fixture. Expired, + // ratio-less, non-omni and expired-at-update gifts must not show. + serde_json::from_value(json!({ + "subscriptionBalance": { + "feature": "FEATURE_OMNI", + "type": "SUBSCRIPTION", + "amountUsedRatio": 1, + "expireTime": "2026-10-22T00:00:00Z" + }, + "giftBalances": [ + { + "feature": "FEATURE_OMNI", + "type": "GIFT", + "amountUsedRatio": 1, + "expireTime": "2026-10-23T00:00:00Z", + "displayName": "Reservation Bonus" + }, + { + "feature": "FEATURE_OMNI", + "type": "GIFT", + "amountUsedRatio": 0.2524, + "expireTime": "2026-12-31T15:59:59Z", + "displayName": "Invite to Earn Credit" + }, + { + "feature": "FEATURE_OMNI", + "type": "GIFT", + "amountUsedRatio": 0, + "expireTime": "2026-10-20T00:00:00Z", + "displayName": "Expired grant" + }, + { + "feature": "FEATURE_OMNI", + "type": "GIFT", + "amountUsedRatio": 0, + "expireTime": "2026-10-21T15:00:00Z", + "displayName": "Expires at update" + }, + { + "feature": "FEATURE_CODING", + "type": "GIFT", + "amountUsedRatio": 0.1 + }, + { + "feature": "FEATURE_OMNI", + "type": "GIFT", + "displayName": "No ratio" + } + ] + })) + .unwrap() + } + + fn fixture_updated_at() -> DateTime { + DateTime::parse_from_rfc3339("2026-10-21T15:00:00Z") + .unwrap() + .with_timezone(&Utc) + } + + #[test] + fn gift_rows_show_live_gifts_with_remaining_share_progress_and_expiry() { + // Upstream #4691: one "Bonus credits" row per live gift. + let rows = gift_display_details(&gift_stats(), fixture_updated_at()); + + let titles: Vec<&str> = rows.iter().map(ProviderDisplayDetail::title).collect(); + assert_eq!(titles, ["Reservation Bonus", "Invite to Earn Credit"]); + assert!( + rows.iter() + .all(|row| row.section_title() == Some("Bonus credits")) + ); + + let exhausted = &rows[0]; + assert_eq!(exhausted.value(), "0% left"); + assert!(exhausted.secondary_value().unwrap().starts_with("Expires ")); + + let live = &rows[1]; + assert_eq!(live.value(), "75% left"); + let progress = live.progress().unwrap(); + assert!((progress.used() - 0.2524).abs() < f64::EPSILON); + assert!((progress.total() - 1.0).abs() < f64::EPSILON); + assert!(live.secondary_value().unwrap().starts_with("Expires ")); + } + + #[test] + fn malformed_gift_list_keeps_subscription_usage() { + // Upstream #4691: a malformed optional gift list never hides monthly usage. + let usage: KimiWebUsageResponse = serde_json::from_value(json!({ + "usages": [{ + "scope": "FEATURE_CODING", + "detail": { "limit": "100", "used": "25" } + }] + })) + .unwrap(); + let subscription: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ + "subscriptionBalance": { "amountUsedRatio": 0.5 }, + "giftBalances": { "unexpected": true } + })) + .unwrap(); + + assert!(gift_display_details(&subscription, fixture_updated_at()).is_empty()); + let snapshot = web::snapshot_from_web_usage_response(usage, Some(subscription)).unwrap(); + let monthly = snapshot + .extra_rate_windows + .iter() + .find(|window| window.id == MONTHLY_WINDOW_ID) + .unwrap(); + assert!((monthly.window.used_percent - 50.0).abs() < f64::EPSILON); + } + + #[test] + fn gift_balances_are_not_quota_windows() { + // Gift pools are display rows only; they never add an extra quota lane. + let usage: KimiWebUsageResponse = serde_json::from_value(json!({ + "usages": [{ + "scope": "FEATURE_CODING", + "detail": { "limit": "100", "used": "25" } + }] + })) + .unwrap(); + let snapshot = web::snapshot_from_web_usage_response(usage, Some(gift_stats())).unwrap(); + + assert!(!has_extra(&snapshot, "kimi-gift-0")); + assert!( + !snapshot + .extra_rate_windows + .iter() + .any(|window| window.title == "Invite to Earn Credit") + ); + } + + #[test] + fn web_language_requests_chinese_gift_names_only_for_chinese_ui() { + // Upstream #4691 `KimiWebLanguageTests`: only Chinese UI asks for zh-CN. + assert_eq!(web_language(Language::Chinese), "zh-CN"); + assert_eq!(web_language(Language::ChineseTraditional), "zh-CN"); + assert_eq!(web_language(Language::English), "en-US"); + assert_eq!(web_language(Language::Japanese), "en-US"); + assert_eq!(web_language(Language::Korean), "en-US"); + } + + #[test] + fn web_requests_send_the_x_language_header() { + // The header is always one of the two upstream web languages. + let client = Client::new(); + let request = kimi_web_request( + &client, + "https://www.kimi.com/example", + KimiRegion::China, + "fixture-token", + json!({}), + ) + .unwrap(); + + let language = request + .headers() + .get("x-language") + .and_then(|value| value.to_str().ok()) + .unwrap(); + assert!(matches!(language, "zh-CN" | "en-US"), "{language}"); + assert_eq!(language, web_language(crate::locale::current_language())); + } } diff --git a/rust/src/providers/kimi/web.rs b/rust/src/providers/kimi/web.rs index caf216f753e..44003dc89ef 100644 --- a/rust/src/providers/kimi/web.rs +++ b/rust/src/providers/kimi/web.rs @@ -13,16 +13,17 @@ use reqwest::Client; +use super::code_api::MISSING_WEEKLY_DESCRIPTION; use super::credential::KimiWebCredential; use super::desktop_session::desktop_credential; use super::local_storage::local_storage_tokens; use super::{ KIMI_SUBSCRIPTION_SERVICE, KIMI_SUBSCRIPTION_STATS_SERVICE, KIMI_WEB_USAGE_SERVICE, KimiProvider, KimiRegion, KimiSubscriptionResponse, KimiSubscriptionStatsResponse, - KimiWebUsageResponse, apply_subscription_windows, kimi_web_post, + KimiWebUsageResponse, apply_subscription_windows, gift_display_details, kimi_web_post, }; use crate::browser::cookies::get_cookie_header; -use crate::core::{ProviderError, ProviderId, UsageSnapshot}; +use crate::core::{ProviderError, ProviderFetchResult, ProviderId, RateWindow, UsageSnapshot}; /// Persisted Kimi cookie-source value ("manual" default; matches the /// `claude`/settings convention of a fresh read at fetch time). @@ -74,9 +75,7 @@ impl WebFetchFailure { /// One automatic token attempt. `None` means the server rejected the token /// and the chain moves on; anything else ends it. -fn settle_attempt( - result: Result, -) -> Option> { +fn settle_attempt(result: Result) -> Option> { match result { Ok(usage) => Some(Ok(usage)), Err(ProviderError::AuthRequired) => None, @@ -191,7 +190,7 @@ pub(crate) async fn fetch_via_web( cookie_header: Option<&str>, region: KimiRegion, account_isolated: bool, -) -> Result { +) -> Result { fetch_web_session_isolated(cookie_header, region, account_isolated) .await .map_err(|failure| failure.error) @@ -205,7 +204,7 @@ pub(super) async fn fetch_web_session_isolated( cookie_header: Option<&str>, region: KimiRegion, account_isolated: bool, -) -> Result { +) -> Result { // An explicitly selected token account is an identity boundary: fetch only // its own session cookie and never fall back to another ambient credential // or browser account (upstream per-provider token account routing). @@ -224,7 +223,7 @@ pub(super) async fn fetch_web_session_isolated( pub(super) async fn fetch_web_session( cookie_header: Option<&str>, region: KimiRegion, -) -> Result { +) -> Result { let source = cookie_source(); let input = WebTokenInput { manual_header: cookie_header, @@ -251,13 +250,13 @@ pub(super) async fn fetch_web_session( /// is tried, then browser import. Only a server rejection moves on to the /// next automatic token; a Desktop session that changed before dispatch is a /// non-auth error and stops the chain. -async fn fetch_with_web_tokens( +async fn fetch_with_web_tokens( input: &WebTokenInput<'_>, mut fetch: F, -) -> Result +) -> Result where F: FnMut(KimiWebCredential) -> Fut, - Fut: Future>, + Fut: Future>, { if let Some(token) = input .manual_header @@ -340,7 +339,7 @@ async fn fetch_via_web_token( client: &reqwest::Client, credential: &KimiWebCredential, region: KimiRegion, -) -> Result { +) -> Result { let usage_url = region.web_api_url(KIMI_WEB_USAGE_SERVICE); let resp = kimi_web_post( client, @@ -398,36 +397,56 @@ pub(super) fn snapshot_from_web_usage_response( subscription: Option, ) -> Result { snapshot_from_web_usage_response_with_plan(response, subscription, None) + .map(|fetched| fetched.usage) } fn snapshot_from_web_usage_response_with_plan( response: KimiWebUsageResponse, subscription: Option, plan_name: Option, -) -> Result { +) -> Result { let coding = response .usages + .unwrap_or_default() .into_iter() - .find(|usage| usage.scope == "FEATURE_CODING") - .ok_or_else(|| ProviderError::Parse("Kimi FEATURE_CODING usage missing".into()))?; - let primary = KimiProvider::rate_window_from_usage_detail(&coding.detail, Some(10080))?; - let mut usage = UsageSnapshot::new(primary).with_login_method("Kimi"); - - if let Some(limit) = coding.limits.unwrap_or_default().into_iter().next() { - let window_minutes = limit.window.as_ref().and_then(super::kimi_window_minutes); - let rate_limit = - KimiProvider::rate_window_from_usage_detail(&limit.detail, window_minutes)?; - usage = usage.with_secondary(rate_limit); - } + .find(|usage| usage.scope == "FEATURE_CODING"); + let has_coding = coding.is_some(); + let mut usage = match coding { + Some(coding) => { + let primary = KimiProvider::rate_window_from_usage_detail(&coding.detail, Some(10080))?; + let mut usage = UsageSnapshot::new(primary).with_login_method("Kimi"); + + if let Some(limit) = coding.limits.unwrap_or_default().into_iter().next() { + let window_minutes = limit.window.as_ref().and_then(super::kimi_window_minutes); + let rate_limit = + KimiProvider::rate_window_from_usage_detail(&limit.detail, window_minutes)?; + usage = usage.with_secondary(rate_limit); + } + usage + } + // Upstream #4700: a lapsed plan can omit its Code pools while the + // membership pools below still show usage. + None => UsageSnapshot::new(RateWindow::informational(MISSING_WEEKLY_DESCRIPTION)) + .with_login_method("Kimi"), + }; + let mut details = Vec::new(); if let Some(subscription) = subscription.as_ref() { + details = gift_display_details(subscription, usage.updated_at); usage = apply_subscription_windows(usage, subscription); } if let Some(plan_name) = plan_name { usage = usage.with_login_method(plan_name); } - Ok(usage) + // Without Code pools, the membership pools must supply at least one lane. + if !has_coding && usage.extra_rate_windows.is_empty() { + return Err(ProviderError::Parse( + "FEATURE_CODING scope not found in response".into(), + )); + } + + Ok(ProviderFetchResult::new(usage, "web").with_display_details(details)) } pub(super) async fn fetch_subscription_plan( @@ -1031,4 +1050,47 @@ mod tests { .unwrap(); assert_eq!(inactive.plan_name(), None); } + + #[test] + fn lapsed_web_result_carries_gift_rows_and_membership_pool() { + // Upstream #4700 + #4691: a lapsed plan keeps the membership pool and + // the live gift rows in the web result, with the web source label. + let usage: KimiWebUsageResponse = serde_json::from_value(serde_json::json!({ + "totalQuota": { "limit": "100", "used": "36" } + })) + .unwrap(); + let subscription: KimiSubscriptionStatsResponse = + serde_json::from_value(serde_json::json!({ + "subscriptionBalance": { + "feature": "FEATURE_OMNI", + "type": "SUBSCRIPTION", + "amountUsedRatio": 0.36, + "expireTime": "2026-11-22T00:00:00Z" + }, + "giftBalances": [{ + "feature": "FEATURE_OMNI", + "type": "GIFT", + "amountUsedRatio": 0.6, + "expireTime": "2099-12-31T15:59:59Z", + "displayName": "Welcome Credit" + }] + })) + .unwrap(); + + let fetched = + snapshot_from_web_usage_response_with_plan(usage, Some(subscription), None).unwrap(); + + assert_eq!(fetched.source_label, "web"); + assert!(fetched.usage.primary.is_informational); + assert!( + fetched + .usage + .extra_rate_windows + .iter() + .any(|window| window.id == "kimi-monthly") + ); + assert_eq!(fetched.display_details.len(), 1); + assert_eq!(fetched.display_details[0].title(), "Welcome Credit"); + assert_eq!(fetched.display_details[0].value(), "40% left"); + } }