diff --git a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs index 80c956d386..1cb23a40f5 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs @@ -81,11 +81,29 @@ pub fn install(app: tauri::AppHandle) { }); } -fn resolve_refresh_interval(settings: &Settings) -> Option { - if settings.adaptive_refresh { - return Some(adaptive_delay_now()); +const LOW_POWER_MIN_INTERVAL: Duration = Duration::from_secs(30 * 60); + +/// Pure upstream `BackgroundWorkPowerPolicy.automaticInterval` port: +/// floor automatic intervals to 30 minutes when low-power mode is on. +/// `None` (manual / no timer) stays `None`. +pub(crate) fn automatic_interval( + requested: Option, + low_power_mode_enabled: bool, +) -> Option { + let requested = requested?; + if !low_power_mode_enabled { + return Some(requested); } - refresh_interval(settings.refresh_interval_secs) + Some(requested.max(LOW_POWER_MIN_INTERVAL)) +} + +fn resolve_refresh_interval(settings: &Settings) -> Option { + let requested = if settings.adaptive_refresh { + Some(adaptive_delay_now()) + } else { + refresh_interval(settings.refresh_interval_secs) + }; + automatic_interval(requested, settings.low_power_mode) } fn adaptive_delay_now() -> Duration { @@ -226,6 +244,34 @@ mod tests { assert_eq!(delay, Duration::from_secs(30 * 60)); } + #[test] + fn low_power_mode_floors_fixed_and_adaptive_intervals() { + assert_eq!( + automatic_interval(Some(Duration::from_secs(60)), true), + Some(Duration::from_secs(30 * 60)) + ); + assert_eq!( + automatic_interval(Some(Duration::from_secs(3600)), true), + Some(Duration::from_secs(3600)) + ); + assert_eq!( + automatic_interval(Some(Duration::from_secs(60)), false), + Some(Duration::from_secs(60)) + ); + assert_eq!(automatic_interval(None, true), None); + + let settings = Settings { + low_power_mode: true, + adaptive_refresh: false, + refresh_interval_secs: 300, + ..Default::default() + }; + assert_eq!( + resolve_refresh_interval(&settings), + Some(Duration::from_secs(30 * 60)) + ); + } + #[test] fn fixed_cadence_advances_from_the_scheduled_tick() { let start = Instant::now(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index e15a195804..5dee459180 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -458,6 +458,7 @@ pub struct SettingsSnapshot { refresh_interval_secs: u64, adaptive_refresh: bool, refresh_all_providers_on_menu_open: bool, + low_power_mode: bool, start_at_login: bool, start_minimized: bool, show_notifications: bool, @@ -563,6 +564,7 @@ impl From for SettingsSnapshot { refresh_interval_secs: settings.refresh_interval_secs, adaptive_refresh: settings.adaptive_refresh, refresh_all_providers_on_menu_open: settings.refresh_all_providers_on_menu_open, + low_power_mode: settings.low_power_mode, start_at_login: settings.start_at_login, start_minimized: settings.start_minimized, show_notifications: settings.show_notifications, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 1e44f3346c..6b237d4325 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -84,6 +84,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::Mistral, "qoder" => ProviderId::Qoder, "sakana" => ProviderId::Sakana, + "notion" => ProviderId::Notion, _ => return None, }) } @@ -184,6 +185,7 @@ fn workspace_provider(provider_id: &str) -> Option { "devin" => ProviderId::Devin, "opencodego" => ProviderId::OpenCodeGo, "zed" => ProviderId::Zed, + "xai" => ProviderId::Xai, _ => return None, }) } @@ -548,6 +550,23 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option( + lang, + "auto", + "Automatically imports the browser session cookie.", + "", + None, + ), + cookie_option( + lang, + "manual", + "", + "Paste a full cookie header or the token_v2 value.", + None, + ), + cookie_option(lang, "off", "", "", Some("Notion cookies are disabled.")), + ], _ => Vec::new(), } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 2f4979af1b..d6e579107a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -11,6 +11,7 @@ pub struct SettingsUpdate { pub refresh_interval_secs: Option, pub adaptive_refresh: Option, pub refresh_all_providers_on_menu_open: Option, + pub low_power_mode: Option, pub start_at_login: Option, pub start_minimized: Option, pub show_notifications: Option, @@ -82,6 +83,8 @@ impl SettingsUpdate { fn notifies_float_bar(&self) -> bool { self.enabled_providers.is_some() || self.refresh_interval_secs.is_some() + || self.low_power_mode.is_some() + || self.adaptive_refresh.is_some() || self.codex_custom_sessions_dirs.is_some() || self.high_usage_threshold.is_some() || self.critical_usage_threshold.is_some() @@ -144,6 +147,9 @@ impl SettingsUpdate { if let Some(v) = self.refresh_all_providers_on_menu_open { settings.refresh_all_providers_on_menu_open = v; } + if let Some(v) = self.low_power_mode { + settings.low_power_mode = v; + } if let Some(ref s) = self.tray_icon_mode && let Some(mode) = parse_tray_icon_mode(s) { diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index b72c7eb405..efaed84e72 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -65,6 +65,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { refreshIntervalSecs: 300, adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, startAtLogin: false, startMinimized: false, showNotifications: true, diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-notion.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-notion.svg new file mode 100644 index 0000000000..671be89960 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-notion.svg @@ -0,0 +1 @@ +Notion \ No newline at end of file diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xai.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xai.svg new file mode 100644 index 0000000000..f0a69128c4 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index c442890fc2..ea2cd8db55 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -40,6 +40,8 @@ import manus from "./icons/ProviderIcon-manus.svg?raw"; import mimo from "./icons/ProviderIcon-mimo.svg?raw"; import minimax from "./icons/ProviderIcon-minimax.svg?raw"; import mistral from "./icons/ProviderIcon-mistral.svg?raw"; +import notion from "./icons/ProviderIcon-notion.svg?raw"; +import xai from "./icons/ProviderIcon-xai.svg?raw"; import ollama from "./icons/ProviderIcon-ollama.svg?raw"; import opencode from "./icons/ProviderIcon-opencode.svg?raw"; import opencodego from "./icons/ProviderIcon-opencodego.svg?raw"; @@ -118,6 +120,8 @@ const RAW: Record = { manus: tint(manus), mimo: tint(mimo), minimax: tint(minimax), + notion: tint(notion), + xai: tint(xai), mistral: tint(mistral), ollama: tint(ollama), opencode: tint(opencode), @@ -209,6 +213,8 @@ export const PROVIDER_ICON_REGISTRY: Record = { devin: { id: "devin", brandColor: "#111827", fallbackLetter: "D" }, zed: { id: "zed", brandColor: "#084ccf", fallbackLetter: "Z" }, qwencloud: { id: "qwencloud", brandColor: "#615CED", fallbackLetter: "Q" }, + notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, + xai: { id: "xai", brandColor: "#8e8e93", fallbackLetter: "X", svgPath: RAW.xai }, }; const ALIASES: Record = { @@ -221,6 +227,9 @@ const ALIASES: Record = { qwen: "qwencloud", "qwen cloud": "qwencloud", "qwen-cloud": "qwencloud", + "notion ai": "notion", + "notion-ai": "notion", + notionai: "notion", qianwen: "alibaba", "alibaba token plan": "alibabatokenplan", "alibaba-token-plan": "alibabatokenplan", @@ -268,8 +277,9 @@ const ALIASES: Record = { "azure-openai": "azureopenai", "t3 chat": "t3chat", "t3-chat": "t3chat", - xai: "grok", - "x.ai": "grok", + // xai is its own Management API provider (not an alias of consumer Grok). + "x.ai": "xai", + "x-ai": "xai", supergrok: "grok", "super-grok": "grok", "eleven labs": "elevenlabs", diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 151397c556..3c03116d93 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -94,6 +94,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { refreshIntervalSecs: 300, adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, startAtLogin: false, startMinimized: false, showNotifications: true, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index afb65e19e3..8536fa3c39 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -269,16 +269,20 @@ export default function FloatBar({ state }: { state: BootstrapState }) { // The detached floatbar should keep usage fresh, but it must not open or // focus any other surface. Refresh data only; provider-updated events feed - // this window when the backend completes. + // this window when the backend completes. Respect Low Power Mode's 30-min + // floor for automatic ticks (manual refresh stays elsewhere/immediate). useEffect(() => { - const intervalMs = Math.max(60_000, settings.refreshIntervalSecs * 1000); + const baseMs = Math.max(60_000, settings.refreshIntervalSecs * 1000); + const intervalMs = settings.lowPowerMode + ? Math.max(baseMs, 30 * 60 * 1000) + : baseMs; const tick = () => { void refreshProvidersIfStale().catch(() => {}); }; tick(); const id = setInterval(tick, intervalMs); return () => clearInterval(id); - }, [settings.refreshIntervalSecs]); + }, [settings.refreshIntervalSecs, settings.lowPowerMode]); useEffect(() => { const unlisten = listen(FLOAT_BAR_CONFIG_CHANGED_EVENT, () => { diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 1a271564e5..28811f426f 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -418,6 +418,8 @@ export const ALL_LOCALE_KEYS = [ "RefreshIntervalHelper", "RefreshAllProvidersOnMenuOpen", "RefreshAllProvidersOnMenuOpenHelper", + "LowPowerMode", + "LowPowerModeHelper", "HighUsageWarningHelper", "CriticalUsageWarningHelper", "GlobalShortcutFieldLabel", diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index ca4066b543..cec062a350 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -120,6 +120,7 @@ function settings(): SettingsSnapshot { refreshIntervalSecs: 300, adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, startAtLogin: false, startMinimized: false, showNotifications: true, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 730f013377..dcb47e0f04 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -106,6 +106,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { refreshIntervalSecs: 300, adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, startAtLogin: false, startMinimized: false, showNotifications: true, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 733157b2e2..eaabe525cf 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -21,17 +21,17 @@ const HAS_DASHBOARD = new Set([ "aiand", "commandcode", "copilot", "crof", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", - "mimo", "minimax", "mistral", "nanogpt", "ollama", "openaiapi", + "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", "opencode", "opencodego", "openrouter", "perplexity", "qoder", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", - "zai", + "xai", "zai", ]); /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ "alibabatokenplan", "amp", "augment", "azureopenai", "bedrock", "claude", "codex", "copilot", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "elevenlabs", "gemini", "grok", "groq", "kiro", "mistral", "openaiapi", - "openrouter", "vertexai", "windsurf", + "openrouter", "vertexai", "windsurf", "xai", ]); /** diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx index 3cae07d2f5..1023ea2b22 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx @@ -38,6 +38,7 @@ export function CredentialsDispatcher({ providerId, t }: Props) { case "opencodego": case "zed": case "sub2api": + case "xai": return ; default: return null; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx index 7351364d4f..47485a4205 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx @@ -114,6 +114,7 @@ const WORKSPACE_EXTRA_IDS: Record = { opencodego: true, zed: true, sub2api: true, + xai: true, }; function extraConfig(providerId: string, t: Props["t"]) { @@ -160,6 +161,13 @@ function extraConfig(providerId: string, t: Props["t"]) { placeholder: t("Sub2ApiBaseUrlPlaceholder"), help: t("Sub2ApiBaseUrlHelp"), }; + case "xai": + return { + title: "xAI team", + label: "Team ID", + placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + help: "Required. Shown in the xAI Console URL and team settings. Or set XAI_TEAM_ID. Pair with a Management API key (not an inference key).", + }; default: return null; } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index e7cfbb1bca..411a0a1e22 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -42,6 +42,7 @@ const settings: SettingsSnapshot = { refreshIntervalSecs: 300, adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, startAtLogin: false, startMinimized: false, showNotifications: true, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 0c0d42acbe..54de485c46 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -32,6 +32,7 @@ const settings: SettingsSnapshot = { refreshIntervalSecs: 300, adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, startAtLogin: false, startMinimized: false, showNotifications: true, @@ -144,6 +145,15 @@ describe("GeneralTab language picker", () => { expect(set).toHaveBeenCalledWith({ predictivePaceWarningEnabled: true }); }); + it("updates the low power mode preference", () => { + const set = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("checkbox", { name: "LowPowerMode" })); + + expect(set).toHaveBeenCalledWith({ lowPowerMode: true }); + }); + it("updates the default notification sound set", () => { const set = vi.fn(); render( diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index a524760435..e162bebdc0 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -522,6 +522,18 @@ export default function GeneralTab({ onChange={(v) => set({ refreshAllProvidersOnMenuOpen: v })} /> + + set({ lowPowerMode: v })} + /> + } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index a284e4e082..499c02bccb 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -216,6 +216,7 @@ function providerSourceHintShort( case "manus": case "mimo": case "zoommate": + case "notion": case "t3chat": case "commandcode": return t("ProviderSourceWebShort"); @@ -245,6 +246,7 @@ function providerSourceHintShort( case "deepgram": case "groq": case "llmproxy": + case "xai": return t("ProviderSourceApiShort"); case "kiro": return t("ProviderSourceKiroEnvShort"); diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 20e04840c8..595c31ce95 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -64,4 +64,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["sakana", "Sakana AI"], ["sub2api", "sub2api"], ["qwencloud", "Qwen Cloud"], + ["notion", "Notion AI"], ]; diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index 3845d01b25..2003b0eb4e 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -52,6 +52,7 @@ describe("Language type", () => { refreshIntervalSecs: 300, adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, startAtLogin: false, startMinimized: false, showNotifications: true, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index b3fd3d5d64..89e16af7a4 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -157,6 +157,7 @@ export interface SettingsSnapshot { refreshIntervalSecs: number; adaptiveRefresh: boolean; refreshAllProvidersOnMenuOpen: boolean; + lowPowerMode: boolean; startAtLogin: boolean; startMinimized: boolean; showNotifications: boolean; @@ -237,6 +238,7 @@ export interface SettingsUpdate { refreshIntervalSecs?: number; adaptiveRefresh?: boolean; refreshAllProvidersOnMenuOpen?: boolean; + lowPowerMode?: boolean; startAtLogin?: boolean; startMinimized?: boolean; showNotifications?: boolean; diff --git a/docs/PORTING.md b/docs/PORTING.md new file mode 100644 index 0000000000..d32eb8477a --- /dev/null +++ b/docs/PORTING.md @@ -0,0 +1,222 @@ +# Upstream port procedure (Windows) + +How **Win-CodexBar** tracks and ports releases from upstream +[`steipete/CodexBar`](https://github.com/steipete/CodexBar). This is a Windows +rewrite (Rust + Tauri), not a Swift fork — treat upstream as a **behavior and +wire-shape source**, not code to cherry-pick. + +## Sync baseline + +| Item | Value | +|------|--------| +| Upstream repo | `steipete/CodexBar` | +| Last landed baseline | **v0.46.0** (`e53abbeb`) | +| Current port branch | **v0.47.0** (this work) | +| PR naming | One PR per upstream release: `Port upstream CodexBar X.Y.Z` | + +Version bumps of *this* repo are a **separate later step**. A port PR lands +behavior; it does not have to ship a release tag. + +## Zero upstream contact + +- Do **not** open issues or PRs against `steipete/CodexBar`. +- Do **not** ping upstream maintainers about port status. +- Consume public release notes, compare API, and tagged sources only. + +--- + +## Procedure + +### 1. Detect the delta + +1. Read the upstream GitHub release notes for `v`. +2. Diff tags with the compare API (pin both ends — never `main`): + +```powershell +# Example: 0.46.0 → 0.47.0 +$prev = 'v0.46.0' +$new = 'v0.47.0' +Invoke-RestMethod "https://api.github.com/repos/steipete/CodexBar/compare/$prev...$new" | + Select-Object -ExpandProperty files | + Select-Object status, filename | + Format-Table -AutoSize +``` + +3. **Pin every source read to the release tag**, e.g. + +```text +https://raw.githubusercontent.com/steipete/CodexBar/v0.47.0/path/to/File.swift +``` + +Never use `main` / default-branch raw URLs for port work — they drift under you. + +### 2. Classify every release-note item + +For each bullet / merged PR in the release, assign exactly one class: + +| Class | Meaning | +|-------|---------| +| **PORT** | Has a local counterpart (provider, settings path, CLI, UI surface, fixture). | +| **SKIP** | macOS-exclusive or no Windows analog. Common skips: Keychain, iCloud/CloudKit, AppKit/SwiftUI menu chrome, `libproc`, `0600` POSIX file modes (our `secure_file` + DPAPI already covers owner-only intent). | +| **DECIDE-by-audit** | Unclear. Needs evidence before coding (see below). | + +**DECIDE-by-audit evidence** (collect before touching code): + +- Upstream file paths + symbols at the **tagged** revision +- Local counterpart path (or explicit “none”) +- Wire shape / fixture sample if network or file format is involved +- UI surface impact (tray / settings tab / float bar / none) +- Proposed class: PORT, SKIP, or **DEFER-with-evidence** + +When the upstream reference is ambiguous, prefer **DEFER-with-evidence** over a +guess-port. Example from the 0.47.0 pass: Claude cold-boot items +(`#2493` / `#2494`) were deferred — not portable as written, not silently +half-implemented. + +### 3. Split into workstreams + +Group PORT items into independent commits / mini-PRs on the port branch: + +- One provider or one vertical feature per commit when practical +- Shared infrastructure (factory, icons, settings schema) lands with the first + consumer that needs it, or as its own scoped commit if several consumers share + it +- Keep SKIP / DEFER notes in the final PR body — do not open empty stub modules + “for later” + +### 4. Port fixtures from exact wire shapes + +- Copy field names, enums, and JSON shapes from upstream’s tagged sources or + captured responses. +- **Never invent** upstream field names to make a test green. +- Prefer checked-in fixtures under the provider’s test tree; keep redaction of + secrets. + +### 5. Per-provider recipe + +New or materially changed provider — touch the full registration path: + +1. Provider module — `rust/src/providers//` (parse, auth, `fetch_usage`) +2. `ProviderId` — `rust/src/core/provider.rs` (`cli_name`, display, cookie domain, …) +3. Factory arm — `rust/src/core/provider_factory.rs` (exhaustive match) +4. Token accounts / multi-account plumbing if the provider uses it +5. Frontend catalog — `providerIcons` / `providerCatalog` (and any settings + detail UI) +6. `provider_settings` / settings schema as needed +7. Locale keys in the **existing catalog style** (machine-translated values OK) + +Worked example on this branch: + +- Notion AI — commit `4774d64a` (`Port upstream 0.47.0: Notion AI provider`) +- xAI — commit `c05d48df` (`Port upstream 0.47.0: XAI provider`) + +Also see [PROVIDERS.md](./PROVIDERS.md). + +### 6. CUA proof rule (UI-affecting ports) + +Unit tests and `local-check` do **not** prove tray, settings chrome, float bar, +theme, or WebView2 behavior. + +If the port changes any of those surfaces: + +1. Fresh local rebuild of the desktop binary +2. Proof mode as needed (`CODEXBAR_PROOF_MODE`, e.g. `settings:providers`) +3. Drive with **CUA Driver** ([trycua/cua](https://github.com/trycua/cua)); + attach screenshots / notes to the PR +4. If CUA cannot run, say why and attach equivalent manual proof + +Details: root [AGENTS.md](../AGENTS.md) (Testing & QA) and the PR template. + +### 7. Gate before PR + +```powershell +# Repo gate +.\scripts\local-check.ps1 + +# Focused provider / parser tests (example) +cargo test -p codexbar notion +cargo test -p codexbar xai + +# CLI smoke (adjust -p ids) +cargo run -p codexbar -- usage -p notion -v +cargo run -p codexbar -- config providers +``` + +UI-affecting work: CUA (or documented manual) proof on top of the above. + +### 8. PR body and follow-ups + +PR title: `Port upstream CodexBar X.Y.Z`. + +Body must list: + +- **Ported** — item + short note / commit +- **Skipped** — item + reason (macOS-only, no counterpart, …) +- **Deferred** — item + evidence pointer (issue-style notes OK inside the PR) + +Do **not** mix the Win-CodexBar version bump / changelog release cut into the +port PR unless that is an explicit separate decision. Port first; release later. + +--- + +## Conventions + +| Rule | Detail | +|------|--------| +| No upstream interaction | No issues/PRs/comments on `steipete/CodexBar` | +| Commit scope | One workstream per commit; message prefix `Port upstream X.Y.Z: …` | +| Source pin | Always `vX.Y.Z` tag URLs / compare range — never `main` | +| Locales | Add keys in existing catalog style; machine translation allowed | +| Ambiguity | **DEFER-with-evidence** > guess-port | +| Secrets | DPAPI / `secure_file` for owner-only data; do not reimplement Keychain | +| POSIX mode bits | SKIP — owner-only intent already covered on Windows | +| Stubs | No empty “coming soon” provider shells for SKIP items | + +--- + +## Appendix — Worked example: upstream 0.47.0 + +Baseline: upstream **v0.46.0** @ `e53abbeb`. Branch: `port/upstream-0.47.0`. + +### Ported + +| Item | Notes | +|------|--------| +| Notion AI provider | Full recipe; commit `4774d64a` | +| XAI provider | Full recipe; commit `c05d48df` | +| Hooks watch | `codexbar hooks watch` (#2536); commit `6495db74` | +| Low Power Mode | Settings + refresh throttling (#2518); commit `e42cb140` | +| Cursor optional on-demand usage | #2338; commit `19400339` | +| Command Code persist browser sessions | #2564; commit `f079b603` | +| OpenCode Go idle WAL read | #2544; commit `6510f30b` | +| Real-calendar monthly pace | #2552; commit `acb9c44f` | +| z.ai / Kimi / Grok window durations | #2431; commit `7cbcc775` | + +### Skipped + +| Item | Reason | +|------|--------| +| iCloud sync | CloudKit / macOS account surface — no counterpart | +| Keychain work | macOS Keychain; Windows uses DPAPI + `secure_file` | +| `libproc` usage | macOS process APIs | +| Menu SwiftUI churn | AppKit/SwiftUI menu bar — different shell (Tauri tray) | +| z.ai charts | No local chart counterpart for that change set | +| CurrencyExchange | No local counterpart | +| W5 | No local counterpart | +| W7 | No local counterpart | + +### Deferred (not portable as written) + +| Item | Reason | +|------|--------| +| Claude #2493 / #2494 (cold-boot) | DEFER-with-evidence — upstream behavior tied to macOS lifecycle / paths that do not map cleanly; do not guess-port | + +--- + +## Related + +- [ARCHITECTURE.md](./ARCHITECTURE.md) — module map and data flow +- [PROVIDERS.md](./PROVIDERS.md) — factory and add-provider checklist +- [CONFIGURATION.md](./CONFIGURATION.md) — settings stores / DPAPI paths +- [BUILDING.md](./BUILDING.md) — build and test entry points +- Root [AGENTS.md](../AGENTS.md) — CUA proof and agent rules diff --git a/rust/src/cli/hooks.rs b/rust/src/cli/hooks.rs index c52a4afd5e..084ef582f8 100644 --- a/rust/src/cli/hooks.rs +++ b/rust/src/cli/hooks.rs @@ -1,10 +1,19 @@ -//! `codexbar hooks` — list / enable / disable / test external hook rules. +//! `codexbar hooks` — list / enable / disable / test / watch external hook rules. use clap::{Args, Subcommand}; use serde::Serialize; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; -use crate::core::{HookEvent, HookEventType, HookRunner, HooksConfig, ProviderId}; -use crate::settings::Settings; +use crate::core::{ + FetchContext, HookEvent, HookEventType, HookProviderObservation, HookProviderStatus, + HookQuotaLaneKey, HookQuotaLaneObservation, HookQuotaWindow, HookRateLimiter, HookRunner, + HookTransitionDetector, HooksConfig, ProviderError, ProviderId, RateWindow, SourceMode, + instantiate_provider, +}; +use crate::settings::{ApiKeys, Settings}; +use crate::status::{StatusLevel, fetch_provider_status}; #[derive(Args, Debug, Clone)] pub struct HooksArgs { @@ -22,6 +31,8 @@ pub enum HooksCommand { Disable(HooksToggleArgs), /// Run matching rules for a sample event Test(HooksTestArgs), + /// Continuously poll providers and fire hooks on real transitions + Watch(HooksWatchArgs), } #[derive(Args, Debug, Clone)] @@ -59,6 +70,45 @@ pub struct HooksTestArgs { pub pretty: bool, } +/// Default poll period (seconds). Longer than serve cache TTL — watch originates +/// traffic against every enabled provider on every tick. +pub const HOOKS_WATCH_DEFAULT_INTERVAL: u64 = 300; +/// Floor for `--interval`. Rejected rather than clamped. +pub const HOOKS_WATCH_MINIMUM_INTERVAL: u64 = 60; +/// Sleep tick so Ctrl-C is noticed without waiting the full interval. +const HOOKS_WATCH_SLEEP_TICK: Duration = Duration::from_millis(200); + +#[derive(Args, Debug, Clone)] +pub struct HooksWatchArgs { + /// Poll period in seconds (default 300, minimum 60) + #[arg(long, default_value_t = HOOKS_WATCH_DEFAULT_INTERVAL)] + pub interval: u64, + + /// Provider CLI name(s); comma-separated or repeated. Default: enabled providers. + #[arg(long, value_delimiter = ',')] + pub provider: Vec, + + /// Emit JSON hook events + #[arg(long)] + pub json: bool, + + /// Pretty-print JSON + #[arg(long)] + pub pretty: bool, + + /// Print fetch diagnostics + #[arg(long)] + pub verbose: bool, + + /// Web fetch timeout in seconds (default 60) + #[arg(long = "web-timeout", default_value_t = 60)] + pub web_timeout: u64, + + /// Data source: auto, web, cli, oauth + #[arg(long, default_value = "auto", value_parser = ["auto", "web", "cli", "oauth"])] + pub source: String, +} + #[derive(Debug, Serialize)] struct HooksListJson { enabled: bool, @@ -93,9 +143,285 @@ pub async fn run(args: HooksArgs) -> anyhow::Result<()> { HooksCommand::Enable(a) => run_set_enabled(true, a), HooksCommand::Disable(a) => run_set_enabled(false, a), HooksCommand::Test(a) => run_test(a), + HooksCommand::Watch(a) => run_watch(a).await, + } +} + +/// Continuously poll providers and dispatch hooks on real quota/status transitions. +async fn run_watch(args: HooksWatchArgs) -> anyhow::Result<()> { + // Validate command-only args before reading config (upstream ordering). + let interval = decode_hooks_watch_interval(args.interval)?; + let explicit = decode_hooks_watch_providers(&args.provider)?; + + let hooks = HooksConfig::load(); + let settings = Settings::load(); + let providers = resolve_hooks_watch_providers(explicit, &settings)?; + + if !hooks.enabled { + anyhow::bail!("Hooks are disabled. Run `codexbar hooks enable` first."); + } + if hooks.events.is_empty() { + anyhow::bail!("No hook rules configured. See `codexbar hooks list`."); + } + + let stop = Arc::new(AtomicBool::new(false)); + let stop_for_signal = Arc::clone(&stop); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + stop_for_signal.store(true, Ordering::SeqCst); + }); + + let mut detector = HookTransitionDetector::new(); + let rate_limiter = HookRateLimiter::default(); + let source_mode = SourceMode::parse(&args.source).unwrap_or(SourceMode::Auto); + + if !args.json { + let names = providers + .iter() + .map(|p| p.cli_name()) + .collect::>() + .join(", "); + println!( + "Watching {} provider(s) every {}s: {}", + providers.len(), + interval.as_secs(), + names + ); + println!("Press Ctrl-C to stop."); + } + + while !stop.load(Ordering::SeqCst) { + for provider in &providers { + if stop.load(Ordering::SeqCst) { + break; + } + let observation = hooks_watch_observation( + *provider, + &settings, + source_mode, + args.web_timeout, + args.verbose, + ) + .await; + + let dispatches = detector.evaluate(&observation, &hooks); + for dispatch in dispatches { + report_hook_event(&dispatch.event, args.json, args.pretty)?; + let dispatch_config = match &dispatch.rules { + Some(rules) => HooksConfig { + enabled: true, + events: rules.clone(), + }, + None => hooks.clone(), + }; + HookRunner::dispatch(&dispatch.event, &dispatch_config, &rate_limiter); + } + } + + if stop.load(Ordering::SeqCst) { + break; + } + sleep_interruptibly(interval, &stop).await; + } + + Ok(()) +} + +fn decode_hooks_watch_interval(raw: u64) -> anyhow::Result { + if raw < HOOKS_WATCH_MINIMUM_INTERVAL { + anyhow::bail!( + "--interval must be at least {} seconds.", + HOOKS_WATCH_MINIMUM_INTERVAL + ); + } + Ok(Duration::from_secs(raw)) +} + +fn decode_hooks_watch_providers(names: &[String]) -> anyhow::Result>> { + if names.is_empty() { + return Ok(None); + } + let mut selected = Vec::new(); + for name in names { + let id = ProviderId::from_cli_name(name) + .ok_or_else(|| anyhow::anyhow!("Unknown provider: {name}"))?; + if !selected.contains(&id) { + selected.push(id); + } + } + Ok(Some(selected)) +} + +fn resolve_hooks_watch_providers( + explicit: Option>, + settings: &Settings, +) -> anyhow::Result> { + if let Some(list) = explicit { + return Ok(list); + } + let enabled = settings.get_enabled_provider_ids(); + if enabled.is_empty() { + anyhow::bail!("No providers are enabled."); + } + Ok(enabled) +} + +async fn hooks_watch_observation( + provider_id: ProviderId, + settings: &Settings, + source_mode: SourceMode, + web_timeout: u64, + verbose: bool, +) -> HookProviderObservation { + let status = match fetch_provider_status(provider_id.cli_name()).await { + Some(s) => map_status_level(s.level), + None => HookProviderStatus::Unknown, + }; + + let workspace = settings.workspace_id(provider_id); + let region = settings.api_region(provider_id); + let gateway = settings.gateway_url(provider_id); + + let mut ctx = FetchContext { + source_mode, + include_credits: false, + web_timeout, + verbose, + manual_cookie_header: None, + api_key: None, + workspace_id: (!workspace.is_empty()).then(|| workspace.to_string()), + api_region: (!region.is_empty()).then(|| region.to_string()), + gateway_url: (!gateway.is_empty()).then(|| gateway.to_string()), + auto_prefer_web: false, + }; + + if ctx.api_key.is_none() { + ctx.api_key = ApiKeys::load() + .get(provider_id.cli_name()) + .map(|s| s.to_string()); + } + + let provider = instantiate_provider(provider_id); + match provider.fetch_usage(&ctx).await { + Ok(result) => { + let usage = &result.usage; + let account = usage.account_email.clone(); + HookProviderObservation { + provider: provider_id.cli_name().to_string(), + lanes: hooks_watch_lanes(provider_id, usage, settings, account.as_deref()), + status, + refresh_failure_status: None, + account_display_name: account, + } + } + Err(err) => HookProviderObservation { + provider: provider_id.cli_name().to_string(), + lanes: Vec::new(), + status, + refresh_failure_status: Some(hook_refresh_failure_status(&err)), + account_display_name: None, + }, + } +} + +fn hooks_watch_lanes( + provider_id: ProviderId, + usage: &crate::core::UsageSnapshot, + settings: &Settings, + account: Option<&str>, +) -> Vec { + let mut lanes = Vec::new(); + let pairs: [(HookQuotaWindow, Option<&RateWindow>); 2] = [ + (HookQuotaWindow::Session, Some(&usage.primary)), + (HookQuotaWindow::Weekly, usage.secondary.as_ref()), + ]; + for (window, rate_window) in pairs { + let Some(rate_window) = rate_window else { + continue; + }; + if rate_window.is_informational { + continue; + } + let thresholds = settings.usage_thresholds(provider_id, window.as_str()); + // Settings store *used* percentages; detector math uses used fractions. + let fallback = [thresholds.high / 100.0, thresholds.critical / 100.0]; + lanes.push(HookQuotaLaneObservation { + key: HookQuotaLaneKey::new( + provider_id.cli_name(), + window, + account.map(str::to_string), + None, + ), + label: window.display_name().to_string(), + rate_window: Some(rate_window.clone()), + fallback_thresholds: fallback.to_vec(), + account_display_name: account.map(str::to_string), + }); + } + lanes +} + +fn map_status_level(level: StatusLevel) -> HookProviderStatus { + match level { + StatusLevel::Operational => HookProviderStatus::None, + StatusLevel::Degraded => HookProviderStatus::Minor, + StatusLevel::Partial => HookProviderStatus::Major, + StatusLevel::Major => HookProviderStatus::Critical, + StatusLevel::Unknown => HookProviderStatus::Unknown, + } +} + +/// 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::Timeout => "timeout".into(), + ProviderError::Network(err) => { + if err.is_timeout() { + "timeout".into() + } else if err.is_connect() { + "offline".into() + } else { + "network_error".into() + } + } + ProviderError::NotInstalled(_) => "error".into(), + ProviderError::Parse(_) | ProviderError::UnsupportedSource(_) | ProviderError::Other(_) => { + "error".into() + } } } +async fn sleep_interruptibly(interval: Duration, stop: &AtomicBool) { + let mut remaining = interval; + while remaining > Duration::ZERO && !stop.load(Ordering::SeqCst) { + let tick = remaining.min(HOOKS_WATCH_SLEEP_TICK); + tokio::time::sleep(tick).await; + remaining = remaining.saturating_sub(tick); + } +} + +fn report_hook_event(event: &HookEvent, json: bool, pretty: bool) -> anyhow::Result<()> { + if json { + print_json(event, pretty)?; + return Ok(()); + } + let mut line = format!("{} {}", event.event.as_str(), event.provider); + if let Some(window) = &event.window { + line.push_str(&format!(" window={window}")); + } + if let Some(usage) = event.usage_percent { + line.push_str(&format!(" usage={:.0}%", usage * 100.0)); + } + if let Some(status) = &event.status { + line.push_str(&format!(" status={status}")); + } + println!("{line}"); + Ok(()) +} + fn run_list(args: HooksListArgs) -> anyhow::Result<()> { let config = HooksConfig::load(); let settings = Settings::load(); @@ -317,4 +643,24 @@ mod tests { assert_eq!(e.provider, "claude"); assert!(e.environment_variables().contains_key("CODEXBAR_PROVIDER")); } + + #[test] + fn watch_interval_rejects_below_floor() { + assert!(decode_hooks_watch_interval(59).is_err()); + assert!(decode_hooks_watch_interval(60).is_ok()); + assert_eq!( + decode_hooks_watch_interval(300).unwrap(), + Duration::from_secs(300) + ); + } + + #[test] + fn watch_providers_reject_unknown_and_dedupe() { + assert!(decode_hooks_watch_providers(&["nosuch".into()]).is_err()); + let got = decode_hooks_watch_providers(&["codex".into(), "codex".into()]) + .unwrap() + .unwrap(); + assert_eq!(got, vec![ProviderId::Codex]); + assert!(decode_hooks_watch_providers(&[]).unwrap().is_none()); + } } diff --git a/rust/src/core/hook_transition.rs b/rust/src/core/hook_transition.rs new file mode 100644 index 0000000000..75b4f3938b --- /dev/null +++ b/rust/src/core/hook_transition.rs @@ -0,0 +1,1111 @@ +//! Hook transition detector (upstream #2536 / `HookTransitionDetector`). +//! +//! Turns successive provider observations into edge-triggered hook events. +//! Platform-neutral and side-effect free: decides *what fired* and never +//! fetches or runs commands. State is in-memory only — a restart starts fresh +//! and the first sample of any lane establishes a baseline without firing. +//! +//! # Rules implemented +//! +//! - **Baseline-only first sample**: first reading of a lane/status never fires. +//! - **quota_low**: fires only when usage fraction crosses a watched threshold +//! upward (`previous < t && current >= t`). Rules with an explicit `threshold` +//! watch only that value; rules without one use the lane's +//! `fallback_thresholds` (provider notification thresholds as used fractions). +//! - **quota_low rule narrowing**: only rules whose own threshold crossed this +//! poll are attached to the dispatch (avoids re-firing lower thresholds). +//! - **quota_reached**: session lane only; fires on upward edge into +//! `reached_threshold` (default 1.0). Weekly lanes never fire `quota_reached`. +//! - **quota_reset**: fires when the reset boundary advances +//! (`current_resets_at > previous_resets_at`) **or** usage drops by at least +//! `reset_drop_threshold` (default 0.2). A reset suppresses depletion edges +//! (`quota_low` / `quota_reached`) in the same poll. +//! - **provider_unavailable / provider_recovered**: edge on definite outage +//! state (`minor`/`major`/`critical` ↔ `none`). `maintenance` and `unknown` +//! never flip tracked state. +//! - **refresh_failed**: emits a coarse failure status without disturbing quota +//! or status baselines. +//! - **Lane lifecycle**: synthetic/informational or missing lanes forget their +//! baseline; lanes that disappear between polls are pruned so reappearance +//! starts fresh. +//! - **Config revision**: `reset_if_configuration_changed` clears all baselines +//! so rule edits do not fire for crossings that spanned the change. +//! - **Disabled / over-capacity config**: `enabled == false` or more than +//! `HooksConfig::MAX_RULES` rules → no events. + +use chrono::{DateTime, Utc}; +use std::collections::{HashMap, HashSet}; + +use super::hooks::{HookEvent, HookEventType, HookRule, HooksConfig}; +use super::rate_window::RateWindow; + +/// Identifies one quota lane for hook transition tracking. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct HookQuotaLaneKey { + pub provider: String, + pub window: HookQuotaWindow, + pub account_discriminator: Option, + pub window_id: Option, +} + +impl HookQuotaLaneKey { + pub fn new( + provider: impl Into, + window: HookQuotaWindow, + account_discriminator: Option, + window_id: Option, + ) -> Self { + Self { + provider: provider.into(), + window, + account_discriminator, + window_id, + } + } +} + +/// Quota lane kind mirrored from upstream `QuotaWarningWindow`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HookQuotaWindow { + Session, + Weekly, +} + +impl HookQuotaWindow { + pub fn as_str(self) -> &'static str { + match self { + Self::Session => "session", + Self::Weekly => "weekly", + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::Session => "Session", + Self::Weekly => "Weekly", + } + } +} + +/// One quota lane observed in a single poll. +#[derive(Debug, Clone)] +pub struct HookQuotaLaneObservation { + pub key: HookQuotaLaneKey, + /// Display label for the event payload (e.g. "Session", "Weekly"). + pub label: String, + /// `None` means the lane was not reported this poll. + pub rate_window: Option, + /// Provider notification thresholds as usage fractions (0…1). + pub fallback_thresholds: Vec, + pub account_display_name: Option, +} + +/// Coarse provider availability, mirroring status-indicator semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HookProviderStatus { + None, + Minor, + Major, + Critical, + Maintenance, + #[default] + Unknown, +} + +impl HookProviderStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Minor => "minor", + Self::Major => "major", + Self::Critical => "critical", + Self::Maintenance => "maintenance", + Self::Unknown => "unknown", + } + } + + /// `maintenance` and `unknown` never flip tracked state. + pub fn outage_state(self) -> Option { + match self { + Self::Minor | Self::Major | Self::Critical => Some(true), + Self::None => Some(false), + Self::Maintenance | Self::Unknown => None, + } + } +} + +/// Everything observed for one provider in a single poll. +#[derive(Debug, Clone)] +pub struct HookProviderObservation { + pub provider: String, + pub lanes: Vec, + pub status: HookProviderStatus, + /// Coarse failure category when the refresh itself failed (never a raw error). + pub refresh_failure_status: Option, + pub account_display_name: Option, +} + +impl HookProviderObservation { + pub fn new(provider: impl Into) -> Self { + Self { + provider: provider.into(), + lanes: Vec::new(), + status: HookProviderStatus::Unknown, + refresh_failure_status: None, + account_display_name: None, + } + } +} + +/// One event to dispatch, optionally narrowed to specific rules (`quota_low`). +#[derive(Debug, Clone)] +pub struct HookDispatch { + pub event: HookEvent, + /// When set (currently only for `quota_low`), only these rules should run. + pub rules: Option>, +} + +#[derive(Debug, Clone, Copy)] +struct LaneSample { + usage: f64, + resets_at: Option>, +} + +/// Turns successive provider observations into hook events. +#[derive(Debug, Default)] +pub struct HookTransitionDetector { + window_observation: HashMap, + provider_status_had_issue: HashMap, + config_revision: Option, + reached_threshold: f64, + reset_drop_threshold: f64, +} + +impl HookTransitionDetector { + pub fn new() -> Self { + Self { + reached_threshold: 1.0, + reset_drop_threshold: 0.2, + ..Self::default() + } + } + + pub fn with_thresholds(reached_threshold: f64, reset_drop_threshold: f64) -> Self { + Self { + reached_threshold, + reset_drop_threshold, + ..Self::default() + } + } + + /// Drops every baseline when the hook configuration changed. + pub fn reset_if_configuration_changed(&mut self, revision: i64) { + if self.config_revision == Some(revision) { + return; + } + self.window_observation.clear(); + self.provider_status_had_issue.clear(); + self.config_revision = Some(revision); + } + + /// Evaluates one poll of one provider and returns the events to dispatch. + pub fn evaluate( + &mut self, + observation: &HookProviderObservation, + config: &HooksConfig, + ) -> Vec { + self.evaluate_at(observation, config, Utc::now()) + } + + pub fn evaluate_at( + &mut self, + observation: &HookProviderObservation, + config: &HooksConfig, + now: DateTime, + ) -> Vec { + if !config.enabled || config.events.len() > HooksConfig::MAX_RULES { + return Vec::new(); + } + + if let Some(failure) = observation.refresh_failure_status.as_deref() { + let event = HookEvent::new(HookEventType::RefreshFailed, observation.provider.clone()) + .with_status(failure) + .with_timestamp(now); + let event = match &observation.account_display_name { + Some(account) => event.with_account(account.clone()), + None => event, + }; + // Failed refresh must not disturb baselines. + return vec![HookDispatch { event, rules: None }]; + } + + let mut dispatches = self.status_events(observation, now); + + let observed_keys: HashSet = + observation.lanes.iter().map(|l| l.key.clone()).collect(); + for lane in &observation.lanes { + dispatches.extend(self.lane_events(lane, &observation.provider, config, now)); + } + self.prune_lanes(&observation.provider, &observed_keys); + + dispatches + } + + fn status_events( + &mut self, + observation: &HookProviderObservation, + now: DateTime, + ) -> Vec { + let Some(is_outage) = observation.status.outage_state() else { + return Vec::new(); + }; + let previous = self + .provider_status_had_issue + .insert(observation.provider.clone(), is_outage); + let Some(previous) = previous else { + return Vec::new(); + }; + if previous == is_outage { + return Vec::new(); + } + + let event_type = if is_outage { + HookEventType::ProviderUnavailable + } else { + HookEventType::ProviderRecovered + }; + let event = HookEvent::new(event_type, observation.provider.clone()) + .with_status(observation.status.as_str()) + .with_timestamp(now); + let event = match &observation.account_display_name { + Some(account) => event.with_account(account.clone()), + None => event, + }; + vec![HookDispatch { event, rules: None }] + } + + fn lane_events( + &mut self, + lane: &HookQuotaLaneObservation, + provider: &str, + config: &HooksConfig, + now: DateTime, + ) -> Vec { + // Informational / synthetic stand-ins carry no usage to compare. Forget + // so a later real reading starts fresh. + let Some(rate_window) = lane.rate_window.as_ref() else { + self.window_observation.remove(&lane.key); + return Vec::new(); + }; + if rate_window.is_informational { + self.window_observation.remove(&lane.key); + return Vec::new(); + } + + let current = (rate_window.used_percent / 100.0).clamp(0.0, 1.0); + let previous_sample = self.window_observation.insert( + lane.key.clone(), + LaneSample { + usage: current, + resets_at: rate_window.resets_at, + }, + ); + + let Some(previous) = previous_sample else { + return Vec::new(); + }; + + if let Some(reset_event) = + self.reset_event(lane, provider, previous, current, rate_window, now) + { + return vec![HookDispatch { + event: reset_event, + rules: None, + }]; + } + + let mut dispatches = self.quota_low_events(lane, provider, previous, current, config, now); + + if lane.key.window == HookQuotaWindow::Session + && previous.usage < self.reached_threshold + && current >= self.reached_threshold + { + dispatches.push(HookDispatch { + event: build_lane_event(HookEventType::QuotaReached, provider, lane, current, now), + rules: None, + }); + } + + dispatches + } + + fn reset_event( + &self, + lane: &HookQuotaLaneObservation, + provider: &str, + previous: LaneSample, + current: f64, + rate_window: &RateWindow, + now: DateTime, + ) -> Option { + let boundary_moved = match (previous.resets_at, rate_window.resets_at) { + (Some(prev), Some(curr)) => curr > prev, + _ => false, + }; + let usage_dropped = previous.usage - current >= self.reset_drop_threshold; + if !boundary_moved && !usage_dropped { + return None; + } + Some(build_lane_event( + HookEventType::QuotaReset, + provider, + lane, + current, + now, + )) + } + + fn quota_low_events( + &self, + lane: &HookQuotaLaneObservation, + provider: &str, + previous: LaneSample, + current: f64, + config: &HooksConfig, + now: DateTime, + ) -> Vec { + let rules: Vec<&HookRule> = config + .events + .iter() + .filter(|rule| { + rule.enabled + && rule_watches_quota_low(rule) + && (rule.provider.is_none() || rule.provider.as_deref() == Some(provider)) + }) + .collect(); + if rules.is_empty() { + return Vec::new(); + } + + let crossed: Vec = rules + .into_iter() + .filter(|rule| { + quota_low_threshold_crossed( + rule.threshold, + previous.usage, + current, + &lane.fallback_thresholds, + ) + }) + .cloned() + .collect(); + if crossed.is_empty() { + return Vec::new(); + } + + vec![HookDispatch { + event: build_lane_event(HookEventType::QuotaLow, provider, lane, current, now), + rules: Some(crossed), + }] + } + + fn prune_lanes(&mut self, provider: &str, keeping: &HashSet) { + self.window_observation + .retain(|key, _| key.provider != provider || keeping.contains(key)); + } +} + +fn rule_watches_quota_low(rule: &HookRule) -> bool { + rule.event == Some(HookEventType::QuotaLow) || rule.events.contains(&HookEventType::QuotaLow) +} + +/// Returns true when any watched threshold was crossed upward. +pub fn quota_low_threshold_crossed( + rule_threshold: Option, + previous_usage: f64, + current_usage: f64, + fallback_thresholds: &[f64], +) -> bool { + let watched: Vec = match rule_threshold { + Some(t) => vec![t], + None => fallback_thresholds.to_vec(), + }; + watched + .into_iter() + .any(|t| previous_usage < t && current_usage >= t) +} + +fn build_lane_event( + event_type: HookEventType, + provider: &str, + lane: &HookQuotaLaneObservation, + usage_fraction: f64, + now: DateTime, +) -> HookEvent { + let mut event = HookEvent::new(event_type, provider) + .with_window(lane.label.clone()) + .with_usage_fraction(usage_fraction) + .with_timestamp(now); + if let Some(account) = &lane.account_display_name { + event = event.with_account(account.clone()); + } + event +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use std::path::PathBuf; + + const PROVIDER: &str = "codex"; + + fn lane_key(window: HookQuotaWindow, account: Option<&str>) -> HookQuotaLaneKey { + HookQuotaLaneKey::new(PROVIDER, window, account.map(str::to_string), None) + } + + fn rate_window(used_percent: f64, resets_at: Option>) -> RateWindow { + let mut w = RateWindow::new(used_percent); + w.window_minutes = Some(300); + w.resets_at = resets_at; + w + } + + fn informational_window(used_percent: f64) -> RateWindow { + let mut w = RateWindow::informational("placeholder"); + w.used_percent = used_percent; + w + } + + fn lane( + used_percent: Option, + key: HookQuotaLaneKey, + resets_at: Option>, + thresholds: &[f64], + informational: bool, + ) -> HookQuotaLaneObservation { + let label = key.window.display_name().to_string(); + let account = key.account_discriminator.clone(); + HookQuotaLaneObservation { + key, + label, + rate_window: used_percent.map(|p| { + if informational { + informational_window(p) + } else { + rate_window(p, resets_at) + } + }), + fallback_thresholds: thresholds.to_vec(), + account_display_name: account, + } + } + + fn observation( + lanes: Vec, + status: HookProviderStatus, + refresh_failure: Option<&str>, + ) -> HookProviderObservation { + HookProviderObservation { + provider: PROVIDER.into(), + lanes, + status, + refresh_failure_status: refresh_failure.map(str::to_string), + account_display_name: None, + } + } + + fn rule(event: HookEventType, threshold: Option, provider: Option<&str>) -> HookRule { + HookRule { + enabled: true, + event: Some(event), + events: Vec::new(), + provider: provider.map(str::to_string), + threshold, + executable: PathBuf::from("/bin/true"), + arguments: Vec::new(), + timeout_secs: 10, + } + } + + fn config(enabled: bool, rules: Option>) -> HooksConfig { + HooksConfig { + enabled, + events: rules.unwrap_or_else(|| { + vec![ + rule(HookEventType::QuotaLow, None, None), + rule(HookEventType::QuotaReached, None, None), + rule(HookEventType::QuotaReset, None, None), + rule(HookEventType::ProviderUnavailable, None, None), + rule(HookEventType::ProviderRecovered, None, None), + rule(HookEventType::RefreshFailed, None, None), + ] + }), + } + } + + fn events_of(dispatches: &[HookDispatch]) -> Vec { + dispatches.iter().map(|d| d.event.event).collect() + } + + #[test] + fn first_sample_establishes_baseline_without_firing() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let dispatches = detector.evaluate( + &observation( + vec![lane( + Some(95.0), + lane_key(HookQuotaWindow::Session, None), + None, + &[0.8], + false, + )], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!(dispatches.is_empty()); + } + + #[test] + fn quota_low_fires_once_on_upward_crossing() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(50.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + + let crossing = detector.evaluate( + &observation( + vec![lane(Some(85.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert_eq!(events_of(&crossing), vec![HookEventType::QuotaLow]); + + let persisting = detector.evaluate( + &observation( + vec![lane(Some(90.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!(persisting.is_empty()); + } + + #[test] + fn quota_low_dispatches_only_rule_whose_threshold_crossed() { + let mut detector = HookTransitionDetector::new(); + let cfg = config( + true, + Some(vec![ + rule(HookEventType::QuotaLow, Some(0.5), None), + rule(HookEventType::QuotaLow, Some(0.8), None), + ]), + ); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(60.0), key.clone(), None, &[], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(85.0), key, None, &[], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert_eq!(dispatches.len(), 1); + let rules = dispatches[0].rules.as_ref().expect("narrowed rules"); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].threshold, Some(0.8)); + } + + #[test] + fn quota_reached_fires_on_upward_edge_only() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(90.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + + let reached = detector.evaluate( + &observation( + vec![lane(Some(100.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!( + reached + .iter() + .any(|d| d.event.event == HookEventType::QuotaReached) + ); + + let still_full = detector.evaluate( + &observation( + vec![lane(Some(100.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!( + !still_full + .iter() + .any(|d| d.event.event == HookEventType::QuotaReached) + ); + } + + #[test] + fn quota_reached_never_fires_for_weekly_lane() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Weekly, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(90.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(100.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!( + !dispatches + .iter() + .any(|d| d.event.event == HookEventType::QuotaReached) + ); + } + + #[test] + fn quota_reset_fires_when_reset_boundary_advances() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + let first = Utc.timestamp_opt(1_000_000, 0).unwrap(); + let second = first + chrono::Duration::seconds(18_000); + + let _ = detector.evaluate( + &observation( + vec![lane(Some(100.0), key.clone(), Some(first), &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(0.0), key, Some(second), &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert_eq!(events_of(&dispatches), vec![HookEventType::QuotaReset]); + } + + #[test] + fn quota_reset_fires_on_usage_drop_without_boundary() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(95.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(10.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert_eq!(events_of(&dispatches), vec![HookEventType::QuotaReset]); + } + + #[test] + fn reset_suppresses_depletion_edge_in_same_poll() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(95.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(5.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!( + !dispatches + .iter() + .any(|d| d.event.event == HookEventType::QuotaReached) + ); + assert!( + !dispatches + .iter() + .any(|d| d.event.event == HookEventType::QuotaLow) + ); + } + + #[test] + fn provider_status_fires_outage_and_recovery_edges() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let _ = detector.evaluate(&observation(vec![], HookProviderStatus::None, None), &cfg); + + let outage = detector.evaluate(&observation(vec![], HookProviderStatus::Major, None), &cfg); + assert_eq!(events_of(&outage), vec![HookEventType::ProviderUnavailable]); + + let persisting = detector.evaluate( + &observation(vec![], HookProviderStatus::Critical, None), + &cfg, + ); + assert!(persisting.is_empty()); + + let recovered = + detector.evaluate(&observation(vec![], HookProviderStatus::None, None), &cfg); + assert_eq!( + events_of(&recovered), + vec![HookEventType::ProviderRecovered] + ); + } + + #[test] + fn unknown_and_maintenance_never_flip_status_state() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let _ = detector.evaluate(&observation(vec![], HookProviderStatus::None, None), &cfg); + + assert!( + detector + .evaluate( + &observation(vec![], HookProviderStatus::Unknown, None), + &cfg + ) + .is_empty() + ); + assert!( + detector + .evaluate( + &observation(vec![], HookProviderStatus::Maintenance, None), + &cfg + ) + .is_empty() + ); + + let outage = detector.evaluate(&observation(vec![], HookProviderStatus::Major, None), &cfg); + assert_eq!(events_of(&outage), vec![HookEventType::ProviderUnavailable]); + } + + #[test] + fn first_definite_status_does_not_fire() { + let mut detector = HookTransitionDetector::new(); + let dispatches = detector.evaluate( + &observation(vec![], HookProviderStatus::Critical, None), + &config(true, None), + ); + assert!(dispatches.is_empty()); + } + + #[test] + fn refresh_failure_emits_coarse_status_only() { + let mut detector = HookTransitionDetector::new(); + let dispatches = detector.evaluate( + &observation(vec![], HookProviderStatus::Unknown, Some("timeout")), + &config(true, None), + ); + assert_eq!(events_of(&dispatches), vec![HookEventType::RefreshFailed]); + assert_eq!(dispatches[0].event.status.as_deref(), Some("timeout")); + } + + #[test] + fn refresh_failure_does_not_disturb_quota_baselines() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(50.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let _ = detector.evaluate( + &observation(vec![], HookProviderStatus::Unknown, Some("offline")), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(85.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!( + dispatches + .iter() + .any(|d| d.event.event == HookEventType::QuotaLow) + ); + } + + #[test] + fn disabled_hooks_produce_no_events() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(false, None); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(50.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(95.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!(dispatches.is_empty()); + } + + #[test] + fn configuration_change_clears_baselines() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + detector.reset_if_configuration_changed(1); + let _ = detector.evaluate( + &observation( + vec![lane(Some(50.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + detector.reset_if_configuration_changed(2); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(95.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!(dispatches.is_empty()); + } + + #[test] + fn synthetic_placeholder_lane_never_fires() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(50.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(100.0), key, None, &[0.8], true)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!(dispatches.is_empty()); + } + + #[test] + fn disappearing_lane_resets_baseline() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(50.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let _ = detector.evaluate( + &observation(vec![], HookProviderStatus::Unknown, None), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(95.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!(dispatches.is_empty()); + } + + #[test] + fn accounts_on_same_provider_track_independently() { + let mut detector = HookTransitionDetector::new(); + let cfg = config(true, None); + let first = lane_key(HookQuotaWindow::Session, Some("a@example.com")); + let second = lane_key(HookQuotaWindow::Session, Some("b@example.com")); + let _ = detector.evaluate( + &observation( + vec![ + lane(Some(50.0), first.clone(), None, &[0.8], false), + lane(Some(50.0), second.clone(), None, &[0.8], false), + ], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![ + lane(Some(85.0), first, None, &[0.8], false), + lane(Some(55.0), second, None, &[0.8], false), + ], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].event.event, HookEventType::QuotaLow); + } + + #[test] + fn quota_low_respects_explicit_rule_threshold_over_fallback() { + let mut detector = HookTransitionDetector::new(); + let cfg = config( + true, + Some(vec![rule(HookEventType::QuotaLow, Some(0.9), None)]), + ); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(50.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let below = detector.evaluate( + &observation( + vec![lane(Some(85.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!(below.is_empty()); + let above = detector.evaluate( + &observation( + vec![lane(Some(95.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert_eq!(events_of(&above), vec![HookEventType::QuotaLow]); + } + + #[test] + fn quota_low_ignores_rules_scoped_to_another_provider() { + let mut detector = HookTransitionDetector::new(); + let cfg = config( + true, + Some(vec![rule(HookEventType::QuotaLow, None, Some("claude"))]), + ); + let key = lane_key(HookQuotaWindow::Session, None); + let _ = detector.evaluate( + &observation( + vec![lane(Some(50.0), key.clone(), None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + let dispatches = detector.evaluate( + &observation( + vec![lane(Some(95.0), key, None, &[0.8], false)], + HookProviderStatus::Unknown, + None, + ), + &cfg, + ); + assert!(dispatches.is_empty()); + } + + #[test] + fn rate_limiter_suppresses_duplicate_refresh_failed() { + use super::super::hooks::HookRateLimiter; + use std::time::Duration; + + let limiter = HookRateLimiter::new(Duration::from_secs(600)); + let event = HookEvent::new(HookEventType::RefreshFailed, "codex").with_status("timeout"); + assert!(limiter.allow(&event)); + assert!(!limiter.allow(&event)); + + // Quota events are not rate-limited by HookEventType::is_rate_limited. + assert!(!HookEventType::QuotaLow.is_rate_limited()); + assert!(HookEventType::RefreshFailed.is_rate_limited()); + assert!(HookEventType::ProviderUnavailable.is_rate_limited()); + } +} diff --git a/rust/src/core/hooks.rs b/rust/src/core/hooks.rs index 9fc643b09e..da4979e0a1 100644 --- a/rust/src/core/hooks.rs +++ b/rust/src/core/hooks.rs @@ -103,6 +103,17 @@ impl HookEvent { self.status = Some(status.into()); self } + pub fn with_usage_fraction(mut self, usage: f64) -> Self { + let usage = usage.clamp(0.0, 1.0); + self.usage_percent = Some(usage); + self.remaining_percent = Some((1.0 - usage) * 100.0); + self + } + + pub fn with_timestamp(mut self, ts: chrono::DateTime) -> Self { + self.timestamp = format_unix_utc(ts.timestamp().max(0) as u64); + self + } /// `CODEXBAR_*` environment variables for the hook process. pub fn environment_variables(&self) -> HashMap { diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index d62419dbcb..b4edf3eadd 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -4,7 +4,9 @@ mod adaptive_refresh; mod aws_signing; mod cost_pricing; pub mod curl_capture; +mod hook_transition; mod hooks; + mod http; mod http_proxy; mod jsonl_scanner; @@ -25,7 +27,9 @@ pub use adaptive_refresh::*; pub use aws_signing::*; pub use cost_pricing::*; pub use curl_capture::*; +pub use hook_transition::*; pub use hooks::*; + pub use http::*; pub use http_proxy::*; pub use jsonl_scanner::*; diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 2f17de6d23..70336416f4 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -77,6 +77,8 @@ pub enum ProviderId { Neuralwatt, ZoomMate, QwenCloud, + Notion, + Xai, } impl ProviderId { @@ -149,6 +151,8 @@ impl ProviderId { ProviderId::Neuralwatt, ProviderId::ZoomMate, ProviderId::QwenCloud, + ProviderId::Notion, + ProviderId::Xai, ] } @@ -221,6 +225,8 @@ impl ProviderId { ProviderId::Neuralwatt => "neuralwatt", ProviderId::ZoomMate => "zoommate", ProviderId::QwenCloud => "qwen-cloud", + ProviderId::Notion => "notion", + ProviderId::Xai => "xai", } } @@ -295,6 +301,8 @@ impl ProviderId { ProviderId::Neuralwatt => "Neuralwatt", ProviderId::ZoomMate => "ZoomMate", ProviderId::QwenCloud => "Qwen Cloud", + ProviderId::Notion => "Notion AI", + ProviderId::Xai => "xAI", } } @@ -372,6 +380,8 @@ impl ProviderId { ProviderId::Neuralwatt => None, ProviderId::ZoomMate => Some("zoommate.zoom.us"), ProviderId::QwenCloud => Some("qwencloud.com"), + ProviderId::Notion => Some("app.notion.com"), + ProviderId::Xai => None, } } @@ -432,7 +442,8 @@ impl ProviderId { "openaiapi" | "openai-api" | "openai api" | "openai-balance" => { Some(ProviderId::OpenAIApi) } - "grok" | "xai" | "x.ai" | "supergrok" | "super-grok" => Some(ProviderId::Grok), + "grok" | "supergrok" | "super-grok" => Some(ProviderId::Grok), + "xai" | "x.ai" | "x-ai" => Some(ProviderId::Xai), "elevenlabs" | "eleven-labs" | "11labs" => Some(ProviderId::ElevenLabs), "deepgram" | "dg" => Some(ProviderId::Deepgram), "groq" | "groqcloud" | "groq-cloud" | "groq cloud" => Some(ProviderId::Groq), @@ -457,6 +468,7 @@ impl ProviderId { Some(ProviderId::QwenCloud) } "zoommate" | "zoom-mate" | "zoom mate" => Some(ProviderId::ZoomMate), + "notion" | "notion-ai" | "notionai" | "notion ai" => Some(ProviderId::Notion), _ => None, } } @@ -685,7 +697,8 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("step-fun", ProviderId::StepFun); map.insert("openai-api", ProviderId::OpenAIApi); map.insert("openai-balance", ProviderId::OpenAIApi); - map.insert("xai", ProviderId::Grok); + map.insert("xai", ProviderId::Xai); + map.insert("x.ai", ProviderId::Xai); map.insert("supergrok", ProviderId::Grok); map.insert("eleven-labs", ProviderId::ElevenLabs); map.insert("11labs", ProviderId::ElevenLabs); @@ -699,6 +712,8 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("cross-model", ProviderId::CrossModel); map.insert("sakana-ai", ProviderId::Sakana); map.insert("sub-2-api", ProviderId::Sub2Api); + map.insert("notion-ai", ProviderId::Notion); + map.insert("notionai", ProviderId::Notion); map } @@ -709,7 +724,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 66); + assert_eq!(all.len(), 68); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Kimi)); @@ -756,6 +771,8 @@ mod tests { assert!(all.contains(&ProviderId::Neuralwatt)); assert!(all.contains(&ProviderId::ZoomMate)); assert!(all.contains(&ProviderId::QwenCloud)); + assert!(all.contains(&ProviderId::Notion)); + assert!(all.contains(&ProviderId::Xai)); } #[test] @@ -949,4 +966,43 @@ mod tests { // Bare "qwen" must not resolve to Alibaba Coding Plan. assert_ne!(ProviderId::from_cli_name("qwen"), Some(ProviderId::Alibaba)); } + + #[test] + fn test_provider_id_notion() { + assert_eq!(ProviderId::Notion.cli_name(), "notion"); + assert_eq!(ProviderId::Notion.display_name(), "Notion AI"); + assert_eq!(ProviderId::Notion.cookie_domain(), Some("app.notion.com")); + assert_eq!( + ProviderId::from_cli_name("notion"), + Some(ProviderId::Notion) + ); + assert_eq!( + ProviderId::from_cli_name("notion-ai"), + Some(ProviderId::Notion) + ); + assert_eq!( + ProviderId::from_cli_name("notionai"), + Some(ProviderId::Notion) + ); + assert_eq!( + ProviderId::from_cli_name("notion ai"), + Some(ProviderId::Notion) + ); + } + + #[test] + fn test_provider_id_xai() { + assert_eq!(ProviderId::Xai.cli_name(), "xai"); + assert_eq!(ProviderId::Xai.display_name(), "xAI"); + assert_eq!(ProviderId::Xai.cookie_domain(), None); + assert_eq!(ProviderId::from_cli_name("xai"), Some(ProviderId::Xai)); + assert_eq!(ProviderId::from_cli_name("x.ai"), Some(ProviderId::Xai)); + assert_eq!(ProviderId::from_cli_name("x-ai"), Some(ProviderId::Xai)); + // Grok keeps consumer aliases; xai is the developer-platform provider. + assert_eq!(ProviderId::from_cli_name("grok"), Some(ProviderId::Grok)); + assert_eq!( + ProviderId::from_cli_name("supergrok"), + Some(ProviderId::Grok) + ); + } } diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 2d0fd93ad1..2ac2e5e8f5 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -15,11 +15,11 @@ use crate::providers::{ FactoryProvider, GeminiProvider, GrokProvider, GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, MiMoProvider, MiniMaxProvider, MistralProvider, - NanoGPTProvider, NeuralwattProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, - OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, - QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, - VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, - ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, + NanoGPTProvider, NeuralwattProvider, NotionProvider, OllamaProvider, OpenAIApiProvider, + OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, + QoderProvider, QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, + T3ChatProvider, VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, + WindsurfProvider, XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -94,6 +94,8 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Neuralwatt => Box::new(NeuralwattProvider::new()), ProviderId::ZoomMate => Box::new(ZoomMateProvider::new()), ProviderId::QwenCloud => Box::new(QwenCloudProvider::new()), + ProviderId::Notion => Box::new(NotionProvider::new()), + ProviderId::Xai => Box::new(XaiProvider::new()), } } diff --git a/rust/src/core/rate_window.rs b/rust/src/core/rate_window.rs index 6d5f42fc28..25038e559e 100755 --- a/rust/src/core/rate_window.rs +++ b/rust/src/core/rate_window.rs @@ -1,156 +1,224 @@ -//! Rate window model - represents a usage limit window (e.g., 5-hour session, 7-day weekly) - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// Represents a rate limit window with usage percentage and reset time -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RateWindow { - /// Percentage of the window that has been used (0-100) - pub used_percent: f64, - - /// Duration of the window in minutes (e.g., 300 for 5-hour, 10080 for 7-day) - #[serde(skip_serializing_if = "Option::is_none")] - pub window_minutes: Option, - - /// When the window resets - #[serde(skip_serializing_if = "Option::is_none")] - pub resets_at: Option>, - - /// Human-readable reset description (e.g., "Jan 15 at 3:00pm") - #[serde(skip_serializing_if = "Option::is_none")] - pub reset_description: Option, - - /// Whether this row is an informational value rather than a quota. - #[serde(default)] - pub is_informational: bool, -} - -impl RateWindow { - /// Create a new rate window - pub fn new(used_percent: f64) -> Self { - Self { - used_percent: Self::finite_percent(used_percent), - window_minutes: None, - resets_at: None, - reset_description: None, - is_informational: false, - } - } - - /// Create an informational row without implying a percentage quota. - pub fn informational(description: impl Into) -> Self { - Self { - reset_description: Some(description.into()), - is_informational: true, - ..Self::new(0.0) - } - } - - /// Create a rate window with full details - pub fn with_details( - used_percent: f64, - window_minutes: Option, - resets_at: Option>, - reset_description: Option, - ) -> Self { - Self { - used_percent: Self::finite_percent(used_percent), - window_minutes, - resets_at, - reset_description, - is_informational: false, - } - } - - /// Get the remaining percentage (100 - used) - pub fn remaining_percent(&self) -> f64 { - 100.0 - self.used_percent - } - - /// Check if the window is exhausted (>= 100% used) - pub fn is_exhausted(&self) -> bool { - self.used_percent >= 100.0 - } - - /// Check if the window is nearly exhausted (>= 90% used) - pub fn is_nearly_exhausted(&self) -> bool { - self.used_percent >= 90.0 - } - - /// Format the reset time as a countdown string - pub fn format_countdown(&self) -> Option { - let resets_at = self.resets_at?; - let now = Utc::now(); - - if resets_at <= now { - return Some("now".to_string()); - } - - let duration = resets_at - now; - let hours = duration.num_hours(); - let total_minutes = ((duration.num_seconds() + 59) / 60).max(1); - let minutes = total_minutes % 60; - - if hours > 24 { - let days = hours / 24; - Some(format!("{}d {}h", days, hours % 24)) - } else if hours > 0 { - Some(format!("{}h {}m", hours, minutes)) - } else { - Some(format!("{}m", minutes)) - } - } - - fn finite_percent(value: f64) -> f64 { - if value.is_finite() { - value.clamp(0.0, 100.0) - } else { - 0.0 - } - } -} - -impl Default for RateWindow { - fn default() -> Self { - Self::new(0.0) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_remaining_percent() { - let window = RateWindow::new(75.0); - assert!((window.remaining_percent() - 25.0).abs() < f64::EPSILON); - } - - #[test] - fn test_clamping() { - let window = RateWindow::new(150.0); - assert!((window.used_percent - 100.0).abs() < f64::EPSILON); - - let window = RateWindow::new(-10.0); - assert!(window.used_percent.abs() < f64::EPSILON); - } - - #[test] - fn test_exhausted() { - assert!(RateWindow::new(100.0).is_exhausted()); - assert!(!RateWindow::new(99.0).is_exhausted()); - } - - #[test] - fn countdown_uses_one_minute_for_sub_minute_future_reset() { - let window = RateWindow::with_details( - 10.0, - None, - Some(Utc::now() + chrono::Duration::seconds(30)), - None, - ); - - assert_eq!(window.format_countdown().as_deref(), Some("1m")); - } -} +//! Rate window model - represents a usage limit window (e.g., 5-hour session, 7-day weekly) + +use chrono::{DateTime, Datelike, Utc}; +use serde::{Deserialize, Serialize}; + +/// Represents a rate limit window with usage percentage and reset time +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RateWindow { + /// Percentage of the window that has been used (0-100) + pub used_percent: f64, + + /// Duration of the window in minutes (e.g., 300 for 5-hour, 10080 for 7-day) + #[serde(skip_serializing_if = "Option::is_none")] + pub window_minutes: Option, + + /// When the window resets + #[serde(skip_serializing_if = "Option::is_none")] + pub resets_at: Option>, + + /// Human-readable reset description (e.g., "Jan 15 at 3:00pm") + #[serde(skip_serializing_if = "Option::is_none")] + pub reset_description: Option, + + /// Whether this row is an informational value rather than a quota. + #[serde(default)] + pub is_informational: bool, +} + +impl RateWindow { + /// Create a new rate window + pub fn new(used_percent: f64) -> Self { + Self { + used_percent: Self::finite_percent(used_percent), + window_minutes: None, + resets_at: None, + reset_description: None, + is_informational: false, + } + } + + /// Create an informational row without implying a percentage quota. + pub fn informational(description: impl Into) -> Self { + Self { + reset_description: Some(description.into()), + is_informational: true, + ..Self::new(0.0) + } + } + + /// Create a rate window with full details + pub fn with_details( + used_percent: f64, + window_minutes: Option, + resets_at: Option>, + reset_description: Option, + ) -> Self { + Self { + used_percent: Self::finite_percent(used_percent), + window_minutes, + resets_at, + reset_description, + is_informational: false, + } + } + + /// Real UTC Gregorian month length ending at `resets_at`, in minutes. + /// + /// Mirrors upstream `ProviderPaceCapability.inferredMonthlyWindowMinutes` + /// (reset − 1 calendar month). Used so monthly pace scores the actual cycle + /// (28–31 days) instead of a flat 30-day sentinel. + pub fn calendar_month_window_minutes(resets_at: DateTime) -> Option { + let start = subtract_one_calendar_month(resets_at)?; + let minutes = (resets_at - start).num_minutes(); + if minutes > 0 { + Some(minutes as u32) + } else { + None + } + } + + /// Monthly window minutes from a known reset. Returns `None` when there is + /// no reset (upstream leaves windowMinutes unset in that case). + pub fn monthly_window_minutes(resets_at: Option>) -> Option { + resets_at.and_then(Self::calendar_month_window_minutes) + } + + /// Get the remaining percentage (100 - used) + pub fn remaining_percent(&self) -> f64 { + 100.0 - self.used_percent + } + + /// Check if the window is exhausted (>= 100% used) + pub fn is_exhausted(&self) -> bool { + self.used_percent >= 100.0 + } + + /// Check if the window is nearly exhausted (>= 90% used) + pub fn is_nearly_exhausted(&self) -> bool { + self.used_percent >= 90.0 + } + + /// Format the reset time as a countdown string + pub fn format_countdown(&self) -> Option { + let resets_at = self.resets_at?; + let now = Utc::now(); + + if resets_at <= now { + return Some("now".to_string()); + } + + let duration = resets_at - now; + let hours = duration.num_hours(); + let total_minutes = ((duration.num_seconds() + 59) / 60).max(1); + let minutes = total_minutes % 60; + + if hours > 24 { + let days = hours / 24; + Some(format!("{}d {}h", days, hours % 24)) + } else if hours > 0 { + Some(format!("{}h {}m", hours, minutes)) + } else { + Some(format!("{}m", minutes)) + } + } + + fn finite_percent(value: f64) -> f64 { + if value.is_finite() { + value.clamp(0.0, 100.0) + } else { + 0.0 + } + } +} + +/// Subtract one Gregorian calendar month in UTC (upstream Calendar.date(byAdding: .month, -1)). +fn subtract_one_calendar_month(dt: DateTime) -> Option> { + let y = dt.year(); + let m = dt.month(); + let (py, pm) = if m == 1 { (y - 1, 12) } else { (y, m - 1) }; + let max_day = days_in_month(py, pm); + let day = dt.day().min(max_day); + dt.date_naive() + .with_year(py)? + .with_month(pm)? + .with_day(day) + .map(|d| d.and_time(dt.time()).and_utc()) +} + +fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if chrono::NaiveDate::from_ymd_opt(year, 2, 29).is_some() { + 29 + } else { + 28 + } + } + _ => 30, + } +} + +impl Default for RateWindow { + fn default() -> Self { + Self::new(0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn test_remaining_percent() { + let window = RateWindow::new(75.0); + assert!((window.remaining_percent() - 25.0).abs() < f64::EPSILON); + } + + #[test] + fn test_clamping() { + let window = RateWindow::new(150.0); + assert!((window.used_percent - 100.0).abs() < f64::EPSILON); + + let window = RateWindow::new(-10.0); + assert!(window.used_percent.abs() < f64::EPSILON); + } + + #[test] + fn test_exhausted() { + assert!(RateWindow::new(100.0).is_exhausted()); + assert!(!RateWindow::new(99.0).is_exhausted()); + } + + #[test] + fn countdown_uses_one_minute_for_sub_minute_future_reset() { + let window = RateWindow::with_details( + 10.0, + None, + Some(Utc::now() + chrono::Duration::seconds(30)), + None, + ); + + assert_eq!(window.format_countdown().as_deref(), Some("1m")); + } + + #[test] + fn calendar_month_window_uses_real_cycle_length() { + // March 1 2026 ends a 28-day February cycle (upstream ProviderPaceCapabilityTests). + let resets = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); + assert_eq!( + RateWindow::calendar_month_window_minutes(resets), + Some(28 * 24 * 60) + ); + // 31-day cycle ending Aug 1. + let resets = Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap(); + assert_eq!( + RateWindow::calendar_month_window_minutes(resets), + Some(31 * 24 * 60) + ); + assert_eq!(RateWindow::monthly_window_minutes(None), None); + } +} diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 652b6af926..8a71cfef02 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -197,6 +197,14 @@ impl TokenAccountSupport { requires_manual_cookie_source: true, cookie_name: None, }), + ProviderId::Notion => Some(TokenAccountSupport { + title: "Session tokens", + subtitle: "Store multiple Notion Cookie headers or token_v2 values.", + placeholder: "Cookie: token_v2=... or paste the token_v2 value", + injection: TokenInjection::CookieHeader, + requires_manual_cookie_source: true, + cookie_name: Some("token_v2"), + }), ProviderId::Sub2Api => Some(TokenAccountSupport { title: "Group API keys", subtitle: "Store multiple sub2api group API keys with labels such as Claude, Codex, or Gemini.", @@ -257,6 +265,16 @@ impl TokenAccountSupport { requires_manual_cookie_source: false, cookie_name: None, }), + ProviderId::Xai => Some(TokenAccountSupport { + title: "Management API keys", + subtitle: "Store multiple xAI Management API keys. Team ID is set separately under provider settings.", + placeholder: "xai-... Management API key from console.x.ai", + injection: TokenInjection::Environment { + key: "XAI_MANAGEMENT_API_KEY".to_string(), + }, + requires_manual_cookie_source: false, + cookie_name: None, + }), // Upstream 0.45 #2271: labeled OpenRouter API keys via token accounts. ProviderId::OpenRouter => Some(TokenAccountSupport { title: "API keys", diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 28f9a7356c..7bb2b92a07 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -656,6 +656,8 @@ locale_keys! { RefreshIntervalHelper, RefreshAllProvidersOnMenuOpen, RefreshAllProvidersOnMenuOpenHelper, + LowPowerMode, + LowPowerModeHelper, HighUsageWarningHelper, CriticalUsageWarningHelper, GlobalShortcutFieldLabel, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 360fb1467a..aa289501e0 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -400,6 +400,8 @@ RefreshIntervalLabel = Refresh interval RefreshIntervalHelper = Seconds between automatic provider refreshes (0 = manual). RefreshAllProvidersOnMenuOpen = Refresh on menu open RefreshAllProvidersOnMenuOpenHelper = Force-refresh enabled providers whenever the tray menu opens. +LowPowerMode = Low Power Mode +LowPowerModeHelper = Limit automatic background refresh to once every 30 minutes. Manual refresh stays immediate. HighUsageWarningHelper = Show a warning when usage exceeds this percentage. CriticalUsageWarningHelper = Show a critical alert when usage exceeds this percentage. GlobalShortcutFieldLabel = Global shortcut diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 08b4e6e0c9..044c7a9bb6 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -399,6 +399,8 @@ RefreshInterval30Min = 30 minutos RefreshInterval1Hour = 1 hora RefreshAllProvidersOnMenuOpen = Actualizar al abrir el menú RefreshAllProvidersOnMenuOpenHelper = Fuerza la actualización de proveedores habilitados cada vez que se abre el menú de bandeja. +LowPowerMode = Modo de bajo consumo +LowPowerModeHelper = Limita la actualización automática en segundo plano a una vez cada 30 minutos. La actualización manual sigue siendo inmediata. HighUsageWarningHelper = Mostrar una advertencia cuando el uso exceda este porcentaje. CriticalUsageWarningHelper = Mostrar una alerta crítica cuando el uso exceda este porcentaje. GlobalShortcutFieldLabel = Atajo global diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 440e4d9da7..626ced9843 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -394,6 +394,8 @@ RefreshIntervalLabel = 更新間隔 RefreshIntervalHelper = プロバイダーを自動更新する間隔(秒)(0 = 手動)。 RefreshAllProvidersOnMenuOpen = メニュー表示時に更新 RefreshAllProvidersOnMenuOpenHelper = トレイメニューを開くたびに有効なプロバイダーを強制更新。 +LowPowerMode = 低電力モード +LowPowerModeHelper = 自動のバックグラウンド更新を30分に1回までに制限します。手動更新はすぐ実行されます。 HighUsageWarningHelper = 使用量がこの割合を超えたら警告を表示。 CriticalUsageWarningHelper = 使用量がこの割合を超えたら重大アラートを表示。 GlobalShortcutFieldLabel = グローバルショートカット diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index ee07730725..053e7b717e 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -399,6 +399,8 @@ RefreshInterval30Min = 30분 RefreshInterval1Hour = 1시간 RefreshAllProvidersOnMenuOpen = 메뉴를 열 때 새로고침 RefreshAllProvidersOnMenuOpenHelper = 트레이 메뉴를 열 때마다 활성화된 제공업체를 강제로 새로고침합니다. +LowPowerMode = 저전력 모드 +LowPowerModeHelper = 자동 백그라운드 새로고침을 30분에 한 번으로 제한합니다. 수동 새로고침은 즉시 유지됩니다. HighUsageWarningHelper = 사용량이 이 백분율을 초과하면 경고를 표시합니다. CriticalUsageWarningHelper = 사용량이 이 백분율을 초과하면 위험 경고를 표시합니다. GlobalShortcutFieldLabel = 글로벌 단축키 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index d2d4dfa562..74847b75d0 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -378,6 +378,8 @@ RefreshIntervalLabel = Интервал обновления RefreshIntervalHelper = Секунды между автоматическими обновлениями провайдера (0 = вручную). RefreshAllProvidersOnMenuOpen = Обновить при открытии меню RefreshAllProvidersOnMenuOpenHelper = Провайдеры с включенным принудительным обновлением всякий раз, когда открывается меню на панели задач. +LowPowerMode = Режим энергосбережения +LowPowerModeHelper = Ограничивает автоматическое фоновое обновление раз в 30 минут. Ручное обновление остаётся мгновенным. HighUsageWarningHelper = Показывать предупреждение, когда использование превышает этот процент. CriticalUsageWarningHelper = Показывать критическое предупреждение, когда использование превышает этот процент. GlobalShortcutFieldLabel = Глобальный ярлык diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 2459738d7c..0e02ac0a77 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -394,6 +394,8 @@ RefreshIntervalLabel = 刷新间隔 RefreshIntervalHelper = 两次自动刷新之间的秒数(0 = 手动)。 RefreshAllProvidersOnMenuOpen = 打开菜单时刷新 RefreshAllProvidersOnMenuOpenHelper = 每次打开托盘菜单时强制刷新已启用的提供商。 +LowPowerMode = 低功耗模式 +LowPowerModeHelper = 将自动后台刷新限制为每 30 分钟一次。手动刷新仍可立即执行。 HighUsageWarningHelper = 当用量超过该百分比时显示预警。 CriticalUsageWarningHelper = 当用量超过该百分比时显示严重告警。 GlobalShortcutFieldLabel = 全局快捷键 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index abcc7bef62..c8dff38f76 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -394,6 +394,8 @@ RefreshIntervalLabel = 重新整理間隔 RefreshIntervalHelper = 兩次自動重新整理之間的秒數(0 = 手動)。 RefreshAllProvidersOnMenuOpen = 開啟選單時重新整理 RefreshAllProvidersOnMenuOpenHelper = 每次開啟系統匣選單時強制重新整理已啟用的提供商。 +LowPowerMode = 低耗電模式 +LowPowerModeHelper = 將自動背景重新整理限制為每 30 分鐘一次。手動重新整理仍可立即執行。 HighUsageWarningHelper = 當用量超過該百分比時顯示預警。 CriticalUsageWarningHelper = 當用量超過該百分比時顯示嚴重告警。 GlobalShortcutFieldLabel = 全域性快捷鍵 diff --git a/rust/src/providers/alibaba/parser.rs b/rust/src/providers/alibaba/parser.rs index 7698737c69..fb862fb2ec 100644 --- a/rust/src/providers/alibaba/parser.rs +++ b/rust/src/providers/alibaba/parser.rs @@ -90,10 +90,11 @@ pub(crate) fn parse_response(json: &serde_json::Value) -> Result Result { + use crate::browser::cookie_cache::CookieHeaderCache; + + if let Some(cached) = CookieHeaderCache::load(ProviderId::CommandCode) { + match self.fetch_web(&cached.cookie_header).await { + Ok(result) => return Ok(result), + Err(ProviderError::AuthRequired) => { + CookieHeaderCache::clear(ProviderId::CommandCode); + } + Err(err) => return Err(err), + } + } + + let cookie_header = crate::providers::browser_cookie_header(&["commandcode.ai"])?; + let result = self.fetch_web(&cookie_header).await?; + let _ = CookieHeaderCache::store(ProviderId::CommandCode, &cookie_header, "browser"); + Ok(result) + } } fn normalize_cookie_header(raw: &str) -> Option { @@ -257,11 +279,10 @@ impl Provider for CommandCodeProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { - let cookie = match ctx.manual_cookie_header.as_deref() { - Some(cookie) => cookie.to_string(), - None => crate::providers::browser_cookie_header(&["commandcode.ai"])?, - }; - self.fetch_web(&cookie).await + if let Some(cookie) = ctx.manual_cookie_header.as_deref() { + return self.fetch_web(cookie).await; + } + self.fetch_with_cookie_refresh().await } SourceMode::OAuth | SourceMode::Cli => { Err(ProviderError::UnsupportedSource(ctx.source_mode)) diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index 0574a14998..96fdd0ef3e 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -29,7 +29,8 @@ impl CursorProvider { session_label: "Plan", weekly_label: "Auto", supports_opus: false, - supports_credits: true, + // Upstream #2338: Cursor has no account credit balance to advertise. + supports_credits: false, default_enabled: true, is_primary: false, dashboard_url: Some("https://cursor.com/dashboard/usage"), @@ -112,10 +113,17 @@ impl CursorProvider { usage: UsageSnapshot, cost: Option, token_report: Option<&token_cost::CursorTokenCostReport>, + include_credits: bool, ) -> ProviderFetchResult { - let cost = token_report - .and_then(|r| r.merge_into_cost(cost.clone())) - .or(cost); + // On-demand / plan cost follows the shared optional-usage setting + // (`FetchContext.include_credits` ↔ upstream showOptionalCreditsAndExtraUsage). + let cost = if include_credits { + token_report + .and_then(|r| r.merge_into_cost(cost.clone())) + .or(cost) + } else { + None + }; let mut result = ProviderFetchResult::new(usage, "web"); if let Some(c) = cost { result = result.with_cost(c); @@ -161,7 +169,12 @@ impl Provider for CursorProvider { plan_type, token_report.as_ref(), ); - Ok(Self::build_fetch_result(usage, cost, token_report.as_ref())) + Ok(Self::build_fetch_result( + usage, + cost, + token_report.as_ref(), + ctx.include_credits, + )) } Err(e) => { tracing::warn!("Cursor API fetch failed: {}", e); @@ -229,4 +242,29 @@ mod tests { ProviderError::UnsupportedSource(SourceMode::OAuth) )); } + + #[test] + fn does_not_advertise_unsupported_credits() { + let provider = CursorProvider::new(); + assert!(!provider.metadata().supports_credits); + } + + #[test] + fn on_demand_cost_follows_include_credits_setting() { + let usage = UsageSnapshot::new(RateWindow::new(16.0)); + let cost = CostSnapshot::new(3.5, "USD", "On-demand (billing cycle)").with_limit(10.0); + + let shown = + CursorProvider::build_fetch_result(usage.clone(), Some(cost.clone()), None, true); + assert!( + shown.cost.is_some(), + "include_credits=true keeps on-demand cost" + ); + + let hidden = CursorProvider::build_fetch_result(usage, Some(cost), None, false); + assert!( + hidden.cost.is_none(), + "include_credits=false hides on-demand extra usage" + ); + } } diff --git a/rust/src/providers/doubao/mod.rs b/rust/src/providers/doubao/mod.rs index 77cdfafc82..f56fcf5bce 100644 --- a/rust/src/providers/doubao/mod.rs +++ b/rust/src/providers/doubao/mod.rs @@ -499,10 +499,16 @@ fn coding_plan_window( let level = quota.level.to_ascii_lowercase(); levels.iter().any(|candidate| *candidate == level) })?; + let resets_at = quota.reset_timestamp.and_then(datetime_from_epoch); + // Monthly windows: expand the 30-day sentinel to the real calendar cycle. + let window_minutes = match minutes { + Some(m) if m == 30 * 24 * 60 => RateWindow::monthly_window_minutes(resets_at).or(Some(m)), + other => other, + }; Some(RateWindow::with_details( quota.percent, - minutes, - quota.reset_timestamp.and_then(datetime_from_epoch), + window_minutes, + resets_at, None, )) } @@ -1045,7 +1051,10 @@ mod tests { let snapshot = coding_plan_snapshot(decode_coding_plan_usage(body).unwrap()); assert_eq!(snapshot.primary.used_percent, 12.5); assert_eq!(snapshot.secondary.unwrap().used_percent, 50.0); - assert_eq!(snapshot.tertiary.unwrap().used_percent, 75.0); + let monthly = snapshot.tertiary.expect("monthly"); + assert_eq!(monthly.used_percent, 75.0); + // ResetTimestamp 1785628800 = 2026-08-02 → prior month is 31 days. + assert_eq!(monthly.window_minutes, Some(31 * 24 * 60)); assert_eq!(snapshot.login_method.as_deref(), Some("active")); } diff --git a/rust/src/providers/grok/mod.rs b/rust/src/providers/grok/mod.rs index 27ae9aab45..621fd65a54 100644 --- a/rust/src/providers/grok/mod.rs +++ b/rust/src/providers/grok/mod.rs @@ -323,6 +323,8 @@ fn result_from_billing( team_id: Option, login_method: Option, ) -> ProviderFetchResult { + // Upstream #2431 / #2566: do not infer windowMinutes from time-until-reset. + // A monthly quota near its reset would otherwise be misclassified as weekly. let mut usage = UsageSnapshot::new(RateWindow::with_details( billing.used_percent, None, @@ -616,4 +618,23 @@ mod tests { )); assert!(!is_cookie_authentication_failure(&ProviderError::NoCookies)); } + + #[test] + fn billing_snapshot_leaves_window_minutes_unset() { + // A monthly quota with six days left must not be reported as weekly. + let resets = Utc::now() + chrono::Duration::days(6); + let result = result_from_billing( + GrokBillingSnapshot { + used_percent: 12.0, + resets_at: Some(resets), + }, + "web", + None, + None, + Some("SuperGrok".into()), + ); + assert_eq!(result.usage.primary.window_minutes, None); + assert_eq!(result.usage.primary.resets_at, Some(resets)); + assert_eq!(result.usage.login_method.as_deref(), Some("SuperGrok")); + } } diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index 61248f89d9..ea546c3f26 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -405,7 +405,8 @@ impl KimiProvider { "Monthly", RateWindow::with_details( ratio * 100.0, - None, + // Verified monthly sentinel (#2431 / #2566). + Some(30 * 24 * 60), balance.expire_time.as_ref().and_then(parse_kimi_timestamp), None, ), @@ -976,6 +977,7 @@ mod tests { .find(|window| window.id == "kimi-monthly") .unwrap(); assert_eq!(monthly.title, "Monthly"); + assert_eq!(monthly.window.window_minutes, Some(30 * 24 * 60)); assert!((monthly.window.used_percent - 77.16).abs() < 0.0001); let code_7d = snapshot .extra_rate_windows diff --git a/rust/src/providers/mimo/mod.rs b/rust/src/providers/mimo/mod.rs index 30fa1a2734..e2b163a139 100644 --- a/rust/src/providers/mimo/mod.rs +++ b/rust/src/providers/mimo/mod.rs @@ -233,12 +233,17 @@ fn snapshot_from_parts( let primary = if let Some(item) = usage_item { RateWindow::with_details( item.percent, - None, + RateWindow::monthly_window_minutes(period_end), period_end, Some(format!("{}/{} tokens", item.used, item.limit)), ) } else { - RateWindow::with_details(0.0, None, period_end, Some("No token-plan usage".into())) + RateWindow::with_details( + 0.0, + RateWindow::monthly_window_minutes(period_end), + period_end, + Some("No token-plan usage".into()), + ) }; let mut secondary = RateWindow::new(0.0); secondary.reset_description = Some(balance_description( @@ -348,4 +353,18 @@ mod tests { "12.50 CNY balance" ); } + + #[test] + fn mimo_token_plan_uses_calendar_month_minutes() { + // Period end 2026-03-01 → February cycle is 28 days. + let period_end = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); + let primary = RateWindow::with_details( + 40.0, + RateWindow::monthly_window_minutes(Some(period_end)), + Some(period_end), + Some("400/1000 tokens".into()), + ); + assert_eq!(primary.window_minutes, Some(28 * 24 * 60)); + assert_eq!(primary.resets_at, Some(period_end)); + } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index b2b7225f5e..5a1b8187af 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -46,6 +46,7 @@ pub mod minimax; pub mod mistral; pub mod nanogpt; pub mod neuralwatt; +pub mod notion; pub mod ollama; pub mod openai; pub mod openaiapi; @@ -65,6 +66,7 @@ pub mod vertexai; pub mod warp; pub mod wayfinder; pub mod windsurf; +pub mod xai; pub mod zai; pub mod zed; pub mod zenmux; @@ -115,6 +117,7 @@ pub use minimax::{MiniMaxProvider, MiniMaxRegion}; pub use mistral::MistralProvider; pub use nanogpt::NanoGPTProvider; pub use neuralwatt::NeuralwattProvider; +pub use notion::NotionProvider; pub use ollama::OllamaProvider; pub use openaiapi::OpenAIApiProvider; pub use opencode::OpenCodeProvider; @@ -133,6 +136,7 @@ pub use vertexai::VertexAIProvider; pub use warp::WarpProvider; pub use wayfinder::WayfinderProvider; pub use windsurf::WindsurfProvider; +pub use xai::XaiProvider; pub use zai::ZaiProvider; pub use zed::ZedProvider; pub use zenmux::ZenMuxProvider; diff --git a/rust/src/providers/notion/mod.rs b/rust/src/providers/notion/mod.rs new file mode 100644 index 0000000000..8c23968c44 --- /dev/null +++ b/rust/src/providers/notion/mod.rs @@ -0,0 +1,861 @@ +//! Notion AI provider implementation (upstream CodexBar 0.47.0 / #2552). +//! +//! Cookie-authenticated workspace allowance tracking via: +//! - `POST https://app.notion.com/api/v3/getSpaces` +//! - `POST https://app.notion.com/api/v3/getCreditRateLimitStatus` body `{"spaceId":...}` +//! +//! Reports a rolling (session-shaped) primary window and a billing-period secondary +//! window. Billing length uses the shared monthly sentinel so calendar-month pace +//! can resolve the real cycle ending at `periodEndMs`. + +use async_trait::async_trait; +use chrono::{DateTime, TimeZone, Utc}; +use reqwest::Client; +use serde_json::Value; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, +}; +use crate::providers::browser_cookie_header; + +const BASE_URL: &str = "https://app.notion.com"; +const GET_SPACES_URL: &str = "https://app.notion.com/api/v3/getSpaces"; +const RATE_LIMIT_URL: &str = "https://app.notion.com/api/v3/getCreditRateLimitStatus"; +const DASHBOARD_URL: &str = "https://app.notion.com/"; +const STATUS_PAGE_URL: &str = "https://status.notion.so"; +const SESSION_COOKIE_NAME: &str = "token_v2"; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; + +/// Cookie domains: app host first, legacy `notion.so` kept for pre-move sessions. +const COOKIE_DOMAINS: &[&str] = &[ + "app.notion.com", + "www.notion.com", + "notion.com", + "www.notion.so", + "notion.so", +]; + +/// Shared monthly pace sentinel (`30d`) — calendar-month resolution replaces it +/// with the real cycle length ending at `resets_at` (upstream `ProviderPaceCapability`). +const MONTHLY_WINDOW_SENTINEL_MINUTES: u32 = 30 * 24 * 60; + +pub struct NotionProvider { + metadata: ProviderMetadata, +} + +#[derive(Debug, Clone)] +struct NotionWorkspace { + id: String, + name: Option, + subscription_tier: Option, +} + +impl NotionWorkspace { + fn may_have_allowance(&self) -> bool { + matches!( + self.subscription_tier + .as_deref() + .map(str::to_ascii_lowercase) + .as_deref(), + Some("business") | Some("enterprise") + ) + } + + fn display_tier(&self) -> Option { + let raw = self.subscription_tier.as_deref()?.trim(); + if raw.is_empty() { + return None; + } + let mut chars = raw.chars(); + let first = chars.next()?.to_uppercase().collect::(); + Some(format!("{first}{}", chars.as_str())) + } +} + +#[derive(Debug, Clone)] +struct NotionAccount { + user_id: Option, + email: Option, + workspaces: Vec, +} + +impl NotionAccount { + fn resolve_workspace(&self, preferred_id: Option<&str>) -> Option<&NotionWorkspace> { + if let Some(preferred) = normalize_space_id(preferred_id) + && let Some(match_) = self + .workspaces + .iter() + .find(|ws| normalize_space_id(Some(&ws.id)).as_deref() == Some(preferred.as_str())) + { + return Some(match_); + } + // Unknown preferred id is almost always a typo; fall back rather than 403. + self.workspaces + .iter() + .find(|ws| ws.may_have_allowance()) + .or_else(|| self.workspaces.first()) + } +} + +#[derive(Debug, Clone)] +struct RollingWindow { + window: Option, + used: Option, + limit: Option, +} + +#[derive(Debug, Clone)] +struct BillingPeriodWindow { + used: Option, + limit: Option, + period_end_ms: Option, +} + +#[derive(Debug, Clone)] +struct CreditRateLimitStatus { + status: Option, + window: Option, + resets_in_seconds: Option, + billing_period_window: Option, +} + +impl CreditRateLimitStatus { + fn is_not_applicable(&self) -> bool { + self.status + .as_deref() + .is_some_and(|s| s.eq_ignore_ascii_case("not_applicable")) + } +} + +impl NotionProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Notion, + display_name: "Notion AI", + session_label: "Rolling", + weekly_label: "Monthly", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some(DASHBOARD_URL), + status_page_url: Some(STATUS_PAGE_URL), + }, + } + } + + fn resolve_cookie_header(ctx: &FetchContext) -> Result { + if let Some(raw) = ctx.manual_cookie_header.as_deref() + && let Some(header) = normalize_cookie_header(raw) + { + return Ok(header); + } + browser_cookie_header(COOKIE_DOMAINS) + .and_then(|header| normalize_cookie_header(&header).ok_or(ProviderError::NoCookies)) + } + + async fn fetch_via_web(&self, ctx: &FetchContext) -> Result { + let cookie_header = Self::resolve_cookie_header(ctx)?; + let client = crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(ctx.web_timeout.max(1))) + .build() + .map_err(|e| ProviderError::Other(e.to_string()))?; + + let account = Self::fetch_account(&client, &cookie_header).await?; + let workspace = account + .resolve_workspace(ctx.workspace_id.as_deref()) + .cloned() + .ok_or_else(|| { + ProviderError::Other("No Notion workspace found for this account.".into()) + })?; + + let rate_limit = Self::fetch_rate_limit(&client, &cookie_header, &workspace.id).await?; + + if rate_limit.is_not_applicable() { + let name = workspace.name.as_deref().unwrap_or("this workspace"); + return Err(ProviderError::Other(format!( + "Notion AI usage allowance is not tracked for \"{name}\". \ + Allowances apply to Business and Enterprise workspaces." + ))); + } + + build_usage_snapshot(&rate_limit, Some(&workspace), Some(&account), Utc::now()) + } + + async fn fetch_account( + client: &Client, + cookie_header: &str, + ) -> Result { + let body = Self::post_json( + client, + GET_SPACES_URL, + cookie_header, + &Value::Object(Default::default()), + ) + .await?; + parse_spaces(&body) + } + + async fn fetch_rate_limit( + client: &Client, + cookie_header: &str, + space_id: &str, + ) -> Result { + let mut map = serde_json::Map::new(); + map.insert("spaceId".into(), Value::String(space_id.to_string())); + let body = + Self::post_json(client, RATE_LIMIT_URL, cookie_header, &Value::Object(map)).await?; + parse_rate_limit_status(&body) + } + + async fn post_json( + client: &Client, + url: &str, + cookie_header: &str, + body: &Value, + ) -> Result { + let resp = client + .post(url) + .header("Cookie", cookie_header) + .header("Content-Type", "application/json") + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("User-Agent", USER_AGENT) + .header("Referer", DASHBOARD_URL) + .header("Origin", BASE_URL) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .json(body) + .send() + .await?; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if status.as_u16() == 401 { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "Notion API error: HTTP {status}" + ))); + } + + serde_json::from_str(&text) + .map_err(|e| ProviderError::Parse(format!("Could not parse Notion usage: {e}"))) + } +} + +impl Default for NotionProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for NotionProvider { + fn id(&self) -> ProviderId { + ProviderId::Notion + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + tracing::debug!("Fetching Notion AI usage"); + match ctx.source_mode { + SourceMode::Auto | SourceMode::Web => { + let usage = self.fetch_via_web(ctx).await?; + Ok(ProviderFetchResult::new(usage, "web")) + } + SourceMode::Cli | SourceMode::OAuth => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Web] + } + + fn supports_web(&self) -> bool { + true + } + + fn supports_cli(&self) -> bool { + false + } +} + +fn build_usage_snapshot( + rate_limit: &CreditRateLimitStatus, + workspace: Option<&NotionWorkspace>, + account: Option<&NotionAccount>, + now: DateTime, +) -> Result { + let rolling = rate_limit.window.as_ref().and_then(|window| { + percent(window.used, window.limit).map(|used_percent| { + RateWindow::with_details( + used_percent, + rolling_minutes(window.window.as_deref()), + rolling_reset(rate_limit.resets_in_seconds, now), + None, + ) + }) + }); + + let billing = rate_limit + .billing_period_window + .as_ref() + .and_then(|billing| { + percent(billing.used, billing.limit).map(|used_percent| { + RateWindow::with_details( + used_percent, + Some(MONTHLY_WINDOW_SENTINEL_MINUTES), + date_from_milliseconds(billing.period_end_ms), + None, + ) + }) + }); + + let (primary, secondary) = match (rolling, billing) { + (Some(primary), secondary) => (primary, secondary), + (None, Some(primary)) => (primary, None), + (None, None) => { + return Err(ProviderError::Parse( + "getCreditRateLimitStatus returned no measurable usage windows.".into(), + )); + } + }; + + let mut snapshot = UsageSnapshot::new(primary); + if let Some(secondary) = secondary { + snapshot = snapshot.with_secondary(secondary); + } + snapshot.updated_at = now; + + if let Some(email) = account.and_then(|a| a.email.clone()) { + snapshot = snapshot.with_email(email); + } + if let Some(name) = workspace.and_then(|w| w.name.clone()) { + snapshot = snapshot.with_organization(name); + } + if let Some(tier) = workspace.and_then(|w| w.display_tier()) { + snapshot = snapshot.with_login_method(tier); + } + + Ok(snapshot) +} + +fn parse_rate_limit_status(json: &Value) -> Result { + let status = json + .get("status") + .and_then(Value::as_str) + .map(str::to_string); + let window = json.get("window").and_then(|w| { + if w.is_null() { + return None; + } + Some(RollingWindow { + window: w.get("window").and_then(Value::as_str).map(str::to_string), + used: number_field(w, "used"), + limit: number_field(w, "limit"), + }) + }); + let billing_period_window = json.get("billingPeriodWindow").and_then(|w| { + if w.is_null() { + return None; + } + Some(BillingPeriodWindow { + used: number_field(w, "used"), + limit: number_field(w, "limit"), + period_end_ms: number_field(w, "periodEndMs"), + }) + }); + let resets_in_seconds = number_field(json, "resetsInSeconds"); + + let parsed = CreditRateLimitStatus { + status, + window, + resets_in_seconds, + billing_period_window, + }; + + if !parsed.is_not_applicable() + && parsed.window.is_none() + && parsed.billing_period_window.is_none() + { + return Err(ProviderError::Parse( + "getCreditRateLimitStatus returned no usage windows.".into(), + )); + } + + Ok(parsed) +} + +fn parse_spaces(json: &Value) -> Result { + let root = json + .as_object() + .ok_or_else(|| ProviderError::Parse("getSpaces response is not a JSON object.".into()))?; + + let user_id = resolve_user_id(root).ok_or_else(|| { + ProviderError::Parse("getSpaces response did not identify a single user.".into()) + })?; + + let container = root + .get(&user_id) + .and_then(Value::as_object) + .ok_or_else(|| { + ProviderError::Parse("getSpaces response did not identify a single user.".into()) + })?; + + let mut email = None; + let mut name = None; + if let Some(users) = container.get("notion_user").and_then(Value::as_object) { + let record = users + .get(&user_id) + .and_then(unwrap_record) + .or_else(|| users.values().find_map(unwrap_record)); + if let Some(record) = record { + email = record + .get("email") + .and_then(Value::as_str) + .map(str::to_string); + name = record + .get("name") + .and_then(Value::as_str) + .map(str::to_string); + } + } + let _ = name; // identity carries email/workspace; display name unused + + let mut workspaces = Vec::new(); + if let Some(spaces) = container.get("space").and_then(Value::as_object) { + let mut keys: Vec<&String> = spaces.keys().collect(); + keys.sort(); + for key in keys { + let Some(record) = spaces.get(key).and_then(unwrap_record) else { + continue; + }; + let id = record + .get("id") + .and_then(Value::as_str) + .unwrap_or(key.as_str()) + .to_string(); + workspaces.push(NotionWorkspace { + id, + name: record + .get("name") + .and_then(Value::as_str) + .map(str::to_string), + subscription_tier: record + .get("subscription_tier") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + } + + Ok(NotionAccount { + user_id: Some(user_id), + email, + workspaces, + }) +} + +fn resolve_user_id(root: &serde_json::Map) -> Option { + let identified: Vec<&String> = root + .keys() + .filter(|key| { + let Some(container) = root.get(*key).and_then(Value::as_object) else { + return false; + }; + let Some(users) = container.get("notion_user").and_then(Value::as_object) else { + return false; + }; + let Some(record) = users.get(*key).and_then(unwrap_record) else { + return false; + }; + record.get("id").and_then(Value::as_str) == Some(key.as_str()) + }) + .collect(); + + if identified.len() == 1 { + return identified.first().map(|s| (*s).clone()); + } + if identified.is_empty() && root.len() == 1 { + return root.keys().next().cloned(); + } + None +} + +fn unwrap_record(raw: &Value) -> Option<&serde_json::Map> { + let outer = raw.as_object()?; + let Some(value) = outer.get("value").and_then(Value::as_object) else { + return Some(outer); + }; + if let Some(inner) = value.get("value").and_then(Value::as_object) { + return Some(inner); + } + Some(value) +} + +fn normalize_space_id(raw: Option<&str>) -> Option { + let trimmed = raw?.trim(); + if trimmed.is_empty() { + return None; + } + let compact: String = trimmed.chars().filter(|c| *c != '-').collect(); + let compact_lower = compact.to_ascii_lowercase(); + if compact_lower.len() == 32 && compact_lower.chars().all(|c| c.is_ascii_hexdigit()) { + let chars: Vec = compact_lower.chars().collect(); + let groups = [0..8, 8..12, 12..16, 16..20, 20..32]; + return Some( + groups + .into_iter() + .map(|range| chars[range].iter().collect::()) + .collect::>() + .join("-"), + ); + } + Some(trimmed.to_ascii_lowercase()) +} + +fn percent(used: Option, limit: Option) -> Option { + let used = used?; + let limit = limit?; + if limit <= 0.0 { + return None; + } + Some((used / limit * 100.0).max(0.0)) +} + +fn rolling_minutes(raw: Option<&str>) -> Option { + let minutes = minutes_from_window_token(raw)?; + if minutes == MONTHLY_WINDOW_SENTINEL_MINUTES { + return None; + } + Some(minutes) +} + +fn minutes_from_window_token(raw: Option<&str>) -> Option { + let raw = raw?.trim().to_ascii_lowercase(); + if raw.is_empty() { + return None; + } + let unit = raw.chars().last()?; + let value: u32 = raw[..raw.len().saturating_sub(1)].parse().ok()?; + if value == 0 { + return None; + } + match unit { + 'm' => Some(value), + 'h' => Some(value.saturating_mul(60)), + 'd' => Some(value.saturating_mul(24 * 60)), + 'w' => Some(value.saturating_mul(7 * 24 * 60)), + _ => None, + } +} + +fn rolling_reset(seconds: Option, now: DateTime) -> Option> { + let seconds = seconds?; + if seconds < 0.0 { + return None; + } + Some(now + chrono::Duration::milliseconds((seconds * 1000.0) as i64)) +} + +fn date_from_milliseconds(raw: Option) -> Option> { + let raw = raw?; + if raw <= 0.0 { + return None; + } + let secs = (raw / 1000.0).floor() as i64; + let nsecs = (((raw / 1000.0) - secs as f64) * 1_000_000_000.0) as u32; + Utc.timestamp_opt(secs, nsecs).single() +} + +fn number_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(|v| match v { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.parse().ok(), + _ => None, + }) +} + +/// Normalize a cookie header or bare `token_v2` value into a usable Cookie header. +fn normalize_cookie_header(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + // Strip optional `Cookie:` prefix from cURL captures. + let without_prefix = trimmed + .strip_prefix("Cookie:") + .or_else(|| trimmed.strip_prefix("cookie:")) + .map(str::trim) + .unwrap_or(trimmed); + + if without_prefix.is_empty() { + return None; + } + + // Bare token_v2 value (no `=` pairs). + if !without_prefix.contains('=') { + return Some(format!("{SESSION_COOKIE_NAME}={without_prefix}")); + } + + // Collapse whitespace around pairs. + let pairs: Vec<&str> = without_prefix + .split(';') + .map(str::trim) + .filter(|p| !p.is_empty() && p.contains('=')) + .collect(); + if pairs.is_empty() { + return None; + } + Some(pairs.join("; ")) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_spaces_json() -> Value { + json!({ + "user-1111-2222-3333-444444444444": { + "notion_user": { + "user-1111-2222-3333-444444444444": { + "value": { + "id": "user-1111-2222-3333-444444444444", + "email": "ada@example.com", + "name": "Ada" + } + } + }, + "space": { + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": { + "value": { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "Personal", + "plan_type": "personal", + "subscription_tier": "personal" + } + }, + "11111111-2222-3333-4444-555555555555": { + "value": { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Acme Biz", + "plan_type": "team", + "subscription_tier": "business" + } + }, + "99999999-8888-7777-6666-555555555555": { + "value": { + "value": { + "id": "99999999-8888-7777-6666-555555555555", + "name": "Nested Ent", + "plan_type": "team", + "subscription_tier": "enterprise" + } + } + } + } + } + }) + } + + fn sample_rate_limit_json() -> Value { + json!({ + "status": "ok", + "window": { + "creditType": "ai", + "scope": "user", + "window": "6h", + "used": 30.0, + "limit": 100.0 + }, + "resetsInSeconds": 3600.0, + "billingPeriodWindow": { + "creditType": "ai", + "scope": "workspace", + "cadence": "monthly", + "used": 250.0, + "limit": 1000.0, + "periodEndMs": 1_735_689_600_000.0 + }, + "enforcement": "soft" + }) + } + + #[test] + fn parses_rate_limit_rolling_and_billing() { + let status = parse_rate_limit_status(&sample_rate_limit_json()).unwrap(); + assert!(!status.is_not_applicable()); + let window = status.window.unwrap(); + assert_eq!(window.window.as_deref(), Some("6h")); + assert_eq!(window.used, Some(30.0)); + assert_eq!(window.limit, Some(100.0)); + let billing = status.billing_period_window.unwrap(); + assert_eq!(billing.used, Some(250.0)); + assert_eq!(billing.limit, Some(1000.0)); + assert_eq!(billing.period_end_ms, Some(1_735_689_600_000.0)); + assert_eq!(status.resets_in_seconds, Some(3600.0)); + } + + #[test] + fn builds_snapshot_with_rolling_primary_and_billing_secondary() { + let status = parse_rate_limit_status(&sample_rate_limit_json()).unwrap(); + let account = parse_spaces(&sample_spaces_json()).unwrap(); + let workspace = account.resolve_workspace(None).unwrap(); + let now = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(); + let snap = build_usage_snapshot(&status, Some(workspace), Some(&account), now).unwrap(); + + assert!((snap.primary.used_percent - 30.0).abs() < f64::EPSILON); + assert_eq!(snap.primary.window_minutes, Some(6 * 60)); + assert_eq!( + snap.primary.resets_at, + Some(now + chrono::Duration::seconds(3600)) + ); + + let secondary = snap.secondary.expect("billing secondary"); + assert!((secondary.used_percent - 25.0).abs() < f64::EPSILON); + assert_eq!( + secondary.window_minutes, + Some(MONTHLY_WINDOW_SENTINEL_MINUTES) + ); + assert_eq!( + secondary.resets_at, + date_from_milliseconds(Some(1_735_689_600_000.0)) + ); + + assert_eq!(snap.account_email.as_deref(), Some("ada@example.com")); + assert_eq!(snap.account_organization.as_deref(), Some("Acme Biz")); + assert_eq!(snap.login_method.as_deref(), Some("Business")); + } + + #[test] + fn billing_only_window_becomes_primary() { + let status = parse_rate_limit_status(&json!({ + "status": "ok", + "billingPeriodWindow": { + "used": 10.0, + "limit": 40.0, + "periodEndMs": 1_735_689_600_000.0 + } + })) + .unwrap(); + let now = Utc::now(); + let snap = build_usage_snapshot(&status, None, None, now).unwrap(); + assert!((snap.primary.used_percent - 25.0).abs() < f64::EPSILON); + assert_eq!( + snap.primary.window_minutes, + Some(MONTHLY_WINDOW_SENTINEL_MINUTES) + ); + assert!(snap.secondary.is_none()); + } + + #[test] + fn preferred_workspace_id_is_honored_including_undashed() { + let account = parse_spaces(&sample_spaces_json()).unwrap(); + let preferred = account + .resolve_workspace(Some("99999999888877776666555555555555")) + .unwrap(); + assert_eq!(preferred.name.as_deref(), Some("Nested Ent")); + assert_eq!(preferred.subscription_tier.as_deref(), Some("enterprise")); + + let dashed = account + .resolve_workspace(Some("99999999-8888-7777-6666-555555555555")) + .unwrap(); + assert_eq!(dashed.id, preferred.id); + } + + #[test] + fn unknown_preferred_workspace_falls_back_to_business() { + let account = parse_spaces(&sample_spaces_json()).unwrap(); + let ws = account + .resolve_workspace(Some("00000000-0000-0000-0000-000000000000")) + .unwrap(); + assert_eq!(ws.name.as_deref(), Some("Acme Biz")); + } + + #[test] + fn auto_selects_first_business_or_enterprise_workspace() { + let account = parse_spaces(&sample_spaces_json()).unwrap(); + let ws = account.resolve_workspace(None).unwrap(); + // Sorted keys: 1111... (business) before 9999... (enterprise) before aaaa... (personal) + assert_eq!(ws.subscription_tier.as_deref(), Some("business")); + } + + #[test] + fn not_applicable_status_parses_and_is_flagged() { + let status = parse_rate_limit_status(&json!({ + "status": "not_applicable" + })) + .unwrap(); + assert!(status.is_not_applicable()); + } + + #[test] + fn empty_rate_limit_body_is_rejected() { + let err = parse_rate_limit_status(&json!({"status": "ok"})).unwrap_err(); + match err { + ProviderError::Parse(msg) => assert!(msg.contains("no usage windows")), + other => panic!("unexpected error: {other}"), + } + } + + #[test] + fn rolling_minutes_drop_monthly_sentinel_token() { + assert_eq!(rolling_minutes(Some("6h")), Some(360)); + assert_eq!(rolling_minutes(Some("30d")), None); + assert_eq!(rolling_minutes(Some("720h")), None); + assert_eq!(minutes_from_window_token(Some("1w")), Some(7 * 24 * 60)); + } + + #[test] + fn normalize_cookie_accepts_header_and_bare_token() { + assert_eq!( + normalize_cookie_header("token_v2=abc; other=1").as_deref(), + Some("token_v2=abc; other=1") + ); + assert_eq!( + normalize_cookie_header("Cookie: token_v2=abc").as_deref(), + Some("token_v2=abc") + ); + assert_eq!( + normalize_cookie_header("just-the-token-value").as_deref(), + Some("token_v2=just-the-token-value") + ); + assert!(normalize_cookie_header(" ").is_none()); + } + + #[test] + fn normalize_space_id_dashes_32_hex() { + assert_eq!( + normalize_space_id(Some("11111111222233334444555555555555")).as_deref(), + Some("11111111-2222-3333-4444-555555555555") + ); + assert_eq!( + normalize_space_id(Some("11111111-2222-3333-4444-555555555555")).as_deref(), + Some("11111111-2222-3333-4444-555555555555") + ); + } + + #[test] + fn nested_value_records_parse() { + let account = parse_spaces(&sample_spaces_json()).unwrap(); + assert!( + account + .workspaces + .iter() + .any(|w| w.name.as_deref() == Some("Nested Ent")) + ); + } +} diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index 01d99a3f35..5eba63d982 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -95,10 +95,11 @@ impl LocalUsageSnapshot { Some(now + Duration::seconds(self.weekly_reset_in_sec)), None, )); + let monthly_reset = now + Duration::seconds(self.monthly_reset_in_sec); snap = snap.with_tertiary(RateWindow::with_details( self.monthly_usage_percent, - Some(43200), - Some(now + Duration::seconds(self.monthly_reset_in_sec)), + RateWindow::monthly_window_minutes(Some(monthly_reset)).or(Some(43200)), + Some(monthly_reset), None, )); ProviderFetchResult::new(snap, "local") @@ -196,10 +197,7 @@ fn has_auth_key(path: &Path) -> bool { } fn read_rows(db_path: &Path) -> Result, ProviderError> { - let conn = - Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|e| { - ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}")) - })?; + let conn = open_readonly_connection(db_path)?; conn.busy_timeout(std::time::Duration::from_millis(250)) .map_err(|e| { ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}")) @@ -237,6 +235,75 @@ fn read_rows(db_path: &Path) -> Result, ProviderError> { Ok(out) } +/// Open a read-only connection without creating `-wal`/`-shm` sidecars for idle +/// WAL-mode databases (upstream #2544). +fn open_readonly_connection(db_path: &Path) -> Result { + let map_err = |e: rusqlite::Error| { + ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}")) + }; + + // Prefer immutable URI when sidecars are absent so a clean WAL shutdown + // (header still WAL, no -wal/-shm) does not recreate them on open. + if wal_sidecars_missing(db_path) + && let Ok(conn) = open_immutable(db_path) + { + return Ok(conn); + } + + match Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) { + Ok(conn) => Ok(conn), + Err(err) if is_cant_open(&err) && wal_sidecars_missing(db_path) => { + open_immutable(db_path).map_err(map_err) + } + Err(err) => Err(map_err(err)), + } +} + +fn open_immutable(db_path: &Path) -> Result { + let uri = sqlite_immutable_uri(db_path); + Connection::open_with_flags( + uri, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) +} + +fn sqlite_immutable_uri(db_path: &Path) -> String { + let abs = db_path + .canonicalize() + .unwrap_or_else(|_| db_path.to_path_buf()); + let raw = abs.to_string_lossy(); + // Windows canonicalize() yields `\\?\C:\...`; strip that for SQLite URIs. + let stripped = raw + .strip_prefix(r"\\?\") + .or_else(|| raw.strip_prefix("//?/")) + .unwrap_or(raw.as_ref()); + let path = stripped.replace('\\', "/"); + // Prefer `file:` (no authority) so drive letters stay valid on Windows. + format!("file:{path}?immutable=1") +} + +fn wal_sidecars_missing(db_path: &Path) -> bool { + let wal = sidecar_path(db_path, "-wal"); + let shm = sidecar_path(db_path, "-shm"); + !wal.exists() && !shm.exists() +} + +fn sidecar_path(db_path: &Path, suffix: &str) -> PathBuf { + let mut s = db_path.as_os_str().to_os_string(); + s.push(suffix); + PathBuf::from(s) +} + +fn is_cant_open(err: &rusqlite::Error) -> bool { + matches!( + err.sqlite_error_code(), + Some(rusqlite::ErrorCode::CannotOpen) + ) || err + .to_string() + .to_ascii_lowercase() + .contains("unable to open") +} + fn has_table(conn: &Connection, name: &str) -> bool { conn.query_row( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1", @@ -535,4 +602,61 @@ mod tests { assert_eq!(start, expected); assert_eq!(wed.weekday(), Weekday::Wed); } + + #[test] + fn idle_wal_mode_read_creates_no_sidecars() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let auth = dir.path().join("auth.json"); + std::fs::write(&auth, r#"{"opencode-go":{"key":"k"}}"#).unwrap(); + + // Build a WAL-mode DB, insert a row, leave journal_mode=WAL, then drop + // any writer-created sidecars so the main file is an idle WAL header. + { + let conn = Connection::open(&db).unwrap(); + conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap(); + conn.execute_batch( + "CREATE TABLE message ( + id TEXT PRIMARY KEY, + data TEXT, + time_created INTEGER + );", + ) + .unwrap(); + let now = Utc::now(); + let created = now.timestamp_millis() - 1_000; + let data = format!( + r#"{{"providerID":"opencode-go","role":"assistant","cost":3,"time":{{"created":{created}}}}}"# + ); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES ('m1', ?1, ?2)", + rusqlite::params![data, created], + ) + .unwrap(); + // Truncate empties WAL content before close; journal_mode stays WAL. + let _ = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())); + drop(conn); + } + + // Ensure idle-WAL case: header says WAL, no live sidecars. + let wal = sidecar_path(&db, "-wal"); + let shm = sidecar_path(&db, "-shm"); + // Prefer rename-away over delete if OS still holds handles. + for side in [&wal, &shm] { + if side.exists() { + let parked = side.with_extension("parked"); + let _ = std::fs::rename(side, parked); + } + } + assert!(!wal.exists(), "precondition: no -wal"); + assert!(!shm.exists(), "precondition: no -shm"); + + let snap = fetch_from_paths(&auth, &db, Utc::now()).expect("read idle WAL db"); + assert!(snap.rolling_usage_percent > 0.0, "{snap:?}"); + + assert!( + !wal.exists() && !shm.exists(), + "reader must not create -wal/-shm sidecars" + ); + } } diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index a019908986..d9fe01d507 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -157,10 +157,11 @@ impl OpenCodeGoProvider { } if let Some((pct, reset)) = monthly { + let resets_at = now + chrono::Duration::seconds(reset); snap = snap.with_tertiary(RateWindow::with_details( pct, - Some(43200), - Some(now + chrono::Duration::seconds(reset)), + RateWindow::monthly_window_minutes(Some(resets_at)).or(Some(43200)), + Some(resets_at), None, )); } @@ -491,6 +492,9 @@ mod tests { assert!((secondary.used_percent - 13.0).abs() < 0.001); let tertiary = snap.tertiary.expect("monthly"); assert!((tertiary.used_percent - 7.0).abs() < 0.001); + let expected = RateWindow::monthly_window_minutes(tertiary.resets_at).or(Some(43200)); + assert_eq!(tertiary.window_minutes, expected); + assert!(tertiary.resets_at.is_some()); } #[test] diff --git a/rust/src/providers/xai/mod.rs b/rust/src/providers/xai/mod.rs new file mode 100644 index 0000000000..b6c07f7825 --- /dev/null +++ b/rust/src/providers/xai/mod.rs @@ -0,0 +1,811 @@ +//! xAI developer-platform provider (Management API billing). +//! +//! Intentionally separate from the Grok consumer provider: +//! - `GET https://management-api.x.ai/v1/billing/teams/{team_id}/prepaid/balance` +//! - `POST https://management-api.x.ai/v1/billing/teams/{team_id}/usage` +//! +//! Ported from steipete/CodexBar v0.47.0 `XAIBillingFetcher` / +//! `XAIProviderDescriptor` / `XAIUsageSnapshot`. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::core::{ + CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; + +const BASE_URL: &str = "https://management-api.x.ai"; +const CREDENTIAL_TARGET: &str = "codexbar-xai"; +const ENV_API_KEYS: &[&str] = &["XAI_MANAGEMENT_API_KEY"]; +const ENV_TEAM_ID: &str = "XAI_TEAM_ID"; +const HISTORY_DAYS: i64 = 30; +const REQUEST_TIMEOUT_SECS: u64 = 15; + +#[derive(Debug, Deserialize)] +struct BalanceEnvelope { + total: BalanceAmount, +} + +#[derive(Debug, Deserialize)] +struct BalanceAmount { + /// Inverted ledger cents as a string (a $10 top-up is "-1000"). + val: String, +} + +#[derive(Debug, Deserialize)] +struct UsageEnvelope { + #[serde(default, rename = "timeSeries")] + time_series: Vec, + #[serde(default, rename = "limitReached")] + limit_reached: Option, +} + +#[derive(Debug, Deserialize)] +struct UsageSeries { + #[serde(default, rename = "dataPoints")] + data_points: Vec, +} + +#[derive(Debug, Deserialize)] +struct UsageDataPoint { + timestamp: String, + #[serde(default)] + values: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageRequestEnvelope { + analytics_request: AnalyticsRequest, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyticsRequest { + time_range: TimeRange, + time_unit: &'static str, + values: [AnalyticsValue; 1], + group_by: [String; 0], + filters: [String; 0], +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct TimeRange { + start_time: String, + end_time: String, + timezone: &'static str, +} + +#[derive(Debug, Serialize)] +struct AnalyticsValue { + name: &'static str, + aggregation: &'static str, +} + +#[derive(Debug, Clone, PartialEq)] +struct DailyBucket { + day: String, + cost_usd: f64, +} + +#[derive(Debug, Clone, PartialEq)] +struct XaiUsageSnapshot { + balance_usd: f64, + daily: Vec, + history_days: i64, + limit_reached: bool, + updated_at: DateTime, +} + +impl XaiUsageSnapshot { + fn window_cost_usd(&self) -> f64 { + self.daily.iter().map(|b| b.cost_usd).sum() + } + + fn history_window_period_label(&self) -> String { + let base = if self.history_days == 1 { + "Today".to_string() + } else { + format!("Last {} days", self.history_days) + }; + if self.limit_reached { + format!("{base} (partial)") + } else { + base + } + } + fn to_usage_snapshot(&self) -> UsageSnapshot { + let balance_text = format_balance_line(self.balance_usd); + let mut detail = balance_text.clone(); + if !self.daily.is_empty() { + detail = format!( + "{detail} · {}: ${:.2}", + self.history_window_period_label(), + self.window_cost_usd() + ); + } + + // Prepaid money is not a quota meter. + UsageSnapshot::new(RateWindow::informational(detail)).with_login_method("Management API") + } + + fn to_cost_snapshot(&self) -> CostSnapshot { + // Menu card prefers `balance` when limit is absent (shows period title + balance). + // `used` carries the best-effort 30-day window spend for any secondary UI. + let mut cost = CostSnapshot::new(self.window_cost_usd().max(0.0), "USD", "Prepaid credits"); + // `with_balance` clamps negatives to 0; deficit is still shown in the + // primary reset description above. + if self.balance_usd.is_finite() && self.balance_usd >= 0.0 { + cost = cost.with_balance(self.balance_usd); + } else if self.balance_usd.is_finite() { + cost = cost.with_balance(0.0); + } + cost + } +} + +fn format_balance_line(balance_usd: f64) -> String { + if balance_usd < 0.0 { + format!("Deficit: ${:.2}", -balance_usd) + } else { + format!("Balance: ${:.2}", balance_usd) + } +} + +pub struct XaiProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl XaiProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Xai, + display_name: "xAI", + session_label: "Spend", + weekly_label: "Spend", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://console.x.ai"), + status_page_url: Some("https://status.x.ai"), + }, + client: crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + fn resolve_api_key(api_key: Option<&str>) -> Result { + let raw = crate::providers::resolve_api_key(api_key, CREDENTIAL_TARGET, ENV_API_KEYS)?; + clean_value(&raw).ok_or_else(|| { + ProviderError::NotInstalled( + "Missing xAI Management API key. Add one in Settings or set XAI_MANAGEMENT_API_KEY. \ + Inference API keys are not accepted by the Management API." + .to_string(), + ) + }) + } + + fn resolve_team_id(workspace_id: Option<&str>) -> Result { + if let Some(id) = workspace_id.and_then(clean_value) { + validate_team_id(&id)?; + return Ok(id); + } + if let Ok(env) = std::env::var(ENV_TEAM_ID) + && let Some(id) = clean_value(&env) + { + validate_team_id(&id)?; + return Ok(id); + } + Err(ProviderError::NotInstalled( + "Missing xAI team ID. Add it in Settings or set XAI_TEAM_ID \ + (shown in the xAI Console URL and team settings)." + .to_string(), + )) + } + + async fn fetch_usage_api( + &self, + ctx: &FetchContext, + ) -> Result { + let api_key = Self::resolve_api_key(ctx.api_key.as_deref())?; + let team_id = Self::resolve_team_id(ctx.workspace_id.as_deref())?; + let now = Utc::now(); + let snapshot = self.fetch_usage_snapshot(&api_key, &team_id, now).await?; + Ok( + ProviderFetchResult::new(snapshot.to_usage_snapshot(), "api") + .with_cost(snapshot.to_cost_snapshot()), + ) + } + + async fn fetch_usage_snapshot( + &self, + api_key: &str, + team_id: &str, + now: DateTime, + ) -> Result { + let balance_usd = self.fetch_balance_usd(api_key, team_id).await?; + + // History is best-effort enrichment: the balance is independently useful, + // so only credential problems escalate from the usage call. + let (daily, limit_reached) = match self.fetch_daily_usage(api_key, team_id, now).await { + Ok(pair) => pair, + Err(ProviderError::AuthRequired) => return Err(ProviderError::AuthRequired), + Err(err) if is_auth_like(&err) => return Err(err), + Err(_) => (Vec::new(), false), + }; + + Ok(XaiUsageSnapshot { + balance_usd, + daily, + history_days: HISTORY_DAYS, + limit_reached, + updated_at: now, + }) + } + + async fn fetch_balance_usd(&self, api_key: &str, team_id: &str) -> Result { + let url = team_url(team_id, &["prepaid", "balance"])?; + let response = self + .client + .get(url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .send() + .await?; + map_status_error(response.status())?; + let envelope: BalanceEnvelope = response + .json() + .await + .map_err(|e| ProviderError::Parse(format!("Could not parse xAI billing data: {e}")))?; + balance_usd_from_ledger_cents(&envelope.total.val) + } + + async fn fetch_daily_usage( + &self, + api_key: &str, + team_id: &str, + now: DateTime, + ) -> Result<(Vec, bool), ProviderError> { + let url = team_url(team_id, &["usage"])?; + let body = usage_request_body(now); + let response = self + .client + .post(url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + map_status_error(response.status())?; + + let envelope: UsageEnvelope = response + .json() + .await + .map_err(|e| ProviderError::Parse(format!("Could not parse xAI usage history: {e}")))?; + + aggregate_daily_usage(&envelope) + } +} + +fn aggregate_daily_usage( + envelope: &UsageEnvelope, +) -> Result<(Vec, bool), ProviderError> { + let mut totals: BTreeMap = BTreeMap::new(); + for series in &envelope.time_series { + for point in &series.data_points { + let day = utc_day_from_timestamp(&point.timestamp)?; + let value = point.values.first().copied().unwrap_or(0.0); + *totals.entry(day).or_default() += value; + } + } + let daily = totals + .into_iter() + .map(|(day, cost_usd)| DailyBucket { day, cost_usd }) + .collect(); + Ok((daily, envelope.limit_reached.unwrap_or(false))) +} + +impl Default for XaiProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for XaiProvider { + fn id(&self) -> ProviderId { + ProviderId::Xai + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_api(ctx).await, + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + // Upstream source modes: auto + api (OAuth slot = API key path here). + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +fn clean_value(raw: &str) -> Option { + let mut value = raw.trim().to_string(); + if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + value = value[1..value.len() - 1].trim().to_string(); + } + if let Some(stripped) = value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer ")) + { + value = stripped.trim().to_string(); + } + (!value.is_empty()).then_some(value) +} + +fn validate_team_id(team_id: &str) -> Result<(), ProviderError> { + if team_id.contains('/') || team_id == "." || team_id == ".." { + return Err(ProviderError::Other( + "The xAI team ID must be a single identifier without path separators.".to_string(), + )); + } + Ok(()) +} + +/// The ledger records credit as negative cents (a $10 top-up is "-1000"), +/// so remaining balance is the negated cent value in dollars. +fn balance_usd_from_ledger_cents(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err(ProviderError::Parse(format!( + "balance total.val is not a cent amount: {raw}" + ))); + } + // Match upstream: optional leading minus, digits, optional fractional part. + let valid = value + .bytes() + .enumerate() + .all(|(i, b)| b.is_ascii_digit() || b == b'.' || (i == 0 && b == b'-')) + && value.chars().filter(|c| *c == '.').count() <= 1 + && value != "-" + && value != "." + && value != "-."; + if !valid { + return Err(ProviderError::Parse(format!( + "balance total.val is not a cent amount: {raw}" + ))); + } + let cents: f64 = value.parse().map_err(|_| { + ProviderError::Parse(format!("balance total.val is not a cent amount: {raw}")) + })?; + if !cents.is_finite() { + return Err(ProviderError::Parse(format!( + "balance total.val is not a cent amount: {raw}" + ))); + } + Ok(-cents / 100.0) +} + +fn team_url(team_id: &str, suffix: &[&str]) -> Result { + let mut url = reqwest::Url::parse(BASE_URL) + .map_err(|e| ProviderError::Other(format!("Invalid xAI base URL: {e}")))?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| ProviderError::Other("Invalid xAI base URL path".into()))?; + segments.push("v1"); + segments.push("billing"); + segments.push("teams"); + segments.push(team_id); + for part in suffix { + segments.push(part); + } + } + Ok(url) +} + +fn usage_request_body(now: DateTime) -> UsageRequestEnvelope { + let window_start = (now.date_naive() - Duration::days(HISTORY_DAYS - 1)) + .and_hms_opt(0, 0, 0) + .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) + .unwrap_or(now); + UsageRequestEnvelope { + analytics_request: AnalyticsRequest { + time_range: TimeRange { + start_time: format_request_timestamp(window_start), + end_time: format_request_timestamp(now), + timezone: "Etc/GMT", + }, + time_unit: "TIME_UNIT_DAY", + values: [AnalyticsValue { + name: "usd", + aggregation: "AGGREGATION_SUM", + }], + group_by: [], + filters: [], + }, + } +} + +fn format_request_timestamp(dt: DateTime) -> String { + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + dt.year(), + dt.month(), + dt.day(), + dt.hour(), + dt.minute(), + dt.second() + ) +} + +fn utc_day_from_timestamp(timestamp: &str) -> Result { + let parsed = DateTime::parse_from_rfc3339(timestamp) + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|_| { + // Fractional seconds without offset, or plain date. + DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.fZ") + .map(|dt| dt.with_timezone(&Utc)) + }) + .or_else(|_| { + NaiveDate::parse_from_str(timestamp, "%Y-%m-%d") + .ok() + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) + .ok_or_else(|| { + ProviderError::Parse(format!("usage timestamp is not ISO 8601: {timestamp}")) + }) + }) + .map_err(|_| { + ProviderError::Parse(format!("usage timestamp is not ISO 8601: {timestamp}")) + })?; + Ok(parsed.format("%Y-%m-%d").to_string()) +} + +fn map_status_error(status: reqwest::StatusCode) -> Result<(), ProviderError> { + if status.is_success() { + return Ok(()); + } + match status.as_u16() { + 401 | 403 => Err(ProviderError::Other( + "xAI rejected the Management API key. Create one in the xAI Console under \ + Settings > Management Keys; inference API keys are not accepted." + .to_string(), + )), + 404 => Err(ProviderError::Other( + "xAI returned 404 for this team. Check the team ID, and that the Management key \ + belongs to the same team." + .to_string(), + )), + 429 => Err(ProviderError::Other( + "xAI Management API rate limit exceeded. Usage will refresh on the next cycle." + .to_string(), + )), + code => Err(ProviderError::Other(format!( + "xAI Management API returned HTTP {code}." + ))), + } +} + +fn is_auth_like(err: &ProviderError) -> bool { + match err { + ProviderError::AuthRequired => true, + ProviderError::Other(msg) => { + msg.contains("rejected the Management API key") || msg.contains("HTTP 401") + } + _ => false, + } +} + +/// Parse fixture JSON without network (unit tests). +fn parse_snapshot_for_testing( + balance_json: &str, + usage_json: Option<&str>, + now: DateTime, +) -> Result { + let envelope: BalanceEnvelope = serde_json::from_str(balance_json) + .map_err(|e| ProviderError::Parse(format!("Could not parse xAI billing data: {e}")))?; + let balance_usd = balance_usd_from_ledger_cents(&envelope.total.val)?; + + let (daily, limit_reached) = if let Some(usage_json) = usage_json { + match serde_json::from_str::(usage_json) { + Ok(envelope) => aggregate_daily_usage(&envelope)?, + Err(_) => (Vec::new(), false), + } + } else { + (Vec::new(), false) + }; + + Ok(XaiUsageSnapshot { + balance_usd, + daily, + history_days: HISTORY_DAYS, + limit_reached, + updated_at: now, + }) +} + +/// Encode the usage request body for assertion in tests. +fn usage_request_json_for_testing(now: DateTime) -> Value { + serde_json::to_value(usage_request_body(now)).expect("usage request serializes") +} + +#[cfg(test)] +mod tests { + use super::*; + + const BALANCE_FIXTURE: &str = r#"{ + "changes": [ + { + "teamId": "team-1234", + "changeOrigin": "PURCHASE", + "topupStatus": "SUCCEEDED", + "amount": { "val": "-1000" }, + "invoiceId": "fixture-invoice-id", + "invoiceNumber": "000-000-000-001", + "createTime": "2026-12-24T15:28:02.308840Z", + "paymentProcessor": { "kind": "STRIPE" } + } + ], + "total": { "val": "-1000" } + }"#; + + const USAGE_FIXTURE: &str = r#"{ + "timeSeries": [ + { + "group": ["Chat grok-4-fixture"], + "groupLabels": ["Chat grok-4-fixture"], + "dataPoints": [ + { "timestamp": "2027-01-13T00:00:00Z", "values": [0.75973725] }, + { "timestamp": "2027-01-14T00:00:00Z", "values": [0.5] }, + { "timestamp": "2027-01-15T00:00:00Z", "values": [0] } + ] + }, + { + "group": ["Live search"], + "groupLabels": ["Live search"], + "dataPoints": [ + { "timestamp": "2027-01-13T00:00:00Z", "values": [0.5] }, + { "timestamp": "2027-01-14T00:00:00Z", "values": [0] }, + { "timestamp": "2027-01-15T00:00:00Z", "values": [0] } + ] + } + ], + "limitReached": false + }"#; + + fn fixture_now() -> DateTime { + // 2027-01-15 08:00:00 UTC + DateTime::from_timestamp(1_800_000_000, 0).unwrap() + } + + #[test] + fn cleans_whitespace_and_quotes() { + assert_eq!( + clean_value(" 'fixture-management-key' ").as_deref(), + Some("fixture-management-key") + ); + assert_eq!(clean_value(" "), None); + assert_eq!(clean_value(" \"team-1234\" ").as_deref(), Some("team-1234")); + assert_eq!( + clean_value("Bearer xai-mgmt-key").as_deref(), + Some("xai-mgmt-key") + ); + } + + #[test] + fn balance_ledger_mapping() { + assert!((balance_usd_from_ledger_cents("-1000").unwrap() - 10.0).abs() < 1e-9); + assert!((balance_usd_from_ledger_cents("2500").unwrap() - (-25.0)).abs() < 1e-9); + assert!((balance_usd_from_ledger_cents("0").unwrap()).abs() < 1e-9); + assert!((balance_usd_from_ledger_cents("-333").unwrap() - 3.33).abs() < 1e-9); + for bad in ["", "n/a", "12abc", " "] { + assert!( + balance_usd_from_ledger_cents(bad).is_err(), + "expected err for {bad:?}" + ); + } + } + + #[test] + fn team_id_path_separators_rejected() { + assert!(validate_team_id("team-1234").is_ok()); + assert!(validate_team_id("team/../other").is_err()); + assert!(validate_team_id(".").is_err()); + assert!(validate_team_id("..").is_err()); + } + + #[test] + fn usage_request_window_matches_upstream() { + let body = usage_request_json_for_testing(fixture_now()); + let analytics = &body["analyticsRequest"]; + let time_range = &analytics["timeRange"]; + assert_eq!(time_range["startTime"], "2026-12-17 00:00:00"); + assert_eq!(time_range["endTime"], "2027-01-15 08:00:00"); + assert_eq!(time_range["timezone"], "Etc/GMT"); + assert_eq!(analytics["timeUnit"], "TIME_UNIT_DAY"); + assert_eq!(analytics["values"][0]["name"], "usd"); + assert_eq!(analytics["values"][0]["aggregation"], "AGGREGATION_SUM"); + assert_eq!(analytics["groupBy"], Value::Array(vec![])); + assert_eq!(analytics["filters"], Value::Array(vec![])); + } + + #[test] + fn parses_balance_and_sums_daily_usage() { + let snapshot = + parse_snapshot_for_testing(BALANCE_FIXTURE, Some(USAGE_FIXTURE), fixture_now()) + .unwrap(); + assert!((snapshot.balance_usd - 10.0).abs() < 1e-9); + assert!(!snapshot.limit_reached); + assert_eq!(snapshot.history_days, 30); + assert_eq!( + snapshot + .daily + .iter() + .map(|b| b.day.as_str()) + .collect::>(), + vec!["2027-01-13", "2027-01-14", "2027-01-15"] + ); + assert!((snapshot.daily[0].cost_usd - 1.25973725).abs() < 1e-9); + assert!((snapshot.daily[1].cost_usd - 0.5).abs() < 1e-9); + assert!((snapshot.daily[2].cost_usd).abs() < 1e-9); + + let usage = snapshot.to_usage_snapshot(); + assert_eq!(usage.login_method.as_deref(), Some("Management API")); + assert!( + usage + .primary + .reset_description + .as_deref() + .unwrap_or("") + .contains("Balance: $10.00") + ); + assert!(usage.primary.is_informational); + + let cost = snapshot.to_cost_snapshot(); + assert_eq!(cost.balance, Some(10.0)); + assert_eq!(cost.period, "Prepaid credits"); + assert!((cost.used - 1.75973725).abs() < 1e-9); + assert!(cost.limit.is_none()); + } + + #[test] + fn malformed_balance_is_parse_error_not_zero() { + for body in [ + "{}", + r#"{"total":{}}"#, + r#"{"total":{"val":""}}"#, + r#"{"total":{"val":"n/a"}}"#, + r#"{"error":"forbidden"}"#, + ] { + let err = + parse_snapshot_for_testing(body, Some(USAGE_FIXTURE), fixture_now()).unwrap_err(); + match err { + ProviderError::Parse(_) => {} + other => panic!("expected parse error for {body}, got {other:?}"), + } + } + } + + #[test] + fn malformed_usage_degrades_to_balance_only() { + let snapshot = parse_snapshot_for_testing( + BALANCE_FIXTURE, + Some(r#"{"object":"list"}"#), + fixture_now(), + ) + .unwrap(); + assert!((snapshot.balance_usd - 10.0).abs() < 1e-9); + assert!(snapshot.daily.is_empty()); + } + + #[test] + fn limit_reached_labels_history_partial() { + let body = USAGE_FIXTURE.replace(r#""limitReached": false"#, r#""limitReached": true"#); + let snapshot = + parse_snapshot_for_testing(BALANCE_FIXTURE, Some(&body), fixture_now()).unwrap(); + assert!(snapshot.limit_reached); + assert_eq!( + snapshot.history_window_period_label(), + "Last 30 days (partial)" + ); + let usage = snapshot.to_usage_snapshot(); + assert!( + usage + .primary + .reset_description + .as_deref() + .unwrap_or("") + .contains("Last 30 days (partial)") + ); + } + + #[test] + fn team_url_encodes_path_component() { + let url = team_url("team one", &["prepaid", "balance"]).unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host_str(), Some("management-api.x.ai")); + assert!(url.as_str().contains("team%20one")); + assert!(!url.as_str().contains("team one")); + assert!(url.path().ends_with("/prepaid/balance")); + } + + #[test] + fn metadata_matches_upstream_descriptor() { + let provider = XaiProvider::new(); + assert_eq!(provider.id(), ProviderId::Xai); + assert_eq!(provider.metadata().display_name, "xAI"); + assert_eq!(provider.metadata().session_label, "Spend"); + assert_eq!( + provider.metadata().dashboard_url, + Some("https://console.x.ai") + ); + assert_eq!( + provider.metadata().status_page_url, + Some("https://status.x.ai") + ); + assert!(!provider.metadata().supports_credits); + assert!(!provider.metadata().default_enabled); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::OAuth] + ); + } + + #[test] + fn xai_is_distinct_from_grok() { + assert_ne!(ProviderId::Xai, ProviderId::Grok); + assert_eq!(ProviderId::Xai.cli_name(), "xai"); + assert_eq!(ProviderId::Grok.cli_name(), "grok"); + assert_eq!(ProviderId::from_cli_name("xai"), Some(ProviderId::Xai)); + assert_eq!(ProviderId::from_cli_name("x.ai"), Some(ProviderId::Xai)); + assert_eq!(ProviderId::from_cli_name("grok"), Some(ProviderId::Grok)); + assert_eq!( + ProviderId::from_cli_name("supergrok"), + Some(ProviderId::Grok) + ); + // XAI has no cookie domain; Grok still scrapes grok.com sessions. + assert_eq!(ProviderId::Xai.cookie_domain(), None); + assert_eq!(ProviderId::Grok.cookie_domain(), Some("grok.com")); + } + + #[test] + fn missing_credentials_are_actionable() { + let err = XaiProvider::resolve_api_key(Some(" ")).unwrap_err(); + match err { + ProviderError::NotInstalled(msg) => { + assert!(msg.contains("XAI_MANAGEMENT_API_KEY")); + } + other => panic!("expected NotInstalled, got {other:?}"), + } + let err = XaiProvider::resolve_team_id(Some(" ")).unwrap_err(); + match err { + ProviderError::NotInstalled(msg) => { + assert!(msg.contains("XAI_TEAM_ID")); + } + other => panic!("expected NotInstalled, got {other:?}"), + } + } +} diff --git a/rust/src/providers/zai/mod.rs b/rust/src/providers/zai/mod.rs index 2579a3a127..5d8f5bdacd 100755 --- a/rust/src/providers/zai/mod.rs +++ b/rust/src/providers/zai/mod.rs @@ -336,7 +336,45 @@ impl ZaiProvider { .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) .map(|timestamp| timestamp.with_timezone(&Utc)) }); - RateWindow::with_details(compute_percent(l), window_mins, resets_at, None) + let reset_description = rate_window_description(l, window_mins); + RateWindow::with_details( + compute_percent(l), + window_mins, + resets_at, + reset_description, + ) + } + + fn rate_window_minutes(l: &ZaiLimit) -> Option { + // Upstream #2431 / #2566: verified z.ai duration table. + // MCP 1-minute marker and bare TIME_LIMIT → monthly sentinel; + // explicit unit/number durations kept as-is; tokens use computed minutes. + if is_mcp_monthly_marker(l) { + return Some(30 * 24 * 60); + } + let explicit = ZaiProvider::window_minutes(l); + let is_time = matches!(l.limit_type.as_deref(), Some("TIME_LIMIT") | Some("mcp")); + if is_time { + explicit.or(Some(30 * 24 * 60)) + } else { + explicit + } + } + + fn rate_window_description(l: &ZaiLimit, window_mins: Option) -> Option { + if is_mcp_monthly_marker(l) { + return Some("Monthly".into()); + } + if let Some(label) = window_label(l) { + return Some(label); + } + if matches!(l.limit_type.as_deref(), Some("TIME_LIMIT") | Some("mcp")) { + // Bare time limit with monthly sentinel. + if window_mins == Some(30 * 24 * 60) { + return Some("Monthly".into()); + } + } + None } // Build windows based on upstream layout: @@ -346,22 +384,22 @@ impl ZaiProvider { 0 => { // No token limits; use time_limit as primary if available let p = time_limit - .map(|l| make_window(l, Self::window_minutes(l))) + .map(|l| make_window(l, rate_window_minutes(l))) .unwrap_or_else(|| RateWindow::new(0.0)); (p, None, None) } 1 => { - let p = make_window(token_limits[0], Self::window_minutes(token_limits[0])); - let s = time_limit.map(|l| make_window(l, Self::window_minutes(l))); + let p = make_window(token_limits[0], rate_window_minutes(token_limits[0])); + let s = time_limit.map(|l| make_window(l, rate_window_minutes(l))); (p, s, None) } _ => { // 2+ token limits: longest → primary (weekly), shortest → tertiary (5-hour) let weekly = token_limits.last().unwrap(); let session = token_limits.first().unwrap(); - let p = make_window(weekly, Self::window_minutes(weekly)); - let s = time_limit.map(|l| make_window(l, Self::window_minutes(l))); - let t = Some(make_window(session, Self::window_minutes(session))); + let p = make_window(weekly, rate_window_minutes(weekly)); + let s = time_limit.map(|l| make_window(l, rate_window_minutes(l))); + let t = Some(make_window(session, rate_window_minutes(session))); (p, s, t) } }; @@ -377,10 +415,11 @@ impl ZaiProvider { Ok(usage) } - /// Compute window_minutes from a limit's unit + number fields + /// Compute window_minutes from a limit's unit + number fields. + /// Returns `None` when number ≤ 0 or unit is unknown (upstream windowMinutes). fn window_minutes(l: &ZaiLimit) -> Option { + let number = l.number.filter(|&n| n > 0)? as u32; let unit = l.unit?; - let number = l.number.unwrap_or(1) as u32; let minutes_per_unit = match unit { 1 => 1440, // days 3 => 60, // hours @@ -392,6 +431,50 @@ impl ZaiProvider { } } +fn is_mcp_monthly_marker(l: &ZaiLimit) -> bool { + // Upstream: timeLimit + unit.minutes + number == 1 → monthly MCP marker. + matches!(l.limit_type.as_deref(), Some("TIME_LIMIT") | Some("mcp")) + && l.unit == Some(5) + && l.number == Some(1) +} + +fn window_label(l: &ZaiLimit) -> Option { + let number = l.number.filter(|&n| n > 0)?; + let unit = l.unit?; + let unit_label = match unit { + 1 => { + if number == 1 { + "day" + } else { + "days" + } + } + 3 => { + if number == 1 { + "hour" + } else { + "hours" + } + } + 5 => { + if number == 1 { + "minute" + } else { + "minutes" + } + } + 6 => { + if number == 1 { + "week" + } else { + "weeks" + } + } + _ => return None, + }; + Some(format!("{number} {unit_label} window")) +} + impl ZaiTeamContext { fn from_env() -> Option { let organization_id = std::env::var(ZAI_BIGMODEL_ORG_ENV) @@ -600,6 +683,86 @@ mod tests { assert!(usage.primary.resets_at.is_some()); } + #[test] + fn time_limit_with_explicit_duration_keeps_minutes() { + let provider = ZaiProvider::new(); + let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ + "code": 200, + "data": { + "limits": [{ + "type": "TIME_LIMIT", + "unit": 3, + "number": 5, + "usage": 100, + "currentValue": 20, + "remaining": 80, + "percentage": 25, + "nextResetTime": 123000_i64 + }] + } + })) + .unwrap(); + let usage = provider.parse_quota_response("a).unwrap(); + assert_eq!(usage.primary.window_minutes, Some(300)); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("5 hours window") + ); + } + + #[test] + fn time_limit_without_duration_uses_monthly_sentinel() { + let provider = ZaiProvider::new(); + let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ + "code": 200, + "data": { + "limits": [{ + "type": "TIME_LIMIT", + "unit": 1, + "number": 0, + "usage": 100, + "currentValue": 20, + "remaining": 80, + "percentage": 25, + "nextResetTime": 123000_i64 + }] + } + })) + .unwrap(); + let usage = provider.parse_quota_response("a).unwrap(); + assert_eq!(usage.primary.window_minutes, Some(30 * 24 * 60)); + assert_eq!(usage.primary.reset_description.as_deref(), Some("Monthly")); + } + + #[test] + fn mcp_one_minute_marker_is_monthly_sentinel() { + let provider = ZaiProvider::new(); + let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ + "code": 200, + "data": { + "limits": [ + { + "type": "TOKENS_LIMIT", + "unit": 6, + "number": 1, + "percentage": 34 + }, + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "percentage": 10 + } + ] + } + })) + .unwrap(); + let usage = provider.parse_quota_response("a).unwrap(); + let secondary = usage.secondary.expect("time limit secondary"); + assert_eq!(secondary.window_minutes, Some(30 * 24 * 60)); + assert_eq!(secondary.reset_description.as_deref(), Some("Monthly")); + } + #[test] fn preserves_api_code_error_message() { let provider = ZaiProvider::new(); diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 62163381cc..106a179c8e 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -50,6 +50,11 @@ pub struct Settings { #[serde(default)] pub refresh_all_providers_on_menu_open: bool, + /// When true, automatic background refresh is floored to once per 30 minutes. + /// Manual refresh stays immediate. + #[serde(default)] + pub low_power_mode: bool, + /// Whether to start minimized pub start_minimized: bool, @@ -420,6 +425,7 @@ impl Default for Settings { refresh_interval_secs: 300, // 5 minutes adaptive_refresh: false, refresh_all_providers_on_menu_open: false, + low_power_mode: false, start_minimized: false, start_at_login: false, show_notifications: true, diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 35c1fe0c24..9d40e74237 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -410,6 +410,17 @@ pub fn get_api_key_providers() -> Vec { config_file_path: Some("~/.grok/auth.json"), dashboard_url: Some("https://grok.com/settings/subscription"), }, + ProviderConfigInfo { + id: ProviderId::Xai, + name: "xAI", + requires_api_key: true, + api_key_env_var: Some("XAI_MANAGEMENT_API_KEY"), + api_key_help: Some( + "Create a Management API key at console.x.ai under Settings > Management Keys (inference keys are rejected). Team ID goes in provider workspace settings or XAI_TEAM_ID.", + ), + config_file_path: Some("~/.codexbar/config.json"), + dashboard_url: Some("https://console.x.ai"), + }, ProviderConfigInfo { id: ProviderId::ElevenLabs, name: "ElevenLabs", diff --git a/rust/src/settings/provider_workspace.rs b/rust/src/settings/provider_workspace.rs index 8206dd03e4..913670510e 100644 --- a/rust/src/settings/provider_workspace.rs +++ b/rust/src/settings/provider_workspace.rs @@ -31,6 +31,15 @@ pub fn validate_provider_workspace_value( .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') }), ProviderId::Zed => validate_zed_url(trimmed), + ProviderId::Xai => { + if trimmed.contains('/') || trimmed == "." || trimmed == ".." { + return Err( + "The xAI team ID must be a single identifier without path separators" + .to_string(), + ); + } + Ok(trimmed.to_string()) + } ProviderId::LiteLLM => validate_token_endpoint(trimmed, "LiteLLM base URL", |_| true), ProviderId::Sub2Api => validate_sub2api_base_url(trimmed), _ => Ok(trimmed.to_string()), diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 5730142fad..af21a03745 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -19,6 +19,9 @@ pub(super) struct RawSettings { #[serde(default)] adaptive_refresh: bool, refresh_all_providers_on_menu_open: bool, + #[serde(default)] + low_power_mode: bool, + start_minimized: bool, start_at_login: bool, show_notifications: bool, @@ -172,6 +175,7 @@ impl Default for RawSettings { refresh_interval_secs: s.refresh_interval_secs, adaptive_refresh: s.adaptive_refresh, refresh_all_providers_on_menu_open: s.refresh_all_providers_on_menu_open, + low_power_mode: s.low_power_mode, start_minimized: s.start_minimized, start_at_login: s.start_at_login, show_notifications: s.show_notifications, @@ -477,6 +481,7 @@ impl From for Settings { refresh_interval_secs: raw.refresh_interval_secs, adaptive_refresh: raw.adaptive_refresh, refresh_all_providers_on_menu_open: raw.refresh_all_providers_on_menu_open, + low_power_mode: raw.low_power_mode, start_minimized: raw.start_minimized, start_at_login: raw.start_at_login, show_notifications: raw.show_notifications, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 11195edfb6..ef6d55ca72 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -22,6 +22,24 @@ fn test_settings_default() { assert!(!settings.float_bar_show_cost); assert!(settings.promote_tray_icon); assert!(settings.claude_daily_routines_usage_visible); + assert!(!settings.low_power_mode); +} + +#[test] +fn low_power_mode_defaults_false_and_round_trips() { + let defaulted: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#) + .expect("missing low_power_mode defaults false"); + assert!(!defaulted.low_power_mode); + + let enabled = Settings { + low_power_mode: true, + ..Settings::default() + }; + let json = serde_json::to_string(&enabled).expect("serialize low_power_mode"); + assert!(json.contains("\"low_power_mode\":true")); + + let loaded: Settings = serde_json::from_str(&json).expect("deserialize low_power_mode"); + assert!(loaded.low_power_mode); } #[test] @@ -446,6 +464,7 @@ fn test_api_key_provider_catalog_includes_token_providers() { ProviderId::Grok, ProviderId::Groq, ProviderId::LLMProxy, + ProviderId::Xai, ] { assert!( providers.iter().any(|provider| provider.id == id),