From 17d5e87a9b8f2b3945817616c4a95a016fc74a5b Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:28:49 +0700 Subject: [PATCH 01/14] Add the Synthetic provider --- README.md | 1 + .../icons/ProviderIcon-synthetic.svg | 14 + .../src/components/providers/providerIcons.ts | 3 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 19 + rust/src/core/provider.rs | 11 +- rust/src/core/provider_factory.rs | 2 + rust/src/core/token_accounts.rs | 3 +- rust/src/providers/mod.rs | 2 + rust/src/providers/synthetic/mod.rs | 728 ++++++++++++++++++ rust/src/providers/synthetic/tests.rs | 417 ++++++++++ rust/src/settings/api_keys.rs | 11 + 12 files changed, 1210 insertions(+), 2 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-synthetic.svg create mode 100644 rust/src/providers/synthetic/mod.rs create mode 100644 rust/src/providers/synthetic/tests.rs diff --git a/README.md b/README.md index e30de60432..45fd57a94f 100755 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | llmman | Local daemon / optional API Key | Memory in use, loaded and stored models | | DevPass | API Key | Plan credits, Premium weekly, API-key spend | | xKiro | API Key | Daily free tokens | +| Synthetic | API Key | Five-hour quota, Weekly tokens, Hourly search, Plan | diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-synthetic.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-synthetic.svg new file mode 100644 index 0000000000..b50f4da2cc --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-synthetic.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 082672f4d2..776b0e21c3 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -63,6 +63,7 @@ import replicate from "./icons/ProviderIcon-replicate.svg?raw"; import sakana from "./icons/ProviderIcon-sakana.svg?raw"; import stepfun from "./icons/ProviderIcon-stepfun.svg?raw"; import sub2api from "./icons/ProviderIcon-sub2api.svg?raw"; +import synthetic from "./icons/ProviderIcon-synthetic.svg?raw"; import t3chat from "./icons/ProviderIcon-t3chat.svg?raw"; import venice from "./icons/ProviderIcon-venice.svg?raw"; import vercel from "./icons/ProviderIcon-vercel.svg?raw"; @@ -157,6 +158,7 @@ const RAW: Record = { sakana: tint(sakana), stepfun: tint(stepfun), sub2api: tint(sub2api), + synthetic: tint(synthetic), t3chat: tint(t3chat), venice: tint(venice), vercel: tint(vercel), @@ -250,6 +252,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { sub2api: { id: "sub2api", brandColor: "#14b8a6", fallbackLetter: "S", svgPath: RAW.sub2api }, venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, + synthetic: { id: "synthetic", brandColor: "#141414", fallbackLetter: "S", svgPath: RAW.synthetic }, openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" }, chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" }, litellm: { id: "litellm", brandColor: "#0ea5e9", fallbackLetter: "L" }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index c2d33f3719..b798e2a46f 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -86,4 +86,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["devpass", "DevPass"], ["xkiro", "xKiro"], ["raycast", "Raycast"], + ["synthetic", "Synthetic"], ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 0cf80274a7..5da62bbfc2 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -185,6 +185,25 @@ are never followed and response bodies are never echoed in errors. budget, inconsistent coverage, malformed amounts) is a parse error, not a partial balance. +### Synthetic + +Synthetic reads `GET https://api.synthetic.new/v2/quotas` with +`Authorization: Bearer `. Configure the key in Settings → Providers → +Synthetic (or `SYNTHETIC_API_KEY`); the CLI also accepts `synthetic.new`. + +- `rollingFiveHourLimit` is the "Five-hour quota" lane, `weeklyTokenLimit` the + "Weekly tokens" lane and `search.hourly` a "Search hourly" window. Other + payload shapes fall back to the first quota-like objects found under + `quotas`, `quota`, `limits`, `usage`, `entries`, `subscription` or `data`. +- When the five-hour quota reports `tickPercent`, the lane resets at the next + regen tick and its detail line reads, for example, + "76% after next regen · Full in ~3 regens". The weekly lane does the same + when it carries `maxCredits` and `nextRegenCredits`, which also give a + weekly USD cost. +- The plan name (`plan`, `planName`, `tier`, ...) is shown as the plan. +- 401 and 403 report "Invalid Synthetic API credentials."; response bodies are + never echoed in errors. + ## Listing what is enabled ```powershell diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 7a4aba44da..b8fbd4bcf2 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -103,6 +103,7 @@ pub enum ProviderId { XKiro, Raycast, Vercel, + Synthetic, } impl ProviderId { @@ -198,6 +199,7 @@ impl ProviderId { ProviderId::XKiro, ProviderId::Raycast, ProviderId::Vercel, + ProviderId::Synthetic, ] } @@ -254,6 +256,7 @@ impl ProviderId { ProviderId::XKiro => "xkiro", ProviderId::Raycast => "raycast", ProviderId::Vercel => "vercel", + ProviderId::Synthetic => "synthetic", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -350,6 +353,7 @@ impl ProviderId { ProviderId::XKiro => "xKiro", ProviderId::Raycast => "Raycast", ProviderId::Vercel => "Vercel AI Gateway", + ProviderId::Synthetic => "Synthetic", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -462,6 +466,7 @@ impl ProviderId { ProviderId::DevPass => None, ProviderId::XKiro => None, ProviderId::Vercel => None, + ProviderId::Synthetic => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -614,6 +619,7 @@ impl ProviderId { "replicate" | "r8" => Some(ProviderId::Replicate), "atlascloud" | "atlas-cloud" | "atlas cloud" => Some(ProviderId::AtlasCloud), "raycast" | "raycast-ai" => Some(ProviderId::Raycast), + "synthetic" | "synthetic.new" => Some(ProviderId::Synthetic), _ => None, } } @@ -1142,6 +1148,7 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("vercel ai gateway", ProviderId::Vercel); map.insert("ai-gateway", ProviderId::Vercel); map.insert("ai gateway", ProviderId::Vercel); + map.insert("synthetic.new", ProviderId::Synthetic); map.insert("metaspark", ProviderId::Meta); map.insert("meta-spark", ProviderId::Meta); map.insert("muse-spark", ProviderId::Meta); @@ -1305,6 +1312,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Raycast => "#FF6363", // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. ProviderId::Vercel => "#737373", + ProviderId::Synthetic => "#141414", } } @@ -1319,7 +1327,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 89); + assert_eq!(all.len(), 90); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1389,6 +1397,7 @@ mod tests { assert!(all.contains(&ProviderId::XKiro)); assert!(all.contains(&ProviderId::Raycast)); assert!(all.contains(&ProviderId::Vercel)); + assert!(all.contains(&ProviderId::Synthetic)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 1be54fc0c6..f89c913eb6 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -7,6 +7,7 @@ use super::{Provider, ProviderId}; use crate::providers::AtlasCloudProvider; +use crate::providers::SyntheticProvider; use crate::providers::{ AbacusProvider, AiAndProvider, AixyProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, AugmentProvider, AzureOpenAIProvider, BedrockProvider, @@ -41,6 +42,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Copilot => Box::new(CopilotProvider::new()), ProviderId::Antigravity => Box::new(AntigravityProvider::new()), ProviderId::AtlasCloud => Box::new(AtlasCloudProvider::new()), + ProviderId::Synthetic => Box::new(SyntheticProvider::new()), ProviderId::Factory => Box::new(FactoryProvider::new()), ProviderId::Zai => Box::new(ZaiProvider::new()), ProviderId::Kiro => Box::new(KiroProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index bae251693b..3bee7d5aad 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -431,7 +431,8 @@ impl TokenAccountSupport { | ProviderId::DevPass | ProviderId::XKiro | ProviderId::Raycast - | ProviderId::Vercel => None, + | ProviderId::Vercel + | ProviderId::Synthetic => None, } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 78f57c45a6..336e8e4e3e 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -84,6 +84,7 @@ pub mod replicate; pub mod sakana; pub mod stepfun; pub mod sub2api; +pub mod synthetic; pub mod t3chat; pub mod typesafe; pub mod v0; @@ -175,6 +176,7 @@ pub use replicate::ReplicateProvider; pub use sakana::SakanaProvider; pub use stepfun::StepFunProvider; pub use sub2api::Sub2ApiProvider; +pub use synthetic::SyntheticProvider; pub use t3chat::T3ChatProvider; pub use typesafe::TypeSafeProvider; pub use v0::V0Provider; diff --git a/rust/src/providers/synthetic/mod.rs b/rust/src/providers/synthetic/mod.rs new file mode 100644 index 0000000000..92490e1061 --- /dev/null +++ b/rust/src/providers/synthetic/mod.rs @@ -0,0 +1,728 @@ +//! Synthetic (synthetic.new) quota provider. +//! +//! Port of upstream `Resources/Plugins/synthetic.js`: one bearer-authenticated +//! `GET /v2/quotas` whose payload names a rolling five-hour quota, a weekly +//! token quota and an hourly search quota, with a key-guessing fallback for +//! other payload shapes. + +use async_trait::async_trait; +use chrono::{DateTime, TimeZone, Utc}; +use reqwest::{Client, StatusCode, redirect::Policy}; +use serde_json::Value; +use std::time::Duration; + +use crate::core::{ + CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const QUOTAS_URL: &str = "https://api.synthetic.new/v2/quotas"; +const CREDENTIAL_TARGET: &str = "codexbar-synthetic"; +pub(crate) const API_KEY_ENV: &str = "SYNTHETIC_API_KEY"; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const SEARCH_HOURLY_ID: &str = "synthetic-search-hourly"; +const SEARCH_HOURLY_TITLE: &str = "Search hourly"; + +const PERCENT_USED_KEYS: &[&str] = &[ + "percentUsed", + "usedPercent", + "usagePercent", + "usage_percent", + "used_percent", + "percent_used", + "percent", +]; +const PERCENT_REMAINING_KEYS: &[&str] = &[ + "percentRemaining", + "remainingPercent", + "remaining_percent", + "percent_remaining", +]; +const LIMIT_KEYS: &[&str] = &[ + "limit", + "messageLimit", + "message_limit", + "messages", + "maxRequests", + "max_requests", + "requestLimit", + "request_limit", + "quota", + "max", + "total", + "capacity", + "allowance", +]; +const USED_KEYS: &[&str] = &[ + "used", + "usage", + "usedMessages", + "used_messages", + "messagesUsed", + "messages_used", + "requests", + "requestCount", + "request_count", + "consumed", + "spent", +]; +const REMAINING_KEYS: &[&str] = &["remaining", "left", "available", "balance"]; +const RESET_KEYS: &[&str] = &[ + "resetAt", + "reset_at", + "resetsAt", + "resets_at", + "renewAt", + "renew_at", + "renewsAt", + "renews_at", + "nextTickAt", + "next_tick_at", + "nextRegenAt", + "next_regen_at", + "periodEnd", + "period_end", + "expiresAt", + "expires_at", + "endAt", + "end_at", +]; +const PLAN_KEYS: &[&str] = &[ + "plan", + "planName", + "plan_name", + "subscription", + "subscriptionPlan", + "tier", + "package", + "packageName", +]; +const TICK_PERCENT_KEYS: &[&str] = &[ + "tickPercent", + "tick_percent", + "nextTickPercent", + "next_tick_percent", +]; +/// Upstream fallback collections, read from the root and then from `data`. +const FALLBACK_COLLECTIONS: &[&str] = &[ + "quotas", + "quota", + "limits", + "usage", + "entries", + "subscription", +]; + +pub struct SyntheticProvider { + metadata: ProviderMetadata, + client: Client, + quotas_url: String, +} + +impl SyntheticProvider { + pub fn new() -> Self { + let client = crate::core::credentialed_http_client_builder() + .redirect(Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .expect("Synthetic HTTP client configuration is valid"); + Self::with_client(QUOTAS_URL, client) + } + + fn with_client(quotas_url: impl Into, client: Client) -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Synthetic, + display_name: "Synthetic", + session_label: "Five-hour quota", + weekly_label: "Weekly tokens", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: None, + status_page_url: None, + tertiary_label_key: None, + }, + client, + quotas_url: quotas_url.into(), + } + } + + async fn fetch_quotas(&self, ctx: &FetchContext) -> Result { + let key = crate::providers::resolve_api_key( + ctx.api_key.as_deref(), + CREDENTIAL_TARGET, + &[API_KEY_ENV], + )?; + + let response = self + .client + .get(&self.quotas_url) + .bearer_auth(&key) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await?; + let status = response.status(); + if status != StatusCode::OK { + return Err(status_error(status)); + } + + let body = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::Read(error) => ProviderError::Network(error), + BoundedBodyError::TooLarge => ProviderError::Parse(format!( + "Synthetic response exceeded {MAX_RESPONSE_BYTES} bytes." + )), + })?; + let payload: Value = serde_json::from_slice(&body) + .map_err(|_| parse_failure("the response is not JSON."))?; + Ok(parse_quotas(&payload)?.into_result()) + } +} + +impl Default for SyntheticProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for SyntheticProvider { + fn id(&self) -> ProviderId { + ProviderId::Synthetic + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_quotas(ctx).await, + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +fn status_error(status: StatusCode) -> ProviderError { + match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + ProviderError::Other("Invalid Synthetic API credentials.".into()) + } + status => ProviderError::Other(format!("Synthetic API error: HTTP {}.", status.as_u16())), + } +} + +/// One parsed quota object (upstream `parseQuota`). +#[derive(Debug, Clone, PartialEq)] +struct Quota { + used_percent: f64, + window_minutes: Option, + resets_at: Option>, + /// Upstream `windowDescription`, only when there is no reset time. + window_description: Option, + /// Upstream `nextRegenPercent`: the share one regen tick restores. + next_regen_percent: Option, + cost: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct QuotaCost { + used: f64, + limit: f64, + resets_at: Option>, + next_regen_amount: Option, +} + +/// The three card lanes plus plan and the first cost-bearing quota. +#[derive(Debug, Clone, PartialEq)] +struct ParsedQuotas { + primary: Option, + secondary: Option, + tertiary: Option, + plan: Option, + cost: Option, +} + +fn parse_failure(reason: &str) -> ProviderError { + ProviderError::Parse(format!("Failed to parse Synthetic response: {reason}")) +} + +fn parse_quotas(payload: &Value) -> Result { + let wrapped; + let root = match payload { + Value::Array(_) => { + wrapped = serde_json::json!({ "quotas": payload }); + &wrapped + } + Value::Object(_) => payload, + _ => return Err(parse_failure("expected an object or array.")), + }; + let data = root + .get("data") + .filter(|data| data.is_object() || data.is_array()); + + let slot = |pick: fn(&Value) -> Option<&Value>| -> Option<&Value> { + pick(root) + .filter(|value| is_quota(value)) + .or_else(|| data.and_then(pick).filter(|value| is_quota(value))) + }; + let slots = [ + slot(|value| value.get("rollingFiveHourLimit")), + slot(|value| value.get("weeklyTokenLimit")), + slot(|value| value.get("search").and_then(|search| search.get("hourly"))), + ]; + + let parsed: Vec> = if slots.iter().any(Option::is_some) { + slots + .iter() + .map(|value| value.and_then(parse_quota)) + .collect() + } else { + let candidates = FALLBACK_COLLECTIONS + .iter() + .map(|key| root.get(*key)) + .chain(std::iter::once(root.get("data"))) + .chain( + FALLBACK_COLLECTIONS + .iter() + .map(|key| data.and_then(|data| data.get(*key))), + ); + let mut values = Vec::new(); + for candidate in candidates.flatten() { + collect(candidate, &mut values); + if !values.is_empty() { + break; + } + } + values + .into_iter() + .filter_map(parse_quota) + .map(Some) + .collect() + }; + if !parsed.iter().any(Option::is_some) { + return Err(parse_failure("Missing quota data.")); + } + + let plan = first_string(root, PLAN_KEYS) + .or_else(|| data.and_then(|data| first_string(data, PLAN_KEYS))); + let cost = parsed.iter().flatten().find_map(|quota| quota.cost.clone()); + let mut lanes = parsed.into_iter(); + Ok(ParsedQuotas { + primary: lanes.next().flatten(), + secondary: lanes.next().flatten(), + tertiary: lanes.next().flatten(), + plan, + cost, + }) +} + +fn collect<'a>(candidate: &'a Value, out: &mut Vec<&'a Value>) { + match candidate { + Value::Array(items) => items.iter().for_each(|item| collect(item, out)), + Value::Object(_) if is_quota(candidate) => out.push(candidate), + // serde_json's default map is a BTreeMap, so keys iterate sorted, as + // upstream's `Object.keys(candidate).sort()` does. + Value::Object(map) => map.values().for_each(|value| collect(value, out)), + _ => {} + } +} + +fn is_quota(value: &Value) -> bool { + value.is_object() + && [ + LIMIT_KEYS, + USED_KEYS, + REMAINING_KEYS, + PERCENT_USED_KEYS, + PERCENT_REMAINING_KEYS, + ] + .iter() + .any(|keys| first_number(value, keys).is_some()) +} + +fn parse_quota(payload: &Value) -> Option { + let mut used_percent = normalized_percent(first_number(payload, PERCENT_USED_KEYS)); + let percent_remaining = normalized_percent(first_number(payload, PERCENT_REMAINING_KEYS)); + if used_percent.is_none() + && let Some(remaining) = percent_remaining + { + used_percent = Some(100.0 - remaining); + } + if used_percent.is_none() { + let mut limit = first_number(payload, LIMIT_KEYS); + let mut used = first_number(payload, USED_KEYS); + let remaining = first_number(payload, REMAINING_KEYS); + if limit.is_none() + && let (Some(used), Some(remaining)) = (used, remaining) + { + limit = Some(used + remaining); + } + if used.is_none() + && let (Some(limit), Some(remaining)) = (limit, remaining) + { + used = Some(limit - remaining); + } + if let (Some(limit), Some(used)) = (limit, used) + && limit > 0.0 + { + used_percent = Some(percent_of(used, limit)); + } + } + let used_percent = used_percent?.clamp(0.0, 100.0); + + let window_minutes = window_minutes(payload).and_then(|minutes| u32::try_from(minutes).ok()); + let resets_at = first_date(payload, RESET_KEYS); + let window_description = if resets_at.is_none() { + window_minutes.and_then(window_description) + } else { + None + }; + let next_regen_percent = normalized_percent(first_number(payload, TICK_PERCENT_KEYS)); + + let cost = first_currency(payload, &["maxCredits", "max_credits"]).map(|limit| { + let remaining = first_currency(payload, &["remainingCredits", "remaining_credits"]); + let explicit_used = first_currency(payload, &["usedCredits", "used_credits"]); + let used = explicit_used + .or_else(|| remaining.map(|remaining| (limit - remaining).max(0.0))) + .unwrap_or(used_percent / 100.0 * limit); + QuotaCost { + used, + limit, + resets_at, + next_regen_amount: first_currency(payload, &["nextRegenCredits", "next_regen_credits"]), + } + }); + + Some(Quota { + used_percent, + window_minutes, + resets_at, + window_description, + next_regen_percent, + cost, + }) +} + +/// Upstream `ctx.pct`: `used / limit * 100`, clamped to 0..=100. +fn percent_of(used: f64, limit: f64) -> f64 { + if !used.is_finite() || !limit.is_finite() || limit <= 0.0 { + return 100.0; + } + (used / limit * 100.0).clamp(0.0, 100.0) +} + +/// Upstream `normalizedPercent`: fractions (<= 1) are scaled to percent. +fn normalized_percent(value: Option) -> Option { + value.map(|value| if value <= 1.0 { value * 100.0 } else { value }) +} + +fn number_value(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_f64().filter(|value| value.is_finite()), + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + trimmed + .parse::() + .ok() + .filter(|value| value.is_finite()) + } + _ => None, + } +} + +fn first_number(payload: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| payload.get(*key).and_then(number_value)) +} + +fn first_string(payload: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + payload + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) + }) +} + +/// Upstream `currencyValue`: strings may carry `$` and thousands separators. +fn currency_value(value: &Value) -> Option { + match value { + Value::String(text) => { + let cleaned: String = text + .trim() + .chars() + .filter(|ch| *ch != '$' && *ch != ',') + .collect(); + let cleaned = cleaned.trim(); + // JavaScript `Number("")` is 0. + if cleaned.is_empty() { + return Some(0.0); + } + cleaned + .parse::() + .ok() + .filter(|value| value.is_finite()) + } + other => number_value(other), + } +} + +fn first_currency(payload: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| payload.get(*key).and_then(currency_value)) +} + +/// Upstream `parseDate`: epoch milliseconds above 1e12, epoch seconds above +/// 1e9, otherwise an ISO-8601 string. +#[allow( + clippy::cast_possible_truncation, + reason = "finite epoch values; `as` saturates and chrono rejects out-of-range instants" +)] +fn parse_date(value: &Value) -> Option> { + if let Some(number) = number_value(value) { + if number > 1_000_000_000_000.0 { + return Utc.timestamp_millis_opt(number as i64).single(); + } + if number > 1_000_000_000.0 { + return Utc.timestamp_millis_opt((number * 1000.0) as i64).single(); + } + } + value + .as_str() + .and_then(|text| DateTime::parse_from_rfc3339(text.trim()).ok()) + .map(|date| date.with_timezone(&Utc)) +} + +fn first_date(payload: &Value, keys: &[&str]) -> Option> { + keys.iter().find_map(|key| match payload.get(*key) { + None | Some(Value::Null) => None, + Some(value) => parse_date(value), + }) +} + +/// JavaScript `Math.round`: halves round toward positive infinity. +#[allow( + clippy::cast_possible_truncation, + reason = "display counts are small; `as` saturates on overflow" +)] +fn js_round(value: f64) -> i64 { + (value + 0.5).floor() as i64 +} + +fn window_minutes(payload: &Value) -> Option { + let scaled = [ + ( + &[ + "windowMinutes", + "window_minutes", + "periodMinutes", + "period_minutes", + ], + 1.0, + ), + ( + &["windowHours", "window_hours", "periodHours", "period_hours"], + 60.0, + ), + ( + &["windowDays", "window_days", "periodDays", "period_days"], + 1440.0, + ), + ( + &[ + "windowSeconds", + "window_seconds", + "periodSeconds", + "period_seconds", + ], + 1.0 / 60.0, + ), + ]; + for (keys, factor) in scaled { + if let Some(value) = first_number(payload, keys) { + return Some(js_round(value * factor)); + } + } + let text = first_string( + payload, + &[ + "window", + "windowLabel", + "window_label", + "period", + "periodLabel", + "period_label", + ], + )?; + window_text_minutes(&text) +} + +/// Upstream regex `^([0-9]*\.?[0-9]+)(minutes?|mins?|m|hours?|hrs?|hr|h|days?|d)$` +/// over the lowercased text with all whitespace removed. +fn window_text_minutes(text: &str) -> Option { + let compact: String = text + .to_lowercase() + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect(); + let split = compact + .find(|ch: char| !(ch.is_ascii_digit() || ch == '.')) + .unwrap_or(compact.len()); + let (number, unit) = compact.split_at(split); + let digits = |part: &str| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()); + let valid_number = match number.split_once('.') { + Some((whole, fraction)) => (whole.is_empty() || digits(whole)) && digits(fraction), + None => digits(number), + }; + if !valid_number { + return None; + } + let multiplier = match unit { + "m" | "min" | "mins" | "minute" | "minutes" => 1.0, + "h" | "hr" | "hrs" | "hour" | "hours" => 60.0, + "d" | "day" | "days" => 1440.0, + _ => return None, + }; + let value: f64 = number.parse().ok()?; + Some(js_round(value * multiplier)) +} + +/// Upstream `windowDescription`. +fn window_description(minutes: u32) -> Option { + let plural = |count: u32, unit: &str| { + format!("{count} {unit}{} window", if count == 1 { "" } else { "s" }) + }; + match minutes { + 0 => None, + m if m % 1440 == 0 => Some(plural(m / 1440, "day")), + m if m % 60 == 0 => Some(plural(m / 60, "hour")), + m => Some(plural(m, "minute")), + } +} + +/// Upstream `syntheticRollingRegenDetail` / `syntheticRegenDetail` text in +/// the default "% left" form: `"% after next regen · "`. +#[allow( + clippy::cast_possible_truncation, + reason = "regen tick counts are small; `as` saturates on overflow" +)] +fn regen_detail(used_percent: f64, next_regen_percent: f64, ticks_to_full: f64) -> String { + let remaining = (100.0 - used_percent).max(0.0); + let after_next = (remaining + next_regen_percent).min(100.0); + let right = if ticks_to_full <= 0.1 { + "Near full".to_string() + } else if ticks_to_full < 1.5 { + "Full in ~1 regen".to_string() + } else { + format!("Full in ~{} regens", ticks_to_full.ceil() as i64) + }; + format!("{after_next:.0}% after next regen · {right}") +} + +impl Quota { + /// A plain window: reset time, or the "N hours window" text without one. + fn rate_window(&self) -> RateWindow { + RateWindow::with_details( + self.used_percent, + self.window_minutes, + self.resets_at, + self.window_description.clone(), + ) + } + + /// A window whose next regen tick replaces the pace line. + /// + /// The window length is left out on purpose: the card derives a pace line + /// from `window_minutes` + `resets_at`, and measured against the next tick + /// time that line would be meaningless. Upstream shows the regen line + /// instead of pace. + fn regen_window(&self, next_regen_percent: f64, ticks_to_full: f64) -> RateWindow { + RateWindow::with_details( + self.used_percent, + None, + self.resets_at, + Some(regen_detail( + self.used_percent, + next_regen_percent, + ticks_to_full, + )), + ) + .with_description_as_detail() + } + + /// Five-hour lane: regen line when the payload has a tick percent. + fn rolling_window(&self) -> RateWindow { + match (self.resets_at, self.next_regen_percent) { + (Some(_), Some(tick)) if tick > 0.0 => { + self.regen_window(tick, self.used_percent.max(0.0) / tick) + } + _ => self.rate_window(), + } + } + + /// Weekly lane: regen line when the cost carries a next-regen amount. + fn weekly_window(&self, cost: Option<&QuotaCost>) -> RateWindow { + let regen = cost.and_then(|cost| { + let amount = cost.next_regen_amount.filter(|amount| *amount > 0.0)?; + (cost.limit > 0.0 && self.resets_at.is_some()) + .then(|| (amount / cost.limit * 100.0, cost.used.max(0.0) / amount)) + }); + match regen { + Some((percent, ticks)) => self.regen_window(percent, ticks), + None => self.rate_window(), + } + } +} + +impl ParsedQuotas { + fn into_result(self) -> ProviderFetchResult { + let primary = self + .primary + .as_ref() + .map(Quota::rolling_window) + .unwrap_or_else(|| RateWindow::informational("No five-hour quota reported")); + let mut usage = UsageSnapshot::new(primary); + if let Some(weekly) = &self.secondary { + usage = usage.with_secondary(weekly.weekly_window(self.cost.as_ref())); + } + if let Some(search) = &self.tertiary { + usage = usage.with_extra_rate_window( + SEARCH_HOURLY_ID, + SEARCH_HOURLY_TITLE, + search.rate_window(), + ); + } + if let Some(plan) = &self.plan { + usage = usage.with_login_method(plan.clone()); + } + let mut result = ProviderFetchResult::new(usage, "api"); + if let Some(cost) = &self.cost { + let mut snapshot = CostSnapshot::new(cost.used, "USD", "Weekly").with_limit(cost.limit); + if let Some(resets_at) = cost.resets_at { + snapshot = snapshot.with_resets_at(resets_at); + } + result = result.with_cost(snapshot); + } + result + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/providers/synthetic/tests.rs b/rust/src/providers/synthetic/tests.rs new file mode 100644 index 0000000000..fea4bf8905 --- /dev/null +++ b/rust/src/providers/synthetic/tests.rs @@ -0,0 +1,417 @@ +use super::*; +use std::io::{Read, Write}; +use std::net::TcpListener; + +/// The parity pack payload (`scenarios/providers/Synthetic/routes.json`) with +/// its `{{epoch:+N}}` templates resolved against `now`. +fn pack_payload(now: i64) -> Value { + serde_json::json!({ + "plan": "Synthetic Standard", + "rollingFiveHourLimit": { + "limit": 600, + "used": 214, + "remaining": 386, + "windowMinutes": 300, + "nextTickAt": now + 2700, + "tickPercent": 0.12 + }, + "weeklyTokenLimit": { + "percentUsed": 0.58, + "windowDays": 7, + "resetAt": now + 388_800 + }, + "search": { + "hourly": { + "limit": 250, + "used": 61, + "remaining": 189, + "windowMinutes": 60, + "resetAt": now + 1500 + } + } + }) +} + +const NOW: i64 = 1_790_000_000; + +fn at(seconds: i64) -> Option> { + Utc.timestamp_opt(seconds, 0).single() +} + +#[test] +fn parses_the_pack_payload_into_three_lanes_and_a_plan() { + let parsed = parse_quotas(&pack_payload(NOW)).expect("pack payload parses"); + + let five_hour = parsed.primary.as_ref().expect("five-hour lane"); + assert!((five_hour.used_percent - 214.0 / 600.0 * 100.0).abs() < 1e-9); + assert_eq!(five_hour.window_minutes, Some(300)); + assert_eq!(five_hour.resets_at, at(NOW + 2700)); + assert_eq!(five_hour.next_regen_percent, Some(12.0)); + assert_eq!(five_hour.window_description, None); + + let weekly = parsed.secondary.as_ref().expect("weekly lane"); + assert!((weekly.used_percent - 58.0).abs() < 1e-9); + assert_eq!(weekly.window_minutes, Some(10_080)); + assert_eq!(weekly.resets_at, at(NOW + 388_800)); + + let search = parsed.tertiary.as_ref().expect("search lane"); + assert!((search.used_percent - 24.4).abs() < 1e-9); + assert_eq!(search.window_minutes, Some(60)); + assert_eq!(search.resets_at, at(NOW + 1500)); + + assert_eq!(parsed.plan.as_deref(), Some("Synthetic Standard")); + assert_eq!(parsed.cost, None); +} + +#[test] +fn pack_result_matches_the_mac_card() { + let result = parse_quotas(&pack_payload(NOW)) + .expect("pack payload parses") + .into_result(); + let usage = &result.usage; + + // "Five-hour quota 64% left", "Regenerates in 45m", + // "76% after next regen · Full in ~3 regens". + assert_eq!(usage.primary.remaining_percent().round(), 64.0); + assert_eq!(usage.primary.resets_at, at(NOW + 2700)); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("76% after next regen · Full in ~3 regens") + ); + assert!(usage.primary.description_is_detail); + // No window length, so the card shows no pace line on the regen lane. + assert_eq!(usage.primary.window_minutes, None); + + // "Weekly tokens 42% left", "Resets in 4d 12h", pace from the 7-day window. + let weekly = usage.secondary.as_ref().expect("weekly lane"); + assert!((weekly.remaining_percent() - 42.0).abs() < 1e-9); + assert_eq!(weekly.window_minutes, Some(10_080)); + assert_eq!(weekly.resets_at, at(NOW + 388_800)); + assert!(!weekly.description_is_detail); + + // "Search hourly 76% left", "Resets in 25m". + assert_eq!(usage.extra_rate_windows.len(), 1); + let search = &usage.extra_rate_windows[0]; + assert_eq!(search.id, "synthetic-search-hourly"); + assert_eq!(search.title, "Search hourly"); + assert_eq!(search.window.remaining_percent().round(), 76.0); + assert_eq!(search.window.resets_at, at(NOW + 1500)); + assert!(usage.tertiary.is_none()); + + assert_eq!(usage.login_method.as_deref(), Some("Synthetic Standard")); + assert!(result.cost.is_none()); +} + +#[test] +fn accepts_epoch_seconds_millis_numeric_strings_and_iso_dates() { + for reset in [ + serde_json::json!(NOW + 60), + serde_json::json!((NOW + 60) * 1000), + serde_json::json!(format!("{}", NOW + 60)), + serde_json::json!( + at(NOW + 60) + .expect("valid date") + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + ), + ] { + let payload = serde_json::json!({ "used": 1, "limit": 4, "resetAt": reset }); + let quota = parse_quota(&payload).expect("quota parses"); + assert_eq!(quota.resets_at, at(NOW + 60), "{payload}"); + } + // Small numbers are not epochs, and unparseable strings are skipped. + let payload = serde_json::json!({ "used": 1, "limit": 4, "resetAt": 12, "endAt": "soon" }); + assert_eq!(parse_quota(&payload).expect("quota parses").resets_at, None); +} + +#[test] +fn derives_used_percent_like_upstream() { + let cases = [ + (serde_json::json!({ "usedPercent": 0.25 }), 25.0), + (serde_json::json!({ "percent": 40 }), 40.0), + // Exactly 1 is a fraction upstream, so it means 100%. + (serde_json::json!({ "percent_used": 1 }), 100.0), + (serde_json::json!({ "percentRemaining": 0.3 }), 70.0), + (serde_json::json!({ "remaining_percent": "80" }), 20.0), + (serde_json::json!({ "used": 30, "remaining": 70 }), 30.0), + (serde_json::json!({ "quota": 50, "left": 10 }), 80.0), + (serde_json::json!({ "limit": 10, "used": 15 }), 100.0), + (serde_json::json!({ "usedPercent": 150 }), 100.0), + ]; + for (payload, expected) in cases { + let quota = parse_quota(&payload).expect("quota parses"); + assert!( + (quota.used_percent - expected).abs() < 1e-9, + "{payload}: {}", + quota.used_percent + ); + } + // A limit of zero gives no percentage, so the object is not a lane. + assert_eq!( + parse_quota(&serde_json::json!({ "limit": 0, "used": 0 })), + None + ); +} + +#[test] +fn reads_window_length_from_numbers_and_text() { + let cases = [ + (serde_json::json!({ "windowHours": 5 }), 300), + (serde_json::json!({ "period_days": 1 }), 1440), + (serde_json::json!({ "windowSeconds": 3600 }), 60), + (serde_json::json!({ "window": "5h" }), 300), + (serde_json::json!({ "period": "7 Days" }), 10_080), + (serde_json::json!({ "windowLabel": "1.5 hrs" }), 90), + (serde_json::json!({ "period_label": ".5d" }), 720), + (serde_json::json!({ "window": "30mins" }), 30), + ]; + for (payload, expected) in cases { + assert_eq!(window_minutes(&payload), Some(expected), "{payload}"); + } + for text in ["weekly", "5", "h", "5w", "1.h", "5.5.5h"] { + assert_eq!(window_text_minutes(text), None, "{text}"); + } +} + +#[test] +fn describes_windows_without_a_reset_time() { + assert_eq!(window_description(300).as_deref(), Some("5 hours window")); + assert_eq!(window_description(60).as_deref(), Some("1 hour window")); + assert_eq!(window_description(1440).as_deref(), Some("1 day window")); + assert_eq!(window_description(10_080).as_deref(), Some("7 days window")); + assert_eq!(window_description(45).as_deref(), Some("45 minutes window")); + assert_eq!(window_description(1).as_deref(), Some("1 minute window")); + assert_eq!(window_description(0), None); + + let quota = parse_quota(&serde_json::json!({ "used": 1, "limit": 2, "window": "5h" })) + .expect("quota parses"); + assert_eq!(quota.window_description.as_deref(), Some("5 hours window")); + assert_eq!( + quota.rate_window().reset_description.as_deref(), + Some("5 hours window") + ); + assert!(!quota.rate_window().description_is_detail); +} + +#[test] +fn regen_detail_text_matches_upstream_thresholds() { + assert_eq!( + regen_detail(214.0 / 6.0, 12.0, 214.0 / 6.0 / 12.0), + "76% after next regen · Full in ~3 regens" + ); + assert_eq!( + regen_detail(1.0, 12.0, 1.0 / 12.0), + "100% after next regen · Near full" + ); + assert_eq!( + regen_detail(14.0, 12.0, 14.0 / 12.0), + "98% after next regen · Full in ~1 regen" + ); + assert_eq!( + regen_detail(18.0, 12.0, 1.5), + "94% after next regen · Full in ~2 regens" + ); +} + +#[test] +fn weekly_cost_with_next_regen_amount_drives_the_weekly_regen_line() { + let payload = serde_json::json!({ + "data": { + "weeklyTokenLimit": { + "percentUsed": 40, + "resetAt": NOW + 86_400, + "windowDays": 7, + "maxCredits": "$1,000.00", + "remainingCredits": "600", + "nextRegenCredits": 50 + } + }, + "planName": "Pro" + }); + let parsed = parse_quotas(&payload).expect("payload parses"); + assert!(parsed.primary.is_none()); + let cost = parsed.cost.clone().expect("cost from maxCredits"); + assert_eq!(cost.limit, 1000.0); + assert_eq!(cost.used, 400.0); + assert_eq!(cost.next_regen_amount, Some(50.0)); + + let result = parsed.into_result(); + assert!(result.usage.primary.is_informational); + let weekly = result.usage.secondary.as_ref().expect("weekly lane"); + // 60% left + 5% per regen; 400 / 50 = 8 regens to full. + assert_eq!( + weekly.reset_description.as_deref(), + Some("65% after next regen · Full in ~8 regens") + ); + assert!(weekly.description_is_detail); + assert_eq!(weekly.window_minutes, None); + assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); + let cost = result.cost.expect("weekly cost"); + assert_eq!(cost.period, "Weekly"); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(cost.used, 400.0); + assert_eq!(cost.limit, Some(1000.0)); + assert_eq!(cost.resets_at, at(NOW + 86_400)); +} + +#[test] +fn cost_used_falls_back_to_explicit_credits_then_percent() { + let explicit = parse_quota(&serde_json::json!({ + "percentUsed": 10, "maxCredits": 20, "usedCredits": "$3" + })) + .expect("quota parses"); + assert_eq!(explicit.cost.expect("cost").used, 3.0); + let from_percent = parse_quota(&serde_json::json!({ "percentUsed": 25, "maxCredits": 20 })) + .expect("quota parses"); + assert_eq!(from_percent.cost.expect("cost").used, 5.0); +} + +#[test] +fn fallback_collections_collect_quota_objects_in_sorted_key_order() { + let payload = serde_json::json!({ + "tier": "Starter", + "limits": { + "zeta": { "used": 9, "limit": 10, "window": "1h" }, + "alpha": { "used": 1, "limit": 10, "window": "5h" }, + "mid": [{ "percentRemaining": 50, "window": "7d" }] + } + }); + let parsed = parse_quotas(&payload).expect("payload parses"); + assert_eq!(parsed.primary.as_ref().map(|q| q.used_percent), Some(10.0)); + assert_eq!( + parsed.secondary.as_ref().map(|q| q.used_percent), + Some(50.0) + ); + assert_eq!(parsed.tertiary.as_ref().map(|q| q.used_percent), Some(90.0)); + assert_eq!(parsed.plan.as_deref(), Some("Starter")); + + let array_root = serde_json::json!([{ "used": 3, "limit": 4 }]); + let parsed = parse_quotas(&array_root).expect("array root parses"); + assert_eq!(parsed.primary.as_ref().map(|q| q.used_percent), Some(75.0)); + assert!(parsed.secondary.is_none()); +} + +#[test] +fn reports_missing_quota_data_and_non_object_roots() { + for payload in [ + serde_json::json!({ "plan": "Free" }), + serde_json::json!({ "quotas": [] }), + serde_json::json!({ "rollingFiveHourLimit": { "name": "five" } }), + ] { + let error = parse_quotas(&payload).expect_err("no quota data"); + assert_eq!( + error.to_string(), + "Parse error: Failed to parse Synthetic response: Missing quota data." + ); + } + for payload in [serde_json::json!("text"), serde_json::json!(4), Value::Null] { + assert!(matches!( + parse_quotas(&payload), + Err(ProviderError::Parse(_)) + )); + } +} + +#[test] +fn maps_statuses_to_upstream_messages() { + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + assert_eq!( + status_error(status).to_string(), + "Invalid Synthetic API credentials." + ); + } + assert_eq!( + status_error(StatusCode::TOO_MANY_REQUESTS).to_string(), + "Synthetic API error: HTTP 429." + ); + assert_eq!( + status_error(StatusCode::BAD_GATEWAY).to_string(), + "Synthetic API error: HTTP 502." + ); +} + +/// Serve one canned HTTP response; the handle yields the lowercased request. +fn provider_serving( + status_line: &'static str, + body: String, +) -> (SyntheticProvider, std::thread::JoinHandle) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind local test server"); + let address = listener.local_addr().expect("local server address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).expect("read request"); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + write!( + stream, + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write response"); + request + }); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + let provider = SyntheticProvider::with_client(format!("http://{address}/v2/quotas"), client); + (provider, server) +} + +fn keyed_context() -> FetchContext { + FetchContext { + api_key: Some("test-synthetic-key".into()), + ..FetchContext::default() + } +} + +#[tokio::test] +async fn sends_a_bearer_request_and_parses_the_pack_payload() { + let now = Utc::now().timestamp(); + let (provider, server) = provider_serving("200 OK", pack_payload(now).to_string()); + + let result = provider + .fetch_usage(&keyed_context()) + .await + .expect("quota fetch"); + let request = server.join().expect("test server thread"); + assert!(request.starts_with("get /v2/quotas ")); + assert!(request.contains("authorization: bearer test-synthetic-key")); + assert_eq!(result.source_label, "api"); + assert_eq!(result.usage.primary.remaining_percent().round(), 64.0); + assert_eq!( + result.usage.login_method.as_deref(), + Some("Synthetic Standard") + ); +} + +#[tokio::test] +async fn errors_do_not_echo_the_response_body() { + for status_line in ["200 OK", "401 Unauthorized", "500 Internal Server Error"] { + let (provider, server) = provider_serving(status_line, "private-response".into()); + let error = provider + .fetch_usage(&keyed_context()) + .await + .expect_err(status_line); + server.join().expect("test server thread"); + assert!( + !error.to_string().contains("private-response"), + "{status_line}" + ); + } +} + +#[tokio::test] +async fn web_and_cli_sources_are_unsupported() { + let provider = SyntheticProvider::new(); + for source_mode in [SourceMode::Web, SourceMode::Cli] { + let ctx = FetchContext { + source_mode, + ..keyed_context() + }; + assert!(matches!( + provider.fetch_usage(&ctx).await, + Err(ProviderError::UnsupportedSource(_)) + )); + } +} diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 3ea5048ca4..65c5c18c7f 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -751,6 +751,17 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://dash.aixy-gateway.com"), }, + ProviderConfigInfo { + id: ProviderId::Synthetic, + name: "Synthetic", + requires_api_key: true, + api_key_env_var: Some(crate::providers::synthetic::API_KEY_ENV), + api_key_help: Some( + "Save a Synthetic API key to read the quota endpoint, or set SYNTHETIC_API_KEY.", + ), + config_file_path: None, + dashboard_url: None, + }, ] } From b358acd6d29015681437039aa0fa7d567c20ec58 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:54:55 +0700 Subject: [PATCH 02/14] Add the ClawRouter provider --- README.md | 1 + .../src/commands/provider_settings.rs | 7 + .../icons/ProviderIcon-clawrouter.svg | 7 + .../src/components/providers/providerIcons.ts | 3 + apps/desktop-tauri/src/i18n/keys.ts | 2 + .../sections/WayfinderGatewaySection.test.tsx | 2 + .../sections/WayfinderGatewaySection.tsx | 7 +- .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 20 + rust/src/core/provider.rs | 11 +- rust/src/core/provider_factory.rs | 2 + rust/src/core/token_accounts.rs | 3 +- rust/src/locale.rs | 2 + rust/src/locale/en-US.ftl | 2 + rust/src/locale/es-MX.ftl | 2 + rust/src/locale/ja-JP.ftl | 2 + rust/src/locale/ko-KR.ftl | 2 + rust/src/locale/pt-BR.ftl | 2 + rust/src/locale/ru-RU.ftl | 2 + rust/src/locale/tests.rs | 10 +- rust/src/locale/tr-TR.ftl | 2 + rust/src/locale/uk-UA.ftl | 2 + rust/src/locale/zh-CN.ftl | 2 + rust/src/locale/zh-TW.ftl | 2 + rust/src/providers/clawrouter/mod.rs | 220 ++++++++ rust/src/providers/clawrouter/model.rs | 311 +++++++++++ rust/src/providers/clawrouter/tests.rs | 525 ++++++++++++++++++ rust/src/providers/mod.rs | 2 + rust/src/settings/api_keys.rs | 11 + 29 files changed, 1162 insertions(+), 5 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-clawrouter.svg create mode 100644 rust/src/providers/clawrouter/mod.rs create mode 100644 rust/src/providers/clawrouter/model.rs create mode 100644 rust/src/providers/clawrouter/tests.rs diff --git a/README.md b/README.md index e30de60432..fe3d94bfda 100755 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Raycast | Cookies (Chrome auto or manual) | Monthly AI credits, plan, renewal | | Replicate | Cookies / token accounts | Monthly spend, credit balance | | Aixy | API Key / token accounts | Applicable budget balances, 7-day key usage | +| ClawRouter | API Key | Monthly budget, requests, tokens, routed-provider spend | | ElevenLabs | API Key | Subscription Credits, Voice Slots | | Deepgram | API Key | Project Usage | | Groq | API Key | Enterprise Metrics | 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 9fbdbc481a..3b2a45acf2 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -453,6 +453,7 @@ mod tests { (ProviderId::Wayfinder, "http://localhost:8787"), (ProviderId::Bifrost, "https://bifrost.example.com"), (ProviderId::Aixy, "https://aixy.example.com/prefix"), + (ProviderId::ClawRouter, "https://router.example.com/v1"), ] { settings.set_gateway_url(id, url); let ctx = super::super::providers::build_fetch_context( @@ -485,6 +486,7 @@ mod tests { assert_eq!(gateway_provider("wayfinder"), Some(ProviderId::Wayfinder)); assert_eq!(gateway_provider("bifrost"), Some(ProviderId::Bifrost)); assert_eq!(gateway_provider("aixy"), Some(ProviderId::Aixy)); + assert_eq!(gateway_provider("clawrouter"), Some(ProviderId::ClawRouter)); assert_eq!(gateway_provider("codex"), None); } @@ -548,6 +550,7 @@ fn gateway_provider(provider_id: &str) -> Option { "wayfinder" => Some(codexbar::core::ProviderId::Wayfinder), "bifrost" => Some(codexbar::core::ProviderId::Bifrost), "aixy" => Some(codexbar::core::ProviderId::Aixy), + "clawrouter" => Some(codexbar::core::ProviderId::ClawRouter), _ => None, } } @@ -577,6 +580,10 @@ pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Res codexbar::providers::aixy::validate_gateway_url(gateway_url) .map_err(|error| error.to_string())?; } + codexbar::core::ProviderId::ClawRouter => { + codexbar::providers::clawrouter::validate_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } _ => unreachable!("gateway_provider only returns gateway providers"), } diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-clawrouter.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-clawrouter.svg new file mode 100644 index 0000000000..f8718f87e9 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-clawrouter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 082672f4d2..a79e1172b0 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -63,6 +63,7 @@ import replicate from "./icons/ProviderIcon-replicate.svg?raw"; import sakana from "./icons/ProviderIcon-sakana.svg?raw"; import stepfun from "./icons/ProviderIcon-stepfun.svg?raw"; import sub2api from "./icons/ProviderIcon-sub2api.svg?raw"; +import clawrouter from "./icons/ProviderIcon-clawrouter.svg?raw"; import t3chat from "./icons/ProviderIcon-t3chat.svg?raw"; import venice from "./icons/ProviderIcon-venice.svg?raw"; import vercel from "./icons/ProviderIcon-vercel.svg?raw"; @@ -157,6 +158,7 @@ const RAW: Record = { sakana: tint(sakana), stepfun: tint(stepfun), sub2api: tint(sub2api), + clawrouter: tint(clawrouter), t3chat: tint(t3chat), venice: tint(venice), vercel: tint(vercel), @@ -250,6 +252,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { sub2api: { id: "sub2api", brandColor: "#14b8a6", fallbackLetter: "S", svgPath: RAW.sub2api }, venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, + clawrouter: { id: "clawrouter", brandColor: "#596EF6", fallbackLetter: "C", svgPath: RAW.clawrouter }, openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" }, chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" }, litellm: { id: "litellm", brandColor: "#0ea5e9", fallbackLetter: "L" }, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index bca1f5588c..f6aa6cb5f5 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -122,6 +122,8 @@ export const ALL_LOCALE_KEYS = [ "AixyGatewayTitle", "AixyGatewayLabel", "AixyGatewayHelp", + "ClawRouterGatewayTitle", + "ClawRouterGatewayHelp", "WayfinderModels", "WayfinderRequests", "WayfinderTokens", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx index 1b95267446..7c70e3cd5f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx @@ -27,6 +27,7 @@ describe("WayfinderGatewaySection", () => { ["wayfinder", "WayfinderGatewayTitle", "WayfinderGatewayLabel", "WayfinderGatewayHelp"], ["bifrost", "BifrostGatewayTitle", "WayfinderGatewayLabel", "BifrostGatewayHelp"], ["aixy", "AixyGatewayTitle", "AixyGatewayLabel", "AixyGatewayHelp"], + ["clawrouter", "ClawRouterGatewayTitle", "AixyGatewayLabel", "ClawRouterGatewayHelp"], ] as const)("uses localized %s copy", (providerId, title, label, help) => { renderSection(providerId); @@ -45,6 +46,7 @@ describe("WayfinderGatewaySection", () => { it("recognizes only gateway providers", () => { expect(isGatewayProviderId("aixy")).toBe(true); + expect(isGatewayProviderId("clawrouter")).toBe(true); expect(isGatewayProviderId("bifrost")).toBe(true); expect(isGatewayProviderId("wayfinder")).toBe(true); expect(isGatewayProviderId("codex")).toBe(false); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx index 9c454f0ae1..63276f3d07 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx @@ -1,6 +1,6 @@ import type { LocaleKey } from "../../../../i18n/keys"; -export type GatewayProviderId = "wayfinder" | "bifrost" | "aixy"; +export type GatewayProviderId = "wayfinder" | "bifrost" | "aixy" | "clawrouter"; interface GatewayCopy { title: LocaleKey; @@ -24,6 +24,11 @@ const GATEWAY_COPY: Record = { label: "AixyGatewayLabel", help: "AixyGatewayHelp", }, + clawrouter: { + title: "ClawRouterGatewayTitle", + label: "AixyGatewayLabel", + help: "ClawRouterGatewayHelp", + }, }; export function isGatewayProviderId(id: string): id is GatewayProviderId { diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index c2d33f3719..8c24f77534 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -86,4 +86,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["devpass", "DevPass"], ["xkiro", "xKiro"], ["raycast", "Raycast"], + ["clawrouter", "ClawRouter"], ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 0cf80274a7..f33c43af9a 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -185,6 +185,26 @@ are never followed and response bodies are never echoed in errors. budget, inconsistent coverage, malformed amounts) is a parse error, not a partial balance. +### ClawRouter + +ClawRouter reports the monthly budget, spend and routed-provider usage of one +policy key (`GET {base}/v1/usage`, `Authorization: Bearer `). Configure +the key in Settings → Providers → ClawRouter (or `CLAWROUTER_API_KEY`). Leave +the Base URL empty for `https://clawrouter.openclaw.ai`; set it (or +`CLAWROUTER_BASE_URL`) for another deployment. The service root and its `/v1` +URL both work, a bare host is treated as HTTPS, and plain HTTP, embedded +credentials, a query or a fragment are rejected before the key is read. +Redirects are never followed and response bodies are never echoed in errors. + +- The primary lane is the monthly budget (spent / limit), resetting on the + first day of the next month (UTC). The cost line shows this month's spend + against the limit. +- Details show requests, tokens, actual cost, the budget ledger and the + monthly budget, then up to 20 routed providers ordered by spend and + requests. +- A policy without a monthly limit shows "Unmetered" and this month's actual + cost. + ## Listing what is enabled ```powershell diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 7a4aba44da..4c3073982f 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -103,6 +103,7 @@ pub enum ProviderId { XKiro, Raycast, Vercel, + ClawRouter, } impl ProviderId { @@ -198,6 +199,7 @@ impl ProviderId { ProviderId::XKiro, ProviderId::Raycast, ProviderId::Vercel, + ProviderId::ClawRouter, ] } @@ -254,6 +256,7 @@ impl ProviderId { ProviderId::XKiro => "xkiro", ProviderId::Raycast => "raycast", ProviderId::Vercel => "vercel", + ProviderId::ClawRouter => "clawrouter", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -350,6 +353,7 @@ impl ProviderId { ProviderId::XKiro => "xKiro", ProviderId::Raycast => "Raycast", ProviderId::Vercel => "Vercel AI Gateway", + ProviderId::ClawRouter => "ClawRouter", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -462,6 +466,7 @@ impl ProviderId { ProviderId::DevPass => None, ProviderId::XKiro => None, ProviderId::Vercel => None, + ProviderId::ClawRouter => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -614,6 +619,7 @@ impl ProviderId { "replicate" | "r8" => Some(ProviderId::Replicate), "atlascloud" | "atlas-cloud" | "atlas cloud" => Some(ProviderId::AtlasCloud), "raycast" | "raycast-ai" => Some(ProviderId::Raycast), + "clawrouter" | "claw-router" => Some(ProviderId::ClawRouter), _ => None, } } @@ -1142,6 +1148,7 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("vercel ai gateway", ProviderId::Vercel); map.insert("ai-gateway", ProviderId::Vercel); map.insert("ai gateway", ProviderId::Vercel); + map.insert("claw-router", ProviderId::ClawRouter); map.insert("metaspark", ProviderId::Meta); map.insert("meta-spark", ProviderId::Meta); map.insert("muse-spark", ProviderId::Meta); @@ -1305,6 +1312,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Raycast => "#FF6363", // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. ProviderId::Vercel => "#737373", + ProviderId::ClawRouter => "#596EF6", } } @@ -1319,7 +1327,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 89); + assert_eq!(all.len(), 90); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1389,6 +1397,7 @@ mod tests { assert!(all.contains(&ProviderId::XKiro)); assert!(all.contains(&ProviderId::Raycast)); assert!(all.contains(&ProviderId::Vercel)); + assert!(all.contains(&ProviderId::ClawRouter)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 1be54fc0c6..bf5879004a 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -7,6 +7,7 @@ use super::{Provider, ProviderId}; use crate::providers::AtlasCloudProvider; +use crate::providers::ClawRouterProvider; use crate::providers::{ AbacusProvider, AiAndProvider, AixyProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, AugmentProvider, AzureOpenAIProvider, BedrockProvider, @@ -41,6 +42,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Copilot => Box::new(CopilotProvider::new()), ProviderId::Antigravity => Box::new(AntigravityProvider::new()), ProviderId::AtlasCloud => Box::new(AtlasCloudProvider::new()), + ProviderId::ClawRouter => Box::new(ClawRouterProvider::new()), ProviderId::Factory => Box::new(FactoryProvider::new()), ProviderId::Zai => Box::new(ZaiProvider::new()), ProviderId::Kiro => Box::new(KiroProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index bae251693b..280c6b70b2 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -431,7 +431,8 @@ impl TokenAccountSupport { | ProviderId::DevPass | ProviderId::XKiro | ProviderId::Raycast - | ProviderId::Vercel => None, + | ProviderId::Vercel + | ProviderId::ClawRouter => None, } } diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 0f3904ef59..a0218ad104 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -318,6 +318,8 @@ locale_keys! { AixyGatewayTitle, AixyGatewayLabel, AixyGatewayHelp, + ClawRouterGatewayTitle, + ClawRouterGatewayHelp, WayfinderModels, WayfinderRequests, WayfinderTokens, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 06e082deda..3798cf68cb 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -117,6 +117,8 @@ BifrostGatewayHelp = Base URL of your Bifrost gateway. AixyGatewayTitle = Aixy gateway AixyGatewayLabel = Base URL AixyGatewayHelp = Leave empty for the hosted Aixy gateway, or enter the Base URL of a self-hosted one. HTTP is allowed only for localhost, private-network and .local hosts. +ClawRouterGatewayTitle = ClawRouter deployment +ClawRouterGatewayHelp = Leave empty for the hosted ClawRouter service, or enter the HTTPS URL of another deployment. The service root and its /v1 URL both work. WayfinderModels = Models WayfinderRequests = Requests WayfinderTokens = Tokens diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index efbd5e4c4f..5d5ccbbb69 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -90,6 +90,8 @@ BifrostGatewayHelp = URL base de tu gateway de Bifrost. AixyGatewayTitle = Gateway de Aixy AixyGatewayLabel = URL base AixyGatewayHelp = Déjalo vacío para usar el gateway alojado de Aixy o introduce la URL base de uno propio. HTTP solo se permite para localhost, redes privadas y hosts .local. +ClawRouterGatewayTitle = Despliegue de ClawRouter +ClawRouterGatewayHelp = Déjalo vacío para usar el servicio alojado de ClawRouter o introduce la URL HTTPS de otro despliegue. Sirven tanto la raíz del servicio como su URL /v1. WayfinderModels = Modelos WayfinderRequests = Solicitudes WayfinderTokens = Tokens diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index b09af73828..804d622d20 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -90,6 +90,8 @@ BifrostGatewayHelp = Bifrost ゲートウェイのベース URL。 AixyGatewayTitle = Aixy ゲートウェイ AixyGatewayLabel = ベース URL AixyGatewayHelp = ホスト型 Aixy ゲートウェイを使う場合は空欄にするか、自分でホストするゲートウェイのベース URL を入力してください。HTTP は localhost、プライベートネットワーク、.local ホストでのみ使用できます。 +ClawRouterGatewayTitle = ClawRouter デプロイ +ClawRouterGatewayHelp = ホスト型 ClawRouter サービスを使う場合は空欄にするか、別のデプロイの HTTPS URL を入力してください。サービスのルートと /v1 URL のどちらでも使用できます。 WayfinderModels = モデル WayfinderRequests = リクエスト WayfinderTokens = トークン diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index b71361f19d..083c1e2669 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -90,6 +90,8 @@ BifrostGatewayHelp = Bifrost 게이트웨이의 기본 URL입니다. AixyGatewayTitle = Aixy 게이트웨이 AixyGatewayLabel = 기본 URL AixyGatewayHelp = 호스팅된 Aixy 게이트웨이를 사용하려면 비워 두거나 자체 호스팅 게이트웨이의 기본 URL을 입력하세요. HTTP는 localhost, 사설 네트워크 및 .local 호스트에서만 허용됩니다. +ClawRouterGatewayTitle = ClawRouter 배포 +ClawRouterGatewayHelp = 호스팅된 ClawRouter 서비스를 사용하려면 비워 두거나 다른 배포의 HTTPS URL을 입력하세요. 서비스 루트와 /v1 URL 모두 사용할 수 있습니다. WayfinderModels = 모델 WayfinderRequests = 요청 WayfinderTokens = 토큰 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 2835706f4a..bac6deafe1 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -113,6 +113,8 @@ BifrostGatewayHelp = URL base do seu gateway Bifrost. AixyGatewayTitle = Gateway Aixy AixyGatewayLabel = URL base AixyGatewayHelp = Deixe em branco para usar o gateway hospedado da Aixy ou informe a URL base de um gateway próprio. HTTP só é permitido para localhost, redes privadas e hosts .local. +ClawRouterGatewayTitle = Implantação do ClawRouter +ClawRouterGatewayHelp = Deixe em branco para usar o serviço hospedado do ClawRouter ou informe a URL HTTPS de outra implantação. Funcionam tanto a raiz do serviço quanto a URL /v1. WayfinderModels = Modelos WayfinderRequests = Solicitações WayfinderTokens = Tokens diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 7446881d6f..8c51271bce 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -117,6 +117,8 @@ BifrostGatewayHelp = Базовый URL вашего шлюза Bifrost. AixyGatewayTitle = Шлюз Aixy AixyGatewayLabel = Базовый URL AixyGatewayHelp = Оставьте пустым для размещенного шлюза Aixy или укажите базовый URL собственного шлюза. HTTP разрешен только для localhost, частных сетей и хостов .local. +ClawRouterGatewayTitle = Развертывание ClawRouter +ClawRouterGatewayHelp = Оставьте пустым для размещенного сервиса ClawRouter или укажите HTTPS URL другого развертывания. Подходит как корень сервиса, так и его URL /v1. WayfinderModels = Модели WayfinderRequests = Запросы WayfinderTokens = Токены diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 9e9443e30e..97aee9f4bc 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -441,7 +441,13 @@ fn test_english_is_complete_and_other_languages_can_fallback() { .map(|(locale, resource)| (locale, resource_key_names(resource))) .collect(); let locale_key_names: HashSet<&str> = LocaleKey::ALL.iter().map(|(_, name)| *name).collect(); - let aixy_gateway_keys = ["AixyGatewayTitle", "AixyGatewayLabel", "AixyGatewayHelp"]; + let aixy_gateway_keys = [ + "AixyGatewayTitle", + "AixyGatewayLabel", + "AixyGatewayHelp", + "ClawRouterGatewayTitle", + "ClawRouterGatewayHelp", + ]; let credential_expiry_keys = [ "CredentialExpiryNotifications", "CredentialExpiryNotificationsHelper", @@ -453,7 +459,7 @@ fn test_english_is_complete_and_other_languages_can_fallback() { for name in aixy_gateway_keys { assert!( keys.contains(name), - "missing Aixy gateway Fluent key {name} in {locale}" + "missing gateway Fluent key {name} in {locale}" ); } for name in keys { diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index bd2ed50761..2a654e8d22 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -117,6 +117,8 @@ BifrostGatewayHelp = Bifrost ağ geçidinizin temel URL'si. AixyGatewayTitle = Aixy Ağ Geçidi AixyGatewayLabel = Temel URL AixyGatewayHelp = Barındırılan Aixy ağ geçidini kullanmak için boş bırakın veya kendi ağ geçidinizin temel URL'sini girin. HTTP yalnızca localhost, özel ağlar ve .local ana bilgisayarları için kullanılabilir. +ClawRouterGatewayTitle = ClawRouter Dağıtımı +ClawRouterGatewayHelp = Barındırılan ClawRouter hizmetini kullanmak için boş bırakın veya başka bir dağıtımın HTTPS URL'sini girin. Hizmet kökü ve /v1 URL'si birlikte çalışır. WayfinderModels = Modeller WayfinderRequests = İstekler WayfinderTokens = Tokenlar diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index cac3dabe7d..94be8221a1 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -117,6 +117,8 @@ BifrostGatewayHelp = Базова URL-адреса вашого шлюзу Bifro AixyGatewayTitle = Шлюз Aixy AixyGatewayLabel = Базова URL-адреса AixyGatewayHelp = Залиште порожнім для розміщеного шлюзу Aixy або вкажіть базову URL-адресу власного. HTTP дозволено лише для localhost, приватних мереж і хостів .local. +ClawRouterGatewayTitle = Розгортання ClawRouter +ClawRouterGatewayHelp = Залиште порожнім для розміщеного сервісу ClawRouter або вкажіть HTTPS URL-адресу іншого розгортання. Підходить як корінь сервісу, так і його URL-адреса /v1. WayfinderModels = Моделі WayfinderRequests = Запити WayfinderTokens = Токени diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index b60e376fbb..bfd39a641e 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -90,6 +90,8 @@ BifrostGatewayHelp = Bifrost 网关的基础 URL。 AixyGatewayTitle = Aixy 网关 AixyGatewayLabel = 基础 URL AixyGatewayHelp = 留空以使用托管的 Aixy 网关,或输入自托管网关的基础 URL。HTTP 仅允许用于 localhost、专用网络和 .local 主机。 +ClawRouterGatewayTitle = ClawRouter 部署 +ClawRouterGatewayHelp = 留空以使用托管的 ClawRouter 服务,或输入其他部署的 HTTPS URL。服务根地址和 /v1 URL 均可使用。 WayfinderModels = 模型 WayfinderRequests = 请求 WayfinderTokens = 令牌 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 8ffe255101..f172156595 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -90,6 +90,8 @@ BifrostGatewayHelp = Bifrost 閘道的基礎 URL。 AixyGatewayTitle = Aixy 閘道 AixyGatewayLabel = 基礎 URL AixyGatewayHelp = 留空以使用託管的 Aixy 閘道,或輸入自架閘道的基礎 URL。HTTP 僅允許用於 localhost、私人網路和 .local 主機。 +ClawRouterGatewayTitle = ClawRouter 部署 +ClawRouterGatewayHelp = 留空以使用託管的 ClawRouter 服務,或輸入其他部署的 HTTPS URL。服務根位址和 /v1 URL 皆可使用。 WayfinderModels = 模型 WayfinderRequests = 請求 WayfinderTokens = 權杖 diff --git a/rust/src/providers/clawrouter/mod.rs b/rust/src/providers/clawrouter/mod.rs new file mode 100644 index 0000000000..b3d5b2132d --- /dev/null +++ b/rust/src/providers/clawrouter/mod.rs @@ -0,0 +1,220 @@ +//! ClawRouter: monthly budget, spend and routed-provider usage for one policy key. +//! +//! Ported from upstream CodexBar v0.73.0 (`Resources/Plugins/clawrouter.js`, +//! `ClawRouterSettingsReader.swift`). The key is sent only as a Bearer token +//! to `GET {base}/v1/usage`; the configured base URL is validated before the +//! credential is resolved, redirects are never followed, and response bodies +//! are never echoed in errors. + +mod model; +#[cfg(test)] +mod tests; + +use std::time::Duration; + +use async_trait::async_trait; +use reqwest::{Client, StatusCode, Url}; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + SourceMode, +}; + +const CREDENTIAL_TARGET: &str = "codexbar-clawrouter"; +pub(crate) const API_KEY_ENV: &str = "CLAWROUTER_API_KEY"; +const BASE_URL_ENV: &str = "CLAWROUTER_BASE_URL"; +const DEFAULT_BASE_URL: &str = "https://clawrouter.openclaw.ai"; +const DASHBOARD_URL: &str = "https://clawrouter.openclaw.ai/dashboard/access"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +pub struct ClawRouterProvider { + metadata: ProviderMetadata, + client: Option, + default_base_url: String, +} + +impl ClawRouterProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::ClawRouter, + display_name: "ClawRouter", + session_label: "Monthly budget", + weekly_label: "Requests", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some(DASHBOARD_URL), + status_page_url: None, + tertiary_label_key: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(REQUEST_TIMEOUT) + // The Bearer credential must never follow a redirect to + // another origin. + .redirect(reqwest::redirect::Policy::none()) + .build() + .ok(), + default_base_url: DEFAULT_BASE_URL.to_owned(), + } + } + + #[cfg(test)] + fn with_client(default_base_url: &str, client: Client) -> Self { + let mut provider = Self::new(); + provider.client = Some(client); + provider.default_base_url = default_base_url.to_owned(); + provider + } + + async fn fetch_api(&self, ctx: &FetchContext) -> Result { + // Validate the endpoint before touching the keyring or environment so + // a malformed or plain-HTTP override never receives the key. + let url = match configured_base_url(ctx) { + Some(raw) => usage_url(&raw)?, + None => default_usage_url(&self.default_base_url)?, + }; + let api_key = crate::providers::resolve_api_key( + ctx.api_key.as_deref(), + CREDENTIAL_TARGET, + &[API_KEY_ENV], + )?; + let client = self.client.as_ref().ok_or_else(|| { + ProviderError::Other("Could not create a secure ClawRouter HTTP client.".into()) + })?; + + let timeout = Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT); + let response = client + .get(url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .timeout(timeout) + .send() + .await?; + check_status(response.status())?; + + let bytes = crate::providers::read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + crate::providers::BoundedBodyError::TooLarge => ProviderError::Parse( + "Could not parse ClawRouter usage: response is too large".into(), + ), + crate::providers::BoundedBodyError::Read(error) => ProviderError::Network(error), + })?; + let usage = model::parse_usage(&bytes)?; + Ok(model::build_result(&usage)) + } +} + +impl Default for ClawRouterProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for ClawRouterProvider { + fn id(&self) -> ProviderId { + ProviderId::ClawRouter + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_api(ctx).await, + source => Err(ProviderError::UnsupportedSource(source)), + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +/// The saved Base URL, then `CLAWROUTER_BASE_URL`; `None` selects the hosted service. +fn configured_base_url(ctx: &FetchContext) -> Option { + ctx.gateway_url + .clone() + .filter(|url| !url.trim().is_empty()) + .or_else(|| { + std::env::var(BASE_URL_ENV) + .ok() + .filter(|url| !url.trim().is_empty()) + }) +} + +/// Classify a non-success status without reading or echoing the body. +fn check_status(status: StatusCode) -> Result<(), ProviderError> { + let code = status.as_u16(); + match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ProviderError::Other( + "ClawRouter rejected the API key. Check the key and its policy status.".into(), + )), + _ if status.is_success() => Ok(()), + _ => Err(ProviderError::Other(format!( + "ClawRouter API returned HTTP {code}." + ))), + } +} + +/// Build `{root}/v1/usage` from a configured Base URL override. +/// +/// The value may point at the service root or `/v1`; trailing slashes are +/// dropped and a scheme-less value is treated as HTTPS. Only HTTPS URLs +/// without embedded credentials, queries or fragments are accepted. +fn usage_url(raw: &str) -> Result { + let invalid = || { + ProviderError::Other( + "ClawRouter Base URL is invalid. Use an HTTPS URL without embedded credentials.".into(), + ) + }; + let raw = raw.trim(); + // Decide on the scheme before trimming slashes, so "https://" stays a + // URL with no host instead of becoming the host name "https". + let has_scheme = raw.contains("://"); + let raw = raw.trim_end_matches('/'); + if raw.is_empty() || raw.contains(['?', '#']) || raw.chars().any(char::is_whitespace) { + return Err(invalid()); + } + let candidate = if has_scheme { + raw.to_owned() + } else { + format!("https://{raw}") + }; + let mut url = Url::parse(&candidate).map_err(|_| invalid())?; + if url.scheme() != "https" + || url.host_str().is_none_or(str::is_empty) + || !url.username().is_empty() + || url.password().is_some() + { + return Err(invalid()); + } + let path = url.path().trim_end_matches('/'); + let path = if path.ends_with("/v1") { + format!("{path}/usage") + } else { + format!("{path}/v1/usage") + }; + url.set_path(&path); + Ok(url) +} + +fn default_usage_url(base: &str) -> Result { + let base = base.trim_end_matches('/'); + Url::parse(&format!("{base}/v1/usage")) + .map_err(|_| ProviderError::Other("ClawRouter default URL is invalid.".into())) +} + +/// Validate a configured Base URL before saving it to settings. An empty value +/// is valid and selects the hosted service. +pub fn validate_gateway_url(raw: &str) -> Result<(), ProviderError> { + if raw.trim().is_empty() { + return Ok(()); + } + usage_url(raw).map(|_| ()) +} diff --git a/rust/src/providers/clawrouter/model.rs b/rust/src/providers/clawrouter/model.rs new file mode 100644 index 0000000000..db292a7fbe --- /dev/null +++ b/rust/src/providers/clawrouter/model.rs @@ -0,0 +1,311 @@ +//! `/v1/usage` parsing and card presentation, ported from `clawrouter.js`. + +use std::cmp::Ordering; + +use chrono::{DateTime, TimeZone, Utc}; +use serde_json::{Map, Value}; + +use crate::core::{ + CostSnapshot, ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, + UsageSnapshot, +}; + +/// Upstream shows at most 20 routed-provider rows. +const MAX_PROVIDER_ROWS: usize = 20; +const USAGE_SECTION: &str = "Usage"; +const PROVIDERS_SECTION: &str = "Routed providers"; +const COST_PERIOD: &str = "This month"; + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Budget { + pub configured: bool, + pub ledger: String, + pub limit: Option, + pub spent: Option, + pub remaining: Option, + pub resets_at: Option>, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Summary { + pub requests: i64, + pub successes: i64, + pub errors: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub total_tokens: i64, + pub actual_cost: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct RoutedProvider { + pub name: String, + pub requests: i64, + pub tokens: i64, + pub cost: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct ClawRouterUsage { + pub budget: Budget, + pub summary: Summary, + /// Sorted by cost, then requests (both descending), then name. + pub providers: Vec, +} + +fn parse_failure(detail: &str) -> ProviderError { + ProviderError::Parse(format!("Could not parse ClawRouter usage: {detail}")) +} + +/// JavaScript `Number.isInteger` on a JSON number. +#[allow( + clippy::cast_possible_truncation, + reason = "the float is integral and checked to be within i64 range" +)] +fn integer(value: Option<&Value>, field: &str) -> Result { + let invalid = || parse_failure(&format!("{field} must be an integer")); + let number = value.and_then(Value::as_number).ok_or_else(invalid)?; + if let Some(value) = number.as_i64() { + return Ok(value); + } + match number.as_f64() { + Some(value) + if value.is_finite() && value.fract() == 0.0 && value.abs() < i64::MAX as f64 => + { + Ok(value as i64) + } + _ => Err(invalid()), + } +} + +fn micros(value: Option<&Value>, field: &str) -> Result { + integer(value, field).map(|micros| micros as f64 / 1_000_000.0) +} + +fn optional_micros(value: Option<&Value>, field: &str) -> Result, ProviderError> { + match value { + None | Some(Value::Null) => Ok(None), + value => micros(value, field).map(Some), + } +} + +/// First instant of the month after a `...YYYY-MM` window key, in UTC. +pub(super) fn monthly_reset(window_key: Option<&Value>) -> Option> { + let key = window_key?.as_str()?.as_bytes(); + let tail = key.get(key.len().checked_sub(7)?..)?; + let digits = |range: std::ops::Range| -> Option { + let part = tail.get(range)?; + part.iter() + .all(u8::is_ascii_digit) + .then(|| std::str::from_utf8(part).ok()?.parse().ok())? + }; + if tail[4] != b'-' { + return None; + } + let year = i32::try_from(digits(0..4)?).ok()?; + let month = digits(5..7)?; + if !(1..=12).contains(&month) { + return None; + } + let (year, month) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0).single() +} + +fn object(value: Option<&Value>) -> Option<&Map> { + value.and_then(Value::as_object) +} + +pub(super) fn parse_usage(body: &[u8]) -> Result { + let payload: Value = + serde_json::from_slice(body).map_err(|_| parse_failure("response was not valid JSON"))?; + let shape = || parse_failure("response shape is invalid"); + let root = payload.as_object().ok_or_else(shape)?; + let budget = object(root.get("budget")).ok_or_else(shape)?; + let usage = object(root.get("usage")).ok_or_else(shape)?; + let summary = object(usage.get("summary")).ok_or_else(shape)?; + let providers = usage + .get("providers") + .and_then(Value::as_array) + .ok_or_else(shape)?; + + let (Some(configured), Some(ledger)) = ( + budget.get("configured").and_then(Value::as_bool), + budget.get("ledger").and_then(Value::as_str), + ) else { + return Err(parse_failure("budget is invalid")); + }; + let budget = Budget { + configured, + ledger: ledger.to_owned(), + limit: optional_micros(budget.get("limitMicros"), "budget.limitMicros")?, + spent: optional_micros(budget.get("spentMicros"), "budget.spentMicros")?, + remaining: optional_micros(budget.get("remainingMicros"), "budget.remainingMicros")?, + resets_at: monthly_reset(budget.get("windowKey")), + }; + let summary = Summary { + requests: integer(summary.get("requestCount"), "summary.requestCount")?, + successes: integer(summary.get("successCount"), "summary.successCount")?, + errors: integer(summary.get("errorCount"), "summary.errorCount")?, + input_tokens: integer(summary.get("inputTokens"), "summary.inputTokens")?, + output_tokens: integer(summary.get("outputTokens"), "summary.outputTokens")?, + total_tokens: integer(summary.get("totalTokens"), "summary.totalTokens")?, + actual_cost: micros(summary.get("actualCostMicros"), "summary.actualCostMicros")?, + }; + + let mut providers = providers + .iter() + .map(|item| { + let name = item + .get("provider") + .and_then(Value::as_str) + .ok_or_else(|| parse_failure("provider name must be a string"))? + .trim(); + let requests = integer(item.get("requestCount"), "provider.requestCount")?; + // Success and error counts are validated like upstream even + // though the card shows only requests, tokens and cost. + integer(item.get("successCount"), "provider.successCount")?; + integer(item.get("errorCount"), "provider.errorCount")?; + Ok(RoutedProvider { + name: if name.is_empty() { "Unknown" } else { name }.to_owned(), + requests, + tokens: integer(item.get("totalTokens"), "provider.totalTokens")?, + cost: micros(item.get("actualCostMicros"), "provider.actualCostMicros")?, + }) + }) + .collect::, ProviderError>>()?; + providers.sort_by(|a, b| { + b.cost + .partial_cmp(&a.cost) + .unwrap_or(Ordering::Equal) + .then(b.requests.cmp(&a.requests)) + // Approximates JavaScript `localeCompare`: case-insensitive first. + .then_with(|| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then_with(|| a.name.cmp(&b.name)) + }) + }); + + Ok(ClawRouterUsage { + budget, + summary, + providers, + }) +} + +/// Upstream `ctx.pct`: 100 when the limit is unusable, else clamped to 0..=100. +fn pct(used: f64, limit: f64) -> f64 { + if !(used.is_finite() && limit.is_finite() && limit > 0.0) { + return 100.0; + } + (used / limit * 100.0).clamp(0.0, 100.0) +} + +fn usd6(value: f64) -> String { + format!("${value:.6}") +} + +pub(super) fn build_result(usage: &ClawRouterUsage) -> ProviderFetchResult { + let budget = &usage.budget; + let summary = &usage.summary; + let metered = budget.spent.zip(budget.limit); + + let primary = match metered { + Some((spent, limit)) if limit > 0.0 => { + RateWindow::with_details(pct(spent, limit), None, budget.resets_at, None) + } + _ if budget.configured => RateWindow::informational("No monthly limit reported"), + _ => RateWindow::informational("Unmetered"), + }; + let snapshot = UsageSnapshot::new(primary) + .with_organization(format!("{} routed providers", usage.providers.len())) + .with_login_method(if budget.configured { + "Managed monthly budget" + } else { + "Unmetered" + }); + + let mut result = ProviderFetchResult::new(snapshot, "api"); + let cost = match metered { + Some((spent, limit)) => { + Some(CostSnapshot::new(spent, "USD", COST_PERIOD).with_limit(limit)) + } + None if summary.actual_cost > 0.0 => { + Some(CostSnapshot::new(summary.actual_cost, "USD", COST_PERIOD)) + } + None => None, + }; + if let Some(mut cost) = cost { + if let Some(resets_at) = budget.resets_at { + cost = cost.with_resets_at(resets_at); + } + result = result.with_cost(cost); + } + for detail in details(usage) { + result = result.with_display_detail(detail); + } + result +} + +fn usage_row(id: &str, title: &str, value: String) -> Option { + ProviderDisplayDetail::new(id, title, value)?.with_section_title(USAGE_SECTION) +} + +fn details(usage: &ClawRouterUsage) -> Vec> { + let budget = &usage.budget; + let summary = &usage.summary; + let mut rows = vec![ + usage_row("requests", "Requests", summary.requests.to_string()).and_then(|row| { + row.with_secondary_value(format!( + "{} succeeded · {} failed", + summary.successes, summary.errors + )) + }), + usage_row("tokens", "Tokens", summary.total_tokens.to_string()).and_then(|row| { + row.with_secondary_value(format!( + "{} input · {} output", + summary.input_tokens, summary.output_tokens + )) + }), + usage_row("actual-cost", "Actual cost", usd6(summary.actual_cost)), + usage_row("ledger", "Budget ledger", budget.ledger.clone()), + ]; + if let Some((spent, limit)) = budget.spent.zip(budget.limit) { + let row = usage_row( + "monthly-budget", + "Monthly budget", + format!("{} / ${limit:.2}", usd6(spent)), + ); + rows.push(match budget.remaining { + Some(remaining) => row + .and_then(|row| row.with_secondary_value(format!("{} remaining", usd6(remaining)))), + None => row, + }); + } + rows.extend( + usage + .providers + .iter() + .take(MAX_PROVIDER_ROWS) + .enumerate() + .map(|(index, provider)| { + ProviderDisplayDetail::new( + format!("provider-{index}"), + &provider.name, + format!("{} requests", provider.requests), + )? + .with_secondary_value(format!( + "{} · {} tokens", + usd6(provider.cost), + provider.tokens + ))? + .with_section_title(PROVIDERS_SECTION) + }), + ); + rows +} diff --git a/rust/src/providers/clawrouter/tests.rs b/rust/src/providers/clawrouter/tests.rs new file mode 100644 index 0000000000..988dc78c6f --- /dev/null +++ b/rust/src/providers/clawrouter/tests.rs @@ -0,0 +1,525 @@ +use std::io::{Read, Write}; +use std::net::TcpListener; + +use chrono::{TimeZone, Utc}; +use reqwest::redirect::Policy; +use serde_json::{Value, json}; + +use super::model::{build_result, monthly_reset, parse_usage}; +use super::*; + +/// The parity pack payload (`scenarios/providers/ClawRouter/routes.json`) +/// with `{{month:+0}}` resolved to `window_key`. +fn pack_payload(window_key: &str) -> Value { + json!({ + "budget": { + "configured": true, + "ledger": "parity-ledger", + "limitMicros": 50_000_000, + "spentMicros": 18_420_000, + "remainingMicros": 31_580_000, + "windowKey": window_key + }, + "usage": { + "summary": { + "requestCount": 1284, + "successCount": 1261, + "errorCount": 23, + "inputTokens": 3_120_450, + "outputTokens": 812_300, + "totalTokens": 3_932_750, + "actualCostMicros": 18_420_000 + }, + "providers": [ + {"provider": "Anthropic", "requestCount": 402, "successCount": 395, "errorCount": 7, "totalTokens": 1_520_400, "actualCostMicros": 9_120_000}, + {"provider": "OpenAI", "requestCount": 511, "successCount": 503, "errorCount": 8, "totalTokens": 1_230_100, "actualCostMicros": 5_300_000}, + {"provider": "DeepSeek", "requestCount": 233, "successCount": 225, "errorCount": 8, "totalTokens": 790_250, "actualCostMicros": 2_700_000}, + {"provider": "Mistral", "requestCount": 138, "successCount": 138, "errorCount": 0, "totalTokens": 392_000, "actualCostMicros": 1_300_000} + ] + } + }) +} + +fn parse(value: &Value) -> Result { + parse_usage(value.to_string().as_bytes()) +} + +/// (section, title, value, secondary) for every display row, in order. +fn rows(result: &ProviderFetchResult) -> Vec<(String, String, String, Option)> { + result + .display_details() + .iter() + .map(|row| { + ( + row.section_title().unwrap_or_default().to_owned(), + row.title().to_owned(), + row.value().to_owned(), + row.secondary_value().map(str::to_owned), + ) + }) + .collect() +} + +fn row( + section: &str, + title: &str, + value: &str, + secondary: Option<&str>, +) -> (String, String, String, Option) { + ( + section.to_owned(), + title.to_owned(), + value.to_owned(), + secondary.map(str::to_owned), + ) +} + +#[test] +fn pack_result_matches_the_mac_card() { + let usage = parse(&pack_payload("parity-2026-10")).expect("pack payload parses"); + let result = build_result(&usage); + let reset = Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap(); + + // "Monthly budget 63% left", resets at the first of next month. + let primary = &result.usage.primary; + assert!((primary.used_percent - 36.84).abs() < 1e-9); + assert_eq!(primary.remaining_percent().round(), 63.0); + assert_eq!(primary.resets_at, Some(reset)); + assert_eq!(primary.window_minutes, None); + assert!(!primary.is_informational); + assert!(result.usage.secondary.is_none()); + + // Header: "Managed monthly budget"; organization counts routed providers. + assert_eq!( + result.usage.login_method.as_deref(), + Some("Managed monthly budget") + ); + assert_eq!( + result.usage.account_organization.as_deref(), + Some("4 routed providers") + ); + + // "This month: $18.42 / $50.00". + let cost = result.cost.as_ref().expect("monthly cost"); + assert!((cost.used - 18.42).abs() < 1e-9); + assert_eq!(cost.limit, Some(50.0)); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(cost.period, "This month"); + assert_eq!(cost.resets_at, Some(reset)); + + assert_eq!( + rows(&result), + vec![ + row( + "Usage", + "Requests", + "1284", + Some("1261 succeeded · 23 failed") + ), + row( + "Usage", + "Tokens", + "3932750", + Some("3120450 input · 812300 output") + ), + row("Usage", "Actual cost", "$18.420000", None), + row("Usage", "Budget ledger", "parity-ledger", None), + row( + "Usage", + "Monthly budget", + "$18.420000 / $50.00", + Some("$31.580000 remaining") + ), + row( + "Routed providers", + "Anthropic", + "402 requests", + Some("$9.120000 · 1520400 tokens") + ), + row( + "Routed providers", + "OpenAI", + "511 requests", + Some("$5.300000 · 1230100 tokens") + ), + row( + "Routed providers", + "DeepSeek", + "233 requests", + Some("$2.700000 · 790250 tokens") + ), + row( + "Routed providers", + "Mistral", + "138 requests", + Some("$1.300000 · 392000 tokens") + ), + ] + ); +} + +#[test] +fn monthly_reset_rolls_into_the_next_month_and_year() { + let key = |value: &str| json!(value); + assert_eq!( + monthly_reset(Some(&key("2026-01"))), + Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).single() + ); + assert_eq!( + monthly_reset(Some(&key("ledger:2026-12"))), + Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).single() + ); + for invalid in [ + "2026-13", "2026-00", "2026-1", "26-10", "2026/10", "2026-10x", "", + ] { + assert_eq!(monthly_reset(Some(&key(invalid))), None, "{invalid}"); + } + assert_eq!(monthly_reset(Some(&json!(202610))), None); + assert_eq!(monthly_reset(None), None); +} + +#[test] +fn routed_providers_sort_by_cost_then_requests_then_name_and_cap_at_twenty() { + let mut payload = pack_payload("2026-10"); + let providers: Vec = (0..25) + .map(|index| { + json!({ + "provider": if index == 3 { " ".to_owned() } else { format!("p{index:02}") }, + "requestCount": index % 3, + "successCount": 0, + "errorCount": 0, + "totalTokens": 10, + "actualCostMicros": if index < 6 { 1_000_000 } else { 0 } + }) + }) + .collect(); + payload["usage"]["providers"] = Value::Array(providers); + let usage = parse(&payload).expect("parses"); + + let names: Vec<&str> = usage.providers.iter().map(|p| p.name.as_str()).collect(); + // $1 rows first, by requests desc (2, 1, 0) then case-insensitive name; + // blank names become "Unknown". + assert_eq!( + &names[..6], + ["p02", "p05", "p01", "p04", "p00", "Unknown"].as_slice() + ); + + let result = build_result(&usage); + let routed = rows(&result) + .into_iter() + .filter(|(section, ..)| section == "Routed providers") + .count(); + assert_eq!(routed, 20); + assert_eq!( + result.usage.account_organization.as_deref(), + Some("25 routed providers") + ); +} + +#[test] +fn unmetered_policy_reports_actual_cost_without_a_limit() { + let mut payload = pack_payload("2026-10"); + payload["budget"] = json!({"configured": false, "ledger": "none", "windowKey": "2026-10"}); + let result = build_result(&parse(&payload).expect("parses")); + + assert!(result.usage.primary.is_informational); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Unmetered") + ); + assert_eq!(result.usage.login_method.as_deref(), Some("Unmetered")); + let cost = result.cost.as_ref().expect("actual cost"); + assert!((cost.used - 18.42).abs() < 1e-9); + assert_eq!(cost.limit, None); + assert_eq!( + cost.resets_at, + Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).single() + ); + assert!( + rows(&result) + .iter() + .all(|(_, title, ..)| title != "Monthly budget") + ); + + // No spend at all: no cost block. + payload["usage"]["summary"]["actualCostMicros"] = json!(0); + assert!( + build_result(&parse(&payload).expect("parses")) + .cost + .is_none() + ); +} + +#[test] +fn zero_limit_keeps_the_cost_but_drops_the_meter() { + let mut payload = pack_payload("2026-10"); + payload["budget"]["limitMicros"] = json!(0); + payload["budget"]["remainingMicros"] = Value::Null; + let result = build_result(&parse(&payload).expect("parses")); + + assert!(result.usage.primary.is_informational); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("No monthly limit reported") + ); + assert_eq!(result.cost.as_ref().and_then(|cost| cost.limit), Some(0.0)); + assert!(rows(&result).contains(&row("Usage", "Monthly budget", "$18.420000 / $0.00", None))); +} + +#[test] +fn overspent_budget_clamps_to_full() { + let mut payload = pack_payload("2026-10"); + payload["budget"]["spentMicros"] = json!(75_000_000); + let result = build_result(&parse(&payload).expect("parses")); + assert_eq!(result.usage.primary.used_percent, 100.0); +} + +#[test] +fn rejects_invalid_payloads_with_upstream_messages() { + let cases: Vec<(Value, &str)> = vec![ + (json!([]), "response shape is invalid"), + (json!({"budget": {}}), "response shape is invalid"), + ( + { + let mut p = pack_payload("2026-10"); + p["usage"]["providers"] = json!({}); + p + }, + "response shape is invalid", + ), + ( + { + let mut p = pack_payload("2026-10"); + p["budget"]["configured"] = json!("yes"); + p + }, + "budget is invalid", + ), + ( + { + let mut p = pack_payload("2026-10"); + p["budget"]["limitMicros"] = json!(1.5); + p + }, + "budget.limitMicros must be an integer", + ), + ( + { + let mut p = pack_payload("2026-10"); + p["usage"]["summary"]["totalTokens"] = json!("3932750"); + p + }, + "summary.totalTokens must be an integer", + ), + ( + { + let mut p = pack_payload("2026-10"); + p["usage"]["providers"][1]["provider"] = json!(7); + p + }, + "provider name must be a string", + ), + ( + { + let mut p = pack_payload("2026-10"); + p["usage"]["providers"][0]["errorCount"] = Value::Null; + p + }, + "provider.errorCount must be an integer", + ), + ]; + for (payload, detail) in cases { + let error = parse(&payload).expect_err(detail); + assert_eq!( + error.to_string(), + format!("Parse error: Could not parse ClawRouter usage: {detail}") + ); + } + assert_eq!( + parse_usage(b"").unwrap_err().to_string(), + "Parse error: Could not parse ClawRouter usage: response was not valid JSON" + ); +} + +#[test] +fn integral_floats_count_as_integers() { + let mut payload = pack_payload("2026-10"); + payload["usage"]["summary"]["requestCount"] = json!(1284.0); + assert_eq!(parse(&payload).expect("parses").summary.requests, 1284); +} + +#[test] +fn base_url_overrides_normalize_to_v1_usage_over_https() { + for (raw, expected) in [ + ( + "https://router.example.com", + "https://router.example.com/v1/usage", + ), + ( + "https://router.example.com/", + "https://router.example.com/v1/usage", + ), + ( + "https://router.example.com/v1", + "https://router.example.com/v1/usage", + ), + ( + "https://router.example.com/v1/", + "https://router.example.com/v1/usage", + ), + ( + "https://router.example.com/team/v1", + "https://router.example.com/team/v1/usage", + ), + ("router.example.com", "https://router.example.com/v1/usage"), + ( + " router.example.com:8443 ", + "https://router.example.com:8443/v1/usage", + ), + ] { + assert_eq!(usage_url(raw).expect(raw).as_str(), expected, "{raw}"); + } +} + +#[test] +fn base_url_overrides_reject_non_https_and_credentials() { + for raw in [ + "http://router.example.com", + "http://localhost:8080", + "ftp://router.example.com", + "https://user:pass@router.example.com", + "https://router.example.com/?x=1", + "https://router.example.com/#frag", + "https://", + "https://router example.com", + "/", + ] { + let error = usage_url(raw).expect_err(raw); + assert_eq!( + error.to_string(), + "ClawRouter Base URL is invalid. Use an HTTPS URL without embedded credentials.", + "{raw}" + ); + assert!(validate_gateway_url(raw).is_err(), "{raw}"); + } + assert!(validate_gateway_url("").is_ok()); + assert!(validate_gateway_url(" ").is_ok()); + assert!(validate_gateway_url("clawrouter.openclaw.ai").is_ok()); +} + +#[test] +fn maps_statuses_to_upstream_messages() { + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + assert_eq!( + check_status(status).unwrap_err().to_string(), + "ClawRouter rejected the API key. Check the key and its policy status." + ); + } + for (status, code) in [ + (StatusCode::TOO_MANY_REQUESTS, 429), + (StatusCode::BAD_GATEWAY, 502), + (StatusCode::NOT_FOUND, 404), + ] { + assert_eq!( + check_status(status).unwrap_err().to_string(), + format!("ClawRouter API returned HTTP {code}.") + ); + } + assert!(check_status(StatusCode::OK).is_ok()); +} + +/// Serve one canned HTTP response; the handle yields the lowercased request. +fn provider_serving( + status_line: &'static str, + body: String, +) -> (ClawRouterProvider, std::thread::JoinHandle) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind local test server"); + let address = listener.local_addr().expect("local server address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).expect("read request"); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + write!( + stream, + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write response"); + request + }); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + let provider = ClawRouterProvider::with_client(&format!("http://{address}/"), client); + (provider, server) +} + +fn keyed_context() -> FetchContext { + FetchContext { + api_key: Some("test-clawrouter-key".into()), + ..FetchContext::default() + } +} + +#[tokio::test] +async fn sends_a_bearer_request_to_v1_usage() { + let (provider, server) = provider_serving("200 OK", pack_payload("2026-10").to_string()); + + let result = provider + .fetch_usage(&keyed_context()) + .await + .expect("usage fetch"); + let request = server.join().expect("test server thread"); + assert!(request.starts_with("get /v1/usage "), "{request}"); + assert!(request.contains("authorization: bearer test-clawrouter-key")); + assert_eq!(result.source_label, "api"); + assert_eq!(result.usage.primary.remaining_percent().round(), 63.0); +} + +#[tokio::test] +async fn errors_do_not_echo_the_response_body() { + for status_line in ["200 OK", "401 Unauthorized", "500 Internal Server Error"] { + let (provider, server) = provider_serving(status_line, "private-response".into()); + let error = provider + .fetch_usage(&keyed_context()) + .await + .expect_err(status_line); + server.join().expect("test server thread"); + assert!( + !error.to_string().contains("private-response"), + "{status_line}" + ); + } +} + +#[tokio::test] +async fn invalid_saved_base_url_fails_before_any_request() { + let provider = ClawRouterProvider::new(); + let ctx = FetchContext { + gateway_url: Some("http://router.example.com".into()), + ..keyed_context() + }; + let error = provider.fetch_usage(&ctx).await.expect_err("http override"); + assert_eq!( + error.to_string(), + "ClawRouter Base URL is invalid. Use an HTTPS URL without embedded credentials." + ); +} + +#[tokio::test] +async fn web_and_cli_sources_are_unsupported() { + let provider = ClawRouterProvider::new(); + for source_mode in [SourceMode::Web, SourceMode::Cli] { + let ctx = FetchContext { + source_mode, + ..keyed_context() + }; + assert!(matches!( + provider.fetch_usage(&ctx).await, + Err(ProviderError::UnsupportedSource(_)) + )); + } +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 78f57c45a6..51b07f7ac6 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -22,6 +22,7 @@ pub mod bifrost; pub mod chart; pub mod chutes; pub mod claude; +pub mod clawrouter; pub mod clinepass; pub mod codebuddy; pub mod codebuff; @@ -115,6 +116,7 @@ pub use bedrock::BedrockProvider; pub use bifrost::BifrostProvider; pub use chutes::ChutesProvider; pub use claude::ClaudeProvider; +pub use clawrouter::ClawRouterProvider; pub use clinepass::ClinePassProvider; pub use codebuddy::CodeBuddyProvider; pub use codebuff::CodebuffProvider; diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 3ea5048ca4..3ee092f0ff 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -751,6 +751,17 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://dash.aixy-gateway.com"), }, + ProviderConfigInfo { + id: ProviderId::ClawRouter, + name: "ClawRouter", + requires_api_key: true, + api_key_env_var: Some(crate::providers::clawrouter::API_KEY_ENV), + api_key_help: Some( + "Save a ClawRouter policy key. Leave the Base URL empty for the hosted service, or set an HTTPS URL for another deployment. Or set CLAWROUTER_API_KEY and CLAWROUTER_BASE_URL.", + ), + config_file_path: None, + dashboard_url: Some("https://clawrouter.openclaw.ai/dashboard/access"), + }, ] } From 5ce549d970243a813c4924bff1625133aca61006 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:19:26 +0700 Subject: [PATCH 03/14] Add the IBM Bob provider --- README.md | 1 + .../providers/icons/ProviderIcon-ibmbob.svg | 7 + .../src/components/providers/providerIcons.ts | 3 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 19 + rust/src/core/provider.rs | 14 +- rust/src/core/provider_factory.rs | 2 + rust/src/core/token_accounts.rs | 25 + .../providers/ibmbob/fixtures/profile.json | 16 + rust/src/providers/ibmbob/mod.rs | 332 +++++++++++ rust/src/providers/ibmbob/model.rs | 216 +++++++ rust/src/providers/ibmbob/tests.rs | 534 ++++++++++++++++++ rust/src/providers/mod.rs | 2 + rust/src/settings/api_keys.rs | 11 + 14 files changed, 1182 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-ibmbob.svg create mode 100644 rust/src/providers/ibmbob/fixtures/profile.json create mode 100644 rust/src/providers/ibmbob/mod.rs create mode 100644 rust/src/providers/ibmbob/model.rs create mode 100644 rust/src/providers/ibmbob/tests.rs diff --git a/README.md b/README.md index e30de60432..aaffe97b80 100755 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | LLM Proxy | API Key | Quota Stats | | llmman | Local daemon / optional API Key | Memory in use, loaded and stored models | | DevPass | API Key | Plan credits, Premium weekly, API-key spend | +| IBM Bob | API Key / token accounts | Monthly Bobcoins, per-team budgets | | xKiro | API Key | Daily free tokens | diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-ibmbob.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-ibmbob.svg new file mode 100644 index 0000000000..58c72066b4 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-ibmbob.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 082672f4d2..00d4d5a60c 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -36,6 +36,7 @@ import gemini from "./icons/ProviderIcon-gemini.svg?raw"; import grok from "./icons/ProviderIcon-grok.svg?raw"; import groq from "./icons/ProviderIcon-groq.svg?raw"; import huggingface from "./icons/ProviderIcon-huggingface.svg?raw"; +import ibmbob from "./icons/ProviderIcon-ibmbob.svg?raw"; import jetbrains from "./icons/ProviderIcon-jetbrains.svg?raw"; import kilo from "./icons/ProviderIcon-kilo.svg?raw"; import kimi from "./icons/ProviderIcon-kimi.svg?raw"; @@ -130,6 +131,7 @@ const RAW: Record = { grok: tint(grok), groq: tint(groq), huggingface: tint(huggingface), + ibmbob: tint(ibmbob), jetbrains: tint(jetbrains), kilo: tint(kilo), kimi: tint(kimi), @@ -250,6 +252,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { sub2api: { id: "sub2api", brandColor: "#14b8a6", fallbackLetter: "S", svgPath: RAW.sub2api }, venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, + ibmbob: { id: "ibmbob", brandColor: "#0E61FA", fallbackLetter: "B", svgPath: RAW.ibmbob }, openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" }, chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" }, litellm: { id: "litellm", brandColor: "#0ea5e9", fallbackLetter: "L" }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index c2d33f3719..2971032ef5 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -86,4 +86,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["devpass", "DevPass"], ["xkiro", "xKiro"], ["raycast", "Raycast"], + ["ibmbob", "IBM Bob"], ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 0cf80274a7..a17bf82528 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -185,6 +185,25 @@ are never followed and response bodies are never echoed in errors. budget, inconsistent coverage, malformed amounts) is a parse error, not a partial balance. +### IBM Bob + +IBM Bob reads monthly Bobcoin usage. Configure an API key from the IBM Bob web +portal in Settings → Providers → IBM Bob, as token accounts, or with +`BOBSHELL_API_KEY`; the CLI also accepts `ibm-bob`, `bob` and `bobshell`. + +- `GET https://api.us-east.bob.ibm.com/admin/v1/profile` lists the + subscription instances; then each team's budget comes from + `GET /admin/v1/teams/{team}/users/{user}` on the instance's regional host. +- Credentials go only to HTTPS hosts named `bob.ibm.com` or under + `.bob.ibm.com`; any other `region_domain` stops the refresh before a team + request. A JWT session token is sent as `Bearer`, any other key as `Apikey`. +- The "Monthly Bobcoins" bar sums every visible team and shows a percentage + only when every team has a budget; otherwise it shows Bobcoins used. It + resets at the earliest `refresh_at`. The "Bobcoin usage" rows list each team + with its plan, and the plan names are shown as the organization. +- 401 and 403 report that the key was rejected; response bodies are never + echoed in errors. + ## Listing what is enabled ```powershell diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 7a4aba44da..68d1007c8c 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -103,6 +103,7 @@ pub enum ProviderId { XKiro, Raycast, Vercel, + IBMBob, } impl ProviderId { @@ -198,6 +199,7 @@ impl ProviderId { ProviderId::XKiro, ProviderId::Raycast, ProviderId::Vercel, + ProviderId::IBMBob, ] } @@ -254,6 +256,7 @@ impl ProviderId { ProviderId::XKiro => "xkiro", ProviderId::Raycast => "raycast", ProviderId::Vercel => "vercel", + ProviderId::IBMBob => "ibmbob", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -350,6 +353,7 @@ impl ProviderId { ProviderId::XKiro => "xKiro", ProviderId::Raycast => "Raycast", ProviderId::Vercel => "Vercel AI Gateway", + ProviderId::IBMBob => "IBM Bob", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -462,6 +466,7 @@ impl ProviderId { ProviderId::DevPass => None, ProviderId::XKiro => None, ProviderId::Vercel => None, + ProviderId::IBMBob => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -614,6 +619,7 @@ impl ProviderId { "replicate" | "r8" => Some(ProviderId::Replicate), "atlascloud" | "atlas-cloud" | "atlas cloud" => Some(ProviderId::AtlasCloud), "raycast" | "raycast-ai" => Some(ProviderId::Raycast), + "ibmbob" | "ibm-bob" | "ibm bob" | "bob" | "bobshell" => Some(ProviderId::IBMBob), _ => None, } } @@ -1142,6 +1148,10 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("vercel ai gateway", ProviderId::Vercel); map.insert("ai-gateway", ProviderId::Vercel); map.insert("ai gateway", ProviderId::Vercel); + map.insert("ibm-bob", ProviderId::IBMBob); + map.insert("ibm bob", ProviderId::IBMBob); + map.insert("bob", ProviderId::IBMBob); + map.insert("bobshell", ProviderId::IBMBob); map.insert("metaspark", ProviderId::Meta); map.insert("meta-spark", ProviderId::Meta); map.insert("muse-spark", ProviderId::Meta); @@ -1305,6 +1315,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Raycast => "#FF6363", // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. ProviderId::Vercel => "#737373", + ProviderId::IBMBob => "#0E61FA", } } @@ -1319,7 +1330,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 89); + assert_eq!(all.len(), 90); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1389,6 +1400,7 @@ mod tests { assert!(all.contains(&ProviderId::XKiro)); assert!(all.contains(&ProviderId::Raycast)); assert!(all.contains(&ProviderId::Vercel)); + assert!(all.contains(&ProviderId::IBMBob)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 1be54fc0c6..796908a69b 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -7,6 +7,7 @@ use super::{Provider, ProviderId}; use crate::providers::AtlasCloudProvider; +use crate::providers::IBMBobProvider; use crate::providers::{ AbacusProvider, AiAndProvider, AixyProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, AugmentProvider, AzureOpenAIProvider, BedrockProvider, @@ -41,6 +42,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Copilot => Box::new(CopilotProvider::new()), ProviderId::Antigravity => Box::new(AntigravityProvider::new()), ProviderId::AtlasCloud => Box::new(AtlasCloudProvider::new()), + ProviderId::IBMBob => Box::new(IBMBobProvider::new()), ProviderId::Factory => Box::new(FactoryProvider::new()), ProviderId::Zai => Box::new(ZaiProvider::new()), ProviderId::Kiro => Box::new(KiroProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index bae251693b..dfc229763f 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -379,6 +379,17 @@ impl TokenAccountSupport { requires_manual_cookie_source: false, cookie_name: None, }), + // Upstream 0.73: labeled IBM Bob API keys via token accounts. + ProviderId::IBMBob => Some(TokenAccountSupport { + title: "API keys", + subtitle: "Store multiple IBM Bob API keys.", + placeholder: "Paste API key…", + injection: TokenInjection::Environment { + key: "BOBSHELL_API_KEY".to_string(), + }, + requires_manual_cookie_source: false, + cookie_name: None, + }), // These providers don't support token accounts ProviderId::Codex | ProviderId::Pi @@ -1057,6 +1068,20 @@ mod tests { ); } + #[test] + fn ibmbob_token_accounts_inject_bobshell_api_key_env() { + let support = TokenAccountSupport::for_provider(ProviderId::IBMBob).unwrap(); + assert_eq!(support.title, "API keys"); + assert_eq!(support.subtitle, "Store multiple IBM Bob API keys."); + assert_eq!(support.placeholder, "Paste API key…"); + assert!(!support.requires_manual_cookie_source); + let env = TokenAccountSupport::env_override(ProviderId::IBMBob, "bob_fixture").unwrap(); + assert_eq!( + env.get("BOBSHELL_API_KEY").map(String::as_str), + Some("bob_fixture") + ); + } + #[test] fn grok_token_accounts_route_bearer_and_cookie_credentials() { let bearer = diff --git a/rust/src/providers/ibmbob/fixtures/profile.json b/rust/src/providers/ibmbob/fixtures/profile.json new file mode 100644 index 0000000000..32a67bcff3 --- /dev/null +++ b/rust/src/providers/ibmbob/fixtures/profile.json @@ -0,0 +1,16 @@ +{ + "instances": [ + { + "instance_id": "inst-parity-0001", + "instance_name": "Parity Labs Bob", + "user_id": "user-parity-0001", + "plan_name": "Bob Pro", + "refresh_at": "2026-10-26T12:00:00Z", + "region_domain": "us-east.bob.ibm.com", + "teams": [ + {"id": "team-parity-0001", "name": "Platform", "budget_limit": 500, "usage": 312.5}, + {"id": "team-parity-0002", "name": "Docs", "budget_limit": 250, "usage": 96.25} + ] + } + ] +} diff --git a/rust/src/providers/ibmbob/mod.rs b/rust/src/providers/ibmbob/mod.rs new file mode 100644 index 0000000000..639da655dc --- /dev/null +++ b/rust/src/providers/ibmbob/mod.rs @@ -0,0 +1,332 @@ +//! IBM Bob: monthly Bobcoin usage for every team visible to one API key. +//! +//! Ported from upstream CodexBar v0.73.0 (`IBMBobUsageFetcher.swift`, +//! `IBMBobSettingsReader.swift`, `IBMBobProviderDescriptor.swift`). The key +//! is read from `GET /admin/v1/profile` on the fixed US-East host, then each +//! team's budget from `GET /admin/v1/teams/{team}/users/{user}` on the +//! instance's regional host. Credentials are sent only to HTTPS hosts under +//! `bob.ibm.com`, redirects are never followed, and response bodies are never +//! echoed in errors. + +mod model; +#[cfg(test)] +mod tests; + +use std::time::Duration; + +use async_trait::async_trait; +use base64::Engine; +use reqwest::{Client, StatusCode, Url}; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + SourceMode, +}; + +const CREDENTIAL_TARGET: &str = "codexbar-ibmbob"; +pub(crate) const API_KEY_ENV: &str = "BOBSHELL_API_KEY"; +const BASE_URL: &str = "https://api.us-east.bob.ibm.com"; +const DASHBOARD_URL: &str = "https://bob.ibm.com"; +const STATUS_URL: &str = "https://status.bob.ibm.com"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +/// Upper bound on the regional host echoed in an error message. +const MAX_ECHOED_HOST_CHARS: usize = 253; + +pub struct IBMBobProvider { + metadata: ProviderMetadata, + client: Option, + base_url: String, + /// Tests send validated regional requests to a local server instead. + #[cfg(test)] + regional_override: Option, +} + +impl IBMBobProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::IBMBob, + display_name: "IBM Bob", + session_label: "Monthly Bobcoins", + weekly_label: "Monthly Bobcoins", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some(DASHBOARD_URL), + status_page_url: Some(STATUS_URL), + tertiary_label_key: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(REQUEST_TIMEOUT) + // The credential must never follow a redirect off bob.ibm.com. + .redirect(reqwest::redirect::Policy::none()) + .build() + .ok(), + base_url: BASE_URL.to_owned(), + #[cfg(test)] + regional_override: None, + } + } + + #[cfg(test)] + fn with_client(base_url: &str, client: Client) -> Self { + let mut provider = Self::new(); + provider.client = Some(client); + provider.base_url = base_url.trim_end_matches('/').to_owned(); + provider.regional_override = Url::parse(base_url).ok(); + provider + } + + /// The validated regional host, or the fixed host when none is reported. + fn regional_base(&self, region_domain: Option<&str>) -> Result { + let Some(url) = regional_base_url(region_domain)? else { + return Ok(self.base_url.clone()); + }; + let url = self.local_regional_url().unwrap_or(url); + Ok(url.as_str().trim_end_matches('/').to_owned()) + } + + #[cfg(test)] + fn local_regional_url(&self) -> Option { + self.regional_override.clone() + } + + #[cfg(not(test))] + fn local_regional_url(&self) -> Option { + None + } + + async fn get( + &self, + client: &Client, + url: &str, + token: &str, + instance_and_team: Option<(&str, &str)>, + timeout: Duration, + ) -> Result, ProviderError> { + let mut request = client + .get(url) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("Authorization", authorization_value(token)) + .header("User-Agent", "CodexBar") + .timeout(timeout); + if let Some((instance_id, team_id)) = instance_and_team { + request = request + .header("x-instance-id", instance_id) + .header("x-team-id", team_id); + } + let response = request.send().await?; + check_status(response.status())?; + crate::providers::read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + crate::providers::BoundedBodyError::TooLarge => ProviderError::Parse( + "Could not parse IBM Bob usage: response is too large".into(), + ), + crate::providers::BoundedBodyError::Read(error) => ProviderError::Network(error), + }) + } + + async fn fetch_api(&self, ctx: &FetchContext) -> Result { + let token = normalize_api_key(&crate::providers::resolve_api_key( + ctx.api_key.as_deref(), + CREDENTIAL_TARGET, + &[API_KEY_ENV], + )?) + .ok_or_else(|| { + ProviderError::NotInstalled( + "Missing IBM Bob API key. Add one in Settings or set BOBSHELL_API_KEY.".into(), + ) + })?; + let client = self.client.as_ref().ok_or_else(|| { + ProviderError::Other("Could not create a secure IBM Bob HTTP client.".into()) + })?; + let timeout = Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT); + + let profile_url = format!("{}/admin/v1/profile", self.base_url); + let body = self + .get(client, &profile_url, &token, None, timeout) + .await?; + let profile = model::parse_profile(&body)?; + + let mut teams = Vec::new(); + for instance in &profile.instances { + let Some(user_id) = instance.user_id.as_deref().filter(|id| !id.is_empty()) else { + continue; + }; + let base = self.regional_base(instance.region_domain.as_deref())?; + for team in &instance.teams { + if team.id.is_empty() { + continue; + } + let url = format!( + "{base}/admin/v1/teams/{}/users/{}", + path_segment(&team.id), + path_segment(user_id) + ); + let body = self + .get( + client, + &url, + &token, + Some((&instance.instance_id, &team.id)), + timeout, + ) + .await?; + let budget = model::parse_team_budget(&body)?; + teams.push(model::team_usage(instance, team, &budget)); + } + } + if teams.is_empty() { + return Err(ProviderError::Other( + "IBM Bob returned no subscription instances or teams for this API key.".into(), + )); + } + Ok(model::build_result(&teams)) + } +} + +impl Default for IBMBobProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for IBMBobProvider { + fn id(&self) -> ProviderId { + ProviderId::IBMBob + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_api(ctx).await, + source => Err(ProviderError::UnsupportedSource(source)), + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +/// Upstream `IBMBobSettingsReader.apiKey`: trim, then drop one pair of +/// matching surrounding quotes. +fn normalize_api_key(raw: &str) -> Option { + let value = raw.trim(); + let quoted = ['"', '\''] + .iter() + .any(|quote| value.starts_with(*quote) && value.ends_with(*quote)); + let value = if quoted { + // A lone quote character unwraps to nothing, like upstream. + value.get(1..value.len() - 1).unwrap_or("").trim() + } else { + value + }; + (!value.is_empty()).then(|| value.to_owned()) +} + +/// Bob Shell's formats: `Bearer` for a JWT session token, else `Apikey`. +fn authorization_value(token: &str) -> String { + if is_jwt(token) { + format!("Bearer {token}") + } else { + format!("Apikey {token}") + } +} + +/// Three dot-separated parts whose middle part is base64url JSON object. +fn is_jwt(token: &str) -> bool { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return false; + } + let mut payload = parts[1].replace('-', "+").replace('_', "/"); + payload.push_str(&"=".repeat((4 - payload.len() % 4) % 4)); + base64::engine::general_purpose::STANDARD + .decode(payload) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|value| value.is_object()) +} + +/// Validate the instance's `region_domain`. `None` selects the fixed host. +/// +/// The host gets an `api.` prefix when missing and must be `bob.ibm.com` or +/// a subdomain of it, reachable over HTTPS with no port, path, credentials, +/// query or fragment. +fn regional_base_url(region_domain: Option<&str>) -> Result, ProviderError> { + let Some(domain) = model::non_empty(region_domain) else { + return Ok(None); + }; + let host = if domain.to_ascii_lowercase().starts_with("api.") { + domain.to_owned() + } else { + format!("api.{domain}") + }; + let untrusted = || { + let shown: String = host + .chars() + .filter(|c| !c.is_control()) + .take(MAX_ECHOED_HOST_CHARS) + .collect(); + ProviderError::Other(format!( + "IBM Bob returned an untrusted regional API host: {shown}." + )) + }; + // Plain host names only: anything that could carry a port, path, + // credentials, query or fragment is rejected before parsing. + if !host + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') + { + return Err(untrusted()); + } + let url = Url::parse(&format!("https://{host}")).map_err(|_| untrusted())?; + let trusted = url.scheme() == "https" + && url.port().is_none() + && url.path() == "/" + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + && url + .host_str() + .is_some_and(|host| host == "bob.ibm.com" || host.ends_with(".bob.ibm.com")); + if trusted { + Ok(Some(url)) + } else { + Err(untrusted()) + } +} + +/// Percent-encode one path segment of an id returned by the API. +fn path_segment(value: &str) -> String { + let mut url = Url::parse("https://bob.ibm.com/").expect("static URL"); + url.path_segments_mut() + .expect("https URL has path segments") + .push(value); + url.path().trim_start_matches('/').to_owned() +} + +/// Classify a non-success status without reading or echoing the body. +fn check_status(status: StatusCode) -> Result<(), ProviderError> { + match status { + _ if status.is_success() => Ok(()), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ProviderError::Other( + "IBM Bob rejected the API key. Check that it is active and can read subscription usage." + .into(), + )), + _ => Err(ProviderError::Other(format!( + "IBM Bob API returned HTTP {}.", + status.as_u16() + ))), + } +} diff --git a/rust/src/providers/ibmbob/model.rs b/rust/src/providers/ibmbob/model.rs new file mode 100644 index 0000000000..5db43bdbeb --- /dev/null +++ b/rust/src/providers/ibmbob/model.rs @@ -0,0 +1,216 @@ +//! IBM Bob profile and team-budget payloads, and the card presentation. +//! +//! Ported from upstream CodexBar v0.73.0 `IBMBobUsageFetcher.swift` +//! (`IBMBobProfileResponse`, `IBMBobTeamBudgetResponse`, +//! `IBMBobUsageSnapshot.toUsageSnapshot`). + +use chrono::{DateTime, TimeZone, Utc}; +use serde::Deserialize; + +use crate::core::{ + MONTHLY_WINDOW_MINUTES, ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, + UsageSnapshot, +}; + +const DETAIL_SECTION: &str = "Bobcoin usage"; + +#[derive(Debug, Deserialize)] +pub(super) struct Profile { + pub instances: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Instance { + pub instance_id: String, + #[serde(default)] + instance_name: Option, + /// Older payloads name the instance `name`. + #[serde(default, rename = "name")] + legacy_name: Option, + #[serde(default)] + pub user_id: Option, + #[serde(default)] + pub plan_name: Option, + #[serde(default)] + pub refresh_at: Option, + #[serde(default)] + pub region_domain: Option, + pub teams: Vec, +} + +impl Instance { + /// Upstream `instanceName ?? legacyName`: an empty `instance_name` does + /// not fall back to `name`. + pub fn name(&self) -> Option<&str> { + self.instance_name + .as_deref() + .or(self.legacy_name.as_deref()) + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct Team { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub budget_limit: Option, +} + +/// `refresh_at` is Unix seconds or an ISO-8601 string. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub(super) enum RefreshAt { + Seconds(f64), + Text(String), +} + +#[derive(Debug, Deserialize)] +pub(super) struct TeamBudget { + pub usage: f64, + #[serde(default)] + pub budget_limit: Option, +} + +/// One visible team's monthly Bobcoin usage. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct TeamUsage { + pub instance_name: String, + pub team_name: String, + pub plan_name: Option, + pub used: f64, + pub limit: Option, + pub resets_at: Option>, +} + +fn parse_failure(detail: &str) -> ProviderError { + ProviderError::Parse(format!("Could not parse IBM Bob usage: {detail}")) +} + +/// Decode the profile without echoing any of the body in the error. +pub(super) fn parse_profile(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|_| parse_failure("profile response is invalid")) +} + +pub(super) fn parse_team_budget(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|_| parse_failure("team budget response is invalid")) +} + +pub(super) fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +#[allow( + clippy::cast_possible_truncation, + reason = "positive finite seconds; `as` saturates and chrono rejects out-of-range instants" +)] +pub(super) fn parse_refresh_at(value: Option<&RefreshAt>) -> Option> { + match value? { + RefreshAt::Seconds(seconds) if seconds.is_finite() && *seconds > 0.0 => { + let whole = seconds.trunc(); + let nanos = ((seconds - whole) * 1e9).round() as u32; + Utc.timestamp_opt(whole as i64, nanos.min(999_999_999)) + .single() + } + RefreshAt::Seconds(_) => None, + RefreshAt::Text(text) => DateTime::parse_from_rfc3339(non_empty(Some(text))?) + .ok() + .map(|date| date.with_timezone(&Utc)), + } +} + +/// Combine one team's profile entry and budget response. +pub(super) fn team_usage(instance: &Instance, team: &Team, budget: &TeamBudget) -> TeamUsage { + // Upstream `(budget.budgetLimit ?? team.budgetLimit)`, then drops a + // negative limit (it does not fall back to the profile value). + let limit = budget + .budget_limit + .or(team.budget_limit) + .filter(|limit| *limit >= 0.0); + TeamUsage { + instance_name: non_empty(instance.name()) + .unwrap_or(&instance.instance_id) + .to_owned(), + team_name: non_empty(team.name.as_deref()) + .unwrap_or(&team.id) + .to_owned(), + plan_name: non_empty(instance.plan_name.as_deref()).map(str::to_owned), + used: budget.usage.max(0.0), + limit, + resets_at: parse_refresh_at(instance.refresh_at.as_ref()), + } +} + +/// Upstream `bobcoins`: whole numbers without decimals, else two decimals. +pub(super) fn bobcoins(value: f64) -> String { + if value.round() == value { + format!("{value:.0}") + } else { + format!("{value:.2}") + } +} + +fn amount(used: f64, limit: Option) -> String { + match limit { + Some(limit) => format!("{} / {} Bobcoins", bobcoins(used), bobcoins(limit)), + None => format!("{} Bobcoins used", bobcoins(used)), + } +} + +pub(super) fn build_result(teams: &[TeamUsage]) -> ProviderFetchResult { + let used: f64 = teams.iter().map(|team| team.used).sum(); + // A total limit exists only when every team has one. + let limits: Vec = teams.iter().filter_map(|team| team.limit).collect(); + let limit = (!limits.is_empty() && limits.len() == teams.len()).then(|| limits.iter().sum()); + let resets_at = teams.iter().filter_map(|team| team.resets_at).min(); + let summary = amount(used, limit); + + let primary = match limit { + Some(limit) if limit > 0.0 => RateWindow::with_details( + (used / limit * 100.0).clamp(0.0, 100.0), + Some(MONTHLY_WINDOW_MINUTES), + resets_at, + Some(summary), + ), + // Upstream draws a 0% bar here; Windows shows the usage-only text + // instead of an unearned "100% left". + _ => RateWindow { + window_minutes: Some(MONTHLY_WINDOW_MINUTES), + resets_at, + ..RateWindow::informational(summary) + }, + }; + + let mut plans: Vec<&str> = teams + .iter() + .filter_map(|team| team.plan_name.as_deref()) + .collect(); + plans.sort_unstable(); + plans.dedup(); + + let mut usage = UsageSnapshot::new(primary).with_login_method("API key"); + if !plans.is_empty() { + usage = usage.with_organization(plans.join(", ")); + } + + let mut result = ProviderFetchResult::new(usage, "api"); + for (index, team) in teams.iter().enumerate() { + let title = if team.team_name == team.instance_name { + team.team_name.clone() + } else { + format!("{} · {}", team.instance_name, team.team_name) + }; + let row = ProviderDisplayDetail::new( + format!("team-{index}"), + title, + amount(team.used, team.limit), + ) + .and_then(|row| row.with_section_title(DETAIL_SECTION)); + let row = match &team.plan_name { + Some(plan) => row.and_then(|row| row.with_secondary_value(plan.clone())), + None => row, + }; + result = result.with_display_detail(row); + } + result +} diff --git a/rust/src/providers/ibmbob/tests.rs b/rust/src/providers/ibmbob/tests.rs new file mode 100644 index 0000000000..5c1139ae1a --- /dev/null +++ b/rust/src/providers/ibmbob/tests.rs @@ -0,0 +1,534 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; + +use base64::Engine; +use chrono::{TimeZone, Utc}; +use reqwest::redirect::Policy; +use serde_json::json; + +use super::model::{ + TeamUsage, bobcoins, build_result, parse_profile, parse_refresh_at, parse_team_budget, + team_usage, +}; +use super::*; + +/// The parity pack profile (`scenarios/providers/IBMBob/routes.json`) with +/// `refresh_at` pinned to 2026-10-26T12:00:00Z. +const PROFILE: &str = include_str!("fixtures/profile.json"); + +fn team(name: &str, used: f64, limit: Option) -> TeamUsage { + TeamUsage { + instance_name: "Parity Labs Bob".into(), + team_name: name.into(), + plan_name: Some("Bob Pro".into()), + used, + limit, + resets_at: None, + } +} + +fn rows(result: &ProviderFetchResult) -> Vec<(String, String, String, Option)> { + result + .display_details() + .iter() + .map(|row| { + assert_eq!(row.section_title(), Some("Bobcoin usage")); + ( + row.id().to_owned(), + row.title().to_owned(), + row.value().to_owned(), + row.secondary_value().map(str::to_owned), + ) + }) + .collect() +} + +fn pack_teams() -> Vec { + let profile = parse_profile(PROFILE.as_bytes()).expect("pack profile"); + let instance = &profile.instances[0]; + let budgets = [ + parse_team_budget(br#"{"usage": 312.5, "budget_limit": 500}"#).expect("budget"), + parse_team_budget(br#"{"usage": 96.25, "budget_limit": 250}"#).expect("budget"), + ]; + instance + .teams + .iter() + .zip(&budgets) + .map(|(team, budget)| team_usage(instance, team, budget)) + .collect() +} + +#[test] +fn pack_payload_matches_the_mac_card() { + let result = build_result(&pack_teams()); + let primary = &result.usage.primary; + // 408.75 of 750 Bobcoins: 54.5% used, "45% left" on the card. + assert!((primary.used_percent - 54.5).abs() < 1e-9); + assert!(!primary.is_informational); + assert_eq!(primary.window_minutes, Some(43_200)); + assert_eq!( + primary.resets_at, + Some(Utc.with_ymd_and_hms(2026, 10, 26, 12, 0, 0).unwrap()) + ); + assert_eq!( + primary.reset_description.as_deref(), + Some("408.75 / 750 Bobcoins") + ); + assert!(result.usage.secondary.is_none()); + assert_eq!(result.usage.login_method.as_deref(), Some("API key")); + assert_eq!( + result.usage.account_organization.as_deref(), + Some("Bob Pro") + ); + assert_eq!(result.source_label, "api"); + assert!(result.cost.is_none()); + assert_eq!( + rows(&result), + vec![ + ( + "team-0".into(), + "Parity Labs Bob · Platform".into(), + "312.50 / 500 Bobcoins".into(), + Some("Bob Pro".into()) + ), + ( + "team-1".into(), + "Parity Labs Bob · Docs".into(), + "96.25 / 250 Bobcoins".into(), + Some("Bob Pro".into()) + ), + ] + ); +} + +#[test] +fn a_team_without_a_limit_makes_the_total_usage_only() { + let result = build_result(&[ + team("Platform", 312.5, Some(500.0)), + team("Docs", 96.0, None), + ]); + let primary = &result.usage.primary; + assert!(primary.is_informational); + assert_eq!(primary.window_minutes, Some(43_200)); + assert_eq!( + primary.reset_description.as_deref(), + Some("408.50 Bobcoins used") + ); + assert_eq!(rows(&result)[1].2, "96 Bobcoins used"); +} + +#[test] +fn a_zero_total_limit_is_usage_only_and_overspend_clamps() { + let zero = build_result(&[team("Platform", 5.0, Some(0.0))]); + assert!(zero.usage.primary.is_informational); + assert_eq!( + zero.usage.primary.reset_description.as_deref(), + Some("5 / 0 Bobcoins") + ); + + let over = build_result(&[team("Platform", 900.0, Some(500.0))]); + assert_eq!(over.usage.primary.used_percent, 100.0); +} + +#[test] +fn rows_and_plans_follow_upstream_naming() { + let mut same = team("Parity Labs Bob", 1.0, Some(2.0)); + same.plan_name = None; + let mut other_plan = team("Docs", 1.0, Some(2.0)); + other_plan.plan_name = Some("Bob Enterprise".into()); + let result = build_result(&[same, other_plan, team("Platform", 1.0, Some(2.0))]); + + let rows = rows(&result); + assert_eq!(rows[0].1, "Parity Labs Bob"); + assert_eq!(rows[0].3, None); + assert_eq!( + result.usage.account_organization.as_deref(), + Some("Bob Enterprise, Bob Pro") + ); + // The earliest refresh date wins. + let early = Utc.with_ymd_and_hms(2026, 10, 20, 0, 0, 0).unwrap(); + let mut a = team("A", 1.0, Some(2.0)); + a.resets_at = Some(Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap()); + let mut b = team("B", 1.0, Some(2.0)); + b.resets_at = Some(early); + assert_eq!(build_result(&[a, b]).usage.primary.resets_at, Some(early)); +} + +#[test] +fn team_usage_applies_upstream_fallbacks() { + let profile = parse_profile( + json!({"instances": [ + {"instance_id": "inst-1", "instance_name": " ", "name": "Legacy", + "user_id": "u", "plan_name": " ", "teams": [{"id": "team-1", "budget_limit": 40}]}, + {"instance_id": "inst-2", "name": "Legacy Two", "user_id": "u", + "teams": [{"id": "team-2", "name": "Docs"}]} + ]}) + .to_string() + .as_bytes(), + ) + .expect("profile"); + let first = &profile.instances[0]; + let second = &profile.instances[1]; + + // An empty instance_name does not fall back to the legacy name. + let usage = team_usage( + first, + &first.teams[0], + &parse_team_budget(br#"{"usage": -3}"#).unwrap(), + ); + assert_eq!(usage.instance_name, "inst-1"); + assert_eq!(usage.team_name, "team-1"); + assert_eq!(usage.plan_name, None); + assert_eq!(usage.used, 0.0); + assert_eq!(usage.limit, Some(40.0), "profile limit fills a missing one"); + + // A missing instance_name uses the legacy name. + let usage = team_usage( + second, + &second.teams[0], + &parse_team_budget(br#"{"usage": 2, "budget_limit": -1}"#).unwrap(), + ); + assert_eq!(usage.instance_name, "Legacy Two"); + assert_eq!(usage.team_name, "Docs"); + assert_eq!(usage.limit, None, "a negative limit is dropped"); +} + +#[test] +fn refresh_at_accepts_seconds_and_iso_strings() { + let parse = |value: serde_json::Value| { + let profile = parse_profile( + json!({"instances": [{"instance_id": "i", "refresh_at": value, "teams": []}]}) + .to_string() + .as_bytes(), + ) + .expect("profile"); + parse_refresh_at(profile.instances[0].refresh_at.as_ref()) + }; + let expected = Utc.with_ymd_and_hms(2026, 10, 26, 12, 0, 0).unwrap(); + assert_eq!(parse(json!(1_793_016_000)), Some(expected)); + assert_eq!( + parse(json!(1_793_016_000.5)), + Some(expected + chrono::Duration::milliseconds(500)) + ); + assert_eq!(parse(json!("2026-10-26T12:00:00Z")), Some(expected)); + assert_eq!( + parse(json!(" 2026-10-26T14:00:00.000+02:00 ")), + Some(expected) + ); + assert_eq!(parse(json!("next month")), None); + assert_eq!(parse(json!("")), None); + assert_eq!(parse(json!(0)), None); + assert_eq!(parse(json!(-5)), None); + assert_eq!(parse(json!(null)), None); +} + +#[test] +fn malformed_payloads_fail_without_echoing_the_body() { + for body in [ + "not json private-body", + r#"{"instances": "private-body"}"#, + r#"{"instances": [{"instance_id": 7, "teams": []}]}"#, + r#"{"instances": [{"instance_id": "i", "refresh_at": true, "teams": []}]}"#, + r#"{"instances": [{"instance_id": "i", "teams": [{"name": "private-body"}]}]}"#, + ] { + let error = parse_profile(body.as_bytes()).expect_err(body); + assert_eq!( + error.to_string(), + "Parse error: Could not parse IBM Bob usage: profile response is invalid" + ); + } + for body in [r#"{"budget_limit": 5}"#, r#"{"usage": "private-body"}"#] { + assert_eq!( + parse_team_budget(body.as_bytes()).unwrap_err().to_string(), + "Parse error: Could not parse IBM Bob usage: team budget response is invalid" + ); + } +} + +#[test] +fn bobcoins_format_like_upstream() { + assert_eq!(bobcoins(500.0), "500"); + assert_eq!(bobcoins(312.5), "312.50"); + assert_eq!(bobcoins(96.25), "96.25"); + assert_eq!(bobcoins(0.004), "0.00"); +} + +fn jwt(payload: &str) -> String { + let encode = |text: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(text); + format!("{}.{}.sig", encode(r#"{"alg":"none"}"#), encode(payload)) +} + +#[test] +fn jwt_session_tokens_use_bearer_and_api_keys_use_apikey() { + let session = jwt(r#"{"sub":"parity-user","exp":1792929600}"#); + assert_eq!(authorization_value(&session), format!("Bearer {session}")); + assert_eq!( + authorization_value("sk-parity-synthetic-0001"), + "Apikey sk-parity-synthetic-0001" + ); + assert!(!is_jwt(&jwt("[1,2]")), "payload must be a JSON object"); + assert!(!is_jwt("a.not-base64!.c")); + assert!(!is_jwt("only.two")); + assert!(!is_jwt(&format!("{session}.extra"))); +} + +#[test] +fn api_keys_are_trimmed_and_unquoted() { + assert_eq!(normalize_api_key(" key ").as_deref(), Some("key")); + assert_eq!(normalize_api_key("\" key \"").as_deref(), Some("key")); + assert_eq!(normalize_api_key("'key'").as_deref(), Some("key")); + assert_eq!(normalize_api_key("\"key'").as_deref(), Some("\"key'")); + assert_eq!(normalize_api_key("\""), None); + assert_eq!(normalize_api_key("''"), None); + assert_eq!(normalize_api_key(" "), None); +} + +#[test] +fn regional_hosts_must_be_https_under_bob_ibm_com() { + let host = |domain: &str| { + regional_base_url(Some(domain)) + .expect(domain) + .map(|url| url.to_string()) + }; + assert_eq!(regional_base_url(None).unwrap(), None); + assert_eq!(regional_base_url(Some(" ")).unwrap(), None); + assert_eq!( + host("us-east.bob.ibm.com").as_deref(), + Some("https://api.us-east.bob.ibm.com/") + ); + assert_eq!( + host("API.eu-de.bob.ibm.com").as_deref(), + Some("https://api.eu-de.bob.ibm.com/") + ); + assert_eq!( + host("bob.ibm.com").as_deref(), + Some("https://api.bob.ibm.com/") + ); + + for (domain, shown) in [ + ("evil.example.com", "api.evil.example.com"), + ("evilbob.ibm.com", "api.evilbob.ibm.com"), + ("bob.ibm.com.evil.com", "api.bob.ibm.com.evil.com"), + ("us-east.bob.ibm.com:8443", "api.us-east.bob.ibm.com:8443"), + ("us-east.bob.ibm.com/admin", "api.us-east.bob.ibm.com/admin"), + ("user@us-east.bob.ibm.com", "api.user@us-east.bob.ibm.com"), + ("us-east.bob.ibm.com?x=1", "api.us-east.bob.ibm.com?x=1"), + ("us-east.bob.ibm.com.", "api.us-east.bob.ibm.com."), + ] { + assert_eq!( + regional_base_url(Some(domain)).unwrap_err().to_string(), + format!("IBM Bob returned an untrusted regional API host: {shown}."), + "{domain}" + ); + } +} + +#[test] +fn path_segments_are_percent_encoded() { + assert_eq!(path_segment("team-parity-0001"), "team-parity-0001"); + assert_eq!(path_segment("a/b c"), "a%2Fb%20c"); +} + +#[test] +fn status_codes_map_to_upstream_messages() { + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + assert_eq!( + check_status(status).unwrap_err().to_string(), + "IBM Bob rejected the API key. Check that it is active and can read subscription usage." + ); + } + assert_eq!( + check_status(StatusCode::TOO_MANY_REQUESTS) + .unwrap_err() + .to_string(), + "IBM Bob API returned HTTP 429." + ); + assert!(check_status(StatusCode::OK).is_ok()); +} + +/// Serve canned responses by request path; the handle yields every request +/// (lowercased) in arrival order. +fn provider_serving( + routes: Vec<(&'static str, &'static str, String)>, + expected_requests: usize, +) -> (IBMBobProvider, std::thread::JoinHandle>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind local test server"); + let address = listener.local_addr().expect("local server address"); + let routes: HashMap<_, _> = routes + .into_iter() + .map(|(path, status, body)| (path, (status, body))) + .collect(); + let server = std::thread::spawn(move || { + let mut requests = Vec::new(); + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 8192]; + let read = stream.read(&mut request).expect("read request"); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + let path = request.split_whitespace().nth(1).unwrap_or_default(); + let (status, body) = routes + .iter() + .find(|(route, _)| route.eq_ignore_ascii_case(path)) + .map(|(_, response)| response.clone()) + .unwrap_or(("404 Not Found", String::new())); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write response"); + requests.push(request); + } + requests + }); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + let provider = IBMBobProvider::with_client(&format!("http://{address}/"), client); + (provider, server) +} + +fn keyed_context() -> FetchContext { + FetchContext { + api_key: Some("sk-parity-synthetic-0001".into()), + ..FetchContext::default() + } +} + +const TEAM_1: &str = "/admin/v1/teams/team-parity-0001/users/user-parity-0001"; +const TEAM_2: &str = "/admin/v1/teams/team-parity-0002/users/user-parity-0001"; + +#[tokio::test] +async fn fetches_the_profile_then_each_team_budget() { + let (provider, server) = provider_serving( + vec![ + ("/admin/v1/profile", "200 OK", PROFILE.to_owned()), + ( + TEAM_1, + "200 OK", + r#"{"usage": 312.5, "budget_limit": 500}"#.into(), + ), + ( + TEAM_2, + "200 OK", + r#"{"usage": 96.25, "budget_limit": 250}"#.into(), + ), + ], + 3, + ); + + let result = provider + .fetch_usage(&keyed_context()) + .await + .expect("usage fetch"); + let requests = server.join().expect("test server thread"); + + assert!( + requests[0].starts_with("get /admin/v1/profile "), + "{}", + requests[0] + ); + assert!(!requests[0].contains("x-instance-id")); + assert!( + requests[1].starts_with(&format!("get {TEAM_1} ")), + "{}", + requests[1] + ); + assert!(requests[1].contains("x-instance-id: inst-parity-0001")); + assert!(requests[1].contains("x-team-id: team-parity-0001")); + assert!( + requests[2].starts_with(&format!("get {TEAM_2} ")), + "{}", + requests[2] + ); + for request in &requests { + assert!(request.contains("authorization: apikey sk-parity-synthetic-0001")); + assert!(request.contains("user-agent: codexbar")); + assert!(request.contains("accept: application/json")); + } + assert!((result.usage.primary.used_percent - 54.5).abs() < 1e-9); + assert_eq!(rows(&result).len(), 2); +} + +#[tokio::test] +async fn an_untrusted_region_fails_before_any_team_request() { + let profile = PROFILE.replace("us-east.bob.ibm.com", "bob.evil.example.com"); + let (provider, server) = provider_serving(vec![("/admin/v1/profile", "200 OK", profile)], 1); + + let error = provider + .fetch_usage(&keyed_context()) + .await + .expect_err("untrusted region"); + assert_eq!(server.join().expect("test server thread").len(), 1); + assert_eq!( + error.to_string(), + "IBM Bob returned an untrusted regional API host: api.bob.evil.example.com." + ); +} + +#[tokio::test] +async fn no_usable_instance_or_team_is_a_no_subscription_error() { + let profile = json!({"instances": [ + {"instance_id": "no-user", "teams": [{"id": "t"}]}, + {"instance_id": "no-teams", "user_id": "u", "teams": [{"id": ""}]} + ]}) + .to_string(); + let (provider, server) = provider_serving(vec![("/admin/v1/profile", "200 OK", profile)], 1); + + let error = provider + .fetch_usage(&keyed_context()) + .await + .expect_err("no teams"); + server.join().expect("test server thread"); + assert_eq!( + error.to_string(), + "IBM Bob returned no subscription instances or teams for this API key." + ); +} + +#[tokio::test] +async fn errors_do_not_echo_the_response_body() { + for status in ["200 OK", "401 Unauthorized", "503 Service Unavailable"] { + let (provider, server) = provider_serving( + vec![("/admin/v1/profile", status, "private-response".into())], + 1, + ); + let error = provider + .fetch_usage(&keyed_context()) + .await + .expect_err(status); + server.join().expect("test server thread"); + assert!(!error.to_string().contains("private-response"), "{status}"); + } +} + +#[tokio::test] +async fn a_quoted_blank_key_is_missing() { + let provider = IBMBobProvider::new(); + let ctx = FetchContext { + api_key: Some("''".into()), + ..FetchContext::default() + }; + assert_eq!( + provider.fetch_usage(&ctx).await.unwrap_err().to_string(), + "Provider not installed: Missing IBM Bob API key. Add one in Settings or set BOBSHELL_API_KEY." + ); +} + +#[tokio::test] +async fn web_and_cli_sources_are_unsupported() { + let provider = IBMBobProvider::new(); + for source_mode in [SourceMode::Web, SourceMode::Cli] { + let ctx = FetchContext { + source_mode, + ..keyed_context() + }; + assert!(matches!( + provider.fetch_usage(&ctx).await, + Err(ProviderError::UnsupportedSource(_)) + )); + } +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 78f57c45a6..2e2124720b 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -48,6 +48,7 @@ pub mod groq; pub mod helmcode; pub mod huggingface; pub mod hyper; +pub mod ibmbob; pub mod infini; pub mod jetbrains; pub mod kilo; @@ -140,6 +141,7 @@ pub use groq::GroqProvider; pub use helmcode::HelmcodeProvider; pub use huggingface::HuggingFaceProvider; pub use hyper::HyperProvider; +pub use ibmbob::IBMBobProvider; pub use infini::InfiniProvider; pub use jetbrains::JetBrainsProvider; pub use kilo::KiloProvider; diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 3ea5048ca4..10db0bcf77 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -751,6 +751,17 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://dash.aixy-gateway.com"), }, + ProviderConfigInfo { + id: ProviderId::IBMBob, + name: "IBM Bob", + requires_api_key: true, + api_key_env_var: Some(crate::providers::ibmbob::API_KEY_ENV), + api_key_help: Some( + "Create an API key in the IBM Bob web portal and save it here, or set BOBSHELL_API_KEY.", + ), + config_file_path: None, + dashboard_url: Some("https://bob.ibm.com"), + }, ] } From 0a1489821b10d4c1efdc016a5091514c2a10553c Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:42:36 +0700 Subject: [PATCH 04/14] Add the Langdock provider --- README.md | 1 + .../providers/icons/ProviderIcon-langdock.svg | 4 + .../src/components/providers/providerIcons.ts | 3 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 16 + rust/src/core/provider.rs | 10 +- rust/src/core/provider_factory.rs | 2 + rust/src/core/token_accounts.rs | 3 +- rust/src/providers/langdock/mod.rs | 200 +++++++++ rust/src/providers/langdock/model.rs | 136 ++++++ rust/src/providers/langdock/tests.rs | 397 ++++++++++++++++++ rust/src/providers/mod.rs | 2 + 12 files changed, 773 insertions(+), 2 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-langdock.svg create mode 100644 rust/src/providers/langdock/mod.rs create mode 100644 rust/src/providers/langdock/model.rs create mode 100644 rust/src/providers/langdock/tests.rs diff --git a/README.md b/README.md index e30de60432..58b222cfee 100755 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | llmman | Local daemon / optional API Key | Memory in use, loaded and stored models | | DevPass | API Key | Plan credits, Premium weekly, API-key spend | | xKiro | API Key | Daily free tokens | +| Langdock | Browser cookies / manual Cookie header | Session (5h), Weekly | diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-langdock.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-langdock.svg new file mode 100644 index 0000000000..6d80ebd4cc --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-langdock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 082672f4d2..0b55683c83 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -39,6 +39,7 @@ import huggingface from "./icons/ProviderIcon-huggingface.svg?raw"; import jetbrains from "./icons/ProviderIcon-jetbrains.svg?raw"; import kilo from "./icons/ProviderIcon-kilo.svg?raw"; import kimi from "./icons/ProviderIcon-kimi.svg?raw"; +import langdock from "./icons/ProviderIcon-langdock.svg?raw"; import kiro from "./icons/ProviderIcon-kiro.svg?raw"; import llmman from "./icons/ProviderIcon-llmman.svg?raw"; import llmproxy from "./icons/ProviderIcon-llmproxy.svg?raw"; @@ -133,6 +134,7 @@ const RAW: Record = { jetbrains: tint(jetbrains), kilo: tint(kilo), kimi: tint(kimi), + langdock: tint(langdock), kiro: tint(kiro), llmman: tint(llmman), llmproxy: tint(llmproxy), @@ -250,6 +252,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { sub2api: { id: "sub2api", brandColor: "#14b8a6", fallbackLetter: "S", svgPath: RAW.sub2api }, venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, + langdock: { id: "langdock", brandColor: "#5A4AE7", fallbackLetter: "L", svgPath: RAW.langdock }, openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" }, chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" }, litellm: { id: "litellm", brandColor: "#0ea5e9", fallbackLetter: "L" }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index c2d33f3719..01c7c349d5 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -86,4 +86,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["devpass", "DevPass"], ["xkiro", "xKiro"], ["raycast", "Raycast"], + ["langdock", "Langdock"], ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 0cf80274a7..e9b9354f6e 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -112,6 +112,22 @@ the bearer key are never sent together. Rate limits, server errors and malformed balances are final and do not fall back. Upstream's multiple API-key token accounts are not ported yet. +### Langdock personal usage + +Langdock is disabled by default and has no API key. It reads the signed-in web +app: `GET https://app.langdock.com/api/trpc/usageSettings.getPersonalUsage` +(tRPC batch input) with the `auth_token` session cookie. + +- The cookie source picks the session: Automatic imports the `langdock.com` + cookies from the selected browser, Manual uses a pasted Cookie header, and + Off reads nothing. A header without `auth_token` never sends a request. +- The card shows Session (5 hours) and Weekly percentages with their resets. + A plan without session limits shows the weekly lane alone, labelled Weekly. + A workspace without included limits shows one "Included limits" detail row. +- Upstream keeps usage above 100%; the Windows bar clamps it at 100%. +- 401 and tRPC `UNAUTHORIZED` are reported as an expired session. Redirects + are never followed and response bodies are never echoed in errors. + ### llmman daemon llmman reads a local (or LAN) llmman daemon: `GET {base}/llmman/node` for the diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 7a4aba44da..642996d9db 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -103,6 +103,7 @@ pub enum ProviderId { XKiro, Raycast, Vercel, + Langdock, } impl ProviderId { @@ -198,6 +199,7 @@ impl ProviderId { ProviderId::XKiro, ProviderId::Raycast, ProviderId::Vercel, + ProviderId::Langdock, ] } @@ -254,6 +256,7 @@ impl ProviderId { ProviderId::XKiro => "xkiro", ProviderId::Raycast => "raycast", ProviderId::Vercel => "vercel", + ProviderId::Langdock => "langdock", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -350,6 +353,7 @@ impl ProviderId { ProviderId::XKiro => "xKiro", ProviderId::Raycast => "Raycast", ProviderId::Vercel => "Vercel AI Gateway", + ProviderId::Langdock => "Langdock", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -462,6 +466,7 @@ impl ProviderId { ProviderId::DevPass => None, ProviderId::XKiro => None, ProviderId::Vercel => None, + ProviderId::Langdock => Some("langdock.com"), ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -614,6 +619,7 @@ impl ProviderId { "replicate" | "r8" => Some(ProviderId::Replicate), "atlascloud" | "atlas-cloud" | "atlas cloud" => Some(ProviderId::AtlasCloud), "raycast" | "raycast-ai" => Some(ProviderId::Raycast), + "langdock" => Some(ProviderId::Langdock), _ => None, } } @@ -1305,6 +1311,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Raycast => "#FF6363", // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. ProviderId::Vercel => "#737373", + ProviderId::Langdock => "#5A4AE7", } } @@ -1319,7 +1326,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 89); + assert_eq!(all.len(), 90); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1389,6 +1396,7 @@ mod tests { assert!(all.contains(&ProviderId::XKiro)); assert!(all.contains(&ProviderId::Raycast)); assert!(all.contains(&ProviderId::Vercel)); + assert!(all.contains(&ProviderId::Langdock)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 1be54fc0c6..80951ef30f 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -7,6 +7,7 @@ use super::{Provider, ProviderId}; use crate::providers::AtlasCloudProvider; +use crate::providers::LangdockProvider; use crate::providers::{ AbacusProvider, AiAndProvider, AixyProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, AugmentProvider, AzureOpenAIProvider, BedrockProvider, @@ -41,6 +42,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Copilot => Box::new(CopilotProvider::new()), ProviderId::Antigravity => Box::new(AntigravityProvider::new()), ProviderId::AtlasCloud => Box::new(AtlasCloudProvider::new()), + ProviderId::Langdock => Box::new(LangdockProvider::new()), ProviderId::Factory => Box::new(FactoryProvider::new()), ProviderId::Zai => Box::new(ZaiProvider::new()), ProviderId::Kiro => Box::new(KiroProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index bae251693b..100ac4d5ac 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -431,7 +431,8 @@ impl TokenAccountSupport { | ProviderId::DevPass | ProviderId::XKiro | ProviderId::Raycast - | ProviderId::Vercel => None, + | ProviderId::Vercel + | ProviderId::Langdock => None, } } diff --git a/rust/src/providers/langdock/mod.rs b/rust/src/providers/langdock/mod.rs new file mode 100644 index 0000000000..a743d3b9ad --- /dev/null +++ b/rust/src/providers/langdock/mod.rs @@ -0,0 +1,200 @@ +//! Langdock personal usage from the signed-in web app. +//! +//! Ported from upstream CodexBar v0.73.0 (`Resources/Plugins/langdock.js`, +//! `Providers/Langdock/LangdockProviderDescriptor.swift`). Langdock has no +//! API key: the session cookie `auth_token` from app.langdock.com reads the +//! tRPC `usageSettings.getPersonalUsage` procedure. Upstream reads one Edge +//! profile chosen in Settings; Windows uses the shell's explicit browser +//! selection or a pasted Cookie header instead. Response bodies are never +//! echoed in errors. + +mod model; +#[cfg(test)] +mod tests; + +use std::time::Duration; + +use async_trait::async_trait; +use reqwest::{ + Client, StatusCode, + header::{ACCEPT, COOKIE, REFERER}, +}; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + ProviderStateKind, SourceMode, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const BASE_URL: &str = "https://app.langdock.com"; +const USAGE_PATH: &str = "/api/trpc/usageSettings.getPersonalUsage"; +/// `encodeURIComponent(JSON.stringify({0: {json: null, meta: {values: +/// ["undefined"], v: 1}}}))`, the batch input upstream sends. +const USAGE_INPUT: &str = "%7B%220%22%3A%7B%22json%22%3Anull%2C%22meta%22%3A%7B%22values%22%3A%5B%22undefined%22%5D%2C%22v%22%3A1%7D%7D%7D"; +const USAGE_REFERER: &str = "https://app.langdock.com/settings/account/usage"; +const DASHBOARD_URL: &str = "https://app.langdock.com/settings/account/usage"; +/// Upstream cookie domains, in order: the parent domain also covers cookies +/// set on app.langdock.com. +const COOKIE_DOMAINS: &[&str] = &["langdock.com", "app.langdock.com"]; +const SESSION_COOKIE: &str = "auth_token"; +const SIGN_IN_URL: &str = "https://app.langdock.com"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_RESPONSE_BYTES: usize = 512 * 1024; + +const SESSION_EXPIRED: &str = "The selected browser profile is no longer signed in to Langdock."; +const MISSING_SESSION: &str = "No Langdock session was found in the selected browser. Sign in at app.langdock.com or paste a Cookie header in Settings."; +const COOKIES_OFF: &str = "Langdock reads usage from a browser session. Turn on browser cookies or paste a Cookie header in Settings."; + +pub struct LangdockProvider { + metadata: ProviderMetadata, + client: Option, + base_url: String, +} + +impl LangdockProvider { + 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::with_parts(BASE_URL, client) + } + + #[cfg(test)] + fn with_client(base_url: &str, client: Client) -> Self { + Self::with_parts(base_url.trim_end_matches('/'), Some(client)) + } + + fn with_parts(base_url: &str, client: Option) -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Langdock, + display_name: "Langdock", + session_label: "Session", + weekly_label: "Weekly", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some(DASHBOARD_URL), + status_page_url: None, + tertiary_label_key: None, + }, + client, + base_url: base_url.to_owned(), + } + } + + async fn fetch_web(&self, ctx: &FetchContext) -> Result { + let cookie = session_cookie(ctx)?; + let client = self.client.as_ref().ok_or_else(|| { + ProviderError::Other("Could not create a secure Langdock HTTP client.".into()) + })?; + let timeout = Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT); + let url = format!("{}{USAGE_PATH}?batch=1&input={USAGE_INPUT}", self.base_url); + let response = client + .get(url) + .header(ACCEPT, "application/json") + .header(REFERER, USAGE_REFERER) + .header(COOKIE, cookie) + .timeout(timeout) + .send() + .await?; + check_status(response.status())?; + let body = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::TooLarge => model::unexpected_response(), + BoundedBodyError::Read(error) => ProviderError::Network(error), + })?; + model::parse_personal_usage(&body) + } +} + +impl Default for LangdockProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for LangdockProvider { + fn id(&self) -> ProviderId { + ProviderId::Langdock + } + + 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).await, + // The shell maps an Off cookie source to Cli for web-only + // providers, so explain the setting instead of the source mode. + 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 + } + + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + match error { + // Upstream classifies a 401 and tRPC UNAUTHORIZED as + // `authenticationExpired`. + ProviderError::Other(message) if message == SESSION_EXPIRED => { + ProviderStateKind::ExpiredSession + } + _ => error.state_kind(), + } + } +} + +/// The pasted or shell-resolved Cookie header, else the selected browser's +/// langdock.com cookies. Upstream requires `auth_token`. +fn session_cookie(ctx: &FetchContext) -> Result { + let header = match ctx.manual_cookie_header.as_deref() { + Some(header) => crate::providers::normalize_cookie_header(header), + None if ctx.manual_cookie_missing => None, + None => match crate::providers::browser_cookie_header(COOKIE_DOMAINS) { + Ok(header) => crate::providers::normalize_cookie_header(&header), + Err(error) => { + tracing::debug!(%error, "Langdock browser session is unavailable"); + None + } + }, + }; + header + .filter(|header| has_session_cookie(header)) + .ok_or_else(|| ProviderError::BrowserSignInRequired { + message: MISSING_SESSION.into(), + sign_in_url: SIGN_IN_URL.into(), + }) +} + +fn has_session_cookie(header: &str) -> bool { + !crate::providers::cookie_values(header, SESSION_COOKIE).is_empty() +} + +/// Upstream's HTTP status taxonomy; 2xx continues to the body. +fn check_status(status: StatusCode) -> Result<(), ProviderError> { + match status.as_u16() { + 200..=299 => Ok(()), + 401 => Err(model::denied("UNAUTHORIZED")), + 403 => Err(model::denied("FORBIDDEN")), + 429 => Err(model::denied("TOO_MANY_REQUESTS")), + 500.. => Err(model::denied("INTERNAL_SERVER_ERROR")), + code => Err(ProviderError::Other(format!( + "Langdock usage request failed with HTTP {code}." + ))), + } +} diff --git a/rust/src/providers/langdock/model.rs b/rust/src/providers/langdock/model.rs new file mode 100644 index 0000000000..756a737b63 --- /dev/null +++ b/rust/src/providers/langdock/model.rs @@ -0,0 +1,136 @@ +//! Langdock tRPC personal-usage envelope and the card presentation. +//! +//! Ported from upstream CodexBar v0.73.0 `langdock.js` (`fetchUsage`). The +//! payload is read as JSON values so a missing field and an explicit `null` +//! stay distinct, as they are in upstream's JavaScript checks. + +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value}; + +use crate::core::{ + ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot, +}; + +const SESSION_WINDOW_MINUTES: u32 = 300; +const WEEKLY_WINDOW_MINUTES: u32 = 10_080; +const NO_LIMITS: &str = "No included usage limits available"; + +/// Upstream `fail()`: one fixed message that never echoes the body. +pub(super) fn unexpected_response() -> ProviderError { + ProviderError::Parse("Langdock returned an unexpected personal usage response.".into()) +} + +/// Upstream `denied(code)`: the tRPC error code taxonomy, shared with the +/// HTTP status mapping. +pub(super) fn denied(code: &str) -> ProviderError { + ProviderError::Other( + match code { + "UNAUTHORIZED" => super::SESSION_EXPIRED, + "FORBIDDEN" => "Langdock denied access to personal usage.", + "TOO_MANY_REQUESTS" => "Langdock usage requests are rate limited.", + "INTERNAL_SERVER_ERROR" | "TIMEOUT" => { + "Langdock personal usage is temporarily unavailable." + } + _ => "Langdock rejected the personal usage request.", + } + .into(), + ) +} + +fn object(value: Option<&Value>) -> Result<&Map, ProviderError> { + value + .and_then(Value::as_object) + .ok_or_else(unexpected_response) +} + +/// A finite JSON number. Upstream keeps overage above 100%; the Windows +/// rate window clamps at display time. +fn percent(value: Option<&Value>) -> Result { + value + .and_then(Value::as_f64) + .filter(|value| value.is_finite()) + .ok_or_else(unexpected_response) +} + +/// Missing or `null` means no reset; anything else must be an ISO date. +fn reset(value: Option<&Value>) -> Result>, ProviderError> { + match value { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) => DateTime::parse_from_rfc3339(text) + .map(|date| Some(date.with_timezone(&Utc))) + .map_err(|_| unexpected_response()), + Some(_) => Err(unexpected_response()), + } +} + +pub(super) fn parse_personal_usage(body: &[u8]) -> Result { + let raw: Value = serde_json::from_slice(body).map_err(|_| unexpected_response())?; + let [envelope] = raw.as_array().map(Vec::as_slice).unwrap_or_default() else { + return Err(unexpected_response()); + }; + let envelope = object(Some(envelope))?; + if let Some(error) = envelope.get("error") { + let data = object(object(object(Some(error))?.get("json"))?.get("data"))?; + return match data.get("code").and_then(Value::as_str) { + Some(code) if !code.is_empty() => Err(denied(code)), + _ => Err(unexpected_response()), + }; + } + let payload = object(object(object(envelope.get("result"))?.get("data"))?.get("json"))?; + let has_limits = match payload.get("hasIncludedUsageLimits") { + None => None, + Some(Value::Bool(value)) => Some(*value), + Some(_) => return Err(unexpected_response()), + }; + let plan = match payload.get("planUsage") { + None | Some(Value::Null) => None, + plan => Some(plan), + }; + let Some(plan) = plan.filter(|_| has_limits != Some(false)) else { + return Ok(no_included_limits()); + }; + + let plan = object(plan)?; + let session_enabled = plan + .get("sessionUsageLimitsEnabled") + .and_then(Value::as_bool) + .ok_or_else(unexpected_response)?; + let session_reset = reset(plan.get("sessionResetsAt"))?; + let weekly_reset = reset(plan.get("weeklyResetsAt"))?; + let session = if session_enabled { + Some(RateWindow::with_details( + percent(plan.get("sessionUsagePercent"))?, + Some(SESSION_WINDOW_MINUTES), + session_reset, + None, + )) + } else { + None + }; + let weekly = RateWindow::with_details( + percent(plan.get("weeklyUsagePercent"))?, + Some(WEEKLY_WINDOW_MINUTES), + weekly_reset, + None, + ); + + let usage = match session { + Some(session) => UsageSnapshot::new(session).with_secondary(weekly), + // Upstream has no primary bar here; Windows needs a primary window, + // so the weekly lane takes that slot under its own label. + None => UsageSnapshot::new(weekly).with_primary_label("Weekly"), + }; + Ok(ProviderFetchResult::new( + usage.with_login_method("Browser session"), + "web", + )) +} + +/// Upstream returns only a "Usage" detail row and no bars. +fn no_included_limits() -> ProviderFetchResult { + let row = ProviderDisplayDetail::new("included-limits", "Included limits", NO_LIMITS) + .and_then(|row| row.with_section_title("Usage")); + let usage = UsageSnapshot::new(RateWindow::informational(NO_LIMITS)) + .with_login_method("Browser session"); + ProviderFetchResult::new(usage, "web").with_display_detail(row) +} diff --git a/rust/src/providers/langdock/tests.rs b/rust/src/providers/langdock/tests.rs new file mode 100644 index 0000000000..3d417104fd --- /dev/null +++ b/rust/src/providers/langdock/tests.rs @@ -0,0 +1,397 @@ +use std::io::{Read, Write}; +use std::net::TcpListener; + +use chrono::{TimeZone, Utc}; +use reqwest::redirect::Policy; + +use super::model::parse_personal_usage; +use super::*; + +/// Upstream `LangdockPluginTests.plan`. +const PLAN: &str = r#"{"sessionUsageLimitsEnabled":true,"sessionUsagePercent":12.5, + "sessionResetsAt":"2026-09-25T12:00:00.123Z","weeklyUsagePercent":104.2, + "weeklyResetsAt":"2026-09-28T12:00:00Z"}"#; + +fn body(plan: &str) -> String { + format!( + r#"[{{"result":{{"data":{{"json":{{"hasIncludedUsageLimits":true,"planUsage":{plan}}}}}}}}}]"# + ) +} + +fn parse(body: &str) -> Result { + parse_personal_usage(body.as_bytes()) +} + +fn rows(result: &ProviderFetchResult) -> Vec<(String, String, String, Option)> { + result + .display_details + .iter() + .map(|row| { + ( + row.id().to_owned(), + row.title().to_owned(), + row.value().to_owned(), + row.section_title().map(str::to_owned), + ) + }) + .collect() +} + +#[test] +fn the_batch_input_decodes_to_upstream_json() { + let url = reqwest::Url::parse(&format!("https://app.langdock.com/?input={USAGE_INPUT}")) + .expect("static URL"); + let input = url + .query_pairs() + .find(|(name, _)| name == "input") + .map(|(_, value)| value.into_owned()); + assert_eq!( + input.as_deref(), + Some(r#"{"0":{"json":null,"meta":{"values":["undefined"],"v":1}}}"#) + ); +} + +#[test] +fn session_and_weekly_match_the_contributor_fixture() { + let result = parse(&body(PLAN)).expect("plan usage"); + let usage = &result.usage; + assert_eq!(usage.primary.used_percent, 12.5); + assert_eq!(usage.primary.window_minutes, Some(300)); + assert_eq!( + usage.primary.resets_at, + Some( + Utc.with_ymd_and_hms(2026, 9, 25, 12, 0, 0).unwrap() + + chrono::Duration::milliseconds(123) + ) + ); + assert!(usage.primary_label.is_none()); + let weekly = usage.secondary.as_ref().expect("weekly lane"); + // Upstream keeps 104.2% overage; the shared Windows rate window clamps. + assert_eq!(weekly.used_percent, 100.0); + assert_eq!(weekly.window_minutes, Some(10_080)); + assert_eq!( + weekly.resets_at, + Some(Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap()) + ); + assert_eq!(usage.login_method.as_deref(), Some("Browser session")); + assert_eq!(result.source_label, "web"); + assert!(result.display_details.is_empty()); +} + +#[test] +fn the_parity_pack_payload_maps_to_session_42_and_weekly_63() { + let result = parse(&body( + r#"{"sessionUsageLimitsEnabled":true,"sessionUsagePercent":42, + "sessionResetsAt":"2026-10-11T12:30:00.000Z","weeklyUsagePercent":63, + "weeklyResetsAt":"2026-10-15T10:00:00.000Z"}"#, + )) + .expect("pack usage"); + assert_eq!(result.usage.primary.used_percent, 42.0); + assert_eq!( + result.usage.primary.resets_at, + Some(Utc.with_ymd_and_hms(2026, 10, 11, 12, 30, 0).unwrap()) + ); + let weekly = result.usage.secondary.as_ref().expect("weekly lane"); + assert_eq!(weekly.used_percent, 63.0); + assert_eq!( + weekly.resets_at, + Some(Utc.with_ymd_and_hms(2026, 10, 15, 10, 0, 0).unwrap()) + ); +} + +#[test] +fn weekly_only_plans_put_the_weekly_lane_first_under_its_own_label() { + let result = parse(&body( + r#"{"sessionUsageLimitsEnabled":false,"weeklyUsagePercent":0}"#, + )) + .expect("weekly only"); + let usage = &result.usage; + assert_eq!(usage.primary.used_percent, 0.0); + assert_eq!(usage.primary.window_minutes, Some(10_080)); + assert!(usage.primary.resets_at.is_none()); + assert!(!usage.primary.is_informational); + assert_eq!(usage.primary_label.as_deref(), Some("Weekly")); + assert!(usage.secondary.is_none()); +} + +#[test] +fn null_resets_stay_unknown() { + let result = parse(&body( + r#"{"sessionUsageLimitsEnabled":true,"sessionUsagePercent":0,"sessionResetsAt":null, + "weeklyUsagePercent":0,"weeklyResetsAt":null}"#, + )) + .expect("zero usage"); + assert!(result.usage.primary.resets_at.is_none()); + assert!(result.usage.secondary.as_ref().unwrap().resets_at.is_none()); +} + +#[test] +fn missing_included_limits_show_one_detail_row_and_no_bars() { + for payload in [ + body("null"), + r#"[{"result":{"data":{"json":{"hasIncludedUsageLimits":false}}}}]"#.to_owned(), + r#"[{"result":{"data":{"json":{"hasIncludedUsageLimits":false,"planUsage":{"x":1}}}}}]"# + .to_owned(), + r#"[{"result":{"data":{"json":{}}}}]"#.to_owned(), + ] { + let result = parse(&payload).expect(&payload); + assert!(result.usage.primary.is_informational, "{payload}"); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("No included usage limits available") + ); + assert!(result.usage.secondary.is_none()); + assert_eq!( + rows(&result), + vec![( + "included-limits".to_owned(), + "Included limits".to_owned(), + "No included usage limits available".to_owned(), + Some("Usage".to_owned()), + )], + "{payload}" + ); + } +} + +#[test] +fn malformed_envelopes_percentages_and_dates_fail_instead_of_fabricating_zero() { + for payload in [ + "".to_owned(), + "{}".to_owned(), + "[]".to_owned(), + "[{},{}]".to_owned(), + r#"[{"result":{"data":null}}]"#.to_owned(), + r#"[{"error":{}}]"#.to_owned(), + r#"[{"error":{"json":{"data":{"code":403}}}}]"#.to_owned(), + r#"[{"error":{"json":{"data":{"code":""}}}}]"#.to_owned(), + r#"[{"result":{"data":{"json":{"hasIncludedUsageLimits":null,"planUsage":null}}}}]"# + .to_owned(), + r#"[{"result":{"data":{"json":{"hasIncludedUsageLimits":true,"planUsage":[]}}}}]"# + .to_owned(), + body(r#"{"weeklyUsagePercent":4}"#), + body(r#"{"sessionUsageLimitsEnabled":true,"weeklyUsagePercent":4}"#), + body( + r#"{"sessionUsageLimitsEnabled":true,"sessionUsagePercent":"5","weeklyUsagePercent":4}"#, + ), + body(r#"{"sessionUsageLimitsEnabled":false,"weeklyUsagePercent":"4"}"#), + body(r#"{"sessionUsageLimitsEnabled":false}"#), + body( + r#"{"sessionUsageLimitsEnabled":false,"weeklyUsagePercent":4,"weeklyResetsAt":"tomorrow"}"#, + ), + body(r#"{"sessionUsageLimitsEnabled":false,"weeklyUsagePercent":4,"sessionResetsAt":5}"#), + ] { + let error = parse(&payload).expect_err(&payload); + assert_eq!( + error.to_string(), + "Parse error: Langdock returned an unexpected personal usage response.", + "{payload}" + ); + } +} + +#[test] +fn trpc_error_codes_map_to_upstream_messages() { + for (code, message) in [ + ( + "UNAUTHORIZED", + "The selected browser profile is no longer signed in to Langdock.", + ), + ("FORBIDDEN", "Langdock denied access to personal usage."), + ( + "TOO_MANY_REQUESTS", + "Langdock usage requests are rate limited.", + ), + ( + "INTERNAL_SERVER_ERROR", + "Langdock personal usage is temporarily unavailable.", + ), + ( + "TIMEOUT", + "Langdock personal usage is temporarily unavailable.", + ), + ( + "BAD_REQUEST", + "Langdock rejected the personal usage request.", + ), + ] { + let payload = format!(r#"[{{"error":{{"json":{{"data":{{"code":"{code}"}}}}}}}}]"#); + assert_eq!(parse(&payload).expect_err(code).to_string(), message); + } +} + +#[test] +fn http_statuses_map_to_upstream_messages() { + for (status, message) in [ + ( + 401, + "The selected browser profile is no longer signed in to Langdock.", + ), + (403, "Langdock denied access to personal usage."), + (429, "Langdock usage requests are rate limited."), + (500, "Langdock personal usage is temporarily unavailable."), + (503, "Langdock personal usage is temporarily unavailable."), + (400, "Langdock usage request failed with HTTP 400."), + (302, "Langdock usage request failed with HTTP 302."), + ] { + let status = StatusCode::from_u16(status).unwrap(); + assert_eq!(check_status(status).unwrap_err().to_string(), message); + } + assert!(check_status(StatusCode::OK).is_ok()); + assert!(check_status(StatusCode::NO_CONTENT).is_ok()); +} + +#[test] +fn an_expired_session_is_classified_as_expired() { + let provider = LangdockProvider::new(); + assert_eq!( + provider.error_state_kind(&model::denied("UNAUTHORIZED")), + ProviderStateKind::ExpiredSession + ); + assert_eq!( + provider.error_state_kind(&model::denied("FORBIDDEN")), + ProviderStateKind::Unknown + ); +} + +#[test] +fn metadata_matches_the_upstream_descriptor() { + let provider = LangdockProvider::new(); + let metadata = provider.metadata(); + assert_eq!(metadata.display_name, "Langdock"); + assert_eq!(metadata.session_label, "Session"); + assert_eq!(metadata.weekly_label, "Weekly"); + assert_eq!( + metadata.dashboard_url, + Some("https://app.langdock.com/settings/account/usage") + ); + assert!(!metadata.default_enabled); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::Web] + ); + assert!(provider.supports_web()); +} + +/// Serve one canned response; the handle yields the lowercased request. +fn provider_serving( + status: &'static str, + body: String, +) -> (LangdockProvider, std::thread::JoinHandle) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind local test server"); + let address = listener.local_addr().expect("local server address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 8192]; + let read = stream.read(&mut request).expect("read request"); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write response"); + String::from_utf8_lossy(&request[..read]).into_owned() + }); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + ( + LangdockProvider::with_client(&format!("http://{address}/"), client), + server, + ) +} + +fn cookie_context(header: &str) -> FetchContext { + FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: Some(header.into()), + ..FetchContext::default() + } +} + +#[tokio::test] +async fn sends_the_trpc_request_with_the_session_cookie() { + let (provider, server) = provider_serving("200 OK", body(PLAN)); + let result = provider + .fetch_usage(&cookie_context("Cookie: auth_token=synthetic-account-a")) + .await + .expect("usage fetch"); + let request = server.join().expect("test server thread"); + let first_line = request.lines().next().unwrap_or_default(); + assert_eq!( + first_line, + format!("GET {USAGE_PATH}?batch=1&input={USAGE_INPUT} HTTP/1.1") + ); + let lower = request.to_ascii_lowercase(); + assert!( + lower.contains("\r\ncookie: auth_token=synthetic-account-a\r\n"), + "{request}" + ); + assert!(lower.contains("\r\naccept: application/json\r\n")); + assert!(lower.contains("\r\nreferer: https://app.langdock.com/settings/account/usage\r\n")); + assert_eq!(result.usage.primary.used_percent, 12.5); +} + +#[tokio::test] +async fn a_cookie_header_without_auth_token_never_sends_a_request() { + let provider = LangdockProvider::with_client("http://127.0.0.1:9/", Client::new()); + for header in ["session=other", " ", "auth_token="] { + let error = provider + .fetch_usage(&cookie_context(header)) + .await + .expect_err(header); + assert_eq!( + error.to_string(), + "No Langdock session was found in the selected browser. Sign in at app.langdock.com or paste a Cookie header in Settings.", + "{header}" + ); + assert_eq!(error.state_kind(), ProviderStateKind::NeedsAuthentication); + } +} + +#[tokio::test] +async fn a_manual_source_without_a_cookie_does_not_read_a_browser() { + let provider = LangdockProvider::with_client("http://127.0.0.1:9/", Client::new()); + let ctx = FetchContext { + source_mode: SourceMode::Web, + manual_cookie_missing: true, + ..FetchContext::default() + }; + let error = provider.fetch_usage(&ctx).await.expect_err("no session"); + assert!(matches!(error, ProviderError::BrowserSignInRequired { .. })); +} + +#[tokio::test] +async fn errors_do_not_echo_the_response_body() { + let (provider, server) = + provider_serving("200 OK", r#"{"secret":"synthetic-leak-0001"}"#.to_owned()); + let error = provider + .fetch_usage(&cookie_context("auth_token=synthetic-account-a")) + .await + .expect_err("not an array"); + server.join().expect("test server thread"); + assert!(!error.to_string().contains("synthetic-leak-0001")); +} + +#[tokio::test] +async fn cookies_off_and_oauth_do_not_fetch() { + let provider = LangdockProvider::with_client("http://127.0.0.1:9/", Client::new()); + let off = FetchContext { + source_mode: SourceMode::Cli, + ..FetchContext::default() + }; + assert_eq!( + provider.fetch_usage(&off).await.unwrap_err().to_string(), + "Langdock reads usage from a browser session. Turn on browser cookies or paste a Cookie header in Settings." + ); + let oauth = FetchContext { + source_mode: SourceMode::OAuth, + ..FetchContext::default() + }; + assert!(matches!( + provider.fetch_usage(&oauth).await, + Err(ProviderError::UnsupportedSource(SourceMode::OAuth)) + )); +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 78f57c45a6..db3e4a2da7 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -54,6 +54,7 @@ pub mod kilo; pub mod kimi; pub mod kimik2; pub mod kiro; +pub mod langdock; pub mod litellm; pub mod llmman; pub mod llmproxy; @@ -146,6 +147,7 @@ pub use kilo::KiloProvider; pub use kimi::{KimiProvider, KimiRegion}; pub use kimik2::KimiK2Provider; pub use kiro::KiroProvider; +pub use langdock::LangdockProvider; pub use litellm::LiteLLMProvider; pub use llmman::LLMManProvider; pub use llmproxy::LLMProxyProvider; From 58eea49c37971cf8cce7b4df64b745d6832ffa87 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:09:03 +0700 Subject: [PATCH 05/14] Add the LithosAI provider --- README.md | 1 + .../providers/icons/ProviderIcon-lithosai.svg | 1 + .../src/components/providers/providerIcons.ts | 3 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 20 + rust/src/core/provider.rs | 10 +- rust/src/core/provider_factory.rs | 2 + rust/src/core/token_accounts.rs | 3 +- rust/src/providers/lithosai/mod.rs | 271 ++++++++++ rust/src/providers/lithosai/model.rs | 264 +++++++++ rust/src/providers/lithosai/tests.rs | 502 ++++++++++++++++++ rust/src/providers/mod.rs | 2 + 12 files changed, 1078 insertions(+), 2 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-lithosai.svg create mode 100644 rust/src/providers/lithosai/mod.rs create mode 100644 rust/src/providers/lithosai/model.rs create mode 100644 rust/src/providers/lithosai/tests.rs diff --git a/README.md b/README.md index e30de60432..cbbaae6e9c 100755 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | llmman | Local daemon / optional API Key | Memory in use, loaded and stored models | | DevPass | API Key | Plan credits, Premium weekly, API-key spend | | xKiro | API Key | Daily free tokens | +| LithosAI | Browser cookies / manual Cookie header | Prepaid balance, UTC spend today and this month | diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-lithosai.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-lithosai.svg new file mode 100644 index 0000000000..326e97efaa --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-lithosai.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 082672f4d2..be26ee1de8 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -40,6 +40,7 @@ import jetbrains from "./icons/ProviderIcon-jetbrains.svg?raw"; import kilo from "./icons/ProviderIcon-kilo.svg?raw"; import kimi from "./icons/ProviderIcon-kimi.svg?raw"; import kiro from "./icons/ProviderIcon-kiro.svg?raw"; +import lithosai from "./icons/ProviderIcon-lithosai.svg?raw"; import llmman from "./icons/ProviderIcon-llmman.svg?raw"; import llmproxy from "./icons/ProviderIcon-llmproxy.svg?raw"; import manus from "./icons/ProviderIcon-manus.svg?raw"; @@ -134,6 +135,7 @@ const RAW: Record = { kilo: tint(kilo), kimi: tint(kimi), kiro: tint(kiro), + lithosai: tint(lithosai), llmman: tint(llmman), llmproxy: tint(llmproxy), manus: tint(manus), @@ -250,6 +252,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { sub2api: { id: "sub2api", brandColor: "#14b8a6", fallbackLetter: "S", svgPath: RAW.sub2api }, venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, + lithosai: { id: "lithosai", brandColor: "#6B7280", fallbackLetter: "L", svgPath: RAW.lithosai }, openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" }, chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" }, litellm: { id: "litellm", brandColor: "#0ea5e9", fallbackLetter: "L" }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index c2d33f3719..da62049a62 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -86,4 +86,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["devpass", "DevPass"], ["xkiro", "xKiro"], ["raycast", "Raycast"], + ["lithosai", "LithosAI"], ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 0cf80274a7..ee2c0797e1 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -112,6 +112,26 @@ the bearer key are never sent together. Rate limits, server errors and malformed balances are final and do not fall back. Upstream's multiple API-key token accounts are not ported yet. +### LithosAI prepaid balance + +LithosAI is disabled by default and has no billing API key. It reads the +signed-in console at `https://console.lithosai.cloud` with both session cookies, +`__Host-console_session` and `__Host-console_csrf`; the CSRF value is echoed as +`X-Console-Csrf`. A header missing either cookie never sends a request. + +- The cookie source picks the session: Automatic imports the + `console.lithosai.cloud` cookies from the selected browser, Manual uses a + pasted Cookie header, and Off reads nothing. +- Requests, in order: `GET /api/me` (active organization, email), then + `GET /api/billing` and `GET /api/billing/spend?start=YYYY-MM-01&end=YYYY-MM-DD` + (UTC month to date), both with `X-Organization-Id`. +- The card shows the prepaid USD balance (1 USD = 1,000,000,000 nanos) as a + "Prepaid credits" cost block and a **Billing** section: Balance, Payment card, + Account status, Today (UTC) and This month (UTC). There is no quota bar. +- A spend report that fails or does not parse shows "Spend: Unavailable" and + keeps the balance; a 401 anywhere reports an expired session. +- Response bodies and cookies are never echoed in errors or logs. + ### llmman daemon llmman reads a local (or LAN) llmman daemon: `GET {base}/llmman/node` for the diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 7a4aba44da..f9a71ceb9b 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -103,6 +103,7 @@ pub enum ProviderId { XKiro, Raycast, Vercel, + LithosAI, } impl ProviderId { @@ -198,6 +199,7 @@ impl ProviderId { ProviderId::XKiro, ProviderId::Raycast, ProviderId::Vercel, + ProviderId::LithosAI, ] } @@ -254,6 +256,7 @@ impl ProviderId { ProviderId::XKiro => "xkiro", ProviderId::Raycast => "raycast", ProviderId::Vercel => "vercel", + ProviderId::LithosAI => "lithosai", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -350,6 +353,7 @@ impl ProviderId { ProviderId::XKiro => "xKiro", ProviderId::Raycast => "Raycast", ProviderId::Vercel => "Vercel AI Gateway", + ProviderId::LithosAI => "LithosAI", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -462,6 +466,7 @@ impl ProviderId { ProviderId::DevPass => None, ProviderId::XKiro => None, ProviderId::Vercel => None, + ProviderId::LithosAI => Some("console.lithosai.cloud"), ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -614,6 +619,7 @@ impl ProviderId { "replicate" | "r8" => Some(ProviderId::Replicate), "atlascloud" | "atlas-cloud" | "atlas cloud" => Some(ProviderId::AtlasCloud), "raycast" | "raycast-ai" => Some(ProviderId::Raycast), + "lithosai" => Some(ProviderId::LithosAI), _ => None, } } @@ -1305,6 +1311,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Raycast => "#FF6363", // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. ProviderId::Vercel => "#737373", + ProviderId::LithosAI => "#6B7280", } } @@ -1319,7 +1326,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 89); + assert_eq!(all.len(), 90); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1389,6 +1396,7 @@ mod tests { assert!(all.contains(&ProviderId::XKiro)); assert!(all.contains(&ProviderId::Raycast)); assert!(all.contains(&ProviderId::Vercel)); + assert!(all.contains(&ProviderId::LithosAI)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 1be54fc0c6..709d3bcb21 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -7,6 +7,7 @@ use super::{Provider, ProviderId}; use crate::providers::AtlasCloudProvider; +use crate::providers::LithosAIProvider; use crate::providers::{ AbacusProvider, AiAndProvider, AixyProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, AugmentProvider, AzureOpenAIProvider, BedrockProvider, @@ -41,6 +42,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Copilot => Box::new(CopilotProvider::new()), ProviderId::Antigravity => Box::new(AntigravityProvider::new()), ProviderId::AtlasCloud => Box::new(AtlasCloudProvider::new()), + ProviderId::LithosAI => Box::new(LithosAIProvider::new()), ProviderId::Factory => Box::new(FactoryProvider::new()), ProviderId::Zai => Box::new(ZaiProvider::new()), ProviderId::Kiro => Box::new(KiroProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index bae251693b..558ccc7513 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -431,7 +431,8 @@ impl TokenAccountSupport { | ProviderId::DevPass | ProviderId::XKiro | ProviderId::Raycast - | ProviderId::Vercel => None, + | ProviderId::Vercel + | ProviderId::LithosAI => None, } } diff --git a/rust/src/providers/lithosai/mod.rs b/rust/src/providers/lithosai/mod.rs new file mode 100644 index 0000000000..3d388497ce --- /dev/null +++ b/rust/src/providers/lithosai/mod.rs @@ -0,0 +1,271 @@ +//! LithosAI prepaid balance from the signed-in console. +//! +//! Ported from upstream CodexBar v0.73.0 (`Resources/Plugins/lithosai.ts`, +//! `Providers/LithosAI/LithosAIProviderDescriptor.swift`). LithosAI has no API +//! key for billing: the console session cookies `__Host-console_session` and +//! `__Host-console_csrf` read `/api/me`, `/api/billing` and the optional +//! `/api/billing/spend`. The CSRF cookie value is echoed as `X-Console-Csrf`. +//! Upstream imports only Chrome; Windows uses the shell's explicit browser +//! selection or a pasted Cookie header. Response bodies are never echoed in +//! errors and the cookies are never logged. + +mod model; +#[cfg(test)] +mod tests; + +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use reqwest::{ + Client, StatusCode, + header::{ACCEPT, COOKIE}, +}; +use serde_json::{Map, Value}; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + ProviderStateKind, SourceMode, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const BASE_URL: &str = "https://console.lithosai.cloud"; +const COOKIE_DOMAIN: &str = "console.lithosai.cloud"; +const SESSION_COOKIE: &str = "__Host-console_session"; +const CSRF_COOKIE: &str = "__Host-console_csrf"; +const CSRF_HEADER: &str = "X-Console-Csrf"; +const ORGANIZATION_HEADER: &str = "X-Organization-Id"; +const SIGN_IN_URL: &str = "https://console.lithosai.cloud"; +/// Upstream's fixed web timeout. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_RESPONSE_BYTES: usize = 512 * 1024; + +/// Upstream's message after every offered session answered 401. Windows has +/// one session per refresh, so a 401 always ends here. +const SESSION_EXPIRED: &str = "LithosAI session expired. Sign in again or paste fresh cookies."; +const MISSING_SESSION: &str = "Sign in to console.lithosai.cloud in the selected browser, or paste its Cookie header with __Host-console_session and __Host-console_csrf."; +const COOKIES_OFF: &str = "LithosAI cookies are disabled."; + +pub struct LithosAIProvider { + metadata: ProviderMetadata, + client: Option, + base_url: String, +} + +impl LithosAIProvider { + 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::with_parts(BASE_URL, client) + } + + #[cfg(test)] + fn with_client(base_url: &str, client: Client) -> Self { + Self::with_parts(base_url.trim_end_matches('/'), Some(client)) + } + + fn with_parts(base_url: &str, client: Option) -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::LithosAI, + display_name: "LithosAI", + session_label: "Balance", + weekly_label: "Spend", + 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, + base_url: base_url.to_owned(), + } + } + + async fn fetch_web( + &self, + ctx: &FetchContext, + now: DateTime, + ) -> Result { + let session = session(ctx)?; + let client = self.client.as_ref().ok_or_else(|| { + ProviderError::Other("Could not create a secure LithosAI HTTP client.".into()) + })?; + let console = Console { + client, + base_url: &self.base_url, + session: &session, + timeout: Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT), + }; + + let me = console.get("/api/me", None).await?; + let account = model::parse_me(&me)?; + let billing = console + .get("/api/billing", Some(&account.organization_id)) + .await?; + let billing = model::parse_billing(&billing)?; + + // Spend is optional: a broken report must not discard an + // authenticated balance, but an expired session still ends the fetch. + let (start, end) = model::spend_range(now); + let spend = match console + .get( + &format!("/api/billing/spend?start={start}&end={end}"), + Some(&account.organization_id), + ) + .await + { + Ok(report) => model::parse_spend(&report, &start, &end).ok(), + Err(error) if is_session_expired(&error) => return Err(error), + Err(error) => { + tracing::debug!(%error, "LithosAI spend report is unavailable"); + None + } + }; + Ok(model::result(&account, &billing, spend)) + } +} + +impl Default for LithosAIProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for LithosAIProvider { + fn id(&self) -> ProviderId { + ProviderId::LithosAI + } + + 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, Utc::now()).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 + } + + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + if is_session_expired(error) { + ProviderStateKind::ExpiredSession + } else { + error.state_kind() + } + } +} + +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. +struct Session { + cookie_header: String, + csrf: String, +} + +/// The pasted or shell-resolved Cookie header, else the selected browser's +/// console cookies. Upstream requires both console cookies. +fn session(ctx: &FetchContext) -> Result { + let header = match ctx.manual_cookie_header.as_deref() { + Some(header) => crate::providers::normalize_cookie_header(header), + None if ctx.manual_cookie_missing => None, + None => match crate::providers::browser_cookie_header(&[COOKIE_DOMAIN]) { + Ok(header) => crate::providers::normalize_cookie_header(&header), + Err(error) => { + tracing::debug!(%error, "LithosAI browser session is unavailable"); + None + } + }, + }; + header + .and_then(|header| session_from_header(&header)) + .ok_or_else(|| ProviderError::BrowserSignInRequired { + message: MISSING_SESSION.into(), + sign_in_url: SIGN_IN_URL.into(), + }) +} + +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(SESSION_COOKIE)?; + let csrf = first(CSRF_COOKIE)?; + Some(Session { + cookie_header: header.to_owned(), + csrf, + }) +} + +struct Console<'a> { + client: &'a Client, + base_url: &'a str, + session: &'a Session, + timeout: Duration, +} + +impl Console<'_> { + async fn get( + &self, + path: &str, + organization_id: Option<&str>, + ) -> Result, ProviderError> { + let mut request = self + .client + .get(format!("{}{path}", self.base_url)) + .header(ACCEPT, "application/json") + .header(COOKIE, &self.session.cookie_header) + .header(CSRF_HEADER, &self.session.csrf) + .timeout(self.timeout); + if let Some(organization_id) = organization_id { + request = request.header(ORGANIZATION_HEADER, organization_id); + } + let response = request.send().await?; + check_status(response.status())?; + let body = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::TooLarge => model::invalid("JSON response"), + BoundedBodyError::Read(error) => ProviderError::Network(error), + })?; + model::parse_object(&body) + } +} + +/// 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(()), + 401 => SESSION_EXPIRED.to_owned(), + 403 => "LithosAI console access was denied.".to_owned(), + 429 => "LithosAI console requests are rate limited.".to_owned(), + 500.. => "LithosAI console is unavailable.".to_owned(), + code => format!("LithosAI console returned HTTP {code}."), + }; + Err(ProviderError::Other(message)) +} diff --git a/rust/src/providers/lithosai/model.rs b/rust/src/providers/lithosai/model.rs new file mode 100644 index 0000000000..8c32ca8bb6 --- /dev/null +++ b/rust/src/providers/lithosai/model.rs @@ -0,0 +1,264 @@ +//! LithosAI console payloads and the card presentation. +//! +//! Ported from upstream CodexBar v0.73.0 `lithosai.ts`. Money is in nanos +//! (1 USD = 1,000,000,000 nanos) and must be a JavaScript safe integer. + +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value}; + +use crate::core::{ + CostSnapshot, ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, + UsageSnapshot, +}; +use crate::providers::format; + +/// `Number.MAX_SAFE_INTEGER`. +const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; +const NANOS_PER_USD: f64 = 1e9; +const SECTION: &str = "Billing"; + +/// Upstream `invalid(field)`: a parse failure naming the field, never the body. +pub(super) fn invalid(field: &str) -> ProviderError { + ProviderError::Parse(format!("Could not parse LithosAI {field}.")) +} + +pub(super) fn parse_object(body: &[u8]) -> Result, ProviderError> { + let value: Value = serde_json::from_slice(body).map_err(|_| invalid("JSON response"))?; + match value { + Value::Object(map) => Ok(map), + _ => Err(invalid("response object")), + } +} + +fn object(value: Option<&Value>) -> Result<&Map, ProviderError> { + value + .and_then(Value::as_object) + .ok_or_else(|| invalid("response object")) +} + +/// Upstream `amount`: a JSON number that is a safe integer (`4.0` counts). +fn amount(value: Option<&Value>) -> Result { + let number = value.and_then(|value| match value { + Value::Number(number) => Some(number), + _ => None, + }); + let integer = number.and_then(|number| { + number.as_i64().or_else(|| { + let float = number.as_f64()?; + if float.fract() != 0.0 || float.abs() > MAX_SAFE_INTEGER as f64 { + return None; + } + #[allow( + clippy::cast_possible_truncation, + reason = "the value is integral and within the safe-integer range" + )] + let integer = float as i64; + Some(integer) + }) + }); + integer + .filter(|value| value.abs() <= MAX_SAFE_INTEGER) + .ok_or_else(|| invalid("integer amount")) +} + +/// Upstream `text`: a trimmed non-empty string. +fn text(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Account { + pub organization_id: String, + pub organization_name: Option, + pub email: Option, +} + +pub(super) fn parse_me(me: &Map) -> Result { + let organization = object(me.get("activeOrganization"))?; + let organization_id = + text(organization.get("id")).ok_or_else(|| invalid("active organization"))?; + let valid_id = (1..=128).contains(&organization_id.len()) + && organization_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'); + if !valid_id { + return Err(invalid("organization ID")); + } + let email = text(object(me.get("user"))?.get("email")); + Ok(Account { + organization_id, + organization_name: text(organization.get("name")), + email, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct Billing { + pub balance_nanos: i64, + pub has_card: bool, + pub on_hold: bool, +} + +pub(super) fn parse_billing(billing: &Map) -> Result { + let balance_nanos = amount(billing.get("balanceNanos"))?; + let flag = |field: &str| { + billing + .get(field) + .and_then(Value::as_bool) + .ok_or_else(|| invalid(field)) + }; + Ok(Billing { + balance_nanos, + has_card: flag("hasCard")?, + on_hold: flag("onHold")?, + }) +} + +/// The inclusive UTC month-to-date range, as upstream builds it from +/// `toISOString()`. +pub(super) fn spend_range(now: DateTime) -> (String, String) { + let end = now.format("%Y-%m-%d").to_string(); + let start = format!("{}-01", &end[..7]); + (start, end) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct Spend { + pub today_nanos: i64, + pub month_nanos: i64, +} + +fn is_iso_day(day: &str) -> bool { + let bytes = day.as_bytes(); + bytes.len() == 10 + && bytes.iter().enumerate().all(|(index, byte)| match index { + 4 | 7 => *byte == b'-', + _ => byte.is_ascii_digit(), + }) +} + +/// Sparse rows are summed across models and keys; an empty report is zero. +pub(super) fn parse_spend( + report: &Map, + start: &str, + end: &str, +) -> Result { + let days = match (report.get("start"), report.get("end"), report.get("days")) { + (Some(Value::String(s)), Some(Value::String(e)), Some(Value::Array(days))) + if s == start && e == end => + { + days + } + _ => return Err(invalid("spend range")), + }; + let mut spend = Spend { + today_nanos: 0, + month_nanos: 0, + }; + for raw in days { + let day = object(Some(raw))?; + let date = match day.get("day") { + Some(Value::String(date)) + if is_iso_day(date) && date.as_str() >= start && date.as_str() <= end => + { + date + } + _ => return Err(invalid("spend day")), + }; + let nanos = amount(day.get("nanos"))?; + if nanos < 0 { + return Err(invalid("spend amount")); + } + spend.month_nanos = spend + .month_nanos + .checked_add(nanos) + .filter(|sum| *sum <= MAX_SAFE_INTEGER) + .ok_or_else(|| invalid("integer amount"))?; + if date == end { + spend.today_nanos += nanos; + } + } + Ok(spend) +} + +/// Nanos are safe integers, so the conversion to `f64` is exact. +fn usd(nanos: i64) -> f64 { + nanos as f64 / NANOS_PER_USD +} + +/// Upstream `money`: positive sub-cent amounts are labelled explicitly. +pub(super) fn money(value: f64) -> String { + if value > 0.0 && value < 0.01 { + "Less than $0.01".to_owned() + } else { + format::usd(value) + } +} + +fn row(id: &str, title: &str, value: impl Into) -> Option { + ProviderDisplayDetail::new(id, title, value).and_then(|row| row.with_section_title(SECTION)) +} + +/// The prepaid balance as a cost block, plus upstream's "Billing" rows. +/// Upstream has no quota bar; the Windows primary slot carries the balance +/// as an informational value. +pub(super) fn result( + account: &Account, + billing: &Billing, + spend: Option, +) -> ProviderFetchResult { + let balance = usd(billing.balance_nanos); + let balance_text = money(balance); + let mut usage = UsageSnapshot::new(RateWindow::informational(format!( + "Balance: {balance_text}" + ))) + .with_login_method("Browser session"); + if let Some(email) = &account.email { + usage = usage.with_email(email.clone()); + } + if let Some(organization) = &account.organization_name { + usage = usage.with_organization(organization.clone()); + } + + let mut rows = vec![ + row("balance", "Balance", balance_text), + row( + "payment-card", + "Payment card", + if billing.has_card { + "Added" + } else { + "Not added" + }, + ), + row( + "account-status", + "Account status", + if billing.on_hold { "On hold" } else { "Active" }, + ), + ]; + match spend { + Some(spend) => { + rows.push(row( + "spend-today", + "Today (UTC)", + money(usd(spend.today_nanos)), + )); + rows.push(row( + "spend-month", + "This month (UTC)", + money(usd(spend.month_nanos)), + )); + } + None => rows.push(row("spend", "Spend", "Unavailable")), + } + + let cost = CostSnapshot::new(0.0, "USD", "Prepaid credits").with_balance(balance); + ProviderFetchResult::new(usage, "web") + .with_cost(cost) + .with_display_details(rows.into_iter().flatten()) +} diff --git a/rust/src/providers/lithosai/tests.rs b/rust/src/providers/lithosai/tests.rs new file mode 100644 index 0000000000..feb9d3e580 --- /dev/null +++ b/rust/src/providers/lithosai/tests.rs @@ -0,0 +1,502 @@ +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread::JoinHandle; + +use chrono::{TimeZone, Utc}; +use reqwest::redirect::Policy; + +use super::*; + +const ME: &str = r#"{"user":{"email":"person@example.test"},"activeOrganization":{"id":"org-fixture","name":"Synthetic Organization"}}"#; +const EMPTY_SPEND: &str = r#"{"start":"2027-01-01","end":"2027-01-21","days":[]}"#; +const SESSION_HEADER: &str = "__Host-console_session=fixture-1; __Host-console_csrf=csrf-1"; + +/// Upstream `LithosAIPluginTests.now`: 2027-01-21 UTC. +fn now() -> DateTime { + Utc.timestamp_opt(1_800_489_600, 0).unwrap() +} + +fn billing(balance: &str) -> String { + format!(r#"{{"balanceNanos":{balance},"hasCard":true,"onHold":false}}"#) +} + +/// Serve `responses` in order, one connection each; yields the raw requests. +fn serve(responses: Vec<(u16, String)>) -> (LithosAIProvider, JoinHandle>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind local test server"); + let address = listener.local_addr().expect("local server address"); + let server = std::thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 8192]; + let read = stream.read(&mut request).expect("read request"); + requests.push(String::from_utf8_lossy(&request[..read]).into_owned()); + write!( + stream, + "HTTP/1.1 {status} Fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write response"); + } + requests + }); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + ( + LithosAIProvider::with_client(&format!("http://{address}/"), client), + server, + ) +} + +fn context(header: &str) -> FetchContext { + FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: Some(header.into()), + ..FetchContext::default() + } +} + +async fn fetch( + responses: Vec<(u16, String)>, +) -> (Result, Vec) { + let (provider, server) = serve(responses); + let result = provider.fetch_web(&context(SESSION_HEADER), now()).await; + (result, server.join().expect("test server thread")) +} + +fn ok_responses(balance: &str, spend: &str) -> Vec<(u16, String)> { + vec![ + (200, ME.to_owned()), + (200, billing(balance)), + (200, spend.to_owned()), + ] +} + +fn rows(result: &ProviderFetchResult) -> Vec<(String, String)> { + result + .display_details + .iter() + .map(|row| { + assert_eq!(row.section_title(), Some("Billing")); + (row.title().to_owned(), row.value().to_owned()) + }) + .collect() +} + +fn header<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} + +#[tokio::test] +async fn console_balance_and_sparse_spend_match_the_upstream_fixture() { + let (result, requests) = fetch(ok_responses( + "4707099860", + r#"{"start":"2027-01-01","end":"2027-01-21","days":[ + {"day":"2027-01-21","modelId":"fixture-model","nanos":260066994}, + {"day":"2027-01-21","modelId":"fixture-other","nanos":100000000}, + {"day":"2027-01-03","modelId":"fixture-model","nanos":2000000000}]}"#, + )) + .await; + let result = result.expect("usage"); + + assert_eq!( + rows(&result), + [ + ("Balance", "$4.71"), + ("Payment card", "Added"), + ("Account status", "Active"), + ("Today (UTC)", "$0.36"), + ("This month (UTC)", "$2.36"), + ] + .map(|(title, value)| (title.to_owned(), value.to_owned())) + ); + let cost = result.cost.as_ref().expect("prepaid cost block"); + assert_eq!(cost.balance, Some(4.707_099_86)); + assert_eq!(cost.used, 0.0); + assert_eq!(cost.limit, None); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(cost.period, "Prepaid credits"); + let usage = &result.usage; + assert!(usage.primary.is_informational); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("Balance: $4.71") + ); + assert!(usage.secondary.is_none()); + assert_eq!(usage.account_email.as_deref(), Some("person@example.test")); + assert_eq!( + usage.account_organization.as_deref(), + Some("Synthetic Organization") + ); + assert_eq!(usage.login_method.as_deref(), Some("Browser session")); + assert_eq!(result.source_label, "web"); + + let paths: Vec<&str> = requests + .iter() + .map(|request| request.lines().next().unwrap_or_default()) + .collect(); + assert_eq!( + paths, + [ + "GET /api/me HTTP/1.1", + "GET /api/billing HTTP/1.1", + "GET /api/billing/spend?start=2027-01-01&end=2027-01-21 HTTP/1.1", + ] + ); + for request in &requests { + assert_eq!(header(request, "x-console-csrf"), Some("csrf-1")); + assert_eq!(header(request, "cookie"), Some(SESSION_HEADER)); + } + assert_eq!(header(&requests[0], "x-organization-id"), None); + assert_eq!( + header(&requests[1], "x-organization-id"), + Some("org-fixture") + ); + assert_eq!( + header(&requests[2], "x-organization-id"), + Some("org-fixture") + ); +} + +#[tokio::test] +async fn the_parity_pack_payload_shows_balance_and_month_spend() { + let (result, _) = fetch(ok_responses( + "42370000000", + r#"{"start":"2027-01-01","end":"2027-01-21","days":[ + {"day":"2027-01-01","nanos":6000000000},{"day":"2027-01-21","nanos":1840000000}]}"#, + )) + .await; + let result = result.expect("usage"); + assert_eq!( + rows(&result) + .into_iter() + .map(|(_, value)| value) + .collect::>(), + ["$42.37", "Added", "Active", "$1.84", "$7.84"] + ); +} + +#[tokio::test] +async fn zero_debt_and_subcent_balances_are_not_invented_quotas() { + for (balance, cost_balance, shown) in [ + ("0", 0.0, "$0.00"), + // The shared cost block floors balances at zero; the debt stays + // visible in the Balance row. + ("-1000000000", 0.0, "-$1.00"), + ("1000000", 0.001, "Less than $0.01"), + ] { + let (result, _) = fetch(ok_responses(balance, EMPTY_SPEND)).await; + let result = result.expect(balance); + assert_eq!(result.cost.as_ref().unwrap().balance, Some(cost_balance)); + assert!(result.usage.primary.is_informational); + let rows = rows(&result); + assert_eq!(rows[0], ("Balance".to_owned(), shown.to_owned())); + assert_eq!(rows[3], ("Today (UTC)".to_owned(), "$0.00".to_owned())); + assert_eq!(rows[4], ("This month (UTC)".to_owned(), "$0.00".to_owned())); + } +} + +#[tokio::test] +async fn billing_flags_render_their_negative_labels() { + let (result, _) = fetch(vec![ + (200, ME.to_owned()), + ( + 200, + r#"{"balanceNanos":1500000000,"hasCard":false,"onHold":true}"#.to_owned(), + ), + (200, EMPTY_SPEND.to_owned()), + ]) + .await; + let rows = rows(&result.expect("usage")); + assert_eq!(rows[1].1, "Not added"); + assert_eq!(rows[2].1, "On hold"); +} + +#[tokio::test] +async fn malformed_required_balance_fails_closed() { + for balance in ["null", "\"4707099860\"", "0.5", "1e99", "9007199254740992"] { + let (result, _) = fetch(vec![(200, ME.to_owned()), (200, billing(balance))]).await; + assert_eq!( + result.expect_err(balance).to_string(), + "Parse error: Could not parse LithosAI integer amount.", + "{balance}" + ); + } + let (result, _) = fetch(vec![ + (200, ME.to_owned()), + ( + 200, + r#"{"balanceNanos":1,"hasCard":"yes","onHold":false}"#.to_owned(), + ), + ]) + .await; + assert_eq!( + result.unwrap_err().to_string(), + "Parse error: Could not parse LithosAI hasCard." + ); +} + +#[tokio::test] +async fn an_integral_float_balance_is_a_safe_integer() { + let (result, _) = fetch(ok_responses("2000000000.0", EMPTY_SPEND)).await; + assert_eq!(result.expect("usage").cost.unwrap().balance, Some(2.0)); +} + +#[tokio::test] +async fn optional_malformed_spend_preserves_balance_without_reporting_false_zero() { + for spend in [ + "not-json", + "{}", + r#"{"days":null}"#, + r#"{"start":"2027-01-01","end":"2027-01-21","days":[{"day":"2027-01-22","nanos":1}]}"#, + r#"{"start":"2027-01-01","end":"2027-01-21","days":[{"day":"2026-12-31","nanos":1}]}"#, + r#"{"start":"2027-01-01","end":"2027-01-21","days":[{"day":"2027-1-05","nanos":1}]}"#, + r#"{"start":"2027-01-01","end":"2027-01-21","days":[{"day":"2027-01-05","nanos":-1}]}"#, + r#"{"start":"2027-01-01","end":"2027-01-21","days":[{"day":"2027-01-05","nanos":0.5}]}"#, + r#"{"start":"2027-01-01","end":"2027-01-21","days":[{"day":"2027-01-05","nanos":9007199254740991},{"day":"2027-01-06","nanos":1}]}"#, + r#"{"start":"2027-01-02","end":"2027-01-21","days":[]}"#, + r#"{"start":"2027-01-01","end":"2027-01-20","days":[]}"#, + ] { + let (result, _) = fetch(ok_responses("4707099860", spend)).await; + let result = result.expect(spend); + assert_eq!(result.cost.as_ref().unwrap().balance, Some(4.707_099_86)); + let rows = rows(&result); + assert_eq!(rows.len(), 4, "{spend}"); + assert_eq!( + rows[3], + ("Spend".to_owned(), "Unavailable".to_owned()), + "{spend}" + ); + } +} + +#[tokio::test] +async fn spend_http_failures_keep_the_balance_except_an_expired_session() { + for status in [403, 404, 429, 500] { + let (result, _) = fetch(vec![ + (200, ME.to_owned()), + (200, billing("4707099860")), + (status, "{}".to_owned()), + ]) + .await; + let result = result.expect("balance survives"); + assert_eq!(rows(&result)[3].1, "Unavailable"); + } + let (result, _) = fetch(vec![ + (200, ME.to_owned()), + (200, billing("4707099860")), + (401, "{}".to_owned()), + ]) + .await; + assert_eq!( + result.unwrap_err().to_string(), + "LithosAI session expired. Sign in again or paste fresh cookies." + ); +} + +#[tokio::test] +async fn an_expired_session_at_any_request_is_reported_as_expired() { + for at in 1..=2 { + let mut responses = ok_responses("4707099860", EMPTY_SPEND); + responses.truncate(at); + responses[at - 1] = (401, "{}".to_owned()); + let (provider, server) = serve(responses); + let error = provider + .fetch_web(&context(SESSION_HEADER), now()) + .await + .expect_err("expired"); + server.join().expect("test server thread"); + assert_eq!( + error.to_string(), + "LithosAI session expired. Sign in again or paste fresh cookies." + ); + assert_eq!( + provider.error_state_kind(&error), + ProviderStateKind::ExpiredSession + ); + } +} + +#[tokio::test] +async fn non_authentication_failures_map_to_upstream_messages() { + for (status, message) in [ + (403, "LithosAI console access was denied."), + (429, "LithosAI console requests are rate limited."), + (500, "LithosAI console is unavailable."), + (503, "LithosAI console is unavailable."), + (404, "LithosAI console returned HTTP 404."), + (302, "LithosAI console returned HTTP 302."), + ] { + let (provider, server) = serve(vec![(status, "{}".to_owned())]); + let error = provider + .fetch_web(&context(SESSION_HEADER), now()) + .await + .expect_err(message); + server.join().expect("test server thread"); + assert_eq!(error.to_string(), message); + assert_ne!( + provider.error_state_kind(&error), + ProviderStateKind::ExpiredSession + ); + } +} + +#[tokio::test] +async fn the_active_organization_is_validated_before_billing() { + for (me, message) in [ + ( + r#"{"user":{},"activeOrganization":{"id":"org/../x"}}"#, + "organization ID", + ), + ( + r#"{"user":{},"activeOrganization":{"id":" "}}"#, + "active organization", + ), + ( + r#"{"user":{},"activeOrganization":{"id":7}}"#, + "active organization", + ), + (r#"{"user":{}}"#, "response object"), + ( + r#"{"activeOrganization":{"id":"org-fixture"}}"#, + "response object", + ), + ("[]", "response object"), + ("not-json", "JSON response"), + ] { + let (result, requests) = fetch(vec![(200, me.to_owned())]).await; + assert_eq!(requests.len(), 1); + assert_eq!( + result.expect_err(me).to_string(), + format!("Parse error: Could not parse LithosAI {message}."), + "{me}" + ); + } + let long = "a".repeat(129); + let (result, _) = fetch(vec![( + 200, + format!(r#"{{"user":{{}},"activeOrganization":{{"id":"{long}"}}}}"#), + )]) + .await; + assert!(result.unwrap_err().to_string().contains("organization ID")); +} + +#[tokio::test] +async fn missing_identity_fields_stay_empty() { + let (result, _) = fetch(vec![ + ( + 200, + r#"{"user":{"email":" "},"activeOrganization":{"id":"org_1"}}"#.to_owned(), + ), + (200, billing("1")), + (200, EMPTY_SPEND.to_owned()), + ]) + .await; + let usage = result.expect("usage").usage; + assert!(usage.account_email.is_none()); + assert!(usage.account_organization.is_none()); +} + +#[tokio::test] +async fn both_console_cookies_are_required_before_any_request() { + let provider = LithosAIProvider::with_client("http://127.0.0.1:9/", Client::new()); + for header in [ + "__Host-console_session=fixture-1", + "__Host-console_csrf=csrf-1", + "__Host-console_session=; __Host-console_csrf=csrf-1", + "session=other", + ] { + let error = provider + .fetch_web(&context(header), now()) + .await + .expect_err(header); + assert!( + matches!(error, ProviderError::BrowserSignInRequired { .. }), + "{header}" + ); + assert_eq!(error.state_kind(), ProviderStateKind::NeedsAuthentication); + } + let ctx = FetchContext { + source_mode: SourceMode::Web, + manual_cookie_missing: true, + ..FetchContext::default() + }; + assert!(matches!( + provider.fetch_web(&ctx, now()).await, + Err(ProviderError::BrowserSignInRequired { .. }) + )); +} + +#[tokio::test] +async fn disabled_cookies_and_oauth_never_fetch() { + let provider = LithosAIProvider::with_client("http://127.0.0.1:9/", Client::new()); + let off = FetchContext { + source_mode: SourceMode::Cli, + manual_cookie_header: Some(SESSION_HEADER.into()), + ..FetchContext::default() + }; + assert_eq!( + provider.fetch_usage(&off).await.unwrap_err().to_string(), + "LithosAI cookies are disabled." + ); + let oauth = FetchContext { + source_mode: SourceMode::OAuth, + ..FetchContext::default() + }; + assert!(matches!( + provider.fetch_usage(&oauth).await, + Err(ProviderError::UnsupportedSource(SourceMode::OAuth)) + )); +} + +#[tokio::test] +async fn errors_do_not_echo_cookies_or_bodies() { + let (result, _) = fetch(vec![(200, r#"{"leak":"synthetic-leak-0001"}"#.to_owned())]).await; + let message = result.unwrap_err().to_string(); + assert!(!message.contains("synthetic-leak-0001")); + assert!(!message.contains("csrf-1")); +} + +#[test] +fn spend_range_is_the_utc_month_to_date() { + assert_eq!( + model::spend_range(now()), + ("2027-01-01".to_owned(), "2027-01-21".to_owned()) + ); + let first = Utc.with_ymd_and_hms(2026, 10, 1, 23, 59, 59).unwrap(); + assert_eq!( + model::spend_range(first), + ("2026-10-01".to_owned(), "2026-10-01".to_owned()) + ); +} + +#[test] +fn money_matches_upstream_formatting() { + assert_eq!(model::money(1234.5), "$1,234.50"); + assert_eq!(model::money(0.004), "Less than $0.01"); + assert_eq!(model::money(0.0), "$0.00"); +} + +#[test] +fn metadata_matches_the_upstream_descriptor() { + let provider = LithosAIProvider::new(); + let metadata = provider.metadata(); + assert_eq!(metadata.display_name, "LithosAI"); + assert_eq!(metadata.session_label, "Balance"); + assert_eq!(metadata.weekly_label, "Spend"); + assert_eq!( + metadata.dashboard_url, + Some("https://console.lithosai.cloud") + ); + assert!(!metadata.default_enabled); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::Web] + ); +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 78f57c45a6..764865017f 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -55,6 +55,7 @@ pub mod kimi; pub mod kimik2; pub mod kiro; pub mod litellm; +pub mod lithosai; pub mod llmman; pub mod llmproxy; pub mod longcat; @@ -147,6 +148,7 @@ pub use kimi::{KimiProvider, KimiRegion}; pub use kimik2::KimiK2Provider; pub use kiro::KiroProvider; pub use litellm::LiteLLMProvider; +pub use lithosai::LithosAIProvider; pub use llmman::LLMManProvider; pub use llmproxy::LLMProxyProvider; pub use longcat::LongCatProvider; From e200608409b118a73cc60ffe67f006c6e22a3aaf Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:27:44 +0700 Subject: [PATCH 06/14] Expose the LithosAI cookie source picker --- .../src/commands/provider_settings.rs | 8 ++++ .../commands/session_cookie_scope_tests.rs | 41 +++++++++++++++++++ docs/PROVIDERS.md | 8 ++-- rust/src/providers/lithosai/mod.rs | 10 ++++- rust/src/settings.rs | 4 +- rust/src/settings/tests.rs | 9 ++++ 6 files changed, 74 insertions(+), 6 deletions(-) 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 9fbdbc481a..bf574556b0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -251,6 +251,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::TypeSafe, "hyper" => ProviderId::Hyper, "groq" => ProviderId::Groq, + "lithosai" => ProviderId::LithosAI, _ => return None, }) } @@ -853,6 +854,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(), } } 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 4f536e9fe4..e13eda8a76 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 @@ -160,3 +160,44 @@ fn groq_exposes_a_cookie_source_picker_defaulting_to_auto() { .collect(); assert_eq!(values, vec!["auto", "manual", "off"]); } + +/// LithosAI 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 lithosai_exposes_a_cookie_source_picker_and_routes_each_choice() { + let settings = Settings::default(); + assert_eq!( + super::provider_cookie_source_lookup(&settings, "lithosai").as_deref(), + Some("auto") + ); + let values: Vec<_> = super::cookie_source_options_for("lithosai", Language::English) + .into_iter() + .map(|option| option.value) + .collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + + let cookie = "__Host-console_session=fixture; __Host-console_csrf=fixture"; + for source in ["auto", "manual"] { + let ctx = session_context(ProviderId::LithosAI, 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::LithosAI, 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::LithosAI, Some("off"), "auto", Some(cookie)); + assert_eq!(ctx.source_mode, SourceMode::Cli); + assert!(ctx.manual_cookie_header.is_none()); +} diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index ee2c0797e1..bb5f746ebe 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -119,9 +119,11 @@ signed-in console at `https://console.lithosai.cloud` with both session cookies, `__Host-console_session` and `__Host-console_csrf`; the CSRF value is echoed as `X-Console-Csrf`. A header missing either cookie never sends a request. -- The cookie source picks the session: Automatic imports the - `console.lithosai.cloud` cookies from the selected browser, Manual uses a - pasted Cookie header, and Off reads nothing. +- The cookie source in Settings → Providers → LithosAI picks the session and + defaults to Automatic. Automatic and Manual first use a header imported under + Browser Cookies (from the browser you choose) or pasted there. Without one, + Automatic reads the `console.lithosai.cloud` cookies from the detected + browsers and Manual fails closed. Off reads nothing. - Requests, in order: `GET /api/me` (active organization, email), then `GET /api/billing` and `GET /api/billing/spend?start=YYYY-MM-01&end=YYYY-MM-DD` (UTC month to date), both with `X-Organization-Id`. diff --git a/rust/src/providers/lithosai/mod.rs b/rust/src/providers/lithosai/mod.rs index 3d388497ce..bdf401d63b 100644 --- a/rust/src/providers/lithosai/mod.rs +++ b/rust/src/providers/lithosai/mod.rs @@ -24,8 +24,8 @@ use reqwest::{ use serde_json::{Map, Value}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - ProviderStateKind, SourceMode, + FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, + ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -166,6 +166,12 @@ impl Provider for LithosAIProvider { 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 + } + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { if is_session_expired(error) { ProviderStateKind::ExpiredSession diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 7324d6afce..d89db8bfbe 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -1028,7 +1028,9 @@ impl Settings { .get(&id) .and_then(|c| c.cookie_source.as_deref()) .unwrap_or(match id { - ProviderId::Kimi | ProviderId::Hyper | ProviderId::Groq => "auto", + ProviderId::Kimi | ProviderId::Hyper | ProviderId::Groq | ProviderId::LithosAI => { + "auto" + } _ => DEFAULT_COOKIE_SOURCE, }) } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 2d7d051c0b..1be50391a2 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -58,6 +58,15 @@ fn groq_cookie_source_defaults_to_automatic_session_import() { assert_eq!(settings.cookie_source(ProviderId::Groq), "manual"); } +#[test] +fn lithosai_cookie_source_defaults_to_automatic_session_import() { + // Upstream's LithosAI cookie source falls back to `.auto`. + let mut settings = Settings::default(); + assert_eq!(settings.cookie_source(ProviderId::LithosAI), "auto"); + settings.set_cookie_source(ProviderId::LithosAI, "off"); + assert_eq!(settings.cookie_source(ProviderId::LithosAI), "off"); +} + #[test] fn preferred_currency_defaults_validates_and_round_trips() { let legacy: Settings = serde_json::from_str(r#"{"enabled_providers": []}"#) From d722ffeef0e1b4c5325cd379d6475cdc94c17f0d Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:35:28 +0700 Subject: [PATCH 07/14] Expose the Langdock cookie source picker --- .../src/commands/provider_settings.rs | 8 ++++ .../commands/session_cookie_scope_tests.rs | 41 +++++++++++++++++++ docs/PROVIDERS.md | 9 ++-- rust/src/providers/langdock/mod.rs | 10 ++++- rust/src/settings.rs | 4 +- rust/src/settings/tests.rs | 9 ++++ 6 files changed, 75 insertions(+), 6 deletions(-) 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 9fbdbc481a..7a29dfddcf 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -251,6 +251,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::TypeSafe, "hyper" => ProviderId::Hyper, "groq" => ProviderId::Groq, + "langdock" => ProviderId::Langdock, _ => return None, }) } @@ -853,6 +854,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(), } } 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 4f536e9fe4..1ecb64d63f 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 @@ -160,3 +160,44 @@ fn groq_exposes_a_cookie_source_picker_defaulting_to_auto() { .collect(); assert_eq!(values, vec!["auto", "manual", "off"]); } + +/// Langdock 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 langdock_exposes_a_cookie_source_picker_and_routes_each_choice() { + let settings = Settings::default(); + assert_eq!( + super::provider_cookie_source_lookup(&settings, "langdock").as_deref(), + Some("auto") + ); + let values: Vec<_> = super::cookie_source_options_for("langdock", Language::English) + .into_iter() + .map(|option| option.value) + .collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + + let cookie = "auth_token=fixture"; + for source in ["auto", "manual"] { + let ctx = session_context(ProviderId::Langdock, 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::Langdock, 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::Langdock, Some("off"), "auto", Some(cookie)); + assert_eq!(ctx.source_mode, SourceMode::Cli); + assert!(ctx.manual_cookie_header.is_none()); +} diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index e9b9354f6e..79cf8be305 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -118,9 +118,12 @@ Langdock is disabled by default and has no API key. It reads the signed-in web app: `GET https://app.langdock.com/api/trpc/usageSettings.getPersonalUsage` (tRPC batch input) with the `auth_token` session cookie. -- The cookie source picks the session: Automatic imports the `langdock.com` - cookies from the selected browser, Manual uses a pasted Cookie header, and - Off reads nothing. A header without `auth_token` never sends a request. +- The cookie source in Settings → Providers → Langdock picks the session and + defaults to Automatic. Automatic and Manual first use a header imported under + Browser Cookies (from the browser you choose) or pasted there. Without one, + Automatic reads the `langdock.com` cookies from the detected browsers and + Manual fails closed. Off reads nothing. A header without `auth_token` never + sends a request. - The card shows Session (5 hours) and Weekly percentages with their resets. A plan without session limits shows the weekly lane alone, labelled Weekly. A workspace without included limits shows one "Included limits" detail row. diff --git a/rust/src/providers/langdock/mod.rs b/rust/src/providers/langdock/mod.rs index a743d3b9ad..5358ed9c7e 100644 --- a/rust/src/providers/langdock/mod.rs +++ b/rust/src/providers/langdock/mod.rs @@ -21,8 +21,8 @@ use reqwest::{ }; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - ProviderStateKind, SourceMode, + FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, + ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -147,6 +147,12 @@ impl Provider for LangdockProvider { 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 + } + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { match error { // Upstream classifies a 401 and tRPC UNAUTHORIZED as diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 7324d6afce..ab3feea08d 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -1028,7 +1028,9 @@ impl Settings { .get(&id) .and_then(|c| c.cookie_source.as_deref()) .unwrap_or(match id { - ProviderId::Kimi | ProviderId::Hyper | ProviderId::Groq => "auto", + ProviderId::Kimi | ProviderId::Hyper | ProviderId::Groq | ProviderId::Langdock => { + "auto" + } _ => DEFAULT_COOKIE_SOURCE, }) } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 2d7d051c0b..df6188c9b8 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -58,6 +58,15 @@ fn groq_cookie_source_defaults_to_automatic_session_import() { assert_eq!(settings.cookie_source(ProviderId::Groq), "manual"); } +#[test] +fn langdock_cookie_source_defaults_to_automatic_session_import() { + // Upstream's Langdock cookie source falls back to `.auto`. + let mut settings = Settings::default(); + assert_eq!(settings.cookie_source(ProviderId::Langdock), "auto"); + settings.set_cookie_source(ProviderId::Langdock, "off"); + assert_eq!(settings.cookie_source(ProviderId::Langdock), "off"); +} + #[test] fn preferred_currency_defaults_validates_and_round_trips() { let legacy: Settings = serde_json::from_str(r#"{"enabled_providers": []}"#) From dea953c19b6c3f6786b4b664bbeae13a15b4d8e6 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:07:40 +0700 Subject: [PATCH 08/14] Describe the Langdock picker against upstream accurately --- .../desktop-tauri/src-tauri/src/commands/provider_settings.rs | 4 ++-- rust/src/providers/langdock/mod.rs | 4 ++-- rust/src/settings/tests.rs | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) 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 7a29dfddcf..bf0bb9c2bb 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -854,8 +854,8 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ cookie_option(lang, "auto", None), cookie_option(lang, "manual", None), diff --git a/rust/src/providers/langdock/mod.rs b/rust/src/providers/langdock/mod.rs index 5358ed9c7e..291ecde6b8 100644 --- a/rust/src/providers/langdock/mod.rs +++ b/rust/src/providers/langdock/mod.rs @@ -147,8 +147,8 @@ impl Provider for LangdockProvider { true } - /// Upstream sends only the pasted header in Manual mode, so an empty - /// manual source must not fall back to a browser session. + /// Manual means a pasted or imported header only, as for upstream's + /// other cookie providers, so an empty one must not read a browser. fn manual_empty_cookie_policy(&self) -> ManualEmptyCookiePolicy { ManualEmptyCookiePolicy::FailClosedWeb } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index df6188c9b8..28efe0b6e9 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -60,7 +60,8 @@ fn groq_cookie_source_defaults_to_automatic_session_import() { #[test] fn langdock_cookie_source_defaults_to_automatic_session_import() { - // Upstream's Langdock cookie source falls back to `.auto`. + // Upstream has no Langdock source picker (it reads one Edge profile), so + // Windows defaults to automatic browser import. let mut settings = Settings::default(); assert_eq!(settings.cookie_source(ProviderId::Langdock), "auto"); settings.set_cookie_source(ProviderId::Langdock, "off"); From 8c8f3987d02f77e0afeeec3efa4398dde17a1e01 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:53:58 +0700 Subject: [PATCH 09/14] Langdock: pick the browser that holds auth_token - Automatic cookie source: the provider now owns browser cookie resolution. It reads every detected browser's langdock.com cookies and uses the first one with a non-empty auth_token (upstream requiredCookies), instead of the shell's first browser with any langdock.com cookie. Manual headers and an empty Manual or Off source never read a browser. - No included limits: one informational primary labelled "Included limits" (upstream detail title) with no duplicate detail row, so the card no longer shows the message twice under "Session". - Correct the overage comment: RateWindow clamps when the window is built, not at display time. --- docs/PROVIDERS.md | 10 ++- rust/src/providers/langdock/mod.rs | 71 ++++++++++----- rust/src/providers/langdock/model.rs | 16 ++-- rust/src/providers/langdock/tests.rs | 124 +++++++++++++++++++++++++-- 4 files changed, 180 insertions(+), 41 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 16de997dc1..ea9ebb3dad 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -121,12 +121,14 @@ app: `GET https://app.langdock.com/api/trpc/usageSettings.getPersonalUsage` - The cookie source in Settings → Providers → Langdock picks the session and defaults to Automatic. Automatic and Manual first use a header imported under Browser Cookies (from the browser you choose) or pasted there. Without one, - Automatic reads the `langdock.com` cookies from the detected browsers and - Manual fails closed. Off reads nothing. A header without `auth_token` never - sends a request. + Automatic uses the first detected browser whose `langdock.com` cookies hold + `auth_token` (browsers with only other cookies are skipped) and Manual fails + closed. Off reads nothing. A header without `auth_token` never sends a + request. - The card shows Session (5 hours) and Weekly percentages with their resets. A plan without session limits shows the weekly lane alone, labelled Weekly. - A workspace without included limits shows one "Included limits" detail row. + A workspace without included limits shows one "Included limits" row with + "No included usage limits available". - Upstream keeps usage above 100%; the Windows bar clamps it at 100%. - 401 and tRPC `UNAUTHORIZED` are reported as an expired session. Redirects are never followed and response bodies are never echoed in errors. diff --git a/rust/src/providers/langdock/mod.rs b/rust/src/providers/langdock/mod.rs index 291ecde6b8..76ba6377a4 100644 --- a/rust/src/providers/langdock/mod.rs +++ b/rust/src/providers/langdock/mod.rs @@ -4,9 +4,9 @@ //! `Providers/Langdock/LangdockProviderDescriptor.swift`). Langdock has no //! API key: the session cookie `auth_token` from app.langdock.com reads the //! tRPC `usageSettings.getPersonalUsage` procedure. Upstream reads one Edge -//! profile chosen in Settings; Windows uses the shell's explicit browser -//! selection or a pasted Cookie header instead. Response bodies are never -//! echoed in errors. +//! profile chosen in Settings; Windows uses an imported or pasted Cookie +//! header, else the first detected browser whose langdock.com cookies hold +//! `auth_token`. Response bodies are never echoed in errors. mod model; #[cfg(test)] @@ -33,9 +33,9 @@ const USAGE_PATH: &str = "/api/trpc/usageSettings.getPersonalUsage"; const USAGE_INPUT: &str = "%7B%220%22%3A%7B%22json%22%3Anull%2C%22meta%22%3A%7B%22values%22%3A%5B%22undefined%22%5D%2C%22v%22%3A1%7D%7D%7D"; const USAGE_REFERER: &str = "https://app.langdock.com/settings/account/usage"; const DASHBOARD_URL: &str = "https://app.langdock.com/settings/account/usage"; -/// Upstream cookie domains, in order: the parent domain also covers cookies -/// set on app.langdock.com. -const COOKIE_DOMAINS: &[&str] = &["langdock.com", "app.langdock.com"]; +/// Upstream's parent cookie domain; browser lookups match its subdomains, so +/// it also covers cookies set on app.langdock.com. +const COOKIE_DOMAIN: &str = "langdock.com"; const SESSION_COOKIE: &str = "auth_token"; const SIGN_IN_URL: &str = "https://app.langdock.com"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); @@ -45,6 +45,9 @@ const SESSION_EXPIRED: &str = "The selected browser profile is no longer signed const MISSING_SESSION: &str = "No Langdock session was found in the selected browser. Sign in at app.langdock.com or paste a Cookie header in Settings."; const COOKIES_OFF: &str = "Langdock reads usage from a browser session. Turn on browser cookies or paste a Cookie header in Settings."; +/// Every detected browser's `(browser name, Cookie header)` for langdock.com. +type BrowserCookieHeaders = dyn Fn() -> Result, ProviderError> + Sync; + pub struct LangdockProvider { metadata: ProviderMetadata, client: Option, @@ -87,8 +90,26 @@ impl LangdockProvider { } } - async fn fetch_web(&self, ctx: &FetchContext) -> Result { - let cookie = session_cookie(ctx)?; + async fn fetch_routed( + &self, + ctx: &FetchContext, + browser: &BrowserCookieHeaders, + ) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Web => self.fetch_web(ctx, browser).await, + // The shell maps an Off cookie source to Cli for web-only + // providers, so explain the setting instead of the source mode. + SourceMode::Cli => Err(ProviderError::Other(COOKIES_OFF.into())), + SourceMode::OAuth => Err(ProviderError::UnsupportedSource(SourceMode::OAuth)), + } + } + + async fn fetch_web( + &self, + ctx: &FetchContext, + browser: &BrowserCookieHeaders, + ) -> Result { + let cookie = session_cookie(ctx, browser)?; let client = self.client.as_ref().ok_or_else(|| { ProviderError::Other("Could not create a secure Langdock HTTP client.".into()) })?; @@ -130,13 +151,10 @@ impl Provider for LangdockProvider { } async fn fetch_usage(&self, ctx: &FetchContext) -> Result { - match ctx.source_mode { - SourceMode::Auto | SourceMode::Web => self.fetch_web(ctx).await, - // The shell maps an Off cookie source to Cli for web-only - // providers, so explain the setting instead of the source mode. - SourceMode::Cli => Err(ProviderError::Other(COOKIES_OFF.into())), - SourceMode::OAuth => Err(ProviderError::UnsupportedSource(SourceMode::OAuth)), - } + self.fetch_routed(ctx, &|| { + crate::providers::browser_cookie_headers_for_domain(COOKIE_DOMAIN) + }) + .await } fn available_sources(&self) -> Vec { @@ -153,6 +171,12 @@ impl Provider for LangdockProvider { ManualEmptyCookiePolicy::FailClosedWeb } + /// Upstream requires `auth_token` when it picks a browser session, so the + /// provider, not the shell's first-browser lookup, chooses the browser. + fn owns_browser_cookie_resolution(&self) -> bool { + true + } + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { match error { // Upstream classifies a 401 and tRPC UNAUTHORIZED as @@ -165,14 +189,21 @@ impl Provider for LangdockProvider { } } -/// The pasted or shell-resolved Cookie header, else the selected browser's -/// langdock.com cookies. Upstream requires `auth_token`. -fn session_cookie(ctx: &FetchContext) -> Result { +/// The imported or pasted Cookie header alone, else the first detected +/// browser whose langdock.com cookies hold `auth_token` (upstream +/// `requiredCookies`). An empty Manual source never reads a browser. +fn session_cookie( + ctx: &FetchContext, + browser: &BrowserCookieHeaders, +) -> Result { let header = match ctx.manual_cookie_header.as_deref() { Some(header) => crate::providers::normalize_cookie_header(header), None if ctx.manual_cookie_missing => None, - None => match crate::providers::browser_cookie_header(COOKIE_DOMAINS) { - Ok(header) => crate::providers::normalize_cookie_header(&header), + None => match browser() { + Ok(candidates) => candidates.iter().find_map(|(_, header)| { + crate::providers::normalize_cookie_header(header) + .filter(|header| has_session_cookie(header)) + }), Err(error) => { tracing::debug!(%error, "Langdock browser session is unavailable"); None diff --git a/rust/src/providers/langdock/model.rs b/rust/src/providers/langdock/model.rs index 756a737b63..11b06de57d 100644 --- a/rust/src/providers/langdock/model.rs +++ b/rust/src/providers/langdock/model.rs @@ -7,9 +7,7 @@ use chrono::{DateTime, Utc}; use serde_json::{Map, Value}; -use crate::core::{ - ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot, -}; +use crate::core::{ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot}; const SESSION_WINDOW_MINUTES: u32 = 300; const WEEKLY_WINDOW_MINUTES: u32 = 10_080; @@ -44,7 +42,7 @@ fn object(value: Option<&Value>) -> Result<&Map, ProviderError> { } /// A finite JSON number. Upstream keeps overage above 100%; the Windows -/// rate window clamps at display time. +/// `RateWindow` constructor clamps it to 0-100 when the window is built. fn percent(value: Option<&Value>) -> Result { value .and_then(Value::as_f64) @@ -126,11 +124,13 @@ pub(super) fn parse_personal_usage(body: &[u8]) -> Result ProviderFetchResult { - let row = ProviderDisplayDetail::new("included-limits", "Included limits", NO_LIMITS) - .and_then(|row| row.with_section_title("Usage")); let usage = UsageSnapshot::new(RateWindow::informational(NO_LIMITS)) + .with_primary_label("Included limits") .with_login_method("Browser session"); - ProviderFetchResult::new(usage, "web").with_display_detail(row) + ProviderFetchResult::new(usage, "web") } diff --git a/rust/src/providers/langdock/tests.rs b/rust/src/providers/langdock/tests.rs index 3d417104fd..bdf799d8d0 100644 --- a/rust/src/providers/langdock/tests.rs +++ b/rust/src/providers/langdock/tests.rs @@ -126,7 +126,7 @@ fn null_resets_stay_unknown() { } #[test] -fn missing_included_limits_show_one_detail_row_and_no_bars() { +fn missing_included_limits_show_one_message_titled_included_limits() { for payload in [ body("null"), r#"[{"result":{"data":{"json":{"hasIncludedUsageLimits":false}}}}]"#.to_owned(), @@ -140,17 +140,19 @@ fn missing_included_limits_show_one_detail_row_and_no_bars() { result.usage.primary.reset_description.as_deref(), Some("No included usage limits available") ); - assert!(result.usage.secondary.is_none()); + // Upstream's detail row title, so the card never falls back to "Session". assert_eq!( - rows(&result), - vec![( - "included-limits".to_owned(), - "Included limits".to_owned(), - "No included usage limits available".to_owned(), - Some("Usage".to_owned()), - )], + result.usage.primary_label.as_deref(), + Some("Included limits"), "{payload}" ); + assert!(result.usage.secondary.is_none()); + // The primary already carries the message; no second copy as a row. + assert_eq!(rows(&result), Vec::new(), "{payload}"); + assert_eq!( + result.usage.login_method.as_deref(), + Some("Browser session") + ); } } @@ -363,6 +365,110 @@ async fn a_manual_source_without_a_cookie_does_not_read_a_browser() { assert!(matches!(error, ProviderError::BrowserSignInRequired { .. })); } +fn browser_context() -> FetchContext { + FetchContext { + source_mode: SourceMode::Auto, + ..FetchContext::default() + } +} + +#[test] +fn the_provider_owns_browser_cookie_resolution() { + assert!(LangdockProvider::new().owns_browser_cookie_resolution()); +} + +#[tokio::test] +async fn automatic_skips_a_browser_without_auth_token() { + let (provider, server) = provider_serving("200 OK", body(PLAN)); + let browser = || { + Ok(vec![ + ("Chrome".to_owned(), "_ga=1".to_owned()), + ("Edge".to_owned(), "auth_token=x".to_owned()), + ]) + }; + let result = provider + .fetch_routed(&browser_context(), &browser) + .await + .expect("usage fetch"); + let request = server.join().expect("test server thread"); + let cookies: Vec<&str> = request + .lines() + .filter(|line| line.to_ascii_lowercase().starts_with("cookie:")) + .collect(); + assert_eq!(cookies, vec!["cookie: auth_token=x"]); + assert_eq!(result.usage.primary.used_percent, 12.5); +} + +#[tokio::test] +async fn automatic_without_any_auth_token_reports_the_missing_session() { + let provider = LangdockProvider::with_client("http://127.0.0.1:9/", Client::new()); + let browser = || { + Ok(vec![ + ("Chrome".to_owned(), "_ga=1".to_owned()), + ("Edge".to_owned(), "auth_token=; _gid=2".to_owned()), + ]) + }; + let error = provider + .fetch_routed(&browser_context(), &browser) + .await + .expect_err("no session"); + assert_eq!( + error.to_string(), + "No Langdock session was found in the selected browser. Sign in at app.langdock.com or paste a Cookie header in Settings." + ); + assert_eq!(error.state_kind(), ProviderStateKind::NeedsAuthentication); +} + +#[tokio::test] +async fn a_manual_header_or_empty_manual_source_never_reads_a_browser() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let counter = std::sync::Arc::clone(&calls); + let browser = move || { + counter.fetch_add(1, Ordering::SeqCst); + Ok(vec![("Edge".to_owned(), "auth_token=browser".to_owned())]) + }; + + let (provider, server) = provider_serving("200 OK", body(PLAN)); + let manual = FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: Some("auth_token=manual".into()), + ..FetchContext::default() + }; + provider + .fetch_routed(&manual, &browser) + .await + .expect("manual usage"); + let request = server.join().expect("test server thread"); + assert!( + request + .to_ascii_lowercase() + .contains("\r\ncookie: auth_token=manual\r\n") + ); + + let offline = LangdockProvider::with_client("http://127.0.0.1:9/", Client::new()); + let empty_manual = FetchContext { + source_mode: SourceMode::Web, + manual_cookie_missing: true, + ..FetchContext::default() + }; + assert!(matches!( + offline.fetch_routed(&empty_manual, &browser).await, + Err(ProviderError::BrowserSignInRequired { .. }) + )); + let manual_without_token = FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: Some("_ga=1".into()), + ..FetchContext::default() + }; + assert!(matches!( + offline.fetch_routed(&manual_without_token, &browser).await, + Err(ProviderError::BrowserSignInRequired { .. }) + )); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + #[tokio::test] async fn errors_do_not_echo_the_response_body() { let (provider, server) = From ff827411f646034dd04e451620ba09fde92dd249 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:00:04 +0700 Subject: [PATCH 10/14] IBM Bob: show the upstream message when no API key is set - A missing key (shared resolve_api_key NotInstalled) now maps to upstream IBMBobUsageFetcher's "Missing IBM Bob API key. Add one in Settings or set BOBSHELL_API_KEY." instead of the generic "API key not found" text; before, only a blank key reached it. Other resolver errors still propagate. - New pure helper api_key_from; hermetic test a_missing_key_uses_the_ibm_bob_message. --- rust/src/providers/ibmbob/mod.rs | 24 +++++++++++++++++------- rust/src/providers/ibmbob/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/rust/src/providers/ibmbob/mod.rs b/rust/src/providers/ibmbob/mod.rs index 639da655dc..921685e6aa 100644 --- a/rust/src/providers/ibmbob/mod.rs +++ b/rust/src/providers/ibmbob/mod.rs @@ -131,16 +131,11 @@ impl IBMBobProvider { } async fn fetch_api(&self, ctx: &FetchContext) -> Result { - let token = normalize_api_key(&crate::providers::resolve_api_key( + let token = api_key_from(crate::providers::resolve_api_key( ctx.api_key.as_deref(), CREDENTIAL_TARGET, &[API_KEY_ENV], - )?) - .ok_or_else(|| { - ProviderError::NotInstalled( - "Missing IBM Bob API key. Add one in Settings or set BOBSHELL_API_KEY.".into(), - ) - })?; + ))?; let client = self.client.as_ref().ok_or_else(|| { ProviderError::Other("Could not create a secure IBM Bob HTTP client.".into()) })?; @@ -217,6 +212,21 @@ impl Provider for IBMBobProvider { } } +/// Normalize a resolved key; a missing or blank key gets upstream's +/// `IBMBobUsageFetcher` message instead of the shared generic one. +fn api_key_from(resolved: Result) -> Result { + let missing = || { + ProviderError::NotInstalled( + "Missing IBM Bob API key. Add one in Settings or set BOBSHELL_API_KEY.".into(), + ) + }; + match resolved { + Ok(raw) => normalize_api_key(&raw).ok_or_else(missing), + Err(ProviderError::NotInstalled(_)) => Err(missing()), + Err(error) => Err(error), + } +} + /// Upstream `IBMBobSettingsReader.apiKey`: trim, then drop one pair of /// matching surrounding quotes. fn normalize_api_key(raw: &str) -> Option { diff --git a/rust/src/providers/ibmbob/tests.rs b/rust/src/providers/ibmbob/tests.rs index 5c1139ae1a..48371bc11d 100644 --- a/rust/src/providers/ibmbob/tests.rs +++ b/rust/src/providers/ibmbob/tests.rs @@ -284,6 +284,29 @@ fn api_keys_are_trimmed_and_unquoted() { assert_eq!(normalize_api_key(" "), None); } +#[test] +fn a_missing_key_uses_the_ibm_bob_message() { + let missing = "Provider not installed: Missing IBM Bob API key. Add one in Settings or set BOBSHELL_API_KEY."; + let not_found = ProviderError::NotInstalled( + "API key not found. Set BOBSHELL_API_KEY in Preferences or environment.".into(), + ); + assert_eq!( + api_key_from(Err(not_found)).unwrap_err().to_string(), + missing + ); + assert_eq!( + api_key_from(Ok("''".into())).unwrap_err().to_string(), + missing + ); + assert_eq!(api_key_from(Ok(" 'k' ".into())).unwrap(), "k"); + assert_eq!( + api_key_from(Err(ProviderError::Other("boom".into()))) + .unwrap_err() + .to_string(), + "boom" + ); +} + #[test] fn regional_hosts_must_be_https_under_bob_ibm_com() { let host = |domain: &str| { From 5ad29132d0ccded2a169894132e7bf6750a7e98c Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:02:49 +0700 Subject: [PATCH 11/14] ClawRouter: classify rejected keys as needs-auth - HTTP 401/403 ("ClawRouter rejected the API key...") now maps to NeedsAuthentication via error_state_kind, matching upstream's authenticationExpired; the message text is unchanged and other errors keep the default mapping. - HTTP tests no longer read CLAWROUTER_BASE_URL: the env fallback is an injected lookup that the test constructor disables. - Port upstream's budgeted golden fixture (0.024% used, $25 limit, openai before anthropic, reset 2026-08-01Z) as a literal test. --- rust/src/providers/clawrouter/mod.rs | 40 +++++-- rust/src/providers/clawrouter/tests.rs | 152 +++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 11 deletions(-) diff --git a/rust/src/providers/clawrouter/mod.rs b/rust/src/providers/clawrouter/mod.rs index b3d5b2132d..951fe1dcdd 100644 --- a/rust/src/providers/clawrouter/mod.rs +++ b/rust/src/providers/clawrouter/mod.rs @@ -17,7 +17,7 @@ use reqwest::{Client, StatusCode, Url}; use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - SourceMode, + ProviderStateKind, SourceMode, }; const CREDENTIAL_TARGET: &str = "codexbar-clawrouter"; @@ -27,11 +27,16 @@ const DEFAULT_BASE_URL: &str = "https://clawrouter.openclaw.ai"; const DASHBOARD_URL: &str = "https://clawrouter.openclaw.ai/dashboard/access"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +/// Upstream `ctx.fail.authenticationExpired` text for HTTP 401/403. +const REJECTED_KEY_MESSAGE: &str = + "ClawRouter rejected the API key. Check the key and its policy status."; pub struct ClawRouterProvider { metadata: ProviderMetadata, client: Option, default_base_url: String, + /// Reads the `CLAWROUTER_BASE_URL` fallback; tests inject a fixed lookup. + base_url_env: fn() -> Option, } impl ClawRouterProvider { @@ -58,6 +63,7 @@ impl ClawRouterProvider { .build() .ok(), default_base_url: DEFAULT_BASE_URL.to_owned(), + base_url_env: base_url_from_env, } } @@ -66,13 +72,14 @@ impl ClawRouterProvider { let mut provider = Self::new(); provider.client = Some(client); provider.default_base_url = default_base_url.to_owned(); + provider.base_url_env = || None; provider } async fn fetch_api(&self, ctx: &FetchContext) -> Result { // Validate the endpoint before touching the keyring or environment so // a malformed or plain-HTTP override never receives the key. - let url = match configured_base_url(ctx) { + let url = match configured_base_url(ctx, self.base_url_env) { Some(raw) => usage_url(&raw)?, None => default_usage_url(&self.default_base_url)?, }; @@ -134,27 +141,38 @@ impl Provider for ClawRouterProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::OAuth] } + + /// A rejected key (HTTP 401/403) is a sign-in gate, matching upstream's + /// `authenticationExpired`; other `Other` errors keep the default mapping. + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + match error { + ProviderError::Other(message) if message == REJECTED_KEY_MESSAGE => { + ProviderStateKind::NeedsAuthentication + } + _ => error.state_kind(), + } + } } /// The saved Base URL, then `CLAWROUTER_BASE_URL`; `None` selects the hosted service. -fn configured_base_url(ctx: &FetchContext) -> Option { +fn configured_base_url(ctx: &FetchContext, env: fn() -> Option) -> Option { ctx.gateway_url .clone() .filter(|url| !url.trim().is_empty()) - .or_else(|| { - std::env::var(BASE_URL_ENV) - .ok() - .filter(|url| !url.trim().is_empty()) - }) + .or_else(|| env().filter(|url| !url.trim().is_empty())) +} + +fn base_url_from_env() -> Option { + std::env::var(BASE_URL_ENV).ok() } /// Classify a non-success status without reading or echoing the body. fn check_status(status: StatusCode) -> Result<(), ProviderError> { let code = status.as_u16(); match status { - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ProviderError::Other( - "ClawRouter rejected the API key. Check the key and its policy status.".into(), - )), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(ProviderError::Other(REJECTED_KEY_MESSAGE.into())) + } _ if status.is_success() => Ok(()), _ => Err(ProviderError::Other(format!( "ClawRouter API returned HTTP {code}." diff --git a/rust/src/providers/clawrouter/tests.rs b/rust/src/providers/clawrouter/tests.rs index 988dc78c6f..83823031da 100644 --- a/rust/src/providers/clawrouter/tests.rs +++ b/rust/src/providers/clawrouter/tests.rs @@ -428,6 +428,152 @@ fn maps_statuses_to_upstream_messages() { assert!(check_status(StatusCode::OK).is_ok()); } +#[test] +fn rejected_keys_classify_as_needs_authentication() { + let provider = ClawRouterProvider::new(); + let kind = |status: StatusCode| provider.error_state_kind(&check_status(status).unwrap_err()); + assert_eq!( + kind(StatusCode::UNAUTHORIZED), + ProviderStateKind::NeedsAuthentication + ); + assert_eq!( + kind(StatusCode::FORBIDDEN), + ProviderStateKind::NeedsAuthentication + ); + for status in [ + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::NOT_FOUND, + ] { + assert_eq!(kind(status), ProviderStateKind::Unknown, "{status}"); + } + // The guard is message-scoped: other `Other` errors keep the default. + assert_eq!( + provider.error_state_kind(&usage_url("http://router.example.com").unwrap_err()), + ProviderStateKind::Unknown + ); + // A missing key still reaches the shared sign-in mapping. + assert_eq!( + provider.error_state_kind(&ProviderError::NotInstalled("API key not found.".into())), + ProviderStateKind::NeedsAuthentication + ); +} + +#[test] +fn saved_base_url_wins_over_the_environment_fallback() { + fn env_router() -> Option { + Some("https://env.example.com".into()) + } + fn env_blank() -> Option { + Some(" ".into()) + } + fn env_unset() -> Option { + None + } + let with_gateway = |gateway: Option<&str>| FetchContext { + gateway_url: gateway.map(str::to_owned), + ..FetchContext::default() + }; + + assert_eq!( + configured_base_url(&with_gateway(Some("https://saved.example.com")), env_router), + Some("https://saved.example.com".to_owned()) + ); + for gateway in [None, Some(""), Some(" ")] { + assert_eq!( + configured_base_url(&with_gateway(gateway), env_router), + Some("https://env.example.com".to_owned()), + "{gateway:?}" + ); + assert_eq!(configured_base_url(&with_gateway(gateway), env_blank), None); + assert_eq!(configured_base_url(&with_gateway(gateway), env_unset), None); + } +} + +/// Upstream `ClawRouterPluginGoldenTests.budgetedResponse` (v0.73.0), verbatim. +const UPSTREAM_BUDGETED_RESPONSE: &str = r#"{ + "policyId": "openclaw-smoke", + "budget": { + "configured": true, + "ledger": "durable_object", + "windowKey": "openclaw/openclaw-smoke/2026-07", + "limitMicros": 25000000, + "spentMicros": 6000, + "remainingMicros": 24994000 + }, + "usage": { + "ledger": "ready", + "summary": { + "requestCount": 6, + "successCount": 5, + "errorCount": 1, + "inputTokens": 50000, + "outputTokens": 4191, + "totalTokens": 54191, + "actualCostMicros": 6000 + }, + "providers": [ + { + "provider": "anthropic", + "requestCount": 2, + "successCount": 2, + "errorCount": 0, + "totalTokens": 12191, + "actualCostMicros": 2000 + }, + { + "provider": "openai", + "requestCount": 4, + "successCount": 3, + "errorCount": 1, + "totalTokens": 42000, + "actualCostMicros": 4000 + } + ], + "events": [] + } +}"#; + +#[test] +fn upstream_budgeted_golden_fixture() { + let usage = parse_usage(UPSTREAM_BUDGETED_RESPONSE.as_bytes()).expect("golden parses"); + let result = build_result(&usage); + let reset = Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap(); + + let primary = &result.usage.primary; + assert!( + (primary.used_percent - 0.024).abs() < 1e-12, + "{}", + primary.used_percent + ); + assert_eq!(primary.resets_at, Some(reset)); + assert!(!primary.is_informational); + assert!(result.usage.secondary.is_none()); + + let cost = result.cost.as_ref().expect("monthly cost"); + assert!((cost.used - 0.006).abs() < 1e-12, "{}", cost.used); + assert_eq!(cost.limit, Some(25.0)); + assert_eq!(cost.resets_at, Some(reset)); + + let routed: Vec = rows(&result) + .into_iter() + .filter(|(section, ..)| section == "Routed providers") + .map(|(_, title, ..)| title) + .collect(); + assert_eq!(routed, ["openai", "anthropic"]); + assert_eq!( + rows(&result) + .into_iter() + .find(|(_, title, ..)| title == "Monthly budget"), + Some(row( + "Usage", + "Monthly budget", + "$0.006000 / $25.00", + Some("$24.994000 remaining") + )) + ); +} + /// Serve one canned HTTP response; the handle yields the lowercased request. fn provider_serving( status_line: &'static str, @@ -492,6 +638,12 @@ async fn errors_do_not_echo_the_response_body() { !error.to_string().contains("private-response"), "{status_line}" ); + let expected = if status_line.starts_with("401") { + ProviderStateKind::NeedsAuthentication + } else { + ProviderStateKind::Unknown + }; + assert_eq!(provider.error_state_kind(&error), expected, "{status_line}"); } } From d814f9d02f9beb1106ee5f0e4e1c75095e6b4b72 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:06:43 +0700 Subject: [PATCH 12/14] LithosAI: keep debts visible and try every browser session - Omit the Prepaid credits cost block for a negative balance. The shared block floors balances at zero, so a debt showed "$0.00"; the primary line and Balance row keep "-$1.00" (upstream keeps debts visible). - Own browser cookie resolution: Automatic now tries every browser that holds both __Host-console_session and __Host-console_csrf, in order, and moves to the next after a 401 at any request (upstream rejectCookie). A pasted header is still the only session tried, an empty Manual source still fails closed, and Off is unchanged. - When no browser session is usable and the browser read itself failed (for example App-Bound Encryption), report that error instead of the generic sign-in hint. --- docs/PROVIDERS.md | 12 +- rust/src/providers/lithosai/mod.rs | 172 ++++++++++++++++--------- rust/src/providers/lithosai/model.rs | 17 ++- rust/src/providers/lithosai/tests.rs | 184 +++++++++++++++++++++++++-- 4 files changed, 307 insertions(+), 78 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 28744183bf..d93e185d6b 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -141,16 +141,20 @@ signed-in console at `https://console.lithosai.cloud` with both session cookies, - The cookie source in Settings → Providers → LithosAI picks the session and defaults to Automatic. Automatic and Manual first use a header imported under Browser Cookies (from the browser you choose) or pasted there. Without one, - Automatic reads the `console.lithosai.cloud` cookies from the detected - browsers and Manual fails closed. Off reads nothing. + Automatic tries each detected browser holding both `console.lithosai.cloud` + cookies, in order, and moves to the next after a 401; Manual fails closed + and a pasted header is never swapped for a browser session. Off reads nothing. - Requests, in order: `GET /api/me` (active organization, email), then `GET /api/billing` and `GET /api/billing/spend?start=YYYY-MM-01&end=YYYY-MM-DD` (UTC month to date), both with `X-Organization-Id`. - The card shows the prepaid USD balance (1 USD = 1,000,000,000 nanos) as a "Prepaid credits" cost block and a **Billing** section: Balance, Payment card, - Account status, Today (UTC) and This month (UTC). There is no quota bar. + Account status, Today (UTC) and This month (UTC). There is no quota bar. A + negative balance has no cost block and stays visible as, for example, + "Balance: -$1.00" in the primary line and the Balance row. - A spend report that fails or does not parse shows "Spend: Unavailable" and - keeps the balance; a 401 anywhere reports an expired session. + keeps the balance; a 401 anywhere rejects that session, and an expired + session is reported once no candidate is left. - Response bodies and cookies are never echoed in errors or logs. ### llmman daemon diff --git a/rust/src/providers/lithosai/mod.rs b/rust/src/providers/lithosai/mod.rs index bdf401d63b..72add09bcf 100644 --- a/rust/src/providers/lithosai/mod.rs +++ b/rust/src/providers/lithosai/mod.rs @@ -5,9 +5,10 @@ //! key for billing: the console session cookies `__Host-console_session` and //! `__Host-console_csrf` read `/api/me`, `/api/billing` and the optional //! `/api/billing/spend`. The CSRF cookie value is echoed as `X-Console-Csrf`. -//! Upstream imports only Chrome; Windows uses the shell's explicit browser -//! selection or a pasted Cookie header. Response bodies are never echoed in -//! errors and the cookies are never logged. +//! A pasted or imported Cookie header is the only session tried. Without one, +//! Automatic tries every detected browser holding both console cookies, 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. mod model; #[cfg(test)] @@ -40,12 +41,14 @@ const SIGN_IN_URL: &str = "https://console.lithosai.cloud"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const MAX_RESPONSE_BYTES: usize = 512 * 1024; -/// Upstream's message after every offered session answered 401. Windows has -/// one session per refresh, so a 401 always ends here. +/// Upstream's message after every offered session answered 401. const SESSION_EXPIRED: &str = "LithosAI session expired. Sign in again or paste fresh cookies."; const MISSING_SESSION: &str = "Sign in to console.lithosai.cloud in the selected browser, or paste its Cookie header with __Host-console_session and __Host-console_csrf."; const COOKIES_OFF: &str = "LithosAI cookies are disabled."; +/// Every detected browser's Cookie header for the console, in browser order. +type BrowserCookieHeaders = dyn Fn() -> Result, ProviderError> + Sync; + pub struct LithosAIProvider { metadata: ProviderMetadata, client: Option, @@ -88,50 +91,71 @@ impl LithosAIProvider { } } + /// 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, now: DateTime, ) -> Result { - let session = session(ctx)?; + 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 LithosAI HTTP client.".into()) })?; - let console = Console { - client, - base_url: &self.base_url, - session: &session, - timeout: Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT), - }; - - let me = console.get("/api/me", None).await?; - let account = model::parse_me(&me)?; - let billing = console - .get("/api/billing", Some(&account.organization_id)) - .await?; - let billing = model::parse_billing(&billing)?; - - // Spend is optional: a broken report must not discard an - // authenticated balance, but an expired session still ends the fetch. - let (start, end) = model::spend_range(now); - let spend = match console - .get( - &format!("/api/billing/spend?start={start}&end={end}"), - Some(&account.organization_id), - ) - .await - { - Ok(report) => model::parse_spend(&report, &start, &end).ok(), - Err(error) if is_session_expired(&error) => return Err(error), - Err(error) => { - tracing::debug!(%error, "LithosAI spend report is unavailable"); - None + let timeout = Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT); + let mut last_error = None; + for session in &sessions { + let console = Console { + client, + base_url: &self.base_url, + session, + timeout, + }; + match fetch_session(&console, now).await { + Err(error) if is_session_expired(&error) => last_error = Some(error), + result => return result, } - }; - Ok(model::result(&account, &billing, spend)) + } + Err(last_error.unwrap_or_else(missing_session)) } } +/// One session's me → billing → spend sequence. +async fn fetch_session( + console: &Console<'_>, + now: DateTime, +) -> Result { + let me = console.get("/api/me", None).await?; + let account = model::parse_me(&me)?; + let billing = console + .get("/api/billing", Some(&account.organization_id)) + .await?; + let billing = model::parse_billing(&billing)?; + + // Spend is optional: a broken report must not discard an authenticated + // balance, but an expired session still rejects this session. + let (start, end) = model::spend_range(now); + let spend = match console + .get( + &format!("/api/billing/spend?start={start}&end={end}"), + Some(&account.organization_id), + ) + .await + { + Ok(report) => model::parse_spend(&report, &start, &end).ok(), + Err(error) if is_session_expired(&error) => return Err(error), + Err(error) => { + tracing::debug!(%error, "LithosAI spend report is unavailable"); + None + } + }; + Ok(model::result(&account, &billing, spend)) +} + impl Default for LithosAIProvider { fn default() -> Self { Self::new() @@ -150,7 +174,14 @@ impl Provider for LithosAIProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { - SourceMode::Auto | SourceMode::Web => self.fetch_web(ctx, Utc::now()).await, + SourceMode::Auto | SourceMode::Web => { + self.fetch_web( + ctx, + &|| crate::providers::browser_cookie_headers_for_domain(COOKIE_DOMAIN), + Utc::now(), + ) + .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())), @@ -172,6 +203,12 @@ impl Provider for LithosAIProvider { 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 @@ -191,26 +228,45 @@ struct Session { csrf: String, } -/// The pasted or shell-resolved Cookie header, else the selected browser's -/// console cookies. Upstream requires both console cookies. -fn session(ctx: &FetchContext) -> Result { - let header = match ctx.manual_cookie_header.as_deref() { - Some(header) => crate::providers::normalize_cookie_header(header), - None if ctx.manual_cookie_missing => None, - None => match crate::providers::browser_cookie_header(&[COOKIE_DOMAIN]) { - Ok(header) => crate::providers::normalize_cookie_header(&header), - Err(error) => { - tracing::debug!(%error, "LithosAI browser session is unavailable"); - None - } - }, - }; - header - .and_then(|header| session_from_header(&header)) - .ok_or_else(|| ProviderError::BrowserSignInRequired { - message: MISSING_SESSION.into(), - sign_in_url: SIGN_IN_URL.into(), - }) +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, "LithosAI browser sessions are unavailable"); + (Vec::new(), Some(error)) + } + } } fn session_from_header(header: &str) -> Option { diff --git a/rust/src/providers/lithosai/model.rs b/rust/src/providers/lithosai/model.rs index 8c32ca8bb6..592101e3f2 100644 --- a/rust/src/providers/lithosai/model.rs +++ b/rust/src/providers/lithosai/model.rs @@ -203,7 +203,8 @@ fn row(id: &str, title: &str, value: impl Into) -> Option rows.push(row("spend", "Spend", "Unavailable")), } - let cost = CostSnapshot::new(0.0, "USD", "Prepaid credits").with_balance(balance); - ProviderFetchResult::new(usage, "web") - .with_cost(cost) - .with_display_details(rows.into_iter().flatten()) + let result = ProviderFetchResult::new(usage, "web"); + // The shared cost block floors balances at zero, which would show a debt + // as "$0.00". Upstream keeps negative balances visible, so a debt is left + // to the primary line and the Balance row. + let result = if billing.balance_nanos >= 0 { + result.with_cost(CostSnapshot::new(0.0, "USD", "Prepaid credits").with_balance(balance)) + } else { + result + }; + result.with_display_details(rows.into_iter().flatten()) } diff --git a/rust/src/providers/lithosai/tests.rs b/rust/src/providers/lithosai/tests.rs index feb9d3e580..2032c910d1 100644 --- a/rust/src/providers/lithosai/tests.rs +++ b/rust/src/providers/lithosai/tests.rs @@ -51,6 +51,11 @@ fn serve(responses: Vec<(u16, String)>) -> (LithosAIProvider, JoinHandle Result, ProviderError> { + panic!("the browser must not be read") +} + fn context(header: &str) -> FetchContext { FetchContext { source_mode: SourceMode::Web, @@ -63,7 +68,9 @@ async fn fetch( responses: Vec<(u16, String)>, ) -> (Result, Vec) { let (provider, server) = serve(responses); - let result = provider.fetch_web(&context(SESSION_HEADER), now()).await; + let result = provider + .fetch_web(&context(SESSION_HEADER), &no_browser, now()) + .await; (result, server.join().expect("test server thread")) } @@ -185,16 +192,24 @@ async fn the_parity_pack_payload_shows_balance_and_month_spend() { #[tokio::test] async fn zero_debt_and_subcent_balances_are_not_invented_quotas() { for (balance, cost_balance, shown) in [ - ("0", 0.0, "$0.00"), - // The shared cost block floors balances at zero; the debt stays - // visible in the Balance row. - ("-1000000000", 0.0, "-$1.00"), - ("1000000", 0.001, "Less than $0.01"), + ("0", Some(0.0), "$0.00"), + // The shared cost block floors balances at zero, so a debt has no + // cost block and stays visible in the primary line and Balance row. + ("-1000000000", None, "-$1.00"), + ("1000000", Some(0.001), "Less than $0.01"), ] { let (result, _) = fetch(ok_responses(balance, EMPTY_SPEND)).await; let result = result.expect(balance); - assert_eq!(result.cost.as_ref().unwrap().balance, Some(cost_balance)); + assert_eq!( + result.cost.as_ref().map(|cost| cost.balance), + cost_balance.map(Some), + "{balance}" + ); assert!(result.usage.primary.is_informational); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some(format!("Balance: {shown}").as_str()) + ); let rows = rows(&result); assert_eq!(rows[0], ("Balance".to_owned(), shown.to_owned())); assert_eq!(rows[3], ("Today (UTC)".to_owned(), "$0.00".to_owned())); @@ -308,7 +323,7 @@ async fn an_expired_session_at_any_request_is_reported_as_expired() { responses[at - 1] = (401, "{}".to_owned()); let (provider, server) = serve(responses); let error = provider - .fetch_web(&context(SESSION_HEADER), now()) + .fetch_web(&context(SESSION_HEADER), &no_browser, now()) .await .expect_err("expired"); server.join().expect("test server thread"); @@ -335,7 +350,7 @@ async fn non_authentication_failures_map_to_upstream_messages() { ] { let (provider, server) = serve(vec![(status, "{}".to_owned())]); let error = provider - .fetch_web(&context(SESSION_HEADER), now()) + .fetch_web(&context(SESSION_HEADER), &no_browser, now()) .await .expect_err(message); server.join().expect("test server thread"); @@ -413,7 +428,7 @@ async fn both_console_cookies_are_required_before_any_request() { "session=other", ] { let error = provider - .fetch_web(&context(header), now()) + .fetch_web(&context(header), &no_browser, now()) .await .expect_err(header); assert!( @@ -428,11 +443,157 @@ async fn both_console_cookies_are_required_before_any_request() { ..FetchContext::default() }; assert!(matches!( - provider.fetch_web(&ctx, now()).await, + provider.fetch_web(&ctx, &no_browser, now()).await, Err(ProviderError::BrowserSignInRequired { .. }) )); } +const SESSION_A: &str = "__Host-console_session=fixture-a; __Host-console_csrf=csrf-a"; +const SESSION_B: &str = "__Host-console_session=fixture-b; __Host-console_csrf=csrf-b"; + +fn browser_context() -> FetchContext { + FetchContext { + source_mode: SourceMode::Auto, + ..FetchContext::default() + } +} + +fn two_browsers() -> Result, ProviderError> { + Ok(vec![ + ("Chrome".to_owned(), SESSION_A.to_owned()), + ("Edge".to_owned(), SESSION_B.to_owned()), + ]) +} + +#[tokio::test] +async fn automatic_import_skips_a_browser_without_both_console_cookies() { + let (provider, server) = serve(ok_responses("4707099860", EMPTY_SPEND)); + let browser = || { + Ok(vec![ + ( + "Chrome".to_owned(), + "__Host-console_session=fixture-a".to_owned(), + ), + ("Edge".to_owned(), SESSION_B.to_owned()), + ]) + }; + let result = provider + .fetch_web(&browser_context(), &browser, now()) + .await; + let requests = server.join().expect("test server thread"); + assert_eq!(rows(&result.expect("usage"))[0].1, "$4.71"); + assert_eq!(requests.len(), 3); + for request in &requests { + assert_eq!(header(request, "cookie"), Some(SESSION_B)); + assert_eq!(header(request, "x-console-csrf"), Some("csrf-b")); + } +} + +#[tokio::test] +async fn an_expired_browser_session_advances_to_the_next_browser() { + // Upstream `expired session advances to the next profile`: a 401 at any + // of the three requests rejects that session. + for at in 0..3 { + let mut responses = vec![(200, ME.to_owned()), (200, billing("4707099860"))]; + responses.truncate(at); + responses.push((401, "{}".to_owned())); + responses.extend(ok_responses("4707099860", EMPTY_SPEND)); + let (provider, server) = serve(responses); + let result = provider + .fetch_web(&browser_context(), &two_browsers, now()) + .await; + let requests = server.join().expect("test server thread"); + assert_eq!(rows(&result.expect("usage"))[0].1, "$4.71", "{at}"); + assert_eq!(requests.len(), at + 4, "{at}"); + for request in &requests[..=at] { + assert_eq!(header(request, "x-console-csrf"), Some("csrf-a"), "{at}"); + } + assert_eq!( + requests[at + 1].lines().next(), + Some("GET /api/me HTTP/1.1") + ); + for request in &requests[at + 1..] { + assert_eq!(header(request, "x-console-csrf"), Some("csrf-b"), "{at}"); + assert_eq!(header(request, "cookie"), Some(SESSION_B), "{at}"); + } + } +} + +#[tokio::test] +async fn every_expired_browser_session_reports_an_expired_session() { + let (provider, server) = serve(vec![(401, "{}".to_owned()), (401, "{}".to_owned())]); + let error = provider + .fetch_web(&browser_context(), &two_browsers, now()) + .await + .expect_err("expired"); + assert_eq!(server.join().expect("test server thread").len(), 2); + assert_eq!( + error.to_string(), + "LithosAI session expired. Sign in again or paste fresh cookies." + ); + assert_eq!( + provider.error_state_kind(&error), + ProviderStateKind::ExpiredSession + ); +} + +#[tokio::test] +async fn a_non_authentication_failure_does_not_try_the_next_browser() { + let (provider, server) = serve(vec![(403, "{}".to_owned())]); + let error = provider + .fetch_web(&browser_context(), &two_browsers, now()) + .await + .expect_err("denied"); + assert_eq!(server.join().expect("test server thread").len(), 1); + assert_eq!(error.to_string(), "LithosAI console access was denied."); +} + +#[tokio::test] +async fn an_expired_manual_header_never_reads_a_browser() { + let (provider, server) = serve(vec![(401, "{}".to_owned())]); + let error = provider + .fetch_web(&context(SESSION_HEADER), &no_browser, now()) + .await + .expect_err("expired"); + assert_eq!(server.join().expect("test server thread").len(), 1); + assert_eq!( + error.to_string(), + "LithosAI session expired. Sign in again or paste fresh cookies." + ); +} + +#[tokio::test] +async fn a_browser_read_failure_is_reported_when_no_session_is_usable() { + let provider = LithosAIProvider::with_client("http://127.0.0.1:9/", Client::new()); + let blocked = || { + Err(ProviderError::Other( + "Failed to read browser cookies: synthetic app-bound encryption".to_owned(), + )) + }; + assert_eq!( + provider + .fetch_web(&browser_context(), &blocked, now()) + .await + .unwrap_err() + .to_string(), + "Failed to read browser cookies: synthetic app-bound encryption" + ); + let no_cookies = || Err(ProviderError::NoCookies); + let csrf_only = || { + Ok(vec![( + "Chrome".to_owned(), + "__Host-console_csrf=csrf-a".to_owned(), + )]) + }; + for browser in [&no_cookies as &BrowserCookieHeaders, &csrf_only] { + let error = provider + .fetch_web(&browser_context(), browser, now()) + .await + .expect_err("no usable session"); + assert!(matches!(error, ProviderError::BrowserSignInRequired { .. })); + } +} + #[tokio::test] async fn disabled_cookies_and_oauth_never_fetch() { let provider = LithosAIProvider::with_client("http://127.0.0.1:9/", Client::new()); @@ -495,6 +656,7 @@ fn metadata_matches_the_upstream_descriptor() { Some("https://console.lithosai.cloud") ); assert!(!metadata.default_enabled); + assert!(provider.owns_browser_cookie_resolution()); assert_eq!( provider.available_sources(), vec![SourceMode::Auto, SourceMode::Web] From ad8e8023d52121b42bbfdf11cb8df3695d04e721 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:07:10 +0700 Subject: [PATCH 13/14] Synthetic: hide weekly cost like the Mac card - Stop attaching the weekly USD CostSnapshot to the fetch result. Upstream SyntheticProviderDescriptor hides the cost block on the card (ProviderCostPresentation(menuCardStyle: .hidden)), and core has no per-provider "hidden on card" flag. The parsed credits still drive the weekly regen line. As a result the weekly cost also leaves CLI JSON. QuotaCost.resets_at was only used for that snapshot and is removed. - Port upstream's "Synthetic fixture matches the cut-over golden" (ProviderPluginParityTests.swift) and "missing rolling lane keeps weekly and search slots" (SyntheticProviderTests.swift) as literal tests. Weekly used percent is 1.9411527777777593, not upstream's 1.9411527777777735: serde_json without float_roundtrip reads 98.05884722222223 one ULP high (1.4e-14, never visible). - Use the fixed NOW in the bearer-request test instead of Utc::now(). - docs/PROVIDERS.md: the weekly credits no longer give a USD cost. --- docs/PROVIDERS.md | 4 +- rust/src/providers/synthetic/mod.rs | 20 ++-- rust/src/providers/synthetic/tests.rs | 160 ++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 23 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 5da62bbfc2..d21855ae80 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -198,8 +198,8 @@ Synthetic (or `SYNTHETIC_API_KEY`); the CLI also accepts `synthetic.new`. - When the five-hour quota reports `tickPercent`, the lane resets at the next regen tick and its detail line reads, for example, "76% after next regen · Full in ~3 regens". The weekly lane does the same - when it carries `maxCredits` and `nextRegenCredits`, which also give a - weekly USD cost. + when it carries `maxCredits` and `nextRegenCredits`. Like the Mac card, no + weekly USD cost is shown (on the card or in CLI JSON). - The plan name (`plan`, `planName`, `tier`, ...) is shown as the plan. - 401 and 403 report "Invalid Synthetic API credentials."; response bodies are never echoed in errors. diff --git a/rust/src/providers/synthetic/mod.rs b/rust/src/providers/synthetic/mod.rs index 92490e1061..fbe8ff76ac 100644 --- a/rust/src/providers/synthetic/mod.rs +++ b/rust/src/providers/synthetic/mod.rs @@ -12,8 +12,8 @@ use serde_json::Value; use std::time::Duration; use crate::core::{ - CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -240,7 +240,6 @@ struct Quota { struct QuotaCost { used: f64, limit: f64, - resets_at: Option>, next_regen_amount: Option, } @@ -400,7 +399,6 @@ fn parse_quota(payload: &Value) -> Option { QuotaCost { used, limit, - resets_at, next_regen_amount: first_currency(payload, &["nextRegenCredits", "next_regen_credits"]), } }); @@ -712,15 +710,11 @@ impl ParsedQuotas { if let Some(plan) = &self.plan { usage = usage.with_login_method(plan.clone()); } - let mut result = ProviderFetchResult::new(usage, "api"); - if let Some(cost) = &self.cost { - let mut snapshot = CostSnapshot::new(cost.used, "USD", "Weekly").with_limit(cost.limit); - if let Some(resets_at) = cost.resets_at { - snapshot = snapshot.with_resets_at(resets_at); - } - result = result.with_cost(snapshot); - } - result + // The weekly credits only drive the weekly regen line above. Upstream + // hides the cost block on the Synthetic card + // (`ProviderCostPresentation(menuCardStyle: .hidden)`), so no cost is + // attached here. + ProviderFetchResult::new(usage, "api") } } diff --git a/rust/src/providers/synthetic/tests.rs b/rust/src/providers/synthetic/tests.rs index fea4bf8905..aa23df80de 100644 --- a/rust/src/providers/synthetic/tests.rs +++ b/rust/src/providers/synthetic/tests.rs @@ -245,12 +245,157 @@ fn weekly_cost_with_next_regen_amount_drives_the_weekly_regen_line() { assert!(weekly.description_is_detail); assert_eq!(weekly.window_minutes, None); assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); - let cost = result.cost.expect("weekly cost"); - assert_eq!(cost.period, "Weekly"); - assert_eq!(cost.currency_code, "USD"); - assert_eq!(cost.used, 400.0); - assert_eq!(cost.limit, Some(1000.0)); - assert_eq!(cost.resets_at, at(NOW + 86_400)); + // The Mac card hides Synthetic's cost block, so the credits never reach + // the result. + assert!(result.cost.is_none()); +} + +/// Upstream `ProviderPluginParityTests` "Synthetic fixture matches the +/// cut-over golden" payload. +const CUT_OVER_GOLDEN: &str = r#"{ + "plan": "Starter", + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.05884722222223, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 600, + "max": 750, + "limited": false + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } +}"#; + +fn at_millis(millis: i64) -> Option> { + Utc.timestamp_millis_opt(millis).single() +} + +#[test] +fn cut_over_golden_parses_like_upstream() { + let payload: Value = serde_json::from_str(CUT_OVER_GOLDEN).expect("golden is JSON"); + let parsed = parse_quotas(&payload).expect("golden parses"); + + let five_hour = parsed.primary.as_ref().expect("five-hour lane"); + assert_eq!(five_hour.used_percent, 20.0); + assert_eq!(five_hour.window_minutes, None); + assert_eq!(five_hour.resets_at, at(1_776_397_451)); + assert_eq!(five_hour.next_regen_percent, Some(5.0)); + + let weekly = parsed.secondary.as_ref().expect("weekly lane"); + // Upstream asserts 1.9411527777777735 (100 - 98.05884722222223). serde_json + // without `float_roundtrip` reads 98.05884722222223 one ULP high, so the + // difference here is 1.4e-14 and never visible on the card. + assert_eq!(weekly.used_percent, 1.9411527777777593); + assert_eq!(weekly.window_minutes, None); + assert_eq!(weekly.resets_at, at(1_776_403_170)); + + let search = parsed.tertiary.as_ref().expect("search lane"); + assert_eq!(search.used_percent, 0.8); + assert_eq!(search.window_minutes, None); + assert_eq!(search.resets_at, at_millis(1_776_400_201_494)); + + assert_eq!(parsed.plan.as_deref(), Some("Starter")); + // Upstream's providerCost numbers; parsed, but kept off the result. + assert_eq!( + parsed.cost, + Some(QuotaCost { + used: 0.7000000000000028, + limit: 36.0, + next_regen_amount: Some(0.72), + }) + ); +} + +#[test] +fn cut_over_golden_result_matches_the_mac_card() { + let payload: Value = serde_json::from_str(CUT_OVER_GOLDEN).expect("golden is JSON"); + let result = parse_quotas(&payload).expect("golden parses").into_result(); + let usage = &result.usage; + + // 80% left + 5% per tick; 20 / 5 = 4 ticks to full. + assert_eq!(usage.primary.used_percent, 20.0); + assert_eq!(usage.primary.window_minutes, None); + assert_eq!(usage.primary.resets_at, at(1_776_397_451)); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("85% after next regen · Full in ~4 regens") + ); + assert!(usage.primary.description_is_detail); + + // $0.72 / $36 = 2% per regen; $0.70 / $0.72 is under 1.5 ticks. + let weekly = usage.secondary.as_ref().expect("weekly lane"); + assert_eq!(weekly.used_percent, 1.9411527777777593); + assert_eq!(weekly.window_minutes, None); + assert_eq!(weekly.resets_at, at(1_776_403_170)); + assert_eq!( + weekly.reset_description.as_deref(), + Some("100% after next regen · Full in ~1 regen") + ); + assert!(weekly.description_is_detail); + + assert!(usage.tertiary.is_none()); + assert_eq!(usage.extra_rate_windows.len(), 1); + let search = &usage.extra_rate_windows[0]; + assert_eq!(search.id, "synthetic-search-hourly"); + assert_eq!(search.title, "Search hourly"); + assert_eq!(search.window.used_percent, 0.8); + assert_eq!(search.window.resets_at, at_millis(1_776_400_201_494)); + assert!(!search.window.description_is_detail); + + assert_eq!(usage.login_method.as_deref(), Some("Starter")); + assert!(result.cost.is_none()); +} + +/// Upstream `SyntheticPluginGoldenTests` "missing rolling lane keeps weekly +/// and search slots". +#[test] +fn missing_rolling_lane_keeps_weekly_and_search_slots() { + let payload = serde_json::json!({ + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.0, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + }); + let parsed = parse_quotas(&payload).expect("payload parses"); + assert!(parsed.primary.is_none()); + assert_eq!( + parsed.cost.as_ref().map(|cost| (cost.used, cost.limit)), + Some((0.7000000000000028, 36.0)) + ); + + let result = parsed.into_result(); + let usage = &result.usage; + assert!(usage.primary.is_informational); + let weekly = usage.secondary.as_ref().expect("weekly lane"); + assert_eq!(weekly.used_percent, 2.0); + assert_eq!( + weekly.reset_description.as_deref(), + Some("100% after next regen · Full in ~1 regen") + ); + assert_eq!(usage.extra_rate_windows.len(), 1); + assert_eq!(usage.extra_rate_windows[0].window.used_percent, 0.8); + assert!(result.cost.is_none()); } #[test] @@ -367,8 +512,7 @@ fn keyed_context() -> FetchContext { #[tokio::test] async fn sends_a_bearer_request_and_parses_the_pack_payload() { - let now = Utc::now().timestamp(); - let (provider, server) = provider_serving("200 OK", pack_payload(now).to_string()); + let (provider, server) = provider_serving("200 OK", pack_payload(NOW).to_string()); let result = provider .fetch_usage(&keyed_context()) From 7d80a3d0b76a77eb3e07c80c8ce1fbb617da95a0 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:48:52 +0700 Subject: [PATCH 14/14] Synthetic: expect its near-black icon to lift on dark surfaces Synthetic's Mac brand color #141414 is near-black, so the palette's brandColorOnDark lifts it on dark surfaces like v0 and TypeSafe; list it in the registry test. --- .../desktop-tauri/src/components/providers/providerIcons.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts index 5a737c2664..73d9e2670b 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts @@ -164,6 +164,7 @@ describe("provider icon registry", () => { "elevenlabs", "manus", "replicate", + "synthetic", "typesafe", "v0", ]);