feat(usage): unified per-user AI Usage settings tab

Backend: GET /api/usage (requireJwtAuth) aggregates this user's prompt+
completion Transaction spend over today/7d/30d plus a per-model breakdown,
enriched with the org tier from CommerceClient. Response mirrors the
@hanzo/usage UsageSnapshot shape (providerId 'hanzo', totals, providerCost)
for one wire format across Hanzo products.

Frontend: new Usage settings tab beside Balance, reusing the UsageBar idiom;
useGetUserUsage hook follows useGetUserBalance; en localization strings added.
This commit is contained in:
Hanzo AI
2026-07-07 15:31:03 -07:00
parent 885f6ec33c
commit 77f768f7fa
14 changed files with 351 additions and 2 deletions
+93
View File
@@ -0,0 +1,93 @@
const mongoose = require('mongoose');
const { Transaction } = require('~/db/models');
const { getCommerceClient } = require('~/server/services/CommerceClient');
/** 1e6 tokenCredits == $1 USD (mirrors client creditsToUsd). */
const CREDITS_PER_USD = 1_000_000;
/** Only prompt/completion transactions represent AI usage (spend); `credits` are deposits. */
const SPEND_TYPES = ['prompt', 'completion'];
/** One accumulator over abs(rawAmount) tokens, abs(tokenValue) credits, and request count. */
const spendGroup = (id) => ({
$group: {
_id: id,
tokens: { $sum: { $abs: { $ifNull: ['$rawAmount', 0] } } },
credits: { $sum: { $abs: { $ifNull: ['$tokenValue', 0] } } },
requests: { $sum: 1 },
},
});
/** Shape a facet bucket into the @hanzo/usage window schema (UsageTotals + ProviderCostSnapshot). */
function toWindow(bucket) {
const row = (bucket && bucket[0]) || { tokens: 0, credits: 0, requests: 0 };
return {
totals: { tokens: row.tokens, requests: row.requests },
providerCost: { used: row.credits / CREDITS_PER_USD, currencyCode: 'USD' },
};
}
/**
* Unified per-user AI usage. Aggregates this user's spend (prompt + completion
* Transactions) over today / 7d / 30d plus a per-model breakdown, enriched with
* the org tier from Commerce. Response mirrors @hanzo/usage's UsageSnapshot
* shape (providerId 'hanzo', totals, providerCost) so it is one wire format
* across Hanzo products.
*/
async function usageController(req, res) {
const userId = new mongoose.Types.ObjectId(req.user.id);
const now = new Date();
const since30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const since7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [facet] = await Transaction.aggregate([
{ $match: { user: userId, tokenType: { $in: SPEND_TYPES }, createdAt: { $gte: since30 } } },
{
$facet: {
today: [{ $match: { createdAt: { $gte: startOfToday } } }, spendGroup(null)],
d7: [{ $match: { createdAt: { $gte: since7 } } }, spendGroup(null)],
d30: [spendGroup(null)],
models: [spendGroup('$model'), { $sort: { credits: -1 } }, { $limit: 20 }],
},
},
]);
const models = (facet?.models ?? [])
.filter((m) => m._id)
.map((m) => ({
model: m._id,
tokens: m.tokens,
requests: m.requests,
cost: m.credits / CREDITS_PER_USD,
}));
const payload = {
providerId: 'hanzo',
currencyCode: 'USD',
tier: 'Free',
windows: {
today: toWindow(facet?.today),
'7d': toWindow(facet?.d7),
'30d': toWindow(facet?.d30),
},
models,
updatedAt: now.toISOString(),
};
// Enrich with Commerce tier (fail-open: usage is still authoritative locally).
const commerceClient = getCommerceClient();
if (commerceClient) {
try {
const tier = await commerceClient.getTierConfig(req.user.id);
if (tier) {
payload.tier = tier.displayName || tier.name || payload.tier;
}
} catch {
// ignore — return local usage without tier enrichment
}
}
res.status(200).json(payload);
}
module.exports = usageController;
+1
View File
@@ -188,6 +188,7 @@ const startServer = async () => {
app.use('/v1/chat/categories', routes.categories);
app.use('/v1/chat/endpoints', routes.endpoints);
app.use('/v1/chat/balance', routes.balance);
app.use('/v1/chat/usage', routes.usage);
app.use('/v1/chat/models', routes.models);
app.use('/v1/chat/config', routes.config);
app.use('/v1/chat/assistants', routes.assistants);
+2
View File
@@ -9,6 +9,7 @@ const memories = require('./memories');
const presets = require('./presets');
const prompts = require('./prompts');
const balance = require('./balance');
const usage = require('./usage');
const actions = require('./actions');
const apiKeys = require('./apiKeys');
const banner = require('./banner');
@@ -49,6 +50,7 @@ module.exports = {
actions,
presets,
balance,
usage,
messages,
memories,
endpoints,
+8
View File
@@ -0,0 +1,8 @@
const express = require('express');
const router = express.Router();
const controller = require('../controllers/Usage');
const { requireJwtAuth } = require('../middleware/');
router.get('/', requireJwtAuth, controller);
module.exports = router;
+15 -2
View File
@@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import { MessageSquare, Command, DollarSign } from 'lucide-react';
import { MessageSquare, Command, DollarSign, BarChart3 } from 'lucide-react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import {
GearIcon,
@@ -20,6 +20,7 @@ import {
Personalization,
Data,
Balance,
Usage,
Account,
} from './SettingsTabs';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
@@ -43,7 +44,9 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
SettingsTabValues.SPEECH,
...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []),
SettingsTabValues.DATA,
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
...(startupConfig?.balance?.enabled
? [SettingsTabValues.BALANCE, SettingsTabValues.USAGE]
: []),
SettingsTabValues.ACCOUNT,
];
const currentIndex = tabs.indexOf(activeTab);
@@ -114,6 +117,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
icon: <DollarSign size={18} />,
label: 'com_nav_setting_balance' as TranslationKeys,
},
{
value: SettingsTabValues.USAGE,
icon: <BarChart3 size={18} />,
label: 'com_nav_setting_usage' as TranslationKeys,
},
]
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
{
@@ -248,6 +256,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Balance />
</Tabs.Content>
)}
{startupConfig?.balance?.enabled && (
<Tabs.Content value={SettingsTabValues.USAGE} tabIndex={-1}>
<Usage />
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account />
</Tabs.Content>
@@ -0,0 +1,178 @@
import React from 'react';
import { ExternalLink, TrendingUp, Zap, ArrowUpRight, BarChart3 } from 'lucide-react';
import { useGetStartupConfig, useGetUserUsage } from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
const CONSOLE_URL = 'https://console.hanzo.ai/ai-accounts';
function formatUsd(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
/** Compact token count formatter */
function formatTokens(n: number): string {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}
/** Progress bar component (shared idiom with the Balance tab). */
function UsageBar({
label,
sublabel,
current,
limit,
color = 'bg-blue-500',
}: {
label: string;
sublabel?: string;
current: number;
limit: number;
color?: string;
}) {
const pct = limit > 0 ? Math.min((current / limit) * 100, 100) : 0;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-text-primary">{label}</span>
{sublabel && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">{sublabel}</span>
)}
</div>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className={`h-full rounded-full transition-all duration-500 ease-out ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function Usage() {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const usageQuery = useGetUserUsage({
enabled: !!isAuthenticated && !!startupConfig?.balance?.enabled,
});
const usage = usageQuery.data;
const windows = usage?.windows;
const today = windows?.today;
const week = windows?.['7d'];
const month = windows?.['30d'];
const models = usage?.models ?? [];
const monthTokens = month?.totals.tokens ?? 0;
const monthSpend = month?.providerCost.used ?? 0;
const tierLabel = usage?.tier && usage.tier !== 'unknown' ? usage.tier : 'Free';
// Scale the per-window token bars against the widest window (30d).
const tokenScale = Math.max(monthTokens, 1);
const topModelCost = models.length > 0 ? Math.max(...models.map((m) => m.cost), 0.0001) : 1;
return (
<div className="flex flex-col gap-5 p-4 text-sm text-text-primary">
{/* Spend header card */}
<div className="rounded-xl border border-border-medium bg-surface-secondary p-4">
<div className="flex items-start justify-between">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-secondary">
{localize('com_nav_usage_spend_30d')}
</p>
<p className="mt-1 text-2xl font-bold text-text-primary">{formatUsd(monthSpend)}</p>
</div>
<div className="flex items-center gap-1 rounded-full bg-surface-tertiary px-2.5 py-1">
<Zap className="h-3 w-3 text-text-secondary" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-secondary">
{tierLabel}
</span>
</div>
</div>
<p className="mt-1 text-xs text-text-secondary">
{formatTokens(monthTokens)} {localize('com_nav_usage_tokens_used_30d')}
</p>
</div>
{/* Token usage by window */}
<div className="space-y-4">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-text-secondary">
<TrendingUp className="h-3.5 w-3.5" />
{localize('com_nav_usage_tokens')}
</h3>
<UsageBar
label={localize('com_nav_usage_today')}
sublabel={`${formatTokens(today?.totals.tokens ?? 0)} · ${formatUsd(
today?.providerCost.used ?? 0,
)}`}
current={today?.totals.tokens ?? 0}
limit={tokenScale}
color="bg-blue-500"
/>
<UsageBar
label={localize('com_nav_usage_last_7d')}
sublabel={`${formatTokens(week?.totals.tokens ?? 0)} · ${formatUsd(
week?.providerCost.used ?? 0,
)}`}
current={week?.totals.tokens ?? 0}
limit={tokenScale}
color="bg-emerald-500"
/>
<UsageBar
label={localize('com_nav_usage_last_30d')}
sublabel={`${formatTokens(monthTokens)} · ${formatUsd(monthSpend)}`}
current={monthTokens}
limit={tokenScale}
color="bg-violet-500"
/>
</div>
{/* Per-model breakdown */}
{models.length > 0 && (
<div className="space-y-4">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-text-secondary">
<BarChart3 className="h-3.5 w-3.5" />
{localize('com_nav_usage_by_model')}
</h3>
{models.slice(0, 8).map((m) => (
<UsageBar
key={m.model}
label={m.model}
sublabel={`${formatTokens(m.tokens)} · ${formatUsd(m.cost)}`}
current={m.cost}
limit={topModelCost}
color="bg-blue-500"
/>
))}
</div>
)}
{/* Console link */}
<div className="space-y-2 border-t border-border-light pt-4">
<a
href={CONSOLE_URL}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center gap-2 rounded-lg border border-border-medium px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover active:scale-[0.98]"
>
<ExternalLink className="h-4 w-4 text-text-secondary" />
{localize('com_nav_usage_all_providers')}
<ArrowUpRight className="h-3.5 w-3.5 opacity-70" />
</a>
</div>
</div>
);
}
export default React.memo(Usage);
@@ -2,6 +2,7 @@ export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data';
export { default as Speech } from './Speech/Speech';
export { default as Balance } from './Balance/Balance';
export { default as Usage } from './Usage/Usage';
export { default as General } from './General/General';
export { default as Account } from './Account/Account';
export { default as Commands } from './Commands/Commands';
+13
View File
@@ -31,6 +31,19 @@ export const useGetUserBalance = (
});
};
export const useGetUserUsage = (
config?: UseQueryOptions<t.TUsageResponse>,
): QueryObserverResult<t.TUsageResponse> => {
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
return useQuery<t.TUsageResponse>([QueryKeys.usage], () => dataService.getUserUsage(), {
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: true,
...config,
enabled: (config?.enabled ?? true) === true && queriesEnabled,
});
};
export const useGetSearchEnabledQuery = (
config?: UseQueryOptions<boolean>,
): QueryObserverResult<boolean> => {
+9
View File
@@ -584,6 +584,7 @@
"com_nav_setting_mcp": "MCP Settings",
"com_nav_setting_personalization": "Personalization",
"com_nav_setting_speech": "Speech",
"com_nav_setting_usage": "Usage",
"com_nav_settings": "Settings",
"com_nav_shared_links": "Shared links",
"com_nav_show_code": "Always show code when using code interpreter",
@@ -604,6 +605,14 @@
"com_nav_tool_dialog_mcp_server_tools": "MCP Server Tools",
"com_nav_tool_remove": "Remove",
"com_nav_tool_search": "Search tools",
"com_nav_usage_all_providers": "See all providers in console.hanzo.ai/ai-accounts",
"com_nav_usage_by_model": "By model",
"com_nav_usage_last_30d": "Last 30 days",
"com_nav_usage_last_7d": "Last 7 days",
"com_nav_usage_spend_30d": "Spend (30 days)",
"com_nav_usage_today": "Today",
"com_nav_usage_tokens": "Tokens used",
"com_nav_usage_tokens_used_30d": "tokens used in the last 30 days",
"com_nav_user": "USER",
"com_nav_user_msg_markdown": "Render user messages as markdown",
"com_nav_user_name_display": "Display username in messages",
@@ -76,6 +76,8 @@ export const user = () => `${BASE_URL}/v1/chat/user`;
export const balance = () => `${BASE_URL}/v1/chat/balance`;
export const usage = () => `${BASE_URL}/v1/chat/usage`;
export const userPlugins = () => `${BASE_URL}/v1/chat/user/plugins`;
export const deleteUser = () => `${BASE_URL}/v1/chat/user/delete`;
+4
View File
@@ -1693,6 +1693,10 @@ export enum SettingsTabValues {
* Tab for Balance Settings
*/
BALANCE = 'balance',
/**
* Tab for Usage
*/
USAGE = 'usage',
/**
* Tab for Account Settings
*/
@@ -124,6 +124,10 @@ export function getUserBalance(): Promise<t.TBalanceResponse> {
return request.get(endpoints.balance());
}
export function getUserUsage(): Promise<t.TUsageResponse> {
return request.get(endpoints.usage());
}
export const updateTokenCount = (text: string) => {
return request.post(endpoints.tokenizer(), { arg: text });
};
+1
View File
@@ -11,6 +11,7 @@ export enum QueryKeys {
name = 'name', // user key name
models = 'models',
balance = 'balance',
usage = 'usage',
endpoints = 'endpoints',
presets = 'presets',
searchResults = 'searchResults',
+20
View File
@@ -700,3 +700,23 @@ export type TBalanceResponse = {
trialCredits?: number;
paidCredits?: number;
};
/** One usage window — mirrors @hanzo/usage UsageTotals + ProviderCostSnapshot. */
export type TUsageWindow = {
totals: { tokens: number; requests: number };
providerCost: { used: number; currencyCode: string };
};
/** Per-user unified usage payload (mirrors @hanzo/usage UsageSnapshot shape). */
export type TUsageResponse = {
providerId: string;
currencyCode: string;
tier: string;
windows: {
today: TUsageWindow;
'7d': TUsageWindow;
'30d': TUsageWindow;
};
models: { model: string; tokens: number; requests: number; cost: number }[];
updatedAt: string;
};