feat: token analytics
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -279,11 +280,34 @@ class ChatMessageTable:
|
|||||||
end_date: Optional[int] = None,
|
end_date: Optional[int] = None,
|
||||||
db: Optional[Session] = None,
|
db: Optional[Session] = None,
|
||||||
) -> dict[str, dict]:
|
) -> dict[str, dict]:
|
||||||
"""Aggregate token usage by model. Works with SQLite and PostgreSQL."""
|
"""Aggregate token usage by model using database-level aggregation."""
|
||||||
with get_db_context(db) as db:
|
with get_db_context(db) as db:
|
||||||
|
from sqlalchemy import func, cast, Integer
|
||||||
|
|
||||||
|
dialect = db.bind.dialect.name
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
input_tokens = cast(
|
||||||
|
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
|
||||||
|
)
|
||||||
|
output_tokens = cast(
|
||||||
|
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
|
||||||
|
)
|
||||||
|
elif dialect == "postgresql":
|
||||||
|
input_tokens = cast(
|
||||||
|
ChatMessage.usage["input_tokens"].astext, Integer
|
||||||
|
)
|
||||||
|
output_tokens = cast(
|
||||||
|
ChatMessage.usage["output_tokens"].astext, Integer
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||||
|
|
||||||
query = db.query(
|
query = db.query(
|
||||||
ChatMessage.model_id,
|
ChatMessage.model_id,
|
||||||
ChatMessage.usage,
|
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
|
||||||
|
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
|
||||||
|
func.count(ChatMessage.id).label("message_count"),
|
||||||
).filter(
|
).filter(
|
||||||
ChatMessage.role == "assistant",
|
ChatMessage.role == "assistant",
|
||||||
ChatMessage.model_id.isnot(None),
|
ChatMessage.model_id.isnot(None),
|
||||||
@@ -295,27 +319,17 @@ class ChatMessageTable:
|
|||||||
if end_date:
|
if end_date:
|
||||||
query = query.filter(ChatMessage.created_at <= end_date)
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
|
||||||
results = query.all()
|
results = query.group_by(ChatMessage.model_id).all()
|
||||||
|
|
||||||
# Aggregate in Python for cross-database compatibility
|
return {
|
||||||
usage_by_model: dict[str, dict] = {}
|
row.model_id: {
|
||||||
for model_id, usage in results:
|
"input_tokens": row.input_tokens,
|
||||||
if model_id not in usage_by_model:
|
"output_tokens": row.output_tokens,
|
||||||
usage_by_model[model_id] = {
|
"total_tokens": row.input_tokens + row.output_tokens,
|
||||||
"input_tokens": 0,
|
"message_count": row.message_count,
|
||||||
"output_tokens": 0,
|
}
|
||||||
"message_count": 0,
|
for row in results
|
||||||
}
|
}
|
||||||
|
|
||||||
usage_by_model[model_id]["input_tokens"] += usage.get("input_tokens") or 0
|
|
||||||
usage_by_model[model_id]["output_tokens"] += usage.get("output_tokens") or 0
|
|
||||||
usage_by_model[model_id]["message_count"] += 1
|
|
||||||
|
|
||||||
# Add total_tokens
|
|
||||||
for data in usage_by_model.values():
|
|
||||||
data["total_tokens"] = data["input_tokens"] + data["output_tokens"]
|
|
||||||
|
|
||||||
return usage_by_model
|
|
||||||
|
|
||||||
def get_message_count_by_user(
|
def get_message_count_by_user(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -192,3 +192,46 @@ async def get_daily_stats(
|
|||||||
for date, models in sorted(counts.items())
|
for date, models in sorted(counts.items())
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageEntry(BaseModel):
|
||||||
|
model_id: str
|
||||||
|
input_tokens: int
|
||||||
|
output_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
message_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageResponse(BaseModel):
|
||||||
|
models: list[TokenUsageEntry]
|
||||||
|
total_input_tokens: int
|
||||||
|
total_output_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens", response_model=TokenUsageResponse)
|
||||||
|
async def get_token_usage(
|
||||||
|
start_date: Optional[int] = Query(None),
|
||||||
|
end_date: Optional[int] = Query(None),
|
||||||
|
user=Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Get token usage aggregated by model."""
|
||||||
|
usage = ChatMessages.get_token_usage_by_model(
|
||||||
|
start_date=start_date, end_date=end_date, db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
models = [
|
||||||
|
TokenUsageEntry(model_id=model_id, **data)
|
||||||
|
for model_id, data in sorted(usage.items(), key=lambda x: -x[1]["total_tokens"])
|
||||||
|
]
|
||||||
|
|
||||||
|
total_input = sum(m.input_tokens for m in models)
|
||||||
|
total_output = sum(m.output_tokens for m in models)
|
||||||
|
|
||||||
|
return TokenUsageResponse(
|
||||||
|
models=models,
|
||||||
|
total_input_tokens=total_input,
|
||||||
|
total_output_tokens=total_output,
|
||||||
|
total_tokens=total_input + total_output,
|
||||||
|
)
|
||||||
|
|||||||
@@ -193,3 +193,39 @@ export const getDailyStats = async (
|
|||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTokenUsage = async (
|
||||||
|
token: string = '',
|
||||||
|
startDate: number | null = null,
|
||||||
|
endDate: number | null = null
|
||||||
|
) => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (startDate) searchParams.append('start_date', startDate.toString());
|
||||||
|
if (endDate) searchParams.append('end_date', endDate.toString());
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/tokens?${searchParams.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error = err.detail;
|
||||||
|
console.error(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, getContext } from 'svelte';
|
import { onMount, getContext } from 'svelte';
|
||||||
import { models } from '$lib/stores';
|
import { models } from '$lib/stores';
|
||||||
import { getSummary, getModelAnalytics, getUserAnalytics, getDailyStats } from '$lib/apis/analytics';
|
import { getSummary, getModelAnalytics, getUserAnalytics, getDailyStats, getTokenUsage } from '$lib/apis/analytics';
|
||||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
import ChartLine from './ChartLine.svelte';
|
import ChartLine from './ChartLine.svelte';
|
||||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||||
|
import { formatNumber } from '$lib/utils';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@
|
|||||||
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
|
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
|
||||||
let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
|
let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
|
||||||
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
|
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
|
||||||
|
let tokenStats: Record<string, { input_tokens: number; output_tokens: number; total_tokens: number }> = {};
|
||||||
|
let totalTokens = { input: 0, output: 0, total: 0 };
|
||||||
|
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
@@ -69,11 +72,12 @@
|
|||||||
try {
|
try {
|
||||||
const { start, end } = getDateRange(selectedPeriod);
|
const { start, end } = getDateRange(selectedPeriod);
|
||||||
const granularity = selectedPeriod === '24h' ? 'hourly' : 'daily';
|
const granularity = selectedPeriod === '24h' ? 'hourly' : 'daily';
|
||||||
const [summaryRes, modelsRes, usersRes, dailyRes] = await Promise.all([
|
const [summaryRes, modelsRes, usersRes, dailyRes, tokensRes] = await Promise.all([
|
||||||
getSummary(localStorage.token, start, end),
|
getSummary(localStorage.token, start, end),
|
||||||
getModelAnalytics(localStorage.token, start, end),
|
getModelAnalytics(localStorage.token, start, end),
|
||||||
getUserAnalytics(localStorage.token, start, end, 50),
|
getUserAnalytics(localStorage.token, start, end, 50),
|
||||||
getDailyStats(localStorage.token, start, end, granularity)
|
getDailyStats(localStorage.token, start, end, granularity),
|
||||||
|
getTokenUsage(localStorage.token, start, end)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
summary = summaryRes ?? summary;
|
summary = summaryRes ?? summary;
|
||||||
@@ -86,6 +90,23 @@
|
|||||||
|
|
||||||
userStats = usersRes?.users ?? [];
|
userStats = usersRes?.users ?? [];
|
||||||
dailyStats = dailyRes?.data ?? [];
|
dailyStats = dailyRes?.data ?? [];
|
||||||
|
|
||||||
|
// Process token data
|
||||||
|
if (tokensRes) {
|
||||||
|
tokenStats = {};
|
||||||
|
for (const m of tokensRes.models) {
|
||||||
|
tokenStats[m.model_id] = {
|
||||||
|
input_tokens: m.input_tokens,
|
||||||
|
output_tokens: m.output_tokens,
|
||||||
|
total_tokens: m.total_tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
totalTokens = {
|
||||||
|
input: tokensRes.total_input_tokens,
|
||||||
|
output: tokensRes.total_output_tokens,
|
||||||
|
total: tokensRes.total_tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dashboard load failed:', err);
|
console.error('Dashboard load failed:', err);
|
||||||
}
|
}
|
||||||
@@ -140,6 +161,7 @@
|
|||||||
{#if !loading}
|
{#if !loading}
|
||||||
<div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2">
|
<div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2">
|
||||||
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_messages.toLocaleString()}</span> {$i18n.t('messages')}</span>
|
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_messages.toLocaleString()}</span> {$i18n.t('messages')}</span>
|
||||||
|
<span><span class="font-medium text-gray-900 dark:text-gray-300">{formatNumber(totalTokens.total)}</span> {$i18n.t('tokens')}</span>
|
||||||
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_chats.toLocaleString()}</span> {$i18n.t('chats')}</span>
|
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_chats.toLocaleString()}</span> {$i18n.t('chats')}</span>
|
||||||
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_users}</span> {$i18n.t('users')}</span>
|
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_users}</span> {$i18n.t('users')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,6 +235,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
|
||||||
<th scope="col" class="px-2.5 py-2 text-right w-16">%</th>
|
<th scope="col" class="px-2.5 py-2 text-right w-16">%</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -231,13 +254,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td>
|
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td>
|
||||||
|
<td class="px-3 py-1 text-right">{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td>
|
||||||
<td class="px-3 py-1 text-right text-gray-400">
|
<td class="px-3 py-1 text-right text-gray-400">
|
||||||
{totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}%
|
{totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}%
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{#if sortedModels.length === 0}
|
{#if sortedModels.length === 0}
|
||||||
<tr><td colspan="4" class="px-3 py-2 text-center text-gray-400">{$i18n.t('No data')}</td></tr>
|
<tr><td colspan="5" class="px-3 py-2 text-center text-gray-400">{$i18n.t('No data')}</td></tr>
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import hljs from 'highlight.js';
|
|||||||
|
|
||||||
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export const formatNumber = (num: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(num);
|
||||||
|
};
|
||||||
|
|
||||||
function escapeRegExp(string: string): string {
|
function escapeRegExp(string: string): string {
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user