feat(usage): add per-call token usage records table

Expose a paginated endpoint and UI table that lists individual LLM call
records (assistant messages with usage) per bot, showing time, session
type, model, provider, and token counts. Respects existing date / model
/ session-type filters and adds full-height loaders plus a max-width
layout to keep the usage page consistent with other top-level pages.
This commit is contained in:
Acbox
2026-04-24 15:05:53 +08:00
parent 419867655e
commit 8136ef6ed6
13 changed files with 1138 additions and 15 deletions
+10 -1
View File
@@ -1388,7 +1388,16 @@
"dateFrom": "From",
"dateTo": "To",
"chartPie": "Pie",
"chartBar": "Bar"
"chartBar": "Bar",
"records": "Records",
"noRecords": "No records",
"colTime": "Time",
"colBot": "Bot",
"colSessionType": "Type",
"colModel": "Model",
"colProvider": "Provider",
"colInputTokens": "Input",
"colOutputTokens": "Output"
},
"supermarket": {
"title": "Supermarket",
+10 -1
View File
@@ -1384,7 +1384,16 @@
"dateFrom": "开始日期",
"dateTo": "结束日期",
"chartPie": "饼图",
"chartBar": "柱状图"
"chartBar": "柱状图",
"records": "调用记录",
"noRecords": "无调用记录",
"colTime": "时间",
"colBot": "Bot",
"colSessionType": "类型",
"colModel": "模型",
"colProvider": "Provider",
"colInputTokens": "输入",
"colOutputTokens": "输出"
},
"supermarket": {
"title": "市场",
+237 -8
View File
@@ -1,5 +1,5 @@
<template>
<div class="p-6 space-y-6 mx-auto">
<div class="p-6 max-w-7xl mx-auto space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">
{{ $t('usage.title') }}
@@ -115,13 +115,13 @@
</div>
<template v-if="!selectedBotId">
<div class="text-muted-foreground text-center py-20">
<div class="text-muted-foreground flex items-center justify-center min-h-[60vh]">
{{ $t('usage.selectBotPlaceholder') }}
</div>
</template>
<template v-else-if="isLoading">
<div class="flex justify-center py-20">
<div class="flex items-center justify-center min-h-[60vh]">
<Spinner class="size-8" />
</div>
</template>
@@ -263,6 +263,119 @@
>
{{ $t('usage.noData') }}
</div>
<!-- Call records -->
<Card>
<CardHeader class="pb-2 flex flex-row items-center justify-between">
<CardTitle class="text-sm">
{{ $t('usage.records') }}
</CardTitle>
<span
v-if="recordsPaginationSummary"
class="text-xs text-muted-foreground tabular-nums"
>
{{ recordsPaginationSummary }}
</span>
</CardHeader>
<CardContent class="space-y-3">
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('usage.colTime') }}</TableHead>
<TableHead>{{ $t('usage.colBot') }}</TableHead>
<TableHead>{{ $t('usage.colSessionType') }}</TableHead>
<TableHead>{{ $t('usage.colModel') }}</TableHead>
<TableHead>{{ $t('usage.colProvider') }}</TableHead>
<TableHead class="text-right">
{{ $t('usage.colInputTokens') }}
</TableHead>
<TableHead class="text-right">
{{ $t('usage.colOutputTokens') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="isRecordsInitialLoading">
<TableCell
:colspan="7"
class="p-0"
>
<div class="flex items-center justify-center h-[480px]">
<Spinner class="size-6" />
</div>
</TableCell>
</TableRow>
<TableRow v-else-if="recordsList.length === 0">
<TableCell
:colspan="7"
class="p-0"
>
<div class="flex items-center justify-center h-[480px] text-muted-foreground">
{{ $t('usage.noRecords') }}
</div>
</TableCell>
</TableRow>
<template v-else>
<TableRow
v-for="r in recordsList"
:key="r.id"
:class="isRecordsFetching ? 'opacity-60 transition-opacity' : 'transition-opacity'"
>
<TableCell class="text-muted-foreground tabular-nums">
{{ formatDateTimeSeconds(r.created_at) }}
</TableCell>
<TableCell>{{ selectedBotName }}</TableCell>
<TableCell>{{ sessionTypeLabel(r.session_type) }}</TableCell>
<TableCell>{{ recordModelLabel(r) }}</TableCell>
<TableCell class="text-muted-foreground">
{{ r.provider_name || '-' }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ formatNumber(r.input_tokens ?? 0) }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ formatNumber(r.output_tokens ?? 0) }}
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<div
v-if="recordsTotalPages > 1"
class="flex justify-end"
>
<Pagination
:total="recordsTotal"
:items-per-page="RECORDS_PAGE_SIZE"
:sibling-count="1"
:page="recordsPageNumber"
show-edges
@update:page="setRecordsPage"
>
<PaginationContent v-slot="{ items }">
<PaginationFirst />
<PaginationPrevious />
<template
v-for="(item, index) in items"
:key="index"
>
<PaginationEllipsis
v-if="item.type === 'ellipsis'"
:index="index"
/>
<PaginationItem
v-else
:value="item.value"
/>
</template>
<PaginationNext />
<PaginationLast />
</PaginationContent>
</Pagination>
</div>
</CardContent>
</Card>
</template>
</div>
</template>
@@ -289,18 +402,33 @@ import {
CardTitle,
Input,
Label,
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationNext,
PaginationPrevious,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Spinner,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@memohai/ui'
import { getBotsQuery } from '@memohai/sdk/colada'
import { getBotsByBotIdTokenUsage } from '@memohai/sdk'
import { getBotsByBotIdTokenUsage, getBotsByBotIdTokenUsageRecords } from '@memohai/sdk'
import BotSelect from '@/components/bot-select/index.vue'
import type { HandlersDailyTokenUsage, HandlersModelTokenUsage } from '@memohai/sdk'
import type { HandlersDailyTokenUsage, HandlersModelTokenUsage, HandlersTokenUsageRecord } from '@memohai/sdk'
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
import { formatDateTimeSeconds } from '@/utils/date-time'
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent])
@@ -310,8 +438,11 @@ const selectedBotId = useSyncedQueryParam('bot', '')
const timeRange = useSyncedQueryParam('range', '7')
const selectedModelId = useSyncedQueryParam('model', 'all')
const selectedSessionType = useSyncedQueryParam('type', 'all')
const recordsPage = useSyncedQueryParam('rpage', '1')
const modelChartType = ref('pie')
const RECORDS_PAGE_SIZE = 20
function daysAgo(days: number): string {
const d = new Date()
d.setDate(d.getDate() - days + 1)
@@ -349,7 +480,7 @@ const modelIdFilter = computed(() =>
selectedModelId.value === 'all' ? undefined : selectedModelId.value,
)
const { data: usageData, status, refetch } = useQuery({
const { data: usageData, asyncStatus, refetch } = useQuery({
key: () => ['token-usage', selectedBotId.value, dateFrom.value, dateTo.value, modelIdFilter.value ?? ''],
query: async () => {
const { data } = await getBotsByBotIdTokenUsage({
@@ -366,10 +497,13 @@ const { data: usageData, status, refetch } = useQuery({
enabled: () => !!selectedBotId.value,
})
const isLoading = computed(() => status.value === 'loading')
const isFetching = computed(() => asyncStatus.value === 'loading')
const isLoading = computed(() => isFetching.value && !usageData.value)
onMounted(() => {
if (selectedBotId.value) refetch()
if (selectedBotId.value) {
refetch()
}
})
const byModelData = computed<HandlersModelTokenUsage[]>(() => usageData.value?.by_model ?? [])
@@ -384,6 +518,101 @@ const sessionTypeFilter = computed(() =>
selectedSessionType.value === 'all' ? null : selectedSessionType.value as SessionType,
)
const recordsPageNumber = computed(() => {
const parsed = parseInt(recordsPage.value, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1
})
const { data: recordsData, asyncStatus: recordsAsyncStatus, refetch: refetchRecords } = useQuery({
key: () => [
'token-usage-records',
selectedBotId.value,
dateFrom.value,
dateTo.value,
modelIdFilter.value ?? '',
sessionTypeFilter.value ?? '',
recordsPageNumber.value,
],
query: async () => {
const { data } = await getBotsByBotIdTokenUsageRecords({
path: { bot_id: selectedBotId.value },
query: {
from: dateFrom.value,
to: dateTo.value,
model_id: modelIdFilter.value,
session_type: sessionTypeFilter.value ?? undefined,
limit: RECORDS_PAGE_SIZE,
offset: (recordsPageNumber.value - 1) * RECORDS_PAGE_SIZE,
},
throwOnError: true,
})
return data
},
enabled: () => !!selectedBotId.value,
})
const recordsList = computed<HandlersTokenUsageRecord[]>(() => recordsData.value?.items ?? [])
const isRecordsFetching = computed(() => recordsAsyncStatus.value === 'loading')
const isRecordsInitialLoading = computed(() => isRecordsFetching.value && !recordsData.value)
const recordsTotal = computed(() => recordsData.value?.total ?? 0)
const recordsTotalPages = computed(() =>
Math.max(1, Math.ceil(recordsTotal.value / RECORDS_PAGE_SIZE)),
)
const recordsPaginationSummary = computed(() => {
const total = recordsTotal.value
if (total === 0) return ''
const start = (recordsPageNumber.value - 1) * RECORDS_PAGE_SIZE + 1
const end = Math.min(recordsPageNumber.value * RECORDS_PAGE_SIZE, total)
return `${start}-${end} / ${total}`
})
const selectedBotName = computed(() => {
const bot = botList.value.find(b => b.id === selectedBotId.value)
return bot?.display_name || bot?.id || ''
})
function resetRecordsPage() {
if (recordsPage.value !== '1') {
recordsPage.value = '1'
}
}
watch(
() => [
selectedBotId.value,
dateFrom.value,
dateTo.value,
modelIdFilter.value,
sessionTypeFilter.value,
],
resetRecordsPage,
)
function setRecordsPage(page: number) {
const clamped = Math.max(1, Math.min(page, recordsTotalPages.value))
recordsPage.value = String(clamped)
}
function sessionTypeLabel(type: string | undefined): string {
switch (type) {
case 'chat': return t('usage.chat')
case 'heartbeat': return t('usage.heartbeat')
case 'schedule': return t('usage.schedule')
default: return type || '-'
}
}
function recordModelLabel(r: HandlersTokenUsageRecord): string {
return r.model_name || r.model_slug || '-'
}
onMounted(() => {
if (selectedBotId.value) {
refetchRecords()
}
})
interface TypedDayMaps {
chat: Map<string, HandlersDailyTokenUsage>
heartbeat: Map<string, HandlersDailyTokenUsage>
+57
View File
@@ -38,3 +38,60 @@ 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: ListTokenUsageRecords :many
SELECT
m.id,
m.created_at,
m.session_id,
COALESCE(
CASE WHEN s.type = 'subagent' THEN COALESCE(ps.type, 'chat') ELSE s.type END,
'chat'
)::text AS session_type,
m.model_id,
COALESCE(mo.model_id, 'unknown')::text AS model_slug,
COALESCE(mo.name, 'Unknown')::text AS model_name,
COALESCE(lp.name, 'Unknown')::text AS provider_name,
COALESCE((m.usage->>'inputTokens')::bigint, 0)::bigint AS input_tokens,
COALESCE((m.usage->>'outputTokens')::bigint, 0)::bigint AS output_tokens,
COALESCE((m.usage->'inputTokenDetails'->>'cacheReadTokens')::bigint, 0)::bigint AS cache_read_tokens,
COALESCE((m.usage->'inputTokenDetails'->>'cacheWriteTokens')::bigint, 0)::bigint AS cache_write_tokens,
COALESCE((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
LEFT JOIN bot_sessions ps ON ps.id = s.parent_session_id
LEFT JOIN models mo ON mo.id = m.model_id
LEFT JOIN providers lp ON lp.id = mo.provider_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)
AND (
sqlc.narg(session_type)::text IS NULL
OR COALESCE(
CASE WHEN s.type = 'subagent' THEN COALESCE(ps.type, 'chat') ELSE s.type END,
'chat'
) = sqlc.narg(session_type)::text
)
ORDER BY m.created_at DESC, m.id DESC
LIMIT sqlc.arg(page_limit)
OFFSET sqlc.arg(page_offset);
-- name: CountTokenUsageRecords :one
SELECT COUNT(*)::bigint AS total
FROM bot_history_messages m
LEFT JOIN bot_sessions s ON s.id = m.session_id
LEFT JOIN bot_sessions ps ON ps.id = s.parent_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)
AND (
sqlc.narg(session_type)::text IS NULL
OR COALESCE(
CASE WHEN s.type = 'subagent' THEN COALESCE(ps.type, 'chat') ELSE s.type END,
'chat'
) = sqlc.narg(session_type)::text
);
+148
View File
@@ -11,6 +11,46 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countTokenUsageRecords = `-- name: CountTokenUsageRecords :one
SELECT COUNT(*)::bigint AS total
FROM bot_history_messages m
LEFT JOIN bot_sessions s ON s.id = m.session_id
LEFT JOIN bot_sessions ps ON ps.id = s.parent_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)
AND (
$5::text IS NULL
OR COALESCE(
CASE WHEN s.type = 'subagent' THEN COALESCE(ps.type, 'chat') ELSE s.type END,
'chat'
) = $5::text
)
`
type CountTokenUsageRecordsParams 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"`
SessionType pgtype.Text `json:"session_type"`
}
func (q *Queries) CountTokenUsageRecords(ctx context.Context, arg CountTokenUsageRecordsParams) (int64, error) {
row := q.db.QueryRow(ctx, countTokenUsageRecords,
arg.BotID,
arg.FromTime,
arg.ToTime,
arg.ModelID,
arg.SessionType,
)
var total int64
err := row.Scan(&total)
return total, err
}
const getTokenUsageByDayAndType = `-- name: GetTokenUsageByDayAndType :many
SELECT
COALESCE(
@@ -145,3 +185,111 @@ func (q *Queries) GetTokenUsageByModel(ctx context.Context, arg GetTokenUsageByM
}
return items, nil
}
const listTokenUsageRecords = `-- name: ListTokenUsageRecords :many
SELECT
m.id,
m.created_at,
m.session_id,
COALESCE(
CASE WHEN s.type = 'subagent' THEN COALESCE(ps.type, 'chat') ELSE s.type END,
'chat'
)::text AS session_type,
m.model_id,
COALESCE(mo.model_id, 'unknown')::text AS model_slug,
COALESCE(mo.name, 'Unknown')::text AS model_name,
COALESCE(lp.name, 'Unknown')::text AS provider_name,
COALESCE((m.usage->>'inputTokens')::bigint, 0)::bigint AS input_tokens,
COALESCE((m.usage->>'outputTokens')::bigint, 0)::bigint AS output_tokens,
COALESCE((m.usage->'inputTokenDetails'->>'cacheReadTokens')::bigint, 0)::bigint AS cache_read_tokens,
COALESCE((m.usage->'inputTokenDetails'->>'cacheWriteTokens')::bigint, 0)::bigint AS cache_write_tokens,
COALESCE((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
LEFT JOIN bot_sessions ps ON ps.id = s.parent_session_id
LEFT JOIN models mo ON mo.id = m.model_id
LEFT JOIN providers lp ON lp.id = mo.provider_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)
AND (
$5::text IS NULL
OR COALESCE(
CASE WHEN s.type = 'subagent' THEN COALESCE(ps.type, 'chat') ELSE s.type END,
'chat'
) = $5::text
)
ORDER BY m.created_at DESC, m.id DESC
LIMIT $7
OFFSET $6
`
type ListTokenUsageRecordsParams 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"`
SessionType pgtype.Text `json:"session_type"`
PageOffset int32 `json:"page_offset"`
PageLimit int32 `json:"page_limit"`
}
type ListTokenUsageRecordsRow struct {
ID pgtype.UUID `json:"id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
SessionID pgtype.UUID `json:"session_id"`
SessionType string `json:"session_type"`
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"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
}
func (q *Queries) ListTokenUsageRecords(ctx context.Context, arg ListTokenUsageRecordsParams) ([]ListTokenUsageRecordsRow, error) {
rows, err := q.db.Query(ctx, listTokenUsageRecords,
arg.BotID,
arg.FromTime,
arg.ToTime,
arg.ModelID,
arg.SessionType,
arg.PageOffset,
arg.PageLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTokenUsageRecordsRow
for rows.Next() {
var i ListTokenUsageRecordsRow
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.SessionID,
&i.SessionType,
&i.ModelID,
&i.ModelSlug,
&i.ModelName,
&i.ProviderName,
&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
}
+176
View File
@@ -34,6 +34,7 @@ func NewTokenUsageHandler(log *slog.Logger, queries *sqlc.Queries, botService *b
func (h *TokenUsageHandler) Register(e *echo.Echo) {
e.GET("/bots/:bot_id/token-usage", h.GetTokenUsage)
e.GET("/bots/:bot_id/token-usage/records", h.ListTokenUsageRecords)
}
// DailyTokenUsage represents aggregated token usage for a single day.
@@ -64,6 +65,29 @@ type TokenUsageResponse struct {
ByModel []ModelTokenUsage `json:"by_model"`
}
// TokenUsageRecord represents a single LLM call (one assistant message row) with its token usage.
type TokenUsageRecord struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
SessionID string `json:"session_id"`
SessionType string `json:"session_type"`
ModelID string `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"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
}
// TokenUsageRecordsResponse is the response body for GET /bots/:bot_id/token-usage/records.
type TokenUsageRecordsResponse struct {
Items []TokenUsageRecord `json:"items"`
Total int64 `json:"total"`
}
// GetTokenUsage godoc
// @Summary Get token usage statistics
// @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
@@ -215,3 +239,155 @@ func formatOptionalUUID(id pgtype.UUID) string {
}
return id.String()
}
const (
tokenUsageRecordsDefaultLimit = 20
tokenUsageRecordsMaxLimit = 100
)
// ListTokenUsageRecords godoc
// @Summary List per-call token usage records
// @Description Paginated list of individual LLM call records (assistant messages with usage) for a bot, with optional model and session type filters
// @Tags token-usage
// @Produce json
// @Param bot_id path string true "Bot ID"
// @Param from query string true "Start date (YYYY-MM-DD)"
// @Param to query string true "End date exclusive (YYYY-MM-DD)"
// @Param model_id query string false "Optional model UUID to filter by"
// @Param session_type query string false "Optional session type: chat, heartbeat, or schedule"
// @Param limit query int false "Page size (default 20, max 100)"
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} TokenUsageRecordsResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/token-usage/records [get].
func (h *TokenUsageHandler) ListTokenUsageRecords(c echo.Context) error {
userID, err := RequireChannelIdentityID(c)
if err != nil {
return err
}
botID := strings.TrimSpace(c.Param("bot_id"))
if botID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
}
if _, err := AuthorizeBotAccess(c.Request().Context(), h.botService, h.accountService, userID, botID); err != nil {
return err
}
fromStr := strings.TrimSpace(c.QueryParam("from"))
toStr := strings.TrimSpace(c.QueryParam("to"))
if fromStr == "" || toStr == "" {
return echo.NewHTTPError(http.StatusBadRequest, "from and to query parameters are required (YYYY-MM-DD)")
}
fromDate, err := time.Parse("2006-01-02", fromStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid from date format, expected YYYY-MM-DD")
}
toDate, err := time.Parse("2006-01-02", toStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid to date format, expected YYYY-MM-DD")
}
if !toDate.After(fromDate) {
return echo.NewHTTPError(http.StatusBadRequest, "to must be after from")
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid bot id")
}
var pgModelID pgtype.UUID
if modelIDStr := strings.TrimSpace(c.QueryParam("model_id")); modelIDStr != "" {
pgModelID, err = db.ParseUUID(modelIDStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid model_id")
}
}
var pgSessionType pgtype.Text
switch sessionType := strings.TrimSpace(c.QueryParam("session_type")); sessionType {
case "":
case "chat", "heartbeat", "schedule":
pgSessionType = pgtype.Text{String: sessionType, Valid: true}
default:
return echo.NewHTTPError(http.StatusBadRequest, "invalid session_type, expected one of: chat, heartbeat, schedule")
}
limit, err := parseInt32Query(c.QueryParam("limit"), tokenUsageRecordsDefaultLimit)
if err != nil {
return err
}
if limit <= 0 {
limit = tokenUsageRecordsDefaultLimit
}
if limit > tokenUsageRecordsMaxLimit {
limit = tokenUsageRecordsMaxLimit
}
offset, err := parseInt32Query(c.QueryParam("offset"), 0)
if err != nil {
return err
}
fromTS := pgtype.Timestamptz{Time: fromDate, Valid: true}
toTS := pgtype.Timestamptz{Time: toDate, Valid: true}
ctx := c.Request().Context()
rows, err := h.queries.ListTokenUsageRecords(ctx, sqlc.ListTokenUsageRecordsParams{
BotID: pgBotID,
FromTime: fromTS,
ToTime: toTS,
ModelID: pgModelID,
SessionType: pgSessionType,
PageOffset: offset,
PageLimit: limit,
})
if err != nil {
h.logger.Error("list token usage records failed", slog.Any("error", err))
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list token usage records")
}
total, err := h.queries.CountTokenUsageRecords(ctx, sqlc.CountTokenUsageRecordsParams{
BotID: pgBotID,
FromTime: fromTS,
ToTime: toTS,
ModelID: pgModelID,
SessionType: pgSessionType,
})
if err != nil {
h.logger.Error("count token usage records failed", slog.Any("error", err))
return echo.NewHTTPError(http.StatusInternalServerError, "failed to count token usage records")
}
items := make([]TokenUsageRecord, 0, len(rows))
for _, r := range rows {
items = append(items, TokenUsageRecord{
ID: formatOptionalUUID(r.ID),
CreatedAt: formatPgTime(r.CreatedAt),
SessionID: formatOptionalUUID(r.SessionID),
SessionType: r.SessionType,
ModelID: formatOptionalUUID(r.ModelID),
ModelSlug: r.ModelSlug,
ModelName: r.ModelName,
ProviderName: r.ProviderName,
InputTokens: r.InputTokens,
OutputTokens: r.OutputTokens,
CacheReadTokens: r.CacheReadTokens,
CacheWriteTokens: r.CacheWriteTokens,
ReasoningTokens: r.ReasoningTokens,
})
}
return c.JSON(http.StatusOK, TokenUsageRecordsResponse{
Items: items,
Total: total,
})
}
func formatPgTime(t pgtype.Timestamptz) string {
if !t.Valid {
return ""
}
return t.Time.UTC().Format(time.RFC3339)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+84
View File
@@ -1198,6 +1198,27 @@ export type HandlersSupermarketTagsResponse = {
tags?: Array<string>;
};
export type HandlersTokenUsageRecord = {
cache_read_tokens?: number;
cache_write_tokens?: number;
created_at?: string;
id?: string;
input_tokens?: number;
model_id?: string;
model_name?: string;
model_slug?: string;
output_tokens?: number;
provider_name?: string;
reasoning_tokens?: number;
session_id?: string;
session_type?: string;
};
export type HandlersTokenUsageRecordsResponse = {
items?: Array<HandlersTokenUsageRecord>;
total?: number;
};
export type HandlersTokenUsageResponse = {
by_model?: Array<HandlersModelTokenUsage>;
chat?: Array<HandlersDailyTokenUsage>;
@@ -5760,6 +5781,69 @@ export type GetBotsByBotIdTokenUsageResponses = {
export type GetBotsByBotIdTokenUsageResponse = GetBotsByBotIdTokenUsageResponses[keyof GetBotsByBotIdTokenUsageResponses];
export type GetBotsByBotIdTokenUsageRecordsData = {
body?: never;
path: {
/**
* Bot ID
*/
bot_id: string;
};
query: {
/**
* Start date (YYYY-MM-DD)
*/
from: string;
/**
* End date exclusive (YYYY-MM-DD)
*/
to: string;
/**
* Optional model UUID to filter by
*/
model_id?: string;
/**
* Optional session type: chat, heartbeat, or schedule
*/
session_type?: string;
/**
* Page size (default 20, max 100)
*/
limit?: number;
/**
* Offset
*/
offset?: number;
};
url: '/bots/{bot_id}/token-usage/records';
};
export type GetBotsByBotIdTokenUsageRecordsErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type GetBotsByBotIdTokenUsageRecordsError = GetBotsByBotIdTokenUsageRecordsErrors[keyof GetBotsByBotIdTokenUsageRecordsErrors];
export type GetBotsByBotIdTokenUsageRecordsResponses = {
/**
* OK
*/
200: HandlersTokenUsageRecordsResponse;
};
export type GetBotsByBotIdTokenUsageRecordsResponse = GetBotsByBotIdTokenUsageRecordsResponses[keyof GetBotsByBotIdTokenUsageRecordsResponses];
export type PostBotsByBotIdToolsData = {
/**
* JSON-RPC request
+144
View File
@@ -4849,6 +4849,92 @@ const docTemplate = `{
}
}
},
"/bots/{bot_id}/token-usage/records": {
"get": {
"description": "Paginated list of individual LLM call records (assistant messages with usage) for a bot, with optional model and session type filters",
"produces": [
"application/json"
],
"tags": [
"token-usage"
],
"summary": "List per-call token usage records",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Start date (YYYY-MM-DD)",
"name": "from",
"in": "query",
"required": true
},
{
"type": "string",
"description": "End date exclusive (YYYY-MM-DD)",
"name": "to",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Optional model UUID to filter by",
"name": "model_id",
"in": "query"
},
{
"type": "string",
"description": "Optional session type: chat, heartbeat, or schedule",
"name": "session_type",
"in": "query"
},
{
"type": "integer",
"description": "Page size (default 20, max 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.TokenUsageRecordsResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/tools": {
"post": {
"description": "MCP endpoint for tool discovery and invocation.",
@@ -12384,6 +12470,64 @@ const docTemplate = `{
}
}
},
"handlers.TokenUsageRecord": {
"type": "object",
"properties": {
"cache_read_tokens": {
"type": "integer"
},
"cache_write_tokens": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"id": {
"type": "string"
},
"input_tokens": {
"type": "integer"
},
"model_id": {
"type": "string"
},
"model_name": {
"type": "string"
},
"model_slug": {
"type": "string"
},
"output_tokens": {
"type": "integer"
},
"provider_name": {
"type": "string"
},
"reasoning_tokens": {
"type": "integer"
},
"session_id": {
"type": "string"
},
"session_type": {
"type": "string"
}
}
},
"handlers.TokenUsageRecordsResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.TokenUsageRecord"
}
},
"total": {
"type": "integer"
}
}
},
"handlers.TokenUsageResponse": {
"type": "object",
"properties": {
+144
View File
@@ -4840,6 +4840,92 @@
}
}
},
"/bots/{bot_id}/token-usage/records": {
"get": {
"description": "Paginated list of individual LLM call records (assistant messages with usage) for a bot, with optional model and session type filters",
"produces": [
"application/json"
],
"tags": [
"token-usage"
],
"summary": "List per-call token usage records",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Start date (YYYY-MM-DD)",
"name": "from",
"in": "query",
"required": true
},
{
"type": "string",
"description": "End date exclusive (YYYY-MM-DD)",
"name": "to",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Optional model UUID to filter by",
"name": "model_id",
"in": "query"
},
{
"type": "string",
"description": "Optional session type: chat, heartbeat, or schedule",
"name": "session_type",
"in": "query"
},
{
"type": "integer",
"description": "Page size (default 20, max 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.TokenUsageRecordsResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/tools": {
"post": {
"description": "MCP endpoint for tool discovery and invocation.",
@@ -12375,6 +12461,64 @@
}
}
},
"handlers.TokenUsageRecord": {
"type": "object",
"properties": {
"cache_read_tokens": {
"type": "integer"
},
"cache_write_tokens": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"id": {
"type": "string"
},
"input_tokens": {
"type": "integer"
},
"model_id": {
"type": "string"
},
"model_name": {
"type": "string"
},
"model_slug": {
"type": "string"
},
"output_tokens": {
"type": "integer"
},
"provider_name": {
"type": "string"
},
"reasoning_tokens": {
"type": "integer"
},
"session_id": {
"type": "string"
},
"session_type": {
"type": "string"
}
}
},
"handlers.TokenUsageRecordsResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.TokenUsageRecord"
}
},
"total": {
"type": "integer"
}
}
},
"handlers.TokenUsageResponse": {
"type": "object",
"properties": {
+97
View File
@@ -2016,6 +2016,44 @@ definitions:
type: string
type: array
type: object
handlers.TokenUsageRecord:
properties:
cache_read_tokens:
type: integer
cache_write_tokens:
type: integer
created_at:
type: string
id:
type: string
input_tokens:
type: integer
model_id:
type: string
model_name:
type: string
model_slug:
type: string
output_tokens:
type: integer
provider_name:
type: string
reasoning_tokens:
type: integer
session_id:
type: string
session_type:
type: string
type: object
handlers.TokenUsageRecordsResponse:
properties:
items:
items:
$ref: '#/definitions/handlers.TokenUsageRecord'
type: array
total:
type: integer
type: object
handlers.TokenUsageResponse:
properties:
by_model:
@@ -6232,6 +6270,65 @@ paths:
summary: Get token usage statistics
tags:
- token-usage
/bots/{bot_id}/token-usage/records:
get:
description: Paginated list of individual LLM call records (assistant messages
with usage) for a bot, with optional model and session type filters
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Start date (YYYY-MM-DD)
in: query
name: from
required: true
type: string
- description: End date exclusive (YYYY-MM-DD)
in: query
name: to
required: true
type: string
- description: Optional model UUID to filter by
in: query
name: model_id
type: string
- description: 'Optional session type: chat, heartbeat, or schedule'
in: query
name: session_type
type: string
- description: Page size (default 20, max 100)
in: query
name: limit
type: integer
- default: 0
description: Offset
in: query
name: offset
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.TokenUsageRecordsResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: List per-call token usage records
tags:
- token-usage
/bots/{bot_id}/tools:
post:
description: MCP endpoint for tool discovery and invocation.