Skip to content
Open
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 @@ -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.

---
Expand Down
133 changes: 120 additions & 13 deletions rust/src/providers/kimi/code_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,21 +278,55 @@ pub(crate) fn kimi_code_home() -> Option<PathBuf> {
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(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),
}

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.";

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<String> {
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)> {
Expand Down Expand Up @@ -414,8 +448,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();
Expand All @@ -436,6 +472,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;
Expand Down Expand Up @@ -463,15 +564,21 @@ 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.
unsafe {
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 {
Expand Down
69 changes: 45 additions & 24 deletions rust/src/providers/kimi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
63 changes: 63 additions & 0 deletions rust/src/providers/kimi/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down