Files
Aether/frontend/src/api/usage.ts
T

615 lines
19 KiB
TypeScript
Raw Normal View History

2025-12-10 20:52:44 +08:00
import apiClient from './client'
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
2025-12-10 20:52:44 +08:00
import type { ActivityHeatmap } from '@/types/activity'
2026-05-09 01:21:26 +08:00
import type { ImageProgress } from './requestTrace'
2025-12-10 20:52:44 +08:00
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000
const USAGE_ANALYTICS_REQUEST_TIMEOUT_MS = 120 * 1000
2025-12-10 20:52:44 +08:00
export interface UsageRecord {
id: string // UUID
user_id: string // UUID
username?: string
provider_id?: string // UUID
provider_name?: string
model: string
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
2025-12-10 20:52:44 +08:00
input_tokens: number
effective_input_tokens?: number
2025-12-10 20:52:44 +08:00
output_tokens: number
cache_creation_input_tokens?: number
cache_creation_ephemeral_5m_input_tokens?: number
cache_creation_ephemeral_1h_input_tokens?: number
2025-12-10 20:52:44 +08:00
cache_read_input_tokens?: number
total_tokens: number
cost?: number
response_time?: number
created_at: string
2026-06-10 09:16:15 +08:00
updated_at?: string | null
response_time_updated_at?: string | null
2025-12-10 20:52:44 +08:00
has_fallback?: boolean // 🆕 是否发生了 fallback
2026-05-16 15:55:38 +08:00
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
2025-12-10 20:52:44 +08:00
}
export interface UsageStats {
total_requests: number
total_tokens: number
total_cost: number
total_actual_cost?: number
avg_response_time: number
error_count?: number
error_rate?: number
2025-12-10 20:52:44 +08:00
today?: {
requests: number
tokens: number
cost: number
}
activity_heatmap?: ActivityHeatmap | null
}
export interface UsageByModel {
model: string
request_count: number
total_tokens: number
effective_input_tokens?: number
total_input_context?: number
output_tokens?: number
cache_creation_tokens?: number
2025-12-10 20:52:44 +08:00
total_cost: number
avg_response_time?: number
cache_read_tokens?: number
cache_hit_rate?: number
2025-12-10 20:52:44 +08:00
}
export interface UsageByUser {
user_id: string // UUID
email: string
username: string
request_count: number
total_tokens: number
total_cost: number
}
export interface UsageByProvider {
2026-05-20 20:56:36 +08:00
provider_id?: string | null
provider_key?: string
provider_identity_source?: 'provider_id' | 'legacy_name'
2025-12-10 20:52:44 +08:00
provider: string
request_count: number
total_tokens: number
effective_input_tokens?: number
total_input_context?: number
output_tokens?: number
cache_creation_tokens?: number
2025-12-10 20:52:44 +08:00
total_cost: number
actual_cost: number
avg_response_time_ms: number
success_rate: number
error_count: number
cache_read_tokens?: number
cache_hit_rate?: number
2025-12-10 20:52:44 +08:00
}
export interface UsageByApiFormat {
api_format: string
request_count: number
total_tokens: number
effective_input_tokens?: number
total_input_context?: number
output_tokens?: number
cache_creation_tokens?: number
2025-12-10 20:52:44 +08:00
total_cost: number
actual_cost: number
avg_response_time_ms: number
cache_read_tokens?: number
cache_hit_rate?: number
2025-12-10 20:52:44 +08:00
}
export interface UsageFilters {
user_id?: string // UUID
provider_id?: string // UUID
model?: string
search?: string
2025-12-10 20:52:44 +08:00
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
2026-05-16 15:55:38 +08:00
client_family?: string
2025-12-10 20:52:44 +08:00
page?: number
page_size?: number
}
export interface UsageRequestOptions {
skipCache?: boolean
}
type UsageListResponse = {
records?: unknown
pagination?: {
total?: unknown
limit?: unknown
offset?: unknown
}
total?: unknown
limit?: unknown
offset?: unknown
}
function assertPositiveInteger(value: number, field: string): number {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`${field} must be a positive integer`)
}
return value
}
function assertNonNegativeInteger(value: number, field: string): number {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${field} must be a non-negative integer`)
}
return value
}
function assertNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new Error(`Usage response is missing numeric ${field}`)
}
return value
}
function assertUsageRecords(value: unknown): UsageRecord[] {
if (!Array.isArray(value)) {
throw new Error('Usage response is missing records array')
}
return value as UsageRecord[]
}
function compactParams(params: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '')
)
}
function offsetPaginationFromPage(filters?: Pick<UsageFilters, 'page' | 'page_size'>): {
page: number
pageSize: number | undefined
offset: number | undefined
} {
const page = assertPositiveInteger(filters?.page ?? 1, 'page')
if (filters?.page_size === undefined) {
return { page, pageSize: undefined, offset: undefined }
}
const pageSize = assertPositiveInteger(filters.page_size, 'page_size')
return {
page,
pageSize,
offset: assertNonNegativeInteger((page - 1) * pageSize, 'offset'),
}
}
function normalizeUsageRecordPage(
payload: UsageListResponse,
requested: { page: number; pageSize?: number; offset?: number }
): {
records: UsageRecord[]
total: number
page: number
page_size: number
} {
const records = assertUsageRecords(payload.records)
const pagination = payload.pagination
const total = assertNumber(pagination?.total ?? payload.total, 'pagination.total')
const limit = assertPositiveInteger(
assertNumber(pagination?.limit ?? payload.limit, 'pagination.limit'),
'pagination.limit'
)
const offset = assertNonNegativeInteger(
assertNumber(pagination?.offset ?? payload.offset, 'pagination.offset'),
'pagination.offset'
)
const resolvedPage = requested.pageSize !== undefined
? requested.page
: Math.floor(offset / limit) + 1
return {
records,
total,
page: resolvedPage,
page_size: limit,
}
}
function buildCurrentUserUsageParams(filters?: UsageFilters): {
params: Record<string, unknown>
pagination: { page: number; pageSize?: number; offset?: number }
} {
if (filters?.user_id || filters?.provider_id || filters?.model || filters?.granularity) {
throw new Error('getUsageRecords only supports current-user usage filters; use admin usage APIs for user/model/provider filters')
}
const pagination = offsetPaginationFromPage(filters)
return {
pagination,
params: compactParams({
start_date: filters?.start_date,
end_date: filters?.end_date,
preset: filters?.preset,
timezone: filters?.timezone,
tz_offset_minutes: filters?.tz_offset_minutes,
search: filters?.search,
limit: pagination.pageSize,
offset: pagination.offset,
}),
}
}
function buildAdminUsageRecordParams(userId: string, filters?: UsageFilters): {
params: Record<string, unknown>
} {
if (!userId.trim()) {
throw new Error('getUserUsage requires a non-empty user id')
}
if (filters?.provider_id || filters?.granularity) {
throw new Error('getUserUsage does not support provider_id or granularity filters')
}
const pagination = offsetPaginationFromPage(filters)
return {
params: compactParams({
user_id: userId,
start_date: filters?.start_date,
end_date: filters?.end_date,
preset: filters?.preset,
timezone: filters?.timezone,
tz_offset_minutes: filters?.tz_offset_minutes,
search: filters?.search,
model: filters?.model,
limit: pagination.pageSize,
offset: pagination.offset,
}),
}
}
function buildAdminUsageStatsParams(userId: string, filters?: UsageFilters): Record<string, unknown> {
if (!userId.trim()) {
throw new Error('getUserUsage requires a non-empty user id')
}
if (filters?.provider_id || filters?.granularity) {
throw new Error('getUserUsage stats does not support provider_id or granularity filters')
}
return compactParams({
user_id: userId,
start_date: filters?.start_date,
end_date: filters?.end_date,
preset: filters?.preset,
timezone: filters?.timezone,
tz_offset_minutes: filters?.tz_offset_minutes,
model: filters?.model,
})
}
function normalizeActivityHeatmapResponse(payload: unknown): ActivityHeatmap {
const today = new Date()
const endDate = today.toISOString().slice(0, 10)
const start = new Date(today)
start.setUTCDate(start.getUTCDate() - 364)
const startDate = start.toISOString().slice(0, 10)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const candidate = payload as Partial<ActivityHeatmap>
if (Array.isArray(candidate.days)) {
return {
start_date: typeof candidate.start_date === 'string' ? candidate.start_date : startDate,
end_date: typeof candidate.end_date === 'string' ? candidate.end_date : endDate,
total_days: typeof candidate.total_days === 'number' ? candidate.total_days : candidate.days.length,
max_requests: typeof candidate.max_requests === 'number' ? candidate.max_requests : 0,
days: candidate.days,
}
}
}
const grouped = new Map<string, { requests: number; total_tokens: number; total_cost: number; actual_total_cost?: number }>()
if (Array.isArray(payload)) {
for (const item of payload) {
if (!item || typeof item !== 'object') continue
const raw = item as Record<string, unknown>
const date = typeof raw.date === 'string' ? raw.date : ''
if (!date) continue
grouped.set(date, {
requests: typeof raw.requests === 'number'
? raw.requests
: typeof raw.request_count === 'number'
? raw.request_count
: 0,
total_tokens: typeof raw.total_tokens === 'number' ? raw.total_tokens : 0,
total_cost: typeof raw.total_cost === 'number' ? raw.total_cost : 0,
actual_total_cost: typeof raw.actual_total_cost === 'number' ? raw.actual_total_cost : undefined,
})
}
}
const days: ActivityHeatmap['days'] = []
let maxRequests = 0
const cursor = new Date(start)
while (cursor <= today) {
const date = cursor.toISOString().slice(0, 10)
const existing = grouped.get(date)
const requests = existing?.requests ?? 0
maxRequests = Math.max(maxRequests, requests)
days.push({
date,
requests,
total_tokens: existing?.total_tokens ?? 0,
total_cost: existing?.total_cost ?? 0,
actual_total_cost: existing?.actual_total_cost,
})
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return {
start_date: startDate,
end_date: endDate,
total_days: days.length,
max_requests: maxRequests,
days,
}
}
2025-12-10 20:52:44 +08:00
export const usageApi = {
async getUsageRecords(filters?: UsageFilters): Promise<{
records: UsageRecord[]
total: number
page: number
page_size: number
}> {
const { params, pagination } = buildCurrentUserUsageParams(filters)
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
return normalizeUsageRecordPage(response.data, pagination)
2025-12-10 20:52:44 +08:00
},
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
2025-12-10 20:52:44 +08:00
// 为统计数据添加30秒缓存
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}`
2025-12-10 20:52:44 +08:00
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', {
params: filters,
timeout: USAGE_ANALYTICS_REQUEST_TIMEOUT_MS,
})
2025-12-10 20:52:44 +08:00
return response.data
},
options?.skipCache ? 0 : USAGE_ANALYTICS_CACHE_TTL_MS
2025-12-10 20:52:44 +08:00
)
},
/**
* Get usage aggregation by dimension (RESTful API)
* @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format'
* @param filters Optional filters
*/
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
groupBy: 'model' | 'user' | 'provider' | 'api_format',
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
2025-12-10 20:52:44 +08:00
): Promise<T> {
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}`
2025-12-10 20:52:44 +08:00
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
params: { group_by: groupBy, ...filters },
timeout: USAGE_ANALYTICS_REQUEST_TIMEOUT_MS,
2025-12-10 20:52:44 +08:00
})
return response.data
},
options?.skipCache ? 0 : USAGE_ANALYTICS_CACHE_TTL_MS
2025-12-10 20:52:44 +08:00
)
},
// Shorthand methods using getUsageAggregation
async getUsageByModel(
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByModel[]> {
return this.getUsageAggregation<UsageByModel[]>('model', filters, options)
2025-12-10 20:52:44 +08:00
},
async getUsageByUser(
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByUser[]> {
return this.getUsageAggregation<UsageByUser[]>('user', filters, options)
2025-12-10 20:52:44 +08:00
},
async getUsageByProvider(
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByProvider[]> {
return this.getUsageAggregation<UsageByProvider[]>('provider', filters, options)
2025-12-10 20:52:44 +08:00
},
async getUsageByApiFormat(
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByApiFormat[]> {
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters, options)
2025-12-10 20:52:44 +08:00
},
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
records: UsageRecord[]
stats: UsageStats
}> {
const statsParams = buildAdminUsageStatsParams(userId, filters)
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
const [statsResponse, recordsResponse] = await Promise.all([
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
])
2025-12-10 20:52:44 +08:00
return {
records: assertUsageRecords(recordsResponse.data.records),
stats: statsResponse.data,
}
2025-12-10 20:52:44 +08:00
},
async getAllUsageRecords(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
2025-12-10 20:52:44 +08:00
user_id?: string // UUID
username?: string
model?: string
provider?: string
2026-04-29 09:25:19 +08:00
api_format?: string // API 格式筛选(如 openai:chat, claude:messages
2025-12-10 20:52:44 +08:00
status?: string // 'stream' | 'standard' | 'error'
2026-06-10 09:16:15 +08:00
client_family?: string
hide_unknown?: boolean
2026-06-10 09:16:15 +08:00
include_total?: boolean
total_only?: boolean
2025-12-10 20:52:44 +08:00
limit?: number
offset?: number
}): Promise<{
records: Array<Record<string, unknown>>
2025-12-10 20:52:44 +08:00
total: number
limit: number
offset: number
2026-06-10 09:16:15 +08:00
total_is_estimated?: boolean
2025-12-10 20:52:44 +08:00
}> {
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
return dedupedRequest(key, async () => {
const response = await apiClient.get('/api/admin/usage/records', { params })
return response.data
})
2025-12-10 20:52:44 +08:00
},
2026-06-10 09:16:15 +08:00
async getAllUsageRecordTotal(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
search?: string
user_id?: string
username?: string
model?: string
provider?: string
api_format?: string
status?: string
client_family?: string
hide_unknown?: boolean
}): Promise<number> {
const requestParams = compactParams({
...params,
include_total: true,
total_only: true,
limit: 1,
offset: 0,
})
const key = buildCacheKey('usage:records:total', requestParams)
return dedupedRequest(key, async () => {
const response = await apiClient.get<UsageListResponse>('/api/admin/usage/records', {
params: requestParams,
})
return assertNumber(response.data.total, 'total')
})
},
2025-12-10 20:52:44 +08:00
/**
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
* @param ids 可选,逗号分隔的请求 ID 列表
*/
async getActiveRequests(
ids?: string[],
timeRange?: Pick<UsageFilters, 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
): Promise<{
2025-12-10 20:52:44 +08:00
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
2025-12-10 20:52:44 +08:00
input_tokens: number
effective_input_tokens?: number | null
2025-12-10 20:52:44 +08:00
output_tokens: number
cache_creation_input_tokens?: number | null
cache_creation_ephemeral_5m_input_tokens?: number | null
cache_creation_ephemeral_1h_input_tokens?: number | null
cache_read_input_tokens?: number | null
2025-12-10 20:52:44 +08:00
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
2025-12-10 20:52:44 +08:00
response_time_ms: number | null
first_byte_time_ms: number | null
2026-06-10 09:16:15 +08:00
updated_at?: string | null
response_time_updated_at?: string | null
status_code?: number | null
error_message?: string | null
provider?: string | null
api_key_name?: string | null
provider_key_name?: string | null
api_format?: string | null
endpoint_api_format?: string | null
is_stream?: boolean | null
upstream_is_stream?: boolean | null
client_requested_stream?: boolean | null
client_is_stream?: boolean | null
has_format_conversion?: boolean | null
2026-04-17 13:38:23 +08:00
has_fallback?: boolean | null
target_model?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
2026-05-09 01:21:26 +08:00
image_progress?: ImageProgress | null
2025-12-10 20:52:44 +08:00
}>
}> {
const params: Record<string, string | number> = {}
if (ids?.length) {
params.ids = ids.join(',')
}
if (timeRange?.start_date) {
params.start_date = timeRange.start_date
}
if (timeRange?.end_date) {
params.end_date = timeRange.end_date
}
if (timeRange?.preset) {
params.preset = timeRange.preset
}
if (timeRange?.timezone) {
params.timezone = timeRange.timezone
}
if (typeof timeRange?.tz_offset_minutes === 'number') {
params.tz_offset_minutes = timeRange.tz_offset_minutes
}
2025-12-10 20:52:44 +08:00
const response = await apiClient.get('/api/admin/usage/active', { params })
return response.data
},
/**
* 获取活跃度热力图数据(管理员)
* 历史热力图变化很慢,前端做长缓存,避免自动刷新链路重复请求。
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
return cachedRequest(
'admin-usage-activity-heatmap',
async () => {
const response = await apiClient.get<ActivityHeatmap | unknown[]>('/api/admin/usage/heatmap')
return normalizeActivityHeatmapResponse(response.data)
},
ACTIVITY_HEATMAP_CACHE_TTL_MS
)
2025-12-10 20:52:44 +08:00
}
}