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 1/7] 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 2/7] 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 3/7] 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 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 4/7] 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 5/7] 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 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 6/7] 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 7/7] 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", ]);