Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { formatDateTimeAsMoment } from "@/utils/directories";
import { formatDuration, numberWithCommas } from "@/utils/numbers";
import useCurrency from "@/hooks/useCurrency";
import React, { useEffect, useState, useContext } from "react";
import { isMobile } from "react-device-detect";
const MetricsContext = React.createContext();
Expand Down Expand Up @@ -33,14 +34,17 @@ function getAutoShowMetrics() {
* Build the metrics string for a given metrics object
* - Model name
* - Duration and output TPS
* - Cost (in the user's preferred currency, when known)
* - Timestamp
* @param {metrics: {duration:number, outputTps: number, model?: string, timestamp?: number}} metrics
* @param {metrics: {duration:number, outputTps: number, model?: string, timestamp?: number, totalCost?: number}} metrics
* @param {(usd: number) => string} formatCost - formats a USD cost in the user's preferred currency
* @returns {string}
*/
function buildMetricsString(metrics = {}) {
function buildMetricsString(metrics = {}, formatCost = () => "") {
return [
metrics?.model ? metrics.model : "",
`${formatDuration(metrics.duration)} (${formatTps(metrics.outputTps)} tok/s)`,
typeof metrics?.totalCost === "number" ? formatCost(metrics.totalCost) : "",
metrics?.timestamp
? formatDateTimeAsMoment(metrics.timestamp, "MMM D, h:mm A")
: "",
Expand Down Expand Up @@ -103,6 +107,7 @@ export default function RenderMetrics({ metrics = {} }) {
// Inherit the showMetricsAutomatically state from the MetricsProvider so the state is shared across all chats
const { showMetricsAutomatically, setShowMetricsAutomatically } =
useContext(MetricsContext);
const { formatCost } = useCurrency();
if (!metrics?.duration || !metrics?.outputTps || isMobile) return null;

return (
Expand All @@ -118,7 +123,7 @@ export default function RenderMetrics({ metrics = {} }) {
className={`border-none flex md:justify-end items-center gap-x-[8px] -ml-7 ${showMetricsAutomatically ? "opacity-100" : "opacity-0"} md:group-hover:opacity-100 transition-all duration-300`}
>
<p className="cursor-pointer text-xs font-mono text-zinc-400 light:text-slate-500">
{buildMetricsString(metrics)}
{buildMetricsString(metrics, formatCost)}
</p>
</button>
);
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/hooks/useCurrency.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react";
import Appearance from "@/models/appearance";
import {
CURRENCY_CHANGE_EVENT,
formatCost,
getExchangeRates,
} from "@/utils/currency";

/**
* Provides the user's preferred display currency and a formatter that
* converts stored USD costs into it. Falls back to USD display when no
* exchange rate is available for the preferred currency.
* @returns {{currency: string, formatCost: (usd: number) => string}}
*/
export default function useCurrency() {
const [currency, setCurrency] = useState(
Appearance.get("preferredCurrency") || "USD"
);
const [rates, setRates] = useState(null);

useEffect(() => {
getExchangeRates().then((record) => setRates(record?.rates ?? null));
}, []);

useEffect(() => {
function handleCurrencyChange(e) {
if (!e?.detail?.currency) return;
setCurrency(e.detail.currency);
}
window.addEventListener(CURRENCY_CHANGE_EVENT, handleCurrencyChange);
return () =>
window.removeEventListener(CURRENCY_CHANGE_EVENT, handleCurrencyChange);
}, []);

const rate = currency === "USD" ? 1 : rates?.[currency];
const format = useCallback(
(usd) =>
formatCost(usd, rate ? { currency, rate } : { currency: "USD", rate: 1 }),
[currency, rate]
);

return { currency, formatCost: format };
}
6 changes: 6 additions & 0 deletions frontend/src/locales/en/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,11 @@ const TRANSLATIONS = {
description:
"Select the preferred language to render AnythingLLM's UI in - when translations are available.",
},
"preferred-currency": {
title: "Preferred Currency",
description:
"Currency used to display LLM usage costs. Costs are always recorded in USD and converted for display only.",
},
logo: {
title: "Brand Logo",
description: "Upload your custom logo to showcase on all pages.",
Expand Down Expand Up @@ -1923,6 +1928,7 @@ const TRANSLATIONS = {
metrics: {
promptTokens: "Prompt tokens:",
completionTokens: "Completion tokens:",
cost: "Cost:",
},
},
toolCall: {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/models/appearance.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { safeJsonParse } from "@/utils/request";
* 'autoPlayAssistantTtsResponse' |
* 'enableSpellCheck' |
* 'renderHTML' |
* 'disableAutoScroll'
* 'disableAutoScroll' |
* 'preferredCurrency'
* } AvailableSettings - The supported settings for the appearance model.
*/

Expand All @@ -19,6 +20,7 @@ const Appearance = {
enableSpellCheck: true,
renderHTML: false,
disableAutoScroll: false,
preferredCurrency: "USD",
},

/**
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/pages/GeneralSettings/ScheduledJobs/RunDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "@phosphor-icons/react";
import ScheduledJobs from "@/models/scheduledJobs";
import usePolling from "@/hooks/usePolling";
import useCurrency from "@/hooks/useCurrency";
import showToast from "@/utils/toast";
import paths from "@/utils/paths";
import renderMarkdown from "@/utils/chat/markdown";
Expand Down Expand Up @@ -391,6 +392,7 @@ function FinalResponseSection({ t, result }) {
}

function MetricsSection({ t, metrics }) {
const { formatCost } = useCurrency();
if (!metrics || Object.keys(metrics).length === 0) return null;

// Todo: there is a bug where if you create a job that has no tools, we wont get any metrics
Expand Down Expand Up @@ -428,6 +430,14 @@ function MetricsSection({ t, metrics }) {
</span>
</span>
)}
{typeof metrics.totalCost === "number" && (
<span>
{t("scheduledJobs.runDetail.metrics.cost")}{" "}
<span className="text-zinc-50 light:text-slate-950">
{formatCost(metrics.totalCost)}
</span>
</span>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import CurrencyPreference from "../components/CurrencyPreference";
import LanguagePreference from "../components/LanguagePreference";
import ThemePreference from "../components/ThemePreference";

Expand All @@ -27,6 +28,7 @@ export default function InterfaceSettings() {
</div>
<ThemePreference />
<LanguagePreference />
<CurrencyPreference />
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import Appearance from "@/models/appearance";
import {
CURRENCY_CHANGE_EVENT,
SUPPORTED_CURRENCIES,
currencyName,
} from "@/utils/currency";

export default function CurrencyPreference() {
const { t, i18n } = useTranslation();

function changeCurrency(currency) {
Appearance.set("preferredCurrency", currency);
window.dispatchEvent(
new CustomEvent(CURRENCY_CHANGE_EVENT, { detail: { currency } })
);
}

return (
<div className="flex flex-col gap-y-0.5 my-4">
<p className="text-sm leading-6 font-semibold text-white">
{t("customization.items.preferred-currency.title")}
</p>
<p className="text-xs text-white/60">
{t("customization.items.preferred-currency.description")}
</p>
<div className="flex items-center gap-x-4">
<select
name="preferredCurrency"
className="border-none bg-theme-settings-input-bg mt-2 text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-fit py-2 px-4"
defaultValue={Appearance.get("preferredCurrency") || "USD"}
onChange={(e) => changeCurrency(e.target.value)}
>
{SUPPORTED_CURRENCIES.map((code) => {
return (
<option key={code} value={code}>
{code} β€” {currencyName(code, i18n.language)}
</option>
);
})}
</select>
</div>
</div>
);
}
110 changes: 110 additions & 0 deletions frontend/src/utils/currency.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { safeJsonParse } from "@/utils/request";

const FX_CACHE_KEY = "anythingllm_fx_rates";
const FX_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const FX_RATES_URL = "https://api.frankfurter.dev/v1/latest?base=USD";
export const CURRENCY_CHANGE_EVENT = "anythingllm_currency_change";

/**
* Currencies the Frankfurter API can convert USD into. Kept as a static list
* (rather than Intl.supportedValuesOf) so the picker never offers a currency
* we cannot actually convert to.
*/
export const SUPPORTED_CURRENCIES = [
"USD",
"AUD",
"BGN",
"BRL",
"CAD",
"CHF",
"CNY",
"CZK",
"DKK",
"EUR",
"GBP",
"HKD",
"HUF",
"IDR",
"ILS",
"INR",
"ISK",
"JPY",
"KRW",
"MXN",
"MYR",
"NOK",
"NZD",
"PHP",
"PLN",
"RON",
"SEK",
"SGD",
"THB",
"TRY",
"ZAR",
];

/**
* Returns the display name of a currency code in the user's language,
* falling back to the code itself.
* @param {string} code - ISO 4217 currency code (eg: "EUR")
* @param {string} [locale] - BCP 47 locale tag (eg: "en")
* @returns {string}
*/
export function currencyName(code, locale = undefined) {
try {
return new Intl.DisplayNames(locale, { type: "currency" }).of(code) ?? code;
} catch {
return code;
}
}

/**
* Fetches the latest USD-based exchange rates, cached in localStorage for
* 24 hours. Returns a stale cache when the API is unreachable, or null when
* no rates are available at all (callers should then display USD).
* Never throws.
* @returns {Promise<{fetchedAt: number, rates: Record<string, number>}|null>}
*/
export async function getExchangeRates() {
const cached = safeJsonParse(window.localStorage.getItem(FX_CACHE_KEY), null);
if (cached?.fetchedAt && Date.now() - cached.fetchedAt < FX_TTL_MS)
return cached;

try {
const res = await fetch(FX_RATES_URL);
if (!res.ok) throw new Error(`Bad response: ${res.status}`);
const { rates } = await res.json();
if (!rates || typeof rates !== "object")
throw new Error("Malformed rates response");
const record = { fetchedAt: Date.now(), rates };
window.localStorage.setItem(FX_CACHE_KEY, JSON.stringify(record));
return record;
} catch {
return cached ?? null;
}
}

/**
* Formats a stored USD cost into the given display currency.
* Precision scales down with the value so tiny per-message costs stay legible:
* >= 1 shows 2 decimals, >= 0.01 shows up to 4, anything smaller up to 6.
* @param {number} usd - the cost in USD
* @param {{currency?: string, rate?: number}} [options] - display currency and its units-per-USD rate
* @returns {string}
*/
export function formatCost(usd, { currency = "USD", rate = 1 } = {}) {
if (typeof usd !== "number" || !isFinite(usd)) return "";
const value = usd * (rate || 1);
const maxDigits = value >= 1 ? 2 : value >= 0.01 ? 4 : value > 0 ? 6 : 2;
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
minimumFractionDigits: 2,
maximumFractionDigits: maxDigits,
}).format(value);
} catch {
return `$${value.toFixed(maxDigits)}`;
}
}
7 changes: 0 additions & 7 deletions frontend/src/utils/numbers.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,6 @@ export function nFormatter(input) {
return Formatter.format(input);
}

export function dollarFormat(input) {
return new Intl.NumberFormat("en-us", {
style: "currency",
currency: "USD",
}).format(input);
}

export function toPercentString(input = null, decimals = 0) {
if (isNaN(input) || input === null) return "";
const percentage = Math.round(input * 100);
Expand Down
Loading
Loading