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