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)
+}