diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index c8ec7955463..2f85bd21761 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 31634bd910e..f30ece6a766 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 86982b66e48..ddc3535d374 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 00000000000..843383df483 --- /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 a204e1155a2..6b2b1eb5ab7 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 67e51621fbd..ee6cebc2ce5 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,7 +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" }, + 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 1c07040562c..f066db2e92d 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 00000000000..80a84346b5c --- /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 00000000000..7dd97d67784 --- /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/settings/providers/sections/CostSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.test.tsx new file mode 100644 index 00000000000..36189a47a82 --- /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 e1a078f29a2..8d6e89b9c3b 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 b2d24dbdfc1..8026099d83a 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/rust/src/locale.rs b/rust/src/locale.rs index 276a31160e1..41ec464786a 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 53b4453f6d1..9b6db823248 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 bae2c814c39..c8d79fc7a57 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 92618f72f71..f523f1a61df 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 7f59c6fc294..9b80f771f7d 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 855f7a9eb6f..de7c6a1f573 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 9daf93d8098..524d3fde355 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 ca4fabe7912..d57153f838c 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 98dd870db8d..67cdedcc230 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 f893d5b423d..d8b8f6d9688 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 index 41191f11d10..68cd687199b 100644 --- a/rust/src/providers/atlascloud/mod.rs +++ b/rust/src/providers/atlascloud/mod.rs @@ -6,14 +6,16 @@ use serde::Deserialize; use std::time::Duration; use crate::core::{ - FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + 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); @@ -44,7 +46,7 @@ impl AtlasCloudProvider { supports_credits: false, default_enabled: false, is_primary: false, - dashboard_url: Some("https://atlascloud.ai/dashboard"), + dashboard_url: Some(DASHBOARD_URL), status_page_url: None, tertiary_label_key: None, }, @@ -87,12 +89,34 @@ impl AtlasCloudProvider { 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)) + 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}") } } @@ -155,7 +179,7 @@ struct AvailableBalance { value: String, } -fn parse_balance(body: &str) -> Result { +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" @@ -176,7 +200,7 @@ fn parse_balance(body: &str) -> Result { if !parsed.is_finite() { return Err(parse_failure("available.value is not a finite number")); } - Ok(amount.to_owned()) + Ok(parsed) } fn is_decimal(value: &str) -> bool { diff --git a/rust/src/providers/atlascloud/tests.rs b/rust/src/providers/atlascloud/tests.rs index fae77cfe49e..85879c11ae5 100644 --- a/rust/src/providers/atlascloud/tests.rs +++ b/rust/src/providers/atlascloud/tests.rs @@ -9,9 +9,27 @@ fn balance_payload(amount: &str) -> String { } #[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); +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); } } @@ -35,13 +53,28 @@ fn rejects_wrong_envelope_currency_and_non_string_amounts() { #[test] fn accepts_only_signed_decimal_strings_that_parse_to_finite_numbers() { - for amount in ["", "-", "+1", ".5", "1.", "1e3", "NaN", "Infinity", "1.2.3"] { + 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!( - matches!( - parse_balance(&balance_payload(amount)), - Err(ProviderError::Parse(_)) - ), - "unexpectedly accepted {amount:?}" + !message.contains("private-response"), + "error echoed response text: {message}" ); } let overflow = "9".repeat(400); @@ -69,14 +102,22 @@ fn maps_auth_permission_rate_limit_and_server_statuses() { 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") - )); + for status in [ + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::SERVICE_UNAVAILABLE, + ] { + assert!(matches!( + status_error(status), + ProviderError::Other(message) if message.contains("unavailable") + )); + } } -#[tokio::test] -async fn sends_bearer_request_and_exposes_balance_as_display_only_detail() { +/// 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 || { @@ -84,10 +125,9 @@ async fn sends_bearer_request_and_exposes_balance_as_display_only_detail() { 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{}", + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ) @@ -100,21 +140,101 @@ async fn sends_bearer_request_and_exposes_balance_as_display_only_detail() { .expect("test HTTP client"); let provider = AtlasCloudProvider::with_client(format!("http://{address}/public/v1/balance"), client); - let context = FetchContext { + (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(&context).await.expect("balance fetch"); + 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"); - assert_eq!(result.display_details()[0].value(), "-3.25"); + 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!(result.cost.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] diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 18fe27ed64b..c1b0b55c8b0 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -404,7 +404,7 @@ pub fn get_api_key_providers() -> Vec { "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"), + dashboard_url: Some(crate::providers::atlascloud::DASHBOARD_URL), }, ProviderConfigInfo { id: ProviderId::ZenMux,