From e7b4e14a66ea5b7b5dfa72cac7c753dd85ba1515 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:21:15 +0700 Subject: [PATCH 1/9] Add per-provider token account routing --- .../src-tauri/src/commands/providers.rs | 34 ++++- .../src-tauri/src/commands/tests.rs | 133 ++++++++++++++++ rust/src/cli/diagnose.rs | 2 + rust/src/cli/guard.rs | 2 + rust/src/cli/hooks.rs | 2 + rust/src/cli/serve/dashboard/source.rs | 4 + rust/src/cli/serve/data.rs | 2 + rust/src/cli/usage.rs | 2 + rust/src/cli/usage/fetch_helpers.rs | 76 +++++++++- rust/src/cli/usage_tests.rs | 82 +++++++++- rust/src/core/provider.rs | 9 ++ rust/src/core/token_accounts.rs | 142 +++++++++++++++++- rust/src/providers/doubao/mod.rs | 37 +++++ rust/src/providers/kimi/mod.rs | 23 ++- rust/src/providers/kimi/web.rs | 27 ++++ rust/src/providers/opencodego/mod.rs | 21 ++- rust/src/providers/opencodego/usage_api.rs | 23 +++ 17 files changed, 606 insertions(+), 15 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 779f9c801c..cd958bdb97 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -84,6 +84,7 @@ pub(crate) fn build_fetch_context( .and_then(|override_data| override_data.env_override.as_ref()); let active_token_api_key = active_token_env.and_then(|env| env.values().next().cloned()); let usage_source = SourceMode::parse(settings.usage_source(id)).unwrap_or_default(); + let token_account_kind = token_override.as_ref().map(|account| account.kind); // Selected token-account key overrides a stored provider apiKey (upstream #2271 / #1183). let api_key = active_token_api_key.or(stored_api_key); let has_kimi_code_api_key = @@ -220,16 +221,47 @@ pub(crate) fn build_fetch_context( // token account or manual cookie source scopes the session to web creds. let auto_prefer_web = token_override.is_some() || cookie_source == "manual"; + // These upstream account types are explicit identity selections. Keep the + // provider's saved region/source settings intact, but project the selected + // credential into the route required by that account. + let (source_mode, cookie_header, api_key) = match (id, token_account_kind) { + (ProviderId::Kimi, Some(_)) => (SourceMode::Web, active_token_cookie.clone(), None), + (ProviderId::Doubao, Some(_)) => (SourceMode::OAuth, None, active_token_api_key.clone()), + (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey)) + if usage_source == SourceMode::Auto => + { + (SourceMode::Auto, None, active_token_api_key.clone()) + } + (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey)) => { + (usage_source, cookie_header, api_key) + } + (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::Cookie)) + if usage_source == SourceMode::Auto => + { + (SourceMode::Web, active_token_cookie.clone(), api_key) + } + _ => (source_mode, cookie_header, api_key), + }; + let token_account_isolated = token_override.is_some() + && matches!( + id, + ProviderId::Kimi | ProviderId::Doubao | ProviderId::OpenCodeGo + ); + FetchContext { source_mode, manual_cookie_header: cookie_header, manual_cookie_missing: fails_closed_without_cookie, api_key, + token_account_kind, + token_account_isolated, workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), seat_credit_entitlement: settings.seat_credit_entitlement(id), api_region: (!api_region.is_empty()).then_some(api_region), gateway_url, - auto_prefer_web, + auto_prefer_web: auto_prefer_web + && !(id == ProviderId::OpenCodeGo + && token_account_kind == Some(codexbar::core::TokenAccountKind::ApiKey)), ..FetchContext::default() } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 94ac348f11..8b6b43be7f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -483,6 +483,139 @@ fn fetch_context_opencode_empty_manual_remaps_to_web() { assert_eq!(ctx.source_mode, SourceMode::Web); } +#[test] +fn kimi_selected_account_forces_web_and_keeps_saved_region() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Kimi, "oauth"); + settings.set_api_region(ProviderId::Kimi, "international"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-kimi-session")); + accounts.insert(ProviderId::Kimi, data); + + let ctx = super::build_fetch_context( + ProviderId::Kimi, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("kimi-auth=selected-kimi-session") + ); + assert_eq!(ctx.api_key, None); + assert_eq!(ctx.api_region.as_deref(), Some("international")); + assert!(ctx.token_account_isolated); + assert_eq!(settings.usage_source(ProviderId::Kimi), "oauth"); + assert_eq!(settings.api_region(ProviderId::Kimi), "international"); +} + +#[test] +fn doubao_selected_account_forces_ark_api_and_ignores_saved_source() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Doubao, "cli"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-ark-key")); + accounts.insert(ProviderId::Doubao, data); + + let ctx = super::build_fetch_context( + ProviderId::Doubao, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::OAuth); + assert_eq!(ctx.api_key.as_deref(), Some("selected-ark-key")); + assert!(ctx.token_account_isolated); +} + +#[test] +fn opencodego_selected_api_account_overrides_global_key_without_changing_explicit_source() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let mut keys = ApiKeys::default(); + keys.set("opencodego", "global-key", None); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-account-key")); + accounts.insert(ProviderId::OpenCodeGo, data); + + let ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert_eq!(ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(!ctx.auto_prefer_web); + assert!(ctx.token_account_isolated); + + for cookie_source in ["off", "manual"] { + settings.set_cookie_source(ProviderId::OpenCodeGo, cookie_source); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let auto_ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + assert_eq!(auto_ctx.source_mode, SourceMode::Auto); + assert_eq!(auto_ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(auto_ctx.manual_cookie_header.is_none()); + } + + for (saved_source, expected_source) in [("web", SourceMode::Web), ("cli", SourceMode::Cli)] { + settings.set_cookie_source(ProviderId::OpenCodeGo, "off"); + settings.set_usage_source(ProviderId::OpenCodeGo, saved_source); + let explicit_ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + assert_eq!(explicit_ctx.source_mode, expected_source); + } +} + +#[test] +fn opencodego_selected_cookie_account_uses_web_route() { + let settings = Settings::default(); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Web", "Cookie: session=selected-session")); + accounts.insert(ProviderId::OpenCodeGo, data); + + let ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("Cookie: session=selected-session") + ); + assert_eq!( + ctx.token_account_kind, + Some(codexbar::core::TokenAccountKind::Cookie) + ); + assert!(ctx.token_account_isolated); +} + #[test] fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() { let settings = Settings::default(); diff --git a/rust/src/cli/diagnose.rs b/rust/src/cli/diagnose.rs index 64d0b54d59..1d09877414 100644 --- a/rust/src/cli/diagnose.rs +++ b/rust/src/cli/diagnose.rs @@ -177,6 +177,8 @@ async fn collect_provider_diagnostic( .map(ToOwned::to_owned), manual_cookie_missing: false, api_key: api_keys.get(provider_id.cli_name()).map(ToOwned::to_owned), + token_account_kind: None, + token_account_isolated: false, workspace_id: settings .provider_config(provider_id) .and_then(|config| config.workspace_id.clone()), diff --git a/rust/src/cli/guard.rs b/rust/src/cli/guard.rs index b9ecf5dce8..b56945db1b 100644 --- a/rust/src/cli/guard.rs +++ b/rust/src/cli/guard.rs @@ -318,6 +318,8 @@ async fn fetch_guard_outcome( manual_cookie_header: None, manual_cookie_missing: false, api_key: None, + token_account_kind: None, + token_account_isolated: false, workspace_id: None, seat_credit_entitlement: None, api_region: None, diff --git a/rust/src/cli/hooks.rs b/rust/src/cli/hooks.rs index 2f26498cf1..c7b211f7c0 100644 --- a/rust/src/cli/hooks.rs +++ b/rust/src/cli/hooks.rs @@ -296,6 +296,8 @@ async fn hooks_watch_observation( manual_cookie_header: None, manual_cookie_missing: false, api_key: None, + token_account_kind: None, + token_account_isolated: false, workspace_id: (!workspace.is_empty()).then(|| workspace.to_string()), seat_credit_entitlement: settings.seat_credit_entitlement(provider_id), api_region: (!region.is_empty()).then(|| region.to_string()), diff --git a/rust/src/cli/serve/dashboard/source.rs b/rust/src/cli/serve/dashboard/source.rs index 8d28758df4..af7941d386 100644 --- a/rust/src/cli/serve/dashboard/source.rs +++ b/rust/src/cli/serve/dashboard/source.rs @@ -159,6 +159,8 @@ async fn fetch_provider_envelope( manual_cookie_header: None, manual_cookie_missing: false, api_key: None, + token_account_kind: None, + token_account_isolated: false, workspace_id: None, seat_credit_entitlement: None, api_region: None, @@ -292,6 +294,8 @@ async fn collect_claude_accounts(claude_enabled: bool) -> Option) -> String { manual_cookie_header: None, manual_cookie_missing: false, api_key: None, + token_account_kind: None, + token_account_isolated: false, workspace_id: None, seat_credit_entitlement: None, api_region: None, diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 2b1dfff0af..1a588c1c75 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -249,6 +249,8 @@ fn build_usage_fetch_context(args: &UsageArgs, source_mode: SourceMode) -> Fetch manual_cookie_header: None, manual_cookie_missing: false, api_key: None, + token_account_kind: None, + token_account_isolated: false, workspace_id: None, seat_credit_entitlement: None, api_region: None, diff --git a/rust/src/cli/usage/fetch_helpers.rs b/rust/src/cli/usage/fetch_helpers.rs index d093a2bbe7..e9e063f296 100644 --- a/rust/src/cli/usage/fetch_helpers.rs +++ b/rust/src/cli/usage/fetch_helpers.rs @@ -5,7 +5,8 @@ use super::render::{ render_brief_text, render_json_result, render_text_error, render_text_with_status, }; use crate::core::{ - ProviderFetchResult, ProviderId, TokenAccountStore, TokenAccountSupport, instantiate_provider, + ProviderFetchResult, ProviderId, SourceMode, TokenAccountKind, TokenAccountOverride, + TokenAccountStore, TokenAccountSupport, instantiate_provider, }; use crate::settings::ApiKeys; use crate::status::{ProviderStatus as StatusInfo, fetch_provider_status}; @@ -45,7 +46,8 @@ pub async fn fetch_provider_result( .fetch_status .then(|| fetch_provider_status(provider_id.cli_name())); let mut ctx = command.ctx.clone(); - if ctx.api_key.is_none() { + let account_projected = project_cli_account(provider_id, command.account.as_deref(), &mut ctx)?; + if !account_projected && ctx.api_key.is_none() { ctx.api_key = resolve_cli_api_key(provider_id, command.account.as_deref())?; } let result = provider.fetch_usage(&ctx).await?; @@ -57,6 +59,76 @@ pub async fn fetch_provider_result( Ok((result, status)) } +/// Apply the selected labeled account to the same route used by the desktop +/// shell. The three v0.65 account-source ports require provider-specific route +/// selection in addition to the shared credential injection. +fn project_cli_account( + provider: ProviderId, + account_ref: Option<&str>, + ctx: &mut crate::core::FetchContext, +) -> anyhow::Result { + if !matches!( + provider, + ProviderId::Kimi | ProviderId::Doubao | ProviderId::OpenCodeGo + ) { + return Ok(false); + } + let store = TokenAccountStore::new(); + let data = match store.load_provider(provider) { + Ok(data) => data, + Err(error) if account_ref.is_some() => { + return Err(error.into()); + } + Err(_) => return Ok(false), + }; + if data.accounts.is_empty() { + if account_ref.is_some() { + anyhow::bail!( + "No token accounts configured for {}", + provider.display_name() + ); + } + return Ok(false); + } + let account = if let Some(account_ref) = account_ref { + find_token_account(&data, account_ref)? + } else { + data.active_account().ok_or_else(|| { + anyhow::anyhow!("No active token account for {}", provider.display_name()) + })? + } + .clone(); + project_token_account(provider, &account, ctx); + Ok(true) +} + +pub(super) fn project_token_account( + provider: ProviderId, + account: &crate::core::TokenAccount, + ctx: &mut crate::core::FetchContext, +) { + let projected = TokenAccountOverride::from_account(provider, account.clone()); + ctx.token_account_kind = Some(projected.kind); + ctx.token_account_isolated = true; + ctx.api_key = projected + .env_override + .as_ref() + .and_then(|env| env.values().next().cloned()); + ctx.manual_cookie_header = projected.cookie_header; + ctx.auto_prefer_web = projected.kind == TokenAccountKind::Cookie; + + match (provider, projected.kind) { + (ProviderId::Kimi, _) => ctx.source_mode = SourceMode::Web, + (ProviderId::Doubao, _) => ctx.source_mode = SourceMode::OAuth, + (ProviderId::OpenCodeGo, TokenAccountKind::Cookie) + if ctx.source_mode == SourceMode::Auto => + { + ctx.source_mode = SourceMode::Web; + } + _ => {} + } +} + /// Resolve an API key from token accounts (active or `--account`) then stored keys. /// /// Token-account env injection takes precedence over `api_keys.json` so multi-key diff --git a/rust/src/cli/usage_tests.rs b/rust/src/cli/usage_tests.rs index d332131b81..7339b66a43 100644 --- a/rust/src/cli/usage_tests.rs +++ b/rust/src/cli/usage_tests.rs @@ -2,8 +2,9 @@ use super::*; use crate::core::{ - CostSnapshot, ProviderAccountData, ProviderDisplayDetail, ProviderInventoryItem, RateWindow, - TokenAccount, TokenAccountSupport, UsageSnapshot, + CostSnapshot, FetchContext, ProviderAccountData, ProviderDisplayDetail, ProviderId, + ProviderInventoryItem, RateWindow, SourceMode, TokenAccount, TokenAccountKind, + TokenAccountSupport, UsageSnapshot, }; use crate::providers::claude::claude_swap::ClaudeSwapAccount; use crate::status::{ProviderStatus as StatusInfo, StatusLevel}; @@ -175,6 +176,83 @@ fn openrouter_account_ref_resolves_labeled_key() { assert_eq!(by_index.token, "sk-or-v1-work"); } +#[test] +fn kimi_account_projection_forces_isolated_web_and_preserves_region() { + let account = TokenAccount::new("work", "selected-kimi-auth"); + let mut ctx = FetchContext { + source_mode: SourceMode::OAuth, + api_region: Some("international".into()), + api_key: Some("ambient-api-key".into()), + ..FetchContext::default() + }; + + super::fetch_helpers::project_token_account(ProviderId::Kimi, &account, &mut ctx); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("kimi-auth=selected-kimi-auth") + ); + assert_eq!(ctx.api_key, None); + assert_eq!(ctx.api_region.as_deref(), Some("international")); + assert!(ctx.token_account_isolated); +} + +#[test] +fn doubao_account_projection_uses_only_the_selected_ark_key() { + let account = TokenAccount::new("work", "selected-ark-key"); + let mut ctx = FetchContext { + source_mode: SourceMode::Cli, + api_key: Some("ambient-key".into()), + ..FetchContext::default() + }; + + super::fetch_helpers::project_token_account(ProviderId::Doubao, &account, &mut ctx); + + assert_eq!(ctx.source_mode, SourceMode::OAuth); + assert_eq!(ctx.api_key.as_deref(), Some("selected-ark-key")); + assert_eq!(ctx.token_account_kind, Some(TokenAccountKind::ApiKey)); + assert!(ctx.token_account_isolated); +} + +#[test] +fn opencodego_account_projection_distinguishes_api_and_cookie_routes() { + let mut api_ctx = FetchContext::default(); + super::fetch_helpers::project_token_account( + ProviderId::OpenCodeGo, + &TokenAccount::new("api", "selected-opencode-key"), + &mut api_ctx, + ); + assert_eq!(api_ctx.source_mode, SourceMode::Auto); + assert_eq!(api_ctx.api_key.as_deref(), Some("selected-opencode-key")); + assert_eq!(api_ctx.token_account_kind, Some(TokenAccountKind::ApiKey)); + assert!(!api_ctx.auto_prefer_web); + + let mut cookie_ctx = FetchContext::default(); + super::fetch_helpers::project_token_account( + ProviderId::OpenCodeGo, + &TokenAccount::new("web", "Cookie: session=selected-session"), + &mut cookie_ctx, + ); + assert_eq!(cookie_ctx.source_mode, SourceMode::Web); + assert_eq!( + cookie_ctx.manual_cookie_header.as_deref(), + Some("Cookie: session=selected-session") + ); + assert_eq!( + cookie_ctx.token_account_kind, + Some(TokenAccountKind::Cookie) + ); + + cookie_ctx.source_mode = SourceMode::Cli; + super::fetch_helpers::project_token_account( + ProviderId::OpenCodeGo, + &TokenAccount::new("api", "another-key"), + &mut cookie_ctx, + ); + assert_eq!(cookie_ctx.source_mode, SourceMode::Cli); +} + #[test] fn text_rendering_shows_sub_one_percent_usage() { let result = fetch_result(UsageSnapshot::new(RateWindow::new(0.4))); diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 868b66fde6..2dc59a177f 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -766,6 +766,13 @@ pub struct FetchContext { /// API key for providers that require authentication pub api_key: Option, + /// Type of the explicitly selected labeled token account, if any. + pub token_account_kind: Option, + + /// A selected account is an identity boundary: providers must not retry + /// another ambient credential or account after its credential fails. + pub token_account_isolated: bool, + /// Optional provider workspace/project scope from persisted settings. pub workspace_id: Option, @@ -801,6 +808,8 @@ impl Default for FetchContext { manual_cookie_header: None, manual_cookie_missing: false, api_key: None, + token_account_kind: None, + token_account_isolated: false, workspace_id: None, seat_credit_entitlement: None, api_region: None, diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 2b86ad8181..abd9cd2b86 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -20,6 +20,15 @@ pub enum TokenInjection { CookieHeader, /// Inject as environment variable Environment { key: String }, + /// Accept either an API key or a Cookie header, as with OpenCode Go. + EnvironmentOrCookie { key: String }, +} + +/// Credential route selected by a labeled account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenAccountKind { + Cookie, + ApiKey, } /// Support definition for a provider's token accounts @@ -330,6 +339,34 @@ impl TokenAccountSupport { requires_manual_cookie_source: false, cookie_name: None, }), + ProviderId::Kimi => Some(TokenAccountSupport { + title: "Web sessions", + subtitle: "Store labeled Kimi kimi-auth web sessions.", + placeholder: "kimi-auth value or Cookie: kimi-auth=...", + injection: TokenInjection::CookieHeader, + requires_manual_cookie_source: true, + cookie_name: Some("kimi-auth"), + }), + ProviderId::Doubao => Some(TokenAccountSupport { + title: "Ark API keys", + subtitle: "Store labeled Volcengine Ark API keys.", + placeholder: "Ark API key", + injection: TokenInjection::Environment { + key: "ARK_API_KEY".to_string(), + }, + requires_manual_cookie_source: false, + cookie_name: None, + }), + ProviderId::OpenCodeGo => Some(TokenAccountSupport { + title: "API keys or sessions", + subtitle: "Store labeled OpenCode Go API keys or Cookie headers.", + placeholder: "API key or Cookie: ...", + injection: TokenInjection::EnvironmentOrCookie { + key: "OPENCODE_API_KEY".to_string(), + }, + requires_manual_cookie_source: false, + cookie_name: None, + }), // These providers don't support token accounts ProviderId::Codex | ProviderId::Pi @@ -337,7 +374,6 @@ impl TokenAccountSupport { | ProviderId::Antigravity | ProviderId::Kiro | ProviderId::VertexAI - | ProviderId::Kimi | ProviderId::KimiK2 | ProviderId::JetBrains | ProviderId::Warp @@ -346,14 +382,12 @@ impl TokenAccountSupport { | ProviderId::Infini | ProviderId::Perplexity | ProviderId::Abacus - | ProviderId::OpenCodeGo | ProviderId::Kilo | ProviderId::Bedrock | ProviderId::Codebuff | ProviderId::CodeRabbit | ProviderId::DeepSeek | ProviderId::Windsurf - | ProviderId::Doubao | ProviderId::StepFun | ProviderId::Venice | ProviderId::OpenAIApi @@ -404,6 +438,12 @@ impl TokenAccountSupport { map.insert(key.clone(), token.to_string()); Some(map) } + TokenInjection::EnvironmentOrCookie { key } => { + let api_key = Self::normalized_opencodego_api_key(token)?; + let mut map = HashMap::new(); + map.insert(key.clone(), api_key); + Some(map) + } TokenInjection::CookieHeader => { // Check for Claude OAuth token if provider == ProviderId::Claude @@ -419,6 +459,43 @@ impl TokenAccountSupport { } } + fn normalized_opencodego_api_key(token: &str) -> Option { + let token = token.trim(); + let token = if token.len() >= 2 + && ((token.starts_with('"') && token.ends_with('"')) + || (token.starts_with('\'') && token.ends_with('\''))) + { + token[1..token.len() - 1].trim() + } else { + token + }; + if token.is_empty() + || token + .chars() + .any(|ch| ch.is_whitespace() || matches!(ch, '=' | ':')) + { + return None; + } + Some(token.to_string()) + } + + pub fn account_kind(provider: ProviderId, token: &str) -> TokenAccountKind { + if provider == ProviderId::OpenCodeGo { + if Self::normalized_opencodego_api_key(token).is_some() { + TokenAccountKind::ApiKey + } else { + TokenAccountKind::Cookie + } + } else if matches!( + Self::for_provider(provider).map(|support| support.injection), + Some(TokenInjection::Environment { .. }) + ) { + TokenAccountKind::ApiKey + } else { + TokenAccountKind::Cookie + } + } + /// Normalize a cookie header for a provider pub fn normalized_cookie_header(provider: ProviderId, token: &str) -> String { let trimmed = token.trim(); @@ -758,11 +835,13 @@ pub struct TokenAccountOverride { pub env_override: Option>, /// Cookie header to use pub cookie_header: Option, + pub kind: TokenAccountKind, } impl TokenAccountOverride { /// Create an override from an account pub fn from_account(provider: ProviderId, account: TokenAccount) -> Self { + let kind = TokenAccountSupport::account_kind(provider, &account.token); let env_override = TokenAccountSupport::env_override(provider, &account.token); let cookie_header = if env_override.is_none() { Some(TokenAccountSupport::normalized_cookie_header( @@ -778,6 +857,7 @@ impl TokenAccountOverride { account, env_override, cookie_header, + kind, } } } @@ -796,6 +876,9 @@ mod tests { assert!(TokenAccountSupport::is_supported(ProviderId::Copilot)); assert!(TokenAccountSupport::is_supported(ProviderId::OpenRouter)); assert!(TokenAccountSupport::is_supported(ProviderId::Grok)); + assert!(TokenAccountSupport::is_supported(ProviderId::Kimi)); + assert!(TokenAccountSupport::is_supported(ProviderId::Doubao)); + assert!(TokenAccountSupport::is_supported(ProviderId::OpenCodeGo)); assert!(!TokenAccountSupport::is_supported(ProviderId::Codex)); assert!(!TokenAccountSupport::is_supported(ProviderId::Gemini)); assert!(!TokenAccountSupport::is_supported(ProviderId::Hyper)); @@ -803,6 +886,59 @@ mod tests { assert!(!TokenAccountSupport::is_supported(ProviderId::Bifrost)); } + #[test] + fn upstream_account_sources_normalize_and_classify_selected_credentials() { + assert_eq!( + TokenAccountSupport::normalized_cookie_header(ProviderId::Kimi, "selected-session"), + "kimi-auth=selected-session" + ); + assert_eq!( + TokenAccountSupport::account_kind(ProviderId::Kimi, "selected-session"), + TokenAccountKind::Cookie + ); + assert_eq!( + TokenAccountSupport::account_kind(ProviderId::Doubao, "ark-key"), + TokenAccountKind::ApiKey + ); + assert_eq!( + TokenAccountSupport::account_kind(ProviderId::OpenCodeGo, "opencode-key"), + TokenAccountKind::ApiKey + ); + assert_eq!( + TokenAccountSupport::account_kind( + ProviderId::OpenCodeGo, + "Cookie: session=opencode-session" + ), + TokenAccountKind::Cookie + ); + assert_eq!( + TokenAccountSupport::env_override(ProviderId::OpenCodeGo, "opencode-key") + .and_then(|env| env.get("OPENCODE_API_KEY").cloned()) + .as_deref(), + Some("opencode-key") + ); + assert!( + TokenAccountSupport::env_override( + ProviderId::OpenCodeGo, + "Cookie: session=opencode-session" + ) + .is_none() + ); + for malformed in ["", " ", "Cookie: broken", "auth=fixture", "two words"] { + assert_eq!( + TokenAccountSupport::account_kind(ProviderId::OpenCodeGo, malformed), + TokenAccountKind::Cookie + ); + assert!(TokenAccountSupport::env_override(ProviderId::OpenCodeGo, malformed).is_none()); + } + assert_eq!( + TokenAccountSupport::env_override(ProviderId::OpenCodeGo, " 'go_key' ") + .and_then(|env| env.get("OPENCODE_API_KEY").cloned()) + .as_deref(), + Some("go_key") + ); + } + #[test] fn grok_token_accounts_route_bearer_and_cookie_credentials() { let bearer = diff --git a/rust/src/providers/doubao/mod.rs b/rust/src/providers/doubao/mod.rs index b11e6e34ad..e094e62188 100644 --- a/rust/src/providers/doubao/mod.rs +++ b/rust/src/providers/doubao/mod.rs @@ -888,6 +888,15 @@ impl Provider for DoubaoProvider { } async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + if ctx.token_account_isolated + && ctx.token_account_kind == Some(crate::core::TokenAccountKind::ApiKey) + { + let api_key = selected_ark_api_key(ctx)?; + return Ok(ProviderFetchResult::new( + self.fetch_api(&api_key).await?, + "api", + )); + } match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { if let Some(credentials) = Self::coding_plan_credentials(ctx.api_key.as_deref()) { @@ -945,6 +954,15 @@ impl Provider for DoubaoProvider { } } +fn selected_ark_api_key(ctx: &FetchContext) -> Result { + ctx.api_key + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) + .ok_or(ProviderError::AuthRequired) +} + fn resolve_api_key( explicit: Option<&str>, credential_target: &str, @@ -977,6 +995,25 @@ fn resolve_api_key( #[cfg(test)] mod tests { use super::*; + + #[test] + fn selected_ark_account_requires_its_projected_key() { + let isolated = FetchContext { + token_account_isolated: true, + token_account_kind: Some(crate::core::TokenAccountKind::ApiKey), + ..FetchContext::default() + }; + assert!(matches!( + selected_ark_api_key(&isolated), + Err(ProviderError::AuthRequired) + )); + + let selected = FetchContext { + api_key: Some(" selected-key ".into()), + ..isolated + }; + assert_eq!(selected_ark_api_key(&selected).unwrap(), "selected-key"); + } use reqwest::header::{HeaderMap, HeaderValue}; #[test] diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index e326afa3d6..70a704bf2f 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -263,7 +263,14 @@ impl KimiProvider { } fn auth_token_from_cookie_header(cookie_header: &str) -> Result { - for cookie in cookie_header.split(';') { + let header = cookie_header.trim(); + let header = header + .get(..7) + .filter(|prefix| prefix.eq_ignore_ascii_case("cookie:")) + .map(|_| &header[7..]) + .unwrap_or(header) + .trim(); + for cookie in header.split(';') { let cookie = cookie.trim(); if cookie.starts_with("kimi-auth=") || cookie.starts_with("authorization=") @@ -375,7 +382,12 @@ impl Provider for KimiProvider { } } - let usage = web::fetch_via_web(ctx.manual_cookie_header.as_deref(), region).await?; + let usage = web::fetch_via_web( + ctx.manual_cookie_header.as_deref(), + region, + ctx.token_account_isolated, + ) + .await?; Ok(ProviderFetchResult::new(usage, "web")) } SourceMode::OAuth => { @@ -384,7 +396,12 @@ impl Provider for KimiProvider { Ok(ProviderFetchResult::new(usage, "code-api")) } SourceMode::Web => { - let usage = web::fetch_via_web(ctx.manual_cookie_header.as_deref(), region).await?; + let usage = web::fetch_via_web( + ctx.manual_cookie_header.as_deref(), + region, + ctx.token_account_isolated, + ) + .await?; Ok(ProviderFetchResult::new(usage, "web")) } SourceMode::Cli => Err(ProviderError::UnsupportedSource(SourceMode::Cli)), diff --git a/rust/src/providers/kimi/web.rs b/rust/src/providers/kimi/web.rs index c5ed5b8305..b18644640a 100644 --- a/rust/src/providers/kimi/web.rs +++ b/rust/src/providers/kimi/web.rs @@ -132,7 +132,12 @@ fn browser_auth_token(region: KimiRegion) -> Option { pub(crate) async fn fetch_via_web( cookie_header: Option<&str>, region: KimiRegion, + account_isolated: bool, ) -> Result { + if account_isolated { + let token = selected_account_auth_token(cookie_header)?; + return fetch_via_web_token(&client()?, &token, region).await; + } let source = cookie_source(); if let Some(token) = cookie_header.and_then(|header| KimiProvider::auth_token_from_cookie_header(header).ok()) @@ -176,6 +181,12 @@ pub(crate) async fn fetch_via_web( Err(ProviderError::AuthRequired) } +fn selected_account_auth_token(cookie_header: Option<&str>) -> Result { + cookie_header + .and_then(|header| KimiProvider::auth_token_from_cookie_header(header).ok()) + .ok_or(ProviderError::AuthRequired) +} + fn client() -> Result { crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) @@ -327,6 +338,22 @@ pub(super) async fn fetch_subscription_for_enrichment_result( mod tests { use super::*; + #[test] + fn selected_session_rejects_missing_or_invalid_cookie_without_fallback() { + assert!(matches!( + selected_account_auth_token(None), + Err(ProviderError::AuthRequired) + )); + assert!(matches!( + selected_account_auth_token(Some("locale=en-US")), + Err(ProviderError::AuthRequired) + )); + assert_eq!( + selected_account_auth_token(Some("Cookie: kimi-auth=selected")).unwrap(), + "selected" + ); + } + fn static_desktop(_: KimiRegion) -> Option { Some("desktop-token".to_string()) } diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index d676e3dd94..29a39bfea6 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -477,6 +477,15 @@ impl Provider for OpenCodeGoProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching OpenCode Go usage"); + if ctx.token_account_isolated + && ctx.token_account_kind == Some(crate::core::TokenAccountKind::ApiKey) + && ctx.source_mode == SourceMode::Auto + { + let api_key = + usage_api::selected_account_api_key(ctx).ok_or(ProviderError::AuthRequired)?; + return usage_api::fetch(&self.client, ctx, &api_key, "api").await; + } + match ctx.source_mode { SourceMode::Auto => { // Local-first unless workspace/token scope asks for web first @@ -513,12 +522,16 @@ impl Provider for OpenCodeGoProvider { SourceMode::Web => self.fetch_web(ctx).await, SourceMode::Cli => self.fetch_local_with_balance(ctx).await, SourceMode::OAuth => { - let api_key = usage_api::resolve_api_key(ctx).ok_or_else(|| { - ProviderError::NotInstalled( + let api_key = if ctx.token_account_isolated { + usage_api::selected_account_api_key(ctx).ok_or(ProviderError::AuthRequired)? + } else { + usage_api::resolve_api_key(ctx).ok_or_else(|| { + ProviderError::NotInstalled( "Missing OpenCode Go API key. Add one in Settings or set OPENCODE_API_KEY." .to_string(), - ) - })?; + ) + })? + }; usage_api::fetch(&self.client, ctx, &api_key, "api").await } } diff --git a/rust/src/providers/opencodego/usage_api.rs b/rust/src/providers/opencodego/usage_api.rs index 00c5b59a2b..13427919d9 100644 --- a/rust/src/providers/opencodego/usage_api.rs +++ b/rust/src/providers/opencodego/usage_api.rs @@ -24,6 +24,10 @@ pub(super) fn resolve_api_key(ctx: &FetchContext) -> Option { }) } +pub(super) fn selected_account_api_key(ctx: &FetchContext) -> Option { + normalized_api_key(ctx.api_key.as_deref()) +} + pub(super) async fn fetch( client: &Client, ctx: &FetchContext, @@ -147,6 +151,25 @@ fn api_window( mod tests { use super::*; + #[test] + fn selected_account_key_does_not_consult_global_environment() { + let ctx = FetchContext { + token_account_isolated: true, + token_account_kind: Some(crate::core::TokenAccountKind::ApiKey), + api_key: Some(" selected-account-key ".into()), + ..FetchContext::default() + }; + assert_eq!( + selected_account_api_key(&ctx).as_deref(), + Some("selected-account-key") + ); + let missing = FetchContext { + api_key: None, + ..ctx + }; + assert_eq!(selected_account_api_key(&missing), None); + } + #[test] fn api_key_normalization_matches_upstream_settings_reader() { assert_eq!( From 6878cc01873b173d5bcd876e630062129161e17b Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:48:06 +0700 Subject: [PATCH 2/9] Fail closed for mismatched OpenCode Go account sources --- rust/src/providers/opencodego/mod.rs | 24 ++++++++++++++++++++---- rust/src/providers/opencodego/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index 29a39bfea6..b4f922b065 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -477,10 +477,7 @@ impl Provider for OpenCodeGoProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching OpenCode Go usage"); - if ctx.token_account_isolated - && ctx.token_account_kind == Some(crate::core::TokenAccountKind::ApiKey) - && ctx.source_mode == SourceMode::Auto - { + if selected_api_account_requires_api_route(ctx)? { let api_key = usage_api::selected_account_api_key(ctx).ok_or(ProviderError::AuthRequired)?; return usage_api::fetch(&self.client, ctx, &api_key, "api").await; @@ -550,6 +547,25 @@ impl Provider for OpenCodeGoProvider { } } +/// Keep a selected API-key account on its own identity. Explicit web and local +/// sources cannot represent that account, so reject them instead of fetching +/// browser or device-wide data under the selected account's label. +fn selected_api_account_requires_api_route(ctx: &FetchContext) -> Result { + if !ctx.token_account_isolated + || ctx.token_account_kind != Some(crate::core::TokenAccountKind::ApiKey) + { + return Ok(false); + } + + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => Ok(true), + SourceMode::Web | SourceMode::Cli => Err(ProviderError::Other(format!( + "Selected OpenCode Go API-key account is incompatible with explicit {:?} source", + ctx.source_mode + ))), + } +} + impl OpenCodeGoProvider { /// Auto prefers web when a workspace override or active token-account scope /// is present (upstream `requiresScopedWebStrategy`). diff --git a/rust/src/providers/opencodego/tests.rs b/rust/src/providers/opencodego/tests.rs index 880fd87e2b..05b2e14a0d 100644 --- a/rust/src/providers/opencodego/tests.rs +++ b/rust/src/providers/opencodego/tests.rs @@ -4,6 +4,32 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, }; +#[test] +fn selected_api_account_uses_api_in_auto_and_rejects_explicit_other_sources() { + let auto = FetchContext { + source_mode: SourceMode::Auto, + token_account_kind: Some(crate::core::TokenAccountKind::ApiKey), + token_account_isolated: true, + api_key: Some("selected-key".into()), + ..FetchContext::default() + }; + assert!(selected_api_account_requires_api_route(&auto).unwrap()); + assert_eq!( + usage_api::selected_account_api_key(&auto).as_deref(), + Some("selected-key") + ); + + for source in [SourceMode::Web, SourceMode::Cli] { + let context = FetchContext { + source_mode: source, + ..auto.clone() + }; + let error = selected_api_account_requires_api_route(&context).unwrap_err(); + assert!(matches!(error, ProviderError::Other(_))); + assert!(error.to_string().contains(&format!("{source:?}"))); + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct FakeLegacySession { workspace_id: String, From 220c80409e80af7a00e9e701011abc4640a3c535 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:03:14 +0700 Subject: [PATCH 3/9] Preserve token account credentials in fetch routing --- .../src-tauri/src/commands/providers.rs | 165 +++++++++--------- 1 file changed, 83 insertions(+), 82 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index cd958bdb97..f0cf85e8c2 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -86,97 +86,98 @@ pub(crate) fn build_fetch_context( let usage_source = SourceMode::parse(settings.usage_source(id)).unwrap_or_default(); let token_account_kind = token_override.as_ref().map(|account| account.kind); // Selected token-account key overrides a stored provider apiKey (upstream #2271 / #1183). - let api_key = active_token_api_key.or(stored_api_key); + let api_key = active_token_api_key.clone().or(stored_api_key); let has_kimi_code_api_key = id == ProviderId::Kimi && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); let has_opencodego_api_key = id == ProviderId::OpenCodeGo && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); - let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = - if id.cookie_domain().is_none() { - let source_mode = if active_token_env.is_some() { - SourceMode::OAuth - } else { - usage_source - }; - (source_mode, None, false) + let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = if id + .cookie_domain() + .is_none() + { + let source_mode = if active_token_env.is_some() { + SourceMode::OAuth } else { - match cookie_source { - // #433: an explicitly selected, non-empty Claude manual cookie is - // authoritative. Do not let an active OAuth token account silently - // replace it; this keeps tray refresh behavior aligned with diagnose, - // whose Claude Auto path tries the supplied Web cookie before OAuth. - "manual" - if provider.manual_cookie_precedes_token_account() - && stored_cookie - .as_deref() - .is_some_and(|cookie| !cookie.trim().is_empty()) => - { - (SourceMode::Web, stored_cookie.clone(), false) - } - _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), - "off" if provider_uses_oauth_without_cookies(id, usage_source) => { - (SourceMode::OAuth, None, false) - } - "off" - if (has_kimi_code_api_key || has_opencodego_api_key) - && usage_source == SourceMode::Auto => + usage_source + }; + (source_mode, None, false) + } else { + match cookie_source { + // #433: an explicitly selected, non-empty Claude manual cookie is + // authoritative. Do not let an active OAuth token account silently + // replace it; this keeps tray refresh behavior aligned with diagnose, + // whose Claude Auto path tries the supplied Web cookie before OAuth. + "manual" + if provider.manual_cookie_precedes_token_account() + && stored_cookie + .as_deref() + .is_some_and(|cookie| !cookie.trim().is_empty()) => + { + (SourceMode::Web, stored_cookie.clone(), false) + } + _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + "off" if provider_uses_oauth_without_cookies(id, usage_source) => { + (SourceMode::OAuth, None, false) + } + "off" + if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto => + { + (SourceMode::Auto, None, false) + } + // Droid/Factory: cookie-off must never scrape browser cookies. Map to + // Cli (API-only in the provider) so Auto does not fall through to web. + "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), + "off" => (SourceMode::Cli, None, false), + "manual" => { + let cookie_header = active_token_cookie.clone().or(stored_cookie); + let fails_closed_without_cookie = cookie_header.is_none() + && provider.manual_empty_cookie_policy() + == ManualEmptyCookiePolicy::FailClosedWeb; + let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto { - (SourceMode::Auto, None, false) - } - // Droid/Factory: cookie-off must never scrape browser cookies. Map to - // Cli (API-only in the provider) so Auto does not fall through to web. - "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), - "off" => (SourceMode::Cli, None, false), - "manual" => { - let cookie_header = active_token_cookie.or(stored_cookie); - let fails_closed_without_cookie = cookie_header.is_none() - && provider.manual_empty_cookie_policy() - == ManualEmptyCookiePolicy::FailClosedWeb; - let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) - && usage_source == SourceMode::Auto - { - SourceMode::Auto - } else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) - { - // Grok Switch writes ~/.grok/auth.json. Leftover grok.com - // cookies must not force Web, or Weekly/notifications keep - // showing the previous browser account. - mode - } else if cookie_header.is_some() { - SourceMode::Web - } else if fails_closed_without_cookie { - // The provider owns this policy; Web with no header means - // it fails closed instead of importing a browser account - // the user did not select. - SourceMode::Web - } else if provider_uses_oauth_without_cookies(id, usage_source) { - SourceMode::OAuth + SourceMode::Auto + } else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) { + // Grok Switch writes ~/.grok/auth.json. Leftover grok.com + // cookies must not force Web, or Weekly/notifications keep + // showing the previous browser account. + mode + } else if cookie_header.is_some() { + SourceMode::Web + } else if fails_closed_without_cookie { + // The provider owns this policy; Web with no header means + // it fails closed instead of importing a browser account + // the user did not select. + SourceMode::Web + } else if provider_uses_oauth_without_cookies(id, usage_source) { + SourceMode::OAuth + } else { + SourceMode::Cli + }; + (source_mode, cookie_header, fails_closed_without_cookie) + } + // `browser` is accepted as a legacy alias from older settings. + "auto" | "browser" | "web" => { + // Claude resolves its cached cookie and browser fallback inside + // the provider; other providers retain the shell fallback. + let cookie_header = active_token_cookie.clone().or(stored_cookie).or_else(|| { + if defer_provider_browser_cookie_lookup { + None } else { - SourceMode::Cli - }; - (source_mode, cookie_header, fails_closed_without_cookie) - } - // `browser` is accepted as a legacy alias from older settings. - "auto" | "browser" | "web" => { - // Claude resolves its cached cookie and browser fallback inside - // the provider; other providers retain the shell fallback. - let cookie_header = active_token_cookie.or(stored_cookie).or_else(|| { - if defer_provider_browser_cookie_lookup { - None - } else { - provider_cookie_domain(id, settings).and_then(|domain| { - codexbar::browser::cookies::get_cookie_header(domain) - .ok() - .filter(|h| !h.is_empty()) - }) - } - }); - (usage_source, cookie_header, false) - } - _ => (usage_source, stored_cookie, false), + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) + } + }); + (usage_source, cookie_header, false) } - }; + _ => (usage_source, stored_cookie, false), + } + }; // Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell // historically mapped "manual + no cookie" to Cli, which surfaces as From eddd57f5cc2119be51f93a973fefcaebbbd6e987 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:56:12 +0700 Subject: [PATCH 4/9] Share token account source routing policy --- .../src-tauri/src/commands/providers.rs | 36 ++++---- rust/src/cli/usage/fetch_helpers.rs | 15 +--- rust/src/core/token_accounts.rs | 89 ++++++++++++++++++- 3 files changed, 112 insertions(+), 28 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index f0cf85e8c2..2c2d251fe2 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -225,24 +225,28 @@ pub(crate) fn build_fetch_context( // These upstream account types are explicit identity selections. Keep the // provider's saved region/source settings intact, but project the selected // credential into the route required by that account. - let (source_mode, cookie_header, api_key) = match (id, token_account_kind) { - (ProviderId::Kimi, Some(_)) => (SourceMode::Web, active_token_cookie.clone(), None), - (ProviderId::Doubao, Some(_)) => (SourceMode::OAuth, None, active_token_api_key.clone()), - (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey)) - if usage_source == SourceMode::Auto => - { - (SourceMode::Auto, None, active_token_api_key.clone()) - } - (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey)) => { - (usage_source, cookie_header, api_key) - } - (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::Cookie)) - if usage_source == SourceMode::Auto => - { - (SourceMode::Web, active_token_cookie.clone(), api_key) + let (cookie_header, api_key) = match (id, token_account_kind, usage_source) { + (ProviderId::Kimi, Some(_), _) => (active_token_cookie.clone(), None), + (ProviderId::Doubao, Some(_), _) => (None, active_token_api_key.clone()), + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::ApiKey), + SourceMode::Auto, + ) => (None, active_token_api_key.clone()), + (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey), _) => { + (cookie_header, api_key) } - _ => (source_mode, cookie_header, api_key), + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::Cookie), + SourceMode::Auto, + ) => (active_token_cookie.clone(), api_key), + _ => (cookie_header, api_key), }; + let source_mode = token_override + .as_ref() + .and_then(|account| account.effective_source_mode(usage_source)) + .unwrap_or(source_mode); let token_account_isolated = token_override.is_some() && matches!( id, diff --git a/rust/src/cli/usage/fetch_helpers.rs b/rust/src/cli/usage/fetch_helpers.rs index e9e063f296..f8c519d86b 100644 --- a/rust/src/cli/usage/fetch_helpers.rs +++ b/rust/src/cli/usage/fetch_helpers.rs @@ -5,8 +5,8 @@ use super::render::{ render_brief_text, render_json_result, render_text_error, render_text_with_status, }; use crate::core::{ - ProviderFetchResult, ProviderId, SourceMode, TokenAccountKind, TokenAccountOverride, - TokenAccountStore, TokenAccountSupport, instantiate_provider, + ProviderFetchResult, ProviderId, TokenAccountKind, TokenAccountOverride, TokenAccountStore, + TokenAccountSupport, instantiate_provider, }; use crate::settings::ApiKeys; use crate::status::{ProviderStatus as StatusInfo, fetch_provider_status}; @@ -117,15 +117,8 @@ pub(super) fn project_token_account( ctx.manual_cookie_header = projected.cookie_header; ctx.auto_prefer_web = projected.kind == TokenAccountKind::Cookie; - match (provider, projected.kind) { - (ProviderId::Kimi, _) => ctx.source_mode = SourceMode::Web, - (ProviderId::Doubao, _) => ctx.source_mode = SourceMode::OAuth, - (ProviderId::OpenCodeGo, TokenAccountKind::Cookie) - if ctx.source_mode == SourceMode::Auto => - { - ctx.source_mode = SourceMode::Web; - } - _ => {} + if let Some(source_mode) = projected.effective_source_mode(ctx.source_mode) { + ctx.source_mode = source_mode; } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index abd9cd2b86..9c6566e465 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -3,7 +3,7 @@ //! Store and manage multiple accounts/tokens per provider. //! Supports parallel fetching and account switching. -use crate::core::ProviderId; +use crate::core::{ProviderId, SourceMode}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -860,6 +860,20 @@ impl TokenAccountOverride { kind, } } + + /// Normalize source selection for account types whose credential requires + /// a specific route. `None` leaves unrelated providers' source policy alone. + pub fn effective_source_mode(&self, requested: SourceMode) -> Option { + match (self.provider, self.kind, requested) { + (ProviderId::Kimi, _, _) => Some(SourceMode::Web), + (ProviderId::Doubao, _, _) => Some(SourceMode::OAuth), + (ProviderId::OpenCodeGo, TokenAccountKind::Cookie, SourceMode::Auto) => { + Some(SourceMode::Web) + } + (ProviderId::OpenCodeGo, _, _) => Some(requested), + _ => None, + } + } } /// Maximum number of accounts to fetch per provider @@ -939,6 +953,79 @@ mod tests { ); } + #[test] + fn selected_account_effective_source_normalization() { + let cases = [ + ( + ProviderId::Kimi, + "kimi-session", + SourceMode::Auto, + Some(SourceMode::Web), + ), + ( + ProviderId::Kimi, + "kimi-session", + SourceMode::OAuth, + Some(SourceMode::Web), + ), + ( + ProviderId::Kimi, + "kimi-session", + SourceMode::Cli, + Some(SourceMode::Web), + ), + ( + ProviderId::Doubao, + "ark-key", + SourceMode::Cli, + Some(SourceMode::OAuth), + ), + ( + ProviderId::Doubao, + "ark-key", + SourceMode::Web, + Some(SourceMode::OAuth), + ), + ( + ProviderId::OpenCodeGo, + "Cookie: session=web", + SourceMode::Auto, + Some(SourceMode::Web), + ), + ( + ProviderId::OpenCodeGo, + "Cookie: session=web", + SourceMode::Cli, + Some(SourceMode::Cli), + ), + ( + ProviderId::OpenCodeGo, + "api-key", + SourceMode::Auto, + Some(SourceMode::Auto), + ), + ( + ProviderId::OpenCodeGo, + "api-key", + SourceMode::Web, + Some(SourceMode::Web), + ), + ( + ProviderId::OpenCodeGo, + "api-key", + SourceMode::Cli, + Some(SourceMode::Cli), + ), + (ProviderId::OpenRouter, "api-key", SourceMode::Auto, None), + ]; + + for (provider, token, requested, expected) in cases { + let account = + TokenAccountOverride::from_account(provider, TokenAccount::new("selected", token)); + assert_eq!(account.effective_source_mode(requested), expected); + } + } + #[test] fn grok_token_accounts_route_bearer_and_cookie_credentials() { let bearer = From e0a4bdc2f12c0fbf1b8d8c6083f80bff7385ae77 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:13:47 +0700 Subject: [PATCH 5/9] Compute account source before moving credentials --- rust/src/cli/usage/fetch_helpers.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/src/cli/usage/fetch_helpers.rs b/rust/src/cli/usage/fetch_helpers.rs index f8c519d86b..7b75cada51 100644 --- a/rust/src/cli/usage/fetch_helpers.rs +++ b/rust/src/cli/usage/fetch_helpers.rs @@ -108,6 +108,7 @@ pub(super) fn project_token_account( ctx: &mut crate::core::FetchContext, ) { let projected = TokenAccountOverride::from_account(provider, account.clone()); + let effective_source_mode = projected.effective_source_mode(ctx.source_mode); ctx.token_account_kind = Some(projected.kind); ctx.token_account_isolated = true; ctx.api_key = projected @@ -117,7 +118,7 @@ pub(super) fn project_token_account( ctx.manual_cookie_header = projected.cookie_header; ctx.auto_prefer_web = projected.kind == TokenAccountKind::Cookie; - if let Some(source_mode) = projected.effective_source_mode(ctx.source_mode) { + if let Some(source_mode) = effective_source_mode { ctx.source_mode = source_mode; } } From a1980aeb40b24f3035754cec06a5551a526e54e6 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:48:49 +0700 Subject: [PATCH 6/9] Port upstream 0.67.0: Add Aixy provider (key budgets and 7-day usage) --- README.md | 1 + .../src/commands/provider_settings.rs | 40 +- .../src-tauri/src/commands/providers.rs | 7 +- .../providers/icons/ProviderIcon-aixy.svg | 1 + .../src/components/providers/providerIcons.ts | 3 + apps/desktop-tauri/src/i18n/keys.ts | 5 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 +- .../settings/providers/ProviderDetailPane.tsx | 14 +- .../sections/MenuBarMetricSection.test.tsx | 19 + .../sections/MenuBarMetricSection.tsx | 35 +- .../sections/WayfinderGatewaySection.test.tsx | 53 ++ .../sections/WayfinderGatewaySection.tsx | 43 +- .../providers/sections/usageSourcePolicy.ts | 6 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 24 + rust/src/core/provider.rs | 10 +- rust/src/core/provider_factory.rs | 34 +- rust/src/core/token_accounts.rs | 24 + rust/src/locale.rs | 5 + rust/src/locale/en-US.ftl | 5 + .../providers/aixy/fixtures/key-usage.json | 134 +++++ rust/src/providers/aixy/mod.rs | 215 ++++++++ rust/src/providers/aixy/model.rs | 411 ++++++++++++++ rust/src/providers/aixy/present.rs | 173 ++++++ rust/src/providers/aixy/tests.rs | 504 ++++++++++++++++++ rust/src/providers/bifrost/mod.rs | 16 +- rust/src/providers/mod.rs | 22 + rust/src/settings/api_keys.rs | 11 + 28 files changed, 1760 insertions(+), 58 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx create mode 100644 rust/src/providers/aixy/fixtures/key-usage.json create mode 100644 rust/src/providers/aixy/mod.rs create mode 100644 rust/src/providers/aixy/model.rs create mode 100644 rust/src/providers/aixy/present.rs create mode 100644 rust/src/providers/aixy/tests.rs diff --git a/README.md b/README.md index 2e052ad2f9..bd9e0de719 100755 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Grok | Cookies / auth.json | Billing | | Helmcode (also NaN Builders) | Browser cookies / manual Cookie header | Per-model token quotas, reset windows, Helmcode prepaid balance | | Replicate | Cookies / token accounts | Monthly spend, credit balance | +| Aixy | API Key / token accounts | Applicable budget balances, 7-day key usage | | ElevenLabs | API Key | Subscription Credits, Voice Slots | | Deepgram | API Key | Project Usage | | Groq | API Key | Enterprise Metrics | diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 3aa5518b59..314baa56d8 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -400,15 +400,48 @@ mod tests { ); } + #[test] + fn fetch_context_carries_saved_gateway_urls_for_every_gateway_provider() { + use codexbar::settings::{ApiKeys, ManualCookies, Settings}; + use std::collections::HashMap; + + let mut settings = Settings::default(); + for (id, url) in [ + (ProviderId::Wayfinder, "http://localhost:8787"), + (ProviderId::Bifrost, "https://bifrost.example.com"), + (ProviderId::Aixy, "https://aixy.example.com/prefix"), + ] { + settings.set_gateway_url(id, url); + let ctx = super::super::providers::build_fetch_context( + id, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.gateway_url.as_deref(), Some(url), "{id:?}"); + } + + let ctx = super::super::providers::build_fetch_context( + ProviderId::Codex, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.gateway_url, None); + } + #[test] fn maps_gitkraken_organization_provider() { assert_eq!(workspace_provider("gitkraken"), Some(ProviderId::GitKraken)); } #[test] - fn gateway_provider_exposes_wayfinder_and_bifrost_only() { + fn gateway_provider_exposes_gateway_providers_only() { assert_eq!(gateway_provider("wayfinder"), Some(ProviderId::Wayfinder)); assert_eq!(gateway_provider("bifrost"), Some(ProviderId::Bifrost)); + assert_eq!(gateway_provider("aixy"), Some(ProviderId::Aixy)); assert_eq!(gateway_provider("codex"), None); } @@ -466,6 +499,7 @@ fn gateway_provider(provider_id: &str) -> Option { match provider_id { "wayfinder" => Some(codexbar::core::ProviderId::Wayfinder), "bifrost" => Some(codexbar::core::ProviderId::Bifrost), + "aixy" => Some(codexbar::core::ProviderId::Aixy), _ => None, } } @@ -491,6 +525,10 @@ pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Res codexbar::providers::bifrost::validate_gateway_url(gateway_url) .map_err(|error| error.to_string())?; } + codexbar::core::ProviderId::Aixy => { + codexbar::providers::aixy::validate_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } _ => unreachable!("gateway_provider only returns gateway providers"), } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 779f9c801c..6ad574d3ca 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -214,8 +214,11 @@ pub(crate) fn build_fetch_context( let workspace_id = settings.workspace_id(id).trim().to_string(); let api_region = settings.api_region(id).trim().to_string(); - let gateway_url = (id == ProviderId::Wayfinder && !settings.gateway_url(id).is_empty()) - .then(|| settings.gateway_url(id).to_string()); + // Every gateway-style provider (Wayfinder, Bifrost, Aixy) stores its base + // URL here; providers without one report an empty string. + let gateway_url = Some(settings.gateway_url(id)) + .filter(|url| !url.is_empty()) + .map(str::to_owned); // Local-first Auto providers (OpenCode Go) flip to web-first when a // token account or manual cookie source scopes the session to web creds. let auto_prefer_web = token_override.is_some() || cookie_source == "manual"; diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg new file mode 100644 index 0000000000..224bb877f1 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg @@ -0,0 +1 @@ +Aixy diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index ee24a2c1f1..99ef91ee3d 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -20,6 +20,7 @@ import deepgram from "./icons/ProviderIcon-deepgram.svg?raw"; import deepinfra from "./icons/ProviderIcon-deepinfra.svg?raw"; import fireworks from "./icons/ProviderIcon-fireworks.svg?raw"; import aiand from "./icons/ProviderIcon-aiand.svg?raw"; +import aixy from "./icons/ProviderIcon-aixy.svg?raw"; import clinepass from "./icons/ProviderIcon-clinepass.svg?raw"; import longcat from "./icons/ProviderIcon-longcat.svg?raw"; import neuralwatt from "./icons/ProviderIcon-neuralwatt.svg?raw"; @@ -107,6 +108,7 @@ const RAW: Record = { deepinfra: tint(deepinfra), fireworks: tint(fireworks), aiand: tint(aiand), + aixy: tint(aixy), clinepass: tint(clinepass), longcat: tint(longcat), neuralwatt: tint(neuralwatt), @@ -186,6 +188,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { grok: { id: "grok", brandColor: "#111827", fallbackLetter: "G", svgPath: RAW.grok }, groq: { id: "groq", brandColor: "#f55036", fallbackLetter: "G", svgPath: RAW.groq }, bifrost: { id: "bifrost", brandColor: "#5b7cfa", fallbackLetter: "B" }, + aixy: { id: "aixy", brandColor: "#123650", fallbackLetter: "A", svgPath: RAW.aixy }, gitkraken: { id: "gitkraken", brandColor: "#179287", fallbackLetter: "G" }, huggingface: { id: "huggingface", brandColor: "#ffd21e", fallbackLetter: "H", svgPath: RAW.huggingface }, hyper: { id: "hyper", brandColor: "#7c3aed", fallbackLetter: "H" }, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 1c07040562..904dd541f8 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -96,6 +96,11 @@ export const ALL_LOCALE_KEYS = [ "WayfinderGatewayLabel", "WayfinderGatewayHelp", "WayfinderGatewayStatus", + "BifrostGatewayTitle", + "BifrostGatewayHelp", + "AixyGatewayTitle", + "AixyGatewayLabel", + "AixyGatewayHelp", "WayfinderModels", "WayfinderRequests", "WayfinderTokens", diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 09ead3c1d3..8e9c989a21 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -30,7 +30,7 @@ import { const HAS_DASHBOARD = new Set([ "abacus", "alibaba", "alibabatokenplan", "amp", "augment", "azureopenai", "bedrock", "claude", "codex", "codebuff", - "aiand", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", + "aiand", "aixy", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "replicate", "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index ae3e6766a5..5d8099a174 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -47,7 +47,10 @@ import { AccentColorSection } from "./sections/AccentColorSection"; import { ProviderIssueNotice } from "./sections/ProviderIssueNotice"; import { CredentialStorageSection } from "./sections/CredentialStorageSection"; import { CredentialsDispatcher } from "./sections/CredentialsDispatcher"; -import { WayfinderGatewaySection } from "./sections/WayfinderGatewaySection"; +import { + isGatewayProviderId, + WayfinderGatewaySection, +} from "./sections/WayfinderGatewaySection"; import { AzureApiVersionSection } from "./sections/AzureApiVersionSection"; interface Props { @@ -145,9 +148,8 @@ export function ProviderDetailPane({ } }, []); - const gatewayProviderId = providerId === "wayfinder" || providerId === "bifrost" - ? providerId - : null; + const gatewayProviderId = + providerId !== null && isGatewayProviderId(providerId) ? providerId : null; useEffect(() => { setGatewayLoadedProviderId(null); @@ -345,9 +347,10 @@ export function ProviderDetailPane({ t={t} onChanged={reload} /> - {(detail.id === "wayfinder" || detail.id === "bifrost") && + {isGatewayProviderId(detail.id) && gatewayLoadedProviderId === detail.id && ( void saveGateway()} t={t} - bifrost={detail.id === "bifrost"} /> )} { providerMetrics: { copilot: "extraUsage" }, }); }); + + it("offers only Automatic for Aixy, even with extra budget windows", () => { + const aixy = provider(); + aixy.id = "aixy"; + aixy.displayName = "Aixy"; + aixy.weekly = rateWindow(40); + render( + key} + onChange={vi.fn()} + />, + ); + + const options = screen.getAllByRole("option").map((option) => option.textContent); + expect(options).toEqual(["Automatic"]); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx index 7bc89e0d7d..f8ff1eb89f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -20,6 +20,8 @@ interface MetricOption { label: string; } +const AUTOMATIC_ONLY_PROVIDERS: ReadonlySet = new Set(["aixy"]); + export function MenuBarMetricSection({ provider, providerMetrics, @@ -76,11 +78,15 @@ function metricOptions( selected: MetricPreference, t: (key: LocaleKey) => string, ): MetricOption[] { - const options: MetricOption[] = [ - { value: "automatic", label: t("Automatic") }, - { value: "session", label: t("ProviderSessionLabel") }, - ]; + const options: MetricOption[] = [{ value: "automatic", label: t("Automatic") }]; + + // Aixy's primary budget depends on which limits currently apply to the key, + // so a fixed session/weekly lane would be misleading. Offer Automatic only. + if (AUTOMATIC_ONLY_PROVIDERS.has(provider.id)) { + return withSelected(options, selected, t); + } + options.push({ value: "session", label: t("ProviderSessionLabel") }); if (provider.weekly) { options.push({ value: "weekly", label: t("ProviderWeeklyLabel") }); } @@ -104,12 +110,21 @@ function metricOptions( if (provider.id === "gemini" && provider.weekly) { options.push({ value: "average", label: t("Average") }); } - if (!options.some((option) => option.value === selected)) { - options.push({ + return withSelected(options, selected, t); +} + +/** Keep a previously saved preference visible even if it is no longer offered. */ +function withSelected( + options: MetricOption[], + selected: MetricPreference, + t: (key: LocaleKey) => string, +): MetricOption[] { + if (options.some((option) => option.value === selected)) return options; + return [ + ...options, + { value: selected, label: selected === "credits" ? t("CreditsLabel") : selected, - }); - } - - return options; + }, + ]; } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx new file mode 100644 index 0000000000..1b95267446 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + isGatewayProviderId, + WayfinderGatewaySection, + type GatewayProviderId, +} from "./WayfinderGatewaySection"; + +function renderSection(providerId: GatewayProviderId, onSave = vi.fn()) { + render( + key} + />, + ); + return onSave; +} + +describe("WayfinderGatewaySection", () => { + it.each([ + ["wayfinder", "WayfinderGatewayTitle", "WayfinderGatewayLabel", "WayfinderGatewayHelp"], + ["bifrost", "BifrostGatewayTitle", "WayfinderGatewayLabel", "BifrostGatewayHelp"], + ["aixy", "AixyGatewayTitle", "AixyGatewayLabel", "AixyGatewayHelp"], + ] as const)("uses localized %s copy", (providerId, title, label, help) => { + renderSection(providerId); + + expect(screen.getByRole("heading", { name: title })).toBeInTheDocument(); + expect(screen.getByLabelText(label)).toHaveValue("https://gateway.example.com"); + expect(screen.getByText(help)).toBeInTheDocument(); + }); + + it("saves through the shared button", () => { + const onSave = renderSection("aixy"); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it("recognizes only gateway providers", () => { + expect(isGatewayProviderId("aixy")).toBe(true); + expect(isGatewayProviderId("bifrost")).toBe(true); + expect(isGatewayProviderId("wayfinder")).toBe(true); + expect(isGatewayProviderId("codex")).toBe(false); + expect(isGatewayProviderId("toString")).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx index 77b4067771..9c454f0ae1 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx @@ -1,6 +1,37 @@ import type { LocaleKey } from "../../../../i18n/keys"; +export type GatewayProviderId = "wayfinder" | "bifrost" | "aixy"; + +interface GatewayCopy { + title: LocaleKey; + label: LocaleKey; + help: LocaleKey; +} + +const GATEWAY_COPY: Record = { + wayfinder: { + title: "WayfinderGatewayTitle", + label: "WayfinderGatewayLabel", + help: "WayfinderGatewayHelp", + }, + bifrost: { + title: "BifrostGatewayTitle", + label: "WayfinderGatewayLabel", + help: "BifrostGatewayHelp", + }, + aixy: { + title: "AixyGatewayTitle", + label: "AixyGatewayLabel", + help: "AixyGatewayHelp", + }, +}; + +export function isGatewayProviderId(id: string): id is GatewayProviderId { + return Object.prototype.hasOwnProperty.call(GATEWAY_COPY, id); +} + interface Props { + providerId: GatewayProviderId; draft: string; error: string | null; busy: boolean; @@ -8,10 +39,10 @@ interface Props { onDraftChange: (draft: string) => void; onSave: () => void; t: (key: LocaleKey) => string; - bifrost?: boolean; } export function WayfinderGatewaySection({ + providerId, draft, error, busy, @@ -19,13 +50,13 @@ export function WayfinderGatewaySection({ onDraftChange, onSave, t, - bifrost = false, }: Props) { + const copy = GATEWAY_COPY[providerId]; return (
-

{bifrost ? "Bifrost gateway" : t("WayfinderGatewayTitle")}

+

{t(copy.title)}

-

- {bifrost ? "Base URL of your Bifrost gateway." : t("WayfinderGatewayHelp")} -

+

{t(copy.help)}

{error &&

{error}

}
)} {!provider.error && hasDisplayDetails && !compactOverview && ( -
- {provider.displayDetails?.map((detail, index) => ( - - ))} -
- )} - - {!provider.error && hasDisplayDetails && ( -
- {provider.displayDetails?.map((detail, index) => ( - - ))} -
+ displayDetailGroups.map((group) => ( +
+ {group.title && ( +
+ {group.title} +
+ )} + {group.rows.map((detail, index) => ( + + ))} +
+ )) )} {wayfinderUsage && !compactOverview && } @@ -786,26 +801,3 @@ export default function MenuCardDetails({ ); } - -function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { - const progress = detail.progress; - const progressPercent = progress && Number.isFinite(progress.used) && Number.isFinite(progress.total) && progress.total > 0 - ? Math.max(0, Math.min(100, (progress.used / progress.total) * 100)) - : null; - - return ( -
-
- {detail.title}: {detail.value} - {detail.secondaryValue && ( - {detail.secondaryValue} - )} -
- {progressPercent != null && ( -
-
-
- )} -
- ); -} diff --git a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx index 86982b66e4..b39fbe3ef3 100644 --- a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx +++ b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx @@ -1,5 +1,34 @@ import type { ProviderDisplayDetail } from "../types/bridge"; +const MAX_ROWS_PER_SECTION = 24; + +export interface ProviderDisplayDetailGroup { + id: number; + title: string | null; + rows: ProviderDisplayDetail[]; +} + +/** Group consecutive provider details and cap each rendered section. */ +export function groupProviderDisplayDetails( + details: ProviderDisplayDetail[], +): ProviderDisplayDetailGroup[] { + const groups: ProviderDisplayDetailGroup[] = []; + for (const detail of details) { + const title = detail.sectionTitle ?? null; + const current = groups[groups.length - 1]; + if ( + current && + current.title === title && + current.rows.length < MAX_ROWS_PER_SECTION + ) { + current.rows.push(detail); + } else { + groups.push({ id: groups.length, title, rows: [detail] }); + } + } + return groups; +} + /** * One transient provider detail line: "{title}: {value} [secondary]" * plus an optional clamped progress bar. @@ -34,11 +63,21 @@ export function ProviderDisplayRow({
{detail.title}: {detail.value} {detail.secondaryValue && secondaryClassName && ( - {detail.secondaryValue} + <> + {" "} + {detail.secondaryValue} + )}
{progressPercent != null && ( -
+
)} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx index 1c1315b9da..78494f71ec 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx @@ -132,7 +132,7 @@ describe("MenuBarMetricSection", () => { render( key} onChange={vi.fn()} @@ -141,5 +141,6 @@ describe("MenuBarMetricSection", () => { const options = screen.getAllByRole("option").map((option) => option.textContent); expect(options).toEqual(["Automatic"]); + expect(screen.getByRole("combobox")).toHaveValue("automatic"); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx index f8ff1eb89f..f0716229ce 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -30,7 +30,9 @@ export function MenuBarMetricSection({ onChange, }: Props) { const [error, setError] = useState(null); - const selected = providerMetrics[provider.id] ?? "automatic"; + const selected = AUTOMATIC_ONLY_PROVIDERS.has(provider.id) + ? "automatic" + : providerMetrics[provider.id] ?? "automatic"; const options = metricOptions(provider, selected, t); const handleChange = (value: MetricPreference) => { @@ -83,7 +85,7 @@ function metricOptions( // Aixy's primary budget depends on which limits currently apply to the key, // so a fixed session/weekly lane would be misleading. Offer Automatic only. if (AUTOMATIC_ONLY_PROVIDERS.has(provider.id)) { - return withSelected(options, selected, t); + return options; } options.push({ value: "session", label: t("ProviderSessionLabel") }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index b2d24dbdfc..e46e867a87 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -5,7 +5,10 @@ import type { RateWindowSnapshot, } from "../../../../types/bridge"; import { InventoryItemRow } from "../../../../components/InventoryRows"; -import { ProviderDisplayRow } from "../../../../components/ProviderDisplayRow"; +import { + groupProviderDisplayDetails, + ProviderDisplayRow, +} from "../../../../components/ProviderDisplayRow"; import type { LocaleKey } from "../../../../i18n/keys"; import { useFormattedResetTime } from "../../../../hooks/useFormattedResetTime"; import { isUsageItemVisible } from "../../../../lib/usageItemVisibility"; @@ -70,6 +73,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { const inventory = provider.inventory ?? []; const displayDetails = provider.displayDetails ?? []; + const displayDetailGroups = groupProviderDisplayDetails(displayDetails); if (bars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { return null; } @@ -94,14 +98,28 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { lineClassName="provider-usage-inventory" /> ))} - {displayDetails.map((detail) => ( - + {displayDetailGroups.map((group) => ( +
+ {group.title && ( +
+ {group.title} +
+ )} + {group.rows.map((detail) => ( + + ))} +
))} ); @@ -163,4 +181,4 @@ function UsageBar({ )}
); -} \ No newline at end of file +} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index fd8dd55109..233f7c0688 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -635,6 +635,7 @@ export interface ProviderDisplayProgress { /** Transient provider detail row; it is display-only and never quota math. */ export interface ProviderDisplayDetail { id: string; + sectionTitle: string | null; title: string; value: string; secondaryValue: string | null; diff --git a/rust/src/core/display_detail.rs b/rust/src/core/display_detail.rs index 1c80831aa3..cd5720d860 100644 --- a/rust/src/core/display_detail.rs +++ b/rust/src/core/display_detail.rs @@ -18,6 +18,7 @@ use crate::core::ProviderFetchResult; #[derive(Debug, Clone, PartialEq)] pub struct ProviderDisplayDetail { id: String, + section_title: Option, title: String, value: String, secondary_value: Option, @@ -40,6 +41,7 @@ impl ProviderDisplayDetail { ) -> Option { let row = Self { id: id.into(), + section_title: None, title: title.into(), value: value.into(), secondary_value: None, @@ -51,6 +53,16 @@ impl ProviderDisplayDetail { valid.then_some(row) } + /// Attach an optional display section title; rejects invalid text. + pub fn with_section_title(mut self, title: impl Into) -> Option { + let title = title.into(); + if !is_display_shape(&title, 128) { + return None; + } + self.section_title = Some(title); + Some(self) + } + /// Attach a validated secondary value; rejects invalid text. pub fn with_secondary_value(mut self, value: impl Into) -> Option { let value = value.into(); @@ -74,6 +86,10 @@ impl ProviderDisplayDetail { &self.id } + pub fn section_title(&self) -> Option<&str> { + self.section_title.as_deref() + } + pub fn title(&self) -> &str { &self.title } diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index ebbdcb8c20..3014476745 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -758,6 +758,24 @@ mod tests { assert_eq!(details[0].value(), "12"); } + #[test] + fn display_details_validate_optional_section_titles() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let row = ProviderDisplayDetail::new("budget", "Project", "$10 remaining") + .and_then(|row| row.with_section_title("Applicable budgets")); + let result = ProviderFetchResult::new(usage, "api").with_display_detail(row); + + assert_eq!( + result.display_details()[0].section_title(), + Some("Applicable budgets") + ); + assert!( + ProviderDisplayDetail::new("budget", "Project", "$10") + .and_then(|row| row.with_section_title("\n")) + .is_none() + ); + } + #[test] fn cost_snapshot_ignores_non_finite_values() { let cost = CostSnapshot::new(f64::NAN, "USD", "Monthly").with_limit(f64::INFINITY); diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index bae2c814c3..9d51ff351a 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Gateway de Wayfinder WayfinderGatewayLabel = URL del gateway WayfinderGatewayHelp = Usa HTTP solo para localhost o direcciones de loopback. HTTPS permite hosts remotos. WayfinderGatewayStatus = Gateway +AixyGatewayTitle = Gateway de Aixy +AixyGatewayLabel = URL base +AixyGatewayHelp = Déjalo vacío para usar el gateway alojado de Aixy o introduce la URL base de uno propio. HTTP solo se permite para localhost, redes privadas y hosts .local. WayfinderModels = Modelos WayfinderRequests = Solicitudes WayfinderTokens = Tokens diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 92618f72f7..2b1651507d 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Wayfinder ゲートウェイ WayfinderGatewayLabel = ゲートウェイ URL WayfinderGatewayHelp = HTTP は localhost またはループバックアドレスでのみ使用できます。リモートホストには HTTPS を使用します。 WayfinderGatewayStatus = ゲートウェイ +AixyGatewayTitle = Aixy ゲートウェイ +AixyGatewayLabel = ベース URL +AixyGatewayHelp = ホスト型 Aixy ゲートウェイを使う場合は空欄にするか、自分でホストするゲートウェイのベース URL を入力してください。HTTP は localhost、プライベートネットワーク、.local ホストでのみ使用できます。 WayfinderModels = モデル WayfinderRequests = リクエスト WayfinderTokens = トークン diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 7f59c6fc29..6988fded06 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Wayfinder 게이트웨이 WayfinderGatewayLabel = 게이트웨이 URL WayfinderGatewayHelp = HTTP는 localhost 또는 루프백 주소에서만 사용하세요. 원격 호스트에는 HTTPS를 사용합니다. WayfinderGatewayStatus = 게이트웨이 +AixyGatewayTitle = Aixy 게이트웨이 +AixyGatewayLabel = 기본 URL +AixyGatewayHelp = 호스팅된 Aixy 게이트웨이를 사용하려면 비워 두거나 자체 호스팅 게이트웨이의 기본 URL을 입력하세요. HTTP는 localhost, 사설 네트워크 및 .local 호스트에서만 허용됩니다. WayfinderModels = 모델 WayfinderRequests = 요청 WayfinderTokens = 토큰 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 855f7a9eb6..afc216937e 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -64,6 +64,9 @@ WayfinderGatewayTitle = Шлюз Wayfinder WayfinderGatewayLabel = URL-адрес шлюза WayfinderGatewayHelp = Используйте HTTP только для локальных адресов или адресов обратной связи. HTTPS разрешен для удаленных хостов. WayfinderGatewayStatus = шлюз +AixyGatewayTitle = Шлюз Aixy +AixyGatewayLabel = Базовый URL +AixyGatewayHelp = Оставьте пустым для размещенного шлюза Aixy или укажите базовый URL собственного шлюза. HTTP разрешен только для localhost, частных сетей и хостов .local. WayfinderModels = Модели WayfinderRequests = Запросы WayfinderTokens = Токены diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 9daf93d809..d11c4241ae 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -397,8 +397,15 @@ fn test_english_is_complete_and_other_languages_can_fallback() { .map(|(locale, resource)| (locale, resource_key_names(resource))) .collect(); let locale_key_names: HashSet<&str> = LocaleKey::ALL.iter().map(|(_, name)| *name).collect(); + let aixy_gateway_keys = ["AixyGatewayTitle", "AixyGatewayLabel", "AixyGatewayHelp"]; for (locale, keys) in &resource_keys { + for name in aixy_gateway_keys { + assert!( + keys.contains(name), + "missing Aixy gateway Fluent key {name} in {locale}" + ); + } for name in keys { assert!( locale_key_names.contains(name), diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index ca4fabe791..fa2f864995 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -86,6 +86,9 @@ WayfinderGatewayTitle = Wayfinder Ağ Geçidi WayfinderGatewayLabel = Ağ Geçidi URL'si WayfinderGatewayHelp = HTTP'yi yalnızca localhost veya geri döngü adresleri için kullanın. Uzak sunucular için HTTPS kullanılabilir. WayfinderGatewayStatus = Ağ Geçidi +AixyGatewayTitle = Aixy Ağ Geçidi +AixyGatewayLabel = Temel URL +AixyGatewayHelp = Barındırılan Aixy ağ geçidini kullanmak için boş bırakın veya kendi ağ geçidinizin temel URL'sini girin. HTTP yalnızca localhost, özel ağlar ve .local ana bilgisayarları için kullanılabilir. WayfinderModels = Modeller WayfinderRequests = İstekler WayfinderTokens = Tokenlar diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 98dd870db8..f8441f203d 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Wayfinder 网关 WayfinderGatewayLabel = 网关 URL WayfinderGatewayHelp = HTTP 仅可用于 localhost 或回环地址。远程主机请使用 HTTPS。 WayfinderGatewayStatus = 网关 +AixyGatewayTitle = Aixy 网关 +AixyGatewayLabel = 基础 URL +AixyGatewayHelp = 留空以使用托管的 Aixy 网关,或输入自托管网关的基础 URL。HTTP 仅允许用于 localhost、专用网络和 .local 主机。 WayfinderModels = 模型 WayfinderRequests = 请求 WayfinderTokens = 令牌 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index f893d5b423..e7af97178b 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Wayfinder 閘道 WayfinderGatewayLabel = 閘道 URL WayfinderGatewayHelp = HTTP 僅可用於 localhost 或迴路位址。遠端主機請使用 HTTPS。 WayfinderGatewayStatus = 閘道 +AixyGatewayTitle = Aixy 閘道 +AixyGatewayLabel = 基礎 URL +AixyGatewayHelp = 留空以使用託管的 Aixy 閘道,或輸入自架閘道的基礎 URL。HTTP 僅允許用於 localhost、私人網路和 .local 主機。 WayfinderModels = 模型 WayfinderRequests = 請求 WayfinderTokens = 權杖 diff --git a/rust/src/providers/aixy/present.rs b/rust/src/providers/aixy/present.rs index be49923ef9..87f5669478 100644 --- a/rust/src/providers/aixy/present.rs +++ b/rust/src/providers/aixy/present.rs @@ -81,22 +81,24 @@ fn rate_window(budget: &Budget) -> RateWindow { fn details(usage: &KeyUsage) -> Vec> { let mut rows = vec![ - ProviderDisplayDetail::new("key", "Key", &usage.key_label), - ProviderDisplayDetail::new("project", "Project", &usage.project_label), + ProviderDisplayDetail::new("key", "Key", &usage.key_label) + .and_then(|row| row.with_section_title("Aixy key")), + ProviderDisplayDetail::new("project", "Project", &usage.project_label) + .and_then(|row| row.with_section_title("Aixy key")), ProviderDisplayDetail::new( "observed", "Observed", usage.as_of.to_rfc3339_opts(SecondsFormat::Millis, true), - ), + ) + .and_then(|row| row.with_section_title("Aixy key")), ]; rows.extend(usage.budgets.iter().enumerate().map(budget_row)); match &usage.totals { Some(totals) => rows.extend(totals_rows(totals)), - None => rows.push(ProviderDisplayDetail::new( - "usage-7d", - "Usage (last 7 days)", - "Unavailable", - )), + None => rows.push( + ProviderDisplayDetail::new("usage-7d", "Usage (last 7 days)", "Unavailable") + .and_then(|row| row.with_section_title("Last 7 days · this key")), + ), } rows } @@ -105,7 +107,8 @@ fn budget_row((index, budget): (usize, &Budget)) -> Option Option Vec> { @@ -127,19 +131,22 @@ fn totals_rows(totals: &Totals) -> Vec> { "requests-7d", "Requests (last 7 days)", count(totals.requests), - ), + ) + .and_then(|row| row.with_section_title("Last 7 days · this key")), ProviderDisplayDetail::new( "tokens-7d", "Tokens (last 7 days)", count(totals.total_tokens), - ), + ) + .and_then(|row| row.with_section_title("Last 7 days · this key")), ProviderDisplayDetail::new( "spend-7d", "Attributed spend (last 7 days)", totals .spend_usd .map_or_else(|| "Unavailable".to_owned(), usd), - ), + ) + .and_then(|row| row.with_section_title("Last 7 days · this key")), ProviderDisplayDetail::new( "coverage-7d", "Cost coverage (last 7 days)", @@ -151,7 +158,8 @@ fn totals_rows(totals: &Totals) -> Vec> { ) .and_then(|row| { row.with_secondary_value(format!("{} partial", count(totals.partial_requests))) - }), + }) + .and_then(|row| row.with_section_title("Last 7 days · this key")), ] } diff --git a/rust/src/providers/aixy/tests.rs b/rust/src/providers/aixy/tests.rs index 72175225b8..f0b6f6ecb9 100644 --- a/rust/src/providers/aixy/tests.rs +++ b/rust/src/providers/aixy/tests.rs @@ -85,12 +85,14 @@ fn key_usage_keeps_overlapping_budgets_and_reservations_separate() { assert_eq!(result.source_label, "api"); assert_eq!(detail(&result, "key").value(), "Developer CLI"); + assert_eq!(detail(&result, "key").section_title(), Some("Aixy key")); assert_eq!(detail(&result, "project").value(), "Engineering"); assert_eq!( detail(&result, "observed").value(), "2026-09-24T12:00:00.000Z" ); let hard = detail(&result, "budget-0"); + assert_eq!(hard.section_title(), Some("Applicable budgets")); assert_eq!(hard.title(), "Project · Monthly · Shared · Hard"); assert_eq!(hard.value(), "$70.00 / $100.00 remaining"); assert_eq!( @@ -106,6 +108,7 @@ fn key_usage_keeps_overlapping_budgets_and_reservations_separate() { assert_eq!(detail(&result, "tokens-7d").value(), "1,200"); assert_eq!(detail(&result, "spend-7d").value(), "$1.25"); let coverage = detail(&result, "coverage-7d"); + assert_eq!(coverage.section_title(), Some("Last 7 days · this key")); assert_eq!(coverage.value(), "10 / 12 requests"); assert_eq!(coverage.secondary_value(), Some("2 partial")); } @@ -337,11 +340,11 @@ fn usage_url_supports_every_documented_base_form() { ), ("aixy.example.com", "https://aixy.example.com/v1/usage"), ("http://localhost:8080", "http://localhost:8080/v1/usage"), - ("http://10.1.2.3:8080/v1", "http://10.1.2.3:8080/v1/usage"), ( "http://gateway.local/team", "http://gateway.local/team/v1/usage", ), + ("http://10.1.2.3:8080/v1", "http://10.1.2.3:8080/v1/usage"), ] { assert_eq!(usage_url(base).unwrap().as_str(), expected, "{base}"); } From 250f1d8c3313911352fa68f09e554bc95f4151c1 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:55:19 +0700 Subject: [PATCH 8/9] Fix the no-cookie-domain branch after the #619 merge --- .../src-tauri/src/commands/providers.rs | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index dd2b778721..2adfdffaf0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -95,8 +95,8 @@ pub(crate) fn build_fetch_context( .cookie_domain() .is_none() { - let source_mode = if active_token_env.is_some() { - SourceMode::OAuth + let (source_mode, cookie_header, missing_cookie) = if active_token_env.is_some() { + (SourceMode::OAuth, None, false) } else { match cookie_source { // #433: an explicitly selected, non-empty Claude manual cookie is @@ -118,7 +118,7 @@ pub(crate) fn build_fetch_context( // API-key fallback. "off" | "manual" if provider.cookie_source_scopes_session_only() => { let cookie_header = if cookie_source == "manual" { - active_token_cookie.or(stored_cookie) + active_token_cookie.clone().or(stored_cookie) } else { None }; @@ -144,7 +144,7 @@ pub(crate) fn build_fetch_context( "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), "off" => (SourceMode::Cli, None, false), "manual" => { - let cookie_header = active_token_cookie.or(stored_cookie); + let cookie_header = active_token_cookie.clone().or(stored_cookie); let fails_closed_without_cookie = cookie_header.is_none() && provider.manual_empty_cookie_policy() == ManualEmptyCookiePolicy::FailClosedWeb; @@ -176,23 +176,24 @@ pub(crate) fn build_fetch_context( "auto" | "browser" | "web" => { // Claude resolves its cached cookie and browser fallback inside // the provider; other providers retain the shell fallback. - let cookie_header = active_token_cookie.or(stored_cookie).or_else(|| { - if defer_provider_browser_cookie_lookup { - None - } else { - provider_cookie_domain(id, settings).and_then(|domain| { - codexbar::browser::cookies::get_cookie_header(domain) - .ok() - .filter(|h| !h.is_empty()) - }) - } - }); + let cookie_header = + active_token_cookie.clone().or(stored_cookie).or_else(|| { + if defer_provider_browser_cookie_lookup { + None + } else { + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) + } + }); (usage_source, cookie_header, false) } _ => (usage_source, stored_cookie, false), } }; - (source_mode, None, false) + (source_mode, cookie_header, missing_cookie) } else { match cookie_source { // #433: an explicitly selected, non-empty Claude manual cookie is From 4663218def72a806c3bd35053d8e2270426611e2 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:28:46 +0700 Subject: [PATCH 9/9] Fix the #619 merge resolution in build_fetch_context --- .../src-tauri/src/commands/providers.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 2adfdffaf0..2a487504e4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -209,6 +209,24 @@ pub(crate) fn build_fetch_context( (SourceMode::Web, stored_cookie.clone(), false) } _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + // Charm Hyper: the cookie source only picks the session, and + // the usage source keeps routing. Off and an empty Manual + // source never import a browser session, while Auto keeps its + // API-key fallback. + "off" | "manual" if provider.cookie_source_scopes_session_only() => { + let cookie_header = if cookie_source == "manual" { + active_token_cookie.clone().or(stored_cookie) + } else { + None + }; + let source_mode = if provider.available_sources().contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + let cookie_missing = cookie_header.is_none(); + (source_mode, cookie_header, cookie_missing) + } "off" if provider_uses_oauth_without_cookies(id, usage_source) => { (SourceMode::OAuth, None, false) } @@ -274,8 +292,14 @@ pub(crate) fn build_fetch_context( // Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell // historically mapped "manual + no cookie" to Cli, which surfaces as // "Source mode 'Cli' not supported". Remap to Web and try browser cookies - // unless the user explicitly disabled cookies ("off"). - if source_mode == SourceMode::Cli && cookie_source != "off" && !provider.supports_cli() { + // unless the user explicitly disabled cookies ("off"). Providers whose + // cookie source only scopes the session (Charm Hyper) own this contract in + // the provider, so the shell must not remap their source mode. + if source_mode == SourceMode::Cli + && cookie_source != "off" + && !provider.supports_cli() + && !provider.cookie_source_scopes_session_only() + { if cookie_header .as_deref() .map(str::trim)