From 3fb8aa4ae444e54e1f2ec10e1ca937afb5761635 Mon Sep 17 00:00:00 2001 From: NessZerra Date: Tue, 21 Jul 2026 09:38:32 +0700 Subject: [PATCH] feat(providers): add ZenMux, ClinePass, LongCat, Neuralwatt (0.44 port) Port four upstream 0.44 providers: ZenMux Management API quotas, ClinePass usage-limits, LongCat cookie quota/fuel packs, and Neuralwatt kWh/credits. --- .../icons/ProviderIcon-clinepass.svg | 4 + .../providers/icons/ProviderIcon-longcat.svg | 4 + .../icons/ProviderIcon-neuralwatt.svg | 3 + .../providers/icons/ProviderIcon-zenmux.svg | 3 + .../src/components/providers/providerIcons.ts | 18 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 4 +- .../surfaces/settings/tabs/ProvidersTab.tsx | 3 + .../desktop-tauri/src/test/providerCatalog.ts | 4 + apps/desktop-tauri/src/types/bridge.ts | 4 + rust/src/core/provider.rs | 28 ++ rust/src/core/provider_factory.rs | 25 +- rust/src/core/token_accounts.rs | 31 ++ rust/src/providers/clinepass/mod.rs | 216 +++++++++++ rust/src/providers/longcat/mod.rs | 301 +++++++++++++++ rust/src/providers/mod.rs | 8 + rust/src/providers/neuralwatt/mod.rs | 363 ++++++++++++++++++ rust/src/providers/zenmux/mod.rs | 298 ++++++++++++++ rust/src/settings/api_keys.rs | 31 ++ 18 files changed, 1336 insertions(+), 12 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-clinepass.svg create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-longcat.svg create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-neuralwatt.svg create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-zenmux.svg create mode 100644 rust/src/providers/clinepass/mod.rs create mode 100644 rust/src/providers/longcat/mod.rs create mode 100644 rust/src/providers/neuralwatt/mod.rs create mode 100644 rust/src/providers/zenmux/mod.rs diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-clinepass.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-clinepass.svg new file mode 100644 index 0000000000..1ce7fe20be --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-clinepass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-longcat.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-longcat.svg new file mode 100644 index 0000000000..dd1201c95e --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-longcat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-neuralwatt.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-neuralwatt.svg new file mode 100644 index 0000000000..cf43777aca --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-neuralwatt.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-zenmux.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-zenmux.svg new file mode 100644 index 0000000000..3dc2a97c65 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-zenmux.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 4252a1d68e..9c97d56bf0 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -18,6 +18,10 @@ import crossmodel from "./icons/ProviderIcon-crossmodel.svg?raw"; import cursor from "./icons/ProviderIcon-cursor.svg?raw"; import deepgram from "./icons/ProviderIcon-deepgram.svg?raw"; import deepinfra from "./icons/ProviderIcon-deepinfra.svg?raw"; +import clinepass from "./icons/ProviderIcon-clinepass.svg?raw"; +import longcat from "./icons/ProviderIcon-longcat.svg?raw"; +import neuralwatt from "./icons/ProviderIcon-neuralwatt.svg?raw"; +import zenmux from "./icons/ProviderIcon-zenmux.svg?raw"; import deepseek from "./icons/ProviderIcon-deepseek.svg?raw"; import doubao from "./icons/ProviderIcon-doubao.svg?raw"; import elevenlabs from "./icons/ProviderIcon-elevenlabs.svg?raw"; @@ -91,6 +95,10 @@ const RAW: Record = { cursor: tint(cursor), deepgram: tint(deepgram), deepinfra: tint(deepinfra), + clinepass: tint(clinepass), + longcat: tint(longcat), + neuralwatt: tint(neuralwatt), + zenmux: tint(zenmux), deepseek: tint(deepseek), doubao: tint(doubao), elevenlabs: tint(elevenlabs), @@ -142,6 +150,10 @@ export const PROVIDER_ICON_REGISTRY: Record = { cursor: { id: "cursor", brandColor: "#00bfa5", fallbackLetter: "▸", svgPath: RAW.cursor }, deepgram: { id: "deepgram", brandColor: "#13ef93", fallbackLetter: "D", svgPath: RAW.deepgram }, deepinfra: { id: "deepinfra", brandColor: "#2a3275", fallbackLetter: "D", svgPath: RAW.deepinfra }, + clinepass: { id: "clinepass", brandColor: "#61a3fa", fallbackLetter: "C", svgPath: RAW.clinepass }, + longcat: { id: "longcat", brandColor: "#ffd100", fallbackLetter: "L", svgPath: RAW.longcat }, + neuralwatt: { id: "neuralwatt", brandColor: "#38d98c", fallbackLetter: "N", svgPath: RAW.neuralwatt }, + zenmux: { id: "zenmux", brandColor: "#6c5ce7", fallbackLetter: "Z", svgPath: RAW.zenmux }, deepseek: { id: "deepseek", brandColor: "#527df0", fallbackLetter: "D", svgPath: RAW.deepseek }, elevenlabs: { id: "elevenlabs", brandColor: "#111827", fallbackLetter: "E", svgPath: RAW.elevenlabs }, factory: { id: "factory", brandColor: "#ff6b35", fallbackLetter: "◎", svgPath: RAW.factory }, @@ -217,6 +229,12 @@ const ALIASES: Record = { "deep infra": "deepinfra", "deep-infra": "deepinfra", di: "deepinfra", + "zen-mux": "zenmux", + "cline-pass": "clinepass", + "long-cat": "longcat", + lc: "longcat", + "neural-watt": "neuralwatt", + nw: "neuralwatt", codeium: "windsurf", "xiaomi mimo": "mimo", xiaomimimo: "mimo", diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 0a0e614524..3b2a4b20bf 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -38,7 +38,7 @@ import AgentSessions from "../components/AgentSessions"; const HAS_DASHBOARD = new Set([ "abacus", "alibaba", "alibabatokenplan", "amp", "augment", "azureopenai", "bedrock", "claude", "codex", "codebuff", - "commandcode", "copilot", "crof", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", + "commandcode", "copilot", "crof", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "mimo", "minimax", "mistral", "nanogpt", "ollama", "openaiapi", @@ -49,7 +49,7 @@ const HAS_DASHBOARD = new Set([ /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ "alibabatokenplan", "amp", "augment", "azureopenai", "bedrock", - "claude", "codex", "copilot", "deepgram", "deepinfra", "deepseek", "elevenlabs", + "claude", "codex", "copilot", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "elevenlabs", "gemini", "grok", "groq", "kiro", "mistral", "openaiapi", "openrouter", "vertexai", "windsurf", ]); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index cb4f05c312..fb030f12ec 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -230,6 +230,9 @@ function providerSourceHintShort( case "nanogpt": case "warp": case "deepinfra": + case "zenmux": + case "clinepass": + case "neuralwatt": case "doubao": case "crof": case "stepfun": diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 972b0e4ba3..d158e3417c 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -34,6 +34,10 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["codebuff", "Codebuff"], ["deepseek", "DeepSeek"], ["deepinfra", "DeepInfra"], + ["zenmux", "ZenMux"], + ["clinepass", "ClinePass"], + ["longcat", "LongCat"], + ["neuralwatt", "Neuralwatt"], ["windsurf", "Windsurf"], ["manus", "Manus"], ["mimo", "Xiaomi MiMo"], diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 13c3799f34..4454995fd0 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -90,6 +90,10 @@ export type ProofProviderId = | "codebuff" | "deepseek" | "deepinfra" + | "zenmux" + | "clinepass" + | "longcat" + | "neuralwatt" | "windsurf" | "manus" | "mimo" diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 18ad2cc9ad..19c283c200 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -70,6 +70,10 @@ pub enum ProviderId { Sakana, Sub2Api, Wayfinder, + ZenMux, + ClinePass, + LongCat, + Neuralwatt, } impl ProviderId { @@ -135,6 +139,10 @@ impl ProviderId { ProviderId::Sakana, ProviderId::Sub2Api, ProviderId::Wayfinder, + ProviderId::ZenMux, + ProviderId::ClinePass, + ProviderId::LongCat, + ProviderId::Neuralwatt, ] } @@ -200,6 +208,10 @@ impl ProviderId { ProviderId::Sakana => "sakana", ProviderId::Sub2Api => "sub2api", ProviderId::Wayfinder => "wayfinder", + ProviderId::ZenMux => "zenmux", + ProviderId::ClinePass => "clinepass", + ProviderId::LongCat => "longcat", + ProviderId::Neuralwatt => "neuralwatt", } } @@ -265,6 +277,10 @@ impl ProviderId { ProviderId::Sakana => "Sakana AI", ProviderId::Sub2Api => "sub2api", ProviderId::Wayfinder => "Wayfinder", + ProviderId::ZenMux => "ZenMux", + ProviderId::ClinePass => "ClinePass", + ProviderId::LongCat => "LongCat", + ProviderId::Neuralwatt => "Neuralwatt", } } @@ -302,6 +318,7 @@ impl ProviderId { ProviderId::Grok => Some("grok.com"), ProviderId::Qoder => Some("qoder.com"), ProviderId::Sakana => Some("console.sakana.ai"), + ProviderId::LongCat => Some("longcat.chat"), // Token-based providers (don't use cookies) ProviderId::Copilot => None, ProviderId::Zai => None, @@ -335,6 +352,9 @@ impl ProviderId { ProviderId::CrossModel => None, ProviderId::Sub2Api => None, ProviderId::Wayfinder => None, + ProviderId::ZenMux => None, + ProviderId::ClinePass => None, + ProviderId::Neuralwatt => None, } } @@ -407,6 +427,10 @@ impl ProviderId { "sakana" | "sakana-ai" | "sakana ai" => Some(ProviderId::Sakana), "sub2api" | "sub-2-api" | "sub 2 api" => Some(ProviderId::Sub2Api), "wayfinder" => Some(ProviderId::Wayfinder), + "zenmux" | "zen-mux" => Some(ProviderId::ZenMux), + "clinepass" | "cline-pass" | "cline" => Some(ProviderId::ClinePass), + "longcat" | "long-cat" | "lc" => Some(ProviderId::LongCat), + "neuralwatt" | "neural-watt" | "nw" | "neural" => Some(ProviderId::Neuralwatt), _ => None, } } @@ -682,6 +706,10 @@ mod tests { assert!(all.contains(&ProviderId::Sakana)); assert!(all.contains(&ProviderId::Sub2Api)); assert!(all.contains(&ProviderId::Wayfinder)); + assert!(all.contains(&ProviderId::ZenMux)); + assert!(all.contains(&ProviderId::ClinePass)); + assert!(all.contains(&ProviderId::LongCat)); + assert!(all.contains(&ProviderId::Neuralwatt)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 84514ea40c..a5ff4c2094 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -8,16 +8,17 @@ use super::{Provider, ProviderId}; use crate::providers::{ AbacusProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, - AugmentProvider, AzureOpenAIProvider, BedrockProvider, ChutesProvider, ClaudeProvider, - CodebuffProvider, CodexProvider, CommandCodeProvider, CopilotProvider, CrofProvider, - CrossModelProvider, CursorProvider, DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider, - DoubaoProvider, ElevenLabsProvider, FactoryProvider, GeminiProvider, GrokProvider, - GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, - KiroProvider, LLMProxyProvider, LiteLLMProvider, ManusProvider, MiMoProvider, MiniMaxProvider, - MistralProvider, NanoGPTProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, - OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, - SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider, - VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, ZaiProvider, ZedProvider, + AugmentProvider, AzureOpenAIProvider, BedrockProvider, ChutesProvider, ClinePassProvider, + ClaudeProvider, CodebuffProvider, CodexProvider, CommandCodeProvider, CopilotProvider, + CrofProvider, CrossModelProvider, CursorProvider, DeepInfraProvider, DeepSeekProvider, + DeepgramProvider, DevinProvider, DoubaoProvider, ElevenLabsProvider, FactoryProvider, + GeminiProvider, GrokProvider, GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, + KimiK2Provider, KimiProvider, KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, + ManusProvider, MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, + NeuralwattProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, + OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, SakanaProvider, + StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider, VertexAIProvider, + WarpProvider, WayfinderProvider, WindsurfProvider, ZaiProvider, ZedProvider, ZenMuxProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -85,6 +86,10 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Sakana => Box::new(SakanaProvider::new()), ProviderId::Sub2Api => Box::new(Sub2ApiProvider::new()), ProviderId::Wayfinder => Box::new(WayfinderProvider::new()), + ProviderId::ZenMux => Box::new(ZenMuxProvider::new()), + ProviderId::ClinePass => Box::new(ClinePassProvider::new()), + ProviderId::LongCat => Box::new(LongCatProvider::new()), + ProviderId::Neuralwatt => Box::new(NeuralwattProvider::new()), } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 3b53b8b46e..56384039a5 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -209,6 +209,36 @@ impl TokenAccountSupport { requires_manual_cookie_source: false, cookie_name: None, }), + ProviderId::ZenMux => Some(TokenAccountSupport { + title: "API keys", + subtitle: "Store multiple ZenMux Management API keys.", + placeholder: "Management API key", + injection: TokenInjection::Environment { + key: "ZENMUX_MANAGEMENT_API_KEY".to_string(), + }, + requires_manual_cookie_source: false, + cookie_name: None, + }), + ProviderId::ClinePass => Some(TokenAccountSupport { + title: "API keys", + subtitle: "Store multiple ClinePass API keys.", + placeholder: "API key", + injection: TokenInjection::Environment { + key: "CLINEPASS_API_KEY".to_string(), + }, + requires_manual_cookie_source: false, + cookie_name: None, + }), + ProviderId::Neuralwatt => Some(TokenAccountSupport { + title: "API keys", + subtitle: "Store multiple Neuralwatt API keys.", + placeholder: "API key", + injection: TokenInjection::Environment { + key: "NEURALWATT_API_KEY".to_string(), + }, + requires_manual_cookie_source: false, + cookie_name: None, + }), // Upstream 0.45 #2271: labeled OpenRouter API keys via token accounts. ProviderId::OpenRouter => Some(TokenAccountSupport { title: "API keys", @@ -267,6 +297,7 @@ impl TokenAccountSupport { | ProviderId::Devin | ProviderId::Zed | ProviderId::CrossModel + | ProviderId::LongCat | ProviderId::Wayfinder => None, } } diff --git a/rust/src/providers/clinepass/mod.rs b/rust/src/providers/clinepass/mod.rs new file mode 100644 index 0000000000..c22f0f2e7d --- /dev/null +++ b/rust/src/providers/clinepass/mod.rs @@ -0,0 +1,216 @@ +//! ClinePass API-key usage provider (upstream 0.44 #2219). +//! +//! `GET https://api.cline.bot/api/v1/users/me/plan/usage-limits` + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde::Deserialize; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, +}; + +const USAGE_URL: &str = "https://api.cline.bot/api/v1/users/me/plan/usage-limits"; +const CREDENTIAL_TARGET: &str = "codexbar-clinepass"; +const ENV_KEYS: &[&str] = &["CLINEPASS_API_KEY", "CLINE_API_KEY"]; + +#[derive(Debug, Deserialize)] +struct LimitsResponse { + success: bool, + data: LimitsData, +} + +#[derive(Debug, Deserialize)] +struct LimitsData { + limits: Vec, +} + +#[derive(Debug, Deserialize)] +struct LimitEntry { + #[serde(rename = "type")] + limit_type: String, + #[serde(rename = "percentUsed")] + percent_used: f64, + #[serde(rename = "resetsAt")] + resets_at: Option, +} + +pub struct ClinePassProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl ClinePassProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::ClinePass, + display_name: "ClinePass", + session_label: "5-hour", + weekly_label: "Weekly", + supports_opus: true, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some( + "https://app.cline.bot/dashboard/subscription?personal=true", + ), + status_page_url: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } +} + +impl Default for ClinePassProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for ClinePassProvider { + fn id(&self) -> ProviderId { + ProviderId::ClinePass + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => { + let key = crate::providers::resolve_api_key( + ctx.api_key.as_deref(), + CREDENTIAL_TARGET, + ENV_KEYS, + )?; + let resp = self + .client + .get(USAGE_URL) + .bearer_auth(key) + .header("Accept", "application/json") + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "ClinePass API error: HTTP {status}" + ))); + } + let body: LimitsResponse = resp.json().await.map_err(|e| { + ProviderError::Parse(format!("Failed to parse ClinePass usage: {e}")) + })?; + let snap = snapshot_from_limits(&body)?; + Ok(ProviderFetchResult::new(snap, "api")) + } + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +fn parse_iso(raw: Option<&str>) -> Option> { + let raw = raw?.trim(); + if raw.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|d| d.with_timezone(&Utc)) +} + +fn window_for(entry: &LimitEntry) -> Option<(RateWindow, &'static str)> { + let minutes = match entry.limit_type.as_str() { + "five_hour" => Some(5 * 60), + "weekly" => Some(7 * 24 * 60), + "monthly" => Some(30 * 24 * 60), + _ => None, + }?; + let mut w = RateWindow::new(entry.percent_used.clamp(0.0, 100.0)); + w.window_minutes = Some(minutes); + w.resets_at = parse_iso(entry.resets_at.as_deref()); + let slot = match entry.limit_type.as_str() { + "five_hour" => "primary", + "weekly" => "secondary", + "monthly" => "tertiary", + _ => return None, + }; + Some((w, slot)) +} + +fn snapshot_from_limits(body: &LimitsResponse) -> Result { + if !body.success { + return Err(ProviderError::Parse( + "ClinePass response success was false".into(), + )); + } + let mut primary = None; + let mut secondary = None; + let mut tertiary = None; + for limit in &body.data.limits { + if let Some((w, slot)) = window_for(limit) { + match slot { + "primary" => primary = Some(w), + "secondary" => secondary = Some(w), + "tertiary" => tertiary = Some(w), + _ => {} + } + } + } + let primary = primary.ok_or_else(|| { + ProviderError::Parse("ClinePass response missing five_hour window".into()) + })?; + let mut snap = UsageSnapshot::new(primary).with_login_method("API key"); + if let Some(s) = secondary { + snap = snap.with_secondary(s); + } + if let Some(t) = tertiary { + snap = snap.with_tertiary(t); + } + Ok(snap) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ignores_unknown_limit_types() { + let body: LimitsResponse = serde_json::from_str( + r#"{ + "success": true, + "data": { + "limits": [ + { "type": "five_hour", "percentUsed": 12.5, "resetsAt": "2026-07-16T15:00:00Z" }, + { "type": "experimental_pool", "percentUsed": 77, "resetsAt": "2026-07-16T15:00:00Z" }, + { "type": "weekly", "percentUsed": 25, "resetsAt": "2026-07-20T00:00:00Z" }, + { "type": "monthly", "percentUsed": 40, "resetsAt": null } + ] + } + }"#, + ) + .unwrap(); + let snap = snapshot_from_limits(&body).unwrap(); + assert!((snap.primary.used_percent - 12.5).abs() < 0.01); + assert_eq!(snap.primary.window_minutes, Some(300)); + assert!((snap.secondary.as_ref().unwrap().used_percent - 25.0).abs() < 0.01); + assert!((snap.tertiary.as_ref().unwrap().used_percent - 40.0).abs() < 0.01); + } +} diff --git a/rust/src/providers/longcat/mod.rs b/rust/src/providers/longcat/mod.rs new file mode 100644 index 0000000000..5e0cc05462 --- /dev/null +++ b/rust/src/providers/longcat/mod.rs @@ -0,0 +1,301 @@ +//! LongCat cookie-based quota + fuel-pack provider (upstream 0.44 #1697). +//! +//! Default-disabled. Auth via manual cookie or browser import for longcat.chat. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde_json::Value; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, +}; + +const HOST: &str = "https://longcat.chat"; +const USER_CURRENT: &str = "/api/v1/user-current"; +const TOKEN_USAGE: &str = "/api/lc-platform/v1/tokenUsage"; +const PENDING_FUEL: &str = "/api/lc-platform/v1/pending-fuel-packages"; + +pub struct LongCatProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl LongCatProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::LongCat, + display_name: "LongCat", + session_label: "Quota", + weekly_label: "Fuel Pack", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://longcat.chat/platform/"), + status_page_url: None, + }, + // Isolated cookie-free client — auth is only the explicit Cookie header. + client: crate::core::credentialed_http_client_builder() + .cookie_store(false) + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + async fn get_json(&self, path: &str, cookie: &str) -> Result { + let url = format!("{HOST}{path}"); + let resp = self + .client + .get(&url) + .header("Cookie", cookie) + .header("Accept", "application/json") + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "LongCat API {path} returned HTTP {status}" + ))); + } + resp.json() + .await + .map_err(|e| ProviderError::Parse(format!("Failed to parse LongCat {path}: {e}"))) + } +} + +impl Default for LongCatProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for LongCatProvider { + fn id(&self) -> ProviderId { + ProviderId::LongCat + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Web => { + let cookie = match ctx.manual_cookie_header.as_deref() { + Some(c) => normalize_cookie_header(c).ok_or(ProviderError::NoCookies)?, + None => crate::providers::browser_cookie_header(&["longcat.chat"])?, + }; + let account = self.get_json(USER_CURRENT, &cookie).await?; + // Meituan-style envelope may return HTTP 200 with business 401. + if let Some(code) = envelope_code(&account) { + if code == 401 || code == 403 { + return Err(ProviderError::AuthRequired); + } + } + let usage_raw = self.get_json(TOKEN_USAGE, &cookie).await?; + let fuel = match self.get_json(PENDING_FUEL, &cookie).await { + Ok(v) => Some(v), + Err(ProviderError::AuthRequired) => return Err(ProviderError::AuthRequired), + Err(_) => None, + }; + let snap = build_snapshot(&account, &usage_raw, fuel.as_ref())?; + Ok(ProviderFetchResult::new(snap, "web")) + } + SourceMode::Cli | SourceMode::OAuth => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Web] + } +} + +fn normalize_cookie_header(raw: &str) -> Option { + let mut header = raw.trim().to_string(); + if header + .get(.."cookie:".len()) + .is_some_and(|p| p.eq_ignore_ascii_case("cookie:")) + { + header = header["cookie:".len()..].trim().to_string(); + } + (!header.is_empty()).then_some(header) +} + +fn envelope_code(value: &Value) -> Option { + value + .get("code") + .and_then(|c| c.as_i64()) + .or_else(|| value.get("status").and_then(|c| c.as_i64())) +} + +fn envelope_data(value: &Value) -> &Value { + value.get("data").unwrap_or(value) +} + +fn json_f64(value: &Value, key: &str) -> Option { + let v = value.get(key)?; + v.as_f64() + .or_else(|| v.as_i64().map(|i| i as f64)) + .or_else(|| v.as_str()?.parse().ok()) +} + +fn json_str(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn build_snapshot( + account: &Value, + usage_raw: &Value, + fuel_raw: Option<&Value>, +) -> Result { + let account_data = envelope_data(account); + let usage_outer = envelope_data(usage_raw); + let usage = usage_outer + .get("usage") + .filter(|u| u.is_object()) + .unwrap_or(usage_outer); + + let total = json_f64(usage, "totalToken").ok_or_else(|| { + ProviderError::Parse("tokenUsage data was missing totalToken".into()) + })?; + let remaining = json_f64(usage, "availableToken"); + let used = remaining.map(|r| (total - r).max(0.0)).unwrap_or(0.0); + + let primary = if total > 0.0 { + let mut w = RateWindow::new(((used / total) * 100.0).clamp(0.0, 100.0)); + w.reset_description = Some(format!("{}/{}", used as i64, total as i64)); + w + } else { + RateWindow::informational("No token quota") + }; + + let account_name = json_str(account_data, "name") + .or_else(|| json_str(account_data, "nickname")) + .or_else(|| json_str(account_data, "userName")); + + let mut snap = UsageSnapshot::new(primary); + if let Some(name) = account_name { + snap.account_organization = Some(name); + } + + if let Some(fuel_raw) = fuel_raw { + let fuel_data = envelope_data(fuel_raw); + if let Some((total_fuel, remaining_fuel, expiry)) = parse_fuel(fuel_data) { + if total_fuel > 0.0 { + let used_fuel = (total_fuel - remaining_fuel).max(0.0); + let mut secondary = + RateWindow::new(((used_fuel / total_fuel) * 100.0).clamp(0.0, 100.0)); + secondary.resets_at = expiry; + secondary.reset_description = Some(format!( + "Fuel pack: {}/{}", + remaining_fuel as i64, total_fuel as i64 + )); + snap = snap.with_secondary(secondary); + } + } + } + + Ok(snap) +} + +fn parse_fuel(fuel: &Value) -> Option<(f64, f64, Option>)> { + // Accept either { packages: [...] } or a bare array. + let packages = fuel + .get("packages") + .and_then(|p| p.as_array()) + .or_else(|| fuel.as_array())?; + + let mut total = 0.0; + let mut remaining = 0.0; + let mut saw_remaining = false; + let mut nearest: Option> = None; + + for pkg in packages { + if let Some(t) = json_f64(pkg, "totalToken") + .or_else(|| json_f64(pkg, "total")) + .or_else(|| json_f64(pkg, "amount")) + { + total += t.max(0.0); + } + if let Some(r) = json_f64(pkg, "availableToken") + .or_else(|| json_f64(pkg, "remainingToken")) + .or_else(|| json_f64(pkg, "remaining")) + { + remaining += r.max(0.0); + saw_remaining = true; + } + if let Some(raw) = json_str(pkg, "expireTime") + .or_else(|| json_str(pkg, "expireAt")) + .or_else(|| json_str(pkg, "expiresAt")) + { + if let Ok(dt) = DateTime::parse_from_rfc3339(&raw) { + let utc = dt.with_timezone(&Utc); + nearest = Some(match nearest { + Some(n) if n < utc => n, + _ => utc, + }); + } + } + } + + if total <= 0.0 && !saw_remaining { + return None; + } + if total <= 0.0 { + total = remaining; + } + let remaining = if saw_remaining { remaining } else { total }; + Some((total, remaining, nearest)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn builds_quota_and_fuel() { + let account = json!({ "code": 0, "data": { "name": "cat" } }); + let usage = json!({ + "code": 0, + "data": { + "usage": { "totalToken": 1000, "availableToken": 250 } + } + }); + let fuel = json!({ + "code": 0, + "data": { + "packages": [ + { "totalToken": 200, "availableToken": 50, "expireTime": "2026-08-01T00:00:00Z" } + ] + } + }); + let snap = build_snapshot(&account, &usage, Some(&fuel)).unwrap(); + assert!((snap.primary.used_percent - 75.0).abs() < 0.01); + assert_eq!(snap.account_organization.as_deref(), Some("cat")); + let fuel_w = snap.secondary.unwrap(); + assert!((fuel_w.used_percent - 75.0).abs() < 0.01); + } + + #[test] + fn normalizes_cookie() { + assert_eq!( + normalize_cookie_header("Cookie: a=1; b=2").as_deref(), + Some("a=1; b=2") + ); + } +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index c626404be8..4a349f776c 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -11,6 +11,7 @@ pub mod augment; pub mod azureopenai; pub mod bedrock; pub mod chutes; +pub mod clinepass; pub mod claude; pub mod codebuff; pub mod codex; @@ -37,11 +38,13 @@ pub mod kimik2; pub mod kiro; pub mod litellm; pub mod llmproxy; +pub mod longcat; pub mod manus; pub mod mimo; pub mod minimax; pub mod mistral; pub mod nanogpt; +pub mod neuralwatt; pub mod ollama; pub mod openai; pub mod openaiapi; @@ -62,6 +65,7 @@ pub mod wayfinder; pub mod windsurf; pub mod zai; pub mod zed; +pub mod zenmux; // Re-export provider implementations pub use abacus::AbacusProvider; @@ -73,6 +77,7 @@ pub use augment::AugmentProvider; pub use azureopenai::AzureOpenAIProvider; pub use bedrock::BedrockProvider; pub use chutes::ChutesProvider; +pub use clinepass::ClinePassProvider; pub use claude::ClaudeProvider; pub use codebuff::CodebuffProvider; pub use codex::CodexProvider; @@ -98,12 +103,14 @@ pub use kimi::KimiProvider; pub use kimik2::KimiK2Provider; pub use kiro::KiroProvider; pub use litellm::LiteLLMProvider; +pub use longcat::LongCatProvider; pub use llmproxy::LLMProxyProvider; pub use manus::ManusProvider; pub use mimo::MiMoProvider; pub use minimax::{MiniMaxProvider, MiniMaxRegion}; pub use mistral::MistralProvider; pub use nanogpt::NanoGPTProvider; +pub use neuralwatt::NeuralwattProvider; pub use ollama::OllamaProvider; pub use openaiapi::OpenAIApiProvider; pub use opencode::OpenCodeProvider; @@ -123,6 +130,7 @@ pub use wayfinder::WayfinderProvider; pub use windsurf::WindsurfProvider; pub use zai::ZaiProvider; pub use zed::ZedProvider; +pub use zenmux::ZenMuxProvider; pub(crate) fn browser_cookie_header( domains: &[&str], diff --git a/rust/src/providers/neuralwatt/mod.rs b/rust/src/providers/neuralwatt/mod.rs new file mode 100644 index 0000000000..e4324593c5 --- /dev/null +++ b/rust/src/providers/neuralwatt/mod.rs @@ -0,0 +1,363 @@ +//! Neuralwatt API-key usage provider (upstream 0.44 #2220). +//! +//! `GET https://api.neuralwatt.com/v1/quota` — subscription kWh + prepaid credits. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde::Deserialize; + +use crate::core::{ + CostSnapshot, FetchContext, NamedRateWindow, Provider, ProviderError, ProviderFetchResult, + ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; + +const DEFAULT_API_BASE: &str = "https://api.neuralwatt.com"; +const CREDENTIAL_TARGET: &str = "codexbar-neuralwatt"; +const ENV_KEYS: &[&str] = &["NEURALWATT_API_KEY"]; + +#[derive(Debug, Deserialize, Default)] +struct QuotaResponse { + balance: Option, + usage: Option, + subscription: Option, + key: Option, +} + +#[derive(Debug, Deserialize, Default, Clone)] +struct Balance { + credits_remaining_usd: Option, + total_credits_usd: Option, + credits_used_usd: Option, + accounting_method: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct Usage { + current_month: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct UsagePeriod { + cost_usd: Option, + energy_kwh: Option, +} + +#[derive(Debug, Deserialize, Default, Clone)] +struct Subscription { + plan: Option, + status: Option, + current_period_start: Option, + current_period_end: Option, + kwh_included: Option, + kwh_used: Option, + kwh_remaining: Option, + #[allow(dead_code)] + in_overage: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct KeyInfo { + allowance: Option, +} + +#[derive(Debug, Deserialize, Default, Clone)] +struct KeyAllowance { + limit_usd: Option, + period: Option, + spent_usd: Option, + remaining_usd: Option, + blocked: Option, +} + +pub struct NeuralwattProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl NeuralwattProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Neuralwatt, + display_name: "Neuralwatt", + session_label: "Subscription", + weekly_label: "Key allowance", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://portal.neuralwatt.com/dashboard"), + status_page_url: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + fn quota_url() -> String { + let base = std::env::var("NEURALWATT_API_URL") + .ok() + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_API_BASE.to_string()); + if base.ends_with("/v1") { + format!("{base}/quota") + } else { + format!("{base}/v1/quota") + } + } +} + +impl Default for NeuralwattProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for NeuralwattProvider { + fn id(&self) -> ProviderId { + ProviderId::Neuralwatt + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => { + let key = crate::providers::resolve_api_key( + ctx.api_key.as_deref(), + CREDENTIAL_TARGET, + ENV_KEYS, + )?; + let resp = self + .client + .get(Self::quota_url()) + .bearer_auth(key) + .header("Accept", "application/json") + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "Neuralwatt API error: HTTP {status}" + ))); + } + let body: QuotaResponse = resp.json().await.map_err(|e| { + ProviderError::Parse(format!("Failed to parse Neuralwatt quota: {e}")) + })?; + let (snap, cost) = snapshot_from_quota(&body)?; + let mut result = ProviderFetchResult::new(snap, "api"); + if let Some(cost) = cost { + result = result.with_cost(cost); + } + Ok(result) + } + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +fn valid_nn(v: Option) -> Option { + v.filter(|x| x.is_finite() && *x >= 0.0) +} + +fn valid_pos(v: Option) -> Option { + v.filter(|x| x.is_finite() && *x > 0.0) +} + +fn parse_iso(raw: Option<&str>) -> Option> { + let raw = raw?.trim(); + if raw.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|d| d.with_timezone(&Utc)) +} + +fn format_kwh(value: f64) -> String { + if (value - value.round()).abs() < f64::EPSILON { + format!("{:.0}", value) + } else { + format!("{:.2}", value) + } +} + +fn subscription_window(sub: &Subscription) -> Option { + let total = valid_pos(sub.kwh_included).or_else(|| { + let used = valid_nn(sub.kwh_used)?; + let remaining = valid_nn(sub.kwh_remaining)?; + let t = used + remaining; + (t > 0.0).then_some(t) + })?; + let used = valid_nn(sub.kwh_used).or_else(|| { + let remaining = valid_nn(sub.kwh_remaining)?; + Some((total - remaining).max(0.0)) + })?; + let mut w = RateWindow::new(((used / total) * 100.0).clamp(0.0, 100.0)); + if let (Some(start), Some(end)) = ( + parse_iso(sub.current_period_start.as_deref()), + parse_iso(sub.current_period_end.as_deref()), + ) { + if end > start { + let mins = ((end - start).num_minutes()).max(1) as u32; + w.window_minutes = Some(mins); + } + w.resets_at = Some(end); + } + w.reset_description = Some(format!( + "{} / {} kWh", + format_kwh(used), + format_kwh(total) + )); + Some(w) +} + +fn prepaid_remaining(bal: &Balance) -> Option { + if let Some(r) = valid_nn(bal.credits_remaining_usd) { + return Some(r); + } + let total = valid_pos(bal.total_credits_usd)?; + let used = valid_nn(bal.credits_used_usd)?; + Some((total - used).max(0.0)) +} + +fn snapshot_from_quota( + body: &QuotaResponse, +) -> Result<(UsageSnapshot, Option), ProviderError> { + let sub_window = body + .subscription + .as_ref() + .and_then(subscription_window) + .unwrap_or_else(|| RateWindow::informational("No active subscription kWh")); + + let mut snap = UsageSnapshot::new(sub_window); + if let Some(sub) = &body.subscription { + if let Some(plan) = sub + .plan + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let label = plan.replace('_', " "); + snap = snap.with_login_method(format!( + "{} plan", + label + .split_whitespace() + .map(|w| { + let mut c = w.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }) + .collect::>() + .join(" ") + )); + } else if let Some(method) = body + .balance + .as_ref() + .and_then(|b| b.accounting_method.clone()) + .filter(|s| !s.is_empty()) + { + snap = snap.with_login_method(method); + } + let _status = sub.status.as_ref(); + } + + if let Some(allowance) = body.key.as_ref().and_then(|k| k.allowance.clone()) { + let percent = if allowance.blocked == Some(true) { + Some(100.0) + } else if let (Some(spent), Some(limit)) = + (valid_nn(allowance.spent_usd), valid_pos(allowance.limit_usd)) + { + Some(((spent / limit) * 100.0).clamp(0.0, 100.0)) + } else { + None + }; + if let Some(percent) = percent { + let period = allowance + .period + .as_deref() + .unwrap_or("allowance") + .to_string(); + let title = format!( + "Key {}", + period + .chars() + .next() + .map(|c| c.to_uppercase().collect::() + &period[c.len_utf8()..]) + .unwrap_or_else(|| period) + ); + snap.extra_rate_windows + .push(NamedRateWindow::new("key-allowance", title, RateWindow::new(percent))); + let _remaining = allowance.remaining_usd; + } + } + + let cost = body + .balance + .as_ref() + .and_then(prepaid_remaining) + .map(|remaining| CostSnapshot::new(remaining, "USD", "Neuralwatt prepaid balance")); + + let _month = body.usage.as_ref().and_then(|u| u.current_month.as_ref()); + Ok((snap, cost)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subscription_kwh_and_prepaid() { + let body: QuotaResponse = serde_json::from_str( + r#"{ + "balance": { + "credits_remaining_usd": 8.5, + "total_credits_usd": 20.0, + "credits_used_usd": 11.5 + }, + "subscription": { + "plan": "starter", + "kwh_included": 100.0, + "kwh_used": 40.0, + "kwh_remaining": 60.0, + "current_period_end": "2026-08-01T00:00:00Z" + }, + "key": { + "allowance": { + "limit_usd": 50.0, + "period": "month", + "spent_usd": 10.0, + "blocked": false + } + } + }"#, + ) + .unwrap(); + let (snap, cost) = snapshot_from_quota(&body).unwrap(); + assert!((snap.primary.used_percent - 40.0).abs() < 0.01); + assert!(snap.login_method.as_deref().unwrap().contains("Starter")); + assert_eq!(snap.extra_rate_windows.len(), 1); + assert!((snap.extra_rate_windows[0].window.used_percent - 20.0).abs() < 0.01); + assert!((cost.unwrap().used - 8.5).abs() < 0.001); + } +} diff --git a/rust/src/providers/zenmux/mod.rs b/rust/src/providers/zenmux/mod.rs new file mode 100644 index 0000000000..4d739570dd --- /dev/null +++ b/rust/src/providers/zenmux/mod.rs @@ -0,0 +1,298 @@ +//! ZenMux Management API usage provider (upstream 0.44). +//! +//! - `GET https://zenmux.ai/api/v1/management/subscription/detail` +//! - Optional PAYG: `GET .../payg/balance` + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde::Deserialize; + +use crate::core::{ + CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; + +const MANAGEMENT_BASE: &str = "https://zenmux.ai/api/v1/management"; +const CREDENTIAL_TARGET: &str = "codexbar-zenmux"; +const ENV_KEYS: &[&str] = &["ZENMUX_MANAGEMENT_API_KEY", "ZENMUX_API_KEY"]; + +#[derive(Debug, Deserialize)] +struct SubscriptionEnvelope { + success: bool, + data: SubscriptionData, +} + +#[derive(Debug, Deserialize)] +struct SubscriptionData { + plan: PlanInfo, + #[serde(default)] + account_status: String, + quota_5_hour: QuotaInfo, + quota_7_day: QuotaInfo, +} + +#[derive(Debug, Deserialize)] +struct PlanInfo { + #[serde(default)] + tier: String, + expires_at: Option, +} + +#[derive(Debug, Deserialize)] +struct QuotaInfo { + usage_percentage: f64, + resets_at: Option, + max_flows: f64, + used_flows: f64, + #[allow(dead_code)] + remaining_flows: f64, +} + +#[derive(Debug, Deserialize)] +struct BalanceEnvelope { + success: bool, + data: BalanceData, +} + +#[derive(Debug, Deserialize)] +struct BalanceData { + currency: String, + total_credits: f64, +} + +pub struct ZenMuxProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl ZenMuxProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::ZenMux, + display_name: "ZenMux", + session_label: "5-hour quota", + weekly_label: "Weekly quota", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://zenmux.ai/platform/management"), + status_page_url: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + fn resolve_key(api_key: Option<&str>) -> Result { + crate::providers::resolve_api_key(api_key, CREDENTIAL_TARGET, ENV_KEYS) + } + + async fn fetch_json(&self, path: &str, key: &str) -> Result { + let url = format!("{MANAGEMENT_BASE}/{path}"); + let resp = self + .client + .get(&url) + .bearer_auth(key) + .header("Accept", "application/json") + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "ZenMux Management API returned HTTP {status}" + ))); + } + resp.json() + .await + .map_err(|e| ProviderError::Parse(format!("Failed to parse ZenMux response: {e}"))) + } +} + +impl Default for ZenMuxProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for ZenMuxProvider { + fn id(&self) -> ProviderId { + ProviderId::ZenMux + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => { + let key = Self::resolve_key(ctx.api_key.as_deref())?; + let sub_val = self.fetch_json("subscription/detail", &key).await?; + let snapshot = snapshot_from_subscription(&sub_val)?; + + let mut result = ProviderFetchResult::new(snapshot, "api"); + // PAYG balance is optional; failures do not fail the whole fetch. + if let Ok(bal_val) = self.fetch_json("payg/balance", &key).await + && let Ok(cost) = payg_cost_from_balance(&bal_val) + { + result = result.with_cost(cost); + } + Ok(result) + } + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +fn parse_iso(raw: Option<&str>) -> Option> { + let raw = raw?.trim(); + if raw.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|d| d.with_timezone(&Utc)) +} + +fn quota_window(q: &QuotaInfo, minutes: u32) -> RateWindow { + let used = (q.usage_percentage * 100.0).clamp(0.0, 100.0); + let mut w = RateWindow::new(used); + w.window_minutes = Some(minutes); + w.resets_at = parse_iso(q.resets_at.as_deref()); + w.reset_description = Some(format!( + "{} / {} flows", + format_amount(q.used_flows), + format_amount(q.max_flows) + )); + w +} + +fn format_amount(value: f64) -> String { + if (value - value.round()).abs() < f64::EPSILON { + format!("{:.0}", value) + } else { + format!("{:.2}", value) + } +} + +fn snapshot_from_subscription(value: &serde_json::Value) -> Result { + let env: SubscriptionEnvelope = serde_json::from_value(value.clone()) + .map_err(|e| ProviderError::Parse(format!("Failed to parse ZenMux subscription: {e}")))?; + if !env.success { + return Err(ProviderError::Parse( + "ZenMux subscription response reported failure".into(), + )); + } + let plan = env.data.plan.tier.trim(); + let status = env.data.account_status.trim(); + let login = if status.eq_ignore_ascii_case("healthy") || status.is_empty() { + if plan.is_empty() { + None + } else { + Some(format!("{} plan", capitalize(plan))) + } + } else if plan.is_empty() { + Some(capitalize(status)) + } else { + Some(format!("{} plan · {}", capitalize(plan), capitalize(status))) + }; + + let mut snap = UsageSnapshot::new(quota_window(&env.data.quota_5_hour, 5 * 60)) + .with_secondary(quota_window(&env.data.quota_7_day, 7 * 24 * 60)); + if let Some(login) = login { + snap = snap.with_login_method(login); + } + let _expires = parse_iso(env.data.plan.expires_at.as_deref()); + Ok(snap) +} + +fn payg_cost_from_balance(value: &serde_json::Value) -> Result { + let env: BalanceEnvelope = serde_json::from_value(value.clone()) + .map_err(|e| ProviderError::Parse(format!("Failed to parse ZenMux balance: {e}")))?; + if !env.success { + return Err(ProviderError::Parse( + "ZenMux balance response reported failure".into(), + )); + } + if !env.data.currency.trim().eq_ignore_ascii_case("usd") { + return Err(ProviderError::Parse( + "ZenMux balance currency is not USD".into(), + )); + } + Ok(CostSnapshot::new( + env.data.total_credits, + "USD", + "ZenMux PAYG balance", + )) +} + +fn capitalize(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_subscription_windows() { + let value = json!({ + "success": true, + "data": { + "plan": { "tier": "pro", "expires_at": "2026-08-01T00:00:00Z" }, + "account_status": "healthy", + "quota_5_hour": { + "usage_percentage": 0.25, + "resets_at": "2026-07-21T10:00:00Z", + "max_flows": 100, + "used_flows": 25, + "remaining_flows": 75 + }, + "quota_7_day": { + "usage_percentage": 0.1, + "resets_at": "2026-07-28T00:00:00Z", + "max_flows": 1000, + "used_flows": 100, + "remaining_flows": 900 + } + } + }); + let snap = snapshot_from_subscription(&value).unwrap(); + assert!((snap.primary.used_percent - 25.0).abs() < 0.01); + assert_eq!(snap.primary.window_minutes, Some(300)); + let weekly = snap.secondary.unwrap(); + assert!((weekly.used_percent - 10.0).abs() < 0.01); + assert_eq!(snap.login_method.as_deref(), Some("Pro plan")); + } + + #[test] + fn parses_payg_balance() { + let value = json!({ + "success": true, + "data": { "currency": "USD", "total_credits": 12.5 } + }); + let cost = payg_cost_from_balance(&value).unwrap(); + assert!((cost.used - 12.5).abs() < 0.001); + assert_eq!(cost.currency_code, "USD"); + } +} diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 61c2e42719..d36d92e8be 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -310,6 +310,37 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://deepinfra.com/dash"), }, + ProviderConfigInfo { + id: ProviderId::ZenMux, + name: "ZenMux", + requires_api_key: true, + api_key_env_var: Some("ZENMUX_MANAGEMENT_API_KEY"), + api_key_help: Some( + "Use a ZenMux Management API key (not an inference key). Also accepts ZENMUX_API_KEY.", + ), + config_file_path: None, + dashboard_url: Some("https://zenmux.ai/platform/management"), + }, + ProviderConfigInfo { + id: ProviderId::ClinePass, + name: "ClinePass", + requires_api_key: true, + api_key_env_var: Some("CLINEPASS_API_KEY"), + api_key_help: Some( + "Get your API key from Cline / ClinePass. Also accepts CLINE_API_KEY.", + ), + config_file_path: None, + dashboard_url: Some("https://app.cline.bot/dashboard/subscription?personal=true"), + }, + ProviderConfigInfo { + id: ProviderId::Neuralwatt, + name: "Neuralwatt", + requires_api_key: true, + api_key_env_var: Some("NEURALWATT_API_KEY"), + api_key_help: Some("Get your API key from portal.neuralwatt.com."), + config_file_path: None, + dashboard_url: Some("https://portal.neuralwatt.com/dashboard"), + }, ProviderConfigInfo { id: ProviderId::Doubao, name: "Doubao / Volcengine Ark",