diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 270e792e2a..f92d45f3cf 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -77,6 +77,8 @@ pub struct CostSnapshotBridge { pub remaining: Option, #[serde(default = "default_currency")] pub currency_code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub currency_symbol: Option, #[serde(default = "default_cost_period")] pub period: String, #[serde(default)] @@ -101,6 +103,17 @@ fn default_cost_period() -> String { "month".to_string() } +/// Format a cost amount using the snapshot's currency symbol when available, +/// otherwise falling back to the currency-code prefix. Used by tray surfaces +/// that render a spend amount without a rate-window percent (MonthlyPlan). +pub(crate) fn format_cost_amount(cost: &CostSnapshotBridge) -> String { + if let Some(ref symbol) = cost.currency_symbol { + format!("{}{:.2}", symbol, cost.used) + } else { + format!("{:.2} {}", cost.used, cost.currency_code) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NamedRateWindowSnapshot { @@ -337,6 +350,7 @@ impl ProviderUsageSnapshot { limit: c.limit, remaining: c.remaining(), currency_code: c.currency_code.clone(), + currency_symbol: c.currency_symbol.clone(), period: c.period.clone(), resets_at: c.resets_at.map(|dt| dt.to_rfc3339()), formatted_used: c.format_used(), @@ -686,6 +700,8 @@ pub struct SettingsSnapshot { claude_daily_routines_usage_visible: bool, alibaba_token_plan_region: String, weekly_progress_work_days: Option, + cost_summary_display_style: &'static str, + provider_accent_colors: std::collections::HashMap, } #[tauri::command] @@ -791,6 +807,19 @@ impl From for SettingsSnapshot { claude_daily_routines_usage_visible: settings.claude_daily_routines_usage_visible, alibaba_token_plan_region: settings.alibaba_token_plan_region, weekly_progress_work_days: settings.weekly_progress_work_days, + cost_summary_display_style: cost_summary_display_style_label( + settings.cost_summary_display_style, + ), + provider_accent_colors: settings + .provider_configs + .iter() + .filter_map(|(id, config)| { + config + .accent_color + .as_ref() + .map(|color| (id.cli_name().to_string(), color.clone())) + }) + .collect(), } } } @@ -837,6 +866,28 @@ fn theme_label(theme: ThemePreference) -> &'static str { } } +fn cost_summary_display_style_label( + style: codexbar::settings::CostSummaryDisplayStyle, +) -> &'static str { + match style { + codexbar::settings::CostSummaryDisplayStyle::Compact => "compact", + codexbar::settings::CostSummaryDisplayStyle::Detailed => "detailed", + codexbar::settings::CostSummaryDisplayStyle::Hidden => "hidden", + } +} + +pub(crate) fn parse_cost_summary_display_style( + s: &str, +) -> Option { + use codexbar::settings::CostSummaryDisplayStyle; + match s { + "compact" => Some(CostSummaryDisplayStyle::Compact), + "detailed" => Some(CostSummaryDisplayStyle::Detailed), + "hidden" => Some(CostSummaryDisplayStyle::Hidden), + _ => None, + } +} + pub(super) fn parse_theme(s: &str) -> Option { match s { "auto" => Some(ThemePreference::Auto), @@ -855,6 +906,7 @@ fn metric_preference_label(pref: MetricPreference) -> &'static str { MetricPreference::Tertiary => "tertiary", MetricPreference::Credits => "credits", MetricPreference::ExtraUsage => "extraUsage", + MetricPreference::MonthlyPlan => "monthlyPlan", MetricPreference::Average => "average", } } @@ -868,6 +920,7 @@ pub(super) fn parse_metric_preference(s: &str) -> Option { "tertiary" => Some(MetricPreference::Tertiary), "credits" => Some(MetricPreference::Credits), "extraUsage" | "extrausage" => Some(MetricPreference::ExtraUsage), + "monthlyPlan" | "monthlyplan" => Some(MetricPreference::MonthlyPlan), "average" => Some(MetricPreference::Average), _ => None, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 5e8e5b2d2b..fc4e595b7a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ use codexbar::core::{ instantiate_provider, }; use codexbar::locale; +use codexbar::login::{self, LoginOutcome, LoginPhase}; use codexbar::providers::copilot::{CopilotApi, device_flow::CopilotDeviceFlow}; use codexbar::secure_file::{self, SecureFileStatus}; use codexbar::settings::{ diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index d6e579107a..f7ebf7dc57 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -70,6 +70,7 @@ pub struct SettingsUpdate { pub claude_daily_routines_usage_visible: Option, pub alibaba_token_plan_region: Option, pub weekly_progress_work_days: Option, + pub cost_summary_display_style: Option, } impl SettingsUpdate { @@ -326,6 +327,13 @@ impl SettingsUpdate { if let Some(v) = self.weekly_progress_work_days { settings.weekly_progress_work_days = if (2..=6).contains(&v) { Some(v) } else { None }; } + if let Some(v) = self + .cost_summary_display_style + .as_deref() + .and_then(crate::commands::bridge::parse_cost_summary_display_style) + { + settings.cost_summary_display_style = v; + } self } diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 5df7c39da7..403256e4c3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -261,10 +261,12 @@ pub async fn trigger_provider_login( return run_copilot_device_login(&app).await; } - // TODO(6b): replace fallthrough once LoginPhase events land. The login - // runners live in `codexbar::login` but are async-oriented and tightly - // coupled to the egui UI's phase callbacks. For the Tauri shell we - // currently surface the dashboard URL. + if id == ProviderId::Kiro { + return run_cli_provider_login(&app, &provider_id, "kiro", 120).await; + } + + // For other providers, surface the dashboard URL as the login flow + // is not yet wired through the Tauri shell. if let Some(url) = dashboard_url_for_provider(&provider_id) { return open_url_in_browser(&url); } @@ -273,6 +275,39 @@ pub async fn trigger_provider_login( )) } +/// Run a CLI-based provider login (e.g. Kiro) and emit phase events. +async fn run_cli_provider_login( + app: &tauri::AppHandle, + provider_id: &str, + display_name: &str, + timeout_secs: u64, +) -> Result<(), String> { + let app_handle = app.clone(); + let provider_id_owned = provider_id.to_string(); + let result = login::run_kiro_login(timeout_secs, move |phase| { + let phase_str = match phase { + LoginPhase::Idle => "idle", + LoginPhase::Requesting => "requesting", + LoginPhase::WaitingBrowser => "waiting-browser", + LoginPhase::Complete => "complete", + }; + events::emit_login_phase(&app_handle, &provider_id_owned, phase_str, None); + }) + .await; + + match result.outcome { + LoginOutcome::Success => Ok(()), + LoginOutcome::MissingBinary => Err(format!( + "{display_name} CLI not found. Install it and ensure it is on your PATH." + )), + LoginOutcome::LaunchFailed(e) => Err(format!("Failed to launch {display_name} login: {e}")), + LoginOutcome::TimedOut => Err(format!("{display_name} login timed out")), + LoginOutcome::Failed { status } => Err(format!( + "{display_name} login failed with exit code {status}" + )), + } +} + async fn run_copilot_device_login(app: &tauri::AppHandle) -> Result<(), String> { let flow = CopilotDeviceFlow::new(); let device = flow diff --git a/apps/desktop-tauri/src-tauri/src/events.rs b/apps/desktop-tauri/src-tauri/src/events.rs index b6b2e61050..b7484c7643 100644 --- a/apps/desktop-tauri/src-tauri/src/events.rs +++ b/apps/desktop-tauri/src-tauri/src/events.rs @@ -16,6 +16,7 @@ pub const UPDATE_STATE_CHANGED: &str = "update-state-changed"; pub const LOCALE_CHANGED: &str = "locale-changed"; pub const SETTINGS_CHANGED: &str = "settings-changed"; pub const CODEX_ACCOUNTS_UPDATED: &str = "codex-accounts-updated"; +pub const LOGIN_PHASE: &str = "login-phase"; // ── Payloads ───────────────────────────────────────────────────────── @@ -40,6 +41,14 @@ pub struct RefreshStartedPayload { pub provider_ids: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginPhasePayload { + pub provider_id: String, + pub phase: String, + pub auth_link: Option, +} + // ── Emit helpers ───────────────────────────────────────────────────── pub fn emit_surface_mode_changed( @@ -101,3 +110,14 @@ pub fn emit_update_state_changed(app: &AppHandle, payload: &UpdateStatePayload) pub fn emit_settings_changed(app: &AppHandle) { let _ = app.emit(SETTINGS_CHANGED, ()); } + +pub fn emit_login_phase(app: &AppHandle, provider_id: &str, phase: &str, auth_link: Option<&str>) { + let _ = app.emit( + LOGIN_PHASE, + LoginPhasePayload { + provider_id: provider_id.to_string(), + phase: phase.to_string(), + auth_link: auth_link.map(|s| s.to_string()), + }, + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index e7f1c6d31a..65755ccc09 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -3,9 +3,10 @@ use std::sync::Mutex; use crate::commands::ProviderCatalogEntry; -use codexbar::settings::{Settings, TrayIconMode}; #[cfg(test)] -use codexbar::{core::ProviderId, settings::MetricPreference}; +use codexbar::core::ProviderId; +use codexbar::settings::MetricPreference; +use codexbar::settings::{Settings, TrayIconMode}; use tauri::image::Image; use tauri::menu::{CheckMenuItemBuilder, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; @@ -563,6 +564,25 @@ fn provider_status_label( snapshot: &crate::commands::ProviderUsageSnapshot, lang: codexbar::settings::Language, ) -> (String, String) { + // MonthlyPlan metric (PAYG spend, e.g. Mistral): show formatted cost. + let provider = codexbar::core::ProviderId::from_cli_name(&snapshot.provider_id); + let preference = provider + .map(|id| Settings::load().get_provider_metric(id)) + .unwrap_or_default(); + if preference == MetricPreference::MonthlyPlan + && let Some(cost) = snapshot.cost.as_ref() + { + let amount = if !cost.formatted_used.is_empty() { + cost.formatted_used.clone() + } else { + crate::commands::format_cost_amount(cost) + }; + return ( + snapshot.provider_id.clone(), + format!("{} {}", snapshot.display_name, amount), + ); + } + // F5 (upstream 0.48.0): for Codex, prefer the first non-informational lane so // a monthly-only plan shows the monthly window with its reset countdown // instead of the informational "No active 5h session" placeholder. @@ -1041,6 +1061,7 @@ mod tests { limit: Some(limit), remaining: Some((limit - used).max(0.0)), currency_code: "USD".to_string(), + currency_symbol: None, period: "monthly".to_string(), resets_at: None, formatted_used: format!("${used:.2}"), diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 0c568528ad..be5c83bfa0 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -49,6 +49,7 @@ fn preferred_window( extra_usage_window(snapshot).or_else(|| cost_window(snapshot)) } MetricPreference::Average => average_window(snapshot), + MetricPreference::MonthlyPlan => cost_window(snapshot), } } diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index fbd64dbf4c..6320ae1d6e 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -124,6 +124,8 @@ function settings(overrides: Partial = {}): SettingsSnapshot { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, ...overrides, }; } diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 348b5349b0..74fbb2de48 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useState } from "react"; import type { + CostSummaryDisplayStyle, ProviderChartData, ProviderUsageSnapshot, } from "../types/bridge"; @@ -47,15 +48,19 @@ export interface MenuCardDisplayOptions { showResetWhenExhausted?: boolean; showAsUsed?: boolean; compactMetrics?: boolean; + costSummaryDisplayStyle?: CostSummaryDisplayStyle; } interface MenuCardProps { provider: ProviderUsageSnapshot; display: MenuCardDisplayOptions; isRefreshing?: boolean; + /** Per-provider accent color override (hex); applied as CSS --provider-accent. */ + accentColor?: string; onLayoutChange?: () => void; } + export function maskEmail(email: string): string { const at = email.indexOf("@"); if (at <= 1) return "••••@••••"; @@ -109,6 +114,7 @@ export default function MenuCard({ provider, display, isRefreshing = false, + accentColor, onLayoutChange, }: MenuCardProps) { const { @@ -117,6 +123,7 @@ export default function MenuCard({ showResetWhenExhausted = false, showAsUsed = false, compactMetrics = false, + costSummaryDisplayStyle, } = display; const { t } = useLocale(); const [chartData, setChartData] = useState(null); @@ -206,7 +213,7 @@ export default function MenuCard({ } const visibleMetrics = compactMetrics ? metrics.slice(0, 2) : metrics; - const presence = describeCard(provider, chartData, visibleMetrics); + const presence = describeCard(provider, chartData, visibleMetrics, costSummaryDisplayStyle); const { hasDetails } = presence; const cardClassName = [ "menu-card", @@ -218,7 +225,11 @@ export default function MenuCard({ .join(" "); return ( -
+
@@ -254,6 +265,7 @@ export default function MenuCard({ resetTimeRelative, showResetWhenExhausted, showAsUsed, + costSummaryDisplayStyle, }} metrics={visibleMetrics} chartData={chartData} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 5af8df83e8..11c65ea761 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { + CostSummaryDisplayStyle, DailyCostPoint, PaceSnapshot, ProviderChartData, @@ -270,11 +271,11 @@ function getMetricPaceView(snap: RateWindowSnapshot): MetricPaceView { return { kind: "none" }; } - type MetricRowDisplay = { resetTimeRelative: boolean; showResetWhenExhausted?: boolean; showAsUsed?: boolean; + costSummaryDisplayStyle?: CostSummaryDisplayStyle; }; /** @@ -433,6 +434,7 @@ export function describeCard( provider: ProviderUsageSnapshot, chartData: ProviderChartData | null, visibleMetrics: MetricEntry[], + costSummaryDisplayStyle: CostSummaryDisplayStyle = "detailed", ): MenuCardPresence { const hasCostHistory = chartData !== null && chartData.costHistory.some((point) => point.value > 0); @@ -445,7 +447,7 @@ export function describeCard( const localUsage = provider.error ? null : chartData?.localUsage ?? null; const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; const hasMetrics = visibleMetrics.length > 0; - const hasCost = !!provider.cost; + const hasCost = !!provider.cost && costSummaryDisplayStyle !== "hidden"; const hasPace = !!provider.pace; const hasDetails = !provider.error && @@ -481,6 +483,7 @@ export default function MenuCardDetails({ display.resetTimeRelative, ); const localCostHistory = chartData?.costHistory ?? []; + const costStyle = display.costSummaryDisplayStyle ?? "detailed"; const { hasMetrics, @@ -529,9 +532,9 @@ export default function MenuCardDetails({ /> )} - {hasMetrics && hasCost &&
} + {hasMetrics && hasCost && costStyle !== "hidden" &&
} - {provider.cost && ( + {provider.cost && costStyle !== "hidden" && (
{provider.cost.balance != null && provider.cost.limit == null @@ -566,7 +569,7 @@ export default function MenuCardDetails({ )}
- {provider.cost.balance != null && ( + {costStyle === "detailed" && provider.cost.balance != null && (
{t("DetailCostBalance")}:{" "} {provider.cost.formattedBalance || @@ -576,7 +579,7 @@ export default function MenuCardDetails({ )}
)} - {provider.cost.remaining != null && ( + {costStyle === "detailed" && provider.cost.remaining != null && (
{t("DetailCostRemaining")}:{" "} {formatCurrency( @@ -585,13 +588,21 @@ export default function MenuCardDetails({ )}
)} - {formattedCostReset && ( + {costStyle === "detailed" && formattedCostReset && (
{t("DetailCostResets")}: {formattedCostReset}
)} )} + {provider.providerId === "mistral" && provider.cost && ( +
+ {t("MistralMonthlySpend")}:{" "} + {provider.cost.currencySymbol + ? `${provider.cost.currencySymbol}${provider.cost.used.toFixed(2)}` + : provider.cost.formattedUsed} +
+ )}
)} @@ -649,7 +660,7 @@ export default function MenuCardDetails({ `$${v.toFixed(2)}`} t={t} /> diff --git a/apps/desktop-tauri/src/components/MiniBarChart.tsx b/apps/desktop-tauri/src/components/MiniBarChart.tsx index 63e15bd023..df9ca989c4 100644 --- a/apps/desktop-tauri/src/components/MiniBarChart.tsx +++ b/apps/desktop-tauri/src/components/MiniBarChart.tsx @@ -79,9 +79,9 @@ export function SimpleBarChart({
{visible.length > 0 && ( <> - {visible[0].date.slice(-5)} - {fmt(max)} - {visible[visible.length - 1].date.slice(-5)} + {visible[0].date.slice(-5)} + {fmt(max)} + {visible[visible.length - 1].date.slice(-5)} )}
@@ -207,9 +207,9 @@ export function StackedBarChart({
{visible.length > 0 && ( <> - {visible[0].day.slice(-5)} - {max.toFixed(1)} - {visible[visible.length - 1].day.slice(-5)} + {visible[0].day.slice(-5)} + {max.toFixed(1)} + {visible[visible.length - 1].day.slice(-5)} )}
diff --git a/apps/desktop-tauri/src/components/charts/chartPalette.test.ts b/apps/desktop-tauri/src/components/charts/chartPalette.test.ts index 2b7c893c4f..c2299f4607 100644 --- a/apps/desktop-tauri/src/components/charts/chartPalette.test.ts +++ b/apps/desktop-tauri/src/components/charts/chartPalette.test.ts @@ -8,29 +8,31 @@ import { describe("chartPalette.providerColor", () => { it("returns a CSS var() expression referencing a provider token for known ids", () => { expect(providerCostColor("claude")).toBe( - "var(--chart-claude, var(--chart-cost))", + "var(--chart-claude, var(--provider-accent, var(--chart-cost)))", ); expect(providerCreditsColor("codex")).toBe( - "var(--chart-codex, var(--chart-credits))", + "var(--chart-codex, var(--provider-accent, var(--chart-credits)))", ); }); it("is case-insensitive and handles spaced aliases", () => { expect(providerCostColor("CURSOR")).toBe( - "var(--chart-cursor, var(--chart-cost))", + "var(--chart-cursor, var(--provider-accent, var(--chart-cost)))", ); expect(providerCostColor("Kimi K2")).toBe( - "var(--chart-kimik2, var(--chart-cost))", + "var(--chart-kimik2, var(--provider-accent, var(--chart-cost)))", ); expect(providerCostColor("Vertex AI")).toBe( - "var(--chart-vertexai, var(--chart-cost))", + "var(--chart-vertexai, var(--provider-accent, var(--chart-cost)))", ); }); it("falls back to the generic cost/credits token for unknown providers", () => { - expect(providerCostColor("unknown-provider-xyz")).toBe("var(--chart-cost)"); + expect(providerCostColor("unknown-provider-xyz")).toBe( + "var(--provider-accent, var(--chart-cost))", + ); expect(providerCreditsColor("another-ghost")).toBe( - "var(--chart-credits)", + "var(--provider-accent, var(--chart-credits))", ); }); }); diff --git a/apps/desktop-tauri/src/components/charts/chartPalette.ts b/apps/desktop-tauri/src/components/charts/chartPalette.ts index 10ba78a34f..beae1d7a0d 100644 --- a/apps/desktop-tauri/src/components/charts/chartPalette.ts +++ b/apps/desktop-tauri/src/components/charts/chartPalette.ts @@ -56,13 +56,15 @@ const PROVIDER_TOKEN: Record = { /** CSS color expression for a provider's cost-series bars. */ export function providerCostColor(providerId: string): string { const token = PROVIDER_TOKEN[providerId.toLowerCase()]; - return token ? `var(${token}, var(--chart-cost))` : "var(--chart-cost)"; + if (token) return `var(${token}, var(--provider-accent, var(--chart-cost)))`; + return "var(--provider-accent, var(--chart-cost))"; } /** CSS color expression for a provider's credits-series line. */ export function providerCreditsColor(providerId: string): string { const token = PROVIDER_TOKEN[providerId.toLowerCase()]; - return token ? `var(${token}, var(--chart-credits))` : "var(--chart-credits)"; + if (token) return `var(${token}, var(--provider-accent, var(--chart-credits)))`; + return "var(--provider-accent, var(--chart-credits))"; } /** diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 1991ccee8e..a7235838ae 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -185,6 +185,8 @@ function settings(overrides: Partial = {}): SettingsSnapshot { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, ...overrides, }; } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index ff20e42a8d..607625c8df 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -787,6 +787,23 @@ export const ALL_LOCALE_KEYS = [ "PromoteTrayIconLabel", "PromoteTrayIconHelper", "PromoteTrayIconUnsupportedHint", + + // Mistral PAYG monthly spend (#2821, #2947) + "MistralMonthlySpend", + "MistralMonthlySpendHelper", + + // Menu cost-summary display style (#2976) + "CostSummaryDisplayStyle", + "CostSummaryDisplayStyleHelper", + "CostSummaryStyleCompact", + "CostSummaryStyleDetailed", + "CostSummaryStyleHidden", + + // Per-provider accent color override (#2972) + "ProviderAccentColor", + "ProviderAccentColorHelper", + "ProviderAccentColorReset", + "ProviderAccentColorInvalid", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index f71ed56925..3e16dfb6a1 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1845,15 +1845,19 @@ body:has(.tray-panel-reveal) { } .mini-chart__axis { - display: flex; - justify-content: space-between; - align-items: center; + position: relative; font-size: 0.68rem; color: var(--text-muted); font-family: "Cascadia Code", "Fira Code", monospace; padding: 0 2px; } +.mini-chart__axis > span { + position: absolute; + transform: translateX(-50%); + white-space: nowrap; +} + .mini-chart__legend { display: flex; flex-wrap: wrap; @@ -4248,7 +4252,7 @@ html:has(.menu-surface--tray) { .menu-metric__bar-fill { height: 100%; border-radius: 3px; - background: var(--usage-bar-normal); + background: var(--provider-accent, var(--usage-bar-normal)); transition: width 0.3s ease; } @@ -5912,3 +5916,68 @@ html:has(.menu-surface--tray) { .agent-sessions__error { color: var(--provider-status-error); } + +/* ── Per-provider accent color override (#2972) ─────────────────── */ +.provider-detail-accent-color { + margin-top: 16px; +} + +.accent-color-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.accent-color-picker { + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + background: none; +} + +.accent-color-input { + flex: 1; + min-width: 0; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-family: var(--font-mono, monospace); + font-size: 13px; + background: var(--surface-elevated); + color: inherit; +} + +.accent-color-swatch-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 12px; +} + +.accent-color-swatch { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.accent-color-swatch-label { + color: var(--text-secondary); +} + +.accent-color-swatch-value { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); +} + +/* ── Mistral monthly spend row (#2821, #2947) ──────────────────── */ +.menu-card__monthly-spend { + margin-top: 4px; + font-weight: 500; +} diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 81f13ae848..5c1f8ac476 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -178,6 +178,8 @@ function settings(): SettingsSnapshot { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, }; } diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx index 9aa06a7717..4f21b03bea 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx @@ -259,7 +259,9 @@ export default function PopOutPanel({ showResetWhenExhausted: settings.showResetWhenExhausted, showAsUsed: settings.showAsUsed, compactMetrics: selectedProviderId === null, + costSummaryDisplayStyle: settings.costSummaryDisplayStyle, }} + accentColor={settings.providerAccentColors[p.providerId]} />
diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index c4a426a0bd..91fabb8cc2 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -164,6 +164,8 @@ function settings(overrides: Partial = {}): SettingsSnapshot { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, ...overrides, }; } diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 0f92bba552..93a7fd0bde 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -126,7 +126,9 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { showResetWhenExhausted: settings.showResetWhenExhausted, showAsUsed: settings.showAsUsed, compactMetrics: selectedProviderId === null, + costSummaryDisplayStyle: settings.costSummaryDisplayStyle, }} + accentColor={settings.providerAccentColors[p.providerId]} onLayoutChange={requestLayout} />
diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 996b4bdacb..c3377d78a5 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -35,6 +35,7 @@ import { TokenAccountsPanel } from "../tokens/TokenAccountsPanel"; import { ApiKeySection } from "./ApiKeySection"; import { CookieSection } from "./CookieSection"; import { MenuBarMetricSection } from "./sections/MenuBarMetricSection"; +import { AccentColorSection } from "./sections/AccentColorSection"; import { ProviderIssueNotice } from "./sections/ProviderIssueNotice"; import { CredentialStorageSection } from "./sections/CredentialStorageSection"; import { CredentialsDispatcher } from "./sections/CredentialsDispatcher"; @@ -45,6 +46,8 @@ interface Props { cookieDomain?: string | null; resetTimeRelative: boolean; providerMetrics: SettingsSnapshot["providerMetrics"]; + /** Per-provider accent color overrides (CLI name → hex color). */ + providerAccentColors: SettingsSnapshot["providerAccentColors"]; wayfinderGatewayUrl: string; settingsDisabled: boolean; onSettingsChange: (patch: SettingsUpdate) => void; @@ -62,6 +65,7 @@ export function ProviderDetailPane({ cookieDomain = null, resetTimeRelative, providerMetrics, + providerAccentColors, wayfinderGatewayUrl, settingsDisabled, onSettingsChange, @@ -298,6 +302,12 @@ export function ProviderDetailPane({ t={t} onChange={onSettingsChange} /> + @@ -340,6 +350,7 @@ export function ProviderDetailPane({ diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx new file mode 100644 index 0000000000..9e0f8dca41 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; +import type { LocaleKey } from "../../../../i18n/keys"; +import type { SettingsUpdate } from "../../../../types/bridge"; +import { getProviderIcon } from "../../../../components/providers/providerIcons"; + +interface Props { + providerId: string; + accentColor: string | null; + t: (key: LocaleKey) => string; + onChange: (patch: SettingsUpdate) => void; +} + +/** + * Per-provider accent color override (#2972): hex input, native color + * picker, and a reset-to-shipped-color button. The override is persisted + * via the standard settings-update flow (onSettingsChange). + */ +export function AccentColorSection({ + providerId, + accentColor, + t, + onChange, +}: Props) { + const [input, setInput] = useState(accentColor ?? ""); + const [error, setError] = useState(null); + + const brandColor = getProviderIcon(providerId).brandColor; + const effective = accentColor ?? brandColor; + + const handleSave = (raw: string) => { + setError(null); + const trimmed = raw.trim(); + if (trimmed === "") { + onChange({ providerAccentColors: { [providerId]: null } }); + setInput(""); + return; + } + const trimmedHex = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; + if (trimmedHex.length !== 6 || !/^[0-9A-Fa-f]{6}$/.test(trimmedHex)) { + setError(t("ProviderAccentColorInvalid")); + return; + } + const normalized = `#${trimmedHex.toUpperCase()}`; + onChange({ providerAccentColors: { [providerId]: normalized } }); + setInput(normalized); + }; + + const handleReset = () => { + setError(null); + onChange({ providerAccentColors: { [providerId]: null } }); + setInput(""); + }; + + return ( +
+

{t("ProviderAccentColor")}

+

+ {t("ProviderAccentColorHelper")} +

+
+ { + const value = e.target.value.toUpperCase(); + setInput(value); + handleSave(value); + }} + /> + setInput(e.target.value)} + onBlur={() => handleSave(input)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSave(input); + } + }} + /> + +
+
+ + {t("ProviderAccentColor")} + + + {effective} +
+ {error &&

{error}

} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx index 6e4ae41c27..92f7c4f5c2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -93,6 +93,9 @@ function metricOptions( if (provider.id === "cursor" || provider.extraRateWindows.length > 0) { options.push({ value: "extraUsage", label: t("ExtraUsage") }); } + if (provider.id === "mistral") { + options.push({ value: "monthlyPlan", label: t("MistralMonthlySpend") }); + } if (provider.id === "gemini" && provider.weekly) { options.push({ value: "average", label: t("Average") }); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index f295b123ce..b3550e1c30 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { type CSSProperties, useEffect, useState } from "react"; import { getProviderChartData, getSettingsSnapshot } from "../../../../../lib/tauri"; import { providerSupportsChartData } from "../../../../../lib/providerCharts"; import type { ProviderChartData, SettingsSnapshot } from "../../../../../types/bridge"; @@ -13,6 +13,8 @@ type T = ReturnType["t"]; interface Props { providerId: string; accountEmail: string | null; + /** Per-provider accent color override (hex); applied as CSS --provider-accent. */ + accentColor?: string; t: T; } @@ -27,7 +29,7 @@ type TabKey = "tokens" | "cost" | "credits" | "usage"; * Phase 10: fetches the latest settings snapshot so the animation flag feeds * through to each chart component. */ -export function ChartsSection({ providerId, accountEmail, t }: Props) { +export function ChartsSection({ providerId, accountEmail, accentColor, t }: Props) { const [data, setData] = useState(null); const [active, setActive] = useState(null); const [animations, setAnimations] = useState(true); @@ -98,9 +100,12 @@ export function ChartsSection({ providerId, accountEmail, t }: Props) { if (k === "credits") return t("DetailChartCredits"); return t("DetailChartUsageBreakdown"); }; - return ( -
+ +
{available.map((k) => (
+ + {error &&

{error}

} {shareError &&

{shareError}

} @@ -230,3 +233,52 @@ export default function UsageSpendTab(_props: TabProps) {
); } + +function CostSummaryStyleControl({ t }: { t: (key: LocaleKey) => string }) { + const [style, setStyle] = useState("compact"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + void getSettingsSnapshot().then((snap: SettingsSnapshot) => { + setStyle(snap.costSummaryDisplayStyle); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + const handleChange = useCallback(async (value: CostSummaryDisplayStyle) => { + setStyle(value); + try { + await updateSettings({ costSummaryDisplayStyle: value }); + } catch { + /* best-effort; revert handled by next settings refresh */ + } + }, []); + + const options: { value: CostSummaryDisplayStyle; label: string }[] = [ + { value: "compact", label: t("CostSummaryStyleCompact") }, + { value: "detailed", label: t("CostSummaryStyleDetailed") }, + { value: "hidden", label: t("CostSummaryStyleHidden") }, + ]; + + return ( +
+ +

{t("CostSummaryDisplayStyleHelper")}

+ +
+ ); +} diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index d505264216..d2e56e8051 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -117,6 +117,8 @@ describe("Language type", () => { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, }; expect(snap.uiLanguage).toBe("spanish"); diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index f00b510a37..7d108296c8 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -43,6 +43,7 @@ export type MetricPreference = | "tertiary" | "credits" | "extraUsage" + | "monthlyPlan" | "average"; export type Language = @@ -68,6 +69,9 @@ export type UpdateChannel = "stable" | "beta"; export type ThemePreference = "auto" | "light" | "dark"; export type MenuBarDisplayMode = "minimal" | "compact" | "detailed"; + +/** How cost is rendered on provider MenuCards (#2976). */ +export type CostSummaryDisplayStyle = "compact" | "detailed" | "hidden"; export type FloatBarOrientation = "horizontal" | "vertical"; export type FloatBarStyle = "floating" | "taskbar"; @@ -244,6 +248,10 @@ export interface SettingsSnapshot { alibabaTokenPlanRegion: string; /** Optional work-week length [2,6] for session-equivalent weekly forecast. */ weeklyProgressWorkDays?: number | null; + /** How cost is rendered on provider cards (#2976). */ + costSummaryDisplayStyle: CostSummaryDisplayStyle; + /** Per-provider accent color overrides (CLI name → hex color, #2972). */ + providerAccentColors: Record; } /** Partial settings object — only include fields you want to change. */ @@ -310,6 +318,8 @@ export interface SettingsUpdate { claudeDailyRoutinesUsageVisible?: boolean; alibabaTokenPlanRegion?: string; weeklyProgressWorkDays?: number | null; + costSummaryDisplayStyle?: CostSummaryDisplayStyle; + providerAccentColors?: Record; } export interface UsageThresholdOverride { @@ -426,6 +436,8 @@ export interface CostSnapshotBridge { limit: number | null; remaining: number | null; currencyCode: string; + /** Optional currency symbol (e.g. "€", "$", "¥") for localized rendering. */ + currencySymbol?: string | null; period: string; resetsAt: string | null; formattedUsed: string; diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 77ef28f622..706cc5ab32 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -139,8 +139,12 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32) { println!(" Local cost scanning not available for this provider"); println!(" (Only Codex and Claude have local logs)"); } else if result.summary.sessions_count == 0 { - println!(" No usage data found"); - println!(" Check that you have used {} locally", result.display_name); + if result.summary.known_zero { + println!(" No usage in the last {} days (scan complete)", days); + } else { + println!(" No usage data found"); + println!(" Check that you have used {} locally", result.display_name); + } } else { // Total cost if use_color { @@ -244,6 +248,13 @@ fn print_json_output(results: &[CostResult], pretty: bool, days: u32) -> anyhow: } else { serde_json::Value::Null }, + // Upstream 0.50.1 #2932: known-zero flag — scan completed + // with zero results. null for non-Codex; true/false for Codex. + "knownZero": if r.provider == "codex" { + serde_json::Value::Bool(r.summary.known_zero) + } else { + serde_json::Value::Null + }, // F18 (upstream 0.48.0): pricing completeness. "complete" or // {"partial": {"unpriced_models": [...]}}. "modelPricingCompleteness": match &r.summary.model_pricing_completeness { @@ -339,6 +350,7 @@ mod tests { "tokens": { "input": 0, "output": 0, "cached": 0 }, "sessions_count": 1, "historyCoverageIsEstablished": true, + "knownZero": false, "modelPricingCompleteness": { "partial": { "unpriced_models": ["codex-auto-review"] } }, diff --git a/rust/src/cli/dashboard.rs b/rust/src/cli/dashboard.rs index 8375ac4650..bf2cb2eab6 100644 --- a/rust/src/cli/dashboard.rs +++ b/rust/src/cli/dashboard.rs @@ -39,7 +39,7 @@ pub async fn run(args: DashboardArgs) -> anyhow::Result<()> { }; let fetch_timeout = parse_timeout(args.timeout)?; - let producer = SnapshotProducer::new(60, identity).with_fetch_timeout(fetch_timeout); + let producer = SnapshotProducer::new(60, Some(identity)).with_fetch_timeout(fetch_timeout); let payload = producer.collect().await.map_err(anyhow::Error::msg)?; let body = if args.pretty { diff --git a/rust/src/cli/diagnose.rs b/rust/src/cli/diagnose.rs index 28ef7fa122..517598f23d 100644 --- a/rust/src/cli/diagnose.rs +++ b/rust/src/cli/diagnose.rs @@ -370,7 +370,10 @@ fn source_mode_name(mode: SourceMode) -> &'static str { fn error_category(err: &ProviderError) -> &'static str { match err { - ProviderError::AuthRequired | ProviderError::OAuth(_) | ProviderError::NoCookies => "auth", + ProviderError::AuthRequired + | ProviderError::OAuth(_) + | ProviderError::OAuthRevoked(_) + | ProviderError::NoCookies => "auth", ProviderError::Network(_) | ProviderError::Timeout => "network", ProviderError::NotInstalled(_) | ProviderError::UnsupportedSource(_) => "config", ProviderError::Parse(_) => "parse", diff --git a/rust/src/cli/hooks.rs b/rust/src/cli/hooks.rs index 5b66303c46..bcb23d0149 100644 --- a/rust/src/cli/hooks.rs +++ b/rust/src/cli/hooks.rs @@ -376,9 +376,10 @@ fn map_status_level(level: StatusLevel) -> HookProviderStatus { /// Coarse, non-secret category for a refresh failure. Never forwards raw errors. fn hook_refresh_failure_status(error: &ProviderError) -> String { match error { - ProviderError::AuthRequired | ProviderError::NoCookies | ProviderError::OAuth(_) => { - "auth_required".into() - } + ProviderError::AuthRequired + | ProviderError::NoCookies + | ProviderError::OAuth(_) + | ProviderError::OAuthRevoked(_) => "auth_required".into(), ProviderError::Timeout => "timeout".into(), ProviderError::Network(err) => { if err.is_timeout() { diff --git a/rust/src/cli/serve/dashboard/mod.rs b/rust/src/cli/serve/dashboard/mod.rs index 6c624fd5d0..ccede28e8c 100644 --- a/rust/src/cli/serve/dashboard/mod.rs +++ b/rust/src/cli/serve/dashboard/mod.rs @@ -17,7 +17,9 @@ use snapshot::DashboardIdentity; #[derive(Clone)] pub struct DashboardState { pub coordinator: SnapshotCoordinator, - pub identity: DashboardIdentity, + /// `None` = follow the app's `hide_personal_info` setting per request + /// (upstream 0.50.1 #2960). + pub identity: Option, pub refresh_seconds: u32, } @@ -33,7 +35,7 @@ impl std::fmt::Debug for DashboardState { impl DashboardState { /// Production wiring: live producer behind the TTL coordinator. - pub fn live(refresh_seconds: u32, identity: DashboardIdentity) -> Self { + pub fn live(refresh_seconds: u32, identity: Option) -> Self { let producer = source::SnapshotProducer::new(refresh_seconds, identity); let coordinator = SnapshotCoordinator::new( std::time::Duration::from_secs(refresh_seconds.max(1) as u64), @@ -51,7 +53,7 @@ impl DashboardState { pub fn stub( build: coordinator::SnapshotBuildFn, ttl_seconds: u32, - identity: DashboardIdentity, + identity: Option, ) -> Self { Self { coordinator: SnapshotCoordinator::new( diff --git a/rust/src/cli/serve/dashboard/source.rs b/rust/src/cli/serve/dashboard/source.rs index cc695e58c4..10d6ca89a5 100644 --- a/rust/src/cli/serve/dashboard/source.rs +++ b/rust/src/cli/serve/dashboard/source.rs @@ -39,7 +39,7 @@ const ACCOUNT_FETCH_TIMEOUT: Duration = Duration::from_secs(75); #[derive(Clone, Debug)] pub struct SnapshotProducer { pub refresh_seconds: u32, - pub identity: DashboardIdentity, + pub identity: Option, pub version: String, /// Outer per-provider fetch envelope; `None` relies on provider-internal /// `web_timeout` alone (`--timeout 0` in the dashboard command). @@ -47,7 +47,7 @@ pub struct SnapshotProducer { } impl SnapshotProducer { - pub fn new(refresh_seconds: u32, identity: DashboardIdentity) -> Self { + pub fn new(refresh_seconds: u32, identity: Option) -> Self { Self { refresh_seconds, identity, @@ -68,6 +68,13 @@ impl SnapshotProducer { async fn collect_inner(&self) -> Result { let settings = Settings::load(); + // Resolve identity: explicit --identity flag wins; otherwise follow + // the app's hide_personal_info setting (upstream 0.50.1 #2960). + let identity = self.identity.unwrap_or(if settings.hide_personal_info { + DashboardIdentity::Redacted + } else { + DashboardIdentity::Full + }); let provider_ids: Vec = settings.get_enabled_provider_ids(); // Concurrent, individually bounded provider fetches; order restored by index. @@ -108,7 +115,7 @@ impl SnapshotProducer { providers, costs, claude_accounts, - identity: self.identity, + identity, generated_at: Utc::now(), refresh_seconds: self.refresh_seconds, version: Some(self.version.clone()), @@ -300,9 +307,16 @@ mod tests { #[test] fn producer_defaults_to_redacted_identity() { - let producer = SnapshotProducer::new(60, DashboardIdentity::Redacted); - assert_eq!(producer.identity, DashboardIdentity::Redacted); + let producer = SnapshotProducer::new(60, Some(DashboardIdentity::Redacted)); + assert_eq!(producer.identity, Some(DashboardIdentity::Redacted)); assert_eq!(producer.refresh_seconds, 60); assert!(!producer.version.is_empty()); } + + #[test] + fn producer_none_identity_follows_settings() { + let producer = SnapshotProducer::new(60, None); + assert_eq!(producer.identity, None); + assert_eq!(producer.refresh_seconds, 60); + } } diff --git a/rust/src/cli/serve/mod.rs b/rust/src/cli/serve/mod.rs index 74f8eb0cba..5aed8ec63f 100644 --- a/rust/src/cli/serve/mod.rs +++ b/rust/src/cli/serve/mod.rs @@ -82,10 +82,11 @@ pub struct ServeArgs { #[arg(long = "allow-plain-http", default_value_t = false)] pub allow_plain_http: bool, - /// Dashboard snapshot identity detail: redacted (default) or full. `full` - /// exposes real account emails to every authorized dashboard client. - #[arg(long, value_parser = ["redacted", "full"], default_value = "redacted")] - pub identity: String, + /// Dashboard snapshot identity detail: redacted or full. When omitted, + /// the identity follows the app's "hide personal info" setting per + /// request (upstream 0.50.1 #2960). + #[arg(long, value_parser = ["redacted", "full"])] + pub identity: Option, } /// Normalized serve bind configuration after startup validation. @@ -98,8 +99,9 @@ struct ServeConfig { /// [`HEAD_READ_TIMEOUT`]; tests inject a short budget (upstream 0.48.0 /// #2684 makes the deadline injectable for exactly this reason). head_read_budget: Duration, - /// Dashboard snapshot identity mode (`redacted` default, `full` opt-in). - identity: DashboardIdentity, + /// Dashboard snapshot identity mode. `None` means follow the app's + /// `hide_personal_info` setting per request (upstream 0.50.1 #2960). + identity: Option, /// Dashboard state (coordinator + producer). Always `Some` from `run`; /// `None` only in pure-transport tests, where dashboard routes answer 503. dashboard: Option, @@ -171,10 +173,13 @@ fn validate_serve_args(args: &ServeArgs) -> anyhow::Result { if args.port == 0 { anyhow::bail!("--port must be between 1 and 65535."); } - // clap's value_parser already rejects anything but redacted|full. - let Some(identity) = DashboardIdentity::parse(&args.identity) else { - anyhow::bail!("--identity must be redacted or full."); + let identity = match args.identity.as_deref() { + Some(raw) => Some( + DashboardIdentity::parse(raw) + .ok_or_else(|| anyhow::anyhow!("--identity must be redacted or full."))?, + ), + None => None, }; let token = resolve_dashboard_token(args.dashboard_token.as_deref())?; diff --git a/rust/src/cli/serve/tests.rs b/rust/src/cli/serve/tests.rs index 419c1a8150..d2210764d8 100644 --- a/rust/src/cli/serve/tests.rs +++ b/rust/src/cli/serve/tests.rs @@ -62,7 +62,7 @@ fn validate_serve_args_accepts_loopback_without_token() { refresh_interval: 60, dashboard_token: None, allow_plain_http: false, - identity: "redacted".into(), + identity: Some("redacted".into()), }) .unwrap(); assert_eq!(config.host, "127.0.0.1"); @@ -77,7 +77,7 @@ fn validate_serve_args_rejects_lan_without_token() { refresh_interval: 60, dashboard_token: None, allow_plain_http: true, - identity: "redacted".into(), + identity: Some("redacted".into()), }) .unwrap_err() .to_string(); @@ -92,7 +92,7 @@ fn validate_serve_args_rejects_lan_without_allow_plain_http() { refresh_interval: 60, dashboard_token: Some("tok".into()), allow_plain_http: false, - identity: "redacted".into(), + identity: Some("redacted".into()), }) .unwrap_err() .to_string(); @@ -154,7 +154,7 @@ fn head_test_config(budget: Duration, token: Option<&str>) -> ServeConfig { port: 8080, token_digest: token.map(|t| sha256_digest(t.as_bytes())), head_read_budget: budget, - identity: DashboardIdentity::Redacted, + identity: Some(DashboardIdentity::Redacted), dashboard: None, } } @@ -629,7 +629,7 @@ fn stub_state_ok() -> dashboard::DashboardState { dashboard::DashboardState::stub( stub_build(DashboardIdMode::Redacted, false, Duration::ZERO), 3600, - DashboardIdMode::Redacted, + Some(DashboardIdMode::Redacted), ) } @@ -780,7 +780,7 @@ async fn snapshot_identity_modes_redact_or_expose() { let state = dashboard::DashboardState::stub( stub_build(DashboardIdMode::Redacted, false, Duration::ZERO), 3600, - DashboardIdMode::Redacted, + Some(DashboardIdMode::Redacted), ); let config = dashboard_test_config(None, Some(state)); let redacted = request_roundtrip_dashboard( @@ -798,7 +798,7 @@ async fn snapshot_identity_modes_redact_or_expose() { let state = dashboard::DashboardState::stub( stub_build(DashboardIdMode::Full, false, Duration::ZERO), 3600, - DashboardIdMode::Full, + Some(DashboardIdMode::Full), ); let config = dashboard_test_config(None, Some(state)); let full = request_roundtrip_dashboard( @@ -814,7 +814,7 @@ async fn snapshot_claude_accounts_nest_under_claude_row() { let state = dashboard::DashboardState::stub( stub_build(DashboardIdMode::Redacted, true, Duration::ZERO), 3600, - DashboardIdMode::Redacted, + Some(DashboardIdMode::Redacted), ); let config = dashboard_test_config(None, Some(state)); let response = request_roundtrip_dashboard( @@ -835,7 +835,7 @@ async fn snapshot_late_build_is_delivered_not_discarded() { let state = dashboard::DashboardState::stub( stub_build(DashboardIdMode::Redacted, false, Duration::from_millis(250)), 3600, - DashboardIdMode::Redacted, + Some(DashboardIdMode::Redacted), ); let config = dashboard_test_config(None, Some(state)); let started = std::time::Instant::now(); diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index ed6062d58b..223e06afcb 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -360,12 +360,32 @@ fn render_json_result( result: ProviderFetchResult, status: Option<&StatusInfo>, ) -> serde_json::Value { + let usage = &result.usage; + let primary_pace = usage + .primary + .window_minutes + .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) + .then(|| UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES)) + .flatten() + .map(pace_json); + let secondary_pace = usage + .secondary + .as_ref() + .and_then(|w| UsagePace::weekly(w, None, w.window_minutes.unwrap_or(10080))) + .map(pace_json); + let mut json_result = serde_json::json!({ "provider": provider_id.cli_name(), "source": result.source_label, "usage": result.usage, "cost": result.cost, }); + if primary_pace.is_some() || secondary_pace.is_some() { + json_result["pace"] = serde_json::json!({ + "primary": primary_pace, + "secondary": secondary_pace, + }); + } if let Some(s) = status { json_result["status"] = serde_json::json!({ @@ -377,6 +397,16 @@ fn render_json_result( json_result } +/// Serialize a [`UsagePace`] into a compact JSON object for the `--json` output. +fn pace_json(pace: UsagePace) -> serde_json::Value { + serde_json::json!({ + "stage": format!("{:?}", pace.stage).to_lowercase(), + "deltaPercent": pace.delta_percent, + "expectedUsedPercent": pace.expected_used_percent, + "willLastToReset": pace.will_last_to_reset, + }) +} + fn print_usage_output(output: UsageOutput) -> anyhow::Result<()> { match output { UsageOutput::Text(sections) => { @@ -489,6 +519,17 @@ fn append_usage_window_lines( use_color: bool, ) { append_window_line(lines, metadata.session_label, &usage.primary, use_color); + // Upstream 0.50.1 #2957: pace for the 5-hour session window. + if usage.primary.window_minutes == Some(crate::core::SESSION_WINDOW_MINUTES) + && let Some(pace) = + UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES) + { + lines.push(format!( + " Pace: {} {}", + pace.stage.emoji(), + pace.format_status() + )); + } append_secondary_window_line( lines, usage.secondary.as_ref(), diff --git a/rust/src/core/codex_routed_pricing.rs b/rust/src/core/codex_routed_pricing.rs new file mode 100644 index 0000000000..39fb964513 --- /dev/null +++ b/rust/src/core/codex_routed_pricing.rs @@ -0,0 +1,41 @@ +//! Codex routed-model pricing (upstream 0.50.1 #2946). +//! +//! Codex rollouts routed through a non-OpenAI backend (DeepSeek, Kimi, +//! OpenCode) carry the provider as a `provider/model` prefix. This module +//! detects the route and strips the prefix so the cost lookup prices +//! against the right models.dev catalog instead of falling back to OpenAI. + +/// Detect a provider-qualified route prefix on a Codex model name and +/// return the matching models.dev provider id (upstream 0.50.1 #2946). +/// +/// Known routes: `deepseek/` → "deepseek", `kimi/` → "kimi", +/// `opencode/` → "opencode". The `openai/` prefix is stripped by +/// [`normalize_codex_model`] and priced against the OpenAI catalog as +/// before. Unknown `provider/` prefixes return `None` here so the caller +/// leaves them unpriced rather than guessing. +pub fn codex_routed_provider(model: &str) -> Option<&'static str> { + let trimmed = model.trim(); + let (prefix, _rest) = trimmed.split_once('/')?; + match prefix.to_ascii_lowercase().as_str() { + "deepseek" => Some("deepseek"), + "kimi" => Some("kimi"), + "opencode" => Some("opencode"), + _ => None, + } +} + +/// Strip a known route prefix, returning the model id for a models.dev +/// lookup. Unknown prefixes are left intact (the caller leaves them +/// unpriced). `openai/` is also stripped here for the routed path. +pub fn strip_route_prefix(model: &str) -> &str { + let trimmed = model.trim(); + if let Some(rest) = trimmed.strip_prefix("openai/") { + return rest; + } + if codex_routed_provider(trimmed).is_some() + && let Some((_prefix, rest)) = trimmed.split_once('/') + { + return rest; + } + trimmed +} diff --git a/rust/src/core/cost_pricing.rs b/rust/src/core/cost_pricing.rs index b635ab7c97..1c81ae5b29 100755 --- a/rust/src/core/cost_pricing.rs +++ b/rust/src/core/cost_pricing.rs @@ -1,14 +1,9 @@ -//! Cost Usage Pricing -//! -//! Model-specific token pricing for Codex (OpenAI) and Claude (Anthropic) models. -//! Supports tiered pricing for models with token thresholds. - -#![allow(dead_code)] +//! Cost usage pricing — model-specific token pricing for Codex (OpenAI) and Claude (Anthropic). +use super::codex_routed_pricing; use super::models_dev_pricing; use std::collections::HashMap; use std::sync::LazyLock; - /// Whole-request Codex rates for input above the model context threshold. #[derive(Debug, Clone, Copy)] pub struct CodexLongContextRates { @@ -16,7 +11,6 @@ pub struct CodexLongContextRates { pub output_cost_per_token: f64, pub cache_read_input_cost_per_token: f64, } - /// Codex (OpenAI) model pricing #[derive(Debug, Clone, Copy)] pub struct CodexPricing { @@ -31,7 +25,6 @@ pub struct CodexPricing { /// Whole-request rates above the Codex long-context threshold. pub long_context: Option, } - /// Claude (Anthropic) model pricing with optional tiered pricing #[derive(Debug, Clone, Copy)] pub struct ClaudePricing { @@ -54,7 +47,6 @@ pub struct ClaudePricing { /// Cost per cache read input token above threshold pub cache_read_input_cost_per_token_above_threshold: Option, } - /// Codex model pricing table static CODEX_PRICING: LazyLock> = LazyLock::new(|| { let mut m = HashMap::new(); @@ -345,7 +337,6 @@ static CODEX_PRICING: LazyLock> = LazyLock:: }); const CODEX_LONG_CONTEXT_THRESHOLD: u64 = 272_000; - /// Claude model pricing table static CLAUDE_PRICING: LazyLock> = LazyLock::new(|| { let mut m = HashMap::new(); @@ -597,7 +588,6 @@ fn codex_cost_from_rates( + (cached as f64) * cache_read_rate + (output_tokens as f64) * output_rate } - /// Cost usage pricing utilities pub struct CostUsagePricing; @@ -607,7 +597,6 @@ impl CostUsagePricing { /// Usage remains visible under this key but is never priced as a real model /// (including catalog collisions with a generic "unknown" entry). pub const CODEX_UNATTRIBUTED_MODEL: &'static str = "unknown"; - /// True when `model` is the unattributed / model-less sentinel. pub fn is_codex_unattributed_model(model: &str) -> bool { Self::normalize_codex_model(model) == Self::CODEX_UNATTRIBUTED_MODEL @@ -651,6 +640,12 @@ impl CostUsagePricing { trimmed } + /// Detect a provider-qualified route prefix on a Codex model name. + /// Delegates to [`codex_routed_pricing::codex_routed_provider`]. + pub fn codex_routed_provider(model: &str) -> Option<&'static str> { + codex_routed_pricing::codex_routed_provider(model) + } + /// Get the display label for a Codex model (e.g. "Research Preview") pub fn codex_display_label(model: &str) -> Option<&'static str> { let key = Self::normalize_codex_model(model); @@ -792,7 +787,18 @@ impl CostUsagePricing { )); } - let pricing = models_dev_pricing::lookup("openai", model)?; + // Upstream 0.50.1 #2946: provider-qualified routed models are priced + // against the matching models.dev provider, not OpenAI. Unknown + // `provider/` prefixes are left unpriced (not guessed as OpenAI). + let (provider_id, lookup_model) = match codex_routed_pricing::codex_routed_provider(model) { + Some(routed) => (routed, codex_routed_pricing::strip_route_prefix(model)), + None if model.trim().contains('/') && !model.trim().starts_with("openai/") => { + // Unknown route prefix — do not guess. Leave unpriced. + return None; + } + None => ("openai", model), + }; + let pricing = models_dev_pricing::lookup(provider_id, lookup_model)?; let use_tier = pricing .threshold_tokens .is_some_and(|threshold| input_tokens > threshold); diff --git a/rust/src/core/cost_pricing_tests.rs b/rust/src/core/cost_pricing_tests.rs index 8d2fe7a52a..9972922c7a 100644 --- a/rust/src/core/cost_pricing_tests.rs +++ b/rust/src/core/cost_pricing_tests.rs @@ -1,3 +1,4 @@ +use super::codex_routed_pricing; use super::*; #[test] @@ -299,3 +300,54 @@ fn test_codex_fast_cost_usd_base_model_unsuffixed() { "my-custom-model" ); } + +// ── Upstream 0.50.1 #2946: provider-qualified routed model pricing ────────── + +#[test] +fn codex_routed_provider_detects_known_routes() { + assert_eq!( + codex_routed_pricing::codex_routed_provider("deepseek/deepseek-chat"), + Some("deepseek") + ); + assert_eq!( + codex_routed_pricing::codex_routed_provider("kimi/kimi-k2"), + Some("kimi") + ); + assert_eq!( + codex_routed_pricing::codex_routed_provider("opencode/gpt-5"), + Some("opencode") + ); + // Case-insensitive prefix. + assert_eq!( + codex_routed_pricing::codex_routed_provider("DeepSeek/deepseek-chat"), + Some("deepseek") + ); +} + +#[test] +fn codex_routed_provider_returns_none_for_unknown_and_unrouted() { + assert!(codex_routed_pricing::codex_routed_provider("acme/model-x").is_none()); + assert!(codex_routed_pricing::codex_routed_provider("gpt-5").is_none()); + assert!(codex_routed_pricing::codex_routed_provider("deepseek-chat").is_none()); + assert!(codex_routed_pricing::codex_routed_provider("openai/gpt-5").is_none()); +} + +#[test] +fn codex_routed_model_with_unknown_prefix_stays_unpriced() { + // An unknown provider/ prefix must NOT fall back to the OpenAI catalog + // (upstream 0.50.1 #2946: unknown prefixes are left unpriced, not guessed). + assert!(CostUsagePricing::codex_cost_usd("acme/secret-model", 1_000, 0, 500).is_none()); +} + +#[test] +fn codex_routed_model_strips_prefix_for_lookup() { + // A known route prefix produces a clean model id for models.dev lookup. + // A nonexistent sub-model returns None (cleanly unpriced) rather than + // falling back to the OpenAI catalog. + assert!( + CostUsagePricing::codex_cost_usd("deepseek/nonexistent-model-xyz", 1_000, 0, 500).is_none() + ); + assert!( + CostUsagePricing::codex_cost_usd("kimi/nonexistent-model-xyz", 1_000, 0, 500).is_none() + ); +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 80755fa37a..1ad6e29df0 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -2,6 +2,7 @@ mod adaptive_refresh; mod aws_signing; +mod codex_routed_pricing; mod cost_cache_budget; mod cost_pricing; pub mod curl_capture; diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 326fc161ff..f26bc9e491 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -555,6 +555,9 @@ pub enum ProviderError { #[error("OAuth error: {0}")] OAuth(String), + #[error("OAuth token revoked: {0}")] + OAuthRevoked(String), + #[error("Parse error: {0}")] Parse(String), @@ -741,6 +744,84 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map } +/// The shipped brand color (hex) for a provider, mirroring the frontend +/// `PROVIDER_ICON_REGISTRY` in `providerIcons.ts`. Used as the default +/// accent color before any per-provider override (#2972). +pub fn brand_color(id: ProviderId) -> &'static str { + match id { + ProviderId::Codex => "#49A3B0", + ProviderId::Claude => "#CC7C5E", + ProviderId::Cursor => "#00BFA5", + ProviderId::Factory => "#FF6B35", + ProviderId::Gemini => "#AB87EA", + ProviderId::Antigravity => "#60BA7E", + ProviderId::Copilot => "#A855F7", + ProviderId::Zai => "#E85A6A", + ProviderId::MiniMax => "#FE603C", + ProviderId::Kiro => "#FF9900", + ProviderId::VertexAI => "#4285F4", + ProviderId::Augment => "#6366F1", + ProviderId::OpenCode => "#3B82F6", + ProviderId::Kimi => "#FE603C", + ProviderId::KimiK2 => "#4C00FF", + ProviderId::Amp => "#DC2626", + ProviderId::Warp => "#6366F1", + ProviderId::Ollama => "#8B95B0", + ProviderId::AzureOpenAI => "#0078D4", + ProviderId::T3Chat => "#8B5CF6", + ProviderId::OpenRouter => "#6B7280", + ProviderId::JetBrains => "#FF3399", + ProviderId::Alibaba => "#FF6A00", + ProviderId::AlibabaTokenPlan => "#FF6A00", + ProviderId::NanoGPT => "#687FA1", + ProviderId::Infini => "#687FA1", + ProviderId::Perplexity => "#1FB8CD", + ProviderId::Abacus => "#7C3AED", + ProviderId::Mistral => "#FF500F", + ProviderId::OpenCodeGo => "#3B82F6", + ProviderId::Kilo => "#5D87FF", + ProviderId::Bedrock => "#FF9900", + ProviderId::Codebuff => "#44FF00", + ProviderId::DeepSeek => "#527DF0", + ProviderId::DeepInfra => "#2A3275", + ProviderId::AiAnd => "#E25C2B", + ProviderId::Windsurf => "#22C55E", + ProviderId::Manus => "#34322D", + ProviderId::MiMo => "#FF6900", + ProviderId::Doubao => "#2563EB", + ProviderId::CommandCode => "#44FF00", + ProviderId::Crof => "#7C3AED", + ProviderId::StepFun => "#999999", + ProviderId::Venice => "#111827", + ProviderId::OpenAIApi => "#10A37F", + ProviderId::Grok => "#111827", + ProviderId::ElevenLabs => "#111827", + ProviderId::Deepgram => "#13EF93", + ProviderId::Groq => "#F55036", + ProviderId::LLMProxy => "#4F46E5", + ProviderId::Chutes => "#FF5C35", + ProviderId::LiteLLM => "#0EA5E9", + ProviderId::Poe => "#5D5FEF", + ProviderId::Devin => "#111827", + ProviderId::Zed => "#084CCF", + ProviderId::CrossModel => "#C084FC", + ProviderId::Qoder => "#2563EB", + ProviderId::CodeBuddy => "#0052D9", + ProviderId::Sakana => "#0EA5E9", + ProviderId::Sub2Api => "#2DC6D8", + ProviderId::Wayfinder => "#14B8A6", + ProviderId::ZenMux => "#6C5CE7", + ProviderId::ClinePass => "#61A3FA", + ProviderId::LongCat => "#FFD100", + ProviderId::Neuralwatt => "#38D98C", + ProviderId::ZoomMate => "#0B5CFF", + ProviderId::QwenCloud => "#615CED", + ProviderId::Notion => "#337EA9", + ProviderId::Xai => "#8E8E93", + ProviderId::Fireworks => "#F25B1C", + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index dbb4f7f174..0c9c07765c 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -218,6 +218,12 @@ pub struct CostSnapshot { /// Currency code (e.g., "USD") pub currency_code: String, + /// Optional currency symbol (e.g. "€", "$", "¥"). When present, + /// surfaces carry it to the UI for localized currency rendering instead + /// of deriving the symbol from the code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency_symbol: Option, + /// Period description (e.g., "Monthly", "Daily") pub period: String, @@ -240,6 +246,7 @@ impl CostSnapshot { used: finite_amount(used).unwrap_or(0.0), limit: None, currency_code: currency_code.into(), + currency_symbol: None, period: period.into(), resets_at: None, updated_at: Utc::now(), @@ -259,6 +266,12 @@ impl CostSnapshot { self } + /// Builder pattern: set currency symbol for localized rendering. + pub fn with_currency_symbol(mut self, symbol: impl Into) -> Self { + self.currency_symbol = Some(symbol.into()); + self + } + /// Builder pattern: set reset time pub fn with_resets_at(mut self, resets_at: DateTime) -> Self { self.resets_at = Some(resets_at); diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 74c00b3000..945a2ab6da 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -86,6 +86,11 @@ pub struct CostSummary { /// debounce window) or the scan just completed; `false` when the cache is stale /// or empty and a re-scan would be required (upstream 0.48.0 A16). pub history_coverage_established: bool, + /// True when the scan completed with zero results — a *known* zero, not a + /// missing scan. Set only when `history_coverage_established` is true and + /// the scan found no sessions/tokens (upstream 0.50.1 #2932). Never + /// fabricated on incomplete scans. + pub known_zero: bool, /// Period start date pub period_start: Option, /// Period end date @@ -399,6 +404,10 @@ impl CostScanner { &mut seen_pi, ); } + // Upstream 0.50.1 #2932: debounce cache hit with coverage + // established but zero sessions in-range is a known-zero. + summary.known_zero = + summary.history_coverage_established && summary.sessions_count == 0; return (summary, stats); } @@ -434,6 +443,10 @@ impl CostScanner { // A16 (upstream 0.48.0): after a completed scan, coverage IS established // unless cache pruning during save marked a catch-up pending. summary.history_coverage_established = cache.previous_report.is_none(); + // Upstream 0.50.1 #2932: a completed scan with zero results is a + // *known* zero. Only set when coverage is established; an incomplete + // scan must NOT fabricate a zero. + summary.known_zero = summary.history_coverage_established && summary.sessions_count == 0; // OMP / pi-compatible agent sessions (upstream #2269). Dedup by entry id. // Skip when tests inject sessions roots — avoid scanning the real home tree. @@ -1661,4 +1674,42 @@ mod tests { "full scan clears previous_report" ); } + + // ── Upstream 0.50.1 #2932: known-zero history ──────────────────────────── + + #[test] + fn known_zero_is_set_when_scan_completes_with_no_sessions() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + std::fs::create_dir_all(&sessions).unwrap(); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (summary, _) = scanner.scan_codex_detailed(None); + assert!(summary.history_coverage_established, "scan completed"); + assert_eq!(summary.sessions_count, 0, "no sessions"); + assert!(summary.known_zero, "completed scan with zero = known-zero"); + } + + #[test] + fn known_zero_is_not_set_when_scan_has_results() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + write_codex_session_fixture(&sessions, "a.jsonl", 100); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (summary, _) = scanner.scan_codex_detailed(None); + assert!(summary.history_coverage_established); + assert_eq!(summary.sessions_count, 1); + assert!(!summary.known_zero, "scan with results is not known-zero"); + } } diff --git a/rust/src/locale.rs b/rust/src/locale.rs index cd505a84a4..d911f74958 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1043,6 +1043,23 @@ locale_keys! { PromoteTrayIconLabel, PromoteTrayIconHelper, PromoteTrayIconUnsupportedHint, + + // Mistral PAYG monthly spend (#2821, #2947) + MistralMonthlySpend, + MistralMonthlySpendHelper, + + // Menu cost-summary display style (#2976) + CostSummaryDisplayStyle, + CostSummaryDisplayStyleHelper, + CostSummaryStyleCompact, + CostSummaryStyleDetailed, + CostSummaryStyleHidden, + + // Per-provider accent color override (#2972) + ProviderAccentColor, + ProviderAccentColorHelper, + ProviderAccentColorReset, + ProviderAccentColorInvalid, } #[cfg(test)] diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 83203fbebe..4594cd3b72 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -760,3 +760,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = Monthly API spend +MistralMonthlySpendHelper = Current-month API usage for pay-as-you-go Mistral accounts. +CostSummaryDisplayStyle = Cost summary display +CostSummaryDisplayStyleHelper = Choose how cost is shown on every provider card. +CostSummaryStyleCompact = Compact +CostSummaryStyleDetailed = Detailed +CostSummaryStyleHidden = Hidden +ProviderAccentColor = Accent color +ProviderAccentColorHelper = Override the brand color used for usage bars and charts. Enter a hex color like #FF5733. +ProviderAccentColorReset = Reset to default +ProviderAccentColorInvalid = Invalid hex color. Use #RRGGBB format, e.g. #FF5733. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index cb34a56e3c..6b2c39d2de 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -681,3 +681,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = Gasto de API mensual +MistralMonthlySpendHelper = Uso de API del mes actual para cuentas de pago por uso de Mistral. +CostSummaryDisplayStyle = Estilo de resumen de costos +CostSummaryDisplayStyleHelper = Elige cómo se muestran los costos en cada tarjeta de proveedor. +CostSummaryStyleCompact = Compacto +CostSummaryStyleDetailed = Detallado +CostSummaryStyleHidden = Oculto +ProviderAccentColor = Color de acento +ProviderAccentColorHelper = Anula el color de marca usado en barras de uso y gráficos. Introduce un color hexadecimal como #FF5733. +ProviderAccentColorReset = Restablecer predeterminado +ProviderAccentColorInvalid = Color hexadecimal no válido. Usa el formato #RRGGBB, por ejemplo #FF5733. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 489afa6d8c..597bc6a661 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -747,3 +747,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = 月間 API 費用 +MistralMonthlySpendHelper = 従量課金 Mistral アカウントの当月 API 使用量。 +CostSummaryDisplayStyle = 費用サマリー表示スタイル +CostSummaryDisplayStyleHelper = 各プロバイダーカードの費用表示方法を選択します。 +CostSummaryStyleCompact = コンパクト +CostSummaryStyleDetailed = 詳細 +CostSummaryStyleHidden = 非表示 +ProviderAccentColor = アクセントカラー +ProviderAccentColorHelper = 使用量バーとチャートのブランドカラーを上書きします。#FF5733 のような 16 進数カラーを入力。 +ProviderAccentColorReset = デフォルトにリセット +ProviderAccentColorInvalid = 無効な 16 進数カラー。#RRGGBB 形式(例: #FF5733)を使用してください。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 648d62f91d..b8bd2fdd54 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -668,3 +668,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = 월간 API 비용 +MistralMonthlySpendHelper = 종량제 Mistral 계정의 당월 API 사용량입니다. +CostSummaryDisplayStyle = 비용 요약 표시 스타일 +CostSummaryDisplayStyleHelper = 각 공급자 카드에 비용을 표시하는 방법을 선택합니다. +CostSummaryStyleCompact = 간결 +CostSummaryStyleDetailed = 세부 +CostSummaryStyleHidden = 숨김 +ProviderAccentColor = 강조 색상 +ProviderAccentColorHelper = 사용량 막대와 차트에 사용되는 브랜드 색상을 재정의합니다. #FF5733 같은 16진수 색상을 입력하세요. +ProviderAccentColorReset = 기본값으로 재설정 +ProviderAccentColorInvalid = 잘못된 16진수 색상입니다. #RRGGBB 형식(예: #FF5733)을 사용하세요. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 32b41e4cdf..222343bc6b 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -725,3 +725,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = Расход на API за месяц +MistralMonthlySpendHelper = Использование API за текущий месяц для аккаунтов Mistral с оплатой по мере использования. +CostSummaryDisplayStyle = Стиль отображения стоимости +CostSummaryDisplayStyleHelper = Выберите, как стоимость отображается на каждой карте провайдера. +CostSummaryStyleCompact = Компактный +CostSummaryStyleDetailed = Подробный +CostSummaryStyleHidden = Скрытый +ProviderAccentColor = Акцентный цвет +ProviderAccentColorHelper = Переопределите фирменный цвет для полос использованя и графиков. Введите HEX-цвет, например #FF5733. +ProviderAccentColorReset = Сбросить по умолчанию +ProviderAccentColorInvalid = Недопустимый HEX-цвет. Используйте формат #RRGGBB, например #FF5733. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 776ed80059..eb46c3ef24 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -746,3 +746,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = 本月 API 花费 +MistralMonthlySpendHelper = 按量付费 Mistral 账户的当月 API 用量。 +CostSummaryDisplayStyle = 费用摘要显示方式 +CostSummaryDisplayStyleHelper = 选择在每个提供商卡片上如何显示费用。 +CostSummaryStyleCompact = 紧凑 +CostSummaryStyleDetailed = 详细 +CostSummaryStyleHidden = 隐藏 +ProviderAccentColor = 强调色 +ProviderAccentColorHelper = 覆盖用于用量条和图表的品牌颜色。输入十六进制颜色,如 #FF5733。 +ProviderAccentColorReset = 恢复默认 +ProviderAccentColorInvalid = 无效的十六进制颜色。请使用 #RRGGBB 格式,例如 #FF5733。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 55970aa2bb..16fc87f60b 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -746,3 +746,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = 本月 API 花費 +MistralMonthlySpendHelper = 按量付費 Mistral 帳戶的當月 API 用量。 +CostSummaryDisplayStyle = 費用摘要顯示方式 +CostSummaryDisplayStyleHelper = 選擇在每個提供商卡片上如何顯示費用。 +CostSummaryStyleCompact = 緊湊 +CostSummaryStyleDetailed = 詳細 +CostSummaryStyleHidden = 隱藏 +ProviderAccentColor = 強調色 +ProviderAccentColorHelper = 覆蓑用於用量條和圖表的品牌顏色。輸入十六進位顏色,如 #FF5733。 +ProviderAccentColorReset = 恢復預設 +ProviderAccentColorInvalid = 無效的十六進位顏色。請使用 #RRGGBB 格式,例如 #FF5733。 diff --git a/rust/src/login.rs b/rust/src/login.rs index 1a3450a8d2..18c71f0065 100755 --- a/rust/src/login.rs +++ b/rust/src/login.rs @@ -106,7 +106,33 @@ where .await } -/// Generic CLI login runner +/// Run Kiro CLI login +pub async fn run_kiro_login(timeout_secs: u64, on_phase: F) -> LoginResult +where + F: Fn(LoginPhase) + Send + 'static, +{ + // Use Kiro's own binary resolver which checks well-known Windows install + // locations in addition to PATH. + let binary_path = match crate::providers::kiro::find_kiro_cli() { + Some(p) => p, + None => return missing_binary_result("kiro-cli"), + }; + + run_cli_login_path( + &binary_path, + &["login"], + timeout_secs, + on_phase, + &[ + "Successfully logged in", + "Login successful", + "Logged in successfully", + ], + ) + .await +} + +/// Generic CLI login runner (resolves binary via PATH) async fn run_cli_login( binary: &str, args: &[&str], @@ -122,9 +148,23 @@ where Err(_) => return missing_binary_result(binary), }; + run_cli_login_path(&binary_path, args, timeout_secs, on_phase, success_markers).await +} + +/// Generic CLI login runner (uses a pre-resolved binary path) +async fn run_cli_login_path( + binary_path: &std::path::Path, + args: &[&str], + timeout_secs: u64, + on_phase: F, + success_markers: &[&str], +) -> LoginResult +where + F: Fn(LoginPhase) + Send + 'static, +{ on_phase(LoginPhase::Requesting); - let mut child = match spawn_login_process(binary_path.as_path(), args) { + let mut child = match spawn_login_process(binary_path, args) { Ok(c) => c, Err(e) => return launch_failed_result(e), }; diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index 0bacb36a5a..6ea6b2b3fc 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -448,10 +448,21 @@ impl AntigravityProvider { snapshot = snapshot.with_model_specific(ter); } + // Upstream 0.50.1 #2963: one lane per quota bucket. When Antigravity + // emits multiple model configs that map to the same quota bucket + // (e.g. multiple Claude variants in the same 5h session), show one + // lane per quota bucket, not one per model. Dedup by (remaining, + // reset_time) — models sharing the same quota state collapse. + let mut seen_buckets: Vec<(Option, Option)> = Vec::new(); for config in quota_configs { let Some(quota) = &config.quota_info else { continue; }; + let bucket = (quota.remaining_fraction, quota.reset_time.clone()); + if seen_buckets.contains(&bucket) { + continue; + } + seen_buckets.push(bucket); let title = clean_model_label(model_label(config)); if title.is_empty() { continue; diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index 557614da43..cbd9616e88 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -271,3 +271,35 @@ fn is_agy_cli_command_rejects_unrelated_names() { assert!(!is_agy_cli_command("language_server.exe --csrf_token abc")); assert!(!is_agy_cli_command("")); } + +// ── Upstream 0.50.1 #2963: one lane per quota bucket ────────────────────── + +#[test] +fn multiple_models_in_same_quota_bucket_collapse_to_one_lane() { + // Two Claude variants sharing the same remaining fraction (same 5h + // session bucket) should produce one extra rate window, not two. + let resp = make_response(vec![ + ("Claude 3.5 Sonnet", 0.8), + ("Claude 4 Sonnet", 0.8), + ("Gemini 2.5 Pro Low", 0.5), + ]); + let provider = AntigravityProvider::new(); + let snap = provider.parse_user_status(resp).unwrap(); + assert_eq!( + snap.extra_rate_windows.len(), + 2, + "models sharing a quota bucket collapse to one lane" + ); +} + +#[test] +fn models_in_distinct_quota_buckets_keep_separate_lanes() { + let resp = make_response(vec![ + ("Claude 3.5 Sonnet", 0.8), + ("Claude 4 Sonnet", 0.7), + ("Gemini 2.5 Pro Low", 0.5), + ]); + let provider = AntigravityProvider::new(); + let snap = provider.parse_user_status(resp).unwrap(); + assert_eq!(snap.extra_rate_windows.len(), 3); +} diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index c147797e5b..193e2c0f32 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -13,6 +13,9 @@ use regex_lite::Regex; use std::os::windows::process::CommandExt; #[cfg(windows)] use std::process::{Command as StdCommand, Stdio}; +use std::sync::LazyLock; +use std::sync::Mutex; +use std::time::{Duration, Instant}; use crate::cli::tty_runner::{TtyCommandOptions, TtyCommandRunner}; use crate::core::{ @@ -27,6 +30,52 @@ use cli_reset::{ extract_cli_scoped_weekly_limits, normalized_for_label_search, parse_claude_reset_date, parse_percent_line, starts_next_usage_section, }; + +// ── Upstream 0.50.1 #2516: CLI usage-result cache ──────────────────────────── +// +// When token rotation revokes OAuth access, the auto path falls back to the +// CLI. To avoid hammering the CLI probe on every poll, cache the last +// successful CLI result for 15 minutes. The cache is only consulted when +// OAuth returned `OAuthRevoked` (revoked, not merely expired) so normal +// refresh cycles are unaffected. +const CLI_RESULT_CACHE_TTL: Duration = Duration::from_secs(15 * 60); + +struct CachedCliResult { + result: ProviderFetchResult, + cached_at: Instant, +} + +static CLI_RESULT_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + +/// Store a successful CLI fetch result in the 15-minute cache. +fn cache_cli_result(result: ProviderFetchResult) { + if let Ok(mut guard) = CLI_RESULT_CACHE.lock() { + *guard = Some(CachedCliResult { + result, + cached_at: Instant::now(), + }); + } +} + +/// Return a cached CLI result if it is still within the TTL. Used when +/// revoked OAuth prevents a live fetch and the CLI should not be re-probed. +fn cached_cli_result() -> Option { + let Ok(guard) = CLI_RESULT_CACHE.lock() else { + return None; + }; + guard + .as_ref() + .filter(|entry| entry.cached_at.elapsed() <= CLI_RESULT_CACHE_TTL) + .map(|entry| entry.result.clone()) +} + +/// Whether the OAuth source failed with a revocation (not just expiry). +/// Revoked tokens should reuse the working CLI fallback; expired/missing +/// tokens should NOT block the normal refresh path. +fn is_oauth_revoked_error(error: &ProviderError) -> bool { + matches!(error, ProviderError::OAuthRevoked(_)) +} pub use oauth::ClaudeOAuthFetcher; pub use web_api::ClaudeWebApiFetcher; @@ -396,18 +445,41 @@ impl ClaudeProvider { return Ok(result); } - if let Some(result) = - record_auto_source(&mut failures, "OAuth", self.fetch_via_oauth(ctx).await) - { + // Upstream 0.50.1 #2516: track whether OAuth failed with a revocation. + let oauth_result = self.fetch_via_oauth(ctx).await; + let oauth_revoked = oauth_result + .as_ref() + .err() + .is_some_and(is_oauth_revoked_error); + if let Some(result) = record_auto_source(&mut failures, "OAuth", oauth_result) { return Ok(result); } + // When OAuth was revoked (not just expired), reuse a cached CLI result + // if still within the 15-minute TTL to avoid re-probing the CLI. + if oauth_revoked && let Some(cached) = cached_cli_result() { + tracing::debug!("Claude OAuth revoked; returning cached CLI result (15-min cache)"); + return Ok(cached); + } + if let Some(result) = record_auto_source(&mut failures, "CLI", self.fetch_via_cli(ctx).await) { + // Cache the CLI result when OAuth was revoked so subsequent polls + // within the TTL reuse it without re-probing. + if oauth_revoked { + cache_cli_result(result.clone()); + } return Ok(result); } + // Upstream 0.50.1 #2516: when all live sources fail, keep the + // last-known quota visible (stale) instead of blanking the UI. + if let Some(cached) = cached_cli_result() { + tracing::debug!("All Claude live sources failed; returning stale cached CLI result"); + return Ok(cached); + } + Err(claude_auto_fetch_error(failures)) } @@ -1412,4 +1484,26 @@ Active days: 2/10 Longest streak: 1 day assert!(matches!(err, ProviderError::Other(_))); } + + // ── Upstream 0.50.1 #2516: revoked vs missing OAuth ──────────────────────── + + #[test] + fn oauth_revoked_error_is_detected() { + assert!(is_oauth_revoked_error(&ProviderError::OAuthRevoked( + "revoked".to_string() + ))); + assert!(!is_oauth_revoked_error(&ProviderError::OAuth( + "expired".to_string() + ))); + assert!(!is_oauth_revoked_error(&ProviderError::AuthRequired)); + } + + #[test] + fn cli_result_cache_round_trips() { + let result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(42.0)), "cli"); + cache_cli_result(result.clone()); + let cached = cached_cli_result().expect("cached result within TTL"); + assert!((cached.usage.primary.used_percent - 42.0).abs() < 0.01); + assert_eq!(cached.source_label, "cli"); + } } diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index da8e9a6c1e..42fb96b252 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -396,6 +396,22 @@ impl ClaudeOAuthFetcher { let retry_after = Self::retry_after_duration(response.headers().get(RETRY_AFTER)); let body = response.text().await.unwrap_or_default(); + // Upstream 0.50.1 #2516: distinguish revoked tokens (keyring ACL + // revocation, token rotation) from merely expired/invalid ones. + // A revoked token exists but the API rejects it with a + // revocation indicator — the CLI fallback should still work. + if status.as_u16() == 401 || status.as_u16() == 403 { + let lower = body.to_ascii_lowercase(); + if lower.contains("revoked") + || lower.contains("invalid_grant") + || lower.contains("token_revoked") + { + return Err(ProviderError::OAuthRevoked( + "OAuth token was revoked. The CLI fallback will be used.".to_string(), + )); + } + } + if status.as_u16() == 401 { return Err(ProviderError::OAuth( "OAuth token invalid or expired. Run `claude` to re-authenticate.".to_string(), diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 828de5308d..69a4ba30ff 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -15,6 +15,10 @@ const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api"; const USAGE_PATH: &str = "/wham/usage"; const RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits"; const CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(5); +/// How long an external OAuth token set is trusted after the CLI last +/// refreshed it. Matches the CLI's own `needs_refresh` window (8 days) so a +/// token the CLI considers fresh is also trusted here (upstream 0.50.1 #2944). +const EXTERNAL_OAUTH_STALENESS_WINDOW: chrono::TimeDelta = chrono::Duration::days(8); static CREDENTIAL_CACHE: OnceLock>> = OnceLock::new(); @@ -175,6 +179,7 @@ impl CodexApi { })?; let credentials = Self::parse_credentials_json(&content)?; + Self::enforce_external_oauth_gate(&credentials)?; Self::store_cached_credentials(auth_path, modified, credentials.clone()); Ok(credentials) } @@ -190,11 +195,13 @@ impl CodexApi { return Ok(CodexCredentials { access_token: trimmed.to_string(), account_id: None, + is_external_oauth: false, + last_refresh: None, }); } } - // Otherwise, look for tokens object + // Otherwise, look for tokens object (external OAuth source) let tokens = json.get("tokens").ok_or_else(|| { ProviderError::Parse("Codex auth.json exists but contains no tokens.".to_string()) })?; @@ -214,12 +221,49 @@ impl CodexApi { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + // Upstream 0.50.1 #2944: an OAuth token set with a refresh_token is an + // external (CLI-owned) OAuth source. The `last_refresh` timestamp + // (written by the CLI) lets us detect staleness. + let has_refresh_token = tokens + .get("refresh_token") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.trim().is_empty()); + let last_refresh = json + .get("last_refresh") + .and_then(|v| v.as_str()) + .and_then(parse_timestamp); + Ok(CodexCredentials { access_token, account_id, + is_external_oauth: has_refresh_token, + last_refresh, }) } + /// Upstream 0.50.1 #2944: when `codex_external_oauth_sources_allowed` is + /// OFF (the default), stale external OAuth credential files fail closed + /// instead of being used silently. An external OAuth source is an + /// auth.json `tokens` object with a `refresh_token` (CLI-owned OAuth, + /// not an API key). "Stale" means the CLI has not refreshed the token + /// recently (no `last_refresh`, or older than the staleness window). + fn enforce_external_oauth_gate(credentials: &CodexCredentials) -> Result<(), ProviderError> { + if !credentials.is_external_oauth { + return Ok(()); + } + if crate::settings::Settings::load().codex_external_oauth_sources_allowed { + return Ok(()); + } + let now = Utc::now(); + let is_stale = credentials + .last_refresh + .is_none_or(|last| now - last > EXTERNAL_OAUTH_STALENESS_WINDOW); + if is_stale { + return Err(ProviderError::AuthRequired); + } + Ok(()) + } + fn credential_cache() -> &'static Mutex> { CREDENTIAL_CACHE.get_or_init(|| Mutex::new(None)) } @@ -750,6 +794,14 @@ impl Default for CodexApi { struct CodexCredentials { access_token: String, account_id: Option, + /// True when the source is an external OAuth token set (has a + /// `refresh_token`), as opposed to an `OPENAI_API_KEY`. Used by the + /// `codex_external_oauth_sources_allowed` gate (upstream 0.50.1 #2944). + is_external_oauth: bool, + /// `last_refresh` timestamp from auth.json, when present. Used to detect + /// stale external OAuth tokens that should fail closed when the opt-in + /// setting is OFF. + last_refresh: Option>, } struct CachedCodexCredentials { @@ -898,6 +950,23 @@ fn timestamp_to_datetime(timestamp: Option) -> Option> { timestamp.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) } +/// Parse an ISO-8601 / RFC-3339 timestamp from the `last_refresh` field of +/// auth.json. Accepts the same formats the Codex CLI writes. +fn parse_timestamp(raw: &str) -> Option> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(trimmed) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|| { + chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") + .ok() + .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) + }) +} + fn json_f64(value: &serde_json::Value) -> Option { value .as_f64() @@ -1575,4 +1644,93 @@ mod tests { assert!(tertiary.is_none()); assert_eq!(code_review.unwrap().window_minutes, Some(999)); } + + // ── Upstream 0.50.1 #2944: external OAuth source gate ────────────────── + + #[test] + fn api_key_credentials_are_not_external_oauth() { + let creds = CodexApi::parse_credentials_json(r#"{"OPENAI_API_KEY": "sk-test"}"#) + .expect("credentials"); + assert!(!creds.is_external_oauth); + assert!(creds.last_refresh.is_none()); + assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); + } + + #[test] + fn oauth_tokens_with_refresh_token_are_external_source() { + let creds = CodexApi::parse_credentials_json( + r#"{ + "tokens": { + "access_token": "access", + "refresh_token": "refresh", + "account_id": "acct_123" + } + }"#, + ) + .expect("credentials"); + assert!(creds.is_external_oauth); + assert!(creds.last_refresh.is_none()); + } + + #[test] + fn oauth_tokens_without_refresh_token_are_not_external() { + let creds = CodexApi::parse_credentials_json( + r#"{ + "tokens": { + "access_token": "access", + "account_id": "acct_123" + } + }"#, + ) + .expect("credentials"); + assert!(!creds.is_external_oauth); + } + + #[test] + fn external_oauth_gate_fails_closed_for_stale_tokens() { + let creds = CodexCredentials { + access_token: "access".to_string(), + account_id: None, + is_external_oauth: true, + last_refresh: None, + }; + let err = CodexApi::enforce_external_oauth_gate(&creds) + .expect_err("stale external OAuth must fail closed"); + assert!(matches!(err, ProviderError::AuthRequired)); + } + + #[test] + fn external_oauth_gate_fails_closed_for_old_last_refresh() { + let old = Utc::now() - chrono::Duration::days(10); + let creds = CodexCredentials { + access_token: "access".to_string(), + account_id: None, + is_external_oauth: true, + last_refresh: Some(old), + }; + let err = CodexApi::enforce_external_oauth_gate(&creds) + .expect_err("old external OAuth must fail closed"); + assert!(matches!(err, ProviderError::AuthRequired)); + } + + #[test] + fn external_oauth_gate_passes_fresh_tokens() { + let fresh = Utc::now() - chrono::Duration::hours(1); + let creds = CodexCredentials { + access_token: "access".to_string(), + account_id: None, + is_external_oauth: true, + last_refresh: Some(fresh), + }; + assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); + } + + #[test] + fn parse_timestamp_reads_iso8601() { + assert!(parse_timestamp("2026-08-17T10:00:00Z").is_some()); + assert!(parse_timestamp("2026-08-17T10:00:00.123Z").is_some()); + assert!(parse_timestamp(" 2026-08-17T10:00:00Z ").is_some()); + assert!(parse_timestamp("").is_none()); + assert!(parse_timestamp("not-a-date").is_none()); + } } diff --git a/rust/src/providers/cursor/api.rs b/rust/src/providers/cursor/api.rs index 9bb3b82748..e3248030f1 100755 --- a/rust/src/providers/cursor/api.rs +++ b/rust/src/providers/cursor/api.rs @@ -290,12 +290,13 @@ fn clamp_percent(value: f64) -> f64 { /// Period label for plan-included spend from usage-summary (no new network calls). fn plan_period_label(billing_cycle_start: Option<&str>) -> String { + // Upstream 0.50.1 #2951: match the Cursor dashboard's name for the + // included-usage pool (Cursor + third-party models). match billing_cycle_start { - Some(start) if !start.is_empty() => format!("Plan (since {start})"), - _ => "Plan (billing cycle)".to_string(), + Some(start) if !start.is_empty() => format!("Cursor and Third Party (since {start})"), + _ => "Cursor and Third Party (billing cycle)".to_string(), } } - impl Default for CursorApi { fn default() -> Self { Self::new() @@ -589,7 +590,10 @@ mod tests { let cost = cost.expect("plan cost"); assert!((cost.used - 25.0).abs() < 0.01); assert_eq!(cost.limit, Some(50.0)); - assert_eq!(cost.period, "Plan (since 2026-03-01T00:00:00Z)"); + assert_eq!( + cost.period, + "Cursor and Third Party (since 2026-03-01T00:00:00Z)" + ); } #[test] diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index cef75060b2..b648b21962 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -28,7 +28,7 @@ impl CursorProvider { id: ProviderId::Cursor, display_name: "Cursor", session_label: "Plan", - weekly_label: "Auto", + weekly_label: "Cursor", supports_opus: false, // Upstream #2338: Cursor has no account credit balance to advertise. supports_credits: false, diff --git a/rust/src/providers/mistral/mod.rs b/rust/src/providers/mistral/mod.rs index 2d433306fc..61dd9b24cb 100644 --- a/rust/src/providers/mistral/mod.rs +++ b/rust/src/providers/mistral/mod.rs @@ -257,6 +257,7 @@ impl MistralProvider { } let mut cost = CostSnapshot::new(summary.total_cost, summary.currency, "Monthly"); + cost = cost.with_currency_symbol(summary.currency_symbol); if let Some(reset) = reset_date { cost = cost.with_resets_at(reset); } diff --git a/rust/src/providers/ollama/cookies.rs b/rust/src/providers/ollama/cookies.rs new file mode 100644 index 0000000000..94f6d8a228 --- /dev/null +++ b/rust/src/providers/ollama/cookies.rs @@ -0,0 +1,407 @@ +//! Ollama session cookie normalization, recognition, and browser import. +//! +//! Extracted from `mod.rs`. Owns the cookie source enum, header normalization +//! (cURL/Cookie: label stripping), session-cookie name recognition (AuthKit, +//! NextAuth chunked), browser import + validated-cache reuse, and sign-in +//! redirect detection. + +use reqwest::Url; + +use crate::browser::cookies::{Cookie, CookieExtractor}; +use crate::core::{FetchContext, ProviderError, ProviderId}; + +pub(super) const OLLAMA_COOKIE_DOMAIN: &str = "ollama.com"; +pub(super) const OLLAMA_SESSION_COOKIE_NAME: &str = "__Secure-session"; +pub(super) const OLLAMA_SESSION_COOKIE_NAMES: &[&str] = &[ + "session", + OLLAMA_SESSION_COOKIE_NAME, + "ollama_session", + "__Host-ollama_session", + "wos-session", + "__Secure-next-auth.session-token", + "next-auth.session-token", +]; + +pub(super) enum OllamaCookieSource { + Manual(String), + Browser(Vec), +} + +impl OllamaCookieSource { + pub(super) fn header_for_url(&self, url: &Url) -> Option { + match self { + Self::Manual(header) => should_attach_ollama_cookie(url).then(|| header.clone()), + Self::Browser(cookies) => ollama_cookie_header_for_url(cookies, url), + } + } +} + +/// Normalize a raw cookie header input — strip cURL wrappers and `Cookie:` +/// labels, and prefix bare values with the session cookie name. +pub(super) fn normalize_cookie_header(input: &str) -> Option { + let mut header = strip_curl_cookie_wrapper(input); + if header.is_empty() { + return None; + } + + if header + .get(.."cookie:".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) + { + header = header["cookie:".len()..].trim(); + } + + if header.is_empty() { + return None; + } + + if header.contains('=') { + // Upstream 0.50.1 #2949: a copied `Cookie:` label can appear + // mid-string when another cookie comes first — drop the label + // from every `;`-separated segment before sending. + let cleaned = header + .split(';') + .map(str::trim) + .map(|segment| { + if segment + .get(.."cookie:".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) + { + segment["cookie:".len()..].trim().to_string() + } else { + segment.to_string() + } + }) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("; "); + (!cleaned.is_empty()).then_some(cleaned) + } else { + Some(format!("{OLLAMA_SESSION_COOKIE_NAME}={header}")) + } +} + +/// Resolve cookies from manual cookies, validated cache, or browser import. +/// +/// Upstream #2404: reuse the last validated browser session cookie header +/// across refreshes until auth fails, then re-import. +pub(super) fn resolve_cookie_source( + ctx: &FetchContext, +) -> Result { + // Check manual cookie header first + if let Some(cookie) = &ctx.manual_cookie_header + && let Some(header) = normalize_cookie_header(cookie) + { + return has_recognized_ollama_session_cookie(&header) + .then_some(OllamaCookieSource::Manual(header)) + .ok_or(ProviderError::NoCookies); + } + + match resolve_browser_cookie_header(false)? { + Some(header) => Ok(OllamaCookieSource::Manual(header)), + None => Err(ProviderError::NoCookies), + } +} + +/// After a successful web fetch, cache the validated browser/manual session header. +pub(super) fn cache_validated_session_cookie(source: &OllamaCookieSource) { + use crate::browser::cookie_cache::CookieHeaderCache; + if let Some(header) = + source.header_for_url(&Url::parse("https://ollama.com/settings").expect("static url")) + { + let label = match source { + OllamaCookieSource::Manual(_) => "validated", + OllamaCookieSource::Browser(_) => "browser", + }; + let _ = CookieHeaderCache::store(ProviderId::Ollama, &header, label); + } +} + +/// Clear cached session after auth failure so the next refresh re-imports. +pub(super) fn invalidate_cached_session_cookie() { + use crate::browser::cookie_cache::CookieHeaderCache; + CookieHeaderCache::clear(ProviderId::Ollama); +} + +/// Strip copied cURL cookie syntax (`-b …`, `--cookie …`, `-H …`) and the +/// surrounding quotes before normalizing the header value (upstream 0.50.1 +/// #2949). +pub(super) fn strip_curl_cookie_wrapper(raw: &str) -> &str { + let mut header = raw.trim(); + for prefix in ["-b ", "--cookie ", "-H "] { + if let Some(rest) = header.strip_prefix(prefix) { + header = rest.trim(); + } + } + header.trim_matches('\'').trim_matches('"').trim() +} + +/// Resolve a browser/session cookie header for Ollama. +/// +/// When `force_reimport` is false, prefers the last validated cached header +/// (upstream #2404). On force or cache miss, imports from the browser. +pub(super) fn resolve_browser_cookie_header( + force_reimport: bool, +) -> Result, ProviderError> { + use crate::browser::cookie_cache::CookieHeaderCache; + + if !force_reimport + && let Some(cached) = CookieHeaderCache::load(ProviderId::Ollama) + && has_recognized_ollama_session_cookie(&cached.cookie_header) + { + return Ok(Some(cached.cookie_header)); + } + + match crate::providers::browser_cookies_for_domain(OLLAMA_COOKIE_DOMAIN) { + Ok(cookies) => { + let url = Url::parse("https://ollama.com/settings") + .map_err(|e| ProviderError::Other(e.to_string()))?; + Ok(ollama_cookie_header_for_url(&cookies, &url) + .filter(|h| has_recognized_ollama_session_cookie(h))) + } + Err(ProviderError::NoCookies) => Ok(None), + Err(err) => Err(err), + } +} + +pub(super) fn should_attach_ollama_cookie(url: &Url) -> bool { + url.scheme() == "https" + && url + .host_str() + .is_some_and(|host| host.eq_ignore_ascii_case(OLLAMA_COOKIE_DOMAIN)) +} + +pub(super) fn has_recognized_ollama_session_cookie(header: &str) -> bool { + header.split(';').any(|pair| { + let name = pair.trim().split_once('=').map(|(name, _)| name.trim()); + name.is_some_and(is_recognized_ollama_session_cookie_name) + }) +} + +pub(super) fn ollama_cookie_header_for_url(cookies: &[Cookie], url: &Url) -> Option { + let cookies: Vec<_> = cookies + .iter() + .filter(|cookie| cookie_applies_to_ollama_url(cookie, url)) + .cloned() + .collect(); + let header = CookieExtractor::build_cookie_header(&cookies); + has_recognized_ollama_session_cookie(&header).then_some(header) +} + +pub(super) fn cookie_applies_to_ollama_url(cookie: &Cookie, url: &Url) -> bool { + let domain = cookie + .domain + .trim() + .trim_end_matches('.') + .to_ascii_lowercase(); + let path = if cookie.path.is_empty() { + "/" + } else { + cookie.path.as_str() + }; + let request_path = url.path(); + should_attach_ollama_cookie(url) + && (domain == OLLAMA_COOKIE_DOMAIN + || domain.strip_prefix('.') == Some(OLLAMA_COOKIE_DOMAIN)) + && (path == "/" + || request_path == path + || (request_path.starts_with(path) + && (path.ends_with('/') || request_path.as_bytes().get(path.len()) == Some(&b'/')))) +} + +pub(super) fn is_recognized_ollama_session_cookie_name(name: &str) -> bool { + OLLAMA_SESSION_COOKIE_NAMES.contains(&name) + || is_chunked_nextauth_cookie_name(name, "__Secure-next-auth.session-token") + || is_chunked_nextauth_cookie_name(name, "next-auth.session-token") +} + +pub(super) fn is_chunked_nextauth_cookie_name(name: &str, base_name: &str) -> bool { + name.strip_prefix(base_name) + .and_then(|suffix| suffix.strip_prefix('.')) + .is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +pub(super) fn is_ollama_sign_in_redirect(url: &Url) -> bool { + if url.scheme() != "https" { + return false; + } + let Some(host) = url.host_str().map(str::to_ascii_lowercase) else { + return false; + }; + let path = url.path().to_ascii_lowercase(); + if host == OLLAMA_COOKIE_DOMAIN || host == "www.ollama.com" { + return path == "/signin" || path.starts_with("/signin/") || path.contains("/login"); + } + host == "signin.ollama.com" + || (host.ends_with(".workos.com") && path.starts_with("/user_management/authorize")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_raw_ollama_session_cookie_value() { + assert_eq!( + normalize_cookie_header("abc123"), + Some("__Secure-session=abc123".to_string()) + ); + } + + #[test] + fn preserves_full_cookie_header() { + assert_eq!( + normalize_cookie_header("__Secure-session=abc123; aid=device"), + Some("__Secure-session=abc123; aid=device".to_string()) + ); + } + + #[test] + fn strips_cookie_header_prefix() { + assert_eq!( + normalize_cookie_header("Cookie: __Secure-session=abc123"), + Some("__Secure-session=abc123".to_string()) + ); + } + + #[test] + fn strips_mid_string_cookie_label_and_curl_syntax() { + // Upstream 0.50.1 #2949: a copied `Cookie:` label after another + // cookie, and cURL `-H`/`-b` wrappers with quotes. + assert_eq!( + normalize_cookie_header("aid=device; Cookie: __Secure-session=abc123"), + Some("aid=device; __Secure-session=abc123".to_string()) + ); + assert_eq!( + normalize_cookie_header("-H 'Cookie: __Secure-session=abc123'"), + Some("__Secure-session=abc123".to_string()) + ); + assert_eq!( + normalize_cookie_header("-b \"__Secure-session=abc123\""), + Some("__Secure-session=abc123".to_string()) + ); + } + + #[test] + fn ignores_empty_cookie_input() { + assert_eq!(normalize_cookie_header(" "), None); + assert_eq!(normalize_cookie_header("Cookie: "), None); + } + + #[test] + fn recognizes_exact_authkit_and_nextauth_session_cookie_names() { + assert!(has_recognized_ollama_session_cookie( + "wos-session=auth; theme=dark" + )); + assert!(has_recognized_ollama_session_cookie( + "__Secure-next-auth.session-token.0=auth" + )); + assert!(!has_recognized_ollama_session_cookie( + "notwos-session=auth; theme=dark" + )); + assert!(!has_recognized_ollama_session_cookie( + "next-auth.session-token.evil=auth" + )); + assert!(!has_recognized_ollama_session_cookie("theme=dark")); + } + + #[test] + fn limits_browser_cookie_headers_to_ollama_settings_scope() { + use crate::browser::cookies::Cookie; + + let cookie = |name: &str, domain: &str, path: &str| Cookie { + name: name.to_string(), + value: "test".to_string(), + domain: domain.to_string(), + path: path.to_string(), + expires: None, + is_secure: true, + is_http_only: true, + }; + let cookies = [ + cookie("wos-session", ".ollama.com", "/"), + cookie("wos-session", "signin.ollama.com", "/"), + cookie("__Secure-session", "ollama.com", "/signin"), + ]; + + assert_eq!( + ollama_cookie_header_for_url( + &cookies, + &Url::parse("https://ollama.com/settings").unwrap() + ) + .as_deref(), + Some("wos-session=test") + ); + assert_eq!( + ollama_cookie_header_for_url( + &[cookie("__Secure-session", "ollama.com", "/settings")], + &Url::parse("https://ollama.com/api/tags").unwrap() + ), + None + ); + assert_eq!( + ollama_cookie_header_for_url( + &[cookie("__Secure-session", "ollama.com", "/settings")], + &Url::parse("https://ollama.com/settings/account").unwrap() + ) + .as_deref(), + Some("__Secure-session=test") + ); + let source = OllamaCookieSource::Browser(vec![ + cookie("__Secure-session", "ollama.com", "/settings"), + cookie("wos-session", "ollama.com", "/api"), + ]); + assert_eq!( + source + .header_for_url(&Url::parse("https://ollama.com/settings").unwrap()) + .as_deref(), + Some("__Secure-session=test") + ); + assert_eq!( + source + .header_for_url(&Url::parse("https://ollama.com/api/models").unwrap()) + .as_deref(), + Some("wos-session=test") + ); + } + + #[test] + fn only_attaches_web_cookie_to_https_ollama_urls() { + assert!(should_attach_ollama_cookie( + &Url::parse("https://ollama.com/settings").unwrap() + )); + assert!(!should_attach_ollama_cookie( + &Url::parse("http://ollama.com/settings").unwrap() + )); + assert!(!should_attach_ollama_cookie( + &Url::parse("https://example.com/settings").unwrap() + )); + } + + #[test] + fn recognizes_workos_signin_redirects_as_expired_sessions() { + assert!(is_ollama_sign_in_redirect( + &Url::parse("https://signin.ollama.com/?client_id=test").unwrap() + )); + assert!(is_ollama_sign_in_redirect( + &Url::parse("https://auth.workos.com/user_management/authorize?client_id=test") + .unwrap() + )); + assert!(!is_ollama_sign_in_redirect( + &Url::parse("https://auth.workos.com/other").unwrap() + )); + assert!(!is_ollama_sign_in_redirect( + &Url::parse("http://signin.ollama.com/").unwrap() + )); + } + + #[test] + fn recognized_session_cookie_required_for_cache_reuse() { + assert!(has_recognized_ollama_session_cookie( + "__Secure-session=abc123; path=/" + )); + assert!(!has_recognized_ollama_session_cookie("foo=bar; baz=qux")); + } +} diff --git a/rust/src/providers/ollama/mod.rs b/rust/src/providers/ollama/mod.rs index f7a4f82f02..3862b3456f 100755 --- a/rust/src/providers/ollama/mod.rs +++ b/rust/src/providers/ollama/mod.rs @@ -3,13 +3,16 @@ //! Fetches usage data by scraping the Ollama settings page //! Uses session cookies from browser or manual input +mod cookies; + +use cookies::*; + use async_trait::async_trait; use chrono::{DateTime, Utc}; use regex_lite::Regex; use reqwest::Url; use serde::Deserialize; -use crate::browser::cookies::{Cookie, CookieExtractor}; use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, @@ -20,17 +23,6 @@ use crate::settings::ApiKeys; const OLLAMA_SETTINGS_URL: &str = "https://ollama.com/settings"; const OLLAMA_TAGS_URL: &str = "https://ollama.com/api/tags"; const OLLAMA_VALIDATION_URL: &str = "https://ollama.com/api/web_search"; -const OLLAMA_COOKIE_DOMAIN: &str = "ollama.com"; -const OLLAMA_SESSION_COOKIE_NAME: &str = "__Secure-session"; -const OLLAMA_SESSION_COOKIE_NAMES: &[&str] = &[ - "session", - OLLAMA_SESSION_COOKIE_NAME, - "ollama_session", - "__Host-ollama_session", - "wos-session", - "__Secure-next-auth.session-token", - "next-auth.session-token", -]; /// Ollama provider pub struct OllamaProvider { @@ -45,20 +37,6 @@ struct UsageBlock { reset_description: Option, } -enum OllamaCookieSource { - Manual(String), - Browser(Vec), -} - -impl OllamaCookieSource { - fn header_for_url(&self, url: &Url) -> Option { - match self { - Self::Manual(header) => should_attach_ollama_cookie(url).then(|| header.clone()), - Self::Browser(cookies) => ollama_cookie_header_for_url(cookies, url), - } - } -} - impl OllamaProvider { pub fn new() -> Self { Self { @@ -79,7 +57,7 @@ impl OllamaProvider { /// Fetch usage by scraping ollama.com/settings async fn fetch_usage_web(&self, ctx: &FetchContext) -> Result { - let cookies = self.resolve_cookie_source(ctx)?; + let cookies = resolve_cookie_source(ctx)?; let client = crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(ctx.web_timeout)) @@ -93,18 +71,18 @@ impl OllamaProvider { Ok(html) => { // Only cache non-manual browser/validated sessions for reuse. if ctx.manual_cookie_header.is_none() { - Self::cache_validated_session_cookie(&cookies); + cache_validated_session_cookie(&cookies); } self.parse_usage_html(&html) } Err(ProviderError::AuthRequired) if ctx.manual_cookie_header.is_none() => { // Cached/imported session expired — clear and re-import once. - Self::invalidate_cached_session_cookie(); + invalidate_cached_session_cookie(); let fresh = resolve_browser_cookie_header(true)? .map(OllamaCookieSource::Manual) .ok_or(ProviderError::AuthRequired)?; let html = fetch_settings_html_at(&client, &fresh, start_url).await?; - Self::cache_validated_session_cookie(&fresh); + cache_validated_session_cookie(&fresh); self.parse_usage_html(&html) } Err(err) => Err(err), @@ -228,100 +206,6 @@ impl OllamaProvider { Some(format!("{} cloud models available", response.models.len())); Ok(UsageSnapshot::new(primary).with_login_method("API key")) } - - fn normalize_cookie_header(input: &str) -> Option { - let mut header = input.trim(); - if header.is_empty() { - return None; - } - - if header - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim(); - } - - if header.is_empty() { - return None; - } - - if header.contains('=') { - Some(header.to_string()) - } else { - Some(format!("{OLLAMA_SESSION_COOKIE_NAME}={header}")) - } - } - - /// Resolve cookies from manual cookies, validated cache, or browser import. - /// - /// Upstream #2404: reuse the last validated browser session cookie header - /// across refreshes until auth fails, then re-import. - fn resolve_cookie_source( - &self, - ctx: &FetchContext, - ) -> Result { - // Check manual cookie header first - if let Some(cookie) = &ctx.manual_cookie_header - && let Some(header) = Self::normalize_cookie_header(cookie) - { - return has_recognized_ollama_session_cookie(&header) - .then_some(OllamaCookieSource::Manual(header)) - .ok_or(ProviderError::NoCookies); - } - - match resolve_browser_cookie_header(false)? { - Some(header) => Ok(OllamaCookieSource::Manual(header)), - None => Err(ProviderError::NoCookies), - } - } - - /// After a successful web fetch, cache the validated browser/manual session header. - fn cache_validated_session_cookie(source: &OllamaCookieSource) { - use crate::browser::cookie_cache::CookieHeaderCache; - if let Some(header) = source.header_for_url( - &Url::parse(OLLAMA_SETTINGS_URL) - .unwrap_or_else(|_| Url::parse("https://ollama.com/settings").expect("static url")), - ) { - let label = match source { - OllamaCookieSource::Manual(_) => "validated", - OllamaCookieSource::Browser(_) => "browser", - }; - let _ = CookieHeaderCache::store(ProviderId::Ollama, &header, label); - } - } - - /// Clear cached session after auth failure so the next refresh re-imports. - fn invalidate_cached_session_cookie() { - use crate::browser::cookie_cache::CookieHeaderCache; - CookieHeaderCache::clear(ProviderId::Ollama); - } -} - -/// Resolve a browser/session cookie header for Ollama. -/// -/// When `force_reimport` is false, prefers the last validated cached header -/// (upstream #2404). On force or cache miss, imports from the browser. -fn resolve_browser_cookie_header(force_reimport: bool) -> Result, ProviderError> { - use crate::browser::cookie_cache::CookieHeaderCache; - - if !force_reimport - && let Some(cached) = CookieHeaderCache::load(ProviderId::Ollama) - && has_recognized_ollama_session_cookie(&cached.cookie_header) - { - return Ok(Some(cached.cookie_header)); - } - - match crate::providers::browser_cookies_for_domain(OLLAMA_COOKIE_DOMAIN) { - Ok(cookies) => { - let url = - Url::parse(OLLAMA_SETTINGS_URL).map_err(|e| ProviderError::Other(e.to_string()))?; - Ok(ollama_cookie_header_for_url(&cookies, &url) - .filter(|h| has_recognized_ollama_session_cookie(h))) - } - Err(ProviderError::NoCookies) => Ok(None), - Err(err) => Err(err), - } } /// Pure decision helper for the Ollama session reuse path (unit-tested). @@ -553,80 +437,6 @@ fn strip_html_entities(value: &str) -> String { .replace("/", "/") } -fn should_attach_ollama_cookie(url: &Url) -> bool { - url.scheme() == "https" - && url - .host_str() - .is_some_and(|host| host.eq_ignore_ascii_case(OLLAMA_COOKIE_DOMAIN)) -} - -fn has_recognized_ollama_session_cookie(header: &str) -> bool { - header.split(';').any(|pair| { - let name = pair.trim().split_once('=').map(|(name, _)| name.trim()); - name.is_some_and(is_recognized_ollama_session_cookie_name) - }) -} - -fn ollama_cookie_header_for_url(cookies: &[Cookie], url: &Url) -> Option { - let cookies: Vec<_> = cookies - .iter() - .filter(|cookie| cookie_applies_to_ollama_url(cookie, url)) - .cloned() - .collect(); - let header = CookieExtractor::build_cookie_header(&cookies); - has_recognized_ollama_session_cookie(&header).then_some(header) -} - -fn cookie_applies_to_ollama_url(cookie: &Cookie, url: &Url) -> bool { - let domain = cookie - .domain - .trim() - .trim_end_matches('.') - .to_ascii_lowercase(); - let path = if cookie.path.is_empty() { - "/" - } else { - cookie.path.as_str() - }; - let request_path = url.path(); - should_attach_ollama_cookie(url) - && (domain == OLLAMA_COOKIE_DOMAIN - || domain.strip_prefix('.') == Some(OLLAMA_COOKIE_DOMAIN)) - && (path == "/" - || request_path == path - || (request_path.starts_with(path) - && (path.ends_with('/') || request_path.as_bytes().get(path.len()) == Some(&b'/')))) -} - -fn is_recognized_ollama_session_cookie_name(name: &str) -> bool { - OLLAMA_SESSION_COOKIE_NAMES.contains(&name) - || is_chunked_nextauth_cookie_name(name, "__Secure-next-auth.session-token") - || is_chunked_nextauth_cookie_name(name, "next-auth.session-token") -} - -fn is_chunked_nextauth_cookie_name(name: &str, base_name: &str) -> bool { - name.strip_prefix(base_name) - .and_then(|suffix| suffix.strip_prefix('.')) - .is_some_and(|suffix| { - !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) - }) -} - -fn is_ollama_sign_in_redirect(url: &Url) -> bool { - if url.scheme() != "https" { - return false; - } - let Some(host) = url.host_str().map(str::to_ascii_lowercase) else { - return false; - }; - let path = url.path().to_ascii_lowercase(); - if host == OLLAMA_COOKIE_DOMAIN || host == "www.ollama.com" { - return path == "/signin" || path.starts_with("/signin/") || path.contains("/login"); - } - host == "signin.ollama.com" - || (host.ends_with(".workos.com") && path.starts_with("/user_management/authorize")) -} - async fn fetch_settings_html_at( client: &reqwest::Client, source: &OllamaCookieSource, @@ -702,143 +512,6 @@ fn ollama_api_key_error() -> ProviderError { mod tests { use super::*; - #[test] - fn normalizes_raw_ollama_session_cookie_value() { - assert_eq!( - OllamaProvider::normalize_cookie_header("abc123"), - Some("__Secure-session=abc123".to_string()) - ); - } - - #[test] - fn preserves_full_cookie_header() { - assert_eq!( - OllamaProvider::normalize_cookie_header("__Secure-session=abc123; aid=device"), - Some("__Secure-session=abc123; aid=device".to_string()) - ); - } - - #[test] - fn strips_cookie_header_prefix() { - assert_eq!( - OllamaProvider::normalize_cookie_header("Cookie: __Secure-session=abc123"), - Some("__Secure-session=abc123".to_string()) - ); - } - - #[test] - fn ignores_empty_cookie_input() { - assert_eq!(OllamaProvider::normalize_cookie_header(" "), None); - assert_eq!(OllamaProvider::normalize_cookie_header("Cookie: "), None); - } - - #[test] - fn recognizes_exact_authkit_and_nextauth_session_cookie_names() { - assert!(has_recognized_ollama_session_cookie( - "wos-session=auth; theme=dark" - )); - assert!(has_recognized_ollama_session_cookie( - "__Secure-next-auth.session-token.0=auth" - )); - assert!(!has_recognized_ollama_session_cookie( - "notwos-session=auth; theme=dark" - )); - assert!(!has_recognized_ollama_session_cookie( - "next-auth.session-token.evil=auth" - )); - assert!(!has_recognized_ollama_session_cookie("theme=dark")); - } - - #[test] - fn limits_browser_cookie_headers_to_ollama_settings_scope() { - use crate::browser::cookies::Cookie; - - let cookie = |name: &str, domain: &str, path: &str| Cookie { - name: name.to_string(), - value: "test".to_string(), - domain: domain.to_string(), - path: path.to_string(), - expires: None, - is_secure: true, - is_http_only: true, - }; - let cookies = [ - cookie("wos-session", ".ollama.com", "/"), - cookie("wos-session", "signin.ollama.com", "/"), - cookie("__Secure-session", "ollama.com", "/signin"), - ]; - - assert_eq!( - ollama_cookie_header_for_url( - &cookies, - &Url::parse("https://ollama.com/settings").unwrap() - ) - .as_deref(), - Some("wos-session=test") - ); - assert_eq!( - ollama_cookie_header_for_url( - &[cookie("__Secure-session", "ollama.com", "/settings")], - &Url::parse("https://ollama.com/api/tags").unwrap() - ), - None - ); - assert_eq!( - ollama_cookie_header_for_url( - &[cookie("__Secure-session", "ollama.com", "/settings")], - &Url::parse("https://ollama.com/settings/account").unwrap() - ) - .as_deref(), - Some("__Secure-session=test") - ); - let source = OllamaCookieSource::Browser(vec![ - cookie("__Secure-session", "ollama.com", "/settings"), - cookie("wos-session", "ollama.com", "/api"), - ]); - assert_eq!( - source - .header_for_url(&Url::parse("https://ollama.com/settings").unwrap()) - .as_deref(), - Some("__Secure-session=test") - ); - assert_eq!( - source - .header_for_url(&Url::parse("https://ollama.com/api/models").unwrap()) - .as_deref(), - Some("wos-session=test") - ); - } - - #[test] - fn only_attaches_web_cookie_to_https_ollama_urls() { - assert!(should_attach_ollama_cookie( - &Url::parse("https://ollama.com/settings").unwrap() - )); - assert!(!should_attach_ollama_cookie( - &Url::parse("http://ollama.com/settings").unwrap() - )); - assert!(!should_attach_ollama_cookie( - &Url::parse("https://example.com/settings").unwrap() - )); - } - - #[test] - fn recognizes_workos_signin_redirects_as_expired_sessions() { - assert!(is_ollama_sign_in_redirect( - &Url::parse("https://signin.ollama.com/?client_id=test").unwrap() - )); - assert!(is_ollama_sign_in_redirect( - &Url::parse("https://auth.workos.com/user_management/authorize?client_id=test") - .unwrap() - )); - assert!(!is_ollama_sign_in_redirect( - &Url::parse("https://auth.workos.com/other").unwrap() - )); - assert!(!is_ollama_sign_in_redirect( - &Url::parse("http://signin.ollama.com/").unwrap() - )); - } - #[tokio::test] async fn settings_fetch_follows_same_origin_redirects() { let mut server = mockito::Server::new_async().await; @@ -1069,12 +742,4 @@ mod tests { OllamaSessionAction::ReimportBrowser ); } - - #[test] - fn recognized_session_cookie_required_for_cache_reuse() { - assert!(has_recognized_ollama_session_cookie( - "__Secure-session=abc123; path=/" - )); - assert!(!has_recognized_ollama_session_cookie("foo=bar; baz=qux")); - } } diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 106a179c8e..db623ee9d4 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -282,6 +282,18 @@ pub struct Settings { /// Alibaba Token Plan API region: "cn" | "intl" | "cn-personal" | "intl-personal". #[serde(default = "default_alibaba_token_plan_region")] pub alibaba_token_plan_region: String, + + /// Opt-in: allow Codex usage reads from external (non-CLI-owned) OAuth + /// credential sources. Default OFF — when disabled, stale external OAuth + /// credential files fail closed instead of being used silently (upstream + /// 0.50.1 #2944). The CLI-owned `auth.json` is always read read-only; this + /// gate only controls whether stale external OAuth tokens are trusted. + #[serde(default)] + pub codex_external_oauth_sources_allowed: bool, + + /// How cost is rendered on provider MenuCards (#2976). + #[serde(default)] + pub cost_summary_display_style: CostSummaryDisplayStyle, } fn default_window_scale_percent() -> u16 { @@ -483,6 +495,8 @@ impl Default for Settings { claude_daily_routines_usage_visible: true, weekly_progress_work_days: None, alibaba_token_plan_region: default_alibaba_token_plan_region(), + codex_external_oauth_sources_allowed: false, + cost_summary_display_style: CostSummaryDisplayStyle::default(), } } } @@ -1116,4 +1130,33 @@ impl Settings { pub fn set_claude_avoid_keychain_prompts(&mut self, v: bool) { self.set_avoid_keychain_prompts(ProviderId::Claude, v) } + + // ── Per-provider accent color override (#2972) ────────────────── + + /// The user-overridden accent color for `id`, or `None` to use the + /// shipped brand color. + pub fn accent_color(&self, id: ProviderId) -> Option<&str> { + self.provider_configs + .get(&id) + .and_then(|c| c.accent_color.as_deref()) + .filter(|s| !s.trim().is_empty()) + } + + /// Set the accent color override for `id`. Pass an empty string or + /// `None` to clear the override and revert to the shipped brand color. + pub fn set_accent_color(&mut self, id: ProviderId, color: Option>) { + let entry = self.provider_config_mut(id); + entry.accent_color = color + .map(Into::into) + .filter(|s: &String| !s.trim().is_empty()); + } + + /// Resolve the effective accent color for `id`: the user override if + /// set, otherwise the shipped brand color from the provider registry. + pub fn effective_accent_color(&self, id: ProviderId) -> String { + if let Some(override_color) = self.accent_color(id) { + return override_color.trim().to_string(); + } + crate::core::brand_color(id).to_string() + } } diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index af21a03745..01c1cebbfe 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -165,6 +165,10 @@ pub(super) struct RawSettings { weekly_progress_work_days: Option, #[serde(default = "default_alibaba_token_plan_region")] alibaba_token_plan_region: String, + #[serde(default)] + codex_external_oauth_sources_allowed: bool, + #[serde(default)] + cost_summary_display_style: CostSummaryDisplayStyle, } impl Default for RawSettings { @@ -261,6 +265,8 @@ impl Default for RawSettings { claude_daily_routines_usage_visible: s.claude_daily_routines_usage_visible, weekly_progress_work_days: s.weekly_progress_work_days, alibaba_token_plan_region: s.alibaba_token_plan_region, + codex_external_oauth_sources_allowed: s.codex_external_oauth_sources_allowed, + cost_summary_display_style: s.cost_summary_display_style, } } } @@ -552,6 +558,8 @@ impl From for Settings { trimmed.to_string() } }, + cost_summary_display_style: raw.cost_summary_display_style, + codex_external_oauth_sources_allowed: raw.codex_external_oauth_sources_allowed, } } } diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index bd80c81f18..8353c0c1b3 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -280,6 +280,9 @@ pub enum MetricPreference { Credits, #[serde(rename = "extraUsage", alias = "extrausage")] ExtraUsage, + /// Current-month plan spend for PAYG providers (e.g. Mistral) that have + /// cost data but no rate-limit window (#2821, #2947). + MonthlyPlan, Average, } @@ -294,6 +297,7 @@ impl MetricPreference { MetricPreference::Tertiary, MetricPreference::Credits, MetricPreference::ExtraUsage, + MetricPreference::MonthlyPlan, MetricPreference::Average, ] } @@ -308,6 +312,7 @@ impl MetricPreference { MetricPreference::Tertiary => "Tertiary", MetricPreference::Credits => "Credits", MetricPreference::ExtraUsage => "Extra usage", + MetricPreference::MonthlyPlan => "Monthly plan spend", MetricPreference::Average => "Average", } } @@ -322,11 +327,32 @@ impl MetricPreference { MetricPreference::Tertiary => "Tertiary usage limit", MetricPreference::Credits => "Credit balance", MetricPreference::ExtraUsage => "On-demand or extra usage budget", + MetricPreference::MonthlyPlan => "Current-month plan spend (PAYG)", MetricPreference::Average => "Average across metrics", } } } +/// How cost is rendered on provider MenuCards (#2976). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum CostSummaryDisplayStyle { + #[default] + Compact, + Detailed, + Hidden, +} + +impl CostSummaryDisplayStyle { + pub fn all() -> &'static [CostSummaryDisplayStyle] { + &[ + CostSummaryDisplayStyle::Compact, + CostSummaryDisplayStyle::Detailed, + CostSummaryDisplayStyle::Hidden, + ] + } +} + /// Per-provider configuration values. /// /// All fields are optional / falsy-default so unused providers serialize as @@ -365,4 +391,8 @@ pub struct ProviderConfig { /// Claude-only: avoid keychain prompts when reading credentials. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub avoid_keychain_prompts: bool, + /// Per-provider accent color override (hex, e.g. "#FF5733"). `None` + /// means the shipped brand color is used (#2972). + #[serde(skip_serializing_if = "Option::is_none")] + pub accent_color: Option, }