diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 8b4794b1e6..789f8f8467 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -83,23 +83,28 @@ 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 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, cookie_header, missing_cookie) = if active_token_env.is_some() { + (SourceMode::OAuth, None, false) } else { match cookie_source { + // Opt-in web providers keep their default credential lane + // unless the usage source is explicitly Web; a stored or + // browser cookie must not turn Auto into Web. + _ if provider.web_is_opt_in() && usage_source != SourceMode::Web => { + (usage_source, None, false) + } // #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, @@ -119,7 +124,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 }; @@ -145,7 +150,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; @@ -177,28 +182,136 @@ 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, cookie_header, missing_cookie) + } 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), + // Opt-in web providers keep their default credential lane + // unless the usage source is explicitly Web; a stored or + // browser cookie must not turn Auto into Web. + _ if provider.web_is_opt_in() && usage_source != SourceMode::Web => { + (usage_source, 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) + } + "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 + } 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 { + 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 // "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) @@ -237,16 +350,51 @@ 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 (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) + } + ( + 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, + 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 b18b7c949a..f9627a19c3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -478,6 +478,55 @@ fn fetch_context_grok_explicit_web_still_uses_manual_cookie() { ); } +#[test] +fn fetch_context_zed_default_and_stored_cookie_keep_editor_credential_lane() { + // Zed browser billing is opt-in: neither the default manual cookie source + // nor a stored cookie may turn Auto into Web. + let ctx = super::build_fetch_context( + ProviderId::Zed, + &Settings::default(), + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!(ctx.manual_cookie_header.is_none()); + assert!(!ctx.manual_cookie_missing); + + let mut cookies = ManualCookies::default(); + cookies.set("zed", "zed.session=stored"); + let ctx = super::build_fetch_context( + ProviderId::Zed, + &Settings::default(), + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!(ctx.manual_cookie_header.is_none()); +} + +#[test] +fn fetch_context_zed_explicit_web_uses_stored_cookie() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Zed, "web"); + let mut cookies = ManualCookies::default(); + cookies.set("zed", "zed.session=stored"); + let ctx = super::build_fetch_context( + ProviderId::Zed, + &settings, + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("zed.session=stored") + ); +} + #[test] fn fetch_context_opencode_empty_manual_remaps_to_web() { let settings = Settings::default(); @@ -496,6 +545,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/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 0ad26af95b..bd58748d69 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -284,6 +284,10 @@ export const ALL_LOCALE_KEYS = [ "MenuBarMetric", "MenuBarMetricHelper", "UsageSource", + "ProviderZedBrowserSession", + "ProviderZedUsageSourceAutoHelp", + "ProviderZedUsageSourceApiHelp", + "ProviderZedUsageSourceWebHelp", "ProviderNoCodexAccountsDetected", "ProviderCodexAutoImportHelp", "ProviderCodexHistoryHelp", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.test.tsx index 77087ce037..76b32b2691 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.test.tsx @@ -32,6 +32,12 @@ describe("usage source policy", () => { "oauth", "web", ]); + expect(usageSourcePolicy("zed")?.options.map((option) => option.value)).toEqual([ + "auto", + "oauth", + "web", + ]); + expect(usageSourcePolicy("zed")?.options[0].description).toContain("editor credential"); expect(usageSourcePolicy("antigravity")?.options[0].description).toContain( "skips agy reports without account identity", ); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.tsx index d8acbb4a28..eccd7bf0a2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.tsx @@ -57,12 +57,16 @@ export function UsageSourceSection({ className={`provider-detail-segmented__option${isActive ? " is-active" : ""}`} onClick={() => void handleSelect(option.value)} > - {option.label} + {option.labelKey ? t(option.labelKey) : option.label} ); })} -

{selectedOption.description}

+

+ {selectedOption.descriptionKey + ? t(selectedOption.descriptionKey) + : selectedOption.description} +

{error &&

{error}

} ); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts b/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts index 57ab1c71d5..f5cc72dc62 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts @@ -1,7 +1,11 @@ +import type { LocaleKey } from "../../../../i18n/keys"; + export interface UsageSourceOption { value: string; label: string; + labelKey?: LocaleKey; description: string; + descriptionKey?: LocaleKey; } export interface UsageSourcePolicy { @@ -53,6 +57,31 @@ const POLICIES: Readonly> = { { value: "oauth", label: "API", description: "Uses the configured Charm Hyper API key only." }, ], }, + zed: { + options: [ + { + value: "auto", + label: "Auto", + labelKey: "ProviderSourceAutoShort", + description: "Uses the Zed editor credential; the browser session is used only when Browser session is selected.", + descriptionKey: "ProviderZedUsageSourceAutoHelp", + }, + { + value: "oauth", + label: "API", + labelKey: "ProviderSourceApiShort", + description: "Uses the Zed editor credential only.", + descriptionKey: "ProviderZedUsageSourceApiHelp", + }, + { + value: "web", + label: "Browser session", + labelKey: "ProviderZedBrowserSession", + description: "Reads token spend from the zed.dev browser session or manual cookie header only, with no editor-credential fallback.", + descriptionKey: "ProviderZedUsageSourceWebHelp", + }, + ], + }, gitkraken: { options: [ { value: "auto", label: "Auto", description: "Uses the configured GitKraken access token." }, 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..7b75cada51 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, 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,70 @@ 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()); + 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 + .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; + + if let Some(source_mode) = effective_source_mode { + ctx.source_mode = source_mode; + } +} + /// 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 87db192575..0b2b160133 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -443,7 +443,7 @@ impl ProviderId { ProviderId::LiteLLM => None, ProviderId::Poe => None, ProviderId::Devin => None, - ProviderId::Zed => None, + ProviderId::Zed => Some("zed.dev"), ProviderId::CrossModel => None, ProviderId::Sub2Api => None, ProviderId::Wayfinder => None, @@ -768,6 +768,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, @@ -803,6 +810,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, @@ -911,6 +920,14 @@ pub trait Provider: Send + Sync { false } + /// Whether the web lane is used only when the usage source is explicitly + /// `web`. A cookie domain otherwise lets the shell turn Auto into Web + /// (manual cookie present or browser import), which would replace a + /// provider's default non-web credential. + fn web_is_opt_in(&self) -> bool { + false + } + /// Whether the cookie source only scopes the browser session the /// provider may use, leaving the selected usage source in charge of /// routing. @@ -1358,6 +1375,7 @@ mod tests { Some("aistudio.google.com") ); assert_eq!(ProviderId::Kiro.cookie_domain(), Some("kiro.dev")); + assert_eq!(ProviderId::Zed.cookie_domain(), Some("zed.dev")); assert_eq!(ProviderId::Kimi.cookie_domain(), Some("kimi.moonshot.cn")); assert_eq!(ProviderId::OpenCode.cookie_domain(), Some("opencode.ai")); assert_eq!(ProviderId::Venice.cookie_domain(), Some("venice.ai")); diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 2b86ad8181..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; @@ -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,21 @@ impl TokenAccountOverride { account, env_override, cookie_header, + 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, } } } @@ -796,6 +890,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 +900,132 @@ 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 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 = diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 6b43e88960..0991a0cde8 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -526,6 +526,10 @@ locale_keys! { MenuBarMetric, MenuBarMetricHelper, UsageSource, + ProviderZedBrowserSession, + ProviderZedUsageSourceAutoHelp, + ProviderZedUsageSourceApiHelp, + ProviderZedUsageSourceWebHelp, ProviderNoCodexAccountsDetected, ProviderCodexAutoImportHelp, ProviderCodexHistoryHelp, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 1a99574daf..09e0879080 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -271,6 +271,10 @@ ProviderOptionsTitle = Options MenuBarMetric = Menu and floating bar metric MenuBarMetricHelper = Choose which window drives the menu bar and floating bar percentages. UsageSource = Usage source +ProviderZedBrowserSession = Browser session +ProviderZedUsageSourceAutoHelp = Uses the Zed editor credential; browser billing is used only when Browser session is selected. +ProviderZedUsageSourceApiHelp = Uses the Zed editor credential only. +ProviderZedUsageSourceWebHelp = Reads token spend only from the selected zed.dev browser session or saved Cookie header; it never falls back to the editor credential. ProviderNoCodexAccountsDetected = No Codex accounts detected yet. ProviderCodexAutoImportHelp = Automatic imports browser cookies for dashboard extras. ProviderCodexHistoryHelp = Stores local Codex usage history (8 weeks) to personalize Pace predictions. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 5ba578c95f..a4bc9f309e 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -262,6 +262,10 @@ ProviderOptionsTitle = Opciones MenuBarMetric = Métrica de las barras de menú y flotante MenuBarMetricHelper = Elige qué ventana controla los porcentajes de las barras de menú y flotante. UsageSource = Fuente de uso +ProviderZedBrowserSession = Sesión del navegador +ProviderZedUsageSourceAutoHelp = Usa la credencial del editor de Zed; la facturación del navegador solo se usa al seleccionar «Sesión del navegador». +ProviderZedUsageSourceApiHelp = Usa solo la credencial del editor de Zed. +ProviderZedUsageSourceWebHelp = Lee el gasto de tokens solo de la sesión seleccionada de zed.dev o del encabezado Cookie guardado; nunca recurre a la credencial del editor. ProviderNoCodexAccountsDetected = Aún no se detectaron cuentas de Codex. ProviderCodexAutoImportHelp = Importa automáticamente cookies del navegador para extras del panel. ProviderCodexHistoryHelp = Almacena el historial local de uso de Codex (8 semanas) para personalizar predicciones de ritmo. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index c91cef6370..84e0e0012a 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -262,6 +262,10 @@ ProviderOptionsTitle = オプション MenuBarMetric = メニューバーとフローティングバーのメトリクス MenuBarMetricHelper = メニューバーとフローティングバーのパーセント表示に使用する時間枠を選択します。 UsageSource = 使用量ソース +ProviderZedBrowserSession = ブラウザーセッション +ProviderZedUsageSourceAutoHelp = Zed エディターの認証情報を使用します。ブラウザー課金を使うのは「ブラウザーセッション」を選択した場合のみです。 +ProviderZedUsageSourceApiHelp = Zed エディターの認証情報のみを使用します。 +ProviderZedUsageSourceWebHelp = 選択した zed.dev のブラウザーセッションまたは保存済み Cookie ヘッダーからのみトークン使用額を読み取り、エディターの認証情報にはフォールバックしません。 ProviderNoCodexAccountsDetected = Codex アカウントはまだ検出されていません。 ProviderCodexAutoImportHelp = ダッシュボードの追加情報のためブラウザ Cookie を自動インポートします。 ProviderCodexHistoryHelp = Pace 予測をパーソナライズするため、ローカルの Codex 使用履歴(8週間)を保存します。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index cf67c452cf..09a9d1b6b5 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -262,6 +262,10 @@ ProviderOptionsTitle = 옵션 MenuBarMetric = 메뉴 바 및 플로팅 바 메트릭 MenuBarMetricHelper = 메뉴 바와 플로팅 바의 백분율을 결정할 시간 창을 선택합니다. UsageSource = 사용량 소스 +ProviderZedBrowserSession = 브라우저 세션 +ProviderZedUsageSourceAutoHelp = Zed 편집기 자격 증명을 사용하며, 브라우저 결제 정보는 브라우저 세션을 선택한 경우에만 사용합니다. +ProviderZedUsageSourceApiHelp = Zed 편집기 자격 증명만 사용합니다. +ProviderZedUsageSourceWebHelp = 선택한 zed.dev 브라우저 세션 또는 저장된 Cookie 헤더에서만 토큰 사용액을 읽으며 편집기 자격 증명으로 대체하지 않습니다. ProviderNoCodexAccountsDetected = 감지된 Codex 계정이 아직 없습니다. ProviderCodexAutoImportHelp = 대시보드 추가 기능을 위해 브라우저 쿠키를 자동으로 가져옵니다. ProviderCodexHistoryHelp = 사용 속도 예측을 개인화하기 위해 로컬 Codex 사용 기록(8주)을 저장합니다. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index e0129c89de..cbf13bdb6f 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -246,6 +246,10 @@ ProviderOptionsTitle = Опции MenuBarMetric = Метрика строки меню и плавающей панели MenuBarMetricHelper = Выберите временное окно для процентов в строке меню и на плавающей панели. UsageSource = Источник использования +ProviderZedBrowserSession = Сеанс браузера +ProviderZedUsageSourceAutoHelp = Используются учетные данные редактора Zed; данные браузерного биллинга применяются только при выборе сеанса браузера. +ProviderZedUsageSourceApiHelp = Используются только учетные данные редактора Zed. +ProviderZedUsageSourceWebHelp = Расход токенов считывается только из выбранного сеанса zed.dev или сохраненного заголовка Cookie; перехода на учетные данные редактора нет. ProviderNoCodexAccountsDetected = Учетные записи Codex пока не обнаружены. ProviderCodexAutoImportHelp = Автоматический импорт файлов cookie браузера для дополнительных функций панели управления. ProviderCodexHistoryHelp = Сохраняет локальную историю использования Codex (8 недель) для персонализации прогнозов темпа. diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 2abf387409..043e2b4f93 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -268,6 +268,10 @@ ProviderOptionsTitle = Seçenekler MenuBarMetric = Menü çubuğu ölçümü MenuBarMetricHelper = Menü çubuğundaki yüzdeyi hangi pencerenin belirleyeceğini seçin. UsageSource = Kullanım kaynağı +ProviderZedBrowserSession = Tarayıcı oturumu +ProviderZedUsageSourceAutoHelp = Zed düzenleyici kimlik bilgisi kullanılır; tarayıcı faturalandırması yalnızca Tarayıcı oturumu seçildiğinde kullanılır. +ProviderZedUsageSourceApiHelp = Yalnızca Zed düzenleyici kimlik bilgisi kullanılır. +ProviderZedUsageSourceWebHelp = Token harcaması yalnızca seçilen zed.dev tarayıcı oturumundan veya kaydedilmiş Cookie başlığından okunur; düzenleyici kimlik bilgisine geri dönülmez. ProviderNoCodexAccountsDetected = Henüz Codex hesabı algılanmadı. ProviderCodexAutoImportHelp = Otomatik olarak panel ekleri için tarayıcı çerezlerini içe aktarır. ProviderCodexHistoryHelp = Tempo tahminlerini kişiselleştirmek için yerel Codex kullanım geçmişini (8 hafta) depolar. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 60e9e7a74d..64cbff2c45 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -262,6 +262,10 @@ ProviderOptionsTitle = 选项 MenuBarMetric = 菜单栏和浮动栏指标 MenuBarMetricHelper = 选择用于菜单栏和浮动栏百分比的时间窗口。 UsageSource = 用量来源 +ProviderZedBrowserSession = 浏览器会话 +ProviderZedUsageSourceAutoHelp = 使用 Zed 编辑器凭据;仅在选择“浏览器会话”时使用浏览器账单。 +ProviderZedUsageSourceApiHelp = 仅使用 Zed 编辑器凭据。 +ProviderZedUsageSourceWebHelp = 仅从所选的 zed.dev 浏览器会话或已保存的 Cookie 标头读取令牌支出;不会回退到编辑器凭据。 ProviderNoCodexAccountsDetected = 尚未检测到 Codex 账号。 ProviderCodexAutoImportHelp = 自动导入浏览器 Cookie 以补充仪表盘信息。 ProviderCodexHistoryHelp = 在本地保存 Codex 用量历史(8 周),用于个性化 Pace 预测。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 5977e37435..fa414266e4 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -262,6 +262,10 @@ ProviderOptionsTitle = 選項 MenuBarMetric = 選單欄和浮動列指標 MenuBarMetricHelper = 選擇用於選單欄和浮動列百分比的時間範圍。 UsageSource = 用量來源 +ProviderZedBrowserSession = 瀏覽器工作階段 +ProviderZedUsageSourceAutoHelp = 使用 Zed 編輯器憑證;只有選擇「瀏覽器工作階段」時才使用瀏覽器帳單。 +ProviderZedUsageSourceApiHelp = 僅使用 Zed 編輯器憑證。 +ProviderZedUsageSourceWebHelp = 僅從所選的 zed.dev 瀏覽器工作階段或已儲存的 Cookie 標頭讀取 Token 支出;不會回退到編輯器憑證。 ProviderNoCodexAccountsDetected = 尚未檢測到 Codex 賬號。 ProviderCodexAutoImportHelp = 自動匯入瀏覽器 Cookie 以補充儀表板資訊。 ProviderCodexHistoryHelp = 在本機儲存 Codex 用量歷史(8 周),用於個性化 Pace 預測。 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 8418ca6176..5905d9f77f 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -266,7 +266,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=") @@ -378,7 +385,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 => { @@ -387,7 +399,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..b4f922b065 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -477,6 +477,12 @@ impl Provider for OpenCodeGoProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching OpenCode Go usage"); + 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; + } + match ctx.source_mode { SourceMode::Auto => { // Local-first unless workspace/token scope asks for web first @@ -513,12 +519,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 } } @@ -537,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, 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!( diff --git a/rust/src/providers/zed/mod.rs b/rust/src/providers/zed/mod.rs index 3e4f3e50f5..9416a7eacc 100644 --- a/rust/src/providers/zed/mod.rs +++ b/rust/src/providers/zed/mod.rs @@ -1,22 +1,46 @@ +//! Zed provider: editor credential lane (default) and opt-in browser billing. + +mod snapshot; + use async_trait::async_trait; -use reqwest::Client; -use serde_json::Value; +use reqwest::{Client, StatusCode}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, + ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, }; +use crate::providers::{BoundedBodyError, read_bounded_response}; const CREDENTIAL_TARGET: &str = "codexbar-zed"; const DEFAULT_URL: &str = "https://cloud.zed.dev/client/users/me"; +const BILLING_URL: &str = "https://cloud.zed.dev/frontend/billing/usage"; +const COOKIE_DOMAIN: &str = "zed.dev"; +const MAX_RESPONSE_BYTES: usize = 512 * 1024; +const SESSION_EXPIRED: &str = + "Zed browser session expired. Sign in to zed.dev in Chrome or update the Cookie header."; +const MISSING_SESSION: &str = + "Sign in to zed.dev in a supported browser or paste a Cookie header to read token spend."; +const COOKIES_DISABLED: &str = + "Enable Zed browser cookies or paste a Cookie header to read token spend."; pub struct ZedProvider { metadata: ProviderMetadata, client: Client, + billing_url: String, } impl ZedProvider { pub fn new() -> Self { + Self::with_client( + BILLING_URL, + crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_else(|_| Client::new()), + ) + } + + fn with_client(billing_url: impl Into, client: Client) -> Self { Self { metadata: ProviderMetadata { id: ProviderId::Zed, @@ -31,12 +55,37 @@ impl ZedProvider { status_page_url: None, tertiary_label_key: None, }, - client: crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .unwrap_or_else(|_| Client::new()), + client, + billing_url: billing_url.into(), } } + + async fn fetch_editor(&self, ctx: &FetchContext) -> Result { + let key = crate::providers::resolve_api_key( + ctx.api_key.as_deref(), + CREDENTIAL_TARGET, + &["ZED_API_KEY", "ZED_CREDENTIALS"], + )?; + let url = ctx.workspace_id.as_deref().unwrap_or(DEFAULT_URL); + let request = self + .client + .get(url) + .header(reqwest::header::AUTHORIZATION, key.trim()) + .header(reqwest::header::ACCEPT, "application/json"); + let body = read_body(request, false).await?; + snapshot::editor_result(&body, chrono::Utc::now()) + } + + async fn fetch_web(&self, ctx: &FetchContext) -> Result { + let cookie = web_cookie(ctx)?; + let request = self + .client + .get(&self.billing_url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::COOKIE, cookie); + let body = read_body(request, true).await?; + snapshot::web_result(&body) + } } impl Default for ZedProvider { @@ -57,77 +106,91 @@ impl Provider for ZedProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { - SourceMode::Auto | SourceMode::OAuth => { - let key = crate::providers::resolve_api_key( - ctx.api_key.as_deref(), - CREDENTIAL_TARGET, - &["ZED_API_KEY", "ZED_CREDENTIALS"], - )?; - let url = ctx.workspace_id.as_deref().unwrap_or(DEFAULT_URL); - let response = self - .client - .get(url) - .header("Authorization", key.trim()) - .header("Accept", "application/json") - .send() - .await?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err(ProviderError::AuthRequired); - } - if !response.status().is_success() { - return Err(ProviderError::Other(format!( - "Zed usage returned status {}", - response.status() - ))); - } - let value: Value = response - .json() - .await - .map_err(|e| ProviderError::Parse(format!("Failed to parse Zed usage: {e}")))?; - Ok(ProviderFetchResult::new(snapshot_from_user(&value), "api")) - } - SourceMode::Web | SourceMode::Cli => { - Err(ProviderError::UnsupportedSource(ctx.source_mode)) - } + // The editor credential is the default lane. Browser billing is + // opt-in (Web only) and the two lanes are never combined. + SourceMode::Auto | SourceMode::OAuth => self.fetch_editor(ctx).await, + SourceMode::Web => self.fetch_web(ctx).await, + // The shell maps a disabled cookie source to `Cli`; it must not + // import cookies or send a request. + SourceMode::Cli => Err(ProviderError::NotInstalled(COOKIES_DISABLED.into())), } } fn available_sources(&self) -> Vec { - vec![SourceMode::Auto, SourceMode::OAuth] + vec![SourceMode::Auto, SourceMode::Web, SourceMode::OAuth] + } + + fn supports_web(&self) -> bool { + true + } + + fn web_is_opt_in(&self) -> bool { + true + } + + fn manual_empty_cookie_policy(&self) -> ManualEmptyCookiePolicy { + ManualEmptyCookiePolicy::FailClosedWeb } -} -fn snapshot_from_user(value: &Value) -> UsageSnapshot { - let plan = value.get("plan").unwrap_or(value); - let usage = plan.get("usage").unwrap_or(plan); - let edits = usage - .pointer("/edit_predictions") - .or_else(|| usage.pointer("/editPredictions")) - .unwrap_or(usage); - let used = number(edits, &["used"]).unwrap_or(0.0); - let limit = number(edits, &["limit"]); - let percent = limit - .filter(|v| *v > 0.0) - .map_or(0.0, |limit| used / limit * 100.0); - UsageSnapshot::new(RateWindow::new(percent)).with_login_method("Zed") + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + match error { + ProviderError::Other(message) if message == SESSION_EXPIRED => { + ProviderStateKind::ExpiredSession + } + other => other.state_kind(), + } + } } -fn number(value: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| value.get(*key).and_then(Value::as_f64)) +async fn read_body(request: reqwest::RequestBuilder, web: bool) -> Result, ProviderError> { + let response = request.send().await?; + let status = response.status(); + if status != StatusCode::OK { + return Err(status_error(status, web)); + } + read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::Read(error) => ProviderError::Network(error), + BoundedBodyError::TooLarge => ProviderError::Parse(format!( + "Zed usage response exceeded {MAX_RESPONSE_BYTES} bytes." + )), + }) } -#[cfg(test)] -mod tests { - use super::*; +/// Cookie for the browser lane: a pasted header, else the browser session. +/// A manual source with no usable header never falls back to the browser. +fn web_cookie(ctx: &FetchContext) -> Result { + let missing = || ProviderError::NotInstalled(MISSING_SESSION.into()); + if let Some(raw) = ctx.manual_cookie_header.as_deref() { + return crate::providers::normalize_cookie_header(raw).ok_or_else(missing); + } + if ctx.manual_cookie_missing { + return Err(missing()); + } + match crate::providers::browser_cookie_header(&[COOKIE_DOMAIN]) { + Ok(header) => crate::providers::normalize_cookie_header(&header).ok_or_else(missing), + Err(ProviderError::NoCookies) => Err(missing()), + Err(error) => Err(error), + } +} - #[test] - fn parses_edit_predictions() { - let snapshot = snapshot_from_user( - &serde_json::json!({"plan":{"usage":{"editPredictions":{"used":50,"limit":200}}}}), - ); - assert_eq!(snapshot.primary.used_percent, 25.0); +fn status_error(status: StatusCode, web: bool) -> ProviderError { + match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN if web => { + ProviderError::Other(SESSION_EXPIRED.into()) + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ProviderError::AuthRequired, + StatusCode::TOO_MANY_REQUESTS => { + ProviderError::Other("Zed usage requests are rate limited.".into()) + } + status if status.is_server_error() => ProviderError::Other(format!( + "Zed cloud API is unavailable (HTTP {}).", + status.as_u16() + )), + status => ProviderError::Other(format!("Zed cloud API returned HTTP {}.", status.as_u16())), } } + +#[cfg(test)] +mod tests; diff --git a/rust/src/providers/zed/snapshot.rs b/rust/src/providers/zed/snapshot.rs new file mode 100644 index 0000000000..1c71d7803f --- /dev/null +++ b/rust/src/providers/zed/snapshot.rs @@ -0,0 +1,314 @@ +//! Zed response validation and snapshot mapping shared by the editor and +//! browser-billing lanes (upstream v0.65.0 `zed.js` `fetchUsage`). +//! +//! Every value is validated; drifted shapes fail without publishing totals. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, de::Error as _}; +use serde_json::Value; + +use crate::core::{ + CostSnapshot, NamedRateWindow, ProviderDisplayDetail, ProviderError, ProviderFetchResult, + RateWindow, SubscriptionMetadata, UsageSnapshot, +}; + +/// `Number.MAX_SAFE_INTEGER`, the upper bound upstream accepts for counts and cents. +const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; +const PARSE_MESSAGE: &str = "Could not parse Zed usage response. Its format may have changed."; +const SPEND_PERIOD: &str = "Current billing period"; + +fn parse_error() -> ProviderError { + ProviderError::Parse(PARSE_MESSAGE.into()) +} + +/// A non-negative safe integer (upstream `count`). Stored as `f64` because a +/// safe integer is exactly representable and no truncating cast is needed. +#[derive(Debug, Clone, Copy)] +struct Count(f64); + +impl<'de> Deserialize<'de> for Count { + fn deserialize>(deserializer: D) -> Result { + let value = f64::deserialize(deserializer)?; + if value.fract() == 0.0 && (0.0..=MAX_SAFE_INTEGER).contains(&value) { + Ok(Self(value)) + } else { + Err(D::Error::custom("not a safe non-negative integer")) + } + } +} + +/// A finite, non-negative amount of cents no larger than the safe integer +/// range (upstream `cents`), converted to dollars on read. +#[derive(Debug, Clone, Copy)] +struct Cents(f64); + +impl<'de> Deserialize<'de> for Cents { + fn deserialize>(deserializer: D) -> Result { + let value = f64::deserialize(deserializer)?; + if value.is_finite() && (0.0..=MAX_SAFE_INTEGER).contains(&value) { + Ok(Self(value)) + } else { + Err(D::Error::custom("not a safe non-negative amount")) + } + } +} + +impl Cents { + fn dollars(self) -> f64 { + self.0 / 100.0 + } +} + +/// A non-blank string (upstream `text`). +#[derive(Debug, Clone)] +struct Text(String); + +impl<'de> Deserialize<'de> for Text { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value.trim().is_empty() { + Err(D::Error::custom("blank text")) + } else { + Ok(Self(value)) + } + } +} + +#[derive(Debug, Clone, Copy)] +enum RawLimit { + Unlimited, + Null, + Count(Count), +} + +#[derive(Deserialize)] +struct LimitedObject { + limited: Count, +} + +/// `"unlimited"`, `null`, an integer, or `{"limited": n}`; a missing key fails. +fn deserialize_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + match Value::deserialize(deserializer)? { + Value::String(text) if text == "unlimited" => Ok(RawLimit::Unlimited), + Value::Null => Ok(RawLimit::Null), + value @ Value::Object(_) => LimitedObject::deserialize(value) + .map(|object| RawLimit::Count(object.limited)) + .map_err(D::Error::custom), + value => Count::deserialize(value) + .map(RawLimit::Count) + .map_err(D::Error::custom), + } +} + +#[derive(Deserialize)] +struct EditPredictions { + used: Count, + #[serde(deserialize_with = "deserialize_limit")] + limit: RawLimit, +} + +#[derive(Deserialize)] +struct TokenSpend { + spend_in_cents: Cents, + #[serde(default)] + limit_in_cents: Option, +} + +#[derive(Deserialize)] +struct BillingUsage { + edit_predictions: EditPredictions, + token_spend: TokenSpend, +} + +#[derive(Deserialize)] +struct BillingResponse { + plan: Text, + current_usage: BillingUsage, +} + +#[derive(Deserialize)] +struct EditorUsage { + edit_predictions: EditPredictions, +} + +#[derive(Deserialize)] +struct SubscriptionPeriod { + started_at: Text, + ended_at: Text, +} + +#[derive(Deserialize)] +struct EditorPlan { + plan_v3: Text, + usage: EditorUsage, + has_overdue_invoices: bool, + #[serde(default)] + subscription_period: Option, +} + +#[derive(Deserialize)] +struct EditorUser { + #[serde(rename = "id")] + _id: Count, + github_login: String, + #[serde(default)] + name: Option, +} + +#[derive(Deserialize)] +struct EditorResponse { + user: EditorUser, + plan: EditorPlan, +} + +fn parse_root Deserialize<'de>>(body: &[u8]) -> Result { + let value: Value = serde_json::from_slice(body).map_err(|_| parse_error())?; + if !value.is_object() { + return Err(parse_error()); + } + serde_json::from_value(value).map_err(|_| parse_error()) +} + +/// Browser billing lane: `GET /frontend/billing/usage`. +pub(super) fn web_result(body: &[u8]) -> Result { + let response: BillingResponse = parse_root(body)?; + let usage = response.current_usage; + let primary = primary_window(&usage.edit_predictions, true)?; + let snapshot = UsageSnapshot::new(primary).with_login_method(plan_label(&response.plan.0)); + + let spent = usage.token_spend.spend_in_cents.dollars(); + let cap = usage.token_spend.limit_in_cents.map(Cents::dollars); + let mut result = ProviderFetchResult::new(snapshot, "web") + .with_display_detail(ProviderDisplayDetail::new( + "token-spend", + "Token spend", + format_usd(spent), + )) + .with_display_detail(ProviderDisplayDetail::new( + "token-spend-limit", + "Spend limit", + cap.map_or_else(|| "Not reported".to_string(), format_usd), + )); + if let Some(cap) = cap { + result = result + .with_cost(CostSnapshot::new(spent, "USD", SPEND_PERIOD).with_limit(cap)) + .with_display_detail(ProviderDisplayDetail::new( + "token-spend-remaining", + "Remaining budget", + format_usd((cap - spent).max(0.0)), + )); + } + Ok(result) +} + +/// Editor lane: `GET /client/users/me` with the editor credential. +pub(super) fn editor_result( + body: &[u8], + now: DateTime, +) -> Result { + let response: EditorResponse = parse_root(body)?; + let EditorResponse { user, plan } = response; + let primary = primary_window(&plan.usage.edit_predictions, false)?; + let mut snapshot = UsageSnapshot::new(primary).with_login_method(plan_label(&plan.plan_v3.0)); + if !user.github_login.trim().is_empty() { + snapshot = snapshot.with_email(user.github_login); + } + if let Some(name) = user.name.filter(|name| !name.trim().is_empty()) { + snapshot = snapshot.with_organization(name); + } + if plan.has_overdue_invoices { + snapshot.extra_rate_windows.push( + NamedRateWindow::new( + "zed.overdue-invoices", + "Billing", + RateWindow::with_details(100.0, None, None, Some("Overdue invoices".into())), + ) + .with_usage_known(false), + ); + } + if let Some(period) = plan.subscription_period { + let start = parse_instant(&period.started_at.0)?; + let end = parse_instant(&period.ended_at.0)?; + let elapsed_percent = if end > start { + (now - start).num_milliseconds() as f64 / (end - start).num_milliseconds() as f64 + * 100.0 + } else { + 0.0 + }; + snapshot = snapshot + .with_secondary(RateWindow::with_details( + elapsed_percent, + None, + Some(end), + Some(cycle_description((end - now).num_seconds())), + )) + .with_subscription(Some(SubscriptionMetadata::new(None, None, Some(end)))); + } + Ok(ProviderFetchResult::new(snapshot, "api")) +} + +fn parse_instant(value: &str) -> Result, ProviderError> { + DateTime::parse_from_rfc3339(value) + .map(|instant| instant.with_timezone(&Utc)) + .map_err(|_| parse_error()) +} + +/// Edit-prediction lane. `null` means unlimited only in the browser payload. +fn primary_window( + predictions: &EditPredictions, + null_is_unlimited: bool, +) -> Result { + let used = predictions.used.0; + let limit = match predictions.limit { + RawLimit::Unlimited => None, + RawLimit::Null if null_is_unlimited => None, + RawLimit::Null => return Err(parse_error()), + RawLimit::Count(limit) => Some(limit.0), + }; + Ok(match limit { + None => RateWindow::with_details(0.0, None, None, Some("Unlimited".into())), + Some(limit) if limit > 0.0 => RateWindow::with_details( + used / limit * 100.0, + None, + None, + Some(format!("{} / {limit} predictions", used.min(limit))), + ), + Some(_) => RateWindow::informational("No edit predictions included"), + }) +} + +/// `zed_pro_trial` -> `Zed Pro Trial`. +fn plan_label(plan: &str) -> String { + plan.split(['_', ' ']) + .filter(|word| !word.is_empty()) + .map(|word| { + let mut chars = word.chars(); + chars.next().map_or_else(String::new, |first| { + first + .to_uppercase() + .chain(chars.flat_map(char::to_lowercase)) + .collect() + }) + }) + .collect::>() + .join(" ") +} + +fn cycle_description(remaining_seconds: i64) -> String { + if remaining_seconds <= 0 { + return "Cycle ended".into(); + } + let hours = remaining_seconds / 3600; + let minutes = remaining_seconds % 3600 / 60; + if hours >= 24 { + format!("Cycle ends in {}d {}h", hours / 24, hours % 24) + } else if hours > 0 { + format!("Cycle ends in {hours}h {minutes}m") + } else { + format!("Cycle ends in {minutes}m") + } +} + +fn format_usd(value: f64) -> String { + CostSnapshot::new(value, "USD", SPEND_PERIOD).format_used() +} diff --git a/rust/src/providers/zed/tests.rs b/rust/src/providers/zed/tests.rs new file mode 100644 index 0000000000..ed3173c68a --- /dev/null +++ b/rust/src/providers/zed/tests.rs @@ -0,0 +1,492 @@ +use chrono::{TimeZone, Utc}; +use reqwest::redirect::Policy; + +use super::snapshot::{editor_result, web_result}; +use super::*; + +// Fixtures mirror upstream v0.65.0 `ZedPluginTests.swift` / `ZedStatusProbeTests.swift`. +const BILLING: &str = r#"{"plan":"zed_pro","current_usage":{ + "token_spend":{"spend_in_cents":250,"limit_in_cents":1000}, + "edit_predictions":{"used":12,"limit":100}}}"#; + +fn editor_body(plan: &str, used: u32, limit: &str, overdue: bool) -> String { + format!( + r#"{{"user":{{"id":4242,"github_login":"octocat","name":"The Octocat"}}, + "feature_flags":[], + "plan":{{"plan_v3":"{plan}", + "subscription_period":{{"started_at":"2026-05-13T00:00:00.000Z","ended_at":"2026-06-13T00:00:00.000Z"}}, + "usage":{{"edit_predictions":{{"used":{used},"limit":{limit}}}}}, + "has_overdue_invoices":{overdue}}}}}"# + ) +} + +fn detail_value<'a>(result: &'a ProviderFetchResult, id: &str) -> Option<&'a str> { + result + .display_details() + .iter() + .find(|row| row.id() == id) + .map(|row| row.value()) +} + +fn now() -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap() +} + +#[test] +fn browser_billing_reports_dollar_spend_and_plan() { + let result = web_result(BILLING.as_bytes()).unwrap(); + assert_eq!(result.source_label, "web"); + let cost = result.cost.as_ref().unwrap(); + assert_eq!(cost.used, 2.5); + assert_eq!(cost.limit, Some(10.0)); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(cost.period, "Current billing period"); + assert_eq!(cost.balance, None); + assert_eq!(detail_value(&result, "token-spend"), Some("$2.50")); + assert_eq!(detail_value(&result, "token-spend-limit"), Some("$10.00")); + assert_eq!( + detail_value(&result, "token-spend-remaining"), + Some("$7.50") + ); + assert_eq!(result.usage.primary.used_percent, 12.0); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("12 / 100 predictions") + ); + assert_eq!(result.usage.login_method.as_deref(), Some("Zed Pro")); + assert!(result.usage.account_email.is_none()); +} + +#[test] +fn zero_and_overage_spend_keep_exact_amounts_and_floor_remaining() { + for cents in ["0", "12.5", "250", "1500"] { + let body = BILLING.replace("250", cents); + let result = web_result(body.as_bytes()).unwrap(); + let spent = cents.parse::().unwrap() / 100.0; + assert_eq!(result.cost.as_ref().unwrap().used, spent); + assert_eq!( + detail_value(&result, "token-spend-remaining"), + Some(format!("${:.2}", (10.0 - spent).max(0.0)).as_str()) + ); + } +} + +#[test] +fn missing_or_null_spend_cap_and_null_limit_do_not_invent_limits() { + for cap in [ + BILLING.replace("1000", "null"), + BILLING.replace(",\"limit_in_cents\":1000", ""), + ] { + let body = cap.replace("\"limit\":100", "\"limit\":null"); + let result = web_result(body.as_bytes()).unwrap(); + assert!(result.cost.is_none()); + assert_eq!(detail_value(&result, "token-spend"), Some("$2.50")); + assert_eq!( + detail_value(&result, "token-spend-limit"), + Some("Not reported") + ); + assert_eq!(detail_value(&result, "token-spend-remaining"), None); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Unlimited") + ); + assert_eq!(result.usage.primary.used_percent, 0.0); + } +} + +#[test] +fn drifted_and_unsafe_billing_values_fail_without_publishing_totals() { + let bodies = [ + "{}".to_string(), + "[]".to_string(), + "login".to_string(), + BILLING.replace("250", "-1"), + BILLING.replace("250", "true"), + BILLING.replace("250", "9007199254740992"), + BILLING.replace("1000", "-1"), + BILLING.replace("\"plan\":\"zed_pro\"", "\"plan\":{}"), + BILLING.replace("\"plan\":\"zed_pro\"", "\"plan\":\" \""), + BILLING.replace("\"used\":12", "\"used\":1.5"), + BILLING.replace("\"used\":12", "\"used\":-1"), + BILLING.replace("\"limit\":100", "\"limit\":\"lots\""), + BILLING.replace("\"limit\":100", "\"other\":100"), + BILLING.replace("\"limit\":100", "\"limit\":[5]"), + BILLING.replace("\"limit\":100", "\"limit\":{\"limited\":-2}"), + BILLING.replace("edit_predictions", "edit_prediction"), + ]; + for body in bodies { + assert!( + matches!(web_result(body.as_bytes()), Err(ProviderError::Parse(_))), + "{body}" + ); + } +} + +#[test] +fn edit_prediction_limit_accepts_unlimited_null_integer_and_limited_object() { + let unlimited = BILLING.replace("\"limit\":100", "\"limit\":\"unlimited\""); + let limited = BILLING.replace("\"limit\":100", "\"limit\":{\"limited\":20}"); + let overage = BILLING.replace("\"used\":12,\"limit\":100", "\"used\":150,\"limit\":100"); + let zero = BILLING.replace("\"limit\":100", "\"limit\":0"); + let primary = |body: &str| web_result(body.as_bytes()).unwrap().usage.primary; + + assert_eq!( + primary(&unlimited).reset_description.as_deref(), + Some("Unlimited") + ); + let limited = primary(&limited); + assert_eq!(limited.used_percent, 60.0); + assert_eq!( + limited.reset_description.as_deref(), + Some("12 / 20 predictions") + ); + let overage = primary(&overage); + assert_eq!(overage.used_percent, 100.0); + assert_eq!( + overage.reset_description.as_deref(), + Some("100 / 100 predictions") + ); + assert!(primary(&zero).is_informational); +} + +#[test] +fn editor_payload_retains_identity_quota_billing_dates_and_overdue_warning() { + let body = editor_body("zed_pro_trial", 10, r#"{"limited":20}"#, true); + let result = editor_result(body.as_bytes(), now()).unwrap(); + let usage = &result.usage; + assert_eq!(result.source_label, "api"); + assert_eq!(usage.account_email.as_deref(), Some("octocat")); + assert_eq!(usage.account_organization.as_deref(), Some("The Octocat")); + assert_eq!(usage.login_method.as_deref(), Some("Zed Pro Trial")); + assert_eq!(usage.primary.used_percent, 50.0); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("10 / 20 predictions") + ); + let cycle = usage.secondary.as_ref().unwrap(); + let end = Utc.with_ymd_and_hms(2026, 6, 13, 0, 0, 0).unwrap(); + assert_eq!(cycle.resets_at, Some(end)); + assert_eq!( + cycle.reset_description.as_deref(), + Some("Cycle ends in 12d 0h") + ); + // 19 of 31 days elapsed. + assert!((cycle.used_percent - 19.0 / 31.0 * 100.0).abs() < 1e-9); + assert_eq!(usage.subscription.as_ref().unwrap().renews_at, Some(end)); + let overdue = &usage.extra_rate_windows[0]; + assert_eq!(overdue.id, "zed.overdue-invoices"); + assert_eq!(overdue.title, "Billing"); + assert!(!overdue.usage_known); + assert_eq!( + overdue.window.reset_description.as_deref(), + Some("Overdue invoices") + ); + assert!(result.cost.is_none()); +} + +#[test] +fn editor_cycle_reports_ended_and_short_countdowns() { + let body = editor_body("zed_pro", 0, "\"unlimited\"", false); + let describe = |now| { + editor_result(body.as_bytes(), now) + .unwrap() + .usage + .secondary + .unwrap() + .reset_description + .unwrap() + }; + let end = Utc.with_ymd_and_hms(2026, 6, 13, 0, 0, 0).unwrap(); + assert_eq!(describe(end + chrono::Duration::seconds(1)), "Cycle ended"); + assert_eq!( + describe(end - chrono::Duration::minutes(150)), + "Cycle ends in 2h 30m" + ); + assert_eq!( + describe(end - chrono::Duration::minutes(45)), + "Cycle ends in 45m" + ); +} + +#[test] +fn editor_plans_retain_names_and_unlimited_predictions() { + for (plan, label) in [ + ("zed_free", "Zed Free"), + ("zed_pro", "Zed Pro"), + ("zed_pro_trial", "Zed Pro Trial"), + ("zed_student", "Zed Student"), + ("zed_business", "Zed Business"), + ] { + let body = editor_body(plan, 3, "\"unlimited\"", false); + let usage = editor_result(body.as_bytes(), now()).unwrap().usage; + assert_eq!(usage.login_method.as_deref(), Some(label)); + assert_eq!(usage.primary.used_percent, 0.0); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("Unlimited") + ); + assert!(usage.extra_rate_windows.is_empty()); + } +} + +#[test] +fn editor_payload_rejects_drifted_shapes() { + let good = editor_body("zed_pro", 1, "5", false); + let bodies = [ + good.replace("\"id\":4242", "\"id\":\"4242\""), + good.replace("\"github_login\":\"octocat\"", "\"github_login\":7"), + good.replace("\"name\":\"The Octocat\"", "\"name\":7"), + good.replace( + "\"has_overdue_invoices\":false", + "\"has_overdue_invoices\":\"no\"", + ), + good.replace("2026-06-13T00:00:00.000Z", "not-a-date"), + good.replace("\"plan_v3\":\"zed_pro\"", "\"plan_v3\":\"\""), + // `null` is unlimited only in the browser payload. + editor_body("zed_pro", 1, "null", false), + // The legacy lenient shape is not an upstream wire shape. + r#"{"plan":{"usage":{"editPredictions":{"used":50,"limit":200}}}}"#.to_string(), + ]; + for body in bodies { + assert!( + matches!( + editor_result(body.as_bytes(), now()), + Err(ProviderError::Parse(_)) + ), + "{body}" + ); + } +} + +#[test] +fn editor_optional_identity_and_period_may_be_absent_or_blank() { + let body = r#"{"user":{"id":1,"github_login":" ","name":null}, + "plan":{"plan_v3":"zed_free","usage":{"edit_predictions":{"used":0,"limit":50}}, + "has_overdue_invoices":false}}"#; + let usage = editor_result(body.as_bytes(), now()).unwrap().usage; + assert!(usage.account_email.is_none()); + assert!(usage.account_organization.is_none()); + assert!(usage.secondary.is_none()); + assert!(usage.subscription.is_none()); + assert!(usage.extra_rate_windows.is_empty()); +} + +#[test] +fn classifies_statuses_per_lane() { + assert!(matches!( + status_error(StatusCode::UNAUTHORIZED, true), + ProviderError::Other(message) if message == SESSION_EXPIRED + )); + assert!(matches!( + status_error(StatusCode::FORBIDDEN, true), + ProviderError::Other(message) if message == SESSION_EXPIRED + )); + assert!(matches!( + status_error(StatusCode::FORBIDDEN, false), + ProviderError::AuthRequired + )); + assert!(matches!( + status_error(StatusCode::TOO_MANY_REQUESTS, true), + ProviderError::Other(message) if message.contains("rate limited") + )); + assert!(matches!( + status_error(StatusCode::BAD_GATEWAY, true), + ProviderError::Other(message) if message.contains("unavailable") && message.contains("502") + )); + assert!(matches!( + status_error(StatusCode::NOT_FOUND, true), + ProviderError::Other(message) if message.contains("404") + )); +} + +#[test] +fn expired_browser_session_is_classified_as_expired_not_unknown() { + let provider = ZedProvider::new(); + assert_eq!( + provider.error_state_kind(&status_error(StatusCode::UNAUTHORIZED, true)), + ProviderStateKind::ExpiredSession + ); + assert_eq!( + provider.error_state_kind(&ProviderError::Other("other".into())), + ProviderStateKind::Unknown + ); +} + +#[test] +fn sources_are_auto_web_and_api_with_web_opt_in() { + let provider = ZedProvider::new(); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::Web, SourceMode::OAuth] + ); + assert!(provider.web_is_opt_in()); +} + +#[tokio::test] +async fn web_source_sends_only_the_zed_cookie_and_no_authorization() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend/billing/usage") + .match_header("accept", "application/json") + .match_header("cookie", "zed.session=fixture-session") + .match_header("authorization", mockito::Matcher::Missing) + .with_status(200) + .with_body(BILLING) + .create_async() + .await; + let provider = test_provider(&server.url()); + let ctx = FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: Some("Cookie: zed.session=fixture-session".into()), + // An editor credential must never be sent by, or substituted for, Web. + api_key: Some("4242 editor-token".into()), + ..FetchContext::default() + }; + + let result = provider.fetch_usage(&ctx).await.unwrap(); + mock.assert_async().await; + assert_eq!(result.source_label, "web"); + assert_eq!(result.cost.unwrap().used, 2.5); +} + +#[tokio::test] +async fn web_source_maps_expired_and_failing_responses_without_editor_fallback() { + for (status, expected) in [ + (401, SESSION_EXPIRED), + (403, SESSION_EXPIRED), + (429, "Zed usage requests are rate limited."), + (503, "Zed cloud API is unavailable (HTTP 503)."), + (404, "Zed cloud API returned HTTP 404."), + ] { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend/billing/usage") + .with_status(status) + .with_body("login") + .expect(1) + .create_async() + .await; + let ctx = FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: Some("zed.session=fixture-session".into()), + api_key: Some("4242 editor-token".into()), + // Would be hit if Web fell back to the editor credential. + workspace_id: Some(format!("{}/client/users/me", server.url())), + ..FetchContext::default() + }; + + let error = test_provider(&server.url()) + .fetch_usage(&ctx) + .await + .err() + .unwrap(); + mock.assert_async().await; + assert!( + matches!(&error, ProviderError::Other(message) if message == expected), + "{status}: {error}" + ); + } +} + +#[tokio::test] +async fn web_source_without_a_usable_cookie_fails_before_any_request() { + for ctx in [ + FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: Some(" ".into()), + ..FetchContext::default() + }, + FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: Some("zed.session=bad\nvalue".into()), + ..FetchContext::default() + }, + FetchContext { + source_mode: SourceMode::Web, + manual_cookie_missing: true, + ..FetchContext::default() + }, + // Cookie source Off: the shell maps it to Cli. + FetchContext { + source_mode: SourceMode::Cli, + manual_cookie_header: Some("zed.session=fixture-session".into()), + ..FetchContext::default() + }, + ] { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + let error = test_provider(&server.url()) + .fetch_usage(&ctx) + .await + .err() + .unwrap(); + mock.assert_async().await; + assert!(matches!(error, ProviderError::NotInstalled(_)), "{error}"); + } +} + +#[tokio::test] +async fn auto_and_api_sources_use_the_editor_credential_and_never_the_browser() { + for source_mode in [SourceMode::Auto, SourceMode::OAuth] { + let mut server = mockito::Server::new_async().await; + let editor = server + .mock("GET", "/client/users/me") + .match_header("authorization", "4242 fixture-token") + .match_header("cookie", mockito::Matcher::Missing) + .with_status(200) + .with_body(editor_body("zed_pro", 10, r#"{"limited":20}"#, false)) + .create_async() + .await; + let billing = server + .mock("GET", "/frontend/billing/usage") + .expect(0) + .create_async() + .await; + let ctx = FetchContext { + source_mode, + api_key: Some("4242 fixture-token".into()), + manual_cookie_header: Some("zed.session=fixture-session".into()), + workspace_id: Some(format!("{}/client/users/me", server.url())), + ..FetchContext::default() + }; + + let result = test_provider(&server.url()) + .fetch_usage(&ctx) + .await + .unwrap(); + editor.assert_async().await; + billing.assert_async().await; + assert_eq!(result.source_label, "api"); + assert_eq!(result.usage.account_email.as_deref(), Some("octocat")); + } +} + +#[tokio::test] +async fn editor_lane_keeps_auth_required_for_rejected_credentials() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/client/users/me") + .with_status(401) + .create_async() + .await; + let ctx = FetchContext { + source_mode: SourceMode::Auto, + api_key: Some("4242 stale".into()), + workspace_id: Some(format!("{}/client/users/me", server.url())), + ..FetchContext::default() + }; + + assert!(matches!( + test_provider(&server.url()).fetch_usage(&ctx).await, + Err(ProviderError::AuthRequired) + )); +} + +fn test_provider(base_url: &str) -> ZedProvider { + let client = Client::builder().redirect(Policy::none()).build().unwrap(); + ZedProvider::with_client(format!("{base_url}/frontend/billing/usage"), client) +}