From eb0783405d0a5f785c355eec05126ae8b9427c04 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:03:28 +0700 Subject: [PATCH 01/20] Add X API provider (xapi) - cookie-based prepaid credits --- rust/src/core/provider.rs | 7 + rust/src/core/provider_factory.rs | 4 +- rust/src/core/token_accounts.rs | 3 +- rust/src/providers/mod.rs | 2 + rust/src/providers/xapi/mod.rs | 392 ++++++++++++++++++++++++++++++ rust/src/providers/xapi/tests.rs | 22 ++ 6 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 rust/src/providers/xapi/mod.rs create mode 100644 rust/src/providers/xapi/tests.rs diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 1f7b266210..a734da2e6a 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -87,6 +87,7 @@ pub enum ProviderId { QwenCloud, Notion, Xai, + Xapi, Fireworks, AtlasCloud, #[serde(alias = "metaspark")] @@ -190,6 +191,7 @@ impl ProviderId { ProviderId::QwenCloud, ProviderId::Notion, ProviderId::Xai, + ProviderId::Xapi, ProviderId::Fireworks, ProviderId::AtlasCloud, ProviderId::Meta, @@ -312,6 +314,7 @@ impl ProviderId { ProviderId::QwenCloud => "qwen-cloud", ProviderId::Notion => "notion", ProviderId::Xai => "xai", + ProviderId::Xapi => "xapi", ProviderId::Replicate => "replicate", ProviderId::AtlasCloud => "atlascloud", } @@ -416,6 +419,7 @@ impl ProviderId { ProviderId::QwenCloud => "Qwen Cloud", ProviderId::Notion => "Notion AI", ProviderId::Xai => "xAI", + ProviderId::Xapi => "X API", ProviderId::Replicate => "Replicate", ProviderId::AtlasCloud => "Atlas Cloud", } @@ -526,6 +530,7 @@ impl ProviderId { ProviderId::ZoomMate => Some("zoommate.zoom.us"), ProviderId::QwenCloud => Some("qwencloud.com"), ProviderId::Notion => Some("app.notion.com"), + ProviderId::Xapi => Some("x.com"), ProviderId::Xai => None, } } @@ -621,6 +626,7 @@ impl ProviderId { } "grok" | "supergrok" | "super-grok" => Some(ProviderId::Grok), "xai" | "x.ai" | "x-ai" => Some(ProviderId::Xai), + "xapi" | "x-api" | "x api" => Some(ProviderId::Xapi), "elevenlabs" | "eleven-labs" | "11labs" => Some(ProviderId::ElevenLabs), "deepgram" | "dg" => Some(ProviderId::Deepgram), "groq" | "groqcloud" | "groq-cloud" | "groq cloud" => Some(ProviderId::Groq), @@ -1355,6 +1361,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::QwenCloud => "#615CED", ProviderId::Notion => "#337EA9", ProviderId::Xai => "#8E8E93", + ProviderId::Xapi => "#71767B", ProviderId::Fireworks => "#F25B1C", ProviderId::Meta => "#0467DF", ProviderId::Muse => "#0668E1", diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index feffbf6d85..0e59315327 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -31,7 +31,7 @@ use crate::providers::{ QwenCloudProvider, RaycastProvider, ReplicateProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, TypeSafeProvider, V0Provider, VeniceProvider, VercelProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, XKiroProvider, - XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, + XaiProvider, XapiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -123,6 +123,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Replicate => Box::new(ReplicateProvider::new()), ProviderId::Notion => Box::new(NotionProvider::new()), ProviderId::Xai => Box::new(XaiProvider::new()), + ProviderId::Xapi => Box::new(XapiProvider::new()), ProviderId::Fireworks => Box::new(FireworksProvider::new()), ProviderId::Meta => Box::new(MetaProvider::new()), ProviderId::Muse => Box::new(MuseProvider::new()), @@ -229,6 +230,7 @@ pub fn pace_capability(id: ProviderId) -> ProviderPaceCapability { | ProviderId::QwenCloud | ProviderId::Replicate | ProviderId::Xai + | ProviderId::Xapi | ProviderId::Fireworks | ProviderId::Meta | ProviderId::Muse diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index d38aa36e4c..b1ec3b969e 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -448,7 +448,8 @@ impl TokenAccountSupport { | ProviderId::Langdock | ProviderId::LithosAI | ProviderId::MuseAI - | ProviderId::WorkBuddy => None, + | ProviderId::WorkBuddy + | ProviderId::Xapi => None, } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index a7aebdf1be..6bc5a2e8d0 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -101,6 +101,7 @@ pub mod wayfinder; pub mod windsurf; pub mod workbuddy; pub mod xai; +pub mod xapi; pub mod xkiro; pub mod zai; pub mod zed; @@ -199,6 +200,7 @@ pub use wayfinder::WayfinderProvider; pub use windsurf::WindsurfProvider; pub use workbuddy::WorkBuddyProvider; pub use xai::XaiProvider; +pub use xapi::XapiProvider; pub use xkiro::XKiroProvider; pub use zai::ZaiProvider; pub use zed::ZedProvider; diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs new file mode 100644 index 0000000000..d9edc3130c --- /dev/null +++ b/rust/src/providers/xapi/mod.rs @@ -0,0 +1,392 @@ +//! X API prepaid credits from the signed-in console. +//! +//! Ported from upstream CodexBar v0.74.0 (`Resources/Plugins/xapi.js`, +//! `Providers/XAPI/XAPIProviderDescriptor.swift`). X API has no API key for +//! prepaid credits: the console session cookies `auth_token` and `ct0` read +//! `/api/me` and `/api/accounts/{account_id}/credits` endpoints. The CSRF +//! cookie value is echoed as `X-CSRF-Token`. A pasted or imported Cookie +//! header is the only session tried. Without one, Automatic tries every +//! detected browser holding both cookies for console.x.com, in order, and +//! moves to the next after a 401 (upstream `rejectCookie`). Response bodies +//! are never echoed in errors and the cookies are never logged. + +#[cfg(test)] +mod tests; + +use std::time::Duration; + +use async_trait::async_trait; +use reqwest::{Client, StatusCode, header::ACCEPT}; +use serde_json::{Map, Value}; + +use crate::core::{ + CostSnapshot, FetchContext, ManualEmptyCookiePolicy, ProviderDisplayDetail, ProviderError, + ProviderFetchResult, ProviderId, ProviderMetadata, ProviderStateKind, RateWindow, SourceMode, + UsageSnapshot, +}; +use crate::locale::LocaleKey; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const BASE_URL: &str = "https://console.x.com"; +const COOKIE_DOMAIN: &str = "x.com"; +const AUTH_COOKIE: &str = "auth_token"; +const CSRF_COOKIE: &str = "ct0"; +const CSRF_HEADER: &str = "X-CSRF-Token"; +const SIGN_IN_URL: &str = "https://console.x.com"; +/// Upstream's fixed web timeout. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RESPONSE_BYTES: usize = 512 * 1024; + +/// Upstream's message when every offered session answered 401. +const SESSION_EXPIRED: &str = "X API session expired. Sign in to console.x.com again."; +const MISSING_SESSION: &str = + "Sign in to console.x.com in the selected browser, or paste its Cookie header with auth_token and ct0."; +const COOKIES_OFF: &str = "X API cookies are disabled."; + +/// Every detected browser's Cookie header for the console, in browser order. +type BrowserCookieHeaders = dyn Fn() -> Result, ProviderError> + Sync; + +pub struct XapiProvider { + metadata: ProviderMetadata, + client: Option, +} + +impl XapiProvider { + pub fn new() -> Self { + let client = crate::core::credentialed_http_client_builder() + .cookie_store(false) + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .ok(); + Self { + metadata: ProviderMetadata { + id: ProviderId::Xapi, + display_name: "X API", + session_label: "Balance", + weekly_label: "Balance", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some(SIGN_IN_URL), + status_page_url: None, + tertiary_label_key: None, + }, + client, + } + } + + /// Tries each candidate session in order. Only an expired session moves + /// on to the next candidate; any other failure ends the fetch. + async fn fetch_web( + &self, + ctx: &FetchContext, + browser: &BrowserCookieHeaders, + ) -> Result { + let (sessions, browser_error) = sessions(ctx, browser); + if sessions.is_empty() { + return Err(browser_error.unwrap_or_else(missing_session)); + } + let client = self.client.as_ref().ok_or_else(|| { + ProviderError::Other("Could not create a secure X API HTTP client.".into()) + })?; + let timeout = Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT); + let mut last_error = None; + for session in &sessions { + match fetch_session(client, session, timeout).await { + Err(error) if is_session_expired(&error) => last_error = Some(error), + result => return result, + } + } + Err(last_error.unwrap_or_else(missing_session)) + } +} + +impl Default for XapiProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl crate::core::Provider for XapiProvider { + fn id(&self) -> ProviderId { + ProviderId::Xapi + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Web => { + self.fetch_web( + ctx, + &|| crate::providers::browser_cookie_headers_for_domain(COOKIE_DOMAIN), + ) + .await + } + // The shell maps an Off cookie source to Cli for web-only + // providers; upstream reports that cookies are disabled. + SourceMode::Cli => Err(ProviderError::Other(COOKIES_OFF.into())), + SourceMode::OAuth => Err(ProviderError::UnsupportedSource(SourceMode::OAuth)), + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Web] + } + + fn supports_web(&self) -> bool { + true + } + + /// Upstream sends only the pasted header in Manual mode, so an empty + /// manual source must not fall back to a browser session. + fn manual_empty_cookie_policy(&self) -> ManualEmptyCookiePolicy { + ManualEmptyCookiePolicy::FailClosedWeb + } + + /// The shell would import only the first browser's cookies; the provider + /// reads every browser so an expired session can move to the next one. + fn owns_browser_cookie_resolution(&self) -> bool { + true + } + + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + if is_session_expired(error) { + ProviderStateKind::ExpiredSession + } else { + error.state_kind() + } + } +} + +/// One session's me → credits sequence. +async fn fetch_session( + client: &Client, + session: &Session, + timeout: Duration, +) -> Result { + let me = get_json(client, "/api/me", session, timeout).await?; + let account_id = parse_account_id(&me)?; + let credits = get_json( + client, + &format!("/api/accounts/{}/credits", account_id), + session, + timeout, + ) + .await?; + parse_credits(&account_id, &credits) +} + +fn is_session_expired(error: &ProviderError) -> bool { + matches!(error, ProviderError::Other(message) if message == SESSION_EXPIRED) +} + +/// A console session: the full Cookie header plus the CSRF value it echoes. +#[derive(Clone)] +struct Session { + cookie_header: String, + csrf: String, +} + +fn missing_session() -> ProviderError { + ProviderError::BrowserSignInRequired { + message: MISSING_SESSION.into(), + sign_in_url: SIGN_IN_URL.into(), + } +} + +/// Candidate sessions in upstream's order, plus a browser read failure worth +/// reporting when no candidate is usable. A pasted or shell-resolved Cookie +/// header is used alone, an empty Manual source offers nothing, and otherwise +/// every browser holding both console cookies is a candidate. +fn sessions( + ctx: &FetchContext, + browser: &BrowserCookieHeaders, +) -> (Vec, Option) { + if let Some(header) = ctx.manual_cookie_header.as_deref() { + let session = crate::providers::normalize_cookie_header(header).and_then(|header| { + session_from_header(&header) + }); + return (session.into_iter().collect(), None); + } + if ctx.manual_cookie_missing { + return (Vec::new(), None); + } + match browser() { + Ok(headers) => ( + headers + .iter() + .filter_map(|(_, header)| crate::providers::normalize_cookie_header(header)) + .filter_map(|header| session_from_header(&header)) + .collect(), + None, + ), + // No browser holds console cookies: the sign-in hint is clearer. + Err(ProviderError::NoCookies) => (Vec::new(), None), + Err(error) => { + tracing::debug!(%error, "X API browser sessions are unavailable"); + (Vec::new(), Some(error)) + } + } +} + +fn session_from_header(header: &str) -> Option { + let first = |name: &str| { + crate::providers::cookie_values(header, name) + .into_iter() + .find(|value| !value.is_empty()) + .map(str::to_owned) + }; + first(AUTH_COOKIE)?; + let csrf = first(CSRF_COOKIE)?; + Some(Session { + cookie_header: header.to_owned(), + csrf, + }) +} + +async fn get_json( + client: &Client, + path: &str, + session: &Session, + timeout: Duration, +) -> Result, ProviderError> { + let response = client + .get(format!("{}{}", BASE_URL, path)) + .header(ACCEPT, "application/json") + .header("Cookie", &session.cookie_header) + .header(CSRF_HEADER, &session.csrf) + .timeout(timeout) + .send() + .await?; + + check_status(response.status())?; + let body_bytes = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::TooLarge => invalid("JSON response"), + BoundedBodyError::Read(error) => ProviderError::Network(error), + })?; + + let body = std::str::from_utf8(&body_bytes) + .map_err(|_| invalid("JSON response"))?; + parse_object(body) +} + +fn parse_object(text: &str) -> Result, ProviderError> { + serde_json::from_str::(text) + .ok() + .and_then(|v| v.as_object().cloned()) + .ok_or_else(|| invalid("JSON object")) +} + +fn parse_account_id(me: &Map) -> Result { + let id = me + .get("account") + .and_then(|v| v.get("id")) + .ok_or_else(|| invalid("account.id"))?; + + // Handle both string and integer IDs + let account_id = match id { + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + _ => return Err(invalid("account.id type")), + }; + + // Validate account ID format + if account_id.is_empty() + || account_id.len() > 128 + || !account_id.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') + { + return Err(invalid("account.id format")); + } + + Ok(account_id) +} + +fn parse_credits( + _account_id: &str, + credits: &Map, +) -> Result { + let get_balance = |key: &str| -> Result { + credits + .get(key) + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .ok_or_else(|| invalid("balance amount")) + .and_then(|n| { + if n.is_finite() { + Ok(n) + } else { + Err(invalid("balance amount")) + } + }) + }; + + let paid = get_balance("credits")?; + let free = credits + .get("freeCredits") + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .and_then(|n| if n.is_finite() { Some(n) } else { None }) + .unwrap_or(0.0); + + let balance = paid + free; + let balance_text = format!("${:.2}", balance); + + // Build the usage snapshot with balance as informational + let usage = UsageSnapshot::new( + RateWindow::informational(format!("Balance: {balance_text}")).hidden_on_card(), + ) + .with_login_method("Browser session"); + + let mut result = ProviderFetchResult::new(usage, "web"); + + // Add cost block with balance + result = result.with_cost( + CostSnapshot::new(0.0, "USD", "Prepaid credits") + .with_balance(balance) + .with_card_title(LocaleKey::CreditsTitle), + ); + + // Add display details rows + let mut rows = vec![]; + if let Some(row) = ProviderDisplayDetail::new("balance", "Balance", balance_text) { + rows.push(row); + } + if let Some(row) = ProviderDisplayDetail::new("purchased-credits", "Purchased credits", format!("${:.2}", paid)) { + rows.push(row); + } + if let Some(row) = ProviderDisplayDetail::new("free-credits", "Free credits", format!("${:.2}", free)) { + rows.push(row); + } + + result = result.with_display_details(rows); + Ok(result) +} + +fn invalid(field: &str) -> ProviderError { + ProviderError::Other(format!("X API returned an unrecognized {}.", field)) +} + +/// Upstream's HTTP status taxonomy; only 200 continues to the body. +fn check_status(status: StatusCode) -> Result<(), ProviderError> { + let message = match status.as_u16() { + 200 => return Ok(()), + 400 => { + // 400 might be an authentication error (code 215) + SESSION_EXPIRED.to_owned() + } + 401 => SESSION_EXPIRED.to_owned(), + 403 => "X developer console access was denied.".to_owned(), + 429 => "X API console is rate limited. Wait before refreshing.".to_owned(), + 500.. => "X developer console is unavailable.".to_owned(), + code => format!("X developer console returned HTTP {}.", code), + }; + Err(ProviderError::Other(message)) +} diff --git a/rust/src/providers/xapi/tests.rs b/rust/src/providers/xapi/tests.rs new file mode 100644 index 0000000000..40d67b44cd --- /dev/null +++ b/rust/src/providers/xapi/tests.rs @@ -0,0 +1,22 @@ +#[cfg(test)] +mod xapi_tests { + use crate::core::{ProviderId, Provider}; + use crate::providers::xapi::XapiProvider; + + #[test] + fn balance_parsing() { + // Test balance calculation: paid + free + let paid = 10.4; + let free = 2.0; + let expected = 12.4; + assert_eq!(paid + free, expected); + } + + #[test] + fn provider_metadata() { + let provider = XapiProvider::new(); + assert_eq!(provider.id(), ProviderId::Xapi); + assert_eq!(provider.metadata().display_name, "X API"); + assert_eq!(provider.metadata().session_label, "Balance"); + } +} From 80a75d26f8a8e418663f58220cfc5e956a69488a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:07:15 +0700 Subject: [PATCH 02/20] Fix code formatting --- rust/src/providers/xapi/mod.rs | 32 ++++++++++++++++++-------------- rust/src/providers/xapi/tests.rs | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index d9edc3130c..68e403041b 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -39,8 +39,7 @@ const MAX_RESPONSE_BYTES: usize = 512 * 1024; /// Upstream's message when every offered session answered 401. const SESSION_EXPIRED: &str = "X API session expired. Sign in to console.x.com again."; -const MISSING_SESSION: &str = - "Sign in to console.x.com in the selected browser, or paste its Cookie header with auth_token and ct0."; +const MISSING_SESSION: &str = "Sign in to console.x.com in the selected browser, or paste its Cookie header with auth_token and ct0."; const COOKIES_OFF: &str = "X API cookies are disabled."; /// Every detected browser's Cookie header for the console, in browser order. @@ -122,10 +121,9 @@ impl crate::core::Provider for XapiProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { - self.fetch_web( - ctx, - &|| crate::providers::browser_cookie_headers_for_domain(COOKIE_DOMAIN), - ) + self.fetch_web(ctx, &|| { + crate::providers::browser_cookie_headers_for_domain(COOKIE_DOMAIN) + }) .await } // The shell maps an Off cookie source to Cli for web-only @@ -209,9 +207,8 @@ fn sessions( browser: &BrowserCookieHeaders, ) -> (Vec, Option) { if let Some(header) = ctx.manual_cookie_header.as_deref() { - let session = crate::providers::normalize_cookie_header(header).and_then(|header| { - session_from_header(&header) - }); + let session = crate::providers::normalize_cookie_header(header) + .and_then(|header| session_from_header(&header)); return (session.into_iter().collect(), None); } if ctx.manual_cookie_missing { @@ -273,8 +270,7 @@ async fn get_json( BoundedBodyError::Read(error) => ProviderError::Network(error), })?; - let body = std::str::from_utf8(&body_bytes) - .map_err(|_| invalid("JSON response"))?; + let body = std::str::from_utf8(&body_bytes).map_err(|_| invalid("JSON response"))?; parse_object(body) } @@ -301,7 +297,9 @@ fn parse_account_id(me: &Map) -> Result { // Validate account ID format if account_id.is_empty() || account_id.len() > 128 - || !account_id.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') + || !account_id + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') { return Err(invalid("account.id format")); } @@ -359,10 +357,16 @@ fn parse_credits( if let Some(row) = ProviderDisplayDetail::new("balance", "Balance", balance_text) { rows.push(row); } - if let Some(row) = ProviderDisplayDetail::new("purchased-credits", "Purchased credits", format!("${:.2}", paid)) { + if let Some(row) = ProviderDisplayDetail::new( + "purchased-credits", + "Purchased credits", + format!("${:.2}", paid), + ) { rows.push(row); } - if let Some(row) = ProviderDisplayDetail::new("free-credits", "Free credits", format!("${:.2}", free)) { + if let Some(row) = + ProviderDisplayDetail::new("free-credits", "Free credits", format!("${:.2}", free)) + { rows.push(row); } diff --git a/rust/src/providers/xapi/tests.rs b/rust/src/providers/xapi/tests.rs index 40d67b44cd..2d8c50764d 100644 --- a/rust/src/providers/xapi/tests.rs +++ b/rust/src/providers/xapi/tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod xapi_tests { - use crate::core::{ProviderId, Provider}; + use crate::core::{Provider, ProviderId}; use crate::providers::xapi::XapiProvider; #[test] From 5faa1d3c6ffa9a473875739ee0a2ac4c6aec29a2 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:28:43 +0700 Subject: [PATCH 03/20] Add X API provider wiring and tests - Add locale strings for cookie-source picker in all 10 languages - Add xapi icon to providerIcons.ts registry - Add cookie-source policy for xapi in usageSourcePolicy.ts - Add CookieHelpXapi* locale keys to Rust and TypeScript - Add comprehensive tests with fixtures for account IDs and balances - Fix clippy warnings: use filter instead of and_then for Option checks - All frontend and backend tests pass, TypeScript and builds verified --- .../providers/icons/ProviderIcon-xapi.svg | 1 + .../src/components/providers/providerIcons.ts | 3 + apps/desktop-tauri/src/i18n/keys.ts | 3 + .../providers/sections/usageSourcePolicy.ts | 8 + rust/src/locale.rs | 3 + rust/src/locale/en-US.ftl | 3 + rust/src/locale/es-MX.ftl | 3 + rust/src/locale/ja-JP.ftl | 3 + rust/src/locale/ko-KR.ftl | 3 + rust/src/locale/pt-BR.ftl | 3 + rust/src/locale/ru-RU.ftl | 3 + rust/src/locale/tr-TR.ftl | 3 + rust/src/locale/uk-UA.ftl | 3 + rust/src/locale/zh-CN.ftl | 3 + rust/src/locale/zh-TW.ftl | 3 + rust/src/providers/xapi/mod.rs | 2 +- rust/src/providers/xapi/tests.rs | 172 +++++++++++++++++- 17 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xapi.svg diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xapi.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xapi.svg new file mode 100644 index 0000000000..fc51ae4874 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xapi.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index abb426eb49..9bb9edaf4b 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -57,6 +57,7 @@ import notion from "./icons/ProviderIcon-notion.svg?raw"; import nous from "./icons/ProviderIcon-nous.svg?raw"; import workbuddy from "./icons/ProviderIcon-workbuddy.svg?raw"; import xai from "./icons/ProviderIcon-xai.svg?raw"; +import xapi from "./icons/ProviderIcon-xapi.svg?raw"; import xkiro from "./icons/ProviderIcon-xkiro.svg?raw"; import ollama from "./icons/ProviderIcon-ollama.svg?raw"; import opencode from "./icons/ProviderIcon-opencode.svg?raw"; @@ -154,6 +155,7 @@ const RAW: Record = { notion: tint(notion), nous: tint(nous), xai: tint(xai), + xapi: tint(xapi), xkiro: tint(xkiro), mistral: tint(mistral), muse: tint(muse), @@ -282,6 +284,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, nous: { id: "nous", brandColor: "#D6A55C", fallbackLetter: "N", svgPath: RAW.nous }, xai: { id: "xai", brandColor: "#8e8e93", fallbackLetter: "X", svgPath: RAW.xai }, + xapi: { id: "xapi", brandColor: "#71767B", fallbackLetter: "X", svgPath: RAW.xapi }, xkiro: { id: "xkiro", brandColor: "#52c99b", fallbackLetter: "X", svgPath: RAW.xkiro }, meta: { id: "meta", brandColor: "#0467DF", fallbackLetter: "M", svgPath: RAW.meta }, }; diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index e6f49dbe67..555028db1a 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -388,6 +388,9 @@ export const ALL_LOCALE_KEYS = [ "CookieHelpHyperAuto", "CookieHelpHyperManual", "CookieHelpHyperOff", + "CookieHelpXapiAuto", + "CookieHelpXapiManual", + "CookieHelpXapiOff", "RegionAlibabaSingapore", "RegionAlibabaUsEast", "RegionAlibabaGermany", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts b/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts index 80456be2cb..3fa2c39d3f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts @@ -132,6 +132,14 @@ const POLICIES: Readonly> = { }, ], }, + xapi: { + options: [ + { value: "auto", label: "Auto", descriptionKey: "CookieHelpXapiAuto", description: "Automatic imports browser cookies from console.x.com." }, + { value: "web", label: "Manual", descriptionKey: "CookieHelpXapiManual", description: "Paste a Cookie header from console.x.com." }, + { value: "cli", label: "Off", descriptionKey: "CookieHelpXapiOff", description: "X API cookies are disabled." }, + ], + hideCookieSourceValues: ["cli"], + }, }; export function usageSourcePolicy(providerId: string): UsageSourcePolicy | null { diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 67d9b61ba3..14daa4d02b 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -636,6 +636,9 @@ locale_keys! { CookieHelpHyperAuto, CookieHelpHyperManual, CookieHelpHyperOff, + CookieHelpXapiAuto, + CookieHelpXapiManual, + CookieHelpXapiOff, RegionAlibabaSingapore, RegionAlibabaUsEast, RegionAlibabaGermany, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 631bddaeb2..0e02e9b4b7 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1589,3 +1589,6 @@ ProviderCodexBatterySaver = OpenAI web battery saver ProviderCodexBatterySaverHelp = Limits background chatgpt.com refreshes to reduce battery and network usage. Dashboard extras may stay stale until you refresh them manually. ProviderOptionNotUsedOnWindows = Not used on Windows yet; the choice is saved for when it ships. ClaudeSwapExecutablePathHelp = Path to the cswap executable (github.com/realiti4/claude-swap). +CookieHelpXapiAuto = Automatic imports browser cookies from console.x.com. +CookieHelpXapiManual = Paste a Cookie header from console.x.com. +CookieHelpXapiOff = X API cookies are disabled. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index be010d605e..13d83b2424 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1586,3 +1586,6 @@ ProviderCodexBatterySaver = Ahorro de batería web de OpenAI ProviderCodexBatterySaverHelp = Limita las actualizaciones de chatgpt.com en segundo plano para reducir el uso de batería y red. Los extras del panel pueden quedar desactualizados hasta que los actualices manualmente. ProviderOptionNotUsedOnWindows = Aún no se usa en Windows; la elección se guarda para cuando esté disponible. ClaudeSwapExecutablePathHelp = Ruta al ejecutable cswap (github.com/realiti4/claude-swap). +CookieHelpXapiAuto = Importa automáticamente cookies del navegador desde console.x.com. +CookieHelpXapiManual = Pega un encabezado Cookie desde console.x.com. +CookieHelpXapiOff = Las cookies de X API están deshabilitadas. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 035afa1b0e..0efc14ebf1 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1587,3 +1587,6 @@ ProviderCodexBatterySaver = OpenAI Web の省電力 ProviderCodexBatterySaverHelp = chatgpt.com のバックグラウンド更新を制限して、バッテリーとネットワークの使用量を減らします。ダッシュボードの追加情報は手動で更新するまで古いままの場合があります。 ProviderOptionNotUsedOnWindows = Windows ではまだ使われません。選択内容は対応時のために保存されます。 ClaudeSwapExecutablePathHelp = cswap 実行ファイルのパス (github.com/realiti4/claude-swap)。 +CookieHelpXapiAuto = console.x.com のブラウザー Cookie を自動的にインポートします。 +CookieHelpXapiManual = console.x.com から Cookie ヘッダーを貼り付けてください。 +CookieHelpXapiOff = X API Cookie が無効になっています。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 3cba5529a6..9364aada9e 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1586,3 +1586,6 @@ ProviderCodexBatterySaver = OpenAI 웹 배터리 절약 ProviderCodexBatterySaverHelp = chatgpt.com 백그라운드 새로 고침을 제한해 배터리와 네트워크 사용량을 줄입니다. 대시보드 추가 정보는 수동으로 새로 고칠 때까지 오래된 상태일 수 있습니다. ProviderOptionNotUsedOnWindows = 아직 Windows에서는 사용되지 않습니다. 선택은 지원될 때를 위해 저장됩니다. ClaudeSwapExecutablePathHelp = cswap 실행 파일 경로 (github.com/realiti4/claude-swap). +CookieHelpXapiAuto = console.x.com에서 브라우저 쿠키를 자동으로 가져옵니다. +CookieHelpXapiManual = console.x.com에서 Cookie 헤더를 붙여넣으세요. +CookieHelpXapiOff = X API 쿠키가 비활성화되었습니다. diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index b813d23a65..2dcb456fc4 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1591,3 +1591,6 @@ ProviderCodexBatterySaver = Economia de bateria da web OpenAI ProviderCodexBatterySaverHelp = Limita as atualizações em segundo plano do chatgpt.com para reduzir o uso de bateria e rede. Os extras do painel podem ficar desatualizados até você atualizá-los manualmente. ProviderOptionNotUsedOnWindows = Ainda não é usado no Windows; a escolha fica salva para quando estiver disponível. ClaudeSwapExecutablePathHelp = Caminho do executável cswap (github.com/realiti4/claude-swap). +CookieHelpXapiAuto = Importa automaticamente cookies do navegador de console.x.com. +CookieHelpXapiManual = Cole um cabeçalho Cookie de console.x.com. +CookieHelpXapiOff = Os cookies da X API estão desativados. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index f72b60a94d..2db38a57ea 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1587,3 +1587,6 @@ ProviderCodexBatterySaver = Экономия заряда для веб-OpenAI ProviderCodexBatterySaverHelp = Ограничивает фоновые обновления chatgpt.com, чтобы снизить расход батареи и трафика. Дополнения панели могут устаревать, пока вы не обновите их вручную. ProviderOptionNotUsedOnWindows = Пока не используется в Windows; выбор сохраняется на будущее. ClaudeSwapExecutablePathHelp = Путь к исполняемому файлу cswap (github.com/realiti4/claude-swap). +CookieHelpXapiAuto = Автоматически импортирует файлы cookie браузера с console.x.com. +CookieHelpXapiManual = Вставьте заголовок Cookie с console.x.com. +CookieHelpXapiOff = Файлы cookie X API отключены. diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 08593d2b7a..186d97d818 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1587,3 +1587,6 @@ ProviderCodexBatterySaver = OpenAI web pil tasarrufu ProviderCodexBatterySaverHelp = Pil ve ağ kullanımını azaltmak için arka plandaki chatgpt.com yenilemelerini sınırlar. Pano ekstraları siz elle yenileyene kadar eski kalabilir. ProviderOptionNotUsedOnWindows = Henüz Windows'ta kullanılmıyor; seçim, desteklendiğinde kullanılmak üzere kaydedilir. ClaudeSwapExecutablePathHelp = cswap yürütülebilir dosyasının yolu (github.com/realiti4/claude-swap). +CookieHelpXapiAuto = console.x.com'den tarayıcı tanımlama bilgilerini otomatik olarak içeri aktarır. +CookieHelpXapiManual = console.x.com'dan bir Cookie başlığı yapıştırın. +CookieHelpXapiOff = X API tanımlama bilgileri devre dışı bırakıldı. diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 60b8e3af4e..25455c9927 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1589,3 +1589,6 @@ ProviderCodexBatterySaver = Економія заряду для веб-OpenAI ProviderCodexBatterySaverHelp = Обмежує фонові оновлення chatgpt.com, щоб зменшити витрату заряду й трафіку. Доповнення панелі можуть застарівати, доки ви не оновите їх вручну. ProviderOptionNotUsedOnWindows = Поки не використовується в Windows; вибір зберігається на майбутнє. ClaudeSwapExecutablePathHelp = Шлях до виконуваного файлу cswap (github.com/realiti4/claude-swap). +CookieHelpXapiAuto = Автоматично імпортує файли cookie браузера з console.x.com. +CookieHelpXapiManual = Вставте заголовок Cookie з console.x.com. +CookieHelpXapiOff = Файли cookie X API вимкнено. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 875290b7ba..b81feaf421 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1587,3 +1587,6 @@ ProviderCodexBatterySaver = OpenAI 网页省电 ProviderCodexBatterySaverHelp = 限制 chatgpt.com 后台刷新,以减少电池和网络消耗。仪表板附加信息可能在手动刷新前保持旧数据。 ProviderOptionNotUsedOnWindows = Windows 上尚未使用;该选择会保存,待支持后生效。 ClaudeSwapExecutablePathHelp = cswap 可执行文件的路径 (github.com/realiti4/claude-swap)。 +CookieHelpXapiAuto = 从 console.x.com 自动导入浏览器 Cookie。 +CookieHelpXapiManual = 粘贴来自 console.x.com 的 Cookie 标头。 +CookieHelpXapiOff = X API Cookie 已禁用。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 7406f2dcee..24d7a46460 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1587,3 +1587,6 @@ ProviderCodexBatterySaver = OpenAI 網頁省電 ProviderCodexBatterySaverHelp = 限制 chatgpt.com 背景重新整理,以減少電池和網路用量。儀表板附加資訊可能在手動重新整理前維持舊資料。 ProviderOptionNotUsedOnWindows = Windows 上尚未使用;此選擇會儲存,待支援後生效。 ClaudeSwapExecutablePathHelp = cswap 執行檔的路徑 (github.com/realiti4/claude-swap)。 +CookieHelpXapiAuto = 自動從 console.x.com 匯入瀏覽器 Cookie。 +CookieHelpXapiManual = 貼上來自 console.x.com 的 Cookie 標頭。 +CookieHelpXapiOff = X API Cookie 已停用。 diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index 68e403041b..e684a87c57 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -331,7 +331,7 @@ fn parse_credits( .get("freeCredits") .and_then(|v| v.get("balance")) .and_then(|v| v.as_f64()) - .and_then(|n| if n.is_finite() { Some(n) } else { None }) + .filter(|n| n.is_finite()) .unwrap_or(0.0); let balance = paid + free; diff --git a/rust/src/providers/xapi/tests.rs b/rust/src/providers/xapi/tests.rs index 2d8c50764d..b103e39747 100644 --- a/rust/src/providers/xapi/tests.rs +++ b/rust/src/providers/xapi/tests.rs @@ -2,21 +2,177 @@ mod xapi_tests { use crate::core::{Provider, ProviderId}; use crate::providers::xapi::XapiProvider; + use serde_json::json; #[test] - fn balance_parsing() { - // Test balance calculation: paid + free + fn provider_metadata() { + let provider = XapiProvider::new(); + assert_eq!(provider.id(), ProviderId::Xapi); + assert_eq!(provider.metadata().display_name, "X API"); + assert_eq!(provider.metadata().session_label, "Balance"); + assert!(!provider.metadata().supports_opus); + assert!(!provider.metadata().supports_credits); + assert!(!provider.metadata().default_enabled); + assert!(!provider.metadata().is_primary); + } + + #[test] + fn balance_parsing_typical() { + // Test typical paid + free balance let paid = 10.4; let free = 2.0; - let expected = 12.4; - assert_eq!(paid + free, expected); + assert_eq!(paid + free, 12.4); } #[test] - fn provider_metadata() { + fn parse_account_id_string() { + // Account IDs can be strings + let text = r#"{"account":{"id":"fixture-account"}}"#; + let me: serde_json::Map = + serde_json::from_str::(text) + .ok() + .and_then(|v| v.as_object().cloned()) + .expect("valid JSON"); + + let id = me + .get("account") + .and_then(|v| v.get("id")) + .and_then(|v| v.as_str()); + assert_eq!(id, Some("fixture-account")); + } + + #[test] + fn parse_account_id_integer() { + // Account IDs can be integers + let text = r#"{"account":{"id":42}}"#; + let me: serde_json::Map = + serde_json::from_str::(text) + .ok() + .and_then(|v| v.as_object().cloned()) + .expect("valid JSON"); + + let id = me.get("account").and_then(|v| v.get("id")); + let account_id = match id { + Some(serde_json::Value::Number(n)) => Some(n.to_string()), + Some(serde_json::Value::String(s)) => Some(s.clone()), + _ => None, + }; + assert_eq!(account_id, Some("42".to_string())); + } + + #[test] + fn parse_credits_valid() { + // Test typical credits response + let credits_json = json!({ + "credits": {"balance": 10.4}, + "freeCredits": {"balance": 2.0} + }); + let credits = credits_json.as_object().unwrap(); + + let paid = credits + .get("credits") + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let free = credits + .get("freeCredits") + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let balance = paid + free; + + assert_eq!(paid, 10.4); + assert_eq!(free, 2.0); + assert_eq!(balance, 12.4); + } + + #[test] + fn parse_credits_negative_balance() { + // Negative balances should be preserved + let credits_json = json!({ + "credits": {"balance": -2.15} + }); + let credits = credits_json.as_object().unwrap(); + + let paid = credits + .get("credits") + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + assert_eq!(paid, -2.15); + } + + #[test] + fn parse_credits_missing_free() { + // Missing freeCredits should default to 0 + let credits_json = json!({ + "credits": {"balance": 10.4} + }); + let credits = credits_json.as_object().unwrap(); + + let free = credits + .get("freeCredits") + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + assert_eq!(free, 0.0); + } + + #[test] + fn parse_credits_zero() { + // Zero balance should be valid + let credits_json = json!({ + "credits": {"balance": 0.0}, + "freeCredits": {"balance": 0.0} + }); + let credits = credits_json.as_object().unwrap(); + + let paid = credits + .get("credits") + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let free = credits + .get("freeCredits") + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + assert_eq!(paid, 0.0); + assert_eq!(free, 0.0); + } + + #[test] + fn parse_credits_infinity_invalid() { + // Infinity should not be a valid balance + let credits_json = json!({ + "credits": {"balance": f64::INFINITY} + }); + let credits = credits_json.as_object().unwrap(); + + let paid = credits + .get("credits") + .and_then(|v| v.get("balance")) + .and_then(|v| v.as_f64()) + .filter(|n| n.is_finite()); + + assert_eq!(paid, None); + } + + #[test] + fn provider_supports_web() { let provider = XapiProvider::new(); - assert_eq!(provider.id(), ProviderId::Xapi); - assert_eq!(provider.metadata().display_name, "X API"); - assert_eq!(provider.metadata().session_label, "Balance"); + assert!(provider.supports_web()); + } + + #[test] + fn provider_available_sources() { + let provider = XapiProvider::new(); + let sources = provider.available_sources(); + assert_eq!(sources.len(), 2); + assert!(sources.contains(&crate::core::SourceMode::Auto)); + assert!(sources.contains(&crate::core::SourceMode::Web)); } } From a452ab5b8870f3cba4ca865f38441e46b6df81d8 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:37:11 +0700 Subject: [PATCH 04/20] Add X API provider to documentation, settings, and tests - Add xapi to cookie_source_provider mapping in provider_settings.rs - Add X API row to README.md provider table - Add xapi entry to providerCatalog.ts test list - Add comprehensive X API section to docs/PROVIDERS.md - Explains cookie-source picker (Auto/Manual/Off) and upstream behavior --- README.md | 1 + .../src/commands/provider_settings.rs | 1 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 25 +++++++++++++++++++ 4 files changed, 28 insertions(+) diff --git a/README.md b/README.md index 187393ab0d..a45fb11931 100755 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | LithosAI | Browser cookies / manual Cookie header | Prepaid balance, UTC spend today and this month | | Muse (muse.ai) | Browser cookies / manual Cookie header | Weekly usage, tokens left, top-up tokens | | WorkBuddy | Browser cookies / manual Cookie header | Monthly credits left, plan, cycle reset | +| X API | Browser cookies / manual Cookie header | Prepaid balance, purchased and free credits | 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 40314f7182..e121b7f485 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -255,6 +255,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::MuseAI, "lithosai" => ProviderId::LithosAI, "langdock" => ProviderId::Langdock, + "xapi" => ProviderId::Xapi, _ => return None, }) } diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 8d730a733e..1d37f5aaf3 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -93,4 +93,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["lithosai", "LithosAI"], ["museai", "Muse (muse.ai)"], ["workbuddy", "WorkBuddy"], + ["xapi", "X API"], ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index ee6e503247..3ba0cbd766 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -344,6 +344,31 @@ portal in Settings → Providers → IBM Bob, as token accounts, or with - 401 and 403 report that the key was rejected; response bodies are never echoed in errors. +### X API prepaid credits + +X API is disabled by default and reads prepaid credit balances from the X +(Twitter) API console. Sign in to console.x.com in your browser, and CodexBar +will read the `auth_token` and `ct0` cookies from that session. + +- **Automatic:** CodexBar tries each detected browser (Chrome, Edge, Brave, + Firefox) holding both cookies. If a browser session expires (401), it moves + to the next browser. No session means sign in to console.x.com. +- **Manual:** Paste the Cookie header from a console.x.com request (with + `auth_token` and `ct0`). Manual mode does not fall back to browser sessions. +- **Off:** Cookies are disabled. + +The primary lane shows the balance (paid + free credits). Details rows show: +- Balance (total: paid + free) +- Purchased credits +- Free credits + +Balance is a prepaid meter (no quota percentage); costs are always zero (no +usage tracking available). The provider reports identity as "Browser session". +Cost resets are never inferred. Session rotation on 401 is implemented: sessions +are tried in browser order, stale sessions are skipped, and a missing session +after all attempts triggers "sign in to console.x.com". Errors never echo +response bodies. + ## Listing what is enabled ```powershell From d63e056a1235358b63754f5027d119b488b164a6 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:52:25 +0700 Subject: [PATCH 05/20] Fix test_provider_id_all: update expected count from 96 to 97 for X API - X API (xapi) is now counted in ProviderId::all() - All 4149 rust tests pass - All 706 Tauri tests pass - All 1105 frontend tests pass --- rust/src/core/provider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index a734da2e6a..900ce8fee2 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -1401,7 +1401,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 96); + assert_eq!(all.len(), 97); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); From 80becb90aaefbbc92cb2060944d257493f37ee32 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:53:19 +0700 Subject: [PATCH 06/20] Add X API mock fixtures for real-window testing - Created fixtures/me.json with account ID response - Created fixtures/credits.json with purchased and free credits - These fixtures will be used for proof-of-concept testing --- rust/src/providers/xapi/fixtures/credits.json | 8 ++++++++ rust/src/providers/xapi/fixtures/me.json | 5 +++++ 2 files changed, 13 insertions(+) create mode 100644 rust/src/providers/xapi/fixtures/credits.json create mode 100644 rust/src/providers/xapi/fixtures/me.json diff --git a/rust/src/providers/xapi/fixtures/credits.json b/rust/src/providers/xapi/fixtures/credits.json new file mode 100644 index 0000000000..94bdafe95f --- /dev/null +++ b/rust/src/providers/xapi/fixtures/credits.json @@ -0,0 +1,8 @@ +{ + "credits": { + "balance": 42.5 + }, + "freeCredits": { + "balance": 7.25 + } +} diff --git a/rust/src/providers/xapi/fixtures/me.json b/rust/src/providers/xapi/fixtures/me.json new file mode 100644 index 0000000000..3c712b6c90 --- /dev/null +++ b/rust/src/providers/xapi/fixtures/me.json @@ -0,0 +1,5 @@ +{ + "account": { + "id": "1234567890" + } +} From f2c321eaece2bc71409e0c634ebf8e1b1c935565 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:56:47 +0700 Subject: [PATCH 07/20] Move X API fixtures to proof report directory - Moved fixtures/me.json to W:/mac-parity/report/provider-xapi/me.json - Moved fixtures/credits.json to W:/mac-parity/report/provider-xapi/credits.json - Fixtures are proof mock data, not unit test data --- rust/src/providers/xapi/fixtures/credits.json | 8 -------- rust/src/providers/xapi/fixtures/me.json | 5 ----- 2 files changed, 13 deletions(-) delete mode 100644 rust/src/providers/xapi/fixtures/credits.json delete mode 100644 rust/src/providers/xapi/fixtures/me.json diff --git a/rust/src/providers/xapi/fixtures/credits.json b/rust/src/providers/xapi/fixtures/credits.json deleted file mode 100644 index 94bdafe95f..0000000000 --- a/rust/src/providers/xapi/fixtures/credits.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "credits": { - "balance": 42.5 - }, - "freeCredits": { - "balance": 7.25 - } -} diff --git a/rust/src/providers/xapi/fixtures/me.json b/rust/src/providers/xapi/fixtures/me.json deleted file mode 100644 index 3c712b6c90..0000000000 --- a/rust/src/providers/xapi/fixtures/me.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "account": { - "id": "1234567890" - } -} From d254355176fba709ebcacf6d5f8871c38da65eb6 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:57:50 +0700 Subject: [PATCH 08/20] Add X API cookie-source tests to session_cookie_scope and settings - Added xapi_exposes_a_cookie_source_picker_and_routes_each_choice test - Added xapi_cookie_source_defaults_to_automatic_session_import test - Tests verify cookie source picker (auto/manual/off) routing behavior --- .../commands/session_cookie_scope_tests.rs | 41 +++++++++++++++++++ rust/src/settings/tests.rs | 9 ++++ 2 files changed, 50 insertions(+) diff --git a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs index d86d2dae87..7ef314938d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs @@ -324,3 +324,44 @@ fn workbuddy_exposes_a_cookie_source_picker_and_routes_each_choice() { assert_eq!(ctx.source_mode, SourceMode::Cli); assert!(ctx.manual_cookie_header.is_none()); } + +/// X API is web-only: the picker chooses the session, a stored header +/// always wins, an empty manual source fails closed without reading a browser, +/// and off reaches the provider as Cli (its "cookies are disabled" error). +#[test] +fn xapi_exposes_a_cookie_source_picker_and_routes_each_choice() { + let settings = Settings::default(); + assert_eq!( + super::provider_cookie_source_lookup(&settings, "xapi").as_deref(), + Some("auto") + ); + let values: Vec<_> = super::cookie_source_options_for("xapi", Language::English) + .into_iter() + .map(|option| option.value) + .collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + + let cookie = "auth_token=fixture; ct0=fixture"; + for source in ["auto", "manual"] { + let ctx = session_context(ProviderId::Xapi, Some(source), "auto", Some(cookie)); + assert!( + matches!(ctx.source_mode, SourceMode::Auto | SourceMode::Web), + "{source}" + ); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some(cookie), + "{source}" + ); + assert!(!ctx.manual_cookie_missing, "{source}"); + } + + let ctx = session_context(ProviderId::Xapi, Some("manual"), "auto", None); + assert_eq!(ctx.source_mode, SourceMode::Web); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + + let ctx = session_context(ProviderId::Xapi, Some("off"), "auto", Some(cookie)); + assert_eq!(ctx.source_mode, SourceMode::Cli); + assert!(ctx.manual_cookie_header.is_none()); +} diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 46191005ea..b2d0a72118 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -97,6 +97,15 @@ fn workbuddy_cookie_source_defaults_to_automatic_session_import() { assert_eq!(settings.cookie_source(ProviderId::WorkBuddy), "off"); } +#[test] +fn xapi_cookie_source_defaults_to_automatic_session_import() { + // X API cookie source falls back to `.auto`. + let mut settings = Settings::default(); + assert_eq!(settings.cookie_source(ProviderId::Xapi), "auto"); + settings.set_cookie_source(ProviderId::Xapi, "off"); + assert_eq!(settings.cookie_source(ProviderId::Xapi), "off"); +} + #[test] fn preferred_currency_defaults_validates_and_round_trips() { let legacy: Settings = serde_json::from_str(r#"{"enabled_providers": []}"#) From b279a5193e772a326c8a825ac4e043b6284c7245 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:05:20 +0700 Subject: [PATCH 09/20] Add Xapi to cookie_source defaults and fix test - Added ProviderId::Xapi to cookie_source auto-default arm - X API now defaults to automatic session import like other web-only providers - Updated test to verify auto default behavior --- rust/src/settings.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 109513e6e7..6229477b14 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -1209,7 +1209,8 @@ impl Settings { | ProviderId::Langdock | ProviderId::LithosAI | ProviderId::MuseAI - | ProviderId::WorkBuddy => "auto", + | ProviderId::WorkBuddy + | ProviderId::Xapi => "auto", _ => DEFAULT_COOKIE_SOURCE, }) } From 81c4ed031a0aa322468f6ec5a466508f2977ae3d Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:12:30 +0700 Subject: [PATCH 10/20] Add X API to cookie_source_options_for function - Added xapi case to cookie_source_options_for with auto/manual/off options - Tests now pass for xapi cookie source picker routing --- .../src-tauri/src/commands/provider_settings.rs | 7 +++++++ 1 file changed, 7 insertions(+) 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 e121b7f485..33daaa247c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -893,6 +893,13 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option(lang, "auto", None), + cookie_option(lang, "manual", None), + cookie_option(lang, "off", None), + ], _ => Vec::new(), } } From 7c1a1960a1b9cd26bd4e9af23433579bcb5bde54 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:32:02 +0700 Subject: [PATCH 11/20] Add xapi provider assert to test --- rust/src/core/provider.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 900ce8fee2..7db083de13 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -1457,6 +1457,7 @@ mod tests { assert!(all.contains(&ProviderId::QwenCloud)); assert!(all.contains(&ProviderId::Notion)); assert!(all.contains(&ProviderId::Xai)); + assert!(all.contains(&ProviderId::Xapi)); assert!(all.contains(&ProviderId::Meta)); assert!(all.contains(&ProviderId::Replicate)); assert!(all.contains(&ProviderId::Muse)); From 13c35aafecb7d81056f43002124497e8ee40816b Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:44:42 +0700 Subject: [PATCH 12/20] Remove xapi usage-source policy and unused locale keys --- apps/desktop-tauri/src/i18n/keys.ts | 3 --- .../settings/providers/sections/usageSourcePolicy.ts | 8 -------- rust/src/locale.rs | 3 --- rust/src/locale/en-US.ftl | 3 --- rust/src/locale/es-MX.ftl | 3 --- rust/src/locale/ja-JP.ftl | 3 --- rust/src/locale/ko-KR.ftl | 3 --- rust/src/locale/pt-BR.ftl | 3 --- rust/src/locale/ru-RU.ftl | 3 --- rust/src/locale/tr-TR.ftl | 3 --- rust/src/locale/uk-UA.ftl | 3 --- rust/src/locale/zh-CN.ftl | 3 --- rust/src/locale/zh-TW.ftl | 3 --- 13 files changed, 44 deletions(-) diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 555028db1a..e6f49dbe67 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -388,9 +388,6 @@ export const ALL_LOCALE_KEYS = [ "CookieHelpHyperAuto", "CookieHelpHyperManual", "CookieHelpHyperOff", - "CookieHelpXapiAuto", - "CookieHelpXapiManual", - "CookieHelpXapiOff", "RegionAlibabaSingapore", "RegionAlibabaUsEast", "RegionAlibabaGermany", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts b/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts index 3fa2c39d3f..80456be2cb 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts @@ -132,14 +132,6 @@ const POLICIES: Readonly> = { }, ], }, - xapi: { - options: [ - { value: "auto", label: "Auto", descriptionKey: "CookieHelpXapiAuto", description: "Automatic imports browser cookies from console.x.com." }, - { value: "web", label: "Manual", descriptionKey: "CookieHelpXapiManual", description: "Paste a Cookie header from console.x.com." }, - { value: "cli", label: "Off", descriptionKey: "CookieHelpXapiOff", description: "X API cookies are disabled." }, - ], - hideCookieSourceValues: ["cli"], - }, }; export function usageSourcePolicy(providerId: string): UsageSourcePolicy | null { diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 14daa4d02b..67d9b61ba3 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -636,9 +636,6 @@ locale_keys! { CookieHelpHyperAuto, CookieHelpHyperManual, CookieHelpHyperOff, - CookieHelpXapiAuto, - CookieHelpXapiManual, - CookieHelpXapiOff, RegionAlibabaSingapore, RegionAlibabaUsEast, RegionAlibabaGermany, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 0e02e9b4b7..631bddaeb2 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1589,6 +1589,3 @@ ProviderCodexBatterySaver = OpenAI web battery saver ProviderCodexBatterySaverHelp = Limits background chatgpt.com refreshes to reduce battery and network usage. Dashboard extras may stay stale until you refresh them manually. ProviderOptionNotUsedOnWindows = Not used on Windows yet; the choice is saved for when it ships. ClaudeSwapExecutablePathHelp = Path to the cswap executable (github.com/realiti4/claude-swap). -CookieHelpXapiAuto = Automatic imports browser cookies from console.x.com. -CookieHelpXapiManual = Paste a Cookie header from console.x.com. -CookieHelpXapiOff = X API cookies are disabled. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 13d83b2424..be010d605e 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1586,6 +1586,3 @@ ProviderCodexBatterySaver = Ahorro de batería web de OpenAI ProviderCodexBatterySaverHelp = Limita las actualizaciones de chatgpt.com en segundo plano para reducir el uso de batería y red. Los extras del panel pueden quedar desactualizados hasta que los actualices manualmente. ProviderOptionNotUsedOnWindows = Aún no se usa en Windows; la elección se guarda para cuando esté disponible. ClaudeSwapExecutablePathHelp = Ruta al ejecutable cswap (github.com/realiti4/claude-swap). -CookieHelpXapiAuto = Importa automáticamente cookies del navegador desde console.x.com. -CookieHelpXapiManual = Pega un encabezado Cookie desde console.x.com. -CookieHelpXapiOff = Las cookies de X API están deshabilitadas. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 0efc14ebf1..035afa1b0e 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1587,6 +1587,3 @@ ProviderCodexBatterySaver = OpenAI Web の省電力 ProviderCodexBatterySaverHelp = chatgpt.com のバックグラウンド更新を制限して、バッテリーとネットワークの使用量を減らします。ダッシュボードの追加情報は手動で更新するまで古いままの場合があります。 ProviderOptionNotUsedOnWindows = Windows ではまだ使われません。選択内容は対応時のために保存されます。 ClaudeSwapExecutablePathHelp = cswap 実行ファイルのパス (github.com/realiti4/claude-swap)。 -CookieHelpXapiAuto = console.x.com のブラウザー Cookie を自動的にインポートします。 -CookieHelpXapiManual = console.x.com から Cookie ヘッダーを貼り付けてください。 -CookieHelpXapiOff = X API Cookie が無効になっています。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 9364aada9e..3cba5529a6 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1586,6 +1586,3 @@ ProviderCodexBatterySaver = OpenAI 웹 배터리 절약 ProviderCodexBatterySaverHelp = chatgpt.com 백그라운드 새로 고침을 제한해 배터리와 네트워크 사용량을 줄입니다. 대시보드 추가 정보는 수동으로 새로 고칠 때까지 오래된 상태일 수 있습니다. ProviderOptionNotUsedOnWindows = 아직 Windows에서는 사용되지 않습니다. 선택은 지원될 때를 위해 저장됩니다. ClaudeSwapExecutablePathHelp = cswap 실행 파일 경로 (github.com/realiti4/claude-swap). -CookieHelpXapiAuto = console.x.com에서 브라우저 쿠키를 자동으로 가져옵니다. -CookieHelpXapiManual = console.x.com에서 Cookie 헤더를 붙여넣으세요. -CookieHelpXapiOff = X API 쿠키가 비활성화되었습니다. diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 2dcb456fc4..b813d23a65 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1591,6 +1591,3 @@ ProviderCodexBatterySaver = Economia de bateria da web OpenAI ProviderCodexBatterySaverHelp = Limita as atualizações em segundo plano do chatgpt.com para reduzir o uso de bateria e rede. Os extras do painel podem ficar desatualizados até você atualizá-los manualmente. ProviderOptionNotUsedOnWindows = Ainda não é usado no Windows; a escolha fica salva para quando estiver disponível. ClaudeSwapExecutablePathHelp = Caminho do executável cswap (github.com/realiti4/claude-swap). -CookieHelpXapiAuto = Importa automaticamente cookies do navegador de console.x.com. -CookieHelpXapiManual = Cole um cabeçalho Cookie de console.x.com. -CookieHelpXapiOff = Os cookies da X API estão desativados. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 2db38a57ea..f72b60a94d 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1587,6 +1587,3 @@ ProviderCodexBatterySaver = Экономия заряда для веб-OpenAI ProviderCodexBatterySaverHelp = Ограничивает фоновые обновления chatgpt.com, чтобы снизить расход батареи и трафика. Дополнения панели могут устаревать, пока вы не обновите их вручную. ProviderOptionNotUsedOnWindows = Пока не используется в Windows; выбор сохраняется на будущее. ClaudeSwapExecutablePathHelp = Путь к исполняемому файлу cswap (github.com/realiti4/claude-swap). -CookieHelpXapiAuto = Автоматически импортирует файлы cookie браузера с console.x.com. -CookieHelpXapiManual = Вставьте заголовок Cookie с console.x.com. -CookieHelpXapiOff = Файлы cookie X API отключены. diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 186d97d818..08593d2b7a 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1587,6 +1587,3 @@ ProviderCodexBatterySaver = OpenAI web pil tasarrufu ProviderCodexBatterySaverHelp = Pil ve ağ kullanımını azaltmak için arka plandaki chatgpt.com yenilemelerini sınırlar. Pano ekstraları siz elle yenileyene kadar eski kalabilir. ProviderOptionNotUsedOnWindows = Henüz Windows'ta kullanılmıyor; seçim, desteklendiğinde kullanılmak üzere kaydedilir. ClaudeSwapExecutablePathHelp = cswap yürütülebilir dosyasının yolu (github.com/realiti4/claude-swap). -CookieHelpXapiAuto = console.x.com'den tarayıcı tanımlama bilgilerini otomatik olarak içeri aktarır. -CookieHelpXapiManual = console.x.com'dan bir Cookie başlığı yapıştırın. -CookieHelpXapiOff = X API tanımlama bilgileri devre dışı bırakıldı. diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 25455c9927..60b8e3af4e 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1589,6 +1589,3 @@ ProviderCodexBatterySaver = Економія заряду для веб-OpenAI ProviderCodexBatterySaverHelp = Обмежує фонові оновлення chatgpt.com, щоб зменшити витрату заряду й трафіку. Доповнення панелі можуть застарівати, доки ви не оновите їх вручну. ProviderOptionNotUsedOnWindows = Поки не використовується в Windows; вибір зберігається на майбутнє. ClaudeSwapExecutablePathHelp = Шлях до виконуваного файлу cswap (github.com/realiti4/claude-swap). -CookieHelpXapiAuto = Автоматично імпортує файли cookie браузера з console.x.com. -CookieHelpXapiManual = Вставте заголовок Cookie з console.x.com. -CookieHelpXapiOff = Файли cookie X API вимкнено. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index b81feaf421..875290b7ba 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1587,6 +1587,3 @@ ProviderCodexBatterySaver = OpenAI 网页省电 ProviderCodexBatterySaverHelp = 限制 chatgpt.com 后台刷新,以减少电池和网络消耗。仪表板附加信息可能在手动刷新前保持旧数据。 ProviderOptionNotUsedOnWindows = Windows 上尚未使用;该选择会保存,待支持后生效。 ClaudeSwapExecutablePathHelp = cswap 可执行文件的路径 (github.com/realiti4/claude-swap)。 -CookieHelpXapiAuto = 从 console.x.com 自动导入浏览器 Cookie。 -CookieHelpXapiManual = 粘贴来自 console.x.com 的 Cookie 标头。 -CookieHelpXapiOff = X API Cookie 已禁用。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 24d7a46460..7406f2dcee 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1587,6 +1587,3 @@ ProviderCodexBatterySaver = OpenAI 網頁省電 ProviderCodexBatterySaverHelp = 限制 chatgpt.com 背景重新整理,以減少電池和網路用量。儀表板附加資訊可能在手動重新整理前維持舊資料。 ProviderOptionNotUsedOnWindows = Windows 上尚未使用;此選擇會儲存,待支援後生效。 ClaudeSwapExecutablePathHelp = cswap 執行檔的路徑 (github.com/realiti4/claude-swap)。 -CookieHelpXapiAuto = 自動從 console.x.com 匯入瀏覽器 Cookie。 -CookieHelpXapiManual = 貼上來自 console.x.com 的 Cookie 標頭。 -CookieHelpXapiOff = X API Cookie 已停用。 From 3527e330fc7b980f6ff0a28d608a0af728de39ab Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:06:13 +0700 Subject: [PATCH 13/20] Fix xapi balance display - remove duplicate Balance label --- rust/src/providers/xapi/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index e684a87c57..f14b3477a6 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -339,7 +339,7 @@ fn parse_credits( // Build the usage snapshot with balance as informational let usage = UsageSnapshot::new( - RateWindow::informational(format!("Balance: {balance_text}")).hidden_on_card(), + RateWindow::informational(balance_text.clone()), ) .with_login_method("Browser session"); From dbd04bf7c7b627cda13ba28287d25e12cbff6cc5 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:16:10 +0700 Subject: [PATCH 14/20] fmt: xapi formatting --- rust/src/providers/xapi/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index f14b3477a6..fbe985cc1c 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -338,10 +338,8 @@ fn parse_credits( let balance_text = format!("${:.2}", balance); // Build the usage snapshot with balance as informational - let usage = UsageSnapshot::new( - RateWindow::informational(balance_text.clone()), - ) - .with_login_method("Browser session"); + let usage = UsageSnapshot::new(RateWindow::informational(balance_text.clone())) + .with_login_method("Browser session"); let mut result = ProviderFetchResult::new(usage, "web"); From 4ecb0e2530f70a4946f4cd4cc7087fe76f3d8ca0 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 19:39:41 +0700 Subject: [PATCH 15/20] Fix X API display structure and parsing validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account ID validation now only accepts ASCII alphanumeric [A-Za-z0-9_-] (not Unicode), matching upstream specification - Remove duplicate informational meter display; show only 'Credits' section with three rows: Balance, Purchased credits, Free credits - Fix freeCredits handling: undefined → 0, invalid → parse failure - Improved error messages to use 'account or dollar balance' for consistency - Handle HTTP 400 with code 215 as session expired (legacy auth error) - All tests passing --- rust/src/providers/xapi/mod.rs | 134 ++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 52 deletions(-) diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index fbe985cc1c..9f33c1cf53 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -19,9 +19,10 @@ use async_trait::async_trait; use reqwest::{Client, StatusCode, header::ACCEPT}; use serde_json::{Map, Value}; +use crate::core::RateWindow; use crate::core::{ CostSnapshot, FetchContext, ManualEmptyCookiePolicy, ProviderDisplayDetail, ProviderError, - ProviderFetchResult, ProviderId, ProviderMetadata, ProviderStateKind, RateWindow, SourceMode, + ProviderFetchResult, ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, UsageSnapshot, }; use crate::locale::LocaleKey; @@ -262,7 +263,7 @@ async fn get_json( .send() .await?; - check_status(response.status())?; + let status = response.status(); let body_bytes = read_bounded_response(response, MAX_RESPONSE_BYTES) .await .map_err(|error| match error { @@ -271,6 +272,19 @@ async fn get_json( })?; let body = std::str::from_utf8(&body_bytes).map_err(|_| invalid("JSON response"))?; + + // For 400, check if it's code 215 (legacy auth error) before parsing the body + if status == StatusCode::BAD_REQUEST + && let Ok(json) = serde_json::from_str::(body) + && let Some(errors) = json.get("errors").and_then(|v| v.as_array()) + && errors + .iter() + .any(|e| e.get("code").and_then(|c| c.as_i64()) == Some(215)) + { + return Err(ProviderError::Other(SESSION_EXPIRED.to_owned())); + } + + check_status(status)?; parse_object(body) } @@ -285,23 +299,28 @@ fn parse_account_id(me: &Map) -> Result { let id = me .get("account") .and_then(|v| v.get("id")) - .ok_or_else(|| invalid("account.id"))?; + .ok_or_else(|| invalid("account or dollar balance"))?; // Handle both string and integer IDs let account_id = match id { - Value::Number(n) => n.to_string(), + Value::Number(n) => { + if !n.is_u64() || n.as_u64().unwrap() > i64::MAX as u64 { + return Err(invalid("account or dollar balance")); + } + n.to_string() + } Value::String(s) => s.clone(), - _ => return Err(invalid("account.id type")), + _ => return Err(invalid("account or dollar balance")), }; - // Validate account ID format + // Validate account ID format: ASCII alphanumeric, underscore, hyphen; 1-128 chars if account_id.is_empty() || account_id.len() > 128 || !account_id .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { - return Err(invalid("account.id format")); + return Err(invalid("account or dollar balance")); } Ok(account_id) @@ -311,35 +330,47 @@ fn parse_credits( _account_id: &str, credits: &Map, ) -> Result { - let get_balance = |key: &str| -> Result { - credits - .get(key) - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .ok_or_else(|| invalid("balance amount")) - .and_then(|n| { - if n.is_finite() { - Ok(n) + // Paid credits: required + let paid = credits + .get("credits") + .and_then(|v| v.get("balance")) + .ok_or_else(|| invalid("account or dollar balance")) + .and_then(|v| match v { + Value::Number(n) if n.is_f64() => { + let f = n.as_f64().unwrap(); + if f.is_finite() { + Ok(f) } else { - Err(invalid("balance amount")) + Err(invalid("account or dollar balance")) } - }) - }; + } + _ => Err(invalid("account or dollar balance")), + })?; - let paid = get_balance("credits")?; - let free = credits - .get("freeCredits") - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .filter(|n| n.is_finite()) - .unwrap_or(0.0); + // Free credits: if undefined, use 0; if present, must be valid or error + let free = match credits.get("freeCredits") { + None => 0.0, + Some(v) => match v.get("balance") { + None => 0.0, + Some(b) => match b { + Value::Number(n) if n.is_f64() => { + let f = n.as_f64().unwrap(); + if f.is_finite() { + f + } else { + return Err(invalid("account or dollar balance")); + } + } + _ => return Err(invalid("account or dollar balance")), + }, + }, + }; let balance = paid + free; - let balance_text = format!("${:.2}", balance); - // Build the usage snapshot with balance as informational - let usage = UsageSnapshot::new(RateWindow::informational(balance_text.clone())) - .with_login_method("Browser session"); + // Build the usage snapshot with placeholder (not displayed for this provider) + let usage = + UsageSnapshot::new(RateWindow::informational("")).with_login_method("Browser session"); let mut result = ProviderFetchResult::new(usage, "web"); @@ -350,25 +381,27 @@ fn parse_credits( .with_card_title(LocaleKey::CreditsTitle), ); - // Add display details rows - let mut rows = vec![]; - if let Some(row) = ProviderDisplayDetail::new("balance", "Balance", balance_text) { - rows.push(row); - } - if let Some(row) = ProviderDisplayDetail::new( - "purchased-credits", - "Purchased credits", - format!("${:.2}", paid), - ) { - rows.push(row); - } - if let Some(row) = - ProviderDisplayDetail::new("free-credits", "Free credits", format!("${:.2}", free)) - { - rows.push(row); + // Add display details as three rows, all with "Credits" section title + let balance_text = format!("${:.2}", balance); + let paid_text = format!("${:.2}", paid); + let free_text = format!("${:.2}", free); + + let detail_rows = vec![ + ProviderDisplayDetail::new("balance", "Balance", balance_text) + .and_then(|d| d.with_section_title("Credits")), + ProviderDisplayDetail::new("purchased-credits", "Purchased credits", paid_text) + .and_then(|d| d.with_section_title("Credits")), + ProviderDisplayDetail::new("free-credits", "Free credits", free_text) + .and_then(|d| d.with_section_title("Credits")), + ]; + + let detail_rows: Vec<_> = detail_rows.into_iter().flatten().collect(); + + if detail_rows.len() != 3 { + return Err(invalid("account or dollar balance")); } - result = result.with_display_details(rows); + result = result.with_display_details(detail_rows); Ok(result) } @@ -377,13 +410,10 @@ fn invalid(field: &str) -> ProviderError { } /// Upstream's HTTP status taxonomy; only 200 continues to the body. +/// Note: 400 with code 215 is handled specially in get_json; this is for generic 400s. fn check_status(status: StatusCode) -> Result<(), ProviderError> { let message = match status.as_u16() { 200 => return Ok(()), - 400 => { - // 400 might be an authentication error (code 215) - SESSION_EXPIRED.to_owned() - } 401 => SESSION_EXPIRED.to_owned(), 403 => "X developer console access was denied.".to_owned(), 429 => "X API console is rate limited. Wait before refreshing.".to_owned(), From 77e73257a41798cc2a822e0078f623283b073c4f Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 20:40:56 +0700 Subject: [PATCH 16/20] Match upstream X API amounts, account IDs and card layout --- rust/src/providers/xapi/mod.rs | 65 ++++------ rust/src/providers/xapi/tests.rs | 204 ++++++++++++------------------- 2 files changed, 102 insertions(+), 167 deletions(-) diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index 9f33c1cf53..d25dc07127 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -301,14 +301,15 @@ fn parse_account_id(me: &Map) -> Result { .and_then(|v| v.get("id")) .ok_or_else(|| invalid("account or dollar balance"))?; - // Handle both string and integer IDs + // Upstream: `Number.isSafeInteger(id) && id >= 0 ? String(id) : id`. + const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; let account_id = match id { - Value::Number(n) => { - if !n.is_u64() || n.as_u64().unwrap() > i64::MAX as u64 { - return Err(invalid("account or dollar balance")); + Value::Number(n) => match n.as_f64() { + Some(f) if f.fract() == 0.0 && (0.0..=MAX_SAFE_INTEGER).contains(&f) => { + format!("{}", f as u64) } - n.to_string() - } + _ => return Err(invalid("account or dollar balance")), + }, Value::String(s) => s.clone(), _ => return Err(invalid("account or dollar balance")), }; @@ -335,42 +336,22 @@ fn parse_credits( .get("credits") .and_then(|v| v.get("balance")) .ok_or_else(|| invalid("account or dollar balance")) - .and_then(|v| match v { - Value::Number(n) if n.is_f64() => { - let f = n.as_f64().unwrap(); - if f.is_finite() { - Ok(f) - } else { - Err(invalid("account or dollar balance")) - } - } - _ => Err(invalid("account or dollar balance")), - })?; + .and_then(amount)?; - // Free credits: if undefined, use 0; if present, must be valid or error + // Free credits: absent means 0; present (even null) needs a valid balance. let free = match credits.get("freeCredits") { None => 0.0, - Some(v) => match v.get("balance") { - None => 0.0, - Some(b) => match b { - Value::Number(n) if n.is_f64() => { - let f = n.as_f64().unwrap(); - if f.is_finite() { - f - } else { - return Err(invalid("account or dollar balance")); - } - } - _ => return Err(invalid("account or dollar balance")), - }, - }, + Some(v) => amount(v.get("balance").unwrap_or(&Value::Null))?, }; let balance = paid + free; - // Build the usage snapshot with placeholder (not displayed for this provider) - let usage = - UsageSnapshot::new(RateWindow::informational("")).with_login_method("Browser session"); + // Upstream has no window: the balance reaches the tray and float bar only. + let usage = UsageSnapshot::new( + RateWindow::informational(format!("Balance: {}", super::format::usd(balance))) + .hidden_on_card(), + ) + .with_login_method("Browser session"); let mut result = ProviderFetchResult::new(usage, "web"); @@ -382,9 +363,9 @@ fn parse_credits( ); // Add display details as three rows, all with "Credits" section title - let balance_text = format!("${:.2}", balance); - let paid_text = format!("${:.2}", paid); - let free_text = format!("${:.2}", free); + let balance_text = super::format::usd(balance); + let paid_text = super::format::usd(paid); + let free_text = super::format::usd(free); let detail_rows = vec![ ProviderDisplayDetail::new("balance", "Balance", balance_text) @@ -405,6 +386,14 @@ fn parse_credits( Ok(result) } +/// Any finite JSON number, integers included (upstream `amount`). +fn amount(value: &Value) -> Result { + value + .as_f64() + .filter(|f| f.is_finite()) + .ok_or_else(|| invalid("account or dollar balance")) +} + fn invalid(field: &str) -> ProviderError { ProviderError::Other(format!("X API returned an unrecognized {}.", field)) } diff --git a/rust/src/providers/xapi/tests.rs b/rust/src/providers/xapi/tests.rs index b103e39747..af470b3a74 100644 --- a/rust/src/providers/xapi/tests.rs +++ b/rust/src/providers/xapi/tests.rs @@ -1,8 +1,20 @@ #[cfg(test)] mod xapi_tests { use crate::core::{Provider, ProviderId}; - use crate::providers::xapi::XapiProvider; - use serde_json::json; + use crate::providers::xapi::{XapiProvider, parse_account_id, parse_credits}; + use serde_json::{Map, Value, json}; + + fn object(value: Value) -> Map { + value.as_object().cloned().expect("JSON object") + } + + fn detail_values(result: &crate::core::ProviderFetchResult) -> Vec<(String, String)> { + result + .display_details + .iter() + .map(|d| (d.title().to_string(), d.value().to_string())) + .collect() + } #[test] fn provider_metadata() { @@ -17,148 +29,82 @@ mod xapi_tests { } #[test] - fn balance_parsing_typical() { - // Test typical paid + free balance - let paid = 10.4; - let free = 2.0; - assert_eq!(paid + free, 12.4); - } - - #[test] - fn parse_account_id_string() { - // Account IDs can be strings - let text = r#"{"account":{"id":"fixture-account"}}"#; - let me: serde_json::Map = - serde_json::from_str::(text) - .ok() - .and_then(|v| v.as_object().cloned()) - .expect("valid JSON"); - - let id = me - .get("account") - .and_then(|v| v.get("id")) - .and_then(|v| v.as_str()); - assert_eq!(id, Some("fixture-account")); - } - - #[test] - fn parse_account_id_integer() { - // Account IDs can be integers - let text = r#"{"account":{"id":42}}"#; - let me: serde_json::Map = - serde_json::from_str::(text) - .ok() - .and_then(|v| v.as_object().cloned()) - .expect("valid JSON"); - - let id = me.get("account").and_then(|v| v.get("id")); - let account_id = match id { - Some(serde_json::Value::Number(n)) => Some(n.to_string()), - Some(serde_json::Value::String(s)) => Some(s.clone()), - _ => None, - }; - assert_eq!(account_id, Some("42".to_string())); + fn account_id_accepts_strings_and_safe_integers() { + let id = |v: Value| parse_account_id(&object(json!({ "account": { "id": v } }))); + assert_eq!(id(json!("fixture-account")).unwrap(), "fixture-account"); + assert_eq!(id(json!(42)).unwrap(), "42"); + assert_eq!(id(json!(42.0)).unwrap(), "42"); + assert_eq!( + id(json!(9_007_199_254_740_991_u64)).unwrap(), + "9007199254740991" + ); } #[test] - fn parse_credits_valid() { - // Test typical credits response - let credits_json = json!({ - "credits": {"balance": 10.4}, - "freeCredits": {"balance": 2.0} - }); - let credits = credits_json.as_object().unwrap(); - - let paid = credits - .get("credits") - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let free = credits - .get("freeCredits") - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let balance = paid + free; - - assert_eq!(paid, 10.4); - assert_eq!(free, 2.0); - assert_eq!(balance, 12.4); + fn account_id_rejects_unsafe_or_malformed_values() { + let id = |v: Value| parse_account_id(&object(json!({ "account": { "id": v } }))); + assert!(id(json!(9_007_199_254_740_992_u64)).is_err()); + assert!(id(json!(-1)).is_err()); + assert!(id(json!(1.5)).is_err()); + assert!(id(json!("")).is_err()); + assert!(id(json!("a/b")).is_err()); + assert!(id(json!("a".repeat(129))).is_err()); + assert!(parse_account_id(&object(json!({}))).is_err()); } #[test] - fn parse_credits_negative_balance() { - // Negative balances should be preserved - let credits_json = json!({ - "credits": {"balance": -2.15} - }); - let credits = credits_json.as_object().unwrap(); - - let paid = credits - .get("credits") - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - - assert_eq!(paid, -2.15); + fn credits_sum_paid_and_free_into_a_card_hidden_balance() { + let result = parse_credits( + "1", + &object(json!({ "credits": { "balance": 42.5 }, "freeCredits": { "balance": 7.25 } })), + ) + .unwrap(); + + assert_eq!( + detail_values(&result), + [ + ("Balance".to_string(), "$49.75".to_string()), + ("Purchased credits".to_string(), "$42.50".to_string()), + ("Free credits".to_string(), "$7.25".to_string()), + ] + ); + let primary = &result.usage.primary; + assert!(primary.is_informational && primary.hidden_on_card); + assert_eq!( + primary.reset_description.as_deref(), + Some("Balance: $49.75") + ); + assert_eq!(result.cost.as_ref().and_then(|c| c.balance), Some(49.75)); } #[test] - fn parse_credits_missing_free() { - // Missing freeCredits should default to 0 - let credits_json = json!({ - "credits": {"balance": 10.4} - }); - let credits = credits_json.as_object().unwrap(); - - let free = credits - .get("freeCredits") - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - - assert_eq!(free, 0.0); + fn credits_accept_integer_balances_and_missing_free_credits() { + let result = + parse_credits("1", &object(json!({ "credits": { "balance": 1234 } }))).unwrap(); + assert_eq!( + detail_values(&result), + [ + ("Balance".to_string(), "$1,234.00".to_string()), + ("Purchased credits".to_string(), "$1,234.00".to_string()), + ("Free credits".to_string(), "$0.00".to_string()), + ] + ); } #[test] - fn parse_credits_zero() { - // Zero balance should be valid - let credits_json = json!({ - "credits": {"balance": 0.0}, - "freeCredits": {"balance": 0.0} - }); - let credits = credits_json.as_object().unwrap(); - - let paid = credits - .get("credits") - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let free = credits - .get("freeCredits") - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - - assert_eq!(paid, 0.0); - assert_eq!(free, 0.0); + fn credits_keep_negative_balances_signed() { + let result = + parse_credits("1", &object(json!({ "credits": { "balance": -2.15 } }))).unwrap(); + assert_eq!(detail_values(&result)[0].1, "-$2.15"); } #[test] - fn parse_credits_infinity_invalid() { - // Infinity should not be a valid balance - let credits_json = json!({ - "credits": {"balance": f64::INFINITY} - }); - let credits = credits_json.as_object().unwrap(); - - let paid = credits - .get("credits") - .and_then(|v| v.get("balance")) - .and_then(|v| v.as_f64()) - .filter(|n| n.is_finite()); - - assert_eq!(paid, None); + fn credits_reject_missing_or_invalid_balances() { + let parse = |v: Value| parse_credits("1", &object(v)); + assert!(parse(json!({})).is_err()); + assert!(parse(json!({ "credits": { "balance": "10" } })).is_err()); + assert!(parse(json!({ "credits": { "balance": 1 }, "freeCredits": {} })).is_err()); + assert!(parse(json!({ "credits": { "balance": 1 }, "freeCredits": null })).is_err()); } #[test] From 1e84c29d50423980b19a617bc8618c738b04fc06 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 20:31:16 +0700 Subject: [PATCH 17/20] Skip card-hidden windows in the provider settings pane --- .../src-tauri/src/commands/tests.rs | 42 +++++++++++++++++++ .../src-tauri/src/commands/usage_items.rs | 40 +++++++++++++----- .../providers/sections/UsageSection.test.tsx | 16 +++++++ .../providers/sections/UsageSection.tsx | 9 ++-- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 587da2344e..bae262dc8e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1437,6 +1437,48 @@ fn usage_item_descriptors_include_detail_sections() { assert_eq!(hidden.title, "quota"); } +#[test] +fn usage_item_descriptors_skip_card_hidden_windows() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new( + codexbar::core::RateWindow::informational("$1.00").hidden_on_card(), + ) + .with_secondary(codexbar::core::RateWindow::new(20.0)), + cost: None, + wayfinder_usage: None, + inventory: Vec::new(), + reset_credits: None, + display_details: vec![ + codexbar::core::ProviderDisplayDetail::new("spend", "Spend", "$1.00") + .expect("valid detail"), + ], + source_label: "api".to_string(), + has_successful_claude_cli_quota: false, + pace_authoritative: true, + account_identity: None, + open_ai_api_usage: None, + last_good_owner: None, + }; + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + let mut hidden_extra = snapshot.primary.clone(); + hidden_extra.hidden_on_card = true; + snapshot.extra_rate_windows = vec![NamedRateWindowSnapshot { + id: "hidden".to_string(), + title: "Hidden".to_string(), + window: hidden_extra, + fallback_lane: false, + icon_fallback: None, + }]; + + let items = + super::usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Codex); + let ids: Vec<&str> = items.iter().map(|item| item.id.as_str()).collect(); + + assert_eq!(ids, ["detailSection:Spend", "metric:secondary"]); +} + #[test] fn pace_stage_serializes_to_snake_case_string() { use codexbar::core::PaceStage; diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs index e60e8c16ea..5b176d2a39 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs @@ -150,13 +150,21 @@ pub(crate) fn usage_item_descriptors( }; if let Some(snapshot) = snapshot { - let session = locale::get_text(lang, LocaleKey::ProviderSessionLabel); - push( - "primary", - snapshot.primary_label.as_deref().unwrap_or(&session), - true, - ); - if snapshot.secondary.is_some() { + // A card-hidden window is never drawn as a row, so a visibility + // toggle for it would control nothing. + if !snapshot.primary.hidden_on_card { + let session = locale::get_text(lang, LocaleKey::ProviderSessionLabel); + push( + "primary", + snapshot.primary_label.as_deref().unwrap_or(&session), + true, + ); + } + if snapshot + .secondary + .as_ref() + .is_some_and(|w| !w.hidden_on_card) + { let weekly = locale::get_text(lang, LocaleKey::ProviderWeeklyLabel); push( "secondary", @@ -164,11 +172,19 @@ pub(crate) fn usage_item_descriptors( true, ); } - if snapshot.model_specific.is_some() { + if snapshot + .model_specific + .as_ref() + .is_some_and(|w| !w.hidden_on_card) + { let model = locale::get_text(lang, LocaleKey::DetailWindowModelSpecific); push("model-specific", &model, true); } - if snapshot.tertiary.is_some() { + if snapshot + .tertiary + .as_ref() + .is_some_and(|w| !w.hidden_on_card) + { let tertiary = locale::get_text(lang, LocaleKey::DetailWindowTertiary); push( "tertiary", @@ -176,7 +192,11 @@ pub(crate) fn usage_item_descriptors( true, ); } - for extra in &snapshot.extra_rate_windows { + for extra in snapshot + .extra_rate_windows + .iter() + .filter(|extra| !extra.window.hidden_on_card) + { push(&format!("extra-{}", extra.id), &extra.title, true); } } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index 85f30d2f2d..4f226a5303 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -96,6 +96,22 @@ describe("UsageSection", () => { expect(screen.getByText("58% left")).toBeInTheDocument(); }); + it("omits card-hidden windows, as the tray card does", async () => { + const detail = provider(); + detail.session = { ...rateWindow(0), hiddenOnCard: true }; + detail.primaryLabel = "Spend"; + + render( + + key} /> + , + ); + + expect(await screen.findByText("Additional Budget")).toBeInTheDocument(); + expect(screen.queryByText("Spend")).not.toBeInTheDocument(); + expect(screen.queryByText("100% left")).not.toBeInTheDocument(); + }); + it.each([false, true])( "renders detail-backed amounts as a detail line, not reset text (resetsAt: %s)", async (hasReset) => { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index 68ca40cf33..399910ba20 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -88,14 +88,17 @@ export function UsageSection({ provider, resetTimeRelative, display, accentColor }); } + // Card-hidden windows only feed the tray icon and float bar, as on MenuCard. + const shownBars = bars.filter((bar) => !bar.rate.hiddenOnCard); + // Cached snapshots outlive the pool reset; re-check blocks on the clock. const monthlyLimitBlockNow = useMonthlyLimitBlockNow( - bars.map((bar) => bar.rate.monthlyLimitBlock), + shownBars.map((bar) => bar.rate.monthlyLimitBlock), ); const inventory = provider.inventory ?? []; const displayDetails = provider.displayDetails ?? []; const displayDetailGroups = groupProviderDisplayDetails(displayDetails); - if (bars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { + if (shownBars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { return null; } @@ -105,7 +108,7 @@ export function UsageSection({ provider, resetTimeRelative, display, accentColor style={accentColor ? ({ "--provider-accent": accentColor } as CSSProperties) : undefined} >

{t("ProviderUsage")}

- {bars.map((b) => ( + {shownBars.map((b) => ( Date: Mon, 12 Oct 2026 21:01:08 +0700 Subject: [PATCH 18/20] Format X API account ids without a lossy cast --- rust/src/providers/xapi/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index d25dc07127..734774f35d 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -306,7 +306,8 @@ fn parse_account_id(me: &Map) -> Result { let account_id = match id { Value::Number(n) => match n.as_f64() { Some(f) if f.fract() == 0.0 && (0.0..=MAX_SAFE_INTEGER).contains(&f) => { - format!("{}", f as u64) + // `+ 0.0` turns -0 into 0, as JavaScript's String(-0) does. + format!("{:.0}", f + 0.0) } _ => return Err(invalid("account or dollar balance")), }, From 3f834e0625da7dfc98022c1eef62b420e10f7de2 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 21:29:41 +0700 Subject: [PATCH 19/20] Hide the X API cost block on the card like upstream --- rust/src/providers/xapi/mod.rs | 12 +++--------- rust/src/providers/xapi/tests.rs | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index 734774f35d..4e8f4b7548 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -21,11 +21,10 @@ use serde_json::{Map, Value}; use crate::core::RateWindow; use crate::core::{ - CostSnapshot, FetchContext, ManualEmptyCookiePolicy, ProviderDisplayDetail, ProviderError, + FetchContext, ManualEmptyCookiePolicy, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, UsageSnapshot, }; -use crate::locale::LocaleKey; use crate::providers::{BoundedBodyError, read_bounded_response}; const BASE_URL: &str = "https://console.x.com"; @@ -354,15 +353,10 @@ fn parse_credits( ) .with_login_method("Browser session"); + // Upstream's cost presenter uses `menuCardStyle: .hidden`, so no cost + // block: the Credits detail rows are the only card surface. let mut result = ProviderFetchResult::new(usage, "web"); - // Add cost block with balance - result = result.with_cost( - CostSnapshot::new(0.0, "USD", "Prepaid credits") - .with_balance(balance) - .with_card_title(LocaleKey::CreditsTitle), - ); - // Add display details as three rows, all with "Credits" section title let balance_text = super::format::usd(balance); let paid_text = super::format::usd(paid); diff --git a/rust/src/providers/xapi/tests.rs b/rust/src/providers/xapi/tests.rs index af470b3a74..31aa234749 100644 --- a/rust/src/providers/xapi/tests.rs +++ b/rust/src/providers/xapi/tests.rs @@ -74,7 +74,7 @@ mod xapi_tests { primary.reset_description.as_deref(), Some("Balance: $49.75") ); - assert_eq!(result.cost.as_ref().and_then(|c| c.balance), Some(49.75)); + assert!(result.cost.is_none(), "upstream hides the X API cost block"); } #[test] From 9e2152900918e5212a41f6518ab7dfbe8217d79a Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Tue, 13 Oct 2026 08:31:58 +0700 Subject: [PATCH 20/20] Add X API fetch fixtures and qualify Automatic mode Cover HTTP 400/215 and 401 browser fallback, plain 400, and all-expired sessions with a local HTTP fixture. --- docs/PROVIDERS.md | 3 +- rust/src/providers/xapi/mod.rs | 23 +++- rust/src/providers/xapi/tests.rs | 184 +++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 7 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 3ba0cbd766..80df31b1b5 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -350,7 +350,8 @@ X API is disabled by default and reads prepaid credit balances from the X (Twitter) API console. Sign in to console.x.com in your browser, and CodexBar will read the `auth_token` and `ct0` cookies from that session. -- **Automatic:** CodexBar tries each detected browser (Chrome, Edge, Brave, +- **Automatic:** A previously imported Cookie header is used on its own. + Otherwise CodexBar tries each detected browser (Chrome, Edge, Brave, Firefox) holding both cookies. If a browser session expires (401), it moves to the next browser. No session means sign in to console.x.com. - **Manual:** Paste the Cookie header from a console.x.com request (with diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs index 4e8f4b7548..cf44cf5c1f 100644 --- a/rust/src/providers/xapi/mod.rs +++ b/rust/src/providers/xapi/mod.rs @@ -48,10 +48,15 @@ type BrowserCookieHeaders = dyn Fn() -> Result, ProviderEr pub struct XapiProvider { metadata: ProviderMetadata, client: Option, + base_url: String, } impl XapiProvider { pub fn new() -> Self { + Self::with_base_url(BASE_URL) + } + + fn with_base_url(base_url: &str) -> Self { let client = crate::core::credentialed_http_client_builder() .cookie_store(false) .redirect(reqwest::redirect::Policy::none()) @@ -73,6 +78,7 @@ impl XapiProvider { tertiary_label_key: None, }, client, + base_url: base_url.to_owned(), } } @@ -93,7 +99,7 @@ impl XapiProvider { let timeout = Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT); let mut last_error = None; for session in &sessions { - match fetch_session(client, session, timeout).await { + match fetch_session(client, &self.base_url, session, timeout).await { Err(error) if is_session_expired(&error) => last_error = Some(error), result => return result, } @@ -165,14 +171,15 @@ impl crate::core::Provider for XapiProvider { /// One session's me → credits sequence. async fn fetch_session( client: &Client, + base_url: &str, session: &Session, timeout: Duration, ) -> Result { - let me = get_json(client, "/api/me", session, timeout).await?; + let me = get_json(client, &format!("{base_url}/api/me"), session, timeout).await?; let account_id = parse_account_id(&me)?; let credits = get_json( client, - &format!("/api/accounts/{}/credits", account_id), + &format!("{base_url}/api/accounts/{account_id}/credits"), session, timeout, ) @@ -249,12 +256,12 @@ fn session_from_header(header: &str) -> Option { async fn get_json( client: &Client, - path: &str, + url: &str, session: &Session, timeout: Duration, ) -> Result, ProviderError> { let response = client - .get(format!("{}{}", BASE_URL, path)) + .get(url) .header(ACCEPT, "application/json") .header("Cookie", &session.cookie_header) .header(CSRF_HEADER, &session.csrf) @@ -344,7 +351,11 @@ fn parse_credits( Some(v) => amount(v.get("balance").unwrap_or(&Value::Null))?, }; + // Upstream `amount(paid + free)`: two finite balances can overflow to infinity. let balance = paid + free; + if !balance.is_finite() { + return Err(invalid("account or dollar balance")); + } // Upstream has no window: the balance reaches the tray and float bar only. let usage = UsageSnapshot::new( @@ -395,7 +406,7 @@ fn invalid(field: &str) -> ProviderError { /// Upstream's HTTP status taxonomy; only 200 continues to the body. /// Note: 400 with code 215 is handled specially in get_json; this is for generic 400s. -fn check_status(status: StatusCode) -> Result<(), ProviderError> { +pub(crate) fn check_status(status: StatusCode) -> Result<(), ProviderError> { let message = match status.as_u16() { 200 => return Ok(()), 401 => SESSION_EXPIRED.to_owned(), diff --git a/rust/src/providers/xapi/tests.rs b/rust/src/providers/xapi/tests.rs index 31aa234749..d0e8d932b3 100644 --- a/rust/src/providers/xapi/tests.rs +++ b/rust/src/providers/xapi/tests.rs @@ -107,6 +107,34 @@ mod xapi_tests { assert!(parse(json!({ "credits": { "balance": 1 }, "freeCredits": null })).is_err()); } + #[test] + fn credits_reject_a_sum_that_overflows() { + let result = parse_credits( + "1", + &object(json!({ + "credits": { "balance": f64::MAX }, + "freeCredits": { "balance": f64::MAX } + })), + ); + assert!(result.is_err()); + } + + #[test] + fn status_errors_match_upstream() { + use reqwest::StatusCode; + let message = |code: u16| { + crate::providers::xapi::check_status(StatusCode::from_u16(code).unwrap()) + .unwrap_err() + .to_string() + }; + assert!(crate::providers::xapi::check_status(StatusCode::OK).is_ok()); + assert!(message(401).contains("X API session expired")); + assert!(message(403).contains("X developer console access was denied.")); + assert!(message(429).contains("X API console is rate limited.")); + assert!(message(503).contains("X developer console is unavailable.")); + assert!(message(418).contains("X developer console returned HTTP 418.")); + } + #[test] fn provider_supports_web() { let provider = XapiProvider::new(); @@ -122,3 +150,159 @@ mod xapi_tests { assert!(sources.contains(&crate::core::SourceMode::Web)); } } + +#[cfg(test)] +mod xapi_fetch_tests { + use std::sync::{Arc, Mutex}; + + use crate::core::{FetchContext, ProviderError}; + use crate::providers::xapi::{SESSION_EXPIRED, XapiProvider}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// (request path, Cookie header, X-CSRF-Token header) + type Recorded = Arc>>; + + /// Serves one canned response per connection, in order. + async fn serve_in_order( + responses: Vec<(u16, &'static str)>, + ) -> (String, Recorded, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let recorded: Recorded = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&recorded); + let task = tokio::spawn(async move { + for (status, body) in responses { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(read) => request.extend_from_slice(&chunk[..read]), + } + } + let text = String::from_utf8_lossy(&request).to_string(); + let path = text + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + let header = |name: &str| { + text.lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + .unwrap_or_default() + }; + sink.lock() + .unwrap() + .push((path, header("cookie"), header("x-csrf-token"))); + let response = format!( + "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write fixture response"); + stream.shutdown().await.expect("close fixture stream"); + } + }); + (base, recorded, task) + } + + fn two_browsers() -> Result, ProviderError> { + Ok(vec![ + ("Chrome".to_string(), "auth_token=a; ct0=c1".to_string()), + ("Edge".to_string(), "auth_token=b; ct0=c2".to_string()), + ]) + } + + #[tokio::test] + async fn an_expired_session_moves_to_the_next_browser() { + let expired = [ + ( + 400, + r#"{"errors":[{"code":215,"message":"Bad Authentication data."}]}"#, + ), + (401, "{}"), + ]; + for first in expired { + let (base, recorded, task) = serve_in_order(vec![ + first, + (200, r#"{"account":{"id":42}}"#), + ( + 200, + r#"{"credits":{"balance":5},"freeCredits":{"balance":2.5}}"#, + ), + ]) + .await; + let provider = XapiProvider::with_base_url(&base); + let result = provider + .fetch_web(&FetchContext::default(), &two_browsers) + .await + .unwrap_or_else(|error| panic!("status {}: {error:?}", first.0)); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Balance: $7.50") + ); + let requests = recorded.lock().unwrap().clone(); + assert_eq!( + requests, + vec![ + ("/api/me".into(), "auth_token=a; ct0=c1".into(), "c1".into()), + ("/api/me".into(), "auth_token=b; ct0=c2".into(), "c2".into()), + ( + "/api/accounts/42/credits".into(), + "auth_token=b; ct0=c2".into(), + "c2".into() + ), + ], + "status {}", + first.0 + ); + task.abort(); + } + } + + #[tokio::test] + async fn a_plain_bad_request_ends_the_fetch() { + let (base, recorded, task) = serve_in_order(vec![ + (400, r#"{"errors":[{"code":88,"message":"Rate limit"}]}"#), + (200, r#"{"account":{"id":42}}"#), + ]) + .await; + let provider = XapiProvider::with_base_url(&base); + match provider + .fetch_web(&FetchContext::default(), &two_browsers) + .await + { + Err(ProviderError::Other(message)) => { + assert_eq!(message, "X developer console returned HTTP 400.") + } + other => panic!("unexpected {other:?}"), + } + assert_eq!(recorded.lock().unwrap().len(), 1); + task.abort(); + } + + #[tokio::test] + async fn every_expired_session_reports_the_upstream_message() { + let (base, _recorded, task) = serve_in_order(vec![(401, "{}"), (401, "{}")]).await; + let provider = XapiProvider::with_base_url(&base); + match provider + .fetch_web(&FetchContext::default(), &two_browsers) + .await + { + Err(ProviderError::Other(message)) => assert_eq!(message, SESSION_EXPIRED), + other => panic!("unexpected {other:?}"), + } + task.abort(); + } +}