refactor: unify token usage stats across all session types (#274)

- Rewrite SQL queries to join bot_history_messages with bot_sessions,
  supporting chat/heartbeat/schedule usage from a single source
- Update Go handler and CLI command to use unified queries
- Fix daily chart stacking: each session type gets its own bar group
- Add total input/output trend lines to the daily token chart
- Fix summary cards reactivity by restricting aggregation to allDays range
- Fix cache chart reactive dependency tracking by inlining data access
- Add i18n keys for schedule, totalInput, totalOutput
- Default time range changed to 7 days
- Regenerate sqlc, swagger, and SDK
This commit is contained in:
Acbox Liu
2026-03-21 19:14:37 +08:00
committed by GitHub
parent 7d7d0e4b51
commit 80b36f79f3
13 changed files with 320 additions and 410 deletions
+11
View File
@@ -1033,6 +1033,10 @@
"chatOutput": "Chat Output",
"heartbeatInput": "Heartbeat Input",
"heartbeatOutput": "Heartbeat Output",
"scheduleInput": "Schedule Input",
"scheduleOutput": "Schedule Output",
"totalInput": "Total Input",
"totalOutput": "Total Output",
"cacheRead": "Cache Read",
"cacheWrite": "Cache Write",
"noCache": "No Cache",
@@ -1041,6 +1045,13 @@
"modelDistribution": "Model Usage Distribution",
"filterByModel": "Filter by Model",
"allModels": "All Models",
"sessionType": "Session Type",
"allTypes": "All Types",
"chat": "Chat",
"heartbeat": "Heartbeat",
"schedule": "Schedule",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"dateFrom": "From",
"dateTo": "To",
"chartPie": "Pie",
+11
View File
@@ -1029,6 +1029,10 @@
"chatOutput": "对话输出",
"heartbeatInput": "心跳输入",
"heartbeatOutput": "心跳输出",
"scheduleInput": "定时任务输入",
"scheduleOutput": "定时任务输出",
"totalInput": "总输入",
"totalOutput": "总输出",
"cacheRead": "缓存读取",
"cacheWrite": "缓存写入",
"noCache": "无缓存",
@@ -1037,6 +1041,13 @@
"modelDistribution": "模型用量分布",
"filterByModel": "按模型筛选",
"allModels": "全部模型",
"sessionType": "会话类型",
"allTypes": "全部类型",
"chat": "对话",
"heartbeat": "心跳",
"schedule": "定时任务",
"inputTokens": "输入 Tokens",
"outputTokens": "输出 Tokens",
"dateFrom": "开始日期",
"dateTo": "结束日期",
"chartPie": "饼图",
+166 -78
View File
@@ -75,6 +75,29 @@
/>
</div>
<div class="space-y-1.5">
<Label>{{ $t('usage.sessionType') }}</Label>
<Select v-model="selectedSessionType">
<SelectTrigger class="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{{ $t('usage.allTypes') }}
</SelectItem>
<SelectItem value="chat">
{{ $t('usage.chat') }}
</SelectItem>
<SelectItem value="heartbeat">
{{ $t('usage.heartbeat') }}
</SelectItem>
<SelectItem value="schedule">
{{ $t('usage.schedule') }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div
v-if="modelOptions.length > 0"
class="space-y-1.5"
@@ -194,7 +217,7 @@
</CardContent>
</Card>
<!-- Chart 1: Daily token usage stacked area -->
<!-- Chart: Daily token usage -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">
@@ -210,7 +233,7 @@
</CardContent>
</Card>
<!-- Chart 2: Cache breakdown stacked bar -->
<!-- Chart: Cache breakdown -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">
@@ -226,7 +249,7 @@
</CardContent>
</Card>
<!-- Chart 3: Cache hit rate line -->
<!-- Chart: Cache hit rate -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">
@@ -292,8 +315,9 @@ use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipCompon
const { t } = useI18n()
const selectedBotId = useSyncedQueryParam('bot', '')
const timeRange = useSyncedQueryParam('range', '30')
const timeRange = useSyncedQueryParam('range', '7')
const selectedModelId = useSyncedQueryParam('model', 'all')
const selectedSessionType = useSyncedQueryParam('type', 'all')
const modelChartType = ref('pie')
function daysAgo(days: number): string {
@@ -362,6 +386,38 @@ const modelOptions = computed(() =>
byModelData.value.filter(m => m.model_id),
)
type SessionType = 'chat' | 'heartbeat' | 'schedule'
const sessionTypeFilter = computed(() =>
selectedSessionType.value === 'all' ? null : selectedSessionType.value as SessionType,
)
interface TypedDayMaps {
chat: Map<string, HandlersDailyTokenUsage>
heartbeat: Map<string, HandlersDailyTokenUsage>
schedule: Map<string, HandlersDailyTokenUsage>
}
function buildDayMap(rows: HandlersDailyTokenUsage[] | undefined) {
const map = new Map<string, HandlersDailyTokenUsage>()
for (const r of rows ?? []) {
if (r.day) map.set(r.day, r)
}
return map
}
const dayMaps = computed<TypedDayMaps>(() => ({
chat: buildDayMap(usageData.value?.chat),
heartbeat: buildDayMap(usageData.value?.heartbeat),
schedule: buildDayMap(usageData.value?.schedule),
}))
const activeTypes = computed<SessionType[]>(() => {
const filter = sessionTypeFilter.value
if (filter) return [filter]
return ['chat', 'heartbeat', 'schedule']
})
const allDays = computed(() => {
const from = new Date(dateFrom.value + 'T00:00:00')
const toExclusive = new Date(dateTo.value + 'T00:00:00')
@@ -383,26 +439,22 @@ const allDays = computed(() => {
const hasData = computed(() => {
const chat = usageData.value?.chat ?? []
const heartbeat = usageData.value?.heartbeat ?? []
return chat.length > 0 || heartbeat.length > 0 || byModelData.value.length > 0
const schedule = usageData.value?.schedule ?? []
return chat.length > 0 || heartbeat.length > 0 || schedule.length > 0 || byModelData.value.length > 0
})
function buildDayMap(rows: HandlersDailyTokenUsage[] | undefined) {
const map = new Map<string, HandlersDailyTokenUsage>()
for (const r of rows ?? []) {
if (r.day) map.set(r.day, r)
}
return map
}
const summary = computed(() => {
const chatMap = buildDayMap(usageData.value?.chat)
const hbMap = buildDayMap(usageData.value?.heartbeat)
const days = allDays.value
const types = activeTypes.value
const maps = dayMaps.value
let totalInput = 0
let totalOutput = 0
let totalCacheRead = 0
let totalReasoning = 0
for (const m of [chatMap, hbMap]) {
for (const r of m.values()) {
for (const day of days) {
for (const tp of types) {
const r = maps[tp].get(day)
if (!r) continue
totalInput += r.input_tokens ?? 0
totalOutput += r.output_tokens ?? 0
totalCacheRead += r.cache_read_tokens ?? 0
@@ -418,6 +470,11 @@ const summary = computed(() => {
}
})
const sessionTypeInputLabel = (type: SessionType) =>
t(`usage.${type}Input`)
const sessionTypeOutputLabel = (type: SessionType) =>
t(`usage.${type}Output`)
function modelLabel(m: HandlersModelTokenUsage) {
return `${m.model_name || m.model_slug} (${m.provider_name})`
}
@@ -464,7 +521,7 @@ const modelBarOption = computed(() => {
const names = models.map(m => modelLabel(m))
return {
tooltip: { trigger: 'axis' as const },
legend: { data: [t('usage.chatInput'), t('usage.chatOutput')] },
legend: { data: [t('usage.inputTokens'), t('usage.outputTokens')] },
grid: { left: 60, right: 20, bottom: 60, top: 40 },
xAxis: {
type: 'category' as const,
@@ -474,13 +531,13 @@ const modelBarOption = computed(() => {
yAxis: { type: 'value' as const },
series: [
{
name: t('usage.chatInput'),
name: t('usage.inputTokens'),
type: 'bar' as const,
stack: 'tokens',
data: models.map(m => m.input_tokens ?? 0),
},
{
name: t('usage.chatOutput'),
name: t('usage.outputTokens'),
type: 'bar' as const,
stack: 'tokens',
data: models.map(m => m.output_tokens ?? 0),
@@ -495,56 +552,90 @@ const modelChartOption = computed(() =>
const dailyTokensOption = computed(() => {
const days = allDays.value
const chatMap = buildDayMap(usageData.value?.chat)
const hbMap = buildDayMap(usageData.value?.heartbeat)
const types = activeTypes.value
const maps = dayMaps.value
const legendItems: string[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const series: any[] = []
for (const type of types) {
const inputName = sessionTypeInputLabel(type)
const outputName = sessionTypeOutputLabel(type)
legendItems.push(inputName, outputName)
const map = maps[type]
series.push({
name: inputName,
type: 'bar' as const,
stack: type,
data: days.map(d => map.get(d)?.input_tokens ?? 0),
})
series.push({
name: outputName,
type: 'bar' as const,
stack: type,
data: days.map(d => map.get(d)?.output_tokens ?? 0),
})
}
const totalInputLabel = t('usage.totalInput')
const totalOutputLabel = t('usage.totalOutput')
legendItems.push(totalInputLabel, totalOutputLabel)
series.push({
name: totalInputLabel,
type: 'line' as const,
smooth: true,
symbol: 'circle',
symbolSize: 4,
data: days.map(d => {
let sum = 0
for (const tp of types) sum += maps[tp].get(d)?.input_tokens ?? 0
return sum
}),
})
series.push({
name: totalOutputLabel,
type: 'line' as const,
smooth: true,
symbol: 'circle',
symbolSize: 4,
data: days.map(d => {
let sum = 0
for (const tp of types) sum += maps[tp].get(d)?.output_tokens ?? 0
return sum
}),
})
return {
tooltip: { trigger: 'axis' as const },
legend: {
data: [t('usage.chatInput'), t('usage.chatOutput'), t('usage.heartbeatInput'), t('usage.heartbeatOutput')],
data: legendItems,
bottom: 0,
left: 'center',
itemGap: 16,
itemGap: 12,
},
grid: { left: 60, right: 20, bottom: 50, top: 20 },
grid: { left: 60, right: 20, bottom: 55, top: 20 },
xAxis: { type: 'category' as const, data: days },
yAxis: { type: 'value' as const },
series: [
{
name: t('usage.chatInput'),
type: 'line' as const,
stack: 'input',
areaStyle: {},
data: days.map(d => chatMap.get(d)?.input_tokens ?? 0),
},
{
name: t('usage.heartbeatInput'),
type: 'line' as const,
stack: 'input',
areaStyle: {},
data: days.map(d => hbMap.get(d)?.input_tokens ?? 0),
},
{
name: t('usage.chatOutput'),
type: 'line' as const,
stack: 'output',
areaStyle: {},
data: days.map(d => chatMap.get(d)?.output_tokens ?? 0),
},
{
name: t('usage.heartbeatOutput'),
type: 'line' as const,
stack: 'output',
areaStyle: {},
data: days.map(d => hbMap.get(d)?.output_tokens ?? 0),
},
],
series,
}
})
const cacheBreakdownOption = computed(() => {
const days = allDays.value
const chatMap = buildDayMap(usageData.value?.chat)
const hbMap = buildDayMap(usageData.value?.heartbeat)
const types = activeTypes.value
const maps = dayMaps.value
function sumField(day: string, field: 'cache_read_tokens' | 'cache_write_tokens' | 'input_tokens') {
let total = 0
for (const tp of types) {
total += (maps[tp].get(day)?.[field] ?? 0) as number
}
return total
}
return {
tooltip: { trigger: 'axis' as const },
legend: {
@@ -561,32 +652,22 @@ const cacheBreakdownOption = computed(() => {
name: t('usage.cacheRead'),
type: 'bar' as const,
stack: 'cache',
data: days.map(d => {
const c = chatMap.get(d)
const h = hbMap.get(d)
return (c?.cache_read_tokens ?? 0) + (h?.cache_read_tokens ?? 0)
}),
data: days.map(d => sumField(d, 'cache_read_tokens')),
},
{
name: t('usage.cacheWrite'),
type: 'bar' as const,
stack: 'cache',
data: days.map(d => {
const c = chatMap.get(d)
const h = hbMap.get(d)
return (c?.cache_write_tokens ?? 0) + (h?.cache_write_tokens ?? 0)
}),
data: days.map(d => sumField(d, 'cache_write_tokens')),
},
{
name: t('usage.noCache'),
type: 'bar' as const,
stack: 'cache',
data: days.map(d => {
const c = chatMap.get(d)
const h = hbMap.get(d)
const totalInput = (c?.input_tokens ?? 0) + (h?.input_tokens ?? 0)
const cacheRead = (c?.cache_read_tokens ?? 0) + (h?.cache_read_tokens ?? 0)
const cacheWrite = (c?.cache_write_tokens ?? 0) + (h?.cache_write_tokens ?? 0)
const totalInput = sumField(d, 'input_tokens')
const cacheRead = sumField(d, 'cache_read_tokens')
const cacheWrite = sumField(d, 'cache_write_tokens')
return Math.max(0, totalInput - cacheRead - cacheWrite)
}),
},
@@ -596,8 +677,17 @@ const cacheBreakdownOption = computed(() => {
const cacheHitRateOption = computed(() => {
const days = allDays.value
const chatMap = buildDayMap(usageData.value?.chat)
const hbMap = buildDayMap(usageData.value?.heartbeat)
const types = activeTypes.value
const maps = dayMaps.value
function sumField(day: string, field: 'cache_read_tokens' | 'input_tokens') {
let total = 0
for (const tp of types) {
total += (maps[tp].get(day)?.[field] ?? 0) as number
}
return total
}
return {
tooltip: {
trigger: 'axis' as const,
@@ -615,10 +705,8 @@ const cacheHitRateOption = computed(() => {
type: 'line' as const,
smooth: true,
data: days.map(d => {
const c = chatMap.get(d)
const h = hbMap.get(d)
const totalInput = (c?.input_tokens ?? 0) + (h?.input_tokens ?? 0)
const cacheRead = (c?.cache_read_tokens ?? 0) + (h?.cache_read_tokens ?? 0)
const totalInput = sumField(d, 'input_tokens')
const cacheRead = sumField(d, 'cache_read_tokens')
return totalInput > 0 ? parseFloat(((cacheRead / totalInput) * 100).toFixed(1)) : 0
}),
},
+6 -39
View File
@@ -1,5 +1,6 @@
-- name: GetMessageTokenUsageByDay :many
-- name: GetTokenUsageByDayAndType :many
SELECT
COALESCE(s.type, 'chat')::text AS session_type,
date_trunc('day', m.created_at)::date AS day,
COALESCE(SUM((m.usage->>'inputTokens')::bigint), 0)::bigint AS input_tokens,
COALESCE(SUM((m.usage->>'outputTokens')::bigint), 0)::bigint AS output_tokens,
@@ -7,32 +8,16 @@ SELECT
COALESCE(SUM((m.usage->'inputTokenDetails'->>'cacheWriteTokens')::bigint), 0)::bigint AS cache_write_tokens,
COALESCE(SUM((m.usage->'outputTokenDetails'->>'reasoningTokens')::bigint), 0)::bigint AS reasoning_tokens
FROM bot_history_messages m
LEFT JOIN bot_sessions s ON s.id = m.session_id
WHERE m.bot_id = sqlc.arg(bot_id)
AND m.usage IS NOT NULL
AND m.created_at >= sqlc.arg(from_time)
AND m.created_at < sqlc.arg(to_time)
AND (sqlc.narg(model_id)::uuid IS NULL OR m.model_id = sqlc.narg(model_id)::uuid)
GROUP BY day
ORDER BY day;
GROUP BY session_type, day
ORDER BY day, session_type;
-- name: GetHeartbeatTokenUsageByDay :many
SELECT
date_trunc('day', h.started_at)::date AS day,
COALESCE(SUM((h.usage->>'inputTokens')::bigint), 0)::bigint AS input_tokens,
COALESCE(SUM((h.usage->>'outputTokens')::bigint), 0)::bigint AS output_tokens,
COALESCE(SUM((h.usage->'inputTokenDetails'->>'cacheReadTokens')::bigint), 0)::bigint AS cache_read_tokens,
COALESCE(SUM((h.usage->'inputTokenDetails'->>'cacheWriteTokens')::bigint), 0)::bigint AS cache_write_tokens,
COALESCE(SUM((h.usage->'outputTokenDetails'->>'reasoningTokens')::bigint), 0)::bigint AS reasoning_tokens
FROM bot_heartbeat_logs h
WHERE h.bot_id = sqlc.arg(bot_id)
AND h.usage IS NOT NULL
AND h.started_at >= sqlc.arg(from_time)
AND h.started_at < sqlc.arg(to_time)
AND (sqlc.narg(model_id)::uuid IS NULL OR h.model_id = sqlc.narg(model_id)::uuid)
GROUP BY day
ORDER BY day;
-- name: GetMessageTokenUsageByModel :many
-- name: GetTokenUsageByModel :many
SELECT
m.model_id,
COALESCE(mo.model_id, 'unknown') AS model_slug,
@@ -49,21 +34,3 @@ WHERE m.bot_id = sqlc.arg(bot_id)
AND m.created_at < sqlc.arg(to_time)
GROUP BY m.model_id, mo.model_id, mo.name, lp.name
ORDER BY input_tokens DESC;
-- name: GetHeartbeatTokenUsageByModel :many
SELECT
h.model_id,
COALESCE(mo.model_id, 'unknown') AS model_slug,
COALESCE(mo.name, 'Unknown') AS model_name,
COALESCE(lp.name, 'Unknown') AS provider_name,
COALESCE(SUM((h.usage->>'inputTokens')::bigint), 0)::bigint AS input_tokens,
COALESCE(SUM((h.usage->>'outputTokens')::bigint), 0)::bigint AS output_tokens
FROM bot_heartbeat_logs h
LEFT JOIN models mo ON mo.id = h.model_id
LEFT JOIN llm_providers lp ON lp.id = mo.llm_provider_id
WHERE h.bot_id = sqlc.arg(bot_id)
AND h.usage IS NOT NULL
AND h.started_at >= sqlc.arg(from_time)
AND h.started_at < sqlc.arg(to_time)
GROUP BY h.model_id, mo.model_id, mo.name, lp.name
ORDER BY input_tokens DESC;
+33 -46
View File
@@ -28,46 +28,52 @@ func (h *Handler) buildUsageGroup() *CommandGroup {
toTS := pgtype.Timestamptz{Time: now, Valid: true}
nullModel := pgtype.UUID{Valid: false}
chatRows, err := h.queries.GetMessageTokenUsageByDay(cc.Ctx, dbsqlc.GetMessageTokenUsageByDayParams{
rows, err := h.queries.GetTokenUsageByDayAndType(cc.Ctx, dbsqlc.GetTokenUsageByDayAndTypeParams{
BotID: botUUID, FromTime: fromTS, ToTime: toTS, ModelID: nullModel,
})
if err != nil {
return "", err
}
hbRows, err := h.queries.GetHeartbeatTokenUsageByDay(cc.Ctx, dbsqlc.GetHeartbeatTokenUsageByDayParams{
BotID: botUUID, FromTime: fromTS, ToTime: toTS, ModelID: nullModel,
})
if err != nil {
return "", err
}
if len(chatRows) == 0 && len(hbRows) == 0 {
if len(rows) == 0 {
return "No token usage in the last 7 days.", nil
}
type bucket struct {
label string
rows []dbsqlc.GetTokenUsageByDayAndTypeRow
}
buckets := []bucket{
{label: "Chat"},
{label: "Heartbeat"},
{label: "Schedule"},
}
for _, r := range rows {
switch r.SessionType {
case "heartbeat":
buckets[1].rows = append(buckets[1].rows, r)
case "schedule":
buckets[2].rows = append(buckets[2].rows, r)
default:
buckets[0].rows = append(buckets[0].rows, r)
}
}
var b strings.Builder
b.WriteString("Token usage (last 7 days):\n\n")
if len(chatRows) > 0 {
b.WriteString("Chat:\n")
var totalIn, totalOut int64
for _, r := range chatRows {
day := r.Day.Time.Format("01-02")
fmt.Fprintf(&b, " %s: in=%d out=%d\n", day, r.InputTokens, r.OutputTokens)
totalIn += r.InputTokens
totalOut += r.OutputTokens
first := true
for _, bk := range buckets {
if len(bk.rows) == 0 {
continue
}
fmt.Fprintf(&b, " Total: in=%d out=%d\n", totalIn, totalOut)
}
if len(hbRows) > 0 {
if len(chatRows) > 0 {
if !first {
b.WriteByte('\n')
}
b.WriteString("Heartbeat:\n")
first = false
b.WriteString(bk.label + ":\n")
var totalIn, totalOut int64
for _, r := range hbRows {
for _, r := range bk.rows {
day := r.Day.Time.Format("01-02")
fmt.Fprintf(&b, " %s: in=%d out=%d\n", day, r.InputTokens, r.OutputTokens)
totalIn += r.InputTokens
@@ -92,42 +98,23 @@ func (h *Handler) buildUsageGroup() *CommandGroup {
fromTS := pgtype.Timestamptz{Time: from, Valid: true}
toTS := pgtype.Timestamptz{Time: now, Valid: true}
chatRows, err := h.queries.GetMessageTokenUsageByModel(cc.Ctx, dbsqlc.GetMessageTokenUsageByModelParams{
BotID: botUUID, FromTime: fromTS, ToTime: toTS,
})
if err != nil {
return "", err
}
hbRows, err := h.queries.GetHeartbeatTokenUsageByModel(cc.Ctx, dbsqlc.GetHeartbeatTokenUsageByModelParams{
rows, err := h.queries.GetTokenUsageByModel(cc.Ctx, dbsqlc.GetTokenUsageByModelParams{
BotID: botUUID, FromTime: fromTS, ToTime: toTS,
})
if err != nil {
return "", err
}
if len(chatRows) == 0 && len(hbRows) == 0 {
if len(rows) == 0 {
return "No token usage in the last 7 days.", nil
}
var b strings.Builder
b.WriteString("Token usage by model (last 7 days):\n\n")
if len(chatRows) > 0 {
b.WriteString("Chat:\n")
for _, r := range chatRows {
for _, r := range rows {
fmt.Fprintf(&b, " %s (%s): in=%d out=%d\n", r.ModelName, r.ProviderName, r.InputTokens, r.OutputTokens)
}
}
if len(hbRows) > 0 {
if len(chatRows) > 0 {
b.WriteByte('\n')
}
b.WriteString("Heartbeat:\n")
for _, r := range hbRows {
fmt.Fprintf(&b, " %s (%s): in=%d out=%d\n", r.ModelName, r.ProviderName, r.InputTokens, r.OutputTokens)
}
}
return strings.TrimRight(b.String(), "\n"), nil
},
+20 -143
View File
@@ -11,135 +11,9 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const getHeartbeatTokenUsageByDay = `-- name: GetHeartbeatTokenUsageByDay :many
SELECT
date_trunc('day', h.started_at)::date AS day,
COALESCE(SUM((h.usage->>'inputTokens')::bigint), 0)::bigint AS input_tokens,
COALESCE(SUM((h.usage->>'outputTokens')::bigint), 0)::bigint AS output_tokens,
COALESCE(SUM((h.usage->'inputTokenDetails'->>'cacheReadTokens')::bigint), 0)::bigint AS cache_read_tokens,
COALESCE(SUM((h.usage->'inputTokenDetails'->>'cacheWriteTokens')::bigint), 0)::bigint AS cache_write_tokens,
COALESCE(SUM((h.usage->'outputTokenDetails'->>'reasoningTokens')::bigint), 0)::bigint AS reasoning_tokens
FROM bot_heartbeat_logs h
WHERE h.bot_id = $1
AND h.usage IS NOT NULL
AND h.started_at >= $2
AND h.started_at < $3
AND ($4::uuid IS NULL OR h.model_id = $4::uuid)
GROUP BY day
ORDER BY day
`
type GetHeartbeatTokenUsageByDayParams struct {
BotID pgtype.UUID `json:"bot_id"`
FromTime pgtype.Timestamptz `json:"from_time"`
ToTime pgtype.Timestamptz `json:"to_time"`
ModelID pgtype.UUID `json:"model_id"`
}
type GetHeartbeatTokenUsageByDayRow struct {
Day pgtype.Date `json:"day"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
}
func (q *Queries) GetHeartbeatTokenUsageByDay(ctx context.Context, arg GetHeartbeatTokenUsageByDayParams) ([]GetHeartbeatTokenUsageByDayRow, error) {
rows, err := q.db.Query(ctx, getHeartbeatTokenUsageByDay,
arg.BotID,
arg.FromTime,
arg.ToTime,
arg.ModelID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetHeartbeatTokenUsageByDayRow
for rows.Next() {
var i GetHeartbeatTokenUsageByDayRow
if err := rows.Scan(
&i.Day,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadTokens,
&i.CacheWriteTokens,
&i.ReasoningTokens,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getHeartbeatTokenUsageByModel = `-- name: GetHeartbeatTokenUsageByModel :many
SELECT
h.model_id,
COALESCE(mo.model_id, 'unknown') AS model_slug,
COALESCE(mo.name, 'Unknown') AS model_name,
COALESCE(lp.name, 'Unknown') AS provider_name,
COALESCE(SUM((h.usage->>'inputTokens')::bigint), 0)::bigint AS input_tokens,
COALESCE(SUM((h.usage->>'outputTokens')::bigint), 0)::bigint AS output_tokens
FROM bot_heartbeat_logs h
LEFT JOIN models mo ON mo.id = h.model_id
LEFT JOIN llm_providers lp ON lp.id = mo.llm_provider_id
WHERE h.bot_id = $1
AND h.usage IS NOT NULL
AND h.started_at >= $2
AND h.started_at < $3
GROUP BY h.model_id, mo.model_id, mo.name, lp.name
ORDER BY input_tokens DESC
`
type GetHeartbeatTokenUsageByModelParams struct {
BotID pgtype.UUID `json:"bot_id"`
FromTime pgtype.Timestamptz `json:"from_time"`
ToTime pgtype.Timestamptz `json:"to_time"`
}
type GetHeartbeatTokenUsageByModelRow struct {
ModelID pgtype.UUID `json:"model_id"`
ModelSlug string `json:"model_slug"`
ModelName string `json:"model_name"`
ProviderName string `json:"provider_name"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
func (q *Queries) GetHeartbeatTokenUsageByModel(ctx context.Context, arg GetHeartbeatTokenUsageByModelParams) ([]GetHeartbeatTokenUsageByModelRow, error) {
rows, err := q.db.Query(ctx, getHeartbeatTokenUsageByModel, arg.BotID, arg.FromTime, arg.ToTime)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetHeartbeatTokenUsageByModelRow
for rows.Next() {
var i GetHeartbeatTokenUsageByModelRow
if err := rows.Scan(
&i.ModelID,
&i.ModelSlug,
&i.ModelName,
&i.ProviderName,
&i.InputTokens,
&i.OutputTokens,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getMessageTokenUsageByDay = `-- name: GetMessageTokenUsageByDay :many
const getTokenUsageByDayAndType = `-- name: GetTokenUsageByDayAndType :many
SELECT
COALESCE(s.type, 'chat')::text AS session_type,
date_trunc('day', m.created_at)::date AS day,
COALESCE(SUM((m.usage->>'inputTokens')::bigint), 0)::bigint AS input_tokens,
COALESCE(SUM((m.usage->>'outputTokens')::bigint), 0)::bigint AS output_tokens,
@@ -147,23 +21,25 @@ SELECT
COALESCE(SUM((m.usage->'inputTokenDetails'->>'cacheWriteTokens')::bigint), 0)::bigint AS cache_write_tokens,
COALESCE(SUM((m.usage->'outputTokenDetails'->>'reasoningTokens')::bigint), 0)::bigint AS reasoning_tokens
FROM bot_history_messages m
LEFT JOIN bot_sessions s ON s.id = m.session_id
WHERE m.bot_id = $1
AND m.usage IS NOT NULL
AND m.created_at >= $2
AND m.created_at < $3
AND ($4::uuid IS NULL OR m.model_id = $4::uuid)
GROUP BY day
ORDER BY day
GROUP BY session_type, day
ORDER BY day, session_type
`
type GetMessageTokenUsageByDayParams struct {
type GetTokenUsageByDayAndTypeParams struct {
BotID pgtype.UUID `json:"bot_id"`
FromTime pgtype.Timestamptz `json:"from_time"`
ToTime pgtype.Timestamptz `json:"to_time"`
ModelID pgtype.UUID `json:"model_id"`
}
type GetMessageTokenUsageByDayRow struct {
type GetTokenUsageByDayAndTypeRow struct {
SessionType string `json:"session_type"`
Day pgtype.Date `json:"day"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
@@ -172,8 +48,8 @@ type GetMessageTokenUsageByDayRow struct {
ReasoningTokens int64 `json:"reasoning_tokens"`
}
func (q *Queries) GetMessageTokenUsageByDay(ctx context.Context, arg GetMessageTokenUsageByDayParams) ([]GetMessageTokenUsageByDayRow, error) {
rows, err := q.db.Query(ctx, getMessageTokenUsageByDay,
func (q *Queries) GetTokenUsageByDayAndType(ctx context.Context, arg GetTokenUsageByDayAndTypeParams) ([]GetTokenUsageByDayAndTypeRow, error) {
rows, err := q.db.Query(ctx, getTokenUsageByDayAndType,
arg.BotID,
arg.FromTime,
arg.ToTime,
@@ -183,10 +59,11 @@ func (q *Queries) GetMessageTokenUsageByDay(ctx context.Context, arg GetMessageT
return nil, err
}
defer rows.Close()
var items []GetMessageTokenUsageByDayRow
var items []GetTokenUsageByDayAndTypeRow
for rows.Next() {
var i GetMessageTokenUsageByDayRow
var i GetTokenUsageByDayAndTypeRow
if err := rows.Scan(
&i.SessionType,
&i.Day,
&i.InputTokens,
&i.OutputTokens,
@@ -204,7 +81,7 @@ func (q *Queries) GetMessageTokenUsageByDay(ctx context.Context, arg GetMessageT
return items, nil
}
const getMessageTokenUsageByModel = `-- name: GetMessageTokenUsageByModel :many
const getTokenUsageByModel = `-- name: GetTokenUsageByModel :many
SELECT
m.model_id,
COALESCE(mo.model_id, 'unknown') AS model_slug,
@@ -223,13 +100,13 @@ GROUP BY m.model_id, mo.model_id, mo.name, lp.name
ORDER BY input_tokens DESC
`
type GetMessageTokenUsageByModelParams struct {
type GetTokenUsageByModelParams struct {
BotID pgtype.UUID `json:"bot_id"`
FromTime pgtype.Timestamptz `json:"from_time"`
ToTime pgtype.Timestamptz `json:"to_time"`
}
type GetMessageTokenUsageByModelRow struct {
type GetTokenUsageByModelRow struct {
ModelID pgtype.UUID `json:"model_id"`
ModelSlug string `json:"model_slug"`
ModelName string `json:"model_name"`
@@ -238,15 +115,15 @@ type GetMessageTokenUsageByModelRow struct {
OutputTokens int64 `json:"output_tokens"`
}
func (q *Queries) GetMessageTokenUsageByModel(ctx context.Context, arg GetMessageTokenUsageByModelParams) ([]GetMessageTokenUsageByModelRow, error) {
rows, err := q.db.Query(ctx, getMessageTokenUsageByModel, arg.BotID, arg.FromTime, arg.ToTime)
func (q *Queries) GetTokenUsageByModel(ctx context.Context, arg GetTokenUsageByModelParams) ([]GetTokenUsageByModelRow, error) {
rows, err := q.db.Query(ctx, getTokenUsageByModel, arg.BotID, arg.FromTime, arg.ToTime)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetMessageTokenUsageByModelRow
var items []GetTokenUsageByModelRow
for rows.Next() {
var i GetMessageTokenUsageByModelRow
var i GetTokenUsageByModelRow
if err := rows.Scan(
&i.ModelID,
&i.ModelSlug,
+33 -87
View File
@@ -60,12 +60,13 @@ type ModelTokenUsage struct {
type TokenUsageResponse struct {
Chat []DailyTokenUsage `json:"chat"`
Heartbeat []DailyTokenUsage `json:"heartbeat"`
Schedule []DailyTokenUsage `json:"schedule"`
ByModel []ModelTokenUsage `json:"by_model"`
}
// GetTokenUsage godoc
// @Summary Get token usage statistics
// @Description Get daily aggregated token usage for a bot, split by chat and heartbeat, with optional model filter and per-model breakdown
// @Description Get daily aggregated token usage for a bot, split by chat, heartbeat, and schedule session types, with optional model filter and per-model breakdown
// @Tags token-usage
// @Param bot_id path string true "Bot ID"
// @Param from query string true "Start date (YYYY-MM-DD)"
@@ -124,7 +125,7 @@ func (h *TokenUsageHandler) GetTokenUsage(c echo.Context) error {
ctx := c.Request().Context()
chatRows, hbRows, err := h.fetchUsage(ctx, pgBotID, fromTS, toTS, pgModelID)
chat, heartbeat, schedule, err := h.fetchUsageByDay(ctx, pgBotID, fromTS, toTS, pgModelID)
if err != nil {
h.logger.Error("fetch token usage failed", slog.Any("error", err))
return echo.NewHTTPError(http.StatusInternalServerError, "failed to fetch token usage")
@@ -137,39 +138,48 @@ func (h *TokenUsageHandler) GetTokenUsage(c echo.Context) error {
}
resp := TokenUsageResponse{
Chat: convertMessageRows(chatRows),
Heartbeat: convertHeartbeatRows(hbRows),
Chat: chat,
Heartbeat: heartbeat,
Schedule: schedule,
ByModel: byModel,
}
return c.JSON(http.StatusOK, resp)
}
func (h *TokenUsageHandler) fetchUsage(ctx context.Context, botID pgtype.UUID, from, to pgtype.Timestamptz, modelID pgtype.UUID) ([]sqlc.GetMessageTokenUsageByDayRow, []sqlc.GetHeartbeatTokenUsageByDayRow, error) {
chatRows, err := h.queries.GetMessageTokenUsageByDay(ctx, sqlc.GetMessageTokenUsageByDayParams{
func (h *TokenUsageHandler) fetchUsageByDay(ctx context.Context, botID pgtype.UUID, from, to pgtype.Timestamptz, modelID pgtype.UUID) (chat, heartbeat, schedule []DailyTokenUsage, err error) {
rows, err := h.queries.GetTokenUsageByDayAndType(ctx, sqlc.GetTokenUsageByDayAndTypeParams{
BotID: botID,
FromTime: from,
ToTime: to,
ModelID: modelID,
})
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
hbRows, err := h.queries.GetHeartbeatTokenUsageByDay(ctx, sqlc.GetHeartbeatTokenUsageByDayParams{
BotID: botID,
FromTime: from,
ToTime: to,
ModelID: modelID,
})
if err != nil {
return nil, nil, err
for _, r := range rows {
d := DailyTokenUsage{
Day: formatPgDate(r.Day),
InputTokens: r.InputTokens,
OutputTokens: r.OutputTokens,
CacheReadTokens: r.CacheReadTokens,
CacheWriteTokens: r.CacheWriteTokens,
ReasoningTokens: r.ReasoningTokens,
}
return chatRows, hbRows, nil
switch r.SessionType {
case "heartbeat":
heartbeat = append(heartbeat, d)
case "schedule":
schedule = append(schedule, d)
default:
chat = append(chat, d)
}
}
return chat, heartbeat, schedule, nil
}
func (h *TokenUsageHandler) fetchUsageByModel(ctx context.Context, botID pgtype.UUID, from, to pgtype.Timestamptz) ([]ModelTokenUsage, error) {
merged := map[string]*ModelTokenUsage{}
chatRows, err := h.queries.GetMessageTokenUsageByModel(ctx, sqlc.GetMessageTokenUsageByModelParams{
rows, err := h.queries.GetTokenUsageByModel(ctx, sqlc.GetTokenUsageByModelParams{
BotID: botID,
FromTime: from,
ToTime: to,
@@ -177,85 +187,21 @@ func (h *TokenUsageHandler) fetchUsageByModel(ctx context.Context, botID pgtype.
if err != nil {
return nil, err
}
for _, r := range chatRows {
key := r.ModelID.String()
if m, ok := merged[key]; ok {
m.InputTokens += r.InputTokens
m.OutputTokens += r.OutputTokens
} else {
merged[key] = &ModelTokenUsage{
result := make([]ModelTokenUsage, 0, len(rows))
for _, r := range rows {
result = append(result, ModelTokenUsage{
ModelID: formatOptionalUUID(r.ModelID),
ModelSlug: r.ModelSlug,
ModelName: r.ModelName,
ProviderName: r.ProviderName,
InputTokens: r.InputTokens,
OutputTokens: r.OutputTokens,
}
}
}
hbRows, err := h.queries.GetHeartbeatTokenUsageByModel(ctx, sqlc.GetHeartbeatTokenUsageByModelParams{
BotID: botID,
FromTime: from,
ToTime: to,
})
if err != nil {
return nil, err
}
for _, r := range hbRows {
key := r.ModelID.String()
if m, ok := merged[key]; ok {
m.InputTokens += r.InputTokens
m.OutputTokens += r.OutputTokens
} else {
merged[key] = &ModelTokenUsage{
ModelID: formatOptionalUUID(r.ModelID),
ModelSlug: r.ModelSlug,
ModelName: r.ModelName,
ProviderName: r.ProviderName,
InputTokens: r.InputTokens,
OutputTokens: r.OutputTokens,
}
}
}
result := make([]ModelTokenUsage, 0, len(merged))
for _, m := range merged {
result = append(result, *m)
}
return result, nil
}
func convertMessageRows(rows []sqlc.GetMessageTokenUsageByDayRow) []DailyTokenUsage {
out := make([]DailyTokenUsage, 0, len(rows))
for _, r := range rows {
out = append(out, DailyTokenUsage{
Day: formatPgDate(r.Day),
InputTokens: r.InputTokens,
OutputTokens: r.OutputTokens,
CacheReadTokens: r.CacheReadTokens,
CacheWriteTokens: r.CacheWriteTokens,
ReasoningTokens: r.ReasoningTokens,
})
}
return out
}
func convertHeartbeatRows(rows []sqlc.GetHeartbeatTokenUsageByDayRow) []DailyTokenUsage {
out := make([]DailyTokenUsage, 0, len(rows))
for _, r := range rows {
out = append(out, DailyTokenUsage{
Day: formatPgDate(r.Day),
InputTokens: r.InputTokens,
OutputTokens: r.OutputTokens,
CacheReadTokens: r.CacheReadTokens,
CacheWriteTokens: r.CacheWriteTokens,
ReasoningTokens: r.ReasoningTokens,
})
}
return out
}
func formatPgDate(d pgtype.Date) string {
if !d.Valid {
return ""
+1 -1
View File
@@ -1698,7 +1698,7 @@ export const getBotsByBotIdTokenUsageQueryKey = (options: Options<GetBotsByBotId
/**
* Get token usage statistics
*
* Get daily aggregated token usage for a bot, split by chat and heartbeat, with optional model filter and per-model breakdown
* Get daily aggregated token usage for a bot, split by chat, heartbeat, and schedule session types, with optional model filter and per-model breakdown
*/
export const getBotsByBotIdTokenUsageQuery = defineQueryOptions((options: Options<GetBotsByBotIdTokenUsageData>) => ({
key: getBotsByBotIdTokenUsageQueryKey(options),
+1 -1
View File
@@ -963,7 +963,7 @@ export const putBotsByBotIdSubagentsByIdSkills = <ThrowOnError extends boolean =
/**
* Get token usage statistics
*
* Get daily aggregated token usage for a bot, split by chat and heartbeat, with optional model filter and per-model breakdown
* Get daily aggregated token usage for a bot, split by chat, heartbeat, and schedule session types, with optional model filter and per-model breakdown
*/
export const getBotsByBotIdTokenUsage = <ThrowOnError extends boolean = false>(options: Options<GetBotsByBotIdTokenUsageData, ThrowOnError>) => (options.client ?? client).get<GetBotsByBotIdTokenUsageResponses, GetBotsByBotIdTokenUsageErrors, ThrowOnError>({ url: '/bots/{bot_id}/token-usage', ...options });
+2 -1
View File
@@ -903,6 +903,7 @@ export type HandlersTokenUsageResponse = {
by_model?: Array<HandlersModelTokenUsage>;
chat?: Array<HandlersDailyTokenUsage>;
heartbeat?: Array<HandlersDailyTokenUsage>;
schedule?: Array<HandlersDailyTokenUsage>;
};
export type HandlersCreateSessionRequest = {
@@ -1194,7 +1195,7 @@ export type ModelsTestResponse = {
status?: ModelsTestStatus;
};
export type ModelsTestStatus = 'ok' | 'auth_error' | 'error';
export type ModelsTestStatus = 'ok' | 'auth_error' | 'model_not_supported' | 'error';
export type ModelsUpdateRequest = {
client_type?: ModelsClientType;
+9 -1
View File
@@ -4685,7 +4685,7 @@ const docTemplate = `{
},
"/bots/{bot_id}/token-usage": {
"get": {
"description": "Get daily aggregated token usage for a bot, split by chat and heartbeat, with optional model filter and per-model breakdown",
"description": "Get daily aggregated token usage for a bot, split by chat, heartbeat, and schedule session types, with optional model filter and per-model breakdown",
"tags": [
"token-usage"
],
@@ -11192,6 +11192,12 @@ const docTemplate = `{
"items": {
"$ref": "#/definitions/handlers.DailyTokenUsage"
}
},
"schedule": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.DailyTokenUsage"
}
}
}
},
@@ -11918,11 +11924,13 @@ const docTemplate = `{
"enum": [
"ok",
"auth_error",
"model_not_supported",
"error"
],
"x-enum-varnames": [
"TestStatusOK",
"TestStatusAuthError",
"TestStatusModelNotSupported",
"TestStatusError"
]
},
+9 -1
View File
@@ -4676,7 +4676,7 @@
},
"/bots/{bot_id}/token-usage": {
"get": {
"description": "Get daily aggregated token usage for a bot, split by chat and heartbeat, with optional model filter and per-model breakdown",
"description": "Get daily aggregated token usage for a bot, split by chat, heartbeat, and schedule session types, with optional model filter and per-model breakdown",
"tags": [
"token-usage"
],
@@ -11183,6 +11183,12 @@
"items": {
"$ref": "#/definitions/handlers.DailyTokenUsage"
}
},
"schedule": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.DailyTokenUsage"
}
}
}
},
@@ -11909,11 +11915,13 @@
"enum": [
"ok",
"auth_error",
"model_not_supported",
"error"
],
"x-enum-varnames": [
"TestStatusOK",
"TestStatusAuthError",
"TestStatusModelNotSupported",
"TestStatusError"
]
},
+8 -2
View File
@@ -1488,6 +1488,10 @@ definitions:
items:
$ref: '#/definitions/handlers.DailyTokenUsage'
type: array
schedule:
items:
$ref: '#/definitions/handlers.DailyTokenUsage'
type: array
type: object
handlers.createSessionRequest:
properties:
@@ -1966,11 +1970,13 @@ definitions:
enum:
- ok
- auth_error
- model_not_supported
- error
type: string
x-enum-varnames:
- TestStatusOK
- TestStatusAuthError
- TestStatusModelNotSupported
- TestStatusError
models.UpdateRequest:
properties:
@@ -5717,8 +5723,8 @@ paths:
- subagent
/bots/{bot_id}/token-usage:
get:
description: Get daily aggregated token usage for a bot, split by chat and heartbeat,
with optional model filter and per-model breakdown
description: Get daily aggregated token usage for a bot, split by chat, heartbeat,
and schedule session types, with optional model filter and per-model breakdown
parameters:
- description: Bot ID
in: path