From bd083d519b3351d17e6c3e1fd9716df8a8be44e3 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:03:16 +0700 Subject: [PATCH 1/2] Show account email, token-account label and Codex credits on cards --- .../src-tauri/src/commands/bridge.rs | 6 + .../src-tauri/src/commands/providers.rs | 78 +++++++- .../src/commands/providers/reset_backfill.rs | 1 + apps/desktop-tauri/src-tauri/src/powertoys.rs | 2 + .../src-tauri/src/tray_bridge.rs | 1 + .../src-tauri/src/tray_presentation_tests.rs | 1 + .../src-tauri/src/usage_metric.rs | 1 + .../components/MenuCard.headerAccount.test.ts | 37 ++++ .../desktop-tauri/src/components/MenuCard.tsx | 20 +- .../src/components/card/ExtraUsageBlock.tsx | 2 +- .../src/lib/accountMenuRow.test.ts | 8 +- apps/desktop-tauri/src/lib/accountMenuRow.ts | 9 +- apps/desktop-tauri/src/lib/cardCost.test.ts | 20 ++ apps/desktop-tauri/src/lib/cardCost.ts | 18 +- apps/desktop-tauri/src/types/bridge.ts | 5 + rust/src/cli/usage/render.rs | 32 +-- rust/src/codex_accounts/credentials.rs | 26 ++- rust/src/providers/claude/oauth/mod.rs | 14 +- rust/src/providers/claude/oauth/profile.rs | 173 +++++++++++++++++ rust/src/providers/codex/api.rs | 82 ++++++++ rust/src/providers/codex/mod.rs | 182 +++++++++++++++++- rust/src/providers/format.rs | 27 +++ 22 files changed, 686 insertions(+), 59 deletions(-) create mode 100644 apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts create mode 100644 rust/src/providers/claude/oauth/profile.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 033f24a9b9..72077ceb2c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -307,6 +307,10 @@ pub struct ProviderUsageSnapshot { pub plan_name: Option, #[serde(default)] pub account_email: Option, + /// Header-only label of the active token account (upstream shows it when + /// the provider reports no email). Never used for history or warning keys. + #[serde(default)] + pub account_label: Option, #[serde(default)] pub subscription: Option, #[serde(default = "default_source_label")] @@ -569,6 +573,7 @@ impl ProviderUsageSnapshot { }), plan_name: usage.login_method.clone(), account_email: usage.account_email.clone(), + account_label: None, subscription: usage.subscription.as_ref().map(|subscription| { SubscriptionMetadataSnapshot { starts_at: subscription.starts_at.map(|date| date.to_rfc3339()), @@ -630,6 +635,7 @@ impl ProviderUsageSnapshot { cost: None, plan_name: None, account_email: None, + account_label: None, subscription: None, source_label: String::new(), has_successful_claude_cli_quota: false, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 01a56ea642..d9d9b048d0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -745,11 +745,12 @@ fn spawn_provider_refreshes( ); // Resolved here rather than inside the fetch: forecast history is keyed by // account, and the managed-account id is the only discriminator Codex exposes. - let token_account_id = inputs + let active_account = inputs .token_accounts .get(&id) - .and_then(ProviderAccountData::active_account) - .map(|account| account.id); + .and_then(ProviderAccountData::active_account); + let token_account_id = active_account.map(|account| account.id); + let token_account_label = active_account.and_then(|account| header_label(&account.label)); let hooks_enabled = inputs.settings.hooks_enabled; handles.push(tokio::spawn(async move { @@ -761,7 +762,10 @@ fn spawn_provider_refreshes( id, ctx, generation, - token_account_id, + ActiveTokenAccount { + id: token_account_id, + label: token_account_label, + }, hooks_enabled, ) .await; @@ -797,16 +801,31 @@ fn spawn_provider_refreshes( handles } +/// The active token account a refresh runs under: its id scopes forecast +/// history; its label is header display only. +struct ActiveTokenAccount { + id: Option, + label: Option, +} + +/// A token-account label fit for the card header, or `None` when blank. +fn header_label(label: &str) -> Option { + let label = label.trim(); + (!label.is_empty()).then(|| label.to_string()) +} + async fn refresh_provider( app: tauri::AppHandle, id: ProviderId, ctx: FetchContext, generation: u64, - token_account_id: Option, + token_account: ActiveTokenAccount, hooks_enabled: bool, ) { - let (snapshot, account_identity, retention) = - fetch_provider_snapshot(id, ctx, token_account_id).await; + let (mut snapshot, account_identity, retention) = + fetch_provider_snapshot(id, ctx, token_account.id).await; + snapshot.account_label = token_account.label; + let token_account_id = token_account.id; let fresh_snapshot = snapshot.error.is_none(); // Captured before last-good preservation can swap in a cached snapshot. let fetch_attempt = FetchAttempt::of(&snapshot); @@ -1600,6 +1619,50 @@ mod predictive_warning_tests { } } } + + /// Codex OAuth now publishes the auth.json email. Ambient Codex moves from + /// the unscoped key (process-local history only) to the lowercased email; + /// a managed token account still wins, and the header-only token-account + /// label never feeds any scoping key. + #[test] + fn codex_email_scopes_forecast_and_warnings_but_label_does_not() { + use crate::commands::bridge::forecast_account_key; + + let token = uuid::Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap(); + let mut usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(1.0)); + let mut snapshot = empty_snapshot(); + snapshot.provider_id = "codex".to_string(); + + assert_eq!(forecast_account_key(&usage, None), None); + assert_eq!(quota_notification_account_identity(&snapshot, None), ""); + + snapshot.account_label = Some("Work account".to_string()); + assert_eq!(quota_notification_account_identity(&snapshot, None), ""); + + usage.account_email = Some("Parity.User@Example.com".to_string()); + snapshot.account_email = usage.account_email.clone(); + assert_eq!( + forecast_account_key(&usage, None).as_deref(), + Some("parity.user@example.com") + ); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + "parity.user@example.com" + ); + assert_eq!( + forecast_account_key(&usage, Some(token)).as_deref(), + Some("token-account:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + ); + } + + #[test] + fn header_label_trims_and_drops_blank_labels() { + assert_eq!( + header_label(" parity-github "), + Some("parity-github".to_string()) + ); + assert_eq!(header_label(" "), None); + } } #[cfg(test)] mod reset_backfill_tests { @@ -1640,6 +1703,7 @@ mod reset_backfill_tests { cost: None, plan_name: None, account_email: None, + account_label: None, subscription: None, source_label: String::new(), has_successful_claude_cli_quota: false, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs index df74ed2ba8..29617573b8 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -133,6 +133,7 @@ mod tests { cost: None, plan_name: None, account_email: None, + account_label: None, subscription: None, source_label: String::new(), has_successful_claude_cli_quota: false, diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index b00be94882..6194ba3b0d 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -206,6 +206,7 @@ mod tests { cost: None, plan_name: Some("Team".to_string()), account_email: Some("dev@example.com".to_string()), + account_label: None, subscription: None, source_label: "web".to_string(), has_successful_claude_cli_quota: false, @@ -263,6 +264,7 @@ mod tests { cost: None, plan_name: None, account_email: None, + account_label: None, subscription: None, source_label: "web".to_string(), has_successful_claude_cli_quota: false, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index b153962686..c35f8f66ea 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -755,6 +755,7 @@ mod tests { }), plan_name: None, account_email: None, + account_label: None, subscription: None, source_label: String::new(), has_successful_claude_cli_quota: false, diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 8a606b7e9b..ca2f71a022 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -62,6 +62,7 @@ fn fake_snapshot_with( }), plan_name: None, account_email: None, + account_label: None, subscription: None, source_label: String::new(), has_successful_claude_cli_quota: false, diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 2266daa4e4..099fc45e33 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -478,6 +478,7 @@ mod tests { cost: None, plan_name: None, account_email: None, + account_label: None, subscription: None, source_label: "test".to_string(), has_successful_claude_cli_quota: false, diff --git a/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts b/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts new file mode 100644 index 0000000000..5ddcc053fc --- /dev/null +++ b/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { headerAccount, maskEmail } from "./MenuCard"; + +describe("headerAccount", () => { + it("prefers the provider email over the token-account label", () => { + expect( + headerAccount({ accountEmail: "parity.user@example.com", accountLabel: "Work" }, false), + ).toBe("parity.user@example.com"); + }); + + it("falls back to the active token-account label", () => { + expect(headerAccount({ accountEmail: null, accountLabel: "parity-github" }, false)).toBe( + "parity-github", + ); + expect(headerAccount({ accountEmail: " ", accountLabel: " parity-github " }, false)).toBe( + "parity-github", + ); + }); + + it("masks the email or the label under Hide Personal Info", () => { + expect(headerAccount({ accountEmail: "parity.user@example.com" }, true)).toBe( + "p••••••••••@example.com", + ); + expect(headerAccount({ accountEmail: null, accountLabel: "parity-github" }, true)).toBe( + "••••@••••", + ); + }); + + it("shows nothing without an email or label", () => { + expect(headerAccount({ accountEmail: null }, false)).toBeNull(); + expect(headerAccount({ accountEmail: null, accountLabel: "" }, true)).toBeNull(); + }); + + it("masks a non-email label completely", () => { + expect(maskEmail("parity-github")).toBe("••••@••••"); + }); +}); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 2a76046f5a..48f8755970 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -54,6 +54,19 @@ export function maskEmail(email: string): string { return email[0] + "•".repeat(at - 1) + email.slice(at); } +/** + * Upstream `MenuCardView.email(from:)`: the provider-reported email, else the + * active token-account label. Hide Personal Info masks either. + */ +export function headerAccount( + provider: Pick, + hideEmail: boolean, +): string | null { + const account = provider.accountEmail?.trim() || provider.accountLabel?.trim() || null; + if (!account) return null; + return hideEmail ? maskEmail(account) : account; +} + function cardMetrics( provider: ProviderUsageSnapshot, t: ReturnType["t"], @@ -164,12 +177,7 @@ export default function MenuCard({ }, [provider.providerId, provider.accountEmail, localDay, onLayoutChange]); const isWayfinder = provider.providerId === "wayfinder"; - const email = - !isWayfinder && provider.accountEmail - ? hideEmail - ? maskEmail(provider.accountEmail) - : provider.accountEmail - : null; + const email = isWayfinder ? null : headerAccount(provider, hideEmail); const plan = isWayfinder ? null : hideOpenAiApiProjectId( diff --git a/apps/desktop-tauri/src/components/card/ExtraUsageBlock.tsx b/apps/desktop-tauri/src/components/card/ExtraUsageBlock.tsx index b6f072bd78..0200a4b1a1 100644 --- a/apps/desktop-tauri/src/components/card/ExtraUsageBlock.tsx +++ b/apps/desktop-tauri/src/components/card/ExtraUsageBlock.tsx @@ -20,7 +20,7 @@ export default function ExtraUsageBlock({ resetTimeRelative: boolean; }) { const { t } = useLocale(); - const section = cardCostSection(cost, style, t); + const section = cardCostSection(cost, style, t, providerId); const resetText = useFormattedResetTime( section && !section.inline && style !== "compact" ? cost.resetsAt : null, null, diff --git a/apps/desktop-tauri/src/lib/accountMenuRow.test.ts b/apps/desktop-tauri/src/lib/accountMenuRow.test.ts index 1c60aacd00..e8912ae385 100644 --- a/apps/desktop-tauri/src/lib/accountMenuRow.test.ts +++ b/apps/desktop-tauri/src/lib/accountMenuRow.test.ts @@ -39,7 +39,13 @@ function snapshot(overrides: Partial): ProviderUsageSnaps } describe("accountMenuAction", () => { - it.each(["codex", "claude", "grok"])("offers Switch Account... for a signed-in %s", (providerId) => { + it("keeps Add Account... for a signed-in Codex, as upstream loginMenuAction does", () => { + expect( + accountMenuAction(snapshot({ providerId: "codex", accountEmail: "parity.user@example.com" })), + ).toEqual({ labelKey: "AddAccountEllipsis", settingsTab: "providers:codex" }); + }); + + it.each(["claude", "grok"])("offers Switch Account... for a signed-in %s", (providerId) => { expect(accountMenuAction(snapshot({ providerId, accountEmail: "a@example.com" }))).toEqual({ labelKey: "TrayMenuSwitchAccount", settingsTab: `providers:${providerId}`, diff --git a/apps/desktop-tauri/src/lib/accountMenuRow.ts b/apps/desktop-tauri/src/lib/accountMenuRow.ts index 63e325e7d4..f71fa86a82 100644 --- a/apps/desktop-tauri/src/lib/accountMenuRow.ts +++ b/apps/desktop-tauri/src/lib/accountMenuRow.ts @@ -21,10 +21,13 @@ export function accountMenuAction( provider: ProviderUsageSnapshot | null, ): AccountMenuAction | null { if (!provider || !ACCOUNT_MANAGER_PROVIDERS.has(provider.providerId)) return null; + // Upstream `CodexProviderImplementation.loginMenuAction` always offers + // "Add Account..." for Codex, signed in or not. const hasAccount = - Boolean(provider.accountEmail?.trim()) || - hasLiveClaudeIdentity(provider) || - hasSuccessfulClaudeCliQuota(provider); + provider.providerId !== "codex" && + (Boolean(provider.accountEmail?.trim()) || + hasLiveClaudeIdentity(provider) || + hasSuccessfulClaudeCliQuota(provider)); return { labelKey: hasAccount ? "TrayMenuSwitchAccount" : "AddAccountEllipsis", settingsTab: providersTabRequest(provider.providerId), diff --git a/apps/desktop-tauri/src/lib/cardCost.test.ts b/apps/desktop-tauri/src/lib/cardCost.test.ts index 2889c6d30d..3547ce481c 100644 --- a/apps/desktop-tauri/src/lib/cardCost.test.ts +++ b/apps/desktop-tauri/src/lib/cardCost.test.ts @@ -14,6 +14,7 @@ const STRINGS: Partial> = { CostPeriodThisMonth: "This month", PanelToday: "Today", ProviderLabelMonthly: "Monthly", + WindowLabelMonthlyCredits: "Monthly credits", }; const t = (key: LocaleKey) => STRINGS[key] ?? key; @@ -142,6 +143,25 @@ describe("cardCostSection", () => { expect(section?.inline).toBe(true); }); + it("prints Codex credits unitless, as upstream creditsUsageSection does", () => { + const balance = cardCostSection( + cost({ used: 125.5, period: "Credits", formattedUsed: "$125.50" }), + "detailed", + t, + "codex", + ); + expect(balance?.spendLine).toBe("Balance: 125.5"); + expect(balance?.inline).toBe(true); + + const capped = cardCostSection( + cost({ used: 1234.567, limit: 2000, period: "Monthly credits", formattedUsed: "$1,234.57" }), + "detailed", + t, + "codex", + ); + expect(capped?.spendLine).toBe("Monthly credits: 1,234.57 / 2,000"); + }); + it("shows an uncapped spend with its period", () => { const section = cardCostSection( cost({ used: 2, currencyCode: "EUR", period: "Monthly", currencySymbol: "€" }), diff --git a/apps/desktop-tauri/src/lib/cardCost.ts b/apps/desktop-tauri/src/lib/cardCost.ts index 5d2eafa9f2..8a443be4f3 100644 --- a/apps/desktop-tauri/src/lib/cardCost.ts +++ b/apps/desktop-tauri/src/lib/cardCost.ts @@ -55,6 +55,16 @@ function periodLabel(raw: string | null | undefined, fallback: string, t: Transl } } +const creditsFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }); + +/** Mac `UsageFormatter.creditsNumberString`: a unitless credit count, max 2 decimals. */ +export function formatCreditsNumber(value: number): string { + return creditsFormatter.format(value); +} + +/** Codex costs are credits; upstream `creditsUsageSection` prints them unitless. */ +const CREDIT_COST_PROVIDERS = new Set(["codex"]); + /** A cost whose period names a balance carries the balance in `used`. */ function periodNamesBalance(period: string): boolean { const normalized = period.trim().toLowerCase(); @@ -65,13 +75,19 @@ export function cardCostSection( cost: CostSnapshotBridge | null | undefined, style: CostSummaryDisplayStyle | undefined, t: Translate, + providerId?: string, ): CardCostSection | null { if (!cost) return null; if (style === "hidden" && cost.alwaysVisible !== true) return null; const isQuota = cost.currencyCode === "Quota"; + const isCredits = providerId !== undefined && CREDIT_COST_PROVIDERS.has(providerId); const money = (value: number, formatted?: string | null) => - isQuota ? value.toFixed(0) : formatted || formatCostAmount(value, cost.currencyCode); + isQuota + ? value.toFixed(0) + : isCredits + ? formatCreditsNumber(value) + : formatted || formatCostAmount(value, cost.currencyCode); const balanceText = (value: number) => `${t("DetailCostBalance")}: ${money(value, cost.formattedBalance)}`; const limit = cost.limit ?? 0; diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index dbb975a02d..e1193bffbc 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -779,6 +779,11 @@ export interface ProviderUsageSnapshot { cost: CostSnapshotBridge | null; planName: string | null; accountEmail: string | null; + /** + * Active token-account label, shown in the header only when the provider + * reports no email (upstream account fallback). Never a scoping key. + */ + accountLabel?: string | null; subscription?: SubscriptionMetadataSnapshot | null; sourceLabel: string; /** Backend proof of a live successful Claude CLI quota fetch; only true is proof. */ diff --git a/rust/src/cli/usage/render.rs b/rust/src/cli/usage/render.rs index de41a08697..652bf19cc8 100644 --- a/rust/src/cli/usage/render.rs +++ b/rust/src/cli/usage/render.rs @@ -493,38 +493,14 @@ fn format_history_line(cost: &CostSnapshot) -> String { let mut values = vec![spend]; if let Some(tokens) = cost.history_tokens { let unit = if tokens == 1 { "token" } else { "tokens" }; - values.push(format!("{} {unit}", format_token_count(tokens))); + values.push(format!( + "{} {unit}", + crate::providers::format::token_count(tokens) + )); } format!(" {}: {}", cost.period, values.join(" · ")) } -/// Compact token count: `999`, `1.2K`, `15K`, `2.5M`, `1B`. A unit is promoted -/// once the lower unit would round to 1000 (upstream `tokenCountString`). -fn format_token_count(tokens: u64) -> String { - const UNITS: [(u64, f64, &str); 3] = [ - (999_500_000, 1_000_000_000.0, "B"), - (999_500, 1_000_000.0, "M"), - (1_000, 1_000.0, "K"), - ]; - for (threshold, divisor, suffix) in UNITS { - if tokens >= threshold { - #[expect( - clippy::cast_precision_loss, - reason = "display rounding to at most two significant digits" - )] - let scaled = tokens as f64 / divisor; - let formatted = if scaled >= 10.0 { - format!("{scaled:.0}") - } else { - let one = format!("{scaled:.1}"); - one.strip_suffix(".0").unwrap_or(&one).to_string() - }; - return format!("{formatted}{suffix}"); - } - } - tokens.to_string() -} - /// Render usage as text (backwards compatible version) pub fn render_text(provider: ProviderId, result: &ProviderFetchResult, use_color: bool) -> String { render_text_with_status(provider, result, None, use_color) diff --git a/rust/src/codex_accounts/credentials.rs b/rust/src/codex_accounts/credentials.rs index de0f3cbfd3..64cf39b78b 100644 --- a/rust/src/codex_accounts/credentials.rs +++ b/rust/src/codex_accounts/credentials.rs @@ -213,14 +213,7 @@ pub(super) fn identity_from_credentials(credentials: &AuthCredentials) -> AuthBa .and_then(|v| v.as_object()) .cloned() .unwrap_or_default(); - let profile = payload - .get("https://api.openai.com/profile") - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default(); - - let email = normalize_string(payload.get("email").and_then(|v| v.as_str())) - .or_else(|| normalize_string(profile.get("email").and_then(|v| v.as_str()))); + let email = email_from_payload(&payload); let auth_subject = normalize_string(payload.get("sub").and_then(|v| v.as_str())); let plan = normalize_string(auth.get("chatgpt_plan_type").and_then(|v| v.as_str())) .or_else(|| normalize_string(payload.get("chatgpt_plan_type").and_then(|v| v.as_str()))); @@ -254,6 +247,23 @@ pub fn jwt_payload(token: &str) -> Option Option { + email_from_payload(&jwt_payload(id_token)?) +} + +fn email_from_payload(payload: &serde_json::Map) -> Option { + normalize_string(payload.get("email").and_then(|v| v.as_str())).or_else(|| { + normalize_string( + payload + .get("https://api.openai.com/profile") + .and_then(|profile| profile.get("email")) + .and_then(|v| v.as_str()), + ) + }) +} + pub(super) fn account_id_from_id_token(id_token: Option<&str>) -> Option { let payload = id_token.and_then(jwt_payload)?; let auth = payload diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index c4fb510ed8..33296d0e7c 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -13,6 +13,7 @@ use std::time::{Duration, Instant}; use crate::core::{NamedRateWindow, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot}; mod credentials_store; +mod profile; mod refresh; mod usage_gate; @@ -409,7 +410,18 @@ impl ClaudeOAuthFetcher { credentials: ClaudeOAuthCredentials, ) -> Result { let usage_response = self.fetch_usage(&credentials).await?; - let usage = self.build_usage_snapshot(&usage_response, &credentials); + let mut usage = self.build_usage_snapshot(&usage_response, &credentials); + // Header email only; a profile failure leaves the usage result intact. + if usage.account_email.is_none() + && let Some(email) = profile::fetch_email( + &self.client, + &credentials.access_token, + &usage_gate::token_fingerprint(&credentials.access_token), + ) + .await + { + usage = usage.with_email(email); + } let mut result = ProviderFetchResult::new(usage, "oauth"); if let Some(identity) = credential_identity(&credentials) { result = result.with_account_identity(identity); diff --git a/rust/src/providers/claude/oauth/profile.rs b/rust/src/providers/claude/oauth/profile.rs new file mode 100644 index 0000000000..1075e2a1ac --- /dev/null +++ b/rust/src/providers/claude/oauth/profile.rs @@ -0,0 +1,173 @@ +//! Best-effort Claude OAuth profile lookup for the card header email. +//! +//! Upstream reads the account email from `GET /api/oauth/profile` next to the +//! usage call. The email is display metadata only: a failed, slow or +//! malformed profile response never fails the usage fetch, and the result is +//! remembered per token fingerprint so a refresh loop does not re-ask. + +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use reqwest::Client; + +pub(super) const PROFILE_URL: &str = "https://api.anthropic.com/api/oauth/profile"; +const PROFILE_TIMEOUT: Duration = Duration::from_secs(5); +/// A failed lookup is retried after this long; a found email is kept for the +/// life of the token. +const FAILURE_RETRY: Duration = Duration::from_secs(15 * 60); + +enum CachedProfile { + Found(String), + Failed(Instant), +} + +static PROFILE_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// The cached answer for `fingerprint`: `Some(Some(email))` when known, +/// `Some(None)` while a recent failure is cooling down, `None` to ask again. +fn cached(fingerprint: &str, now: Instant) -> Option> { + let cache = PROFILE_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + match cache.get(fingerprint)? { + CachedProfile::Found(email) => Some(Some(email.clone())), + CachedProfile::Failed(at) if now.duration_since(*at) < FAILURE_RETRY => Some(None), + CachedProfile::Failed(_) => None, + } +} + +fn remember(fingerprint: &str, email: Option<&str>, now: Instant) { + let entry = match email { + Some(email) => CachedProfile::Found(email.to_string()), + None => CachedProfile::Failed(now), + }; + PROFILE_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(fingerprint.to_string(), entry); +} + +/// The account email for `access_token`, or `None` on any failure. +pub(super) async fn fetch_email( + client: &Client, + access_token: &str, + fingerprint: &str, +) -> Option { + if let Some(answer) = cached(fingerprint, Instant::now()) { + return answer; + } + let email = request_email(client, access_token).await; + remember(fingerprint, email.as_deref(), Instant::now()); + email +} + +async fn request_email(client: &Client, access_token: &str) -> Option { + let response = client + .get(PROFILE_URL) + .header("Authorization", format!("Bearer {access_token}")) + .header("Accept", "application/json") + .header("anthropic-beta", "oauth-2025-04-20") + .timeout(PROFILE_TIMEOUT) + .send() + .await; + let response = match response { + Ok(response) if response.status().is_success() => response, + Ok(response) => { + tracing::debug!(status = %response.status(), "Claude OAuth profile unavailable"); + return None; + } + Err(error) => { + tracing::debug!(%error, "Claude OAuth profile request failed"); + return None; + } + }; + let body = response.text().await.ok()?; + let email = parse_email(&body); + if email.is_none() { + tracing::debug!("Claude OAuth profile has no account email"); + } + email +} + +/// `account.email_address` (also `emailAddress` / `email`), falling back to +/// the same keys at the top level. Blank or non-email values are ignored. +pub(super) fn parse_email(body: &str) -> Option { + let json: serde_json::Value = serde_json::from_str(body).ok()?; + let read = |object: &serde_json::Value| { + ["email_address", "emailAddress", "email"] + .iter() + .filter_map(|key| object.get(*key).and_then(|v| v.as_str())) + .map(str::trim) + .find(|value| value.contains('@')) + .map(str::to_string) + }; + json.get("account").and_then(read).or_else(|| read(&json)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Shape of the upstream profile response (synthetic values). + const PROFILE_FIXTURE: &str = r#"{ + "account": { + "uuid": "00000000-0000-4000-8000-000000000001", + "full_name": "Parity User", + "display_name": "Parity", + "email_address": "parity.user@example.com", + "has_claude_max": true, + "has_claude_pro": false + }, + "organization": { + "uuid": "00000000-0000-4000-8000-000000000002", + "name": "Parity Labs", + "organization_type": "claude_max", + "billing_type": "stripe_subscription", + "rate_limit_tier": "default_claude_max_20x" + } + }"#; + + #[test] + fn parses_the_account_email_from_the_profile_fixture() { + assert_eq!( + parse_email(PROFILE_FIXTURE).as_deref(), + Some("parity.user@example.com") + ); + } + + #[test] + fn accepts_alternate_keys_and_top_level_email() { + assert_eq!( + parse_email(r#"{"account":{"emailAddress":" a@example.com "}}"#).as_deref(), + Some("a@example.com") + ); + assert_eq!( + parse_email(r#"{"email":"top@example.com"}"#).as_deref(), + Some("top@example.com") + ); + } + + #[test] + fn ignores_missing_blank_or_malformed_values() { + assert!(parse_email("").is_none()); + assert!(parse_email("not json").is_none()); + assert!(parse_email(r#"{"account":{"email_address":" "}}"#).is_none()); + assert!(parse_email(r#"{"account":{"email_address":42}}"#).is_none()); + assert!(parse_email(r#"{"organization":{"name":"Parity Labs"}}"#).is_none()); + } + + #[test] + fn cache_keeps_found_emails_and_cools_down_failures() { + let now = Instant::now(); + remember("fp-found-test", Some("parity.user@example.com"), now); + assert_eq!( + cached("fp-found-test", now + Duration::from_secs(86_400)), + Some(Some("parity.user@example.com".to_string())) + ); + + remember("fp-failed-test", None, now); + assert_eq!(cached("fp-failed-test", now), Some(None)); + assert_eq!(cached("fp-failed-test", now + FAILURE_RETRY), None); + assert_eq!(cached("fp-unknown-test", now), None); + } +} diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 291724881d..07150177c6 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -213,6 +213,7 @@ impl CodexApi { usage, ) .await; + let usage = with_credential_email(usage, &creds); return Ok((usage, cost, creds.account_id.clone(), displayed_credits)); } }; @@ -265,6 +266,7 @@ impl CodexApi { ) .await; let usage = apply_reset_credits_window(usage, displayed_credits.as_ref()); + let usage = with_credential_email(usage, &creds); Ok((usage, cost, creds.account_id.clone(), displayed_credits)) } @@ -580,6 +582,7 @@ impl CodexApi { is_external_oauth: false, access_token_expires_at: None, last_refresh: None, + account_email: None, }); } } @@ -617,6 +620,10 @@ impl CodexApi { .and_then(parse_timestamp); let access_token_expires_at = parse_access_token_expiry(&access_token); + let account_email = tokens + .get("id_token") + .and_then(|v| v.as_str()) + .and_then(crate::codex_accounts::credentials::email_from_id_token); Ok(CodexCredentials { access_token, @@ -624,6 +631,7 @@ impl CodexApi { is_external_oauth: has_refresh_token, access_token_expires_at, last_refresh, + account_email, }) } @@ -1212,6 +1220,15 @@ fn format_plan_type(plan_type: &str) -> String { } } +/// The card header shows the auth.json account email (upstream reads it from +/// the id_token). An email already on the snapshot wins. +fn with_credential_email(usage: UsageSnapshot, creds: &CodexCredentials) -> UsageSnapshot { + match (&usage.account_email, &creds.account_email) { + (None, Some(email)) => usage.with_email(email.clone()), + _ => usage, + } +} + impl Default for CodexApi { fn default() -> Self { Self::new() @@ -1237,6 +1254,9 @@ struct CodexCredentials { /// supplies provenance when the external-source opt-in setting is OFF; /// its age is not an access-token expiry signal. last_refresh: Option>, + /// Account email from the auth.json `id_token` (claim `email`, or the + /// OpenAI profile claim). Shown in the card header; never logged. + account_email: Option, } struct CachedCodexCredentials { @@ -2552,6 +2572,7 @@ mod tests { is_external_oauth: true, access_token_expires_at: None, last_refresh: None, + account_email: None, }; let err = CodexApi::enforce_external_oauth_gate(&creds) .expect_err("external OAuth without provenance must fail closed"); @@ -2567,6 +2588,7 @@ mod tests { is_external_oauth: true, access_token_expires_at: None, last_refresh: Some(old), + account_email: None, }; assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); } @@ -2580,6 +2602,7 @@ mod tests { is_external_oauth: true, access_token_expires_at: None, last_refresh: Some(fresh), + account_email: None, }; assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); } @@ -2616,6 +2639,64 @@ mod tests { assert!(matches!(err, ProviderError::AuthRequired)); } + fn id_token(payload: &str) -> String { + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload); + format!("header.{payload}.signature") + } + + #[test] + fn credentials_read_account_email_from_id_token() { + let token = id_token(r#"{"email":"parity.user@example.com"}"#); + let json = format!( + r#"{{"tokens":{{"access_token":"access","refresh_token":"refresh","id_token":"{token}"}}}}"# + ); + let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); + assert_eq!( + creds.account_email.as_deref(), + Some("parity.user@example.com") + ); + } + + #[test] + fn credentials_fall_back_to_the_openai_profile_email_claim() { + let token = id_token( + r#"{"email":" ","https://api.openai.com/profile":{"email":"profile@example.com"}}"#, + ); + let json = format!(r#"{{"tokens":{{"access_token":"access","id_token":"{token}"}}}}"#); + let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); + assert_eq!(creds.account_email.as_deref(), Some("profile@example.com")); + } + + #[test] + fn credentials_without_a_readable_id_token_have_no_email() { + let json = r#"{"tokens":{"access_token":"access","id_token":"not-a-jwt"}}"#; + let creds = CodexApi::parse_credentials_json(json).expect("credentials"); + assert!(creds.account_email.is_none()); + let api_key = CodexApi::parse_credentials_json(r#"{"OPENAI_API_KEY":"sk-test"}"#) + .expect("credentials"); + assert!(api_key.account_email.is_none()); + } + + #[test] + fn credential_email_fills_the_snapshot_but_never_overrides_one() { + let creds = CodexCredentials { + access_token: "access".to_string(), + account_id: None, + is_external_oauth: true, + access_token_expires_at: None, + last_refresh: None, + account_email: Some("parity.user@example.com".to_string()), + }; + let usage = with_credential_email(UsageSnapshot::new(RateWindow::new(10.0)), &creds); + assert_eq!( + usage.account_email.as_deref(), + Some("parity.user@example.com") + ); + let existing = UsageSnapshot::new(RateWindow::new(10.0)).with_email("whoami@example.com"); + let usage = with_credential_email(existing, &creds); + assert_eq!(usage.account_email.as_deref(), Some("whoami@example.com")); + } + #[test] fn external_oauth_gate_requires_cli_refresh_when_jwt_is_near_expiry() { let soon = Utc::now() + chrono::Duration::minutes(2); @@ -2640,6 +2721,7 @@ mod tests { is_external_oauth: true, access_token_expires_at: None, last_refresh: None, + account_email: None, }; assert!(CodexApi::enforce_external_oauth_gate_at(&creds, true, Utc::now()).is_ok()); } diff --git a/rust/src/providers/codex/mod.rs b/rust/src/providers/codex/mod.rs index 1792711f94..9c07598b5f 100755 --- a/rust/src/providers/codex/mod.rs +++ b/rust/src/providers/codex/mod.rs @@ -13,8 +13,8 @@ use async_trait::async_trait; use std::os::windows::process::CommandExt; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - ResetCreditsObservation, SourceMode, + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, ResetCreditsObservation, SourceMode, }; pub use api::CodexApi; @@ -54,7 +54,8 @@ fn fetch_result( reset_credits: Option<&api::ResetCredits>, ) -> ProviderFetchResult { let account_email = usage.account_email.clone(); - let mut result = ProviderFetchResult::new(usage, source); + let credits = cost.as_ref().and_then(credits_detail); + let mut result = ProviderFetchResult::new(usage, source).with_display_detail(credits); if let Some(cost) = cost.map(|cost| { if cost.account_id.is_none() && let Some(account) = account_email.as_deref() @@ -84,6 +85,62 @@ fn fetch_result( result } +/// Upstream Credits section (`MenuCardView+Costs`): "125.5 left" over a bar. +/// A spend-control limit gives "of " and the remaining share; a plain +/// balance is drawn against the next power of ten, labelled in tokens. +/// Unlimited or absent credits produce no cost and so no row. +fn credits_detail(cost: &crate::core::CostSnapshot) -> Option { + use crate::providers::format; + let (remaining, scale, scale_text) = match cost.limit { + Some(limit) if cost.period == "Monthly credits" => { + let remaining = (limit - cost.used).max(0.0); + (remaining, limit, format!("of {}", format::number(limit, 2))) + } + Some(_) => return None, + None if cost.period == "Credits" => { + let balance = cost.used; + let scale = fallback_credits_scale(balance)?; + let scale_text = if scale <= u64::MAX as f64 { + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "scale is a positive power of ten within u64 range" + )] + let whole = scale as u64; + format::token_count(whole) + } else { + format::number(scale, 2) + }; + (balance, scale, format!("{scale_text} tokens")) + } + None => return None, + }; + if !remaining.is_finite() { + return None; + } + ProviderDisplayDetail::new( + "codex-credits", + "Credits", + format!("{} left", format::number(remaining, 2)), + )? + .with_secondary_value(scale_text)? + .with_progress(remaining.clamp(0.0, scale), scale) +} + +/// `fallbackCreditsScale`: the next power of ten above the balance (1 for 0). +fn fallback_credits_scale(balance: f64) -> Option { + if !balance.is_finite() { + return None; + } + let maximum = balance.max(0.0); + let exponent = if maximum > 0.0 { + maximum.log10().floor() + 1.0 + } else { + 0.0 + }; + Some(10f64.powf(exponent).min(f64::MAX)) +} + fn pat_allows_auto_fallback(error: &ProviderError) -> bool { matches!( error, @@ -278,6 +335,125 @@ mod reset_credit_result_tests { assert!(unknown.inventory.is_empty()); assert!(unknown.reset_credits.is_none()); } + + /// Cost history scoping: the email only labels the cost snapshot when no + /// account id came from the API, and the account identity (the + /// ChatGPT account id that scopes weekly-reset state) is untouched by it. + #[test] + fn oauth_email_labels_cost_but_does_not_replace_account_identity() { + let usage = UsageSnapshot::new(RateWindow::new(10.0)).with_email("parity.user@example.com"); + let cost = crate::core::CostSnapshot::new(125.5, "USD", "Credits"); + let result = fetch_result( + usage.clone(), + Some(cost), + "oauth", + Some("acct_123".to_string()), + None, + ); + assert_eq!( + result.usage.account_email.as_deref(), + Some("parity.user@example.com") + ); + assert_eq!( + result.cost.as_ref().and_then(|c| c.account_id.as_deref()), + Some("parity.user@example.com") + ); + assert_eq!(result.account_identity.as_deref(), Some("acct_123")); + + let keyed = crate::core::CostSnapshot::new(125.5, "USD", "Credits").with_account_id("api"); + let result = fetch_result(usage, Some(keyed), "oauth", None, None); + assert_eq!( + result.cost.as_ref().and_then(|c| c.account_id.as_deref()), + Some("api") + ); + assert!(result.account_identity.is_none()); + } +} + +#[cfg(test)] +mod credits_detail_tests { + use super::*; + use crate::core::CostSnapshot; + + fn row(cost: &CostSnapshot) -> Option<(String, Option, f64, f64)> { + credits_detail(cost).map(|detail| { + let progress = detail.progress().expect("credits bar"); + ( + detail.value().to_string(), + detail.secondary_value().map(str::to_string), + progress.used(), + progress.total(), + ) + }) + } + + #[test] + fn balance_matches_the_upstream_credits_line_and_token_scale() { + let detail = credits_detail(&CostSnapshot::new(125.5, "USD", "Credits")).expect("row"); + assert_eq!(detail.id(), "codex-credits"); + assert_eq!(detail.title(), "Credits"); + assert_eq!( + row(&CostSnapshot::new(125.5, "USD", "Credits")), + Some(( + "125.5 left".to_string(), + Some("1K tokens".to_string()), + 125.5, + 1000.0 + )) + ); + } + + #[test] + fn fallback_scale_is_the_next_power_of_ten() { + let cases = [ + (0.0, "0 left", "1 tokens", 1.0), + (9.5, "9.5 left", "10 tokens", 10.0), + (999.99, "999.99 left", "1K tokens", 1000.0), + (1000.0, "1,000 left", "10K tokens", 10000.0), + (2_500_000.0, "2,500,000 left", "10M tokens", 10_000_000.0), + ]; + for (balance, value, scale_text, scale) in cases { + let (v, secondary, _, total) = + row(&CostSnapshot::new(balance, "USD", "Credits")).expect("row"); + assert_eq!(v, value, "balance {balance}"); + assert_eq!(secondary.as_deref(), Some(scale_text), "balance {balance}"); + assert_eq!(total, scale, "balance {balance}"); + } + } + + #[test] + fn spend_control_limit_shows_remaining_of_limit() { + let cost = CostSnapshot::new(40.0, "USD", "Monthly credits").with_limit(100.0); + assert_eq!( + row(&cost), + Some(( + "60 left".to_string(), + Some("of 100".to_string()), + 60.0, + 100.0 + )) + ); + } + + #[test] + fn other_costs_produce_no_credits_row() { + assert!(credits_detail(&CostSnapshot::new(3.0, "USD", "Monthly")).is_none()); + } + + #[test] + fn fetch_result_carries_the_credits_row_only_with_credits() { + let usage = crate::core::UsageSnapshot::new(crate::core::RateWindow::new(10.0)); + let with = fetch_result( + usage.clone(), + Some(CostSnapshot::new(125.5, "USD", "Credits")), + "oauth", + None, + None, + ); + assert_eq!(with.display_details().len(), 1); + let without = fetch_result(usage, None, "oauth", None, None); + assert!(without.display_details().is_empty()); + } } #[cfg(test)] diff --git a/rust/src/providers/format.rs b/rust/src/providers/format.rs index 864027f0bf..08c80b18c2 100644 --- a/rust/src/providers/format.rs +++ b/rust/src/providers/format.rs @@ -11,6 +11,33 @@ pub(crate) fn number(value: f64, max_fraction_digits: usize) -> String { format_number(value, 0, max_fraction_digits) } +/// Upstream `UsageFormatter.tokenCountString`: "950", "1K", "1.5M", "12B". +/// A unit is promoted where whole lower units would round to 1000. +pub(crate) fn token_count(tokens: u64) -> String { + const UNITS: [(u64, f64, &str); 3] = [ + (999_500_000, 1_000_000_000.0, "B"), + (999_500, 1_000_000.0, "M"), + (1_000, 1_000.0, "K"), + ]; + for (threshold, divisor, suffix) in UNITS { + if tokens >= threshold { + #[expect( + clippy::cast_precision_loss, + reason = "display rounding to at most two significant digits" + )] + let scaled = tokens as f64 / divisor; + let formatted = if scaled >= 10.0 { + format!("{scaled:.0}") + } else { + let one = format!("{scaled:.1}"); + one.strip_suffix(".0").unwrap_or(&one).to_string() + }; + return format!("{formatted}{suffix}"); + } + } + tokens.to_string() +} + /// `ctx.format.usd(value)`: a dollar amount with exactly two fractional /// digits, grouped, and the sign before the dollar sign. pub(crate) fn usd(value: f64) -> String { From cb17161b0a68edb073010ec5ad3123ba842be552 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:28:27 +0700 Subject: [PATCH 2/2] Hide the token-account label under Hide Personal Info like upstream --- .../src/components/MenuCard.headerAccount.test.ts | 11 +++++++---- apps/desktop-tauri/src/components/MenuCard.tsx | 10 ++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts b/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts index 5ddcc053fc..9cc13a4120 100644 --- a/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts +++ b/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts @@ -17,13 +17,16 @@ describe("headerAccount", () => { ); }); - it("masks the email or the label under Hide Personal Info", () => { + it("masks the email and hides the label under Hide Personal Info", () => { expect(headerAccount({ accountEmail: "parity.user@example.com" }, true)).toBe( "p••••••••••@example.com", ); - expect(headerAccount({ accountEmail: null, accountLabel: "parity-github" }, true)).toBe( - "••••@••••", - ); + expect( + headerAccount({ accountEmail: null, accountLabel: "parity-github" }, true), + ).toBeNull(); + expect( + headerAccount({ accountEmail: null, accountLabel: "parity.user@example.com" }, true), + ).toBeNull(); }); it("shows nothing without an email or label", () => { diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 48f8755970..2d42e3ece8 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -56,15 +56,17 @@ export function maskEmail(email: string): string { /** * Upstream `MenuCardView.email(from:)`: the provider-reported email, else the - * active token-account label. Hide Personal Info masks either. + * active token-account label. Hide Personal Info masks the email and drops the + * label, as upstream `PersonalInfoRedactor.redactAccountLabel` blanks it. */ export function headerAccount( provider: Pick, hideEmail: boolean, ): string | null { - const account = provider.accountEmail?.trim() || provider.accountLabel?.trim() || null; - if (!account) return null; - return hideEmail ? maskEmail(account) : account; + const email = provider.accountEmail?.trim(); + if (email) return hideEmail ? maskEmail(email) : email; + if (hideEmail) return null; + return provider.accountLabel?.trim() || null; } function cardMetrics(