diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index c8ec795546..2f85bd2176 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -119,6 +119,8 @@ describe("MenuCard", () => { buildBundle({ ActionCopyError: "Copy error", ApiSpendTitle: "API spend", + AtlasCloudAvailableBalance: "Available balance", + AtlasCloudBalance: "Atlas Cloud balance", DetailPaceRunsOutIn: "Runs out in", PanelEstimatedFromLocalLogs: "Estimated from local logs", PanelLeftSuffix: "left", @@ -601,6 +603,27 @@ describe("MenuCard", () => { expect(details.open).toBe(true); }); + it("renders provider display details once and hides them in compact overview", async () => { + const snapshot = provider(null); + snapshot.displayDetails = [ + { + id: "atlascloud-available", + title: "Available balance", + value: "$95.50", + secondaryValue: null, + progress: null, + }, + ]; + + const detailed = renderCard(snapshot); + expect(await screen.findByText("Available balance: $95.50")).toBeInTheDocument(); + expect(screen.getAllByText("Available balance: $95.50")).toHaveLength(1); + detailed.unmount(); + + renderCard(snapshot, { compactOverview: true }); + expect(screen.queryByText("Available balance: $95.50")).not.toBeInTheDocument(); + }); + it("places Claude accounts above metrics and the collapsed usage details", async () => { tauriMocks.claudeAccountsList.mockResolvedValue([ { id: "a", email: "a@example.com", organization: "Personal", isActive: true, isSaved: true }, diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..f30ece6a76 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -24,6 +24,10 @@ import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; import { InventoryItemRow } from "./InventoryRows"; import { QuotaWindowHistory } from "./QuotaWindowHistory"; import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; +import { + providerCostPeriodTitle, + providerDisplayDetailTitle, +} from "../lib/providerLabels"; import PaceDetailsChart from "./PaceDetailsChart"; /** Format a reserve description from raw pace data at render time. */ @@ -541,6 +545,11 @@ export default function MenuCardDetails({ ); const localCostHistory = chartData?.costHistory ?? []; const costStyle = display.costSummaryDisplayStyle ?? "detailed"; + const costPeriod = providerCostPeriodTitle( + provider.providerId, + provider.cost?.period ?? "", + t, + ); const { hasMetrics, @@ -598,15 +607,7 @@ export default function MenuCardDetails({ {!provider.error && hasDisplayDetails && !compactOverview && (
{provider.displayDetails?.map((detail, index) => ( - - ))} -
- )} - - {!provider.error && hasDisplayDetails && ( -
- {provider.displayDetails?.map((detail, index) => ( - + ))}
)} @@ -621,8 +622,8 @@ export default function MenuCardDetails({ {provider.cost.alwaysVisible === true && (provider.cost.limit ?? 0) <= 0 ? t("ApiSpendTitle") : provider.cost.balance != null && provider.cost.limit == null - ? provider.cost.period || t("CreditsLabel") - : `${t("DetailCostTitle")} — ${provider.cost.period}`} + ? costPeriod || t("CreditsLabel") + : `${t("DetailCostTitle")} — ${costPeriod}`} {provider.cost.balance != null && provider.cost.limit == null ? (
@@ -787,7 +788,14 @@ export default function MenuCardDetails({ ); } -function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { +function DisplayDetailRow({ + detail, + t, +}: { + detail: ProviderDisplayDetail; + t: (key: LocaleKey) => string; +}) { + const title = providerDisplayDetailTitle(detail, t); const progress = detail.progress; const progressPercent = progress && Number.isFinite(progress.used) && Number.isFinite(progress.total) && progress.total > 0 ? Math.max(0, Math.min(100, (progress.used / progress.total) * 100)) @@ -796,13 +804,13 @@ function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { return (
- {detail.title}: {detail.value} + {title}: {detail.value} {detail.secondaryValue && ( {detail.secondaryValue} )}
{progressPercent != null && ( -
+
)} diff --git a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx index 86982b66e4..ddc3535d37 100644 --- a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx +++ b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx @@ -1,4 +1,6 @@ import type { ProviderDisplayDetail } from "../types/bridge"; +import type { LocaleKey } from "../i18n/keys"; +import { providerDisplayDetailTitle } from "../lib/providerLabels"; /** * One transient provider detail line: "{title}: {value} [secondary]" @@ -13,13 +15,16 @@ export function ProviderDisplayRow({ secondaryClassName, trackClassName, fillClassName, + t, }: { detail: ProviderDisplayDetail; lineClassName: string; secondaryClassName?: string; trackClassName: string; fillClassName: string; + t: (key: LocaleKey) => string; }) { + const title = providerDisplayDetailTitle(detail, t); const progress = detail.progress; const progressPercent = progress && @@ -32,13 +37,13 @@ export function ProviderDisplayRow({ return (
- {detail.title}: {detail.value} + {title}: {detail.value} {detail.secondaryValue && secondaryClassName && ( {detail.secondaryValue} )}
{progressPercent != null && ( -
+
)} diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-atlascloud.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-atlascloud.svg new file mode 100644 index 0000000000..843383df48 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-atlascloud.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts index a204e1155a..6b2b1eb5ab 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts @@ -9,6 +9,13 @@ describe("provider icon registry", () => { } }); + it("ships the upstream Atlas Cloud glyph tinted by the brand color", () => { + const svg = PROVIDER_ICON_REGISTRY.atlascloud.svgPath; + expect(svg).toContain(" { expect(PROVIDER_ICON_REGISTRY).not.toHaveProperty("crof"); }); diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 7c65e9922e..65e0eb514e 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -6,6 +6,7 @@ import abacus from "./icons/ProviderIcon-abacus.svg?raw"; import alibaba from "./icons/ProviderIcon-alibaba.svg?raw"; import amp from "./icons/ProviderIcon-amp.svg?raw"; import antigravity from "./icons/ProviderIcon-antigravity.svg?raw"; +import atlascloud from "./icons/ProviderIcon-atlascloud.svg?raw"; import augment from "./icons/ProviderIcon-augment.svg?raw"; import bedrock from "./icons/ProviderIcon-bedrock.svg?raw"; import claude from "./icons/ProviderIcon-claude.svg?raw"; @@ -93,6 +94,7 @@ const RAW: Record = { alibaba: tint(alibaba), amp: tint(amp), antigravity: tint(antigravity), + atlascloud: tint(atlascloud), augment: tint(augment), bedrock: tint(bedrock), claude: tint(claude), @@ -218,6 +220,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", svgPath: RAW.atlascloud }, 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/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 0ad26af95b..8421d4f99e 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -76,6 +76,8 @@ export const ALL_LOCALE_KEYS = [ "ProviderDisabled", "ProviderInfo", "ProviderUsage", + "AtlasCloudAvailableBalance", + "AtlasCloudBalance", "PanelUsageDetails", "AuthType", "DataSource", diff --git a/apps/desktop-tauri/src/lib/providerLabels.test.ts b/apps/desktop-tauri/src/lib/providerLabels.test.ts new file mode 100644 index 0000000000..80a84346b5 --- /dev/null +++ b/apps/desktop-tauri/src/lib/providerLabels.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { LocaleKey } from "../i18n/keys"; +import type { ProviderDisplayDetail } from "../types/bridge"; +import { + providerCostPeriodTitle, + providerDisplayDetailTitle, +} from "./providerLabels"; + +const translate = (key: LocaleKey) => `translated:${key}`; + +describe("provider labels", () => { + it("localizes Atlas Cloud display labels and preserves other provider titles", () => { + const atlasBalance: ProviderDisplayDetail = { + id: "atlascloud-available", + title: "Available balance", + value: "$95.50", + secondaryValue: null, + progress: null, + }; + const other: ProviderDisplayDetail = { + ...atlasBalance, + id: "other", + title: "Credits", + }; + + expect(providerDisplayDetailTitle(atlasBalance, translate)).toBe( + "translated:AtlasCloudAvailableBalance", + ); + expect(providerDisplayDetailTitle(other, translate)).toBe("Credits"); + }); + + it("localizes Atlas Cloud balance period and preserves other periods", () => { + expect( + providerCostPeriodTitle("atlascloud", "Atlas Cloud balance", translate), + ).toBe("translated:AtlasCloudBalance"); + expect(providerCostPeriodTitle("other", "This month", translate)).toBe("This month"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/providerLabels.ts b/apps/desktop-tauri/src/lib/providerLabels.ts new file mode 100644 index 0000000000..7dd97d6778 --- /dev/null +++ b/apps/desktop-tauri/src/lib/providerLabels.ts @@ -0,0 +1,29 @@ +import type { LocaleKey } from "../i18n/keys"; +import type { ProviderDisplayDetail } from "../types/bridge"; + +type Translate = (key: LocaleKey) => string; + +const DISPLAY_DETAIL_TITLE_KEYS: ReadonlyMap = new Map([ + ["atlascloud-available", "AtlasCloudAvailableBalance"], +]); + +const COST_PERIOD_KEYS: ReadonlyMap = new Map([ + ["atlascloud:Atlas Cloud balance", "AtlasCloudBalance"], +]); + +export function providerDisplayDetailTitle( + detail: ProviderDisplayDetail, + t: Translate, +): string { + const key = DISPLAY_DETAIL_TITLE_KEYS.get(detail.id); + return key ? t(key) : detail.title; +} + +export function providerCostPeriodTitle( + providerId: string, + period: string, + t: Translate, +): string { + const key = COST_PERIOD_KEYS.get(`${providerId}:${period}`); + return key ? t(key) : period; +} diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 034dfc09a0..468004996d 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/surfaces/settings/providers/sections/CostSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.test.tsx new file mode 100644 index 0000000000..36189a47a8 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { CostSnapshotBridge } from "../../../../types/bridge"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { CostSection } from "./CostSection"; + +describe("CostSection", () => { + it("does not present the zero usage carrier as spend for balance-only providers", () => { + const cost: CostSnapshotBridge = { + used: 0, + limit: null, + remaining: null, + currencyCode: "USD", + period: "Atlas Cloud balance", + resetsAt: null, + formattedUsed: "$0.00", + formattedLimit: null, + balance: 95.5, + formattedBalance: "$95.50", + }; + + const { container } = render( + key} />, + ); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByText("DetailCostUsed")).toBeNull(); + expect(screen.queryByText("$0.00")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.tsx index e1a078f29a..8d6e89b9c3 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.tsx @@ -13,6 +13,13 @@ interface Props { export function CostSection({ cost, t }: Props) { if (!cost) return null; + const balanceOnly = + cost.balance != null && + cost.limit == null && + cost.used === 0 && + (cost.daily?.length ?? 0) === 0; + if (balanceOnly) return null; + const rows: { label: string; value: string | null }[] = [ { label: t("DetailCostUsed"), value: cost.formattedUsed }, { label: t("DetailCostLimit"), value: cost.formattedLimit }, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index b2d24dbdfc..8026099d83 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -98,6 +98,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { ); -} \ No newline at end of file +} 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 f88b8699ac..403c3cfebf 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, } } @@ -1140,6 +1146,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 => "#FF60FF", ProviderId::GitKraken => "#179287", @@ -1158,7 +1165,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)); @@ -1218,6 +1225,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/locale.rs b/rust/src/locale.rs index 6b43e88960..6824d838e6 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -266,6 +266,8 @@ locale_keys! { ProviderDisabled, ProviderInfo, ProviderUsage, + AtlasCloudAvailableBalance, + AtlasCloudBalance, PanelUsageDetails, AuthType, DataSource, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 1a99574daf..8d788b6e6b 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -69,6 +69,8 @@ ProviderEnabled = Enabled ProviderDisabled = Disabled ProviderInfo = Info ProviderUsage = Usage +AtlasCloudAvailableBalance = Available balance +AtlasCloudBalance = Atlas Cloud balance PanelUsageDetails = Usage details AuthType = Authentication DataSource = Data Source diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 5ba578c95f..7d3e4108a4 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -61,6 +61,8 @@ ProviderEnabled = Habilitado ProviderDisabled = Deshabilitado ProviderInfo = Información ProviderUsage = Uso +AtlasCloudAvailableBalance = Saldo disponible +AtlasCloudBalance = Saldo de Atlas Cloud AuthType = Autenticación DataSource = Fuente de datos ProviderNotDetected = no detectado diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index c91cef6370..4fb7562ae8 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -61,6 +61,8 @@ ProviderEnabled = 有効 ProviderDisabled = 無効 ProviderInfo = 情報 ProviderUsage = 使用量 +AtlasCloudAvailableBalance = 利用可能残高 +AtlasCloudBalance = Atlas Cloud の残高 AuthType = 認証 DataSource = データソース ProviderNotDetected = 未検出 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index cf67c452cf..57d56af267 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -61,6 +61,8 @@ ProviderEnabled = 활성화됨 ProviderDisabled = 비활성화됨 ProviderInfo = 정보 ProviderUsage = 사용량 +AtlasCloudAvailableBalance = 사용 가능 잔액 +AtlasCloudBalance = Atlas Cloud 잔액 AuthType = 인증 DataSource = 데이터 소스 ProviderNotDetected = 감지되지 않음 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index e0129c89de..879661a637 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -45,6 +45,8 @@ ProviderEnabled = Включено ProviderDisabled = Отключено ProviderInfo = Информация ProviderUsage = Использование +AtlasCloudAvailableBalance = Доступный баланс +AtlasCloudBalance = Баланс Atlas Cloud AuthType = Аутентификация DataSource = Источник данных ProviderNotDetected = не обнаружено diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 57becd3865..60260e7370 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -80,6 +80,8 @@ fn test_japanese_menu_card_locale_values_are_translated() { (LocaleKey::DetailCostLimit, "上限"), (LocaleKey::DetailCostRemaining, "残り"), (LocaleKey::DetailCostBalance, "残高"), + (LocaleKey::AtlasCloudAvailableBalance, "利用可能残高"), + (LocaleKey::AtlasCloudBalance, "Atlas Cloud の残高"), (LocaleKey::DetailCostResets, "リセット"), (LocaleKey::DetailChartCost, "コスト(30日間)"), (LocaleKey::DetailChartCredits, "使用クレジット(30日間)"), diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 2abf387409..21691f1ecc 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -67,6 +67,8 @@ ProviderEnabled = Etkin ProviderDisabled = Devre dışı ProviderInfo = Bilgi ProviderUsage = Kullanım +AtlasCloudAvailableBalance = Kullanılabilir bakiye +AtlasCloudBalance = Atlas Cloud bakiyesi AuthType = Kimlik Doğrulama DataSource = Veri Kaynağı ProviderNotDetected = algılanmadı diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 60e9e7a74d..2f1da1ab33 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -61,6 +61,8 @@ ProviderEnabled = 已启用 ProviderDisabled = 已禁用 ProviderInfo = 信息 ProviderUsage = 用量 +AtlasCloudAvailableBalance = 可用余额 +AtlasCloudBalance = Atlas Cloud 余额 AuthType = 认证方式 DataSource = 数据来源 ProviderNotDetected = 未检测到 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 5977e37435..f2a079afb9 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -61,6 +61,8 @@ ProviderEnabled = 已啟用 ProviderDisabled = 已停用 ProviderInfo = 資訊 ProviderUsage = 用量 +AtlasCloudAvailableBalance = 可用餘額 +AtlasCloudBalance = Atlas Cloud 餘額 AuthType = 認證方式 DataSource = 資料來源 ProviderNotDetected = 未檢測到 diff --git a/rust/src/providers/atlascloud/mod.rs b/rust/src/providers/atlascloud/mod.rs new file mode 100644 index 0000000000..68cd687199 --- /dev/null +++ b/rust/src/providers/atlascloud/mod.rs @@ -0,0 +1,226 @@ +//! 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::{ + CostSnapshot, 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"; +/// Single canonical console URL, shared with the API-key settings catalog. +pub const DASHBOARD_URL: &str = "https://www.atlascloud.ai/console"; +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(DASHBOARD_URL), + 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)?; + Ok(balance_result(balance)) + } +} + +/// Typed USD balance for balance formatting, Usage & Spend and currency +/// conversion, plus a signed display row. `CostSnapshot::with_balance` clamps +/// negatives to zero, so the row is what keeps a deficit (`-$1.25`) visible. +fn balance_result(balance: f64) -> ProviderFetchResult { + let usage = + UsageSnapshot::new(RateWindow::informational("Account balance")).with_login_method("API"); + let cost = CostSnapshot::new(0.0, "USD", "Atlas Cloud balance").with_balance(balance); + let detail = ProviderDisplayDetail::new( + "atlascloud-available", + "Available balance", + format_usd(balance), + ); + ProviderFetchResult::new(usage, "api") + .with_cost(cost) + .with_display_detail(detail) +} + +/// `$95.50` / `-$1.25`; a negative that rounds to zero shows no sign. +fn format_usd(amount: f64) -> String { + let magnitude = format!("{:.2}", amount.abs()); + if amount < 0.0 && magnitude != "0.00" { + format!("-${magnitude}") + } else { + format!("${magnitude}") + } +} + +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(parsed) +} + +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..85879c11ae --- /dev/null +++ b/rust/src/providers/atlascloud/tests.rs @@ -0,0 +1,245 @@ +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 parses_zero_and_negative_balances_without_clamping() { + for (amount, expected) in [ + ("12.340", 12.34), + ("0", 0.0), + ("0.00", 0.0), + ("-1.250000", -1.25), + ] { + assert_eq!(parse_balance(&balance_payload(amount)).unwrap(), expected); + } +} + +#[test] +fn formats_signed_usd_like_upstream() { + for (amount, expected) in [ + (95.5, "$95.50"), + (0.0, "$0.00"), + (-1.25, "-$1.25"), + (-0.001, "$0.00"), + (1234.5678, "$1234.57"), + ] { + assert_eq!(format_usd(amount), expected); + } +} + +#[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", + "1e999", + "0x10", + "NaN", + "Infinity", + "1.2.3", + "private-response", + ] { + let error = parse_balance(&balance_payload(amount)).expect_err(amount); + let ProviderError::Parse(message) = error else { + panic!("unexpected error kind for {amount:?}"); + }; + assert!( + !message.contains("private-response"), + "error echoed response text: {message}" + ); + } + 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") + )); + for status in [ + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::SERVICE_UNAVAILABLE, + ] { + assert!(matches!( + status_error(status), + ProviderError::Other(message) if message.contains("unavailable") + )); + } +} + +/// Serve one canned HTTP response; the handle yields the lowercased request. +fn provider_serving( + status_line: &'static str, + body: String, +) -> (AtlasCloudProvider, std::thread::JoinHandle) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind local test server"); + let address = listener.local_addr().expect("local server address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).expect("read request"); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + write!( + stream, + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write response"); + request + }); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + let provider = + AtlasCloudProvider::with_client(format!("http://{address}/public/v1/balance"), client); + (provider, server) +} + +fn keyed_context() -> FetchContext { + FetchContext { + api_key: Some("test-atlas-key".into()), + ..FetchContext::default() + } +} + +#[tokio::test] +async fn sends_bearer_request_and_exposes_typed_balance_with_signed_display_row() { + let (provider, server) = provider_serving("200 OK", balance_payload("-3.25")); + + let result = provider + .fetch_usage(&keyed_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 balance"); + assert_eq!(result.display_details()[0].value(), "-$3.25"); + assert!(result.usage.primary.is_informational); + assert!(result.usage.secondary.is_none()); + assert_eq!(result.usage.login_method.as_deref(), Some("API")); + let cost = result.cost.expect("typed balance carrier"); + assert_eq!(cost.currency_code, "USD"); + // `with_balance` clamps a deficit to zero; the display row keeps the sign. + assert_eq!(cost.balance, Some(0.0)); +} + +#[tokio::test] +async fn positive_balance_is_typed_and_formatted() { + let (provider, server) = provider_serving("200 OK", balance_payload("95.5")); + + let result = provider + .fetch_usage(&keyed_context()) + .await + .expect("balance fetch"); + server.join().expect("test server thread"); + assert_eq!(result.display_details()[0].value(), "$95.50"); + let cost = result.cost.expect("typed balance carrier"); + assert_eq!(cost.balance, Some(95.5)); + assert_eq!(cost.format_balance().as_deref(), Some("$95.50")); +} + +#[tokio::test] +async fn non_ok_statuses_do_not_echo_the_response_body() { + for status_line in [ + "400 Bad Request", + "401 Unauthorized", + "403 Forbidden", + "429 Too Many Requests", + "503 Service Unavailable", + ] { + let (provider, server) = provider_serving(status_line, "private-response".into()); + let error = provider + .fetch_usage(&keyed_context()) + .await + .expect_err(status_line); + server.join().expect("test server thread"); + let message = error.to_string(); + assert!( + !message.contains("private-response"), + "{status_line}: {message}" + ); + } +} + +#[tokio::test] +async fn unparseable_success_body_does_not_echo_the_response_body() { + let (provider, server) = provider_serving("200 OK", "private-response".into()); + let error = provider + .fetch_usage(&keyed_context()) + .await + .expect_err("invalid body"); + server.join().expect("test server thread"); + assert!(matches!(error, ProviderError::Parse(_))); + assert!(!error.to_string().contains("private-response")); +} + +#[test] +fn metadata_uses_the_single_canonical_console_url() { + let provider = AtlasCloudProvider::new(); + assert_eq!( + provider.metadata().dashboard_url, + Some("https://www.atlascloud.ai/console") + ); + let configured = crate::settings::get_api_key_providers() + .into_iter() + .find(|info| info.id == ProviderId::AtlasCloud) + .expect("Atlas Cloud API-key catalog entry"); + assert_eq!(configured.dashboard_url, provider.metadata().dashboard_url); +} + +#[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 6ca919c0be..f8f86dd7ec 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; @@ -100,6 +101,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 457a9b553c..20a060328a 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(crate::providers::atlascloud::DASHBOARD_URL), + }, ProviderConfigInfo { id: ProviderId::ZenMux, name: "ZenMux",