From bdaee879f81a85f7b90793ed70862230406c3fe3 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:00:46 +0700 Subject: [PATCH 1/2] Port Copilot seat credit fallback --- .../src-tauri/src/commands/bridge.rs | 2 + .../src-tauri/src/commands/providers.rs | 1 + .../src-tauri/src/commands/settings.rs | 49 ++++++- .../src-tauri/src/usage_metric.rs | 97 ++++++++++++- apps/desktop-tauri/src/i18n/keys.ts | 3 + .../settings/providers/ProviderDetailPane.tsx | 11 ++ .../credentials/CopilotSeatCreditOptions.tsx | 77 ++++++++++ .../surfaces/settings/tabs/ProvidersTab.tsx | 1 + apps/desktop-tauri/src/types/bridge.ts | 4 + rust/src/cli/diagnose.rs | 1 + rust/src/cli/guard.rs | 1 + rust/src/cli/hooks.rs | 1 + rust/src/cli/serve/dashboard/source.rs | 2 + rust/src/cli/serve/data.rs | 1 + rust/src/cli/usage.rs | 1 + rust/src/core/provider.rs | 5 + rust/src/locale.rs | 3 + rust/src/locale/en-US.ftl | 3 + rust/src/providers/copilot/api.rs | 135 +++++++++++++++++- rust/src/providers/copilot/mod.rs | 6 +- rust/src/settings.rs | 17 +++ rust/src/settings/tests.rs | 5 + rust/src/settings/types.rs | 6 +- 23 files changed, 415 insertions(+), 17 deletions(-) create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CopilotSeatCreditOptions.tsx diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 8cd5e91924..5bff150b31 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -643,6 +643,7 @@ pub struct SettingsSnapshot { claude_daily_routines_usage_visible: bool, claude_allow_reading_claude_code_credentials: bool, alibaba_token_plan_region: String, + copilot_seat_credit_entitlement: Option, weekly_progress_work_days: Option, cost_summary_display_style: &'static str, open_codex_usage_logs_enabled: bool, @@ -762,6 +763,7 @@ impl From for SettingsSnapshot { claude_allow_reading_claude_code_credentials: settings .claude_allow_reading_claude_code_credentials, alibaba_token_plan_region: settings.alibaba_token_plan_region, + copilot_seat_credit_entitlement: settings.seat_credit_entitlement(ProviderId::Copilot), weekly_progress_work_days: settings.weekly_progress_work_days, cost_summary_display_style: cost_summary_display_style_label( settings.cost_summary_display_style, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 0b8b7269a2..c0e23e07c5 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -208,6 +208,7 @@ pub(crate) fn build_fetch_context( manual_cookie_header: cookie_header, api_key, workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), + seat_credit_entitlement: settings.seat_credit_entitlement(id), api_region: (!api_region.is_empty()).then_some(api_region), gateway_url, auto_prefer_web, diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index c24da4f5da..2abb72f791 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -75,6 +75,9 @@ pub struct SettingsUpdate { pub promote_tray_icon: Option, pub claude_daily_routines_usage_visible: Option, pub alibaba_token_plan_region: Option, + /// Optional user-entered Copilot seat AI-credit allowance; `null` clears it. + #[serde(default, deserialize_with = "deserialize_double_option")] + pub copilot_seat_credit_entitlement: Option>, pub weekly_progress_work_days: Option, pub cost_summary_display_style: Option, pub open_codex_usage_logs_enabled: Option, @@ -87,6 +90,7 @@ impl SettingsUpdate { || self.claude_daily_routines_usage_visible.is_some() || self.claude_allow_reading_claude_code_credentials.is_some() || self.alibaba_token_plan_region.is_some() + || self.copilot_seat_credit_entitlement.is_some() || self.weekly_progress_work_days.is_some() } @@ -123,6 +127,7 @@ impl SettingsUpdate { || self.menu_bar_display_mode.is_some() || self.provider_metrics.is_some() || self.codex_spark_usage_visible.is_some() + || self.copilot_seat_credit_entitlement.is_some() || self.enabled_providers.is_some() || self.ui_language.is_some() } @@ -285,7 +290,7 @@ impl SettingsUpdate { Ok(self) } - fn apply_advanced_settings(self, settings: &mut Settings) -> Self { + fn apply_advanced_settings(self, settings: &mut Settings) -> Result { if let Some(v) = self.enable_animations { settings.enable_animations = v; } @@ -364,6 +369,17 @@ impl SettingsUpdate { region.as_str(), ); } + if let Some(value) = self.copilot_seat_credit_entitlement { + if let Some(value) = value + && (!value.is_finite() || value <= 0.0) + { + return Err( + "Copilot seat AI-credit allowance must be a finite number greater than zero" + .to_string(), + ); + } + settings.set_seat_credit_entitlement(codexbar::core::ProviderId::Copilot, value); + } if let Some(v) = self.weekly_progress_work_days { settings.weekly_progress_work_days = if (2..=6).contains(&v) { Some(v) } else { None }; } @@ -374,7 +390,7 @@ impl SettingsUpdate { { settings.cost_summary_display_style = v; } - self + Ok(self) } fn float_bar_patch(&self) -> crate::floatbar::SettingsPatch { @@ -403,12 +419,19 @@ impl SettingsUpdate { .apply_general_settings(settings)? .apply_display_settings(settings) .apply_notification_settings(settings)? - .apply_advanced_settings(settings); + .apply_advanced_settings(settings)?; float_bar_patch.apply(settings); Ok(float_bar_patch) } } +fn deserialize_double_option<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Some(Option::::deserialize(deserializer)?)) +} + fn normalize_custom_sessions_dirs(dirs: Vec) -> Vec { let mut seen = HashSet::new(); let mut out = Vec::new(); @@ -567,17 +590,33 @@ mod tests { claude_allow_reading_claude_code_credentials: Some(true), ..Default::default() } - .apply_advanced_settings(&mut settings); + .apply_advanced_settings(&mut settings) + .unwrap(); assert!(settings.claude_allow_reading_claude_code_credentials); SettingsUpdate { claude_allow_reading_claude_code_credentials: Some(false), ..Default::default() } - .apply_advanced_settings(&mut settings); + .apply_advanced_settings(&mut settings) + .unwrap(); assert!(!settings.claude_allow_reading_claude_code_credentials); } + #[test] + fn copilot_seat_credit_update_distinguishes_missing_clear_and_value() { + let missing: SettingsUpdate = serde_json::from_str("{}").unwrap(); + assert_eq!(missing.copilot_seat_credit_entitlement, None); + + let clear: SettingsUpdate = + serde_json::from_str(r#"{"copilotSeatCreditEntitlement":null}"#).unwrap(); + assert_eq!(clear.copilot_seat_credit_entitlement, Some(None)); + + let value: SettingsUpdate = + serde_json::from_str(r#"{"copilotSeatCreditEntitlement":300}"#).unwrap(); + assert_eq!(value.copilot_seat_credit_entitlement, Some(Some(300.0))); + } + #[test] fn display_settings_that_affect_tray_trigger_presentation_refresh() { assert!( diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 73d642534c..9e156439f2 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -7,6 +7,8 @@ use codexbar::settings::{MetricPreference, Settings}; use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; +const COPILOT_SEAT_CREDIT_WINDOW_ID: &str = "copilot-seat-credits"; + pub(crate) fn selected_usage_window( snapshot: &ProviderUsageSnapshot, settings: &Settings, @@ -16,9 +18,16 @@ pub(crate) fn selected_usage_window( .map(|id| settings.get_provider_metric(id)) .unwrap_or_default(); - preferred_window(snapshot, provider, preference) - .or_else(|| automatic_window(snapshot, provider)) - .unwrap_or_else(|| snapshot.primary.clone()) + if let Some(selected) = preferred_window(snapshot, provider, preference) { + return selected; + } + // A configured Copilot seat allowance is an Automatic-only fallback. + // Preserve an explicit metric choice when its corresponding lane is not + // available instead of silently replacing it with seat-credit progress. + if provider == Some(ProviderId::Copilot) && preference != MetricPreference::Automatic { + return snapshot.primary.clone(); + } + automatic_window(snapshot, provider).unwrap_or_else(|| snapshot.primary.clone()) } /// Select the primary tray metric and, when there are multiple meaningful core @@ -112,21 +121,40 @@ fn automatic_window( } } if snapshot.primary.is_informational { - return weekly.cloned(); + if let Some(weekly) = weekly { + return Some(weekly.clone()); + } } } + if snapshot.primary.is_informational + && provider != Some(ProviderId::Copilot) + && snapshot.secondary.is_none() + { + return None; + } + let policy = automatic_metric_policy(provider); let mut windows = Vec::with_capacity(4 + snapshot.extra_rate_windows.len()); windows.push(&snapshot.primary); windows.extend(snapshot.secondary.iter()); windows.extend(snapshot.model_specific.iter()); windows.extend(snapshot.tertiary.iter()); + let has_real_core_window = std::iter::once(&snapshot.primary) + .chain(snapshot.secondary.iter()) + .chain(snapshot.model_specific.iter()) + .chain(snapshot.tertiary.iter()) + .any(|window| !window.is_informational); if policy.uses_extra_windows { windows.extend( snapshot .extra_rate_windows .iter() + .filter(|extra| { + provider != Some(ProviderId::Copilot) + || !has_real_core_window + || extra.id != COPILOT_SEAT_CREDIT_WINDOW_ID + }) .map(|extra| &extra.window), ); } @@ -350,6 +378,67 @@ mod tests { ); } + #[test] + fn copilot_automatic_uses_seat_credit_progress_without_metered_quota() { + let mut snapshot = snapshot(); + snapshot.provider_id = "copilot".to_string(); + snapshot.primary = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: COPILOT_SEAT_CREDIT_WINDOW_ID.to_string(), + title: "Credits used".to_string(), + window: window(35.0), + }]; + + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 35.0 + ); + } + + #[test] + fn copilot_automatic_keeps_metered_quota_authoritative_over_seat_credits() { + let mut snapshot = snapshot(); + snapshot.provider_id = "copilot".to_string(); + snapshot.primary = window(20.0); + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: COPILOT_SEAT_CREDIT_WINDOW_ID.to_string(), + title: "Credits used".to_string(), + window: window(90.0), + }]; + + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 20.0 + ); + } + + #[test] + fn copilot_explicit_session_does_not_fall_back_to_seat_credits() { + let mut snapshot = snapshot(); + snapshot.provider_id = "copilot".to_string(); + snapshot.primary = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: COPILOT_SEAT_CREDIT_WINDOW_ID.to_string(), + title: "Credits used".to_string(), + window: window(35.0), + }]; + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Copilot, MetricPreference::Session); + + let selected = selected_usage_window(&snapshot, &settings); + assert!(selected.is_informational); + assert_eq!(selected.used_percent, 0.0); + } + #[test] fn cursor_automatic_uses_semantic_monthly_lane_and_keeps_grok_bot_explicit() { let mut snapshot = snapshot(); diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index dbe5d41ac7..b1dff9c370 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -307,6 +307,9 @@ export const ALL_LOCALE_KEYS = [ "ProviderClaudeAllowReadingClaudeCodeCredentialsHelp", "ProviderCodexSparkUsage", "ProviderCodexSparkUsageHelp", + "CopilotSeatCreditTitle", + "CopilotSeatCreditHelper", + "CopilotSeatCreditInvalid", "CodexAccountsTitle", "ClaudeAccountsTitle", "ClaudeAccountsHint", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 9da9e5742d..2856b24760 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -34,6 +34,7 @@ import { UsageSourceSection } from "./sections/UsageSourceSection"; import { shouldShowCookieSource } from "./sections/usageSourcePolicy"; import { RegionSection } from "./sections/RegionSection"; import { CodexUsageOptions } from "./sections/credentials/CodexUsageOptions"; +import { CopilotSeatCreditOptions } from "./sections/credentials/CopilotSeatCreditOptions"; import { CodexAccountsSection } from "./sections/credentials/CodexAccountsSection"; import { ClaudeAccountsSection } from "./sections/credentials/ClaudeAccountsSection"; import { GrokAccountsSection } from "./sections/credentials/GrokAccountsSection"; @@ -52,6 +53,7 @@ interface Props { cookieDomain?: string | null; resetTimeRelative: boolean; providerMetrics: SettingsSnapshot["providerMetrics"]; + copilotSeatCreditEntitlement: SettingsSnapshot["copilotSeatCreditEntitlement"]; /** Per-provider accent color overrides (CLI name → hex color). */ providerAccentColors: SettingsSnapshot["providerAccentColors"]; wayfinderGatewayUrl: string; @@ -71,6 +73,7 @@ export function ProviderDetailPane({ cookieDomain = null, resetTimeRelative, providerMetrics, + copilotSeatCreditEntitlement, providerAccentColors, wayfinderGatewayUrl, settingsDisabled, @@ -350,6 +353,14 @@ export function ProviderDetailPane({ /> {detail.id === "codex" && } + {detail.id === "copilot" && ( + + )} string; + onChange: (patch: SettingsUpdate) => void; +} + +function formatValue(value: number | null | undefined): string { + return value == null ? "" : String(value); +} + +export function CopilotSeatCreditOptions({ + value, + disabled, + t, + onChange, +}: Props) { + const [draft, setDraft] = useState(() => formatValue(value)); + const [error, setError] = useState(null); + + useEffect(() => { + setDraft(formatValue(value)); + setError(null); + }, [value]); + + const commit = () => { + const trimmed = draft.trim(); + if (trimmed === "") { + setError(null); + onChange({ copilotSeatCreditEntitlement: null }); + return; + } + + const next = Number(trimmed); + if (!Number.isFinite(next) || next <= 0) { + setError(t("CopilotSeatCreditInvalid")); + return; + } + + setError(null); + onChange({ copilotSeatCreditEntitlement: next }); + }; + + return ( +
+

{t("ProviderOptionsTitle")}

+ + {error &&
{error}
} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index afa1517431..c4648281b3 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -138,6 +138,7 @@ export default function ProvidersTab({ cookieDomain={selectedEntry?.cookieDomain ?? null} resetTimeRelative={settings.resetTimeRelative} providerMetrics={settings.providerMetrics} + copilotSeatCreditEntitlement={settings.copilotSeatCreditEntitlement} providerAccentColors={settings.providerAccentColors} wayfinderGatewayUrl={settings.wayfinderGatewayUrl ?? "http://127.0.0.1:8088"} settingsDisabled={saving} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 865625efeb..ede8049af9 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -261,6 +261,8 @@ export interface SettingsSnapshot { claudeAllowReadingClaudeCodeCredentials: boolean; /** Alibaba Token Plan region: cn | intl | cn-personal | intl-personal. */ alibabaTokenPlanRegion: string; + /** Optional user-entered Copilot seat AI-credit allowance. */ + copilotSeatCreditEntitlement?: number | null; /** Optional work-week length [2,6] for session-equivalent weekly forecast. */ weeklyProgressWorkDays?: number | null; /** How cost is rendered on provider cards (#2976). */ @@ -341,6 +343,8 @@ export interface SettingsUpdate { promoteTrayIcon?: boolean; claudeDailyRoutinesUsageVisible?: boolean; alibabaTokenPlanRegion?: string; + /** Optional user-entered Copilot seat AI-credit allowance; null clears it. */ + copilotSeatCreditEntitlement?: number | null; weeklyProgressWorkDays?: number | null; costSummaryDisplayStyle?: CostSummaryDisplayStyle; openCodexUsageLogsEnabled?: boolean; diff --git a/rust/src/cli/diagnose.rs b/rust/src/cli/diagnose.rs index f97ccda8b7..10e5ca2c00 100644 --- a/rust/src/cli/diagnose.rs +++ b/rust/src/cli/diagnose.rs @@ -175,6 +175,7 @@ async fn collect_provider_diagnostic( workspace_id: settings .provider_config(provider_id) .and_then(|config| config.workspace_id.clone()), + seat_credit_entitlement: settings.seat_credit_entitlement(provider_id), api_region: settings .provider_config(provider_id) .and_then(|config| config.api_region.clone()), diff --git a/rust/src/cli/guard.rs b/rust/src/cli/guard.rs index cd3cb5693e..7e1f5c441b 100644 --- a/rust/src/cli/guard.rs +++ b/rust/src/cli/guard.rs @@ -318,6 +318,7 @@ async fn fetch_guard_outcome( manual_cookie_header: None, api_key: None, workspace_id: None, + seat_credit_entitlement: None, api_region: None, gateway_url: None, auto_prefer_web: false, diff --git a/rust/src/cli/hooks.rs b/rust/src/cli/hooks.rs index bfec1e12ff..0b77588af0 100644 --- a/rust/src/cli/hooks.rs +++ b/rust/src/cli/hooks.rs @@ -291,6 +291,7 @@ async fn hooks_watch_observation( manual_cookie_header: None, api_key: None, workspace_id: (!workspace.is_empty()).then(|| workspace.to_string()), + seat_credit_entitlement: settings.seat_credit_entitlement(provider_id), api_region: (!region.is_empty()).then(|| region.to_string()), gateway_url: (!gateway.is_empty()).then(|| gateway.to_string()), auto_prefer_web: false, diff --git a/rust/src/cli/serve/dashboard/source.rs b/rust/src/cli/serve/dashboard/source.rs index 61b491c16d..477fe5f96b 100644 --- a/rust/src/cli/serve/dashboard/source.rs +++ b/rust/src/cli/serve/dashboard/source.rs @@ -157,6 +157,7 @@ async fn fetch_provider_envelope( manual_cookie_header: None, api_key: None, workspace_id: None, + seat_credit_entitlement: None, api_region: None, gateway_url: None, auto_prefer_web: false, @@ -276,6 +277,7 @@ async fn collect_claude_accounts(claude_enabled: bool) -> Option) -> String { manual_cookie_header: None, api_key: None, workspace_id: None, + seat_credit_entitlement: None, api_region: None, gateway_url: None, auto_prefer_web: false, diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index e0c19618d9..6331c523e2 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -236,6 +236,7 @@ fn build_usage_fetch_context(args: &UsageArgs, source_mode: SourceMode) -> Fetch manual_cookie_header: None, api_key: None, workspace_id: None, + seat_credit_entitlement: None, api_region: None, gateway_url: None, auto_prefer_web: false, diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 55ab70d391..b1159e3e46 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -689,6 +689,10 @@ pub struct FetchContext { /// Optional provider workspace/project scope from persisted settings. pub workspace_id: Option, + /// Optional Copilot seat AI-credit allowance supplied by the app settings. + /// The provider keeps the credit counter unknown when this is absent. + pub seat_credit_entitlement: Option, + /// Optional provider API/web region from persisted settings. pub api_region: Option, @@ -717,6 +721,7 @@ impl Default for FetchContext { manual_cookie_header: None, api_key: None, workspace_id: None, + seat_credit_entitlement: None, api_region: None, gateway_url: None, auto_prefer_web: false, diff --git a/rust/src/locale.rs b/rust/src/locale.rs index b144f9f487..35583d4af1 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -549,6 +549,9 @@ locale_keys! { ProviderClaudeAllowReadingClaudeCodeCredentialsHelp, ProviderCodexSparkUsage, ProviderCodexSparkUsageHelp, + CopilotSeatCreditTitle, + CopilotSeatCreditHelper, + CopilotSeatCreditInvalid, CodexAccountsTitle, ClaudeAccountsTitle, ClaudeAccountsHint, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index ec1424fccb..665529ce47 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -294,6 +294,9 @@ ProviderClaudeAllowReadingClaudeCodeCredentials = Allow reading Claude Code's cr ProviderClaudeAllowReadingClaudeCodeCredentialsHelp = Lets CodexBar read (and refresh) Claude Code's own OAuth credentials for higher-fidelity usage. Off by default; without it, Auto falls back to reduced-fidelity CLI usage. ProviderCodexSparkUsage = Show Codex Spark usage ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. +CopilotSeatCreditTitle = Copilot seat AI-credit allowance +CopilotSeatCreditHelper = Optional denominator for the Copilot credits-used counter. Automatic mode uses it only when no metered quota is available. +CopilotSeatCreditInvalid = Enter a finite allowance greater than zero, or leave it empty. CodexAccountsTitle = Codex Accounts CodexAccountsHint = Choose an account for Codex. Use Refresh login to renew the ambient session. Restart running sessions after switching. CodexAccountsAddButton = Add account diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index a75e5d96b0..bd9f5fee4d 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -53,6 +53,17 @@ impl CopilotApi { self.fetch_usage_for_host(api_key, None).await } + /// Fetch usage with the optional user-entered seat-credit denominator. + /// GitHub supplies the absolute counter but not an included-credit ceiling. + pub async fn fetch_usage_with_seat_entitlement( + &self, + api_key: Option<&str>, + seat_credit_entitlement: Option, + ) -> Result { + self.fetch_usage_for_host_with_seat_entitlement(api_key, None, seat_credit_entitlement) + .await + } + /// Fetch usage information from Copilot API, optionally targeting an /// enterprise GitHub host. `github.com` maps to `api.github.com`; an /// enterprise host maps to `api.` unless it already starts with @@ -61,9 +72,24 @@ impl CopilotApi { &self, api_key: Option<&str>, github_host: Option<&str>, + ) -> Result { + self.fetch_usage_for_host_with_seat_entitlement(api_key, github_host, None) + .await + } + + async fn fetch_usage_for_host_with_seat_entitlement( + &self, + api_key: Option<&str>, + github_host: Option<&str>, + seat_credit_entitlement: Option, ) -> Result { let token = self.load_token(api_key, github_host)?; - self.fetch_usage_with_token(&token, github_host).await + self.fetch_usage_with_token_and_seat_entitlement( + &token, + github_host, + seat_credit_entitlement, + ) + .await } /// Fetch usage with an already-resolved OAuth token. @@ -71,6 +97,16 @@ impl CopilotApi { &self, token: &str, github_host: Option<&str>, + ) -> Result { + self.fetch_usage_with_token_and_seat_entitlement(token, github_host, None) + .await + } + + async fn fetch_usage_with_token_and_seat_entitlement( + &self, + token: &str, + github_host: Option<&str>, + seat_credit_entitlement: Option, ) -> Result { let api_url = copilot_usage_url(github_host); let response = self @@ -102,7 +138,7 @@ impl CopilotApi { .await .map_err(|e| ProviderError::Parse(e.to_string()))?; - snapshot_from_response(usage_response) + snapshot_from_response_with_seat_entitlement(usage_response, seat_credit_entitlement) } /// Fetch GitHub identity for labeling a stored device-OAuth token. @@ -289,7 +325,7 @@ struct QuotaSnapshot { placeholder: bool, /// Absolute AI-credit consumption counter reported for token-billed seats /// (upstream 0.48.0 #2613: `credits_used`). Kept off the rate-window path - /// on purpose — a counter has no quota denominator to render. + /// on purpose unless a user-entered seat-credit denominator is available. #[serde(default, deserialize_with = "deserialize_optional_f64")] credits_used: Option, } @@ -297,6 +333,13 @@ struct QuotaSnapshot { // --- Snapshot building --- fn snapshot_from_response(response: CopilotUsageResponse) -> Result { + snapshot_from_response_with_seat_entitlement(response, None) +} + +fn snapshot_from_response_with_seat_entitlement( + response: CopilotUsageResponse, + seat_credit_entitlement: Option, +) -> Result { let reset = response .quota_reset_date .as_deref() @@ -316,9 +359,10 @@ fn snapshot_from_response(response: CopilotUsageResponse) -> Result Result, + reset: Option>, +) { + let Some(entitlement) = + seat_credit_entitlement.filter(|value| value.is_finite() && *value > 0.0) + else { + return; + }; + if !credits_used.is_finite() || credits_used < 0.0 { + return; + } + + usage.extra_rate_windows.push(NamedRateWindow::new( + "copilot-seat-credits", + "Credits used", + RateWindow::with_details((credits_used / entitlement) * 100.0, None, reset, None), + )); +} + /// Render the absolute credits counter (whole numbers without decimals). fn format_credits_used(credits: f64) -> String { let amount = if credits.fract() == 0.0 { @@ -1071,6 +1138,62 @@ mod tests { ); } + #[test] + fn configured_seat_allowance_adds_a_numeric_credit_window() { + let response: CopilotUsageResponse = serde_json::from_str( + r#"{ + "copilot_plan": "business", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium_interactions", + "credits_used": 50 + } + } + }"#, + ) + .unwrap(); + let usage = snapshot_from_response_with_seat_entitlement(response, Some(200.0)).unwrap(); + + let seat = usage + .extra_rate_windows + .iter() + .find(|window| window.id == "copilot-seat-credits") + .expect("configured seat-credit window"); + assert!((seat.window.used_percent - 25.0).abs() < 0.001); + assert!(!seat.window.is_informational); + assert_eq!(seat.title, "Credits used"); + } + + #[test] + fn invalid_seat_allowance_keeps_credit_progress_unknown() { + let response: CopilotUsageResponse = serde_json::from_str( + r#"{ + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "credits_used": 50 + } + } + }"#, + ) + .unwrap(); + let usage = snapshot_from_response_with_seat_entitlement(response, Some(0.0)).unwrap(); + + assert!(usage.primary.is_informational); + assert!( + usage + .extra_rate_windows + .iter() + .all(|window| window.id != "copilot-seat-credits") + ); + } + #[test] fn zero_entitlement_business_seat_surfaces_credits_counter() { let usage = parse_snapshot( diff --git a/rust/src/providers/copilot/mod.rs b/rust/src/providers/copilot/mod.rs index ffca7d978e..82bbcf2893 100755 --- a/rust/src/providers/copilot/mod.rs +++ b/rust/src/providers/copilot/mod.rs @@ -63,7 +63,11 @@ impl Provider for CopilotProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching GitHub Copilot usage via GitHub OAuth"); - match self.api.fetch_usage(ctx.api_key.as_deref()).await { + match self + .api + .fetch_usage_with_seat_entitlement(ctx.api_key.as_deref(), ctx.seat_credit_entitlement) + .await + { Ok(usage) => Ok(ProviderFetchResult::new(usage, "oauth")), Err(e) => { tracing::warn!("Copilot API fetch failed: {}", e); diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 1cff42a537..efa1b9e899 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -954,6 +954,23 @@ impl Settings { self.provider_config_mut(id).workspace_id = Some(value.into()); } + /// Optional user-entered allowance for Copilot seat AI credits. + /// + /// GitHub reports the absolute `credits_used` counter but does not expose + /// a documented included-credit ceiling, so callers must keep an absent + /// or non-positive value as unknown rather than inventing a denominator. + pub fn seat_credit_entitlement(&self, id: ProviderId) -> Option { + self.provider_configs + .get(&id) + .and_then(|config| config.seat_credit_entitlement) + .filter(|value| value.is_finite() && *value > 0.0) + } + + pub fn set_seat_credit_entitlement(&mut self, id: ProviderId, value: Option) { + self.provider_config_mut(id).seat_credit_entitlement = + value.filter(|value| value.is_finite() && *value > 0.0); + } + /// Wayfinder gateway URL, defaulting to the local loopback gateway. pub fn gateway_url(&self, id: ProviderId) -> &str { self.provider_configs diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index f4a3f72d43..b9a7fa3c19 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -956,6 +956,7 @@ fn test_provider_configs_roundtrip() { settings.set_historical_tracking(ProviderId::Codex, true); settings.set_avoid_keychain_prompts(ProviderId::Claude, true); settings.set_auto_resume_after_quota_reset(ProviderId::Codex, true); + settings.set_seat_credit_entitlement(ProviderId::Copilot, Some(300.0)); let json = serde_json::to_string(&settings).unwrap(); // The legacy flat fields must NOT appear in serialized output. @@ -984,6 +985,10 @@ fn test_provider_configs_roundtrip() { assert!(loaded.historical_tracking(ProviderId::Codex)); assert!(loaded.avoid_keychain_prompts(ProviderId::Claude)); assert!(loaded.auto_resume_after_quota_reset(ProviderId::Codex)); + assert_eq!( + loaded.seat_credit_entitlement(ProviderId::Copilot), + Some(300.0) + ); assert_eq!( loaded.provider_configs.get(&ProviderId::Codex), settings.provider_configs.get(&ProviderId::Codex) diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index d7557e7a06..1a488db600 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -359,7 +359,7 @@ impl CostSummaryDisplayStyle { /// empty objects (or skip serialization entirely). Defaults are applied via /// the accessor methods on [`Settings`] (e.g. cookie source defaults to /// `"auto"`, region defaults are provider-specific). -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ProviderConfig { #[serde(skip_serializing_if = "Option::is_none")] @@ -410,4 +410,8 @@ pub struct ProviderConfig { /// means the shipped brand color is used (#2972). #[serde(skip_serializing_if = "Option::is_none")] pub accent_color: Option, + /// Optional user-entered Copilot seat AI-credit allowance. GitHub does not + /// publish this denominator; it is only used when a valid value is set. + #[serde(skip_serializing_if = "Option::is_none")] + pub seat_credit_entitlement: Option, } From d96ffe7a8875f46a155d50f41a92c0319dfc3816 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:02:14 +0700 Subject: [PATCH 2/2] Harden Copilot seat credit fallback --- .../src-tauri/src/commands/bridge.rs | 4 +- rust/src/providers/copilot/api.rs | 55 ++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 5bff150b31..7e2e35f615 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -686,6 +686,8 @@ impl From for SettingsSnapshot { .cloned() .collect(); + let copilot_seat_credit_entitlement = settings.seat_credit_entitlement(ProviderId::Copilot); + let provider_metrics = settings .provider_metrics .into_iter() @@ -763,7 +765,7 @@ impl From for SettingsSnapshot { claude_allow_reading_claude_code_credentials: settings .claude_allow_reading_claude_code_credentials, alibaba_token_plan_region: settings.alibaba_token_plan_region, - copilot_seat_credit_entitlement: settings.seat_credit_entitlement(ProviderId::Copilot), + copilot_seat_credit_entitlement, weekly_progress_work_days: settings.weekly_progress_work_days, cost_summary_display_style: cost_summary_display_style_label( settings.cost_summary_display_style, diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index bd9f5fee4d..96e6427a37 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -372,7 +372,7 @@ fn snapshot_from_response_with_seat_entitlement( let primary = primary_quota .as_ref() .map(|quota| quota.to_rate_window(reset)) - .unwrap_or_else(|| RateWindow::new(0.0)); + .unwrap_or_else(|| RateWindow::informational("No Copilot quota reported")); let mut usage = UsageSnapshot::new(primary).with_login_method(plan_label(&response.copilot_plan)); @@ -430,11 +430,15 @@ fn append_seat_credit_window( if !credits_used.is_finite() || credits_used < 0.0 { return; } + let used_percent = (credits_used / entitlement) * 100.0; + if !used_percent.is_finite() { + return; + } usage.extra_rate_windows.push(NamedRateWindow::new( "copilot-seat-credits", "Credits used", - RateWindow::with_details((credits_used / entitlement) * 100.0, None, reset, None), + RateWindow::with_details(used_percent, None, reset, None), )); } @@ -1167,6 +1171,53 @@ mod tests { assert_eq!(seat.title, "Credits used"); } + #[test] + fn missing_primary_quota_is_informational_when_seat_credit_is_available() { + let response: CopilotUsageResponse = serde_json::from_str( + r#"{ + "copilot_plan": "business", + "quota_snapshots": { + "additional_budget": { + "credits_used": 50 + } + } + }"#, + ) + .unwrap(); + let usage = snapshot_from_response_with_seat_entitlement(response, Some(200.0)).unwrap(); + + assert!(usage.primary.is_informational); + assert!( + usage + .extra_rate_windows + .iter() + .any(|window| window.id == "copilot-seat-credits") + ); + } + + #[test] + fn non_finite_derived_seat_credit_percentage_is_omitted() { + let response: CopilotUsageResponse = serde_json::from_str( + r#"{ + "copilot_plan": "business", + "quota_snapshots": { + "premium_interactions": { + "credits_used": 1e308 + } + } + }"#, + ) + .unwrap(); + let usage = snapshot_from_response_with_seat_entitlement(response, Some(1e-308)).unwrap(); + + assert!( + usage + .extra_rate_windows + .iter() + .all(|window| window.id != "copilot-seat-credits") + ); + } + #[test] fn invalid_seat_allowance_keeps_credit_progress_unknown() { let response: CopilotUsageResponse = serde_json::from_str(