From a1980aeb40b24f3035754cec06a5551a526e54e6 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:48:49 +0700 Subject: [PATCH 1/2] Port upstream 0.67.0: Add Aixy provider (key budgets and 7-day usage) --- README.md | 1 + .../src/commands/provider_settings.rs | 40 +- .../src-tauri/src/commands/providers.rs | 7 +- .../providers/icons/ProviderIcon-aixy.svg | 1 + .../src/components/providers/providerIcons.ts | 3 + apps/desktop-tauri/src/i18n/keys.ts | 5 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 +- .../settings/providers/ProviderDetailPane.tsx | 14 +- .../sections/MenuBarMetricSection.test.tsx | 19 + .../sections/MenuBarMetricSection.tsx | 35 +- .../sections/WayfinderGatewaySection.test.tsx | 53 ++ .../sections/WayfinderGatewaySection.tsx | 43 +- .../providers/sections/usageSourcePolicy.ts | 6 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 24 + rust/src/core/provider.rs | 10 +- rust/src/core/provider_factory.rs | 34 +- rust/src/core/token_accounts.rs | 24 + rust/src/locale.rs | 5 + rust/src/locale/en-US.ftl | 5 + .../providers/aixy/fixtures/key-usage.json | 134 +++++ rust/src/providers/aixy/mod.rs | 215 ++++++++ rust/src/providers/aixy/model.rs | 411 ++++++++++++++ rust/src/providers/aixy/present.rs | 173 ++++++ rust/src/providers/aixy/tests.rs | 504 ++++++++++++++++++ rust/src/providers/bifrost/mod.rs | 16 +- rust/src/providers/mod.rs | 22 + rust/src/settings/api_keys.rs | 11 + 28 files changed, 1760 insertions(+), 58 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx create mode 100644 rust/src/providers/aixy/fixtures/key-usage.json create mode 100644 rust/src/providers/aixy/mod.rs create mode 100644 rust/src/providers/aixy/model.rs create mode 100644 rust/src/providers/aixy/present.rs create mode 100644 rust/src/providers/aixy/tests.rs diff --git a/README.md b/README.md index 2e052ad2f9..bd9e0de719 100755 --- a/README.md +++ b/README.md @@ -116,6 +116,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/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 3aa5518b59..314baa56d8 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -400,15 +400,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); } @@ -466,6 +499,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, } } @@ -491,6 +525,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 779f9c801c..6ad574d3ca 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -214,8 +214,11 @@ 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"; 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 ee24a2c1f1..99ef91ee3d 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: "#5b7cfa", 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: "#7c3aed", fallbackLetter: "H" }, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 1c07040562..904dd541f8 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 09ead3c1d3..8e9c989a21 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"]); + }); }); 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..f8ff1eb89f 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, @@ -76,11 +78,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 withSelected(options, selected, t); + } + options.push({ value: "session", label: t("ProviderSessionLabel") }); if (provider.weekly) { options.push({ value: "weekly", label: t("ProviderWeeklyLabel") }); } @@ -104,12 +110,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/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}

}
)} {!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/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx index 1c1315b9da..78494f71ec 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx @@ -132,7 +132,7 @@ describe("MenuBarMetricSection", () => { render( key} onChange={vi.fn()} @@ -141,5 +141,6 @@ describe("MenuBarMetricSection", () => { 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 f8ff1eb89f..f0716229ce 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -30,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) => { @@ -83,7 +85,7 @@ function metricOptions( // 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 withSelected(options, selected, t); + return options; } options.push({ value: "session", label: t("ProviderSessionLabel") }); 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/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index fd8dd55109..233f7c0688 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -635,6 +635,7 @@ export interface ProviderDisplayProgress { /** Transient provider detail row; it is display-only and never quota math. */ export interface ProviderDisplayDetail { id: string; + sectionTitle: string | null; title: string; value: string; secondaryValue: string | null; diff --git a/rust/src/core/display_detail.rs b/rust/src/core/display_detail.rs index 1c80831aa3..cd5720d860 100644 --- a/rust/src/core/display_detail.rs +++ b/rust/src/core/display_detail.rs @@ -18,6 +18,7 @@ use crate::core::ProviderFetchResult; #[derive(Debug, Clone, PartialEq)] pub struct ProviderDisplayDetail { id: String, + section_title: Option, title: String, value: String, secondary_value: Option, @@ -40,6 +41,7 @@ impl ProviderDisplayDetail { ) -> Option { let row = Self { id: id.into(), + section_title: None, title: title.into(), value: value.into(), secondary_value: None, @@ -51,6 +53,16 @@ impl ProviderDisplayDetail { valid.then_some(row) } + /// Attach an optional display section title; rejects invalid text. + pub fn with_section_title(mut self, title: impl Into) -> Option { + let title = title.into(); + if !is_display_shape(&title, 128) { + return None; + } + self.section_title = Some(title); + Some(self) + } + /// Attach a validated secondary value; rejects invalid text. pub fn with_secondary_value(mut self, value: impl Into) -> Option { let value = value.into(); @@ -74,6 +86,10 @@ impl ProviderDisplayDetail { &self.id } + pub fn section_title(&self) -> Option<&str> { + self.section_title.as_deref() + } + pub fn title(&self) -> &str { &self.title } diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index ebbdcb8c20..3014476745 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -758,6 +758,24 @@ mod tests { assert_eq!(details[0].value(), "12"); } + #[test] + fn display_details_validate_optional_section_titles() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let row = ProviderDisplayDetail::new("budget", "Project", "$10 remaining") + .and_then(|row| row.with_section_title("Applicable budgets")); + let result = ProviderFetchResult::new(usage, "api").with_display_detail(row); + + assert_eq!( + result.display_details()[0].section_title(), + Some("Applicable budgets") + ); + assert!( + ProviderDisplayDetail::new("budget", "Project", "$10") + .and_then(|row| row.with_section_title("\n")) + .is_none() + ); + } + #[test] fn cost_snapshot_ignores_non_finite_values() { let cost = CostSnapshot::new(f64::NAN, "USD", "Monthly").with_limit(f64::INFINITY); diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index bae2c814c3..9d51ff351a 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Gateway de Wayfinder WayfinderGatewayLabel = URL del gateway WayfinderGatewayHelp = Usa HTTP solo para localhost o direcciones de loopback. HTTPS permite hosts remotos. WayfinderGatewayStatus = Gateway +AixyGatewayTitle = Gateway de Aixy +AixyGatewayLabel = URL base +AixyGatewayHelp = Déjalo vacío para usar el gateway alojado de Aixy o introduce la URL base de uno propio. HTTP solo se permite para localhost, redes privadas y hosts .local. WayfinderModels = Modelos WayfinderRequests = Solicitudes WayfinderTokens = Tokens diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 92618f72f7..2b1651507d 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Wayfinder ゲートウェイ WayfinderGatewayLabel = ゲートウェイ URL WayfinderGatewayHelp = HTTP は localhost またはループバックアドレスでのみ使用できます。リモートホストには HTTPS を使用します。 WayfinderGatewayStatus = ゲートウェイ +AixyGatewayTitle = Aixy ゲートウェイ +AixyGatewayLabel = ベース URL +AixyGatewayHelp = ホスト型 Aixy ゲートウェイを使う場合は空欄にするか、自分でホストするゲートウェイのベース URL を入力してください。HTTP は localhost、プライベートネットワーク、.local ホストでのみ使用できます。 WayfinderModels = モデル WayfinderRequests = リクエスト WayfinderTokens = トークン diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 7f59c6fc29..6988fded06 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Wayfinder 게이트웨이 WayfinderGatewayLabel = 게이트웨이 URL WayfinderGatewayHelp = HTTP는 localhost 또는 루프백 주소에서만 사용하세요. 원격 호스트에는 HTTPS를 사용합니다. WayfinderGatewayStatus = 게이트웨이 +AixyGatewayTitle = Aixy 게이트웨이 +AixyGatewayLabel = 기본 URL +AixyGatewayHelp = 호스팅된 Aixy 게이트웨이를 사용하려면 비워 두거나 자체 호스팅 게이트웨이의 기본 URL을 입력하세요. HTTP는 localhost, 사설 네트워크 및 .local 호스트에서만 허용됩니다. WayfinderModels = 모델 WayfinderRequests = 요청 WayfinderTokens = 토큰 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 855f7a9eb6..afc216937e 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -64,6 +64,9 @@ WayfinderGatewayTitle = Шлюз Wayfinder WayfinderGatewayLabel = URL-адрес шлюза WayfinderGatewayHelp = Используйте HTTP только для локальных адресов или адресов обратной связи. HTTPS разрешен для удаленных хостов. WayfinderGatewayStatus = шлюз +AixyGatewayTitle = Шлюз Aixy +AixyGatewayLabel = Базовый URL +AixyGatewayHelp = Оставьте пустым для размещенного шлюза Aixy или укажите базовый URL собственного шлюза. HTTP разрешен только для localhost, частных сетей и хостов .local. WayfinderModels = Модели WayfinderRequests = Запросы WayfinderTokens = Токены diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 9daf93d809..d11c4241ae 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -397,8 +397,15 @@ fn test_english_is_complete_and_other_languages_can_fallback() { .map(|(locale, resource)| (locale, resource_key_names(resource))) .collect(); let locale_key_names: HashSet<&str> = LocaleKey::ALL.iter().map(|(_, name)| *name).collect(); + let aixy_gateway_keys = ["AixyGatewayTitle", "AixyGatewayLabel", "AixyGatewayHelp"]; for (locale, keys) in &resource_keys { + for name in aixy_gateway_keys { + assert!( + keys.contains(name), + "missing Aixy gateway Fluent key {name} in {locale}" + ); + } for name in keys { assert!( locale_key_names.contains(name), diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index ca4fabe791..fa2f864995 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -86,6 +86,9 @@ WayfinderGatewayTitle = Wayfinder Ağ Geçidi WayfinderGatewayLabel = Ağ Geçidi URL'si WayfinderGatewayHelp = HTTP'yi yalnızca localhost veya geri döngü adresleri için kullanın. Uzak sunucular için HTTPS kullanılabilir. WayfinderGatewayStatus = Ağ Geçidi +AixyGatewayTitle = Aixy Ağ Geçidi +AixyGatewayLabel = Temel URL +AixyGatewayHelp = Barındırılan Aixy ağ geçidini kullanmak için boş bırakın veya kendi ağ geçidinizin temel URL'sini girin. HTTP yalnızca localhost, özel ağlar ve .local ana bilgisayarları için kullanılabilir. WayfinderModels = Modeller WayfinderRequests = İstekler WayfinderTokens = Tokenlar diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 98dd870db8..f8441f203d 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Wayfinder 网关 WayfinderGatewayLabel = 网关 URL WayfinderGatewayHelp = HTTP 仅可用于 localhost 或回环地址。远程主机请使用 HTTPS。 WayfinderGatewayStatus = 网关 +AixyGatewayTitle = Aixy 网关 +AixyGatewayLabel = 基础 URL +AixyGatewayHelp = 留空以使用托管的 Aixy 网关,或输入自托管网关的基础 URL。HTTP 仅允许用于 localhost、专用网络和 .local 主机。 WayfinderModels = 模型 WayfinderRequests = 请求 WayfinderTokens = 令牌 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index f893d5b423..e7af97178b 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -80,6 +80,9 @@ WayfinderGatewayTitle = Wayfinder 閘道 WayfinderGatewayLabel = 閘道 URL WayfinderGatewayHelp = HTTP 僅可用於 localhost 或迴路位址。遠端主機請使用 HTTPS。 WayfinderGatewayStatus = 閘道 +AixyGatewayTitle = Aixy 閘道 +AixyGatewayLabel = 基礎 URL +AixyGatewayHelp = 留空以使用託管的 Aixy 閘道,或輸入自架閘道的基礎 URL。HTTP 僅允許用於 localhost、私人網路和 .local 主機。 WayfinderModels = 模型 WayfinderRequests = 請求 WayfinderTokens = 權杖 diff --git a/rust/src/providers/aixy/present.rs b/rust/src/providers/aixy/present.rs index be49923ef9..87f5669478 100644 --- a/rust/src/providers/aixy/present.rs +++ b/rust/src/providers/aixy/present.rs @@ -81,22 +81,24 @@ fn rate_window(budget: &Budget) -> RateWindow { fn details(usage: &KeyUsage) -> Vec> { let mut rows = vec![ - ProviderDisplayDetail::new("key", "Key", &usage.key_label), - ProviderDisplayDetail::new("project", "Project", &usage.project_label), + ProviderDisplayDetail::new("key", "Key", &usage.key_label) + .and_then(|row| row.with_section_title("Aixy key")), + ProviderDisplayDetail::new("project", "Project", &usage.project_label) + .and_then(|row| row.with_section_title("Aixy key")), ProviderDisplayDetail::new( "observed", "Observed", usage.as_of.to_rfc3339_opts(SecondsFormat::Millis, true), - ), + ) + .and_then(|row| row.with_section_title("Aixy key")), ]; rows.extend(usage.budgets.iter().enumerate().map(budget_row)); match &usage.totals { Some(totals) => rows.extend(totals_rows(totals)), - None => rows.push(ProviderDisplayDetail::new( - "usage-7d", - "Usage (last 7 days)", - "Unavailable", - )), + None => rows.push( + ProviderDisplayDetail::new("usage-7d", "Usage (last 7 days)", "Unavailable") + .and_then(|row| row.with_section_title("Last 7 days · this key")), + ), } rows } @@ -105,7 +107,8 @@ fn budget_row((index, budget): (usize, &Budget)) -> Option Option Vec> { @@ -127,19 +131,22 @@ fn totals_rows(totals: &Totals) -> Vec> { "requests-7d", "Requests (last 7 days)", count(totals.requests), - ), + ) + .and_then(|row| row.with_section_title("Last 7 days · this key")), ProviderDisplayDetail::new( "tokens-7d", "Tokens (last 7 days)", count(totals.total_tokens), - ), + ) + .and_then(|row| row.with_section_title("Last 7 days · this key")), ProviderDisplayDetail::new( "spend-7d", "Attributed spend (last 7 days)", totals .spend_usd .map_or_else(|| "Unavailable".to_owned(), usd), - ), + ) + .and_then(|row| row.with_section_title("Last 7 days · this key")), ProviderDisplayDetail::new( "coverage-7d", "Cost coverage (last 7 days)", @@ -151,7 +158,8 @@ fn totals_rows(totals: &Totals) -> Vec> { ) .and_then(|row| { row.with_secondary_value(format!("{} partial", count(totals.partial_requests))) - }), + }) + .and_then(|row| row.with_section_title("Last 7 days · this key")), ] } diff --git a/rust/src/providers/aixy/tests.rs b/rust/src/providers/aixy/tests.rs index 72175225b8..f0b6f6ecb9 100644 --- a/rust/src/providers/aixy/tests.rs +++ b/rust/src/providers/aixy/tests.rs @@ -85,12 +85,14 @@ fn key_usage_keeps_overlapping_budgets_and_reservations_separate() { assert_eq!(result.source_label, "api"); assert_eq!(detail(&result, "key").value(), "Developer CLI"); + assert_eq!(detail(&result, "key").section_title(), Some("Aixy key")); assert_eq!(detail(&result, "project").value(), "Engineering"); assert_eq!( detail(&result, "observed").value(), "2026-09-24T12:00:00.000Z" ); let hard = detail(&result, "budget-0"); + assert_eq!(hard.section_title(), Some("Applicable budgets")); assert_eq!(hard.title(), "Project · Monthly · Shared · Hard"); assert_eq!(hard.value(), "$70.00 / $100.00 remaining"); assert_eq!( @@ -106,6 +108,7 @@ fn key_usage_keeps_overlapping_budgets_and_reservations_separate() { assert_eq!(detail(&result, "tokens-7d").value(), "1,200"); assert_eq!(detail(&result, "spend-7d").value(), "$1.25"); let coverage = detail(&result, "coverage-7d"); + assert_eq!(coverage.section_title(), Some("Last 7 days · this key")); assert_eq!(coverage.value(), "10 / 12 requests"); assert_eq!(coverage.secondary_value(), Some("2 partial")); } @@ -337,11 +340,11 @@ fn usage_url_supports_every_documented_base_form() { ), ("aixy.example.com", "https://aixy.example.com/v1/usage"), ("http://localhost:8080", "http://localhost:8080/v1/usage"), - ("http://10.1.2.3:8080/v1", "http://10.1.2.3:8080/v1/usage"), ( "http://gateway.local/team", "http://gateway.local/team/v1/usage", ), + ("http://10.1.2.3:8080/v1", "http://10.1.2.3:8080/v1/usage"), ] { assert_eq!(usage_url(base).unwrap().as_str(), expected, "{base}"); }