diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5046b29688..b4921b56d8 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -116,6 +116,7 @@ export default defineConfig({ "**/inbox-live-update.spec.ts", "**/mesh-compute.spec.ts", "**/observer-archive-policy.spec.ts", + "**/provider-usage.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7e9d916be6..bb7e92756c 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -46,6 +46,7 @@ mod project_git_push; mod project_git_workflow; mod project_repo_paths; mod project_terminal; +mod provider_usage; mod qr_download; mod relay_members; mod relay_reconnect; @@ -96,6 +97,7 @@ pub use project_git_branches::*; pub use project_git_diff::*; pub use project_git_workflow::*; pub use project_terminal::*; +pub use provider_usage::*; pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; diff --git a/desktop/src-tauri/src/commands/provider_usage.rs b/desktop/src-tauri/src/commands/provider_usage.rs new file mode 100644 index 0000000000..2e9b0a3bd0 --- /dev/null +++ b/desktop/src-tauri/src/commands/provider_usage.rs @@ -0,0 +1,658 @@ +use serde::Serialize; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_FRAME_BYTES: usize = 256 * 1024; +const MAX_TOTAL_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ProviderUsageId { + Codex, + Claude, + Grok, +} + +impl ProviderUsageId { + fn parse(value: &str) -> Option { + match value { + "codex" => Some(Self::Codex), + "claude" => Some(Self::Claude), + "grok" => Some(Self::Grok), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Claude => "claude", + Self::Grok => "grok", + } + } +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// A provider shown in the provider-allowance experiment picker. +pub struct ProviderUsageCapability { + id: &'static str, + name: &'static str, + availability: &'static str, + detail: &'static str, +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One independently resetting personal-allowance window. +pub struct ProviderUsageWindow { + id: String, + label: String, + used_percent: u64, + remaining_percent: u64, + resets_at: Option, + duration_minutes: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Optional provider totals that are not tied to one allowance window. +pub struct ProviderUsageTotals { + credit_balance: Option, + reset_credits_available: Option, + lifetime_tokens: Option, + latest_daily_tokens: Option, + latest_daily_date: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// A normalized local personal-allowance snapshot safe to expose over IPC. +pub struct ProviderUsageSnapshot { + provider: &'static str, + vendor: &'static str, + product: &'static str, + source: &'static str, + plan_type: Option, + windows: Vec, + totals: ProviderUsageTotals, + fetched_at: u64, +} + +#[tauri::command] +/// Lists supported and explicitly unsupported personal-allowance adapters. +pub async fn list_provider_usage_capabilities() -> Vec { + let codex_availability = if crate::managed_agents::resolve_command("codex").is_some() { + ("available", "Uses your existing local Codex sign-in") + } else { + ("not_installed", "Install and sign in to the Codex CLI") + }; + + vec![ + ProviderUsageCapability { + id: ProviderUsageId::Codex.as_str(), + name: "Codex", + availability: codex_availability.0, + detail: codex_availability.1, + }, + ProviderUsageCapability { + id: ProviderUsageId::Claude.as_str(), + name: "Claude", + availability: "unsupported", + detail: "No supported standalone personal allowance reader yet", + }, + ProviderUsageCapability { + id: ProviderUsageId::Grok.as_str(), + name: "Grok", + availability: "unsupported", + detail: "Consumer allowance is available in Grok Settings", + }, + ] +} + +#[tauri::command] +/// Reads a normalized personal-allowance snapshot for a selected provider. +pub async fn get_provider_usage(provider: String) -> Result { + let provider = + ProviderUsageId::parse(&provider).ok_or_else(|| "provider_usage_unknown".to_string())?; + match provider { + ProviderUsageId::Codex => { + let codex_path = crate::managed_agents::resolve_command("codex") + .ok_or_else(|| "codex_not_installed".to_string())?; + tokio::task::spawn_blocking(move || read_codex_provider_usage(&codex_path)) + .await + .map_err(|_| "codex_usage_task_failed".to_string())? + } + ProviderUsageId::Claude => Err("claude_usage_unsupported".to_string()), + ProviderUsageId::Grok => Err("grok_usage_unsupported".to_string()), + } +} + +fn read_codex_provider_usage( + codex_path: &std::path::Path, +) -> Result { + let mut command = Command::new(codex_path); + command + .args(["app-server", "--stdio"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // App-server stderr can contain local configuration details. Only + // stable error codes cross the command boundary. + .stderr(Stdio::null()); + if let Some(workdir) = crate::managed_agents::default_agent_workdir() { + command.current_dir(workdir); + } + if let Some(path) = crate::managed_agents::login_shell_path() { + command.env("PATH", path); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Keep launchers and native descendants together so every exit path + // can reap the complete local app-server process tree. + command.process_group(0); + } + crate::util::configure_no_window(&mut command); + + let mut child = command + .spawn() + .map_err(|_| "codex_app_server_start_failed".to_string())?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| finish_with_error(&mut child, "codex_app_server_stdin_unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| finish_with_error(&mut child, "codex_app_server_stdout_unavailable"))?; + + write_message(&mut child, &mut stdin, &initialize_request())?; + + let (sender, receiver) = mpsc::channel(); + let mut reader = Some(std::thread::spawn(move || { + read_bounded_jsonl(stdout, sender) + })); + let deadline = Instant::now() + RESPONSE_TIMEOUT; + + if let Err(code) = wait_for_response(&receiver, deadline, 1) { + stop_child(&mut child); + join_reader(&mut reader); + return Err(code); + } + + let account_requests = post_initialize_requests(); + for message in &account_requests[..2] { + if let Err(code) = write_message(&mut child, &mut stdin, message) { + join_reader(&mut reader); + return Err(code); + } + } + + let rate_limits = match wait_for_response(&receiver, deadline, 2) { + Ok(result) => result, + Err(code) => { + stop_child(&mut child); + join_reader(&mut reader); + return Err(code); + } + }; + if let Err(code) = write_message(&mut child, &mut stdin, &account_requests[2]) { + join_reader(&mut reader); + return Err(code); + } + let token_usage = match wait_for_response(&receiver, deadline, 3) { + Ok(result) => result, + Err(code) => { + stop_child(&mut child); + join_reader(&mut reader); + return Err(code); + } + }; + + stop_child(&mut child); + join_reader(&mut reader); + normalize_usage(&rate_limits, &token_usage, unix_timestamp()) +} + +fn join_reader(reader: &mut Option>) { + if let Some(reader) = reader.take() { + let _ = reader.join(); + } +} + +fn write_message(child: &mut Child, stdin: &mut impl Write, message: &Value) -> Result<(), String> { + writeln!(stdin, "{message}") + .and_then(|_| stdin.flush()) + .map_err(|_| finish_with_error(child, "codex_app_server_write_failed")) +} + +fn initialize_request() -> Value { + json!({ + "method": "initialize", + "id": 1, + "params": { + "clientInfo": { + "name": "buzz_desktop", + "title": "Buzz Desktop", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { + "optOutNotificationMethods": [ + "thread/started", + "item/agentMessage/delta" + ] + } + } + }) +} + +fn post_initialize_requests() -> [Value; 3] { + [ + json!({"method": "initialized", "params": {}}), + json!({"method": "account/rateLimits/read", "id": 2, "params": null}), + json!({"method": "account/usage/read", "id": 3, "params": null}), + ] +} + +fn read_bounded_jsonl(stdout: impl Read, sender: mpsc::Sender>) { + let mut reader = BufReader::new(stdout); + let mut total = 0_usize; + let mut frame = Vec::new(); + loop { + let buffer = match reader.fill_buf() { + Ok(buffer) => buffer, + Err(_) => { + let _ = sender.send(Err("codex_app_server_read_failed".to_string())); + break; + } + }; + if buffer.is_empty() { + if !frame.is_empty() { + let _ = sender.send(Err("codex_app_server_read_failed".to_string())); + } + break; + } + + let newline = buffer.iter().position(|byte| *byte == b'\n'); + let bytes = newline.map_or(buffer.len(), |position| position + 1); + total = total.saturating_add(bytes); + if frame.len().saturating_add(bytes) > MAX_FRAME_BYTES || total > MAX_TOTAL_BYTES { + let _ = sender.send(Err("codex_usage_response_too_large".to_string())); + break; + } + frame.extend_from_slice(&buffer[..bytes]); + reader.consume(bytes); + + if newline.is_none() { + continue; + } + while matches!(frame.last(), Some(b'\n' | b'\r')) { + frame.pop(); + } + let completed = std::mem::take(&mut frame); + match String::from_utf8(completed) { + Ok(line) => { + if sender.send(Ok(line)).is_err() { + break; + } + } + Err(_) => { + let _ = sender.send(Err("codex_app_server_read_failed".to_string())); + break; + } + } + } +} + +fn wait_for_response( + receiver: &mpsc::Receiver>, + deadline: Instant, + expected_id: u64, +) -> Result { + loop { + let now = Instant::now(); + if now >= deadline { + return Err("codex_usage_timeout".to_string()); + } + match receiver.recv_timeout(deadline.saturating_duration_since(now)) { + Ok(Ok(line)) => { + let message = serde_json::from_str::(&line) + .map_err(|_| "codex_usage_invalid_response".to_string())?; + if message.get("id").and_then(Value::as_u64) != Some(expected_id) { + continue; + } + if let Some(error_code) = response_error_code(&message) { + return Err(error_code); + } + return message + .get("result") + .cloned() + .ok_or_else(|| "codex_usage_invalid_response".to_string()); + } + Ok(Err(code)) => return Err(code), + Err(mpsc::RecvTimeoutError::Timeout) => return Err("codex_usage_timeout".to_string()), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("codex_app_server_closed".to_string()) + } + } + } +} + +fn response_error_code(message: &Value) -> Option { + let error = message.get("error")?; + let detail = error + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if detail.contains("auth") || detail.contains("login") { + return Some("codex_not_authenticated".to_string()); + } + if detail.contains("experimental") || detail.contains("method") { + return Some("codex_usage_protocol_unsupported".to_string()); + } + Some("codex_usage_unavailable".to_string()) +} + +fn normalize_usage( + rate_limits_result: &Value, + token_usage_result: &Value, + fetched_at: u64, +) -> Result { + let legacy_snapshot = rate_limits_result + .get("rateLimits") + .ok_or_else(|| "codex_usage_invalid_response".to_string())?; + let bucket_map = rate_limits_result + .get("rateLimitsByLimitId") + .and_then(Value::as_object); + let snapshots: Vec<(&str, &Value)> = match bucket_map { + Some(map) if !map.is_empty() => { + map.iter().map(|(id, value)| (id.as_str(), value)).collect() + } + _ => vec![("codex", legacy_snapshot)], + }; + + let mut windows = Vec::new(); + for (fallback_id, snapshot) in &snapshots { + let limit_id = snapshot + .get("limitId") + .and_then(Value::as_str) + .unwrap_or(fallback_id); + let limit_name = snapshot + .get("limitName") + .and_then(Value::as_str) + .unwrap_or(limit_id); + for (window_id, default_label) in [("primary", "Primary"), ("secondary", "Secondary")] { + let Some(window) = snapshot.get(window_id).filter(|value| !value.is_null()) else { + continue; + }; + windows.push(normalize_window( + limit_id, + limit_name, + window_id, + default_label, + window, + )?); + } + } + if windows.is_empty() { + return Err("codex_usage_limit_unavailable".to_string()); + } + + let first_snapshot = snapshots.first().map(|(_, snapshot)| *snapshot); + let latest_bucket = token_usage_result + .get("dailyUsageBuckets") + .and_then(Value::as_array) + .and_then(|buckets| { + buckets.iter().max_by_key(|bucket| { + bucket + .get("startDate") + .and_then(Value::as_str) + .unwrap_or_default() + }) + }); + + Ok(ProviderUsageSnapshot { + provider: ProviderUsageId::Codex.as_str(), + vendor: "openai", + product: "codex", + source: "personalAllowance", + plan_type: legacy_snapshot + .get("planType") + .and_then(Value::as_str) + .or_else(|| { + first_snapshot + .and_then(|snapshot| snapshot.get("planType")) + .and_then(Value::as_str) + }) + .map(str::to_owned), + windows, + totals: ProviderUsageTotals { + credit_balance: legacy_snapshot + .get("credits") + .and_then(|credits| credits.get("balance")) + .and_then(Value::as_str) + .or_else(|| { + first_snapshot + .and_then(|snapshot| snapshot.get("credits")) + .and_then(|credits| credits.get("balance")) + .and_then(Value::as_str) + }) + .map(str::to_owned), + reset_credits_available: rate_limits_result + .get("rateLimitResetCredits") + .and_then(|credits| credits.get("availableCount")) + .and_then(Value::as_u64), + lifetime_tokens: token_usage_result + .get("summary") + .and_then(|summary| summary.get("lifetimeTokens")) + .and_then(Value::as_u64), + latest_daily_tokens: latest_bucket + .and_then(|bucket| bucket.get("tokens")) + .and_then(Value::as_u64), + latest_daily_date: latest_bucket + .and_then(|bucket| bucket.get("startDate")) + .and_then(Value::as_str) + .map(str::to_owned), + }, + fetched_at, + }) +} + +fn normalize_window( + limit_id: &str, + limit_name: &str, + window_id: &str, + default_label: &str, + window: &Value, +) -> Result { + let used_percent = window + .get("usedPercent") + .and_then(Value::as_i64) + .filter(|value| (0..=100).contains(value)) + .map(|value| value as u64) + .ok_or_else(|| "codex_usage_invalid_response".to_string())?; + let duration_minutes = window.get("windowDurationMins").and_then(Value::as_u64); + let window_label = match duration_minutes { + Some(300) => "5-hour".to_string(), + Some(1_440) => "Daily".to_string(), + Some(10_080) => "Weekly".to_string(), + Some(minutes) if minutes % 1_440 == 0 => format!("{}-day", minutes / 1_440), + Some(minutes) if minutes % 60 == 0 => format!("{}-hour", minutes / 60), + _ => default_label.to_string(), + }; + let label = if limit_name.eq_ignore_ascii_case("codex") { + window_label + } else { + format!("{limit_name} · {window_label}") + }; + Ok(ProviderUsageWindow { + id: format!("{limit_id}:{window_id}"), + label, + used_percent, + remaining_percent: 100 - used_percent, + resets_at: window.get("resetsAt").and_then(Value::as_i64), + duration_minutes, + }) +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn finish_with_error(child: &mut Child, code: &str) -> String { + stop_child(child); + code.to_string() +} + +fn stop_child(child: &mut Child) { + let _ = crate::managed_agents::terminate_process(child.id()); + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initializes_before_account_reads_without_experimental_flag() { + assert_eq!( + initialize_request().get("id").and_then(Value::as_u64), + Some(1) + ); + assert!(initialize_request() + .pointer("/params/capabilities/experimentalApi") + .is_none()); + let requests = post_initialize_requests(); + assert_eq!( + requests[0].get("method").and_then(Value::as_str), + Some("initialized") + ); + assert_eq!(requests[1].get("id").and_then(Value::as_u64), Some(2)); + assert_eq!(requests[2].get("id").and_then(Value::as_u64), Some(3)); + } + + #[test] + fn normalizes_all_multi_bucket_windows() { + let rate_limits = json!({ + "rateLimits": { + "primary": {"usedPercent": 38}, + "credits": {"balance": "0"}, + "planType": "pro" + }, + "rateLimitsByLimitId": { + "codex": { + "limitId": "codex", + "limitName": "Codex", + "primary": { + "usedPercent": 38, + "windowDurationMins": 300, + "resetsAt": 1785258777 + }, + "secondary": { + "usedPercent": 52, + "windowDurationMins": 10080, + "resetsAt": 1785658777 + } + }, + "review": { + "limitId": "review", + "limitName": "Code review", + "primary": {"usedPercent": 12} + } + }, + "rateLimitResetCredits": {"availableCount": 3} + }); + let tokens = json!({ + "summary": {"lifetimeTokens": 13597623776_u64}, + "dailyUsageBuckets": [ + {"startDate": "2026-07-23", "tokens": 373817016}, + {"startDate": "2026-07-24", "tokens": 61038450} + ] + }); + + let usage = normalize_usage(&rate_limits, &tokens, 123).unwrap(); + assert_eq!(usage.provider, "codex"); + assert_eq!(usage.plan_type.as_deref(), Some("pro")); + assert_eq!(usage.windows.len(), 3); + assert_eq!(usage.windows[0].remaining_percent, 62); + assert_eq!(usage.windows[1].remaining_percent, 48); + assert_eq!(usage.windows[0].label, "5-hour"); + assert_eq!(usage.windows[1].label, "Weekly"); + assert_eq!(usage.windows[2].label, "Code review · Primary"); + assert_eq!(usage.totals.credit_balance.as_deref(), Some("0")); + assert_eq!(usage.totals.reset_credits_available, Some(3)); + assert_eq!(usage.totals.lifetime_tokens, Some(13_597_623_776)); + assert_eq!(usage.totals.latest_daily_tokens, Some(61_038_450)); + assert_eq!(usage.fetched_at, 123); + } + + #[test] + fn rejects_out_of_range_percentage() { + let rate_limits = json!({ + "rateLimits": {"primary": {"usedPercent": 140}} + }); + assert_eq!( + normalize_usage(&rate_limits, &json!({}), 1).unwrap_err(), + "codex_usage_invalid_response" + ); + } + + #[test] + fn maps_auth_errors_without_forwarding_details() { + let response = json!({ + "id": 2, + "error": { + "code": -32000, + "message": "Login required for alice@example.com" + } + }); + assert_eq!( + response_error_code(&response).as_deref(), + Some("codex_not_authenticated") + ); + } + + #[test] + fn response_reader_correlates_ids_and_ignores_notifications() { + let (sender, receiver) = mpsc::channel(); + sender + .send(Ok(json!({"method": "account/updated"}).to_string())) + .unwrap(); + sender + .send(Ok(json!({"id": 2, "result": {"ok": true}}).to_string())) + .unwrap(); + assert_eq!( + wait_for_response(&receiver, Instant::now() + Duration::from_secs(1), 2).unwrap(), + json!({"ok": true}) + ); + } + + #[test] + fn response_reader_rejects_malformed_json() { + let (sender, receiver) = mpsc::channel(); + sender.send(Ok("{not-json".to_string())).unwrap(); + assert_eq!( + wait_for_response(&receiver, Instant::now() + Duration::from_secs(1), 1).unwrap_err(), + "codex_usage_invalid_response" + ); + } + + #[test] + fn bounded_reader_rejects_oversized_frame() { + let oversized = vec![b'a'; MAX_FRAME_BYTES + 1]; + let (sender, receiver) = mpsc::channel(); + read_bounded_jsonl(oversized.as_slice(), sender); + assert_eq!( + receiver.recv().unwrap().unwrap_err(), + "codex_usage_response_too_large" + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3b47866ac4..f1f1ed401c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -662,6 +662,8 @@ pub fn run() { transfer_builderlab_community, title_bar_double_click, get_identity, + list_provider_usage_capabilities, + get_provider_usage, get_nsec, import_identity, persist_current_identity, diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.d.mts b/desktop/src/features/provider-usage/providerUsageDisplay.d.mts new file mode 100644 index 0000000000..a6fb14684b --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.d.mts @@ -0,0 +1,11 @@ +export type ProviderUsageTone = "healthy" | "warning" | "critical"; + +export function providerUsageTone(remainingPercent: number): ProviderUsageTone; + +export function formatTokenCount(value: number | null | undefined): string; + +export function formatUsageReset( + epochSeconds: number | null | undefined, +): string; + +export function providerUsageErrorMessage(error: unknown): string; diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.mjs b/desktop/src/features/provider-usage/providerUsageDisplay.mjs new file mode 100644 index 0000000000..df68493f62 --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.mjs @@ -0,0 +1,59 @@ +// @ts-check + +/** + * @param {number} remainingPercent + * @returns {"healthy" | "warning" | "critical"} + */ +export function providerUsageTone(remainingPercent) { + if (remainingPercent < 20) return "critical"; + if (remainingPercent <= 50) return "warning"; + return "healthy"; +} + +/** + * @param {number | null | undefined} value + * @returns {string} + */ +export function formatTokenCount(value) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +/** + * @param {number | null | undefined} epochSeconds + * @returns {string} + */ +export function formatUsageReset(epochSeconds) { + if (typeof epochSeconds !== "number" || !Number.isFinite(epochSeconds)) { + return "Reset unavailable"; + } + return new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(epochSeconds * 1000)); +} + +/** + * @param {unknown} error + * @returns {string} + */ +export function providerUsageErrorMessage(error) { + const code = typeof error === "string" ? error : String(error ?? ""); + if (code.includes("codex_not_installed")) return "Codex is not installed"; + if (code.includes("codex_not_authenticated")) { + return "Sign in with Codex to show usage"; + } + if (code.includes("protocol_unsupported")) { + return "Update Codex to show usage"; + } + if (code.includes("response_too_large")) { + return "Codex returned an unsafe response"; + } + return "Usage temporarily unavailable"; +} diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs b/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs new file mode 100644 index 0000000000..1c90bb637e --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatTokenCount, + providerUsageErrorMessage, + providerUsageTone, +} from "./providerUsageDisplay.mjs"; + +test("providerUsageTone follows the remaining-usage thresholds", () => { + assert.equal(providerUsageTone(62), "healthy"); + assert.equal(providerUsageTone(50), "warning"); + assert.equal(providerUsageTone(20), "warning"); + assert.equal(providerUsageTone(19), "critical"); +}); + +test("formatTokenCount stays compact and handles missing values", () => { + assert.equal(formatTokenCount(null), "—"); + assert.match(formatTokenCount(13_597_623_776), /13[.,]?6B/i); +}); + +test("providerUsageErrorMessage never exposes raw app-server details", () => { + assert.equal( + providerUsageErrorMessage( + "codex_not_authenticated: alice@example.com should not render", + ), + "Sign in with Codex to show usage", + ); + assert.equal( + providerUsageErrorMessage("unknown failure with local path /Users/alice"), + "Usage temporarily unavailable", + ); +}); diff --git a/desktop/src/features/provider-usage/providerUsagePreference.test.mjs b/desktop/src/features/provider-usage/providerUsagePreference.test.mjs new file mode 100644 index 0000000000..a803551ec2 --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsagePreference.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const listeners = new Map(); +const values = new Map(); +globalThis.window = { + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }, + addEventListener: (name, listener) => listeners.set(name, listener), + removeEventListener: (name) => listeners.delete(name), + dispatchEvent: () => true, +}; + +const { + getProviderUsagePreference, + resolveProviderUsagePreference, + setProviderUsagePreference, +} = await import("./providerUsagePreference.ts"); + +test("provider preference defaults to Auto and persists supported values", () => { + assert.equal(getProviderUsagePreference(), "auto"); + setProviderUsagePreference("codex"); + assert.equal(getProviderUsagePreference(), "codex"); +}); + +test("provider preference rejects malformed storage", () => { + values.set("buzz-provider-usage-preference", "secret-provider"); + assert.equal(getProviderUsagePreference(), "auto"); +}); + +test("provider preference tolerates unavailable storage", () => { + const original = globalThis.window.localStorage.getItem; + globalThis.window.localStorage.getItem = () => { + throw new Error("unavailable"); + }; + assert.equal(getProviderUsagePreference(), "auto"); + globalThis.window.localStorage.getItem = original; +}); + +test("Auto resolves to the supported Codex adapter", () => { + assert.equal(resolveProviderUsagePreference("auto"), "codex"); + assert.equal(resolveProviderUsagePreference("grok"), "grok"); + assert.equal( + resolveProviderUsagePreference("auto", [ + { + id: "claude", + name: "Claude", + availability: "available", + detail: "Future supported adapter", + }, + ]), + "claude", + ); +}); diff --git a/desktop/src/features/provider-usage/providerUsagePreference.ts b/desktop/src/features/provider-usage/providerUsagePreference.ts new file mode 100644 index 0000000000..c88596590c --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsagePreference.ts @@ -0,0 +1,71 @@ +import { useSyncExternalStore } from "react"; + +import type { + ProviderUsageCapability, + ProviderUsageId, + ProviderUsagePreference, +} from "@/shared/api/tauriProviderUsage"; + +const STORAGE_KEY = "buzz-provider-usage-preference"; +const CHANGE_EVENT = "buzz-provider-usage-preference-change"; + +function parsePreference(value: string | null): ProviderUsagePreference { + if ( + value === "codex" || + value === "claude" || + value === "grok" || + value === "auto" + ) { + return value; + } + return "auto"; +} + +export function getProviderUsagePreference(): ProviderUsagePreference { + if (typeof window === "undefined") return "auto"; + try { + return parsePreference(window.localStorage.getItem(STORAGE_KEY)); + } catch { + return "auto"; + } +} + +export function setProviderUsagePreference( + preference: ProviderUsagePreference, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, preference); + } catch { + // Device-level UI preference. Storage failure safely falls back to Auto. + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +function subscribe(onStoreChange: () => void): () => void { + window.addEventListener(CHANGE_EVENT, onStoreChange); + window.addEventListener("storage", onStoreChange); + return () => { + window.removeEventListener(CHANGE_EVENT, onStoreChange); + window.removeEventListener("storage", onStoreChange); + }; +} + +export function useProviderUsagePreference(): ProviderUsagePreference { + return useSyncExternalStore( + subscribe, + getProviderUsagePreference, + () => "auto", + ); +} + +export function resolveProviderUsagePreference( + preference: ProviderUsagePreference, + capabilities: ProviderUsageCapability[] = [], +): ProviderUsageId { + if (preference !== "auto") return preference; + return ( + capabilities.find((capability) => capability.availability === "available") + ?.id ?? "codex" + ); +} diff --git a/desktop/src/features/provider-usage/ui/ProviderUsageExperimentSettings.tsx b/desktop/src/features/provider-usage/ui/ProviderUsageExperimentSettings.tsx new file mode 100644 index 0000000000..2235646b6b --- /dev/null +++ b/desktop/src/features/provider-usage/ui/ProviderUsageExperimentSettings.tsx @@ -0,0 +1,150 @@ +import { useQuery } from "@tanstack/react-query"; +import { Check, CircleSlash2, Sparkles } from "lucide-react"; + +import { + listProviderUsageCapabilities, + type ProviderUsageCapability, + type ProviderUsagePreference, +} from "@/shared/api/tauriProviderUsage"; +import { cn } from "@/shared/lib/cn"; +import { + setProviderUsagePreference, + useProviderUsagePreference, +} from "@/features/provider-usage/providerUsagePreference"; + +const FALLBACK_CAPABILITIES: ProviderUsageCapability[] = [ + { + id: "codex", + name: "Codex", + availability: "temporarily_unavailable", + detail: "Local capability check unavailable", + }, + { + id: "claude", + name: "Claude", + availability: "unsupported", + detail: "No supported standalone personal allowance reader yet", + }, + { + id: "grok", + name: "Grok", + availability: "unsupported", + detail: "Consumer allowance is available in Grok Settings", + }, +]; + +function ProviderChoice({ + capability, + selected, + onSelect, +}: { + capability: ProviderUsageCapability; + selected: boolean; + onSelect: (preference: ProviderUsagePreference) => void; +}) { + const disabled = capability.availability !== "available"; + return ( + + ); +} + +export function ProviderUsageExperimentSettings({ + enabled, +}: { + enabled: boolean; +}) { + const preference = useProviderUsagePreference(); + const capabilitiesQuery = useQuery({ + queryKey: ["provider-usage-capabilities"], + queryFn: listProviderUsageCapabilities, + enabled, + staleTime: Number.POSITIVE_INFINITY, + }); + const capabilities = capabilitiesQuery.data ?? FALLBACK_CAPABILITIES; + + if (!enabled) return null; + + return ( +
+

+ Allowance provider +

+
+ + {capabilities.map((capability) => ( + + ))} +
+

+ Personal allowance only. Buzz stores no provider credentials or raw + usage responses and never publishes this data to Nostr. +

+
+ ); +} diff --git a/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx b/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx new file mode 100644 index 0000000000..874e2aeb37 --- /dev/null +++ b/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx @@ -0,0 +1,299 @@ +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, RefreshCw } from "lucide-react"; + +import { + getProviderUsage, + listProviderUsageCapabilities, +} from "@/shared/api/tauriProviderUsage"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Progress } from "@/shared/ui/progress"; +import { Spinner } from "@/shared/ui/spinner"; +import { cn } from "@/shared/lib/cn"; +import { + formatTokenCount, + formatUsageReset, + providerUsageErrorMessage, + providerUsageTone, +} from "@/features/provider-usage/providerUsageDisplay.mjs"; +import { + resolveProviderUsagePreference, + useProviderUsagePreference, +} from "@/features/provider-usage/providerUsagePreference"; + +const FIVE_MINUTES = 5 * 60 * 1000; + +const toneClasses = { + healthy: { + stroke: "stroke-emerald-500", + text: "text-emerald-600 dark:text-emerald-400", + progress: "[&>div]:bg-emerald-500", + }, + warning: { + stroke: "stroke-amber-500", + text: "text-amber-600 dark:text-amber-400", + progress: "[&>div]:bg-amber-500", + }, + critical: { + stroke: "stroke-red-500", + text: "text-red-600 dark:text-red-400", + progress: "[&>div]:bg-red-500", + }, +} as const; + +function UsageRing({ + isLoading, + remainingPercent, + label, +}: { + isLoading: boolean; + remainingPercent?: number; + label: string; +}) { + if (isLoading) { + return