Skip to content
Merged
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
10 changes: 10 additions & 0 deletions apps/desktop-tauri/scripts/proof-scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/proof/fixtures/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop-tauri/src/proof/fixtures/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ProofScenarioName, (now: Date) => ProofScenario> = {
default: defaultScenario,
"two-codex-accounts": twoCodexAccountsScenario,
Expand All @@ -1067,6 +1127,7 @@ const BUILDERS: Record<ProofScenarioName, (now: Date) => ProofScenario> = {
"cookie-no-browsers": cookieNoBrowsersScenario,
vercel: vercelScenario,
"vercel-team-id-invalid": vercelTeamIdInvalidScenario,
"kimi-bonus-credits": kimiBonusCreditsScenario,
};

export function isProofScenarioName(value: string): value is ProofScenarioName {
Expand Down
31 changes: 19 additions & 12 deletions rust/src/providers/kimi/auto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<C, CF, W, WF>(
cli_credential: KimiCliCredential,
Expand All @@ -18,9 +18,9 @@ pub(super) async fn fetch_cli_then_web<C, CF, W, WF>(
) -> Result<ProviderFetchResult, ProviderError>
where
C: FnOnce(String) -> CF,
CF: Future<Output = Result<UsageSnapshot, ProviderError>>,
CF: Future<Output = Result<ProviderFetchResult, ProviderError>>,
W: FnOnce() -> WF,
WF: Future<Output = Result<UsageSnapshot, WebFetchFailure>>,
WF: Future<Output = Result<ProviderFetchResult, WebFetchFailure>>,
{
let cli_failure = match cli_credential {
KimiCliCredential::Unavailable => None,
Expand All @@ -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,
Expand All @@ -41,14 +41,21 @@ 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),
},
}
}

/// 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 {
Expand All @@ -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<UsageSnapshot, WebFetchFailure> {
) -> Result<ProviderFetchResult, WebFetchFailure> {
Err(WebFetchFailure { error, had_token })
}

fn web_without_token() -> Result<UsageSnapshot, WebFetchFailure> {
fn web_without_token() -> Result<ProviderFetchResult, WebFetchFailure> {
web_failure(ProviderError::Other(OFF_SOURCE_ERROR.into()), false)
}

Expand All @@ -105,8 +112,8 @@ mod tests {
/// Runs the Auto CLI-then-web order with scripted sources.
async fn run(
credential: KimiCliCredential,
cli: Result<UsageSnapshot, ProviderError>,
web: Result<UsageSnapshot, WebFetchFailure>,
cli: Result<ProviderFetchResult, ProviderError>,
web: Result<ProviderFetchResult, WebFetchFailure>,
) -> Outcome {
let cli_tokens = RefCell::new(Vec::new());
let web_ran = Cell::new(false);
Expand Down
12 changes: 8 additions & 4 deletions rust/src/providers/kimi/code_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<UsageSnapshot, ProviderError> {
) -> Result<ProviderFetchResult, ProviderError> {
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)?;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading