From bb5c7b956bcdc8839b3d2ebd1798230c1066c026 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:52:10 +0700 Subject: [PATCH 1/3] Port upstream 0.69.0: guide stale Kimi CLI credentials to renewal or an API key --- CHANGELOG.md | 1 + rust/src/providers/kimi/code_api.rs | 123 +++++++++++++++++++++++++--- rust/src/providers/kimi/mod.rs | 69 ++++++++++------ rust/src/providers/kimi/web.rs | 63 ++++++++++++++ 4 files changed, 219 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c054768f2c..f9cfc3bfb1 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ menu-bar layout. - DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards. ### Fixed +- Kimi: when the Kimi Code CLI credential is stale or rejected and no web session can take over, 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. - Claude: when Hide Personal Info is enabled, keep saved account rows distinguishable with stable localized `Account N` labels and matching redacted tooltips. --- diff --git a/rust/src/providers/kimi/code_api.rs b/rust/src/providers/kimi/code_api.rs index 8960fdaf04..50cadbaba6 100644 --- a/rust/src/providers/kimi/code_api.rs +++ b/rust/src/providers/kimi/code_api.rs @@ -278,21 +278,45 @@ pub(crate) fn kimi_code_home() -> Option { dirs::home_dir().map(|home| home.join(".kimi-code")) } -/// Read-only access to a still-fresh Kimi Code CLI access token. +/// State of the Kimi Code CLI credential file, as seen read-only. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum KimiCliCredential { + /// No CLI credential is usable or eligible (missing file, empty token, + /// non-default region, or an endpoint override). + Unavailable, + /// The CLI credential exists but is expired or inside the safety margin. + Stale, + Fresh(String), +} + +/// Guidance shown when a stale or rejected CLI credential leaves no working +/// source. Never includes token values. +const KIMI_CLI_CREDENTIAL_GUIDANCE: &str = "Kimi Code CLI credential is invalid or expired. Run kimi to renew it, or add a Kimi Code API key in Settings > Providers > Kimi (KIMI_CODE_API_KEY). CodexBar does not refresh CLI-owned credentials."; + +pub(crate) fn kimi_cli_credential_error() -> ProviderError { + ProviderError::Other(KIMI_CLI_CREDENTIAL_GUIDANCE.into()) +} + +/// Read-only access to the Kimi Code CLI access token. /// /// Never refreshes or rewrites CLI-owned `credentials/kimi-code.json`. /// Skips when `KIMI_CODE_BASE_URL` / OAuth host overrides are set. -pub(crate) fn kimi_code_cli_access_token(region: KimiRegion, now_unix: f64) -> Option { +pub(crate) fn kimi_code_cli_credential(region: KimiRegion, now_unix: f64) -> KimiCliCredential { if region != KimiRegion::China || has_code_endpoint_override() { - return None; + return KimiCliCredential::Unavailable; } - let home = kimi_code_home()?; - let credential = read_kimi_code_credential(&home)?; - let token = cleaned_owned(credential.access_token)?; - if !is_kimi_code_credential_fresh(credential.expires_at, now_unix) { - return None; + let Some(credential) = kimi_code_home().and_then(|home| read_kimi_code_credential(&home)) + else { + return KimiCliCredential::Unavailable; + }; + let Some(token) = cleaned_owned(credential.access_token) else { + return KimiCliCredential::Unavailable; + }; + if is_kimi_code_credential_fresh(credential.expires_at, now_unix) { + KimiCliCredential::Fresh(token) + } else { + KimiCliCredential::Stale } - Some(token) } pub(crate) fn kimi_code_cli_identity_headers(home: &Path) -> Vec<(&'static str, String)> { @@ -414,8 +438,10 @@ mod tests { std::env::set_var(KIMI_CODE_HOME_ENV, home.path()); } - let token = kimi_code_cli_access_token(KimiRegion::China, now); - assert_eq!(token.as_deref(), Some("oauth-token")); + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, now), + KimiCliCredential::Fresh("oauth-token".into()) + ); let after = std::fs::read(&cred_path).unwrap(); let after_modified = std::fs::metadata(&cred_path).unwrap().modified().unwrap(); @@ -436,6 +462,71 @@ mod tests { } } + #[test] + fn stale_cli_credential_is_reported_without_touching_the_file() { + let _guard = env_lock(); + // A 15-minute token: the 60 s safety margin makes it stale at 14 min. + let issued = 1_800_000_000.0_f64; + let home = write_temp_kimi_code_home("synthetic-stale", Some(json!(issued + 900.0))); + let cred_path = home.path().join("credentials").join("kimi-code.json"); + let original = std::fs::read(&cred_path).unwrap(); + + // SAFETY: guarded by env_lock for process-wide env mutation in tests. + unsafe { + std::env::remove_var(KIMI_CODE_BASE_URL_ENV); + std::env::remove_var(KIMI_CODE_OAUTH_HOST_ENV); + std::env::remove_var(KIMI_OAUTH_HOST_ENV); + std::env::set_var(KIMI_CODE_HOME_ENV, home.path()); + } + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, issued + 839.0), + KimiCliCredential::Fresh("synthetic-stale".into()) + ); + for seconds in [840.0, 900.0] { + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, issued + seconds), + KimiCliCredential::Stale + ); + } + assert_eq!(std::fs::read(&cred_path).unwrap(), original); + + // SAFETY: final cleanup while the env_lock() guard is still alive. + unsafe { + std::env::remove_var(KIMI_CODE_HOME_ENV); + } + } + + #[test] + fn missing_cli_credential_is_unavailable_not_stale() { + let _guard = env_lock(); + let home = tempfile::tempdir().expect("tempdir"); + // SAFETY: guarded by env_lock for process-wide env mutation in tests. + unsafe { + std::env::remove_var(KIMI_CODE_BASE_URL_ENV); + std::env::remove_var(KIMI_CODE_OAUTH_HOST_ENV); + std::env::remove_var(KIMI_OAUTH_HOST_ENV); + std::env::set_var(KIMI_CODE_HOME_ENV, home.path()); + } + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, 1_800_000_000.0), + KimiCliCredential::Unavailable + ); + // SAFETY: final cleanup while the env_lock() guard is still alive. + unsafe { + std::env::remove_var(KIMI_CODE_HOME_ENV); + } + } + + #[test] + fn cli_credential_guidance_explains_renewal_and_api_key_setup() { + assert_eq!( + kimi_cli_credential_error().to_string(), + "Kimi Code CLI credential is invalid or expired. Run kimi to renew it, or add a \ + Kimi Code API key in Settings > Providers > Kimi (KIMI_CODE_API_KEY). CodexBar \ + does not refresh CLI-owned credentials." + ); + } + #[test] fn rejects_expired_or_missing_expiry_cli_credentials() { let now = 1_800_000_000.0_f64; @@ -463,7 +554,10 @@ mod tests { std::env::set_var(KIMI_CODE_BASE_URL_ENV, "https://proxy.example.com/kimi"); } assert!(has_code_endpoint_override()); - assert!(kimi_code_cli_access_token(KimiRegion::China, now).is_none()); + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, now), + KimiCliCredential::Unavailable + ); // SAFETY: still under the same env_lock() guard; swapping which // override keys are present between assertions. @@ -471,7 +565,10 @@ mod tests { std::env::remove_var(KIMI_CODE_BASE_URL_ENV); std::env::set_var(KIMI_CODE_OAUTH_HOST_ENV, "https://oauth.example.com"); } - assert!(kimi_code_cli_access_token(KimiRegion::China, now).is_none()); + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, now), + KimiCliCredential::Unavailable + ); // SAFETY: final cleanup while the env_lock() guard is still alive. unsafe { diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index e326afa3d6..d222417246 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -349,34 +349,55 @@ impl Provider for KimiProvider { } } - if let Some(cli_token) = - code_api::kimi_code_cli_access_token(region, unix_now_secs()) - { - let home = code_api::kimi_code_home().unwrap_or_default(); - let headers = code_api::kimi_code_cli_identity_headers(&home); - match code_api::fetch_via_code_api( - ctx, - region, - Some(&cli_token), - Some(&headers), - "Kimi Code CLI", - ) - .await - { - Ok(usage) => { - return Ok(ProviderFetchResult::new(usage, "code-cli")); - } - Err(err) => { - tracing::debug!( - error = %err, - "Kimi Code CLI credential fetch failed; falling back to web" - ); + let mut cli_credential_unusable = false; + match code_api::kimi_code_cli_credential(region, unix_now_secs()) { + code_api::KimiCliCredential::Fresh(cli_token) => { + let home = code_api::kimi_code_home().unwrap_or_default(); + let headers = code_api::kimi_code_cli_identity_headers(&home); + match code_api::fetch_via_code_api( + ctx, + region, + Some(&cli_token), + Some(&headers), + "Kimi Code CLI", + ) + .await + { + Ok(usage) => { + return Ok(ProviderFetchResult::new(usage, "code-cli")); + } + Err(err) => { + cli_credential_unusable |= + matches!(err, ProviderError::AuthRequired); + tracing::debug!( + error = %err, + "Kimi Code CLI credential fetch failed; falling back to web" + ); + } } } + code_api::KimiCliCredential::Stale => { + cli_credential_unusable = true; + tracing::debug!("Kimi Code CLI credential is stale; falling back to web"); + } + code_api::KimiCliCredential::Unavailable => {} } - let usage = web::fetch_via_web(ctx.manual_cookie_header.as_deref(), region).await?; - Ok(ProviderFetchResult::new(usage, "web")) + match web::fetch_via_web(ctx.manual_cookie_header.as_deref(), region).await { + Ok(usage) => Ok(ProviderFetchResult::new(usage, "web")), + // The CLI credential is the only source the user can + // still repair, so name it instead of a generic web error. + Err(err) + if cli_credential_unusable + && web::is_session_unavailable( + ctx.manual_cookie_header.as_deref(), + &err, + ) => + { + Err(code_api::kimi_cli_credential_error()) + } + Err(err) => Err(err), + } } SourceMode::OAuth => { let usage = diff --git a/rust/src/providers/kimi/web.rs b/rust/src/providers/kimi/web.rs index c5ed5b8305..705c6ae30a 100644 --- a/rust/src/providers/kimi/web.rs +++ b/rust/src/providers/kimi/web.rs @@ -41,6 +41,29 @@ fn browser_import_error(cookie_source: &str) -> ProviderError { ProviderError::Other(message.into()) } +/// Whether a failed web fetch means no usable web session exists: the server +/// rejected every candidate, none was found, or web auth is switched off. +/// Transport and parse failures are not session problems. +pub(super) fn is_session_unavailable(manual_header: Option<&str>, error: &ProviderError) -> bool { + session_unavailable_for(manual_header, &cookie_source(), error) +} + +fn session_unavailable_for( + manual_header: Option<&str>, + cookie_source: &str, + error: &ProviderError, +) -> bool { + match error { + ProviderError::AuthRequired | ProviderError::NoCookies => true, + ProviderError::Other(_) => { + let manual_token = manual_header + .is_some_and(|header| KimiProvider::auth_token_from_cookie_header(header).is_ok()); + !manual_token && !browser_import_allowed(cookie_source) + } + _ => false, + } +} + /// Web auth token chain for both the web fetch and the Code-API enrichment /// (upstream `KimiWebEnrichmentTokenResolver.resolve`): /// 1. Manual cookie header (its `kimi-auth`/auth cookie), source-independent. @@ -339,6 +362,46 @@ mod tests { None } + #[test] + fn session_unavailable_covers_rejected_missing_and_disabled_web_auth() { + let manual = Some("kimi-auth=synthetic-web"); + assert!(session_unavailable_for( + None, + "auto", + &ProviderError::AuthRequired + )); + assert!(session_unavailable_for( + None, + "auto", + &ProviderError::NoCookies + )); + assert!(session_unavailable_for( + None, + "off", + &browser_import_error("off") + )); + assert!(session_unavailable_for( + None, + "manual", + &browser_import_error("manual") + )); + // A usable manual token, or automatic import, means an `Other` error + // is a real web failure rather than an absent session. + let server_error = ProviderError::Other("API error: 500".into()); + assert!(!session_unavailable_for(manual, "off", &server_error)); + assert!(!session_unavailable_for(None, "auto", &server_error)); + assert!(!session_unavailable_for( + None, + "off", + &ProviderError::Timeout + )); + assert!(!session_unavailable_for( + None, + "off", + &ProviderError::Parse("bad".into()) + )); + } + fn input<'a>( manual_header: Option<&'a str>, cookie_source: &'a str, From 7d0742f646f6b12279a08dd9bbaf78233001ec05 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:15:04 +0700 Subject: [PATCH 2/3] Address thermo review --- rust/src/providers/kimi/code_api.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/rust/src/providers/kimi/code_api.rs b/rust/src/providers/kimi/code_api.rs index 50cadbaba6..12b9a2b2b2 100644 --- a/rust/src/providers/kimi/code_api.rs +++ b/rust/src/providers/kimi/code_api.rs @@ -279,7 +279,7 @@ pub(crate) fn kimi_code_home() -> Option { } /// State of the Kimi Code CLI credential file, as seen read-only. -#[derive(Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub(crate) enum KimiCliCredential { /// No CLI credential is usable or eligible (missing file, empty token, /// non-default region, or an endpoint override). @@ -289,6 +289,16 @@ pub(crate) enum KimiCliCredential { Fresh(String), } +impl std::fmt::Debug for KimiCliCredential { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable => formatter.write_str("Unavailable"), + Self::Stale => formatter.write_str("Stale"), + Self::Fresh(_) => formatter.write_str("Fresh([REDACTED])"), + } + } +} + /// Guidance shown when a stale or rejected CLI credential leaves no working /// source. Never includes token values. const KIMI_CLI_CREDENTIAL_GUIDANCE: &str = "Kimi Code CLI credential is invalid or expired. Run kimi to renew it, or add a Kimi Code API key in Settings > Providers > Kimi (KIMI_CODE_API_KEY). CodexBar does not refresh CLI-owned credentials."; From 73307e9ec5ce7c9c9d9d92a8290741e3e1e0f114 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:37:09 +0700 Subject: [PATCH 3/3] Align Kimi CLI guidance with upstream web availability --- CHANGELOG.md | 2 +- rust/src/providers/kimi/auto.rs | 262 +++++++++++++++++++++++++ rust/src/providers/kimi/code_api.rs | 203 +++++++++++++++++--- rust/src/providers/kimi/mod.rs | 51 ++--- rust/src/providers/kimi/web.rs | 283 ++++++++++++++++++++-------- 5 files changed, 656 insertions(+), 145 deletions(-) create mode 100644 rust/src/providers/kimi/auto.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cfc3bfb1..276ae97c97 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ menu-bar layout. - DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards. ### Fixed -- Kimi: when the Kimi Code CLI credential is stale or rejected and no web session can take over, 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. +- 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. - Claude: when Hide Personal Info is enabled, keep saved account rows distinguishable with stable localized `Account N` labels and matching redacted tooltips. --- diff --git a/rust/src/providers/kimi/auto.rs b/rust/src/providers/kimi/auto.rs new file mode 100644 index 0000000000..1654659c05 --- /dev/null +++ b/rust/src/providers/kimi/auto.rs @@ -0,0 +1,262 @@ +//! Auto-mode order after the Code API key: the Kimi Code CLI credential, then +//! web auth (upstream `KimiCLICredentialFetchStrategy` followed by +//! `KimiWebFetchStrategy` in the Kimi fetch plan). +//! +//! Upstream reports the error of the last strategy that was available. Web +//! auth is available only when it has a token to send, so a stale or rejected +//! CLI credential surfaces as renewal guidance only when web auth had nothing +//! to try. A rejected web token keeps its own web error. + +use super::code_api::{KimiCliCredential, kimi_cli_credential_error}; +use super::web::WebFetchFailure; +use crate::core::{ProviderError, ProviderFetchResult, UsageSnapshot}; + +pub(super) async fn fetch_cli_then_web( + cli_credential: KimiCliCredential, + fetch_cli: C, + fetch_web: W, +) -> Result +where + C: FnOnce(String) -> CF, + CF: Future>, + W: FnOnce() -> WF, + WF: Future>, +{ + let cli_failure = match cli_credential { + KimiCliCredential::Unavailable => None, + KimiCliCredential::Stale => { + tracing::debug!("Kimi Code CLI credential is stale; trying web auth"); + Some(kimi_cli_credential_error()) + } + KimiCliCredential::Fresh(token) => match fetch_cli(token).await { + Ok(usage) => return Ok(ProviderFetchResult::new(usage, "code-cli")), + Err(error) => { + tracing::debug!( + error = %error, + "Kimi Code CLI credential fetch failed; trying web auth" + ); + Some(cli_attempt_error(error)) + } + }, + }; + + match fetch_web().await { + Ok(usage) => Ok(ProviderFetchResult::new(usage, "web")), + Err(failure) => match cli_failure { + Some(cli_error) if !failure.had_token => Err(cli_error), + _ => Err(failure.error), + }, + } +} + +/// 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 { + match error { + ProviderError::AuthRequired => kimi_cli_credential_error(), + other => other, + } +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + + use super::*; + use crate::core::RateWindow; + + 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 web_failure( + error: ProviderError, + had_token: bool, + ) -> Result { + Err(WebFetchFailure { error, had_token }) + } + + fn web_without_token() -> Result { + web_failure(ProviderError::Other(OFF_SOURCE_ERROR.into()), false) + } + + fn assert_renewal_guidance(error: &ProviderError) { + let message = error.to_string(); + for expected in [ + "Run kimi", + "Settings > Providers > Kimi", + "KIMI_CODE_API_KEY", + "does not refresh", + ] { + assert!(message.contains(expected), "{message:?} lacks {expected:?}"); + } + assert!(!message.contains("synthetic-"), "{message:?} leaks a token"); + } + + struct Outcome { + result: Result, + cli_tokens: Vec, + web_ran: bool, + } + + /// Runs the Auto CLI-then-web order with scripted sources. + async fn run( + credential: KimiCliCredential, + cli: Result, + web: Result, + ) -> Outcome { + let cli_tokens = RefCell::new(Vec::new()); + let web_ran = Cell::new(false); + let result = fetch_cli_then_web( + credential, + |token| { + cli_tokens.borrow_mut().push(token); + async move { cli } + }, + || { + web_ran.set(true); + async move { web } + }, + ) + .await; + Outcome { + result, + cli_tokens: cli_tokens.into_inner(), + web_ran: web_ran.get(), + } + } + + // Upstream `KimiCLICredentialLifecycleTests`: stale or rejected CLI + // credentials fall back to configured web auth without renewal. + #[tokio::test] + async fn stale_or_rejected_cli_credentials_fall_back_to_configured_web_auth() { + for rejected_by_server in [false, true] { + let credential = if rejected_by_server { + KimiCliCredential::Fresh("api-bad".into()) + } else { + KimiCliCredential::Stale + }; + let outcome = run( + credential, + Err(ProviderError::AuthRequired), + Ok(usage(25.0)), + ) + .await; + + let result = outcome.result.expect("web auth takes over"); + assert_eq!(result.source_label, "web"); + assert_eq!(result.usage.primary.used_percent, 25.0); + let expected_cli_tokens: &[&str] = if rejected_by_server { + &["api-bad"] + } else { + &[] + }; + assert_eq!(outcome.cli_tokens, expected_cli_tokens); + assert!(outcome.web_ran); + } + assert_renewal_guidance(&cli_attempt_error(ProviderError::AuthRequired)); + } + + // Upstream: CLI-only Auto mode (cookie source Off) explains renewal and + // the API key setting. + #[tokio::test] + async fn cli_only_auto_mode_explains_renewal_and_the_api_key_setting() { + for (credential, cli_tokens) in [ + (KimiCliCredential::Stale, Vec::::new()), + ( + KimiCliCredential::Fresh("synthetic-rejected".into()), + vec!["synthetic-rejected".to_string()], + ), + ] { + let outcome = run( + credential, + Err(ProviderError::AuthRequired), + web_without_token(), + ) + .await; + assert_renewal_guidance(&outcome.result.expect_err("no source works")); + assert_eq!(outcome.cli_tokens, cli_tokens); + } + } + + #[tokio::test] + async fn rejected_web_token_keeps_the_web_error() { + let outcome = run( + KimiCliCredential::Stale, + Err(ProviderError::AuthRequired), + web_failure(ProviderError::AuthRequired, true), + ) + .await; + assert!(matches!(outcome.result, Err(ProviderError::AuthRequired))); + + let outcome = run( + KimiCliCredential::Fresh("api-bad".into()), + Err(ProviderError::AuthRequired), + web_failure(ProviderError::Other("API error: 500".into()), true), + ) + .await; + assert!(matches!( + outcome.result, + Err(ProviderError::Other(message)) if message == "API error: 500" + )); + } + + #[tokio::test] + async fn other_cli_failures_are_reported_when_web_auth_has_no_token() { + let outcome = run( + KimiCliCredential::Fresh("cli-token".into()), + Err(ProviderError::Timeout), + web_without_token(), + ) + .await; + assert!(matches!(outcome.result, Err(ProviderError::Timeout))); + + let denied = "Kimi Code API returned status 403 Forbidden (permission or quota denied)"; + let outcome = run( + KimiCliCredential::Fresh("cli-token".into()), + Err(ProviderError::Other(denied.into())), + web_without_token(), + ) + .await; + assert!(matches!( + outcome.result, + Err(ProviderError::Other(message)) if message == denied + )); + } + + #[tokio::test] + async fn unavailable_cli_credential_reports_the_web_error() { + let outcome = run( + KimiCliCredential::Unavailable, + Ok(usage(10.0)), + web_without_token(), + ) + .await; + assert!(matches!( + outcome.result, + Err(ProviderError::Other(message)) if message == OFF_SOURCE_ERROR + )); + assert!(outcome.cli_tokens.is_empty()); + assert!(outcome.web_ran); + } + + // Upstream: the next fetch recovers once the CLI replaces its credential + // (the file side is covered in `code_api`). + #[tokio::test] + async fn fresh_cli_credential_is_used_before_web_auth() { + let outcome = run( + KimiCliCredential::Fresh("cli-ok".into()), + Ok(usage(25.0)), + web_without_token(), + ) + .await; + let result = outcome.result.expect("CLI credential accepted"); + assert_eq!(result.source_label, "code-cli"); + assert_eq!(result.usage.primary.used_percent, 25.0); + assert_eq!(outcome.cli_tokens, ["cli-ok"]); + assert!(!outcome.web_ran); + } +} diff --git a/rust/src/providers/kimi/code_api.rs b/rust/src/providers/kimi/code_api.rs index 5edd125ea5..39afa68a01 100644 --- a/rust/src/providers/kimi/code_api.rs +++ b/rust/src/providers/kimi/code_api.rs @@ -26,11 +26,10 @@ const KIMI_CODE_CREDENTIAL_MIN_TTL_SECS: f64 = 60.0; struct KimiCodeCredentialFile { #[serde(default, alias = "accessToken")] access_token: String, + /// Read only to tell whether the CLI is still signed in when its access + /// token is empty (upstream `hasKimiCodeCredential`). Never used to + /// refresh: the CLI owns and rotates it. #[serde(default)] - #[allow( - dead_code, - reason = "field exists in the CLI credential file; deserialized to preserve the schema but never read locally" - )] refresh_token: Option, #[serde(default, alias = "expiresAt")] expires_at: Option, @@ -66,16 +65,8 @@ pub(crate) async fn fetch_via_code_api( let resp = request.send().await?; - if resp.status() == reqwest::StatusCode::UNAUTHORIZED - || resp.status() == reqwest::StatusCode::FORBIDDEN - { - return Err(ProviderError::AuthRequired); - } if !resp.status().is_success() { - return Err(ProviderError::Other(format!( - "Kimi Code API returned status {}", - resp.status() - ))); + return Err(code_api_status_error(resp.status())); } let json: KimiCodeApiUsageResponse = resp.json().await.map_err(|e| { @@ -113,6 +104,19 @@ pub(crate) async fn fetch_via_code_api( Ok(snapshot) } +/// Upstream `KimiUsageFetcher.codeAPIError`: only 401 means the API key or +/// CLI token was rejected. A 403 is a permission or quota denial, which +/// signing in again would not fix. +fn code_api_status_error(status: reqwest::StatusCode) -> ProviderError { + match status { + reqwest::StatusCode::UNAUTHORIZED => ProviderError::AuthRequired, + reqwest::StatusCode::FORBIDDEN => ProviderError::Other(format!( + "Kimi Code API returned status {status} (permission or quota denied)" + )), + _ => ProviderError::Other(format!("Kimi Code API returned status {status}")), + } +} + pub(super) fn snapshot_from_code_api_response( response: KimiCodeApiUsageResponse, ) -> Result { @@ -233,10 +237,12 @@ pub(crate) fn kimi_code_home() -> Option { /// State of the Kimi Code CLI credential file, as seen read-only. #[derive(PartialEq, Eq)] pub(crate) enum KimiCliCredential { - /// No CLI credential is usable or eligible (missing file, empty token, - /// non-default region, or an endpoint override). + /// No CLI credential is usable or eligible (missing or unreadable file, + /// no access or refresh token, non-default region, or an endpoint + /// override). Unavailable, - /// The CLI credential exists but is expired or inside the safety margin. + /// The CLI is signed in, but its access token is missing, expired, or + /// inside the safety margin. Stale, Fresh(String), } @@ -271,13 +277,16 @@ pub(crate) fn kimi_code_cli_credential(region: KimiRegion, now_unix: f64) -> Kim else { return KimiCliCredential::Unavailable; }; - let Some(token) = cleaned_owned(credential.access_token) else { - return KimiCliCredential::Unavailable; - }; - if is_kimi_code_credential_fresh(credential.expires_at, now_unix) { - KimiCliCredential::Fresh(token) - } else { - KimiCliCredential::Stale + let has_refresh_token = credential.refresh_token.and_then(cleaned_owned).is_some(); + match cleaned_owned(credential.access_token) { + Some(token) if is_kimi_code_credential_fresh(credential.expires_at, now_unix) => { + KimiCliCredential::Fresh(token) + } + Some(_) => KimiCliCredential::Stale, + // Upstream `hasKimiCodeCredential`: a refresh token alone still means + // the CLI is signed in, so this is stale, not absent. + None if has_refresh_token => KimiCliCredential::Stale, + None => KimiCliCredential::Unavailable, } } @@ -343,27 +352,159 @@ mod tests { ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) } - fn write_temp_kimi_code_home( + /// Writes `credentials/kimi-code.json` in the official CLI's shape. + fn write_kimi_code_credential( + home: &Path, access_token: &str, + refresh_token: &str, expires_at: Option, - ) -> tempfile::TempDir { - let dir = tempfile::tempdir().expect("tempdir"); - let credentials = dir.path().join("credentials"); + ) -> PathBuf { + let credentials = home.join("credentials"); std::fs::create_dir_all(&credentials).expect("mkdir credentials"); let mut payload = serde_json::Map::new(); payload.insert("access_token".into(), json!(access_token)); - payload.insert("refresh_token".into(), json!("refresh")); + payload.insert("refresh_token".into(), json!(refresh_token)); + payload.insert("expires_in".into(), json!(900)); + payload.insert("scope".into(), json!("synthetic-scope")); + payload.insert("token_type".into(), json!("Bearer")); if let Some(expires) = expires_at { payload.insert("expires_at".into(), expires); } + let path = credentials.join("kimi-code.json"); std::fs::write( - credentials.join("kimi-code.json"), + &path, serde_json::to_vec_pretty(&serde_json::Value::Object(payload)).unwrap(), ) .expect("write credentials"); + path + } + + fn write_temp_kimi_code_home( + access_token: &str, + expires_at: Option, + ) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + write_kimi_code_credential(dir.path(), access_token, "refresh", expires_at); dir } + /// Points the CLI credential reader at `home` with no endpoint overrides. + /// Taking the guard proves the caller holds `env_lock()`. + fn use_kimi_code_home(_env: &std::sync::MutexGuard<'static, ()>, home: &Path) { + // SAFETY: the caller holds env_lock(), so no other test thread reads + // or writes the process environment concurrently. + unsafe { + std::env::remove_var(KIMI_CODE_BASE_URL_ENV); + std::env::remove_var(KIMI_CODE_OAUTH_HOST_ENV); + std::env::remove_var(KIMI_OAUTH_HOST_ENV); + std::env::set_var(KIMI_CODE_HOME_ENV, home); + } + } + + fn clear_kimi_code_home(_env: &std::sync::MutexGuard<'static, ()>) { + // SAFETY: the caller holds env_lock() (see `use_kimi_code_home`). + unsafe { + std::env::remove_var(KIMI_CODE_HOME_ENV); + } + } + + #[test] + fn code_api_status_errors_follow_upstream_mapping() { + use reqwest::StatusCode; + assert!(matches!( + code_api_status_error(StatusCode::UNAUTHORIZED), + ProviderError::AuthRequired + )); + for (status, message) in [ + ( + StatusCode::FORBIDDEN, + "Kimi Code API returned status 403 Forbidden (permission or quota denied)", + ), + ( + StatusCode::BAD_REQUEST, + "Kimi Code API returned status 400 Bad Request", + ), + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Kimi Code API returned status 500 Internal Server Error", + ), + ] { + assert!(matches!( + code_api_status_error(status), + ProviderError::Other(actual) if actual == message + )); + } + } + + #[test] + fn refresh_only_cli_credential_is_stale_not_absent() { + let env = env_lock(); + let now = 1_800_000_000.0_f64; + let home = tempfile::tempdir().expect("tempdir"); + use_kimi_code_home(&env, home.path()); + + for access_token in ["", " "] { + write_kimi_code_credential( + home.path(), + access_token, + "synthetic-rotating-refresh", + Some(json!(now + 3600.0)), + ); + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, now), + KimiCliCredential::Stale + ); + } + + write_kimi_code_credential(home.path(), "", " ", Some(json!(now + 3600.0))); + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, now), + KimiCliCredential::Unavailable + ); + std::fs::write( + home.path().join("credentials").join("kimi-code.json"), + b"{}", + ) + .expect("write empty credential"); + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, now), + KimiCliCredential::Unavailable + ); + + clear_kimi_code_home(&env); + } + + // Upstream `KimiCLICredentialLifecycleTests`: the next fetch recovers once + // the CLI replaces its rotating credential; CodexBar only rereads it. + #[test] + fn next_read_recovers_after_the_cli_replaces_its_credential() { + let env = env_lock(); + let now = 1_800_000_000.0_f64; + let home = tempfile::tempdir().expect("tempdir"); + use_kimi_code_home(&env, home.path()); + + write_kimi_code_credential(home.path(), "old-access", "refresh", Some(json!(1))); + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, now), + KimiCliCredential::Stale + ); + + let path = write_kimi_code_credential( + home.path(), + "cli-ok", + "rotated-refresh", + Some(json!(now + 900.0)), + ); + let renewed = std::fs::read(&path).unwrap(); + assert_eq!( + kimi_code_cli_credential(KimiRegion::China, now), + KimiCliCredential::Fresh("cli-ok".into()) + ); + assert_eq!(std::fs::read(&path).unwrap(), renewed); + + clear_kimi_code_home(&env); + } + #[test] fn code_api_usage_endpoint_normalizes_base_paths() { let root = Url::parse("https://api.kimi.com").unwrap(); @@ -416,6 +557,9 @@ mod tests { .iter() .any(|(k, v)| *k == "X-Msh-Platform" && v == KIMI_CODE_CLI_PLATFORM) ); + // No device id is minted or written when the CLI has none. + assert!(!headers.iter().any(|(k, _)| *k == "X-Msh-Device-Id")); + assert!(!home.path().join("device_id").exists()); // SAFETY: this test owns KIMI_CODE_HOME_ENV (set at its start under // env_lock); removing it here restores the shared environment. @@ -451,6 +595,7 @@ mod tests { ); } assert_eq!(std::fs::read(&cred_path).unwrap(), original); + assert!(!home.path().join("device_id").exists()); // SAFETY: final cleanup while the env_lock() guard is still alive. unsafe { diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index ffeba29008..87332eae06 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -13,7 +13,10 @@ //! (Electron) Chromium cookie store. //! - [`ratio_pool`]: zero-ratio placeholder reconciliation against matching //! legacy counters (upstream 0.63.0). +//! - [`auto`]: Auto-mode order after the Code API key (CLI credential, then +//! web auth) and which failure is reported when neither works. +mod auto; mod code_api; pub mod desktop_token; mod ratio_pool; @@ -352,12 +355,12 @@ impl Provider for KimiProvider { } } - let mut cli_credential_unusable = false; - match code_api::kimi_code_cli_credential(region, unix_now_secs()) { - code_api::KimiCliCredential::Fresh(cli_token) => { + auto::fetch_cli_then_web( + code_api::kimi_code_cli_credential(region, unix_now_secs()), + |cli_token| async move { let home = code_api::kimi_code_home().unwrap_or_default(); let headers = code_api::kimi_code_cli_identity_headers(&home); - match code_api::fetch_via_code_api( + code_api::fetch_via_code_api( ctx, region, Some(&cli_token), @@ -365,42 +368,10 @@ impl Provider for KimiProvider { "Kimi Code CLI", ) .await - { - Ok(usage) => { - return Ok(ProviderFetchResult::new(usage, "code-cli")); - } - Err(err) => { - cli_credential_unusable |= - matches!(err, ProviderError::AuthRequired); - tracing::debug!( - error = %err, - "Kimi Code CLI credential fetch failed; falling back to web" - ); - } - } - } - code_api::KimiCliCredential::Stale => { - cli_credential_unusable = true; - tracing::debug!("Kimi Code CLI credential is stale; falling back to web"); - } - code_api::KimiCliCredential::Unavailable => {} - } - - match web::fetch_via_web(ctx.manual_cookie_header.as_deref(), region).await { - Ok(usage) => Ok(ProviderFetchResult::new(usage, "web")), - // The CLI credential is the only source the user can - // still repair, so name it instead of a generic web error. - Err(err) - if cli_credential_unusable - && web::is_session_unavailable( - ctx.manual_cookie_header.as_deref(), - &err, - ) => - { - Err(code_api::kimi_cli_credential_error()) - } - Err(err) => Err(err), - } + }, + || web::fetch_web_session(ctx.manual_cookie_header.as_deref(), region), + ) + .await } SourceMode::OAuth => { let usage = diff --git a/rust/src/providers/kimi/web.rs b/rust/src/providers/kimi/web.rs index 705c6ae30a..02092ba3f6 100644 --- a/rust/src/providers/kimi/web.rs +++ b/rust/src/providers/kimi/web.rs @@ -41,26 +41,21 @@ fn browser_import_error(cookie_source: &str) -> ProviderError { ProviderError::Other(message.into()) } -/// Whether a failed web fetch means no usable web session exists: the server -/// rejected every candidate, none was found, or web auth is switched off. -/// Transport and parse failures are not session problems. -pub(super) fn is_session_unavailable(manual_header: Option<&str>, error: &ProviderError) -> bool { - session_unavailable_for(manual_header, &cookie_source(), error) +/// A failed web fetch. `had_token` records whether web auth had a token to +/// send (upstream `KimiWebFetchStrategy.isAvailable`); Auto mode reports an +/// earlier CLI failure only when web auth had nothing to try. +#[derive(Debug)] +pub(super) struct WebFetchFailure { + pub(super) error: ProviderError, + pub(super) had_token: bool, } -fn session_unavailable_for( - manual_header: Option<&str>, - cookie_source: &str, - error: &ProviderError, -) -> bool { - match error { - ProviderError::AuthRequired | ProviderError::NoCookies => true, - ProviderError::Other(_) => { - let manual_token = manual_header - .is_some_and(|header| KimiProvider::auth_token_from_cookie_header(header).is_ok()); - !manual_token && !browser_import_allowed(cookie_source) +impl WebFetchFailure { + fn after_token(error: ProviderError) -> Self { + Self { + error, + had_token: true, } - _ => false, } } @@ -156,47 +151,90 @@ pub(crate) async fn fetch_via_web( cookie_header: Option<&str>, region: KimiRegion, ) -> Result { + fetch_web_session(cookie_header, region) + .await + .map_err(|failure| failure.error) +} + +/// [`fetch_via_web`], also reporting whether web auth had a token to try. +pub(super) async fn fetch_web_session( + cookie_header: Option<&str>, + region: KimiRegion, +) -> Result { let source = cookie_source(); - if let Some(token) = - cookie_header.and_then(|header| KimiProvider::auth_token_from_cookie_header(header).ok()) + let input = WebTokenInput { + manual_header: cookie_header, + cookie_source: &source, + region, + desktop_token: KimiDesktopAuthToken::load_for_region, + browser_token: browser_auth_token, + }; + // One HTTP client for every token attempt, built on first use. + let mut shared_client: Option = None; + fetch_with_web_tokens(input, |token| { + let http = match &shared_client { + Some(http) => Ok(http.clone()), + None => client().inspect(|http| shared_client = Some(http.clone())), + }; + async move { fetch_via_web_token(&http?, &token, region).await } + }) + .await +} + +/// The web fetch over the token chain. An explicit manual token is +/// authoritative; otherwise (automatic source only) the Kimi Desktop session +/// is tried, then browser import. Only a server rejection moves on to the +/// next automatic token. +async fn fetch_with_web_tokens( + input: WebTokenInput<'_>, + mut fetch: F, +) -> Result +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + if let Some(token) = input + .manual_header + .and_then(|header| KimiProvider::auth_token_from_cookie_header(header).ok()) { // An explicit manual credential is authoritative. A rejected manual // token must not silently switch accounts underneath the user. - let client = client()?; - return fetch_via_web_token(&client, &token, region).await; + return fetch(token).await.map_err(WebFetchFailure::after_token); } - if !browser_import_allowed(&source) { - return Err(browser_import_error(&source)); + if !browser_import_allowed(input.cookie_source) { + return Err(WebFetchFailure { + error: browser_import_error(input.cookie_source), + had_token: false, + }); } - let client = client()?; - let mut seen = std::collections::HashSet::new(); - // Read and try the desktop session first. Browser cookies are intentionally // read only after the server rejects this automatic session, so a healthy // desktop account never causes another credential store to be touched. - if let Some(token) = KimiDesktopAuthToken::load_for_region(region) - && seen.insert(token.clone()) - { - match fetch_via_web_token(&client, &token, region).await { + let desktop_token = (input.desktop_token)(input.region); + if let Some(token) = desktop_token.clone() { + match fetch(token).await { Ok(usage) => return Ok(usage), Err(ProviderError::AuthRequired) => {} - Err(error) => return Err(error), + Err(error) => return Err(WebFetchFailure::after_token(error)), } } - if let Some(token) = browser_auth_token(region) - && seen.insert(token.clone()) - { - match fetch_via_web_token(&client, &token, region).await { + let browser_token = + (input.browser_token)(input.region).filter(|token| desktop_token.as_ref() != Some(token)); + if let Some(token) = browser_token.clone() { + match fetch(token).await { Ok(usage) => return Ok(usage), Err(ProviderError::AuthRequired) => {} - Err(error) => return Err(error), + Err(error) => return Err(WebFetchFailure::after_token(error)), } } - Err(ProviderError::AuthRequired) + Err(WebFetchFailure { + error: ProviderError::AuthRequired, + had_token: desktop_token.is_some() || browser_token.is_some(), + }) } fn client() -> Result { @@ -362,44 +400,139 @@ mod tests { None } - #[test] - fn session_unavailable_covers_rejected_missing_and_disabled_web_auth() { - let manual = Some("kimi-auth=synthetic-web"); - assert!(session_unavailable_for( - None, - "auto", - &ProviderError::AuthRequired - )); - assert!(session_unavailable_for( - None, - "auto", - &ProviderError::NoCookies - )); - assert!(session_unavailable_for( - None, - "off", - &browser_import_error("off") - )); - assert!(session_unavailable_for( - None, - "manual", - &browser_import_error("manual") - )); - // A usable manual token, or automatic import, means an `Other` error - // is a real web failure rather than an absent session. - let server_error = ProviderError::Other("API error: 500".into()); - assert!(!session_unavailable_for(manual, "off", &server_error)); - assert!(!session_unavailable_for(None, "auto", &server_error)); - assert!(!session_unavailable_for( - None, - "off", - &ProviderError::Timeout - )); - assert!(!session_unavailable_for( - None, - "off", - &ProviderError::Parse("bad".into()) - )); + fn unread(_: KimiRegion) -> Option { + panic!("automatic Kimi token sources must not be read here") + } + + fn usage(percent: f64) -> UsageSnapshot { + UsageSnapshot::new(crate::core::RateWindow::new(percent)) + } + + fn reject_all(_: &str) -> Result { + Err(ProviderError::AuthRequired) + } + + fn accept_all(_: &str) -> Result { + Ok(usage(25.0)) + } + + fn accept_browser_only(token: &str) -> Result { + if token == "browser-token" { + Ok(usage(25.0)) + } else { + Err(ProviderError::AuthRequired) + } + } + + fn server_error(_: &str) -> Result { + Err(ProviderError::Other( + "API error: 500 Internal Server Error".into(), + )) + } + + /// Runs the web token chain against a scripted server; returns the + /// outcome and every token the server saw, in order. + async fn run_chain( + input: WebTokenInput<'_>, + respond: fn(&str) -> Result, + ) -> (Result, Vec) { + let sent = std::cell::RefCell::new(Vec::new()); + let result = fetch_with_web_tokens(input, |token| { + let response = respond(&token); + sent.borrow_mut().push(token); + async move { response } + }) + .await; + (result, sent.into_inner()) + } + + #[tokio::test] + async fn web_auth_without_a_token_reports_that_none_was_tried() { + for source in ["off", "manual"] { + for manual in [None, Some("not-a-token")] { + let (result, sent) = + run_chain(input(manual, source, unread, unread), accept_all).await; + let failure = result.expect_err("no web token to try"); + assert!(!failure.had_token); + assert_eq!( + failure.error.to_string(), + browser_import_error(source).to_string() + ); + assert!(sent.is_empty()); + } + } + + let (result, sent) = run_chain(input(None, "auto", no_token, no_token), accept_all).await; + let failure = result.expect_err("no automatic token found"); + assert!(!failure.had_token); + assert!(matches!(failure.error, ProviderError::AuthRequired)); + assert!(sent.is_empty()); + } + + #[tokio::test] + async fn rejected_manual_token_is_authoritative_and_counts_as_tried() { + let (result, sent) = run_chain( + input(Some("kimi-auth=synthetic-web"), "auto", unread, unread), + reject_all, + ) + .await; + let failure = result.expect_err("manual token rejected"); + assert!(failure.had_token); + assert!(matches!(failure.error, ProviderError::AuthRequired)); + assert_eq!(sent, ["synthetic-web"]); + } + + #[tokio::test] + async fn rejected_desktop_session_falls_through_to_browser_import() { + let (result, sent) = run_chain( + input(None, "auto", static_desktop, static_browser), + accept_browser_only, + ) + .await; + assert_eq!( + result.expect("browser token accepted").primary.used_percent, + 25.0 + ); + assert_eq!(sent, ["desktop-token", "browser-token"]); + + let (result, sent) = run_chain( + input(None, "auto", static_desktop, static_browser), + reject_all, + ) + .await; + let failure = result.expect_err("every automatic token rejected"); + assert!(failure.had_token); + assert!(matches!(failure.error, ProviderError::AuthRequired)); + assert_eq!(sent, ["desktop-token", "browser-token"]); + } + + #[tokio::test] + async fn healthy_desktop_session_never_reads_browser_cookies() { + let (result, sent) = + run_chain(input(None, "auto", static_desktop, unread), accept_all).await; + assert!(result.is_ok()); + assert_eq!(sent, ["desktop-token"]); + } + + #[tokio::test] + async fn duplicate_browser_token_is_not_sent_twice() { + let (result, sent) = run_chain( + input(None, "auto", static_desktop, duplicate_browser), + reject_all, + ) + .await; + assert!(result.expect_err("desktop token rejected").had_token); + assert_eq!(sent, ["desktop-token"]); + } + + #[tokio::test] + async fn non_auth_web_error_stops_the_token_chain() { + let (result, sent) = + run_chain(input(None, "auto", static_desktop, unread), server_error).await; + let failure = result.expect_err("server error"); + assert!(failure.had_token); + assert!(matches!(failure.error, ProviderError::Other(message) if message.contains("500"))); + assert_eq!(sent, ["desktop-token"]); } fn input<'a>(