diff --git a/README.md b/README.md index ec974c6265..2d89b88d30 100755 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Grok | Cookies / auth.json | Billing | | Helmcode (also NaN Builders) | Browser cookies / manual Cookie header | Per-model token quotas, reset windows, Helmcode prepaid balance | | Replicate | Cookies / token accounts | Monthly spend, credit balance | +| Aixy | API Key / token accounts | Applicable budget balances, 7-day key usage | | ElevenLabs | API Key | Subscription Credits, Voice Slots | | Deepgram | API Key | Project Usage | | Groq | API Key | Enterprise Metrics | diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 96955cf740..59c5f105eb 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -206,6 +206,8 @@ pub struct ProviderDisplayProgressSnapshot { #[serde(rename_all = "camelCase")] pub struct ProviderDisplayDetailSnapshot { pub id: String, + #[serde(default)] + pub section_title: Option, pub title: String, pub value: String, pub secondary_value: Option, @@ -438,6 +440,7 @@ impl ProviderUsageSnapshot { .iter() .map(|detail| ProviderDisplayDetailSnapshot { id: detail.id().to_string(), + section_title: detail.section_title().map(ToOwned::to_owned), title: detail.title().to_string(), value: detail.value().to_string(), secondary_value: detail.secondary_value().map(ToOwned::to_owned), diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index f2a3852773..07d0a70c01 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -401,15 +401,48 @@ mod tests { ); } + #[test] + fn fetch_context_carries_saved_gateway_urls_for_every_gateway_provider() { + use codexbar::settings::{ApiKeys, ManualCookies, Settings}; + use std::collections::HashMap; + + let mut settings = Settings::default(); + for (id, url) in [ + (ProviderId::Wayfinder, "http://localhost:8787"), + (ProviderId::Bifrost, "https://bifrost.example.com"), + (ProviderId::Aixy, "https://aixy.example.com/prefix"), + ] { + settings.set_gateway_url(id, url); + let ctx = super::super::providers::build_fetch_context( + id, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.gateway_url.as_deref(), Some(url), "{id:?}"); + } + + let ctx = super::super::providers::build_fetch_context( + ProviderId::Codex, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.gateway_url, None); + } + #[test] fn maps_gitkraken_organization_provider() { assert_eq!(workspace_provider("gitkraken"), Some(ProviderId::GitKraken)); } #[test] - fn gateway_provider_exposes_wayfinder_and_bifrost_only() { + fn gateway_provider_exposes_gateway_providers_only() { assert_eq!(gateway_provider("wayfinder"), Some(ProviderId::Wayfinder)); assert_eq!(gateway_provider("bifrost"), Some(ProviderId::Bifrost)); + assert_eq!(gateway_provider("aixy"), Some(ProviderId::Aixy)); assert_eq!(gateway_provider("codex"), None); } @@ -467,6 +500,7 @@ fn gateway_provider(provider_id: &str) -> Option { match provider_id { "wayfinder" => Some(codexbar::core::ProviderId::Wayfinder), "bifrost" => Some(codexbar::core::ProviderId::Bifrost), + "aixy" => Some(codexbar::core::ProviderId::Aixy), _ => None, } } @@ -492,6 +526,10 @@ pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Res codexbar::providers::bifrost::validate_gateway_url(gateway_url) .map_err(|error| error.to_string())?; } + codexbar::core::ProviderId::Aixy => { + codexbar::providers::aixy::validate_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } _ => unreachable!("gateway_provider only returns gateway providers"), } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 8b4794b1e6..0989d592f0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -83,21 +83,20 @@ pub(crate) fn build_fetch_context( .and_then(|override_data| override_data.env_override.as_ref()); let active_token_api_key = active_token_env.and_then(|env| env.values().next().cloned()); let usage_source = SourceMode::parse(settings.usage_source(id)).unwrap_or_default(); + let token_account_kind = token_override.as_ref().map(|account| account.kind); // Selected token-account key overrides a stored provider apiKey (upstream #2271 / #1183). - let api_key = active_token_api_key.or(stored_api_key); + let api_key = active_token_api_key.clone().or(stored_api_key); let has_kimi_code_api_key = id == ProviderId::Kimi && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); let has_opencodego_api_key = id == ProviderId::OpenCodeGo && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); - let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = - if id.cookie_domain().is_none() { - let source_mode = if active_token_env.is_some() { - SourceMode::OAuth - } else { - usage_source - }; - (source_mode, None, false) + let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = if id + .cookie_domain() + .is_none() + { + let (source_mode, cookie_header, missing_cookie) = if active_token_env.is_some() { + (SourceMode::OAuth, None, false) } else { match cookie_source { // #433: an explicitly selected, non-empty Claude manual cookie is @@ -119,7 +118,7 @@ pub(crate) fn build_fetch_context( // API-key fallback. "off" | "manual" if provider.cookie_source_scopes_session_only() => { let cookie_header = if cookie_source == "manual" { - active_token_cookie.or(stored_cookie) + active_token_cookie.clone().or(stored_cookie) } else { None }; @@ -145,7 +144,7 @@ pub(crate) fn build_fetch_context( "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), "off" => (SourceMode::Cli, None, false), "manual" => { - let cookie_header = active_token_cookie.or(stored_cookie); + let cookie_header = active_token_cookie.clone().or(stored_cookie); let fails_closed_without_cookie = cookie_header.is_none() && provider.manual_empty_cookie_policy() == ManualEmptyCookiePolicy::FailClosedWeb; @@ -177,28 +176,130 @@ pub(crate) fn build_fetch_context( "auto" | "browser" | "web" => { // Claude resolves its cached cookie and browser fallback inside // the provider; other providers retain the shell fallback. - let cookie_header = active_token_cookie.or(stored_cookie).or_else(|| { - if defer_provider_browser_cookie_lookup { - None - } else { - provider_cookie_domain(id, settings).and_then(|domain| { - codexbar::browser::cookies::get_cookie_header(domain) - .ok() - .filter(|h| !h.is_empty()) - }) - } - }); + let cookie_header = + active_token_cookie.clone().or(stored_cookie).or_else(|| { + if defer_provider_browser_cookie_lookup { + None + } else { + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) + } + }); (usage_source, cookie_header, false) } _ => (usage_source, stored_cookie, false), } }; + (source_mode, cookie_header, missing_cookie) + } else { + match cookie_source { + // #433: an explicitly selected, non-empty Claude manual cookie is + // authoritative. Do not let an active OAuth token account silently + // replace it; this keeps tray refresh behavior aligned with diagnose, + // whose Claude Auto path tries the supplied Web cookie before OAuth. + "manual" + if provider.manual_cookie_precedes_token_account() + && stored_cookie + .as_deref() + .is_some_and(|cookie| !cookie.trim().is_empty()) => + { + (SourceMode::Web, stored_cookie.clone(), false) + } + _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + // Charm Hyper: the cookie source only picks the session, and + // the usage source keeps routing. Off and an empty Manual + // source never import a browser session, while Auto keeps its + // API-key fallback. + "off" | "manual" if provider.cookie_source_scopes_session_only() => { + let cookie_header = if cookie_source == "manual" { + active_token_cookie.clone().or(stored_cookie) + } else { + None + }; + let source_mode = if provider.available_sources().contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + let cookie_missing = cookie_header.is_none(); + (source_mode, cookie_header, cookie_missing) + } + "off" if provider_uses_oauth_without_cookies(id, usage_source) => { + (SourceMode::OAuth, None, false) + } + "off" + if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto => + { + (SourceMode::Auto, None, false) + } + // Droid/Factory: cookie-off must never scrape browser cookies. Map to + // Cli (API-only in the provider) so Auto does not fall through to web. + "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), + "off" => (SourceMode::Cli, None, false), + "manual" => { + let cookie_header = active_token_cookie.clone().or(stored_cookie); + let fails_closed_without_cookie = cookie_header.is_none() + && provider.manual_empty_cookie_policy() + == ManualEmptyCookiePolicy::FailClosedWeb; + let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto + { + SourceMode::Auto + } else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) { + // Grok Switch writes ~/.grok/auth.json. Leftover grok.com + // cookies must not force Web, or Weekly/notifications keep + // showing the previous browser account. + mode + } else if cookie_header.is_some() { + SourceMode::Web + } else if fails_closed_without_cookie { + // The provider owns this policy; Web with no header means + // it fails closed instead of importing a browser account + // the user did not select. + SourceMode::Web + } else if provider_uses_oauth_without_cookies(id, usage_source) { + SourceMode::OAuth + } else { + SourceMode::Cli + }; + (source_mode, cookie_header, fails_closed_without_cookie) + } + // `browser` is accepted as a legacy alias from older settings. + "auto" | "browser" | "web" => { + // Claude resolves its cached cookie and browser fallback inside + // the provider; other providers retain the shell fallback. + let cookie_header = active_token_cookie.clone().or(stored_cookie).or_else(|| { + if defer_provider_browser_cookie_lookup { + None + } else { + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) + } + }); + (usage_source, cookie_header, false) + } + _ => (usage_source, stored_cookie, false), + } + }; // Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell // historically mapped "manual + no cookie" to Cli, which surfaces as // "Source mode 'Cli' not supported". Remap to Web and try browser cookies - // unless the user explicitly disabled cookies ("off"). - if source_mode == SourceMode::Cli && cookie_source != "off" && !provider.supports_cli() { + // unless the user explicitly disabled cookies ("off"). Providers whose + // cookie source only scopes the session (Charm Hyper) own this contract in + // the provider, so the shell must not remap their source mode. + if source_mode == SourceMode::Cli + && cookie_source != "off" + && !provider.supports_cli() + && !provider.cookie_source_scopes_session_only() + { if cookie_header .as_deref() .map(str::trim) @@ -231,22 +332,60 @@ pub(crate) fn build_fetch_context( let workspace_id = settings.workspace_id(id).trim().to_string(); let api_region = settings.api_region(id).trim().to_string(); - let gateway_url = (id == ProviderId::Wayfinder && !settings.gateway_url(id).is_empty()) - .then(|| settings.gateway_url(id).to_string()); + // Every gateway-style provider (Wayfinder, Bifrost, Aixy) stores its base + // URL here; providers without one report an empty string. + let gateway_url = Some(settings.gateway_url(id)) + .filter(|url| !url.is_empty()) + .map(str::to_owned); // Local-first Auto providers (OpenCode Go) flip to web-first when a // token account or manual cookie source scopes the session to web creds. let auto_prefer_web = token_override.is_some() || cookie_source == "manual"; + // These upstream account types are explicit identity selections. Keep the + // provider's saved region/source settings intact, but project the selected + // credential into the route required by that account. + let (cookie_header, api_key) = match (id, token_account_kind, usage_source) { + (ProviderId::Kimi, Some(_), _) => (active_token_cookie.clone(), None), + (ProviderId::Doubao, Some(_), _) => (None, active_token_api_key.clone()), + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::ApiKey), + SourceMode::Auto, + ) => (None, active_token_api_key.clone()), + (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey), _) => { + (cookie_header, api_key) + } + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::Cookie), + SourceMode::Auto, + ) => (active_token_cookie.clone(), api_key), + _ => (cookie_header, api_key), + }; + let source_mode = token_override + .as_ref() + .and_then(|account| account.effective_source_mode(usage_source)) + .unwrap_or(source_mode); + let token_account_isolated = token_override.is_some() + && matches!( + id, + ProviderId::Kimi | ProviderId::Doubao | ProviderId::OpenCodeGo + ); + FetchContext { source_mode, manual_cookie_header: cookie_header, manual_cookie_missing: fails_closed_without_cookie, api_key, + token_account_kind, + token_account_isolated, workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), seat_credit_entitlement: settings.seat_credit_entitlement(id), api_region: (!api_region.is_empty()).then_some(api_region), gateway_url, - auto_prefer_web, + auto_prefer_web: auto_prefer_web + && !(id == ProviderId::OpenCodeGo + && token_account_kind == Some(codexbar::core::TokenAccountKind::ApiKey)), ..FetchContext::default() } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index b18b7c949a..f0ab846f99 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -496,6 +496,139 @@ fn fetch_context_opencode_empty_manual_remaps_to_web() { assert_eq!(ctx.source_mode, SourceMode::Web); } +#[test] +fn kimi_selected_account_forces_web_and_keeps_saved_region() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Kimi, "oauth"); + settings.set_api_region(ProviderId::Kimi, "international"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-kimi-session")); + accounts.insert(ProviderId::Kimi, data); + + let ctx = super::build_fetch_context( + ProviderId::Kimi, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("kimi-auth=selected-kimi-session") + ); + assert_eq!(ctx.api_key, None); + assert_eq!(ctx.api_region.as_deref(), Some("international")); + assert!(ctx.token_account_isolated); + assert_eq!(settings.usage_source(ProviderId::Kimi), "oauth"); + assert_eq!(settings.api_region(ProviderId::Kimi), "international"); +} + +#[test] +fn doubao_selected_account_forces_ark_api_and_ignores_saved_source() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Doubao, "cli"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-ark-key")); + accounts.insert(ProviderId::Doubao, data); + + let ctx = super::build_fetch_context( + ProviderId::Doubao, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::OAuth); + assert_eq!(ctx.api_key.as_deref(), Some("selected-ark-key")); + assert!(ctx.token_account_isolated); +} + +#[test] +fn opencodego_selected_api_account_overrides_global_key_without_changing_explicit_source() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let mut keys = ApiKeys::default(); + keys.set("opencodego", "global-key", None); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-account-key")); + accounts.insert(ProviderId::OpenCodeGo, data); + + let ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert_eq!(ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(!ctx.auto_prefer_web); + assert!(ctx.token_account_isolated); + + for cookie_source in ["off", "manual"] { + settings.set_cookie_source(ProviderId::OpenCodeGo, cookie_source); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let auto_ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + assert_eq!(auto_ctx.source_mode, SourceMode::Auto); + assert_eq!(auto_ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(auto_ctx.manual_cookie_header.is_none()); + } + + for (saved_source, expected_source) in [("web", SourceMode::Web), ("cli", SourceMode::Cli)] { + settings.set_cookie_source(ProviderId::OpenCodeGo, "off"); + settings.set_usage_source(ProviderId::OpenCodeGo, saved_source); + let explicit_ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + assert_eq!(explicit_ctx.source_mode, expected_source); + } +} + +#[test] +fn opencodego_selected_cookie_account_uses_web_route() { + let settings = Settings::default(); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Web", "Cookie: session=selected-session")); + accounts.insert(ProviderId::OpenCodeGo, data); + + let ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("Cookie: session=selected-session") + ); + assert_eq!( + ctx.token_account_kind, + Some(codexbar::core::TokenAccountKind::Cookie) + ); + assert!(ctx.token_account_isolated); +} + #[test] fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() { let settings = Settings::default(); @@ -1128,6 +1261,7 @@ fn provider_inventory_maps_to_the_bridge_without_token_ids() { .with_display_detail( ProviderDisplayDetail::new("credits", "Used this cycle", "12") .and_then(|row| row.with_secondary_value("Monthly refill: 100")) + .and_then(|row| row.with_section_title("Credit usage")) .and_then(|row| row.with_progress(12.0, 100.0)), ); let metadata = instantiate_provider(ProviderId::Grok).metadata().clone(); @@ -1142,6 +1276,15 @@ fn provider_inventory_maps_to_the_bridge_without_token_ids() { ); assert_eq!(snapshot.display_details.len(), 1); assert_eq!(snapshot.display_details[0].value, "12"); + assert_eq!( + snapshot.display_details[0].section_title.as_deref(), + Some("Credit usage") + ); + let snapshot_json = serde_json::to_value(&snapshot).unwrap(); + assert_eq!( + snapshot_json["displayDetails"][0]["sectionTitle"], + "Credit usage" + ); assert_eq!( snapshot.display_details[0].secondary_value.as_deref(), Some("Monthly refill: 100") diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index c8ec795546..02e9b99cad 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -601,6 +601,80 @@ describe("MenuCard", () => { expect(details.open).toBe(true); }); + it("renders provider detail rows once with separated secondary values and accessible progress", async () => { + const snapshot = provider(null); + snapshot.providerId = "aixy"; + snapshot.displayName = "Aixy"; + snapshot.displayDetails = [ + { + id: "budget-0", + sectionTitle: "Applicable budgets", + title: "Project · Monthly · Shared · Hard", + value: "$70.00 / $100.00 remaining", + secondaryValue: "$20.00 spent · $10.00 reserved", + progress: { used: 30, total: 100 }, + }, + ]; + + const { container } = renderCard(snapshot); + + await screen.findByRole("progressbar", { + name: "Project · Monthly · Shared · Hard progress", + }); + expect(container.querySelectorAll(".menu-card__provider-details")).toHaveLength(1); + expect(container.textContent).toContain( + "$70.00 / $100.00 remaining $20.00 spent · $10.00 reserved", + ); + expect( + screen.getByRole("progressbar", { + name: "Project · Monthly · Shared · Hard progress", + }), + ).toHaveAttribute("aria-valuenow", "30"); + }); + + it("hides provider display details in compact overview cards", () => { + const snapshot = provider(null); + snapshot.providerId = "aixy"; + snapshot.displayDetails = [ + { + id: "budget-0", + sectionTitle: "Applicable budgets", + title: "Project · Monthly · Shared · Hard", + value: "$70.00 / $100.00 remaining", + secondaryValue: "$20.00 spent · $10.00 reserved", + progress: { used: 30, total: 100 }, + }, + ]; + + const { container } = renderCard(snapshot, { compactOverview: true }); + + expect(container.querySelector(".menu-card__provider-details")).not.toBeInTheDocument(); + }); + + it("splits provider detail sections after 24 rows", async () => { + const snapshot = provider(null); + snapshot.providerId = "aixy"; + snapshot.displayDetails = Array.from({ length: 25 }, (_, index) => ({ + id: `budget-${index}`, + sectionTitle: "Applicable budgets", + title: `Budget ${index}`, + value: "$0.00 remaining", + secondaryValue: null, + progress: null, + })); + + const { container } = renderCard(snapshot); + + await screen.findAllByRole("heading", { name: "Applicable budgets" }); + const sections = container.querySelectorAll(".menu-card__provider-details"); + expect(sections).toHaveLength(2); + expect( + screen.getAllByRole("heading", { name: "Applicable budgets" }), + ).toHaveLength(2); + expect(sections[0].querySelectorAll(".menu-card__cost-line")).toHaveLength(24); + expect(sections[1].querySelectorAll(".menu-card__cost-line")).toHaveLength(1); + }); + 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..d446fa74c5 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import type { CostSummaryDisplayStyle, DailyCostPoint, - ProviderDisplayDetail, PaceSnapshot, ProviderInventoryItem, ProviderChartData, @@ -22,6 +21,10 @@ import type { LocaleKey } from "../i18n/keys"; import { paceCategory } from "../surfaces/tray/paceCategory"; import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; import { InventoryItemRow } from "./InventoryRows"; +import { + groupProviderDisplayDetails, + ProviderDisplayRow, +} from "./ProviderDisplayRow"; import { QuotaWindowHistory } from "./QuotaWindowHistory"; import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; import PaceDetailsChart from "./PaceDetailsChart"; @@ -541,6 +544,9 @@ export default function MenuCardDetails({ ); const localCostHistory = chartData?.costHistory ?? []; const costStyle = display.costSummaryDisplayStyle ?? "detailed"; + const displayDetailGroups = groupProviderDisplayDetails( + provider.displayDetails ?? [], + ); const { hasMetrics, @@ -596,19 +602,28 @@ export default function MenuCardDetails({ )} {!provider.error && hasDisplayDetails && !compactOverview && ( -
- {provider.displayDetails?.map((detail, index) => ( - - ))} -
- )} - - {!provider.error && hasDisplayDetails && ( -
- {provider.displayDetails?.map((detail, index) => ( - - ))} -
+ displayDetailGroups.map((group) => ( +
+ {group.title && ( +
+ {group.title} +
+ )} + {group.rows.map((detail, index) => ( + + ))} +
+ )) )} {wayfinderUsage && !compactOverview && } @@ -786,26 +801,3 @@ export default function MenuCardDetails({ ); } - -function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { - 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)) - : null; - - return ( -
-
- {detail.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..b39fbe3ef3 100644 --- a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx +++ b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx @@ -1,5 +1,34 @@ import type { ProviderDisplayDetail } from "../types/bridge"; +const MAX_ROWS_PER_SECTION = 24; + +export interface ProviderDisplayDetailGroup { + id: number; + title: string | null; + rows: ProviderDisplayDetail[]; +} + +/** Group consecutive provider details and cap each rendered section. */ +export function groupProviderDisplayDetails( + details: ProviderDisplayDetail[], +): ProviderDisplayDetailGroup[] { + const groups: ProviderDisplayDetailGroup[] = []; + for (const detail of details) { + const title = detail.sectionTitle ?? null; + const current = groups[groups.length - 1]; + if ( + current && + current.title === title && + current.rows.length < MAX_ROWS_PER_SECTION + ) { + current.rows.push(detail); + } else { + groups.push({ id: groups.length, title, rows: [detail] }); + } + } + return groups; +} + /** * One transient provider detail line: "{title}: {value} [secondary]" * plus an optional clamped progress bar. @@ -34,11 +63,21 @@ export function ProviderDisplayRow({
{detail.title}: {detail.value} {detail.secondaryValue && secondaryClassName && ( - {detail.secondaryValue} + <> + {" "} + {detail.secondaryValue} + )}
{progressPercent != null && ( -
+
)} diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg new file mode 100644 index 0000000000..224bb877f1 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg @@ -0,0 +1 @@ +Aixy diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 7c65e9922e..47edca37cc 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -20,6 +20,7 @@ import deepgram from "./icons/ProviderIcon-deepgram.svg?raw"; import deepinfra from "./icons/ProviderIcon-deepinfra.svg?raw"; import fireworks from "./icons/ProviderIcon-fireworks.svg?raw"; import aiand from "./icons/ProviderIcon-aiand.svg?raw"; +import aixy from "./icons/ProviderIcon-aixy.svg?raw"; import clinepass from "./icons/ProviderIcon-clinepass.svg?raw"; import longcat from "./icons/ProviderIcon-longcat.svg?raw"; import neuralwatt from "./icons/ProviderIcon-neuralwatt.svg?raw"; @@ -107,6 +108,7 @@ const RAW: Record = { deepinfra: tint(deepinfra), fireworks: tint(fireworks), aiand: tint(aiand), + aixy: tint(aixy), clinepass: tint(clinepass), longcat: tint(longcat), neuralwatt: tint(neuralwatt), @@ -186,6 +188,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { grok: { id: "grok", brandColor: "#111827", fallbackLetter: "G", svgPath: RAW.grok }, groq: { id: "groq", brandColor: "#f55036", fallbackLetter: "G", svgPath: RAW.groq }, bifrost: { id: "bifrost", brandColor: "#33c09e", fallbackLetter: "B" }, + aixy: { id: "aixy", brandColor: "#123650", fallbackLetter: "A", svgPath: RAW.aixy }, gitkraken: { id: "gitkraken", brandColor: "#179287", fallbackLetter: "G" }, huggingface: { id: "huggingface", brandColor: "#ffd21e", fallbackLetter: "H", svgPath: RAW.huggingface }, hyper: { id: "hyper", brandColor: "#ff60ff", fallbackLetter: "H" }, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 0ad26af95b..42ed0be33a 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -96,6 +96,11 @@ export const ALL_LOCALE_KEYS = [ "WayfinderGatewayLabel", "WayfinderGatewayHelp", "WayfinderGatewayStatus", + "BifrostGatewayTitle", + "BifrostGatewayHelp", + "AixyGatewayTitle", + "AixyGatewayLabel", + "AixyGatewayHelp", "WayfinderModels", "WayfinderRequests", "WayfinderTokens", diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 034dfc09a0..d68cf58527 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -30,7 +30,7 @@ import { const HAS_DASHBOARD = new Set([ "abacus", "alibaba", "alibabatokenplan", "amp", "augment", "azureopenai", "bedrock", "claude", "codex", "codebuff", - "aiand", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", + "aiand", "aixy", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "replicate", "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index ae3e6766a5..5d8099a174 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -47,7 +47,10 @@ import { AccentColorSection } from "./sections/AccentColorSection"; import { ProviderIssueNotice } from "./sections/ProviderIssueNotice"; import { CredentialStorageSection } from "./sections/CredentialStorageSection"; import { CredentialsDispatcher } from "./sections/CredentialsDispatcher"; -import { WayfinderGatewaySection } from "./sections/WayfinderGatewaySection"; +import { + isGatewayProviderId, + WayfinderGatewaySection, +} from "./sections/WayfinderGatewaySection"; import { AzureApiVersionSection } from "./sections/AzureApiVersionSection"; interface Props { @@ -145,9 +148,8 @@ export function ProviderDetailPane({ } }, []); - const gatewayProviderId = providerId === "wayfinder" || providerId === "bifrost" - ? providerId - : null; + const gatewayProviderId = + providerId !== null && isGatewayProviderId(providerId) ? providerId : null; useEffect(() => { setGatewayLoadedProviderId(null); @@ -345,9 +347,10 @@ export function ProviderDetailPane({ t={t} onChanged={reload} /> - {(detail.id === "wayfinder" || detail.id === "bifrost") && + {isGatewayProviderId(detail.id) && gatewayLoadedProviderId === detail.id && ( void saveGateway()} t={t} - bifrost={detail.id === "bifrost"} /> )} { providerMetrics: { copilot: "extraUsage" }, }); }); + + it("offers only Automatic for Aixy, even with extra budget windows", () => { + const aixy = provider(); + aixy.id = "aixy"; + aixy.displayName = "Aixy"; + aixy.weekly = rateWindow(40); + render( + key} + onChange={vi.fn()} + />, + ); + + const options = screen.getAllByRole("option").map((option) => option.textContent); + expect(options).toEqual(["Automatic"]); + expect(screen.getByRole("combobox")).toHaveValue("automatic"); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx index 7bc89e0d7d..f0716229ce 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -20,6 +20,8 @@ interface MetricOption { label: string; } +const AUTOMATIC_ONLY_PROVIDERS: ReadonlySet = new Set(["aixy"]); + export function MenuBarMetricSection({ provider, providerMetrics, @@ -28,7 +30,9 @@ export function MenuBarMetricSection({ onChange, }: Props) { const [error, setError] = useState(null); - const selected = providerMetrics[provider.id] ?? "automatic"; + const selected = AUTOMATIC_ONLY_PROVIDERS.has(provider.id) + ? "automatic" + : providerMetrics[provider.id] ?? "automatic"; const options = metricOptions(provider, selected, t); const handleChange = (value: MetricPreference) => { @@ -76,11 +80,15 @@ function metricOptions( selected: MetricPreference, t: (key: LocaleKey) => string, ): MetricOption[] { - const options: MetricOption[] = [ - { value: "automatic", label: t("Automatic") }, - { value: "session", label: t("ProviderSessionLabel") }, - ]; + const options: MetricOption[] = [{ value: "automatic", label: t("Automatic") }]; + + // Aixy's primary budget depends on which limits currently apply to the key, + // so a fixed session/weekly lane would be misleading. Offer Automatic only. + if (AUTOMATIC_ONLY_PROVIDERS.has(provider.id)) { + return options; + } + options.push({ value: "session", label: t("ProviderSessionLabel") }); if (provider.weekly) { options.push({ value: "weekly", label: t("ProviderWeeklyLabel") }); } @@ -104,12 +112,21 @@ function metricOptions( if (provider.id === "gemini" && provider.weekly) { options.push({ value: "average", label: t("Average") }); } - if (!options.some((option) => option.value === selected)) { - options.push({ + return withSelected(options, selected, t); +} + +/** Keep a previously saved preference visible even if it is no longer offered. */ +function withSelected( + options: MetricOption[], + selected: MetricPreference, + t: (key: LocaleKey) => string, +): MetricOption[] { + if (options.some((option) => option.value === selected)) return options; + return [ + ...options, + { value: selected, label: selected === "credits" ? t("CreditsLabel") : selected, - }); - } - - return options; + }, + ]; } 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..e46e867a87 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -5,7 +5,10 @@ import type { RateWindowSnapshot, } from "../../../../types/bridge"; import { InventoryItemRow } from "../../../../components/InventoryRows"; -import { ProviderDisplayRow } from "../../../../components/ProviderDisplayRow"; +import { + groupProviderDisplayDetails, + ProviderDisplayRow, +} from "../../../../components/ProviderDisplayRow"; import type { LocaleKey } from "../../../../i18n/keys"; import { useFormattedResetTime } from "../../../../hooks/useFormattedResetTime"; import { isUsageItemVisible } from "../../../../lib/usageItemVisibility"; @@ -70,6 +73,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { const inventory = provider.inventory ?? []; const displayDetails = provider.displayDetails ?? []; + const displayDetailGroups = groupProviderDisplayDetails(displayDetails); if (bars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { return null; } @@ -94,14 +98,28 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { lineClassName="provider-usage-inventory" /> ))} - {displayDetails.map((detail) => ( - + {displayDetailGroups.map((group) => ( +
+ {group.title && ( +
+ {group.title} +
+ )} + {group.rows.map((detail) => ( + + ))} +
))} ); @@ -163,4 +181,4 @@ function UsageBar({ )}
); -} \ No newline at end of file +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx new file mode 100644 index 0000000000..1b95267446 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + isGatewayProviderId, + WayfinderGatewaySection, + type GatewayProviderId, +} from "./WayfinderGatewaySection"; + +function renderSection(providerId: GatewayProviderId, onSave = vi.fn()) { + render( + key} + />, + ); + return onSave; +} + +describe("WayfinderGatewaySection", () => { + it.each([ + ["wayfinder", "WayfinderGatewayTitle", "WayfinderGatewayLabel", "WayfinderGatewayHelp"], + ["bifrost", "BifrostGatewayTitle", "WayfinderGatewayLabel", "BifrostGatewayHelp"], + ["aixy", "AixyGatewayTitle", "AixyGatewayLabel", "AixyGatewayHelp"], + ] as const)("uses localized %s copy", (providerId, title, label, help) => { + renderSection(providerId); + + expect(screen.getByRole("heading", { name: title })).toBeInTheDocument(); + expect(screen.getByLabelText(label)).toHaveValue("https://gateway.example.com"); + expect(screen.getByText(help)).toBeInTheDocument(); + }); + + it("saves through the shared button", () => { + const onSave = renderSection("aixy"); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it("recognizes only gateway providers", () => { + expect(isGatewayProviderId("aixy")).toBe(true); + expect(isGatewayProviderId("bifrost")).toBe(true); + expect(isGatewayProviderId("wayfinder")).toBe(true); + expect(isGatewayProviderId("codex")).toBe(false); + expect(isGatewayProviderId("toString")).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx index 77b4067771..9c454f0ae1 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx @@ -1,6 +1,37 @@ import type { LocaleKey } from "../../../../i18n/keys"; +export type GatewayProviderId = "wayfinder" | "bifrost" | "aixy"; + +interface GatewayCopy { + title: LocaleKey; + label: LocaleKey; + help: LocaleKey; +} + +const GATEWAY_COPY: Record = { + wayfinder: { + title: "WayfinderGatewayTitle", + label: "WayfinderGatewayLabel", + help: "WayfinderGatewayHelp", + }, + bifrost: { + title: "BifrostGatewayTitle", + label: "WayfinderGatewayLabel", + help: "BifrostGatewayHelp", + }, + aixy: { + title: "AixyGatewayTitle", + label: "AixyGatewayLabel", + help: "AixyGatewayHelp", + }, +}; + +export function isGatewayProviderId(id: string): id is GatewayProviderId { + return Object.prototype.hasOwnProperty.call(GATEWAY_COPY, id); +} + interface Props { + providerId: GatewayProviderId; draft: string; error: string | null; busy: boolean; @@ -8,10 +39,10 @@ interface Props { onDraftChange: (draft: string) => void; onSave: () => void; t: (key: LocaleKey) => string; - bifrost?: boolean; } export function WayfinderGatewaySection({ + providerId, draft, error, busy, @@ -19,13 +50,13 @@ export function WayfinderGatewaySection({ onDraftChange, onSave, t, - bifrost = false, }: Props) { + const copy = GATEWAY_COPY[providerId]; return (
-

{bifrost ? "Bifrost gateway" : t("WayfinderGatewayTitle")}

+

{t(copy.title)}

-

- {bifrost ? "Base URL of your Bifrost gateway." : t("WayfinderGatewayHelp")} -

+

{t(copy.help)}

{error &&

{error}

}