Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export const PROVIDER_ICON_REGISTRY: Record<string, ProviderIcon> = {
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 },
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop-tauri/src/surfaces/TrayPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/test/providerCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
10 changes: 9 additions & 1 deletion rust/src/core/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ pub enum ProviderId {
Notion,
Xai,
Fireworks,
AtlasCloud,
#[serde(alias = "metaspark")]
Meta,
Muse,
Expand Down Expand Up @@ -176,6 +177,7 @@ impl ProviderId {
ProviderId::Notion,
ProviderId::Xai,
ProviderId::Fireworks,
ProviderId::AtlasCloud,
ProviderId::Meta,
ProviderId::Muse,
ProviderId::Replicate,
Expand Down Expand Up @@ -271,6 +273,7 @@ impl ProviderId {
ProviderId::Notion => "notion",
ProviderId::Xai => "xai",
ProviderId::Replicate => "replicate",
ProviderId::AtlasCloud => "atlascloud",
}
}

Expand Down Expand Up @@ -361,6 +364,7 @@ impl ProviderId {
ProviderId::Notion => "Notion AI",
ProviderId::Xai => "xAI",
ProviderId::Replicate => "Replicate",
ProviderId::AtlasCloud => "Atlas Cloud",
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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",
Expand All @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
2 changes: 2 additions & 0 deletions rust/src/core/provider_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -38,6 +39,7 @@ pub fn instantiate(id: ProviderId) -> Box<dyn Provider> {
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()),
Expand Down
1 change: 1 addition & 0 deletions rust/src/core/token_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ impl TokenAccountSupport {
| ProviderId::Meta
| ProviderId::Nous
| ProviderId::Muse
| ProviderId::AtlasCloud
| ProviderId::Hyper
| ProviderId::GitKraken
| ProviderId::Bifrost => None,
Expand Down
202 changes: 202 additions & 0 deletions rust/src/providers/atlascloud/mod.rs
Original file line number Diff line number Diff line change
@@ -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<String>, 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<ProviderFetchResult, ProviderError> {
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<ProviderFetchResult, ProviderError> {
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<SourceMode> {
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<String, ProviderError> {
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::<f64>()
.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;
Loading