mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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",
|
||||
|
||||
@@ -1384,7 +1384,16 @@
|
||||
"dateFrom": "开始日期",
|
||||
"dateTo": "结束日期",
|
||||
"chartPie": "饼图",
|
||||
"chartBar": "柱状图"
|
||||
"chartBar": "柱状图",
|
||||
"records": "调用记录",
|
||||
"noRecords": "无调用记录",
|
||||
"colTime": "时间",
|
||||
"colBot": "Bot",
|
||||
"colSessionType": "类型",
|
||||
"colModel": "模型",
|
||||
"colProvider": "Provider",
|
||||
"colInputTokens": "输入",
|
||||
"colOutputTokens": "输出"
|
||||
},
|
||||
"supermarket": {
|
||||
"title": "市场",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user