diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index ee24a2c1f1..67e51621fb 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -218,6 +218,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { nanogpt: { id: "nanogpt", brandColor: "#687fa1", fallbackLetter: "N" }, infini: { id: "infini", brandColor: "#687fa1", fallbackLetter: "I" }, abacus: { id: "abacus", brandColor: "#7c3aed", fallbackLetter: "A", svgPath: RAW.abacus }, + atlascloud: { id: "atlascloud", brandColor: "#5975F5", fallbackLetter: "A" }, manus: { id: "manus", brandColor: "#34322d", fallbackLetter: "M", svgPath: RAW.manus }, mimo: { id: "mimo", brandColor: "#ff6900", fallbackLetter: "M", svgPath: RAW.mimo }, doubao: { id: "doubao", brandColor: "#2563eb", fallbackLetter: "D", svgPath: RAW.doubao }, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 09ead3c1d3..e83648c958 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -28,7 +28,7 @@ import { /** Provider IDs that have a dashboard URL in the backend */ const HAS_DASHBOARD = new Set([ - "abacus", "alibaba", "alibabatokenplan", "amp", "augment", + "abacus", "alibaba", "alibabatokenplan", "amp", "atlascloud", "augment", "azureopenai", "bedrock", "claude", "codex", "codebuff", "aiand", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 1b1802bc57..aa2b319e34 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -37,6 +37,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["deepseek", "DeepSeek"], ["deepinfra", "DeepInfra"], ["fireworks", "Fireworks"], + ["atlascloud", "Atlas Cloud"], ["aiand", "ai&"], ["zenmux", "ZenMux"], ["clinepass", "ClinePass"], diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 2dc59a177f..c02d13e5a7 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -87,6 +87,7 @@ pub enum ProviderId { Notion, Xai, Fireworks, + AtlasCloud, #[serde(alias = "metaspark")] Meta, Muse, @@ -176,6 +177,7 @@ impl ProviderId { ProviderId::Notion, ProviderId::Xai, ProviderId::Fireworks, + ProviderId::AtlasCloud, ProviderId::Meta, ProviderId::Muse, ProviderId::Replicate, @@ -271,6 +273,7 @@ impl ProviderId { ProviderId::Notion => "notion", ProviderId::Xai => "xai", ProviderId::Replicate => "replicate", + ProviderId::AtlasCloud => "atlascloud", } } @@ -361,6 +364,7 @@ impl ProviderId { ProviderId::Notion => "Notion AI", ProviderId::Xai => "xAI", ProviderId::Replicate => "Replicate", + ProviderId::AtlasCloud => "Atlas Cloud", } } @@ -403,6 +407,7 @@ impl ProviderId { ProviderId::Sakana => Some("console.sakana.ai"), ProviderId::LongCat => Some("longcat.chat"), ProviderId::Replicate => Some("replicate.com"), + ProviderId::AtlasCloud => None, // Token-based providers (don't use cookies) ProviderId::Copilot => None, ProviderId::Zai => None, @@ -558,6 +563,7 @@ impl ProviderId { "zoommate" | "zoom-mate" | "zoom mate" => Some(ProviderId::ZoomMate), "notion" | "notion-ai" | "notionai" | "notion ai" => Some(ProviderId::Notion), "replicate" | "r8" => Some(ProviderId::Replicate), + "atlascloud" | "atlas-cloud" | "atlas cloud" => Some(ProviderId::AtlasCloud), _ => None, } } @@ -1125,6 +1131,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Meta => "#0467DF", ProviderId::Muse => "#0668E1", ProviderId::Replicate => "#000000", + ProviderId::AtlasCloud => "#5975F5", ProviderId::Nous => "#D6A55C", ProviderId::Hyper => "#7C3AED", ProviderId::GitKraken => "#179287", @@ -1143,7 +1150,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 82); + assert_eq!(all.len(), 83); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1203,6 +1210,7 @@ mod tests { assert!(all.contains(&ProviderId::Replicate)); assert!(all.contains(&ProviderId::Muse)); assert!(all.contains(&ProviderId::Nous)); + assert!(all.contains(&ProviderId::AtlasCloud)); assert!(all.contains(&ProviderId::Hyper)); assert!(all.contains(&ProviderId::GitKraken)); assert!(all.contains(&ProviderId::Bifrost)); diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 747763d4be..3f94d0d606 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -6,6 +6,7 @@ //! this one match arm. use super::{Provider, ProviderId}; +use crate::providers::AtlasCloudProvider; use crate::providers::{ AbacusProvider, AiAndProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, AugmentProvider, AzureOpenAIProvider, BedrockProvider, BifrostProvider, @@ -38,6 +39,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Gemini => Box::new(GeminiProvider::new()), ProviderId::Copilot => Box::new(CopilotProvider::new()), ProviderId::Antigravity => Box::new(AntigravityProvider::new()), + ProviderId::AtlasCloud => Box::new(AtlasCloudProvider::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 9c6566e465..9270a8ed86 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -411,6 +411,7 @@ impl TokenAccountSupport { | ProviderId::Meta | ProviderId::Nous | ProviderId::Muse + | ProviderId::AtlasCloud | ProviderId::Hyper | ProviderId::GitKraken | ProviderId::Bifrost => None, diff --git a/rust/src/providers/atlascloud/mod.rs b/rust/src/providers/atlascloud/mod.rs new file mode 100644 index 0000000000..41191f11d1 --- /dev/null +++ b/rust/src/providers/atlascloud/mod.rs @@ -0,0 +1,202 @@ +//! Atlas Cloud account balance provider. + +use async_trait::async_trait; +use reqwest::{Client, StatusCode, redirect::Policy}; +use serde::Deserialize; +use std::time::Duration; + +use crate::core::{ + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const BALANCE_URL: &str = "https://api.atlascloud.ai/public/v1/balance"; +const CREDENTIAL_TARGET: &str = "codexbar-atlascloud"; +const API_KEY_ENV: &str = "ATLASCLOUD_API_KEY"; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +pub struct AtlasCloudProvider { + metadata: ProviderMetadata, + client: Client, + balance_url: String, +} + +impl AtlasCloudProvider { + pub fn new() -> Self { + let client = crate::core::credentialed_http_client_builder() + .redirect(Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .expect("Atlas Cloud HTTP client configuration is valid"); + Self::with_client(BALANCE_URL, client) + } + + fn with_client(balance_url: impl Into, client: Client) -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::AtlasCloud, + display_name: "Atlas Cloud", + session_label: "Balance", + weekly_label: "Balance", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://atlascloud.ai/dashboard"), + status_page_url: None, + tertiary_label_key: None, + }, + client, + balance_url: balance_url.into(), + } + } + + async fn fetch_balance( + &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.balance_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!( + "Atlas Cloud response exceeded {MAX_RESPONSE_BYTES} bytes." + )), + })?; + let body = std::str::from_utf8(&body).map_err(|error| { + ProviderError::Parse(format!("Invalid Atlas Cloud response: {error}")) + })?; + let balance = parse_balance(body)?; + let detail = ProviderDisplayDetail::new("atlascloud-available", "Available", balance); + Ok(ProviderFetchResult::new( + UsageSnapshot::new(RateWindow::informational("Account balance")), + "api", + ) + .with_display_detail(detail)) + } +} + +impl Default for AtlasCloudProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for AtlasCloudProvider { + fn id(&self) -> ProviderId { + ProviderId::AtlasCloud + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_balance(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 => ProviderError::AuthRequired, + StatusCode::FORBIDDEN => ProviderError::Other( + "Atlas Cloud denied access to the account balance; check API key permissions.".into(), + ), + StatusCode::TOO_MANY_REQUESTS => { + ProviderError::Other("Atlas Cloud rate limit reached.".into()) + } + status if status.is_server_error() => { + ProviderError::Other("Atlas Cloud balance service is unavailable.".into()) + } + status => ProviderError::Other(format!("Atlas Cloud returned HTTP {status}.")), + } +} + +#[derive(Debug, Deserialize)] +struct BalanceResponse { + object: String, + scope: String, + available: AvailableBalance, +} + +#[derive(Debug, Deserialize)] +struct AvailableBalance { + currency: String, + value: String, +} + +fn parse_balance(body: &str) -> Result { + let response: BalanceResponse = serde_json::from_str(body) + .map_err(|error| ProviderError::Parse(format!("Invalid Atlas Cloud response: {error}")))?; + if response.object != "balance" + || response.scope != "account" + || response.available.currency != "usd" + { + return Err(parse_failure("unexpected object, scope, or currency")); + } + let amount = response.available.value; + if !is_decimal(&amount) { + return Err(parse_failure( + "available.value must be a signed decimal string", + )); + } + let parsed = amount + .parse::() + .map_err(|_| parse_failure("available.value is not a finite number"))?; + if !parsed.is_finite() { + return Err(parse_failure("available.value is not a finite number")); + } + Ok(amount.to_owned()) +} + +fn is_decimal(value: &str) -> bool { + let digits = value.strip_prefix('-').unwrap_or(value); + let mut parts = digits.split('.'); + let Some(integer) = parts.next() else { + return false; + }; + let fraction = parts.next(); + parts.next().is_none() + && !integer.is_empty() + && integer.bytes().all(|byte| byte.is_ascii_digit()) + && fraction.is_none_or(|fraction| { + !fraction.is_empty() && fraction.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn parse_failure(reason: &str) -> ProviderError { + ProviderError::Parse(format!("Invalid Atlas Cloud balance response: {reason}.")) +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/providers/atlascloud/tests.rs b/rust/src/providers/atlascloud/tests.rs new file mode 100644 index 0000000000..fae77cfe49 --- /dev/null +++ b/rust/src/providers/atlascloud/tests.rs @@ -0,0 +1,125 @@ +use super::*; +use std::io::{Read, Write}; +use std::net::TcpListener; + +fn balance_payload(amount: &str) -> String { + format!( + r#"{{"object":"balance","scope":"account","available":{{"currency":"usd","value":"{amount}"}}}}"# + ) +} + +#[test] +fn accepts_decimal_zero_and_negative_balances_without_normalizing_text() { + for amount in ["12.340", "0", "-0.25"] { + assert_eq!(parse_balance(&balance_payload(amount)).unwrap(), amount); + } +} + +#[test] +fn rejects_wrong_envelope_currency_and_non_string_amounts() { + let wrong_object = balance_payload("1").replace("balance", "credits"); + let wrong_scope = balance_payload("1").replace("account", "project"); + let wrong_currency = balance_payload("1").replace("usd", "eur"); + + for body in [ + wrong_object.as_str(), + wrong_scope.as_str(), + wrong_currency.as_str(), + r#"{"object":"balance","scope":"account","available":{"currency":"usd","value":1}}"#, + r#"{"object":"balance","scope":"account","available":{}}"#, + "not json", + ] { + assert!(matches!(parse_balance(body), Err(ProviderError::Parse(_)))); + } +} + +#[test] +fn accepts_only_signed_decimal_strings_that_parse_to_finite_numbers() { + for amount in ["", "-", "+1", ".5", "1.", "1e3", "NaN", "Infinity", "1.2.3"] { + assert!( + matches!( + parse_balance(&balance_payload(amount)), + Err(ProviderError::Parse(_)) + ), + "unexpectedly accepted {amount:?}" + ); + } + let overflow = "9".repeat(400); + assert!(matches!( + parse_balance(&balance_payload(&overflow)), + Err(ProviderError::Parse(_)) + )); +} + +#[test] +fn maps_auth_permission_rate_limit_and_server_statuses() { + assert!(matches!( + status_error(StatusCode::UNAUTHORIZED), + ProviderError::AuthRequired + )); + assert!(matches!( + status_error(StatusCode::FORBIDDEN), + ProviderError::Other(message) if message.contains("permissions") + )); + assert!(matches!( + status_error(StatusCode::TOO_MANY_REQUESTS), + ProviderError::Other(message) if message.contains("rate limit") + )); + assert!(matches!( + status_error(StatusCode::BAD_REQUEST), + ProviderError::Other(message) if message.contains("400") + )); + assert!(matches!( + status_error(StatusCode::INTERNAL_SERVER_ERROR), + ProviderError::Other(message) if message.contains("unavailable") + )); +} + +#[tokio::test] +async fn sends_bearer_request_and_exposes_balance_as_display_only_detail() { + 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(); + let body = balance_payload("-3.25"); + write!( + stream, + "HTTP/1.1 200 OK\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 = + AtlasCloudProvider::with_client(format!("http://{address}/public/v1/balance"), client); + let context = FetchContext { + api_key: Some("test-atlas-key".into()), + ..FetchContext::default() + }; + + let result = provider.fetch_usage(&context).await.expect("balance fetch"); + let request = server.join().expect("test server thread"); + assert!(request.starts_with("get /public/v1/balance ")); + assert!(request.contains("authorization: bearer test-atlas-key")); + assert_eq!(result.display_details().len(), 1); + assert_eq!(result.display_details()[0].title(), "Available"); + assert_eq!(result.display_details()[0].value(), "-3.25"); + assert!(result.usage.primary.is_informational); + assert!(result.usage.secondary.is_none()); + assert!(result.cost.is_none()); +} + +#[test] +fn missing_key_uses_the_shared_not_installed_error() { + let result = + crate::providers::resolve_api_key(None, "codexbar-atlascloud-test-without-credential", &[]); + assert!(matches!(result, Err(ProviderError::NotInstalled(_)))); +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index d2f8ca4190..898aad40af 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -13,6 +13,7 @@ pub mod alibaba; pub mod alibabatokenplan; pub mod amp; pub mod antigravity; +pub mod atlascloud; pub mod augment; pub mod azureopenai; pub mod bedrock; @@ -99,6 +100,7 @@ pub use alibaba::{AlibabaProvider, AlibabaRegion}; pub use alibabatokenplan::{AlibabaTokenPlanProvider, AlibabaTokenPlanRegion}; pub use amp::AmpProvider; pub use antigravity::AntigravityProvider; +pub use atlascloud::AtlasCloudProvider; pub use augment::AugmentProvider; pub use azureopenai::AzureOpenAIProvider; pub use bedrock::BedrockProvider; diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 93ddb57226..18fe27ed64 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -395,6 +395,17 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://console.aiand.com"), }, + ProviderConfigInfo { + id: ProviderId::AtlasCloud, + name: "Atlas Cloud", + requires_api_key: true, + api_key_env_var: Some("ATLASCLOUD_API_KEY"), + api_key_help: Some( + "Get an API key from Atlas Cloud and set it in Preferences or ATLASCLOUD_API_KEY.", + ), + config_file_path: None, + dashboard_url: Some("https://www.atlascloud.ai/console"), + }, ProviderConfigInfo { id: ProviderId::ZenMux, name: "ZenMux",