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