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:
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum QueryKeys {
|
||||
name = 'name', // user key name
|
||||
models = 'models',
|
||||
balance = 'balance',
|
||||
usage = 'usage',
|
||||
endpoints = 'endpoints',
|
||||
presets = 'presets',
|
||||
searchResults = 'searchResults',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user