From d31e1a31056e0540ea3b2c7f82f9b993859c80b1 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 19 Oct 2026 01:39:13 +0700 Subject: [PATCH 1/2] Add OpenAI Tokens/min tray metric --- CHANGELOG.md | 1 + .../scripts/proof-scenarios.json | 9 ++ .../src/commands/bridge/settings_snapshot.rs | 2 + .../src-tauri/src/commands/provider_detail.rs | 4 + .../src-tauri/src/tray_bridge.rs | 76 +++++++++++++-- .../src-tauri/src/tray_presentation.rs | 32 +++++- .../src-tauri/src/tray_presentation_tests.rs | 97 +++++++++++++++++++ .../src-tauri/src/usage_metric.rs | 3 + .../desktop-tauri/src/i18n/keyGroups/shell.ts | 3 + .../src/proof/fixtures/bridgeData.ts | 2 + .../src/proof/fixtures/common.ts | 1 + .../sections/MenuBarMetricSection.test.tsx | 36 +++++++ .../sections/MenuBarMetricSection.tsx | 6 ++ .../src/types/bridge/providers.ts | 2 + .../src/types/bridge/settings.ts | 3 +- rust/src/core/provider/traits.rs | 6 ++ rust/src/core/throughput.rs | 20 ++++ rust/src/locale.rs | 3 + rust/src/locale/en-US.ftl | 2 + rust/src/locale/es-MX.ftl | 2 + rust/src/locale/ja-JP.ftl | 2 + rust/src/locale/ko-KR.ftl | 2 + rust/src/locale/pt-BR.ftl | 2 + rust/src/locale/ru-RU.ftl | 2 + rust/src/locale/tr-TR.ftl | 2 + rust/src/locale/uk-UA.ftl | 2 + rust/src/locale/zh-CN.ftl | 2 + rust/src/locale/zh-TW.ftl | 2 + rust/src/providers/openaiapi/mod.rs | 4 + rust/src/settings/types.rs | 6 ++ 30 files changed, 328 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8ded46598..08f42e0d71b 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Localization: align the Raycast auto-import and cookie-disabled strings in ru-RU, uk-UA, tr-TR and zh-CN with each locale's existing cookie and installed-browser terminology. - OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The menu bar token comes in a later release (upstream #4495). - OpenAI API: the card shows the last-hour Tokens/min grid (latest, peak, 5-minute average, reporting frontier, and pending minutes drawn as outlines), and Settings > Providers > OpenAI adds a Live Usage section with a refresh switch, a per-model organization limits editor, a 15-minute / 1-hour / 6-hour window with per-model rows, and the organization caps list, which stays hidden when a project id is saved (upstream #4495). +- OpenAI API: add **Tokens/min** to the Settings > Providers > OpenAI API menu and floating bar metric picker. With it selected, the tray tooltip and tray status row show the latest reported minute, for example `OpenAI API 49M/min`, or "Tokens/min unavailable" when Live usage has not reported a complete minute. The tray meter and floating bar keep the Automatic usage lane (upstream #4495). - Tray: add a **Progress bars stacked** menu bar style that draws the session and weekly meters as two equal-height rows on the single tray icon, with the same windows and pace tint as **Meter bars**; older settings files keep their style (upstream #4436). - Settings providers: show only enabled providers from the sidebar options menu, search by CLI alias (such as `openai` for Codex), restore the default provider order, and undo a drag reorder or a restore with Ctrl+Z or the options menu. Hidden providers keep their slots when the order changes (upstream #4440). - Add **Color by provider** to Settings > Menu Bar (off by default): tints the tray usage bars and percent text with the shown provider's brand or accent color, keeps pace colors and the error colors unchanged, and falls back to the usual colors when a tint would be hard to see on the tray background (upstream #4321). diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index a2a2c5ff820..853f5c84873 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -84,6 +84,15 @@ { "expectCall": { "cmd": "codex_account_switch", "args": { "id": "proof-codex-personal" } } } ] }, + { + "name": "settings-openaiapi-tokens-per-minute-metric", + "query": "window=settings&tab=providers:openaiapi&theme=dark", + "size": [880, 620], + "steps": [ + { "expectText": ["OpenAI API", "Menu and floating bar metric", "Tokens/min"] }, + { "screenshot": "settings-openaiapi-tokens-per-minute-metric" } + ] + }, { "name": "floatbar", "query": "window=floatbar", diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/settings_snapshot.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/settings_snapshot.rs index e8e804aa721..5a559475637 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/settings_snapshot.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/settings_snapshot.rs @@ -456,6 +456,7 @@ fn metric_preference_label(pref: MetricPreference) -> &'static str { MetricPreference::ExtraUsage => "extraUsage", MetricPreference::MonthlyPlan => "monthlyPlan", MetricPreference::Average => "average", + MetricPreference::TokensPerMinute => "tokensPerMinute", } } @@ -470,6 +471,7 @@ pub(in crate::commands) fn parse_metric_preference(s: &str) -> Option Some(MetricPreference::ExtraUsage), "monthlyPlan" | "monthlyplan" => Some(MetricPreference::MonthlyPlan), "average" => Some(MetricPreference::Average), + "tokensPerMinute" | "tokensperminute" => Some(MetricPreference::TokensPerMinute), _ => None, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 808511afed0..f93cbcfea73 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -41,6 +41,9 @@ pub struct ProviderDetail { /// allowance (upstream 0.70.0 #4072). Offers the Monthly Plan metric in /// the settings metric picker. pub monthly_plan_window_id: Option<&'static str>, + /// Offers the Tokens/min metric in the settings metric picker (upstream + /// 0.75.0 #4495). + pub tokens_per_minute_metric: bool, /// Metric picker label for the primary lane when it is not a session /// window (upstream `menuBarLayoutPrimaryLabel`). pub primary_metric_label: Option<&'static str>, @@ -143,6 +146,7 @@ pub(crate) fn build_provider_detail( tertiary: None, tertiary_label_key: metadata.tertiary_label_key, monthly_plan_window_id: provider.monthly_plan_window_id(), + tokens_per_minute_metric: provider.reports_tokens_per_minute(), primary_metric_label: provider.menu_bar_primary_label(), extra_rate_windows: Vec::new(), usage_items: Vec::new(), diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 2c2b76d3d72..156ee425919 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -413,7 +413,12 @@ pub fn update_tray_icon_and_tooltip( let icon = Image::new_owned(rgba, w, h); let _ = tray.set_icon(Some(icon)); - let tooltip = build_tooltip(snapshots, settings.ui_language, settings.hide_personal_info); + let tooltip = build_tooltip( + snapshots, + &settings, + settings.ui_language, + settings.hide_personal_info, + ); let tooltip = crate::status_poller::with_incident_lines(app, &settings, tooltip); let _ = tray.set_tooltip(Some(tooltip)); } @@ -421,6 +426,7 @@ pub fn update_tray_icon_and_tooltip( /// Build a compact multi-line tooltip string from provider snapshots. fn build_tooltip( snapshots: &[crate::commands::ProviderUsageSnapshot], + settings: &Settings, lang: codexbar::settings::Language, hide_personal_info: bool, ) -> String { @@ -443,7 +449,10 @@ fn build_tooltip( let short = truncate_tooltip_text(&err, 36); format!("{}: {} ({})", s.display_name, error_label, short) } else { - let label = crate::commands::compact_tray_status_label(headline_window(s), lang); + let label = crate::tray_presentation::tokens_per_minute_status(s, settings, lang) + .unwrap_or_else(|| { + crate::commands::compact_tray_status_label(headline_window(s), lang) + }); format!("{}: {}", s.display_name, truncate_tooltip_text(&label, 42)) }; lines.push(status); @@ -643,6 +652,7 @@ mod tests { let tooltip = build_tooltip( &[claude, codex], + &Settings::default(), codexbar::settings::Language::English, false, ); @@ -653,6 +663,37 @@ mod tests { ); } + #[test] + fn tooltip_shows_tokens_per_minute_when_selected() { + let mut settings = Settings::default(); + settings.set_provider_metric( + codexbar::core::ProviderId::OpenAIApi, + codexbar::settings::MetricPreference::TokensPerMinute, + ); + let mut openai = fake_snapshot("openaiapi", "OpenAI API", 40.0); + openai.throughput = Some(crate::commands::ProviderThroughputSnapshot { + minutes: Vec::new(), + frontier: Some(1_700_000_000), + latest_tokens_per_minute: Some(1_500_000), + peak_tokens_per_minute: 1_500_000, + peak_at: None, + five_minute_average_tokens_per_minute: 0.0, + models: Vec::new(), + window_minutes: 60, + fetched_at: 1_700_000_060, + project_id: None, + }); + + let tooltip = build_tooltip( + &[openai, fake_snapshot("codex", "Codex", 8.0)], + &settings, + codexbar::settings::Language::English, + false, + ); + + assert_eq!(tooltip, "CodexBar\nOpenAI API: 1.5M/min\nCodex: 8%"); + } + #[test] fn tooltip_skips_informational_codex_primary() { // Weekly-only Codex plan: the 5h lane is an informational placeholder, @@ -663,7 +704,12 @@ mod tests { codex.primary.reset_description = Some("No active 5h session".to_string()); codex.secondary.as_mut().unwrap().reset_description = Some("3d 17h".to_string()); - let tooltip = build_tooltip(&[codex], codexbar::settings::Language::English, false); + let tooltip = build_tooltip( + &[codex], + &Settings::default(), + codexbar::settings::Language::English, + false, + ); assert_eq!(tooltip, "CodexBar\nCodex: 16% • Resets in 3d 17h"); } @@ -674,7 +720,12 @@ mod tests { claude.primary.reset_description = Some("resets in Jun 10 at 3:00PM with extra noisy suffix".to_string()); - let tooltip = build_tooltip(&[claude], codexbar::settings::Language::English, false); + let tooltip = build_tooltip( + &[claude], + &Settings::default(), + codexbar::settings::Language::English, + false, + ); let line = tooltip.lines().nth(1).expect("provider tooltip line"); assert!(line.starts_with("Claude: 13% • Resets in Jun 10 at 3:00PM")); @@ -689,12 +740,18 @@ mod tests { let shown = build_tooltip( &[codex.clone()], + &Settings::default(), codexbar::settings::Language::English, false, ); assert!(shown.contains("proof@example.com"), "{shown}"); - let hidden = build_tooltip(&[codex], codexbar::settings::Language::English, true); + let hidden = build_tooltip( + &[codex], + &Settings::default(), + codexbar::settings::Language::English, + true, + ); assert!(hidden.contains("session expired"), "{hidden}"); assert!(!hidden.contains('@'), "{hidden}"); assert!(!hidden.contains("example.com"), "{hidden}"); @@ -705,7 +762,12 @@ mod tests { let mut claude = fake_snapshot("claude", "Claude", 13.0); claude.error = Some("network timeout".to_string()); - let tooltip = build_tooltip(&[claude], codexbar::settings::Language::Japanese, false); + let tooltip = build_tooltip( + &[claude], + &Settings::default(), + codexbar::settings::Language::Japanese, + false, + ); assert!(tooltip.contains("エラー"), "{tooltip}"); assert!(!tooltip.contains(": error ("), "{tooltip}"); @@ -719,11 +781,13 @@ mod tests { let english_tooltip = build_tooltip( &[claude.clone()], + &Settings::default(), codexbar::settings::Language::English, false, ); let japanese_tooltip = build_tooltip( &[claude.clone()], + &Settings::default(), codexbar::settings::Language::Japanese, false, ); diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs index 807a31a0992..752abaa3876 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -455,13 +455,43 @@ fn provider_status_label( ); } - let label = crate::commands::compact_tray_status_label(headline_window(snapshot), language); + let label = tokens_per_minute_status(snapshot, settings, language).unwrap_or_else(|| { + crate::commands::compact_tray_status_label(headline_window(snapshot), language) + }); ( snapshot.provider_id.clone(), format!("{} {}", snapshot.display_name, label), ) } +/// Tray status text for the Tokens/min metric: the latest reported minute +/// ("12K/min"), or an explicit unavailable label when no minute has been +/// reported (Live usage off, no Admin key, or nothing complete yet). `None` +/// when the provider's metric is not Tokens/min. +pub(crate) fn tokens_per_minute_status( + snapshot: &ProviderUsageSnapshot, + settings: &Settings, + language: Language, +) -> Option { + let provider = ProviderId::from_cli_name(&snapshot.provider_id)?; + if settings.get_provider_metric(provider) != MetricPreference::TokensPerMinute { + return None; + } + Some( + match snapshot + .throughput + .as_ref() + .and_then(|throughput| throughput.latest_tokens_per_minute) + { + Some(tokens) => codexbar::core::tokens_per_minute_label(tokens), + None => codexbar::locale::get_text( + language, + codexbar::locale::LocaleKey::TrayTokensPerMinuteUnavailable, + ), + }, + ) +} + /// Window that headline tray surfaces should label for a provider. pub(crate) fn headline_window(snapshot: &ProviderUsageSnapshot) -> &RateWindowSnapshot { if snapshot.provider_id == "codex" { diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 9027a743515..7bfd3928f9d 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -1272,3 +1272,100 @@ fn pick_tray_provider_none_when_empty() { assert!(pick_tray_provider(&refs, true).is_none()); assert!(pick_tray_provider(&refs, false).is_none()); } + +fn openai_api_throughput(latest: Option) -> crate::commands::ProviderThroughputSnapshot { + crate::commands::ProviderThroughputSnapshot { + minutes: Vec::new(), + frontier: latest.map(|_| 1_700_000_000), + latest_tokens_per_minute: latest, + peak_tokens_per_minute: latest.unwrap_or(0), + peak_at: None, + five_minute_average_tokens_per_minute: 0.0, + models: Vec::new(), + window_minutes: 60, + fetched_at: 1_700_000_060, + project_id: None, + } +} + +#[test] +fn tokens_per_minute_metric_labels_status_with_the_latest_rate() { + let mut settings = Settings { + tray_icon_mode: TrayIconMode::PerProvider, + show_as_used: true, + ..Settings::default() + }; + settings.set_provider_metric(ProviderId::OpenAIApi, MetricPreference::TokensPerMinute); + let mut openai = fake_snapshot("openaiapi", "OpenAI API", 40.0); + openai.throughput = Some(openai_api_throughput(Some(49_000_000))); + let snapshots = vec![openai.clone(), fake_snapshot("codex", "Codex", 30.0)]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + let labels = plan.status_labels(Language::English); + + assert!( + labels.contains(&("openaiapi".to_string(), "OpenAI API 49M/min".to_string())), + "{labels:?}" + ); + // Other providers keep their usage label; the meter keeps a usage lane. + assert!( + labels.contains(&("codex".to_string(), "Codex 30%".to_string())), + "{labels:?}" + ); + + openai.throughput = Some(openai_api_throughput(Some(0))); + assert_eq!( + tokens_per_minute_status(&openai, &settings, Language::English).as_deref(), + Some("0/min") + ); +} + +#[test] +fn tokens_per_minute_metric_without_reported_minutes_is_unavailable() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::OpenAIApi, MetricPreference::TokensPerMinute); + let mut openai = fake_snapshot("openaiapi", "OpenAI API", 40.0); + + assert_eq!( + tokens_per_minute_status(&openai, &settings, Language::English).as_deref(), + Some("Tokens/min unavailable") + ); + openai.throughput = Some(openai_api_throughput(None)); + assert_eq!( + tokens_per_minute_status(&openai, &settings, Language::English).as_deref(), + Some("Tokens/min unavailable") + ); + + // Any other metric leaves the usage label alone. + let automatic = Settings::default(); + openai.throughput = Some(openai_api_throughput(Some(1_500))); + assert_eq!( + tokens_per_minute_status(&openai, &automatic, Language::English), + None + ); +} + +#[test] +fn tokens_per_minute_metric_keeps_the_automatic_icon_lane() { + let mut settings = Settings { + tray_icon_mode: TrayIconMode::Single, + show_as_used: true, + ..Settings::default() + }; + let mut openai = fake_snapshot("openaiapi", "OpenAI API", 40.0); + openai.throughput = Some(openai_api_throughput(Some(12_000))); + let snapshots = vec![openai]; + let automatic_icon = TrayPresentationPlan::resolve(&settings, &snapshots, None).icon; + + settings.set_provider_metric(ProviderId::OpenAIApi, MetricPreference::TokensPerMinute); + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!(plan.icon, automatic_icon); + assert_eq!( + plan.status_labels(Language::English), + vec![( + "status_summary".to_string(), + "OpenAI API 12K/min".to_string() + )] + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index bc99d1fbd51..34fb0861da0 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -169,6 +169,9 @@ fn preferred_window( extra_usage_window(snapshot).or_else(|| cost_window(snapshot)) } MetricPreference::Average => average_window(snapshot), + // A rate, not a quota: the meter keeps the Automatic lane and the tray + // status text carries the Tokens/min value (`tray_presentation`). + MetricPreference::TokensPerMinute => automatic_window(snapshot, provider), // Upstream 0.70.0 (#4072): the provider's plan allowance. A missing // or unknown plan falls back to the primary (included API) allowance, // never to a spend window. Providers without a plan window fall diff --git a/apps/desktop-tauri/src/i18n/keyGroups/shell.ts b/apps/desktop-tauri/src/i18n/keyGroups/shell.ts index 5597623455a..dca46bda709 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/shell.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/shell.ts @@ -199,6 +199,9 @@ export const SHELL_LOCALE_KEYS = [ "MistralMonthlySpendHelper", // Mistral Monthly Plan menu bar metric (upstream 0.70.0 #4072) "MetricMonthlyPlan", + // OpenAI Tokens/min menu bar metric (upstream 0.75.0 #4495) + "MetricTokensPerMinute", + "TrayTokensPerMinuteUnavailable", // Menu cost-summary display style (#2976) "CostSummaryDisplayStyle", diff --git a/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts b/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts index 3cb5341aabc..fa72f9c8237 100644 --- a/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts +++ b/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts @@ -100,6 +100,8 @@ export function providerDetail( secondaryLabel: snapshot?.secondaryLabel ?? null, modelSpecific: snapshot?.modelSpecific ?? null, tertiary: snapshot?.tertiary ?? null, + // Mirrors `Provider::reports_tokens_per_minute`: only OpenAI API reports throughput. + tokensPerMinuteMetric: providerId === "openaiapi", extraRateWindows: snapshot?.extraRateWindows ?? [], usageItems: [], hiddenUsageItemIds: [], diff --git a/apps/desktop-tauri/src/proof/fixtures/common.ts b/apps/desktop-tauri/src/proof/fixtures/common.ts index 3cfeaa00c52..2ea6a6a0c31 100644 --- a/apps/desktop-tauri/src/proof/fixtures/common.ts +++ b/apps/desktop-tauri/src/proof/fixtures/common.ts @@ -90,6 +90,7 @@ export const PROOF_PROVIDER_CATALOG: ProviderCatalogEntry[] = [ { id: "kiro", displayName: "Kiro", cookieDomain: null }, { id: "zai", displayName: "z.ai", cookieDomain: null }, { id: "mistral", displayName: "Mistral", cookieDomain: null }, + { id: "openaiapi", displayName: "OpenAI API", cookieDomain: null }, ]; export function catalogOrder(): string[] { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx index 02dfd230109..bd7e2dc8d2b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx @@ -191,6 +191,42 @@ describe("MenuBarMetricSection", () => { expect(screen.getByRole("combobox")).toHaveValue("extraUsage"); }); + it("offers Tokens/min only for providers that report throughput", () => { + const onChange = vi.fn(); + const openai: ProviderDetail = { + ...provider(false), + id: "openaiapi", + displayName: "OpenAI API", + tokensPerMinuteMetric: true, + }; + const { rerender } = render( + key} + onChange={onChange} + />, + ); + + expect(optionNames()).toEqual(["Automatic", "ProviderSessionLabel", "MetricTokensPerMinute"]); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "tokensPerMinute" } }); + expect(onChange).toHaveBeenCalledWith({ + providerMetrics: { openaiapi: "tokensPerMinute" }, + }); + + rerender( + key} + onChange={vi.fn()} + />, + ); + expect(screen.queryByRole("option", { name: "MetricTokensPerMinute" })).toBeNull(); + }); + it("hides the picker for Aixy, which offers only Automatic", () => { const aixy = provider(); aixy.id = "aixy"; 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 8435fc7c341..df2dcc0f7a2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -125,6 +125,11 @@ function metricOptions( if (provider.id === "gemini" && provider.weekly) { options.push({ value: "average", label: t("Average") }); } + // Upstream 0.75.0 (#4495): the latest Tokens/min from Live usage. The tray + // meter keeps the Automatic lane; the tray status text shows the rate. + if (provider.tokensPerMinuteMetric) { + options.push({ value: "tokensPerMinute", label: t("MetricTokensPerMinute") }); + } if (!options.some((option) => option.value === selected)) { const labelKey = SAVED_ONLY_LABEL_KEYS[selected]; options.push({ @@ -141,4 +146,5 @@ const SAVED_ONLY_LABEL_KEYS: Partial> = { credits: "CreditsLabel", extraUsage: "ExtraUsage", monthlyPlan: "MetricMonthlyPlan", + tokensPerMinute: "MetricTokensPerMinute", }; diff --git a/apps/desktop-tauri/src/types/bridge/providers.ts b/apps/desktop-tauri/src/types/bridge/providers.ts index 6fb0eed74db..f465a477f9a 100644 --- a/apps/desktop-tauri/src/types/bridge/providers.ts +++ b/apps/desktop-tauri/src/types/bridge/providers.ts @@ -252,6 +252,8 @@ export interface ProviderDetail { tertiaryLabelKey?: string | null; /** Extra rate window id holding the monthly plan allowance; offers the Monthly Plan metric (upstream 0.70.0). */ monthlyPlanWindowId?: string | null; + /** Offers the Tokens/min menu bar metric (upstream 0.75.0 #4495). */ + tokensPerMinuteMetric?: boolean; /** Metric picker label for the primary lane when it is not a session window. */ primaryMetricLabel?: string | null; extraRateWindows: Array<{ diff --git a/apps/desktop-tauri/src/types/bridge/settings.ts b/apps/desktop-tauri/src/types/bridge/settings.ts index ea8d41d1e30..79fceac6e43 100644 --- a/apps/desktop-tauri/src/types/bridge/settings.ts +++ b/apps/desktop-tauri/src/types/bridge/settings.ts @@ -49,7 +49,8 @@ export type MetricPreference = | "credits" | "extraUsage" | "monthlyPlan" - | "average"; + | "average" + | "tokensPerMinute"; export type Language = | "english" diff --git a/rust/src/core/provider/traits.rs b/rust/src/core/provider/traits.rs index 2316a46be55..df114ac59c8 100644 --- a/rust/src/core/provider/traits.rs +++ b/rust/src/core/provider/traits.rs @@ -134,6 +134,12 @@ pub trait Provider: Send + Sync { None } + /// Whether refreshes can carry per-minute token throughput, which offers + /// the Tokens/min menu bar metric (upstream 0.75.0 #4495). + fn reports_tokens_per_minute(&self) -> bool { + false + } + /// Label for the primary lane in the menu bar metric picker when the /// lane is not a session window (upstream `menuBarLayoutPrimaryLabel`). /// `None` keeps the generic session label. diff --git a/rust/src/core/throughput.rs b/rust/src/core/throughput.rs index 8a60887b2c9..f6fc94cc8e9 100644 --- a/rust/src/core/throughput.rs +++ b/rust/src/core/throughput.rs @@ -25,6 +25,12 @@ pub struct ProviderThroughput { pub project_id: Option, } +/// Compact menu bar rate such as "12K/min", "1.5M/min" or "0/min" (upstream +/// `ProviderThroughputSection.count(_) + "/min"`). +pub fn tokens_per_minute_label(tokens: u64) -> String { + format!("{}/min", crate::providers::format::token_count(tokens)) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ThroughputMinute { pub start: i64, @@ -45,3 +51,17 @@ pub struct ThroughputModel { /// values are retained but excluded from utilization. pub limit_counted_tokens_by_minute: Vec, } + +#[cfg(test)] +mod tests { + use super::tokens_per_minute_label; + + #[test] + fn tokens_per_minute_label_is_compact_like_upstream() { + assert_eq!(tokens_per_minute_label(0), "0/min"); + assert_eq!(tokens_per_minute_label(950), "950/min"); + assert_eq!(tokens_per_minute_label(12_345), "12K/min"); + assert_eq!(tokens_per_minute_label(1_500_000), "1.5M/min"); + assert_eq!(tokens_per_minute_label(49_000_000), "49M/min"); + } +} diff --git a/rust/src/locale.rs b/rust/src/locale.rs index f3d3a0eba9a..f323c0d32f5 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1526,6 +1526,9 @@ locale_keys! { MistralMonthlySpendHelper, // Mistral Monthly Plan menu bar metric (upstream 0.70.0 #4072) MetricMonthlyPlan, + // OpenAI Tokens/min menu bar metric (upstream 0.75.0 #4495) + MetricTokensPerMinute, + TrayTokensPerMinuteUnavailable, // Menu cost-summary display style (#2976) CostSummaryDisplayStyle, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index add5fcbd67f..b2a865a099c 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1072,6 +1072,8 @@ DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; MistralMonthlySpend = Monthly API spend MistralMonthlySpendHelper = Current-month API usage for pay-as-you-go Mistral accounts. MetricMonthlyPlan = Monthly Plan +MetricTokensPerMinute = Tokens/min +TrayTokensPerMinuteUnavailable = Tokens/min unavailable CostSummaryDisplayStyle = Cost summary display CostSummaryDisplayStyleHelper = Choose how cost is shown on every provider card. CostSummaryStyleCompact = Compact diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index aa7ecf767f6..61ac59bca86 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1071,6 +1071,8 @@ DeepSeekPricingAdvice = Horario oficial: pico 01:00-04:00 y 06:00-10:00 UTC; en MistralMonthlySpend = Gasto de API mensual MistralMonthlySpendHelper = Uso de API del mes actual para cuentas de pago por uso de Mistral. MetricMonthlyPlan = Plan mensual +MetricTokensPerMinute = Tokens/min +TrayTokensPerMinuteUnavailable = Tokens/min no disponible CostSummaryDisplayStyle = Estilo de resumen de costos CostSummaryDisplayStyleHelper = Elige cómo se muestran los costos en cada tarjeta de proveedor. CostSummaryStyleCompact = Compacto diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 711f904059f..64d2c342f9a 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1072,6 +1072,8 @@ DeepSeekPricingAdvice = 公式スケジュール: ピークは UTC 01:00-04:00 MistralMonthlySpend = 月間 API 費用 MistralMonthlySpendHelper = 従量課金 Mistral アカウントの当月 API 使用量。 MetricMonthlyPlan = 月額プラン +MetricTokensPerMinute = トークン/分 +TrayTokensPerMinuteUnavailable = トークン/分を取得できません CostSummaryDisplayStyle = 費用サマリー表示スタイル CostSummaryDisplayStyleHelper = 各プロバイダーカードの費用表示方法を選択します。 CostSummaryStyleCompact = コンパクト diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index b7c5f9b7264..9fee3ecf9b7 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1071,6 +1071,8 @@ DeepSeekPricingAdvice = 공식 일정: 피크 01:00-04:00 및 06:00-10:00 UTC, MistralMonthlySpend = 월간 API 비용 MistralMonthlySpendHelper = 종량제 Mistral 계정의 당월 API 사용량입니다. MetricMonthlyPlan = 월간 요금제 +MetricTokensPerMinute = 토큰/분 +TrayTokensPerMinuteUnavailable = 토큰/분을 사용할 수 없음 CostSummaryDisplayStyle = 비용 요약 표시 스타일 CostSummaryDisplayStyleHelper = 각 공급자 카드에 비용을 표시하는 방법을 선택합니다. CostSummaryStyleCompact = 간결 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index a9637e0fef3..374e0a82092 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -995,6 +995,8 @@ DeepSeekPricingAdvice = Horário oficial: pico 01h00-04h00 e 06h00-10h00 UTC; fo MistralMonthlySpend = Gasto mensal com API MistralMonthlySpendHelper = Uso da API no mês atual para contas Mistral pré-pagas. MetricMonthlyPlan = Plano mensal +MetricTokensPerMinute = Tokens/min +TrayTokensPerMinuteUnavailable = Tokens/min indisponível CostSummaryDisplayStyle = Exibição do resumo de custos CostSummaryDisplayStyleHelper = Escolha como o custo é mostrado em cada cartão de provedor. CostSummaryStyleCompact = Compacto diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 2eb20cc1c5a..fc4f432fcd6 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1072,6 +1072,8 @@ DeepSeekPricingAdvice = Официальное расписание: пик 01:0 MistralMonthlySpend = Расход на API за месяц MistralMonthlySpendHelper = Использование API за текущий месяц для аккаунтов Mistral с оплатой по мере использования. MetricMonthlyPlan = Ежемесячный план +MetricTokensPerMinute = Токены/мин +TrayTokensPerMinuteUnavailable = Токены/мин недоступны CostSummaryDisplayStyle = Стиль отображения стоимости CostSummaryDisplayStyleHelper = Выберите, как стоимость отображается на каждой карте провайдера. CostSummaryStyleCompact = Компактный diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index e4a6e6563db..d0877fb14c8 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1022,6 +1022,8 @@ PromoteTrayIconLabel = Görev Çubuğuna Sabitle (Windows 11) PromoteTrayIconHelper = CodexBar simgesini taşma şevronunun içinde değil, her zaman görev çubuğunda göster PromoteTrayIconUnsupportedHint = Bu işletim sisteminde kullanılamaz. Elle sabitlemek için simgeyi Windows Ayarlar > Görev Çubuğu'ndaki ^ taşma alanının dışına sürükleyin. MetricMonthlyPlan = Aylık plan +MetricTokensPerMinute = Token/dk +TrayTokensPerMinuteUnavailable = Token/dk kullanılamıyor CostSummaryDisplayStyle = Maliyet özeti görünümü CostSummaryDisplayStyleHelper = Maliyetin her sağlayıcı kartında nasıl gösterileceğini seçin. CostSummaryStyleCompact = Kompakt diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index fe58bdebdc3..98c39bb6610 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1068,6 +1068,8 @@ DeepSeekPricingAdvice = Офіційний розклад: пік 01:00-04:00 і MistralMonthlySpend = Витрати на API за місяць MistralMonthlySpendHelper = Використання API за поточний місяць для облікових записів Mistral з оплатою за фактом. MetricMonthlyPlan = Місячний план +MetricTokensPerMinute = Токени/хв +TrayTokensPerMinuteUnavailable = Токени/хв недоступні CostSummaryDisplayStyle = Показ вартості CostSummaryDisplayStyleHelper = Виберіть, як показувати вартість на картці кожного провайдера. CostSummaryStyleCompact = Компактно diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 41aab4c3aae..e4b09101e21 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1072,6 +1072,8 @@ DeepSeekPricingAdvice = 官方时段:UTC 01:00-04:00 和 06:00-10:00 为高峰 MistralMonthlySpend = 本月 API 花费 MistralMonthlySpendHelper = 按量付费 Mistral 账户的当月 API 用量。 MetricMonthlyPlan = 月度套餐 +MetricTokensPerMinute = Token/分钟 +TrayTokensPerMinuteUnavailable = Token/分钟 不可用 CostSummaryDisplayStyle = 费用摘要显示方式 CostSummaryDisplayStyleHelper = 选择在每个提供商卡片上如何显示费用。 CostSummaryStyleCompact = 紧凑 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index cc9b995f27f..d5e88a435b0 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1072,6 +1072,8 @@ DeepSeekPricingAdvice = 官方時程:尖峰為 UTC 01:00-04:00 與 06:00-10:00 MistralMonthlySpend = 本月 API 花費 MistralMonthlySpendHelper = 按量付費 Mistral 帳戶的當月 API 用量。 MetricMonthlyPlan = 月租方案 +MetricTokensPerMinute = Token/分鐘 +TrayTokensPerMinuteUnavailable = Token/分鐘 無法使用 CostSummaryDisplayStyle = 費用摘要顯示方式 CostSummaryDisplayStyleHelper = 選擇在每個提供商卡片上如何顯示費用。 CostSummaryStyleCompact = 緊湊 diff --git a/rust/src/providers/openaiapi/mod.rs b/rust/src/providers/openaiapi/mod.rs index 4e923dd2e93..3be517515bd 100644 --- a/rust/src/providers/openaiapi/mod.rs +++ b/rust/src/providers/openaiapi/mod.rs @@ -713,6 +713,10 @@ impl Provider for OpenAIApiProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::OAuth] } + + fn reports_tokens_per_minute(&self) -> bool { + true + } } /// Resolve the key from Preferences, the credential store, then the environment. diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index abc6994fa0f..79f3446a6f5 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -456,6 +456,9 @@ pub enum MetricPreference { /// plan (upstream 0.70.0 #4072). MonthlyPlan, Average, + /// The latest reported Tokens/min (upstream 0.75.0 #4495). A text metric: + /// the tray meter keeps the Automatic lane while the tray status shows the rate. + TokensPerMinute, } impl MetricPreference { @@ -471,6 +474,7 @@ impl MetricPreference { MetricPreference::ExtraUsage, MetricPreference::MonthlyPlan, MetricPreference::Average, + MetricPreference::TokensPerMinute, ] } @@ -486,6 +490,7 @@ impl MetricPreference { MetricPreference::ExtraUsage => "Extra usage", MetricPreference::MonthlyPlan => "Monthly plan", MetricPreference::Average => "Average", + MetricPreference::TokensPerMinute => "Tokens/min", } } @@ -501,6 +506,7 @@ impl MetricPreference { MetricPreference::ExtraUsage => "On-demand or extra usage budget", MetricPreference::MonthlyPlan => "Monthly plan allowance", MetricPreference::Average => "Average across metrics", + MetricPreference::TokensPerMinute => "Latest tokens per minute", } } } From 3971958b0195bba0333c66166229a05d7ee0b1f5 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 19 Oct 2026 03:05:01 +0700 Subject: [PATCH 2/2] Address review: camelCase metric serde and capability gate --- CHANGELOG.md | 3 +- .../src-tauri/src/tray_presentation.rs | 7 +- .../src-tauri/src/tray_presentation_tests.rs | 14 ++++ rust/src/core/provider/id.rs | 7 ++ rust/src/core/provider/traits.rs | 2 +- rust/src/providers/openaiapi/mod.rs | 4 -- rust/src/settings/tests.rs | 1 + rust/src/settings/tests/metric_preference.rs | 67 +++++++++++++++++++ rust/src/settings/types.rs | 5 +- 9 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 rust/src/settings/tests/metric_preference.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5779b538069..4a82e215526 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,12 @@ - **Claude**: in Auto mode, a Claude CLI probe timeout now falls back to the retained CLI usage from the last 15 minutes, as other CLI failures already did, instead of failing the refresh outright. Without a retained result the timeout is reported as before. - Settings providers: show the FLORA Workspace ID and Fireworks account slug fields, which were defined but never displayed, so they no longer need the `FLORA_WORKSPACE_ID` / `FIREWORKS_ACCOUNT_SLUG` environment variables. - Localization: align the Raycast auto-import and cookie-disabled strings in ru-RU, uk-UA, tr-TR and zh-CN with each locale's existing cookie and installed-browser terminology. -- OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The menu bar token comes in a later release (upstream #4495). +- OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage (upstream #4495). - OpenAI API: the card shows the last-hour Tokens/min grid (latest, peak, 5-minute average, reporting frontier, and pending minutes drawn as outlines), and Settings > Providers > OpenAI adds a Live Usage section with a refresh switch, a per-model organization limits editor, a 15-minute / 1-hour / 6-hour window with per-model rows, and the organization caps list, which stays hidden when a project id is saved (upstream #4495). - OpenAI API: add **Tokens/min** to the Settings > Providers > OpenAI API menu and floating bar metric picker. With it selected, the tray tooltip and tray status row show the latest reported minute, for example `OpenAI API 49M/min`, or "Tokens/min unavailable" when Live usage has not reported a complete minute. The tray meter and floating bar keep the Automatic usage lane (upstream #4495). - Tray: add a **Progress bars stacked** menu bar style that draws the session and weekly meters as two equal-height rows on the single tray icon, with the same windows and pace tint as **Meter bars**; older settings files keep their style (upstream #4436). - Settings providers: show only enabled providers from the sidebar options menu, search by CLI alias (such as `openai` for Codex), restore the default provider order, and undo a drag reorder or a restore with Ctrl+Z or the options menu. Hidden providers keep their slots when the order changes (upstream #4440). - Add **Color by provider** to Settings > Menu Bar (off by default): tints the tray usage bars and percent text with the shown provider's brand or accent color, keeps pace colors and the error colors unchanged, and falls back to the usual colors when a tint would be hard to see on the tray background (upstream #4321). -- OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The chart, menu bar token and Settings controls come in a later release (upstream #4495). - **Claude**: show the plan renewal or expiry date from claude.ai billing under the quota rows when your cookie session supplies it, and make saved limit-reset credits, Extra usage (including a prepaid balance), and plan dates independently selectable under Visible usage items. Existing settings keep all three visible; Admin API spend is unaffected (upstream #4324, #4484). - Devin: use Devin's official transparent hexagon mark for the provider icon in Settings and the CLI dashboard, instead of the settings-gear badge and the letter fallback (upstream #4458). - Kimi (China): use the current Kimi Desktop session from its Local Storage (`%APPDATA%\kimi-desktop`) for web usage and monthly membership enrichment, and check that session again before each request, so a signed-out, replaced, or expired Desktop token is never sent or recovered from an older record (upstream #4416, #3536). diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs index 752abaa3876..f740de42fa5 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -467,14 +467,17 @@ fn provider_status_label( /// Tray status text for the Tokens/min metric: the latest reported minute /// ("12K/min"), or an explicit unavailable label when no minute has been /// reported (Live usage off, no Admin key, or nothing complete yet). `None` -/// when the provider's metric is not Tokens/min. +/// when the provider's metric is not Tokens/min or the provider cannot report +/// a rate, so a saved value never leaks onto another provider's row. pub(crate) fn tokens_per_minute_status( snapshot: &ProviderUsageSnapshot, settings: &Settings, language: Language, ) -> Option { let provider = ProviderId::from_cli_name(&snapshot.provider_id)?; - if settings.get_provider_metric(provider) != MetricPreference::TokensPerMinute { + if !provider.reports_tokens_per_minute() + || settings.get_provider_metric(provider) != MetricPreference::TokensPerMinute + { return None; } Some( diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 7bfd3928f9d..54ad66963fe 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -1345,6 +1345,20 @@ fn tokens_per_minute_metric_without_reported_minutes_is_unavailable() { ); } +#[test] +fn tokens_per_minute_metric_on_a_provider_without_a_rate_falls_back() { + // A saved Tokens/min value must not leak onto a provider that cannot + // report a rate: the row keeps the Automatic usage label instead. + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Codex, MetricPreference::TokensPerMinute); + let mut codex = fake_snapshot("codex", "Codex", 30.0); + codex.throughput = Some(openai_api_throughput(Some(12_000))); + assert_eq!( + tokens_per_minute_status(&codex, &settings, Language::English), + None + ); +} + #[test] fn tokens_per_minute_metric_keeps_the_automatic_icon_lane() { let mut settings = Settings { diff --git a/rust/src/core/provider/id.rs b/rust/src/core/provider/id.rs index 1362fdc8b35..4dd47cb7c21 100755 --- a/rust/src/core/provider/id.rs +++ b/rust/src/core/provider/id.rs @@ -162,6 +162,13 @@ impl ProviderId { } } + /// Whether this provider reports a Tokens/min rate, which offers the + /// Tokens/min menu bar metric (upstream 0.75.0 #4495). Shared by the + /// provider trait and shell code that has no provider instance. + pub fn reports_tokens_per_minute(self) -> bool { + matches!(self, ProviderId::OpenAIApi) + } + /// Parse from CLI name string pub fn from_cli_name(name: &str) -> Option { let name = name.to_lowercase(); diff --git a/rust/src/core/provider/traits.rs b/rust/src/core/provider/traits.rs index df114ac59c8..61a7003dde8 100644 --- a/rust/src/core/provider/traits.rs +++ b/rust/src/core/provider/traits.rs @@ -137,7 +137,7 @@ pub trait Provider: Send + Sync { /// Whether refreshes can carry per-minute token throughput, which offers /// the Tokens/min menu bar metric (upstream 0.75.0 #4495). fn reports_tokens_per_minute(&self) -> bool { - false + self.id().reports_tokens_per_minute() } /// Label for the primary lane in the menu bar metric picker when the diff --git a/rust/src/providers/openaiapi/mod.rs b/rust/src/providers/openaiapi/mod.rs index 3be517515bd..4e923dd2e93 100644 --- a/rust/src/providers/openaiapi/mod.rs +++ b/rust/src/providers/openaiapi/mod.rs @@ -713,10 +713,6 @@ impl Provider for OpenAIApiProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::OAuth] } - - fn reports_tokens_per_minute(&self) -> bool { - true - } } /// Resolve the key from Preferences, the credential store, then the environment. diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 5eee4962917..adee4cea2e3 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -1,6 +1,7 @@ use super::*; mod language_theme; +mod metric_preference; mod provider_configs; mod unreadable_file; diff --git a/rust/src/settings/tests/metric_preference.rs b/rust/src/settings/tests/metric_preference.rs new file mode 100644 index 00000000000..fe6d561b8cd --- /dev/null +++ b/rust/src/settings/tests/metric_preference.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +fn tokens_per_minute_serializes_camel_case_and_reads_both_spellings() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::OpenAIApi, MetricPreference::TokensPerMinute); + + let json = serde_json::to_string(&settings).expect("serialize settings"); + assert!( + json.contains(r#""openaiapi":"tokensPerMinute""#), + "bridge spelling expected in {json}" + ); + + for spelling in ["tokensPerMinute", "tokensperminute"] { + let loaded: Settings = serde_json::from_str(&format!( + r#"{{ "provider_metrics": {{ "openaiapi": "{spelling}" }} }}"# + )) + .expect("metric spelling parses"); + assert_eq!( + loaded.get_provider_metric(ProviderId::OpenAIApi), + MetricPreference::TokensPerMinute, + "{spelling}" + ); + } +} + +#[test] +fn monthly_plan_serializes_camel_case_and_keeps_the_lowercase_alias() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Mistral, MetricPreference::MonthlyPlan); + + let json = serde_json::to_string(&settings).expect("serialize settings"); + assert!( + json.contains(r#""mistral":"monthlyPlan""#), + "bridge spelling expected in {json}" + ); + + for spelling in ["monthlyPlan", "monthlyplan"] { + let loaded: Settings = serde_json::from_str(&format!( + r#"{{ "provider_metrics": {{ "mistral": "{spelling}" }} }}"# + )) + .expect("metric spelling parses"); + assert_eq!( + loaded.get_provider_metric(ProviderId::Mistral), + MetricPreference::MonthlyPlan, + "{spelling}" + ); + } +} + +#[test] +fn camel_case_metric_keeps_the_rest_of_the_settings_file() { + // A metric value the file parser does not know must not reset every other + // setting: the whole document used to fall back to defaults. + let loaded: Settings = serde_json::from_str( + r#"{ + "refresh_interval_secs": 60, + "provider_metrics": { "openaiapi": "tokensPerMinute" } + }"#, + ) + .expect("settings file parses"); + assert_eq!(loaded.refresh_interval_secs, 60); + assert_eq!( + loaded.get_provider_metric(ProviderId::OpenAIApi), + MetricPreference::TokensPerMinute + ); +} diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index 79f3446a6f5..ab1596c07b4 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -453,11 +453,14 @@ pub enum MetricPreference { #[serde(rename = "extraUsage", alias = "extrausage")] ExtraUsage, /// The provider's monthly plan allowance window, such as Mistral's Vibe - /// plan (upstream 0.70.0 #4072). + /// plan (upstream 0.70.0 #4072). Written camelCase like the bridge; files + /// saved with the old lowercase spelling still load. + #[serde(rename = "monthlyPlan", alias = "monthlyplan")] MonthlyPlan, Average, /// The latest reported Tokens/min (upstream 0.75.0 #4495). A text metric: /// the tray meter keeps the Automatic lane while the tray status shows the rate. + #[serde(rename = "tokensPerMinute", alias = "tokensperminute")] TokensPerMinute, }