2025-12-10 20:52:44 +08:00
|
|
|
|
import apiClient from './client'
|
2026-03-03 22:04:40 +08:00
|
|
|
|
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
|
|
|
|
|
2026-04-19 15:17:25 +08:00
|
|
|
|
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
2026-05-20 16:51:52 +08:00
|
|
|
|
const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000
|
|
|
|
|
|
const USAGE_ANALYTICS_REQUEST_TIMEOUT_MS = 120 * 1000
|
2026-04-19 15:17:25 +08:00
|
|
|
|
|
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
|
2026-07-13 21:45:53 +08:00
|
|
|
|
request_type?: string | null
|
2026-07-17 19:20:16 +08:00
|
|
|
|
requested_reasoning_effort?: string | null
|
2026-05-27 00:36:52 +08:00
|
|
|
|
reasoning_effort?: string | null
|
|
|
|
|
|
service_tier?: string | null
|
2026-07-11 12:27:09 +08:00
|
|
|
|
actual_service_tier?: string | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
input_tokens: number
|
2026-04-10 17:44:55 +08:00
|
|
|
|
effective_input_tokens?: number
|
2025-12-10 20:52:44 +08:00
|
|
|
|
output_tokens: number
|
|
|
|
|
|
cache_creation_input_tokens?: number
|
2026-04-19 02:46:28 +08:00
|
|
|
|
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
|
2026-07-15 23:47:19 +08:00
|
|
|
|
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
|
2026-04-10 17:44:55 +08:00
|
|
|
|
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
|
2026-03-14 00:28:28 +08:00
|
|
|
|
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
|
2026-04-10 17:44:55 +08:00
|
|
|
|
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
|
2026-03-14 00:28:28 +08:00
|
|
|
|
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
|
2026-04-10 17:44:55 +08:00
|
|
|
|
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
|
2026-03-14 00:28:28 +08:00
|
|
|
|
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
|
2026-05-17 15:37:52 +00:00
|
|
|
|
search?: string
|
2025-12-10 20:52:44 +08:00
|
|
|
|
start_date?: string
|
|
|
|
|
|
end_date?: string
|
2026-02-04 02:04:54 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 16:51:52 +08:00
|
|
|
|
export interface UsageRequestOptions {
|
|
|
|
|
|
skipCache?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 20:29:39 +08:00
|
|
|
|
type UsageListResponse = {
|
2026-05-17 15:37:52 +00:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 20:23:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
}> {
|
2026-05-17 15:37:52 +00:00
|
|
|
|
const { params, pagination } = buildCurrentUserUsageParams(filters)
|
|
|
|
|
|
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
|
2026-05-31 20:29:39 +08:00
|
|
|
|
return normalizeUsageRecordPage(response.data, pagination)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-20 16:51:52 +08:00
|
|
|
|
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 为统计数据添加30秒缓存
|
2026-05-20 16:51:52 +08:00
|
|
|
|
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}`
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return cachedRequest(
|
|
|
|
|
|
cacheKey,
|
|
|
|
|
|
async () => {
|
2026-05-20 16:51:52 +08:00
|
|
|
|
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
|
|
|
|
|
|
},
|
2026-05-20 16:51:52 +08:00
|
|
|
|
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',
|
2026-05-20 16:51:52 +08:00
|
|
|
|
filters?: UsageFilters & { limit?: number },
|
|
|
|
|
|
options?: UsageRequestOptions
|
2025-12-10 20:52:44 +08:00
|
|
|
|
): Promise<T> {
|
2026-05-20 16:51:52 +08:00
|
|
|
|
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', {
|
2026-05-20 16:51:52 +08:00
|
|
|
|
params: { group_by: groupBy, ...filters },
|
|
|
|
|
|
timeout: USAGE_ANALYTICS_REQUEST_TIMEOUT_MS,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
return response.data
|
|
|
|
|
|
},
|
2026-05-20 16:51:52 +08:00
|
|
|
|
options?.skipCache ? 0 : USAGE_ANALYTICS_CACHE_TTL_MS
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Shorthand methods using getUsageAggregation
|
2026-05-20 16:51:52 +08:00
|
|
|
|
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
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-20 16:51:52 +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
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-20 16:51:52 +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
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-20 16:51:52 +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
|
|
|
|
|
|
}> {
|
2026-05-17 15:37:52 +00:00
|
|
|
|
const statsParams = buildAdminUsageStatsParams(userId, filters)
|
|
|
|
|
|
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
|
|
|
|
|
|
const [statsResponse, recordsResponse] = await Promise.all([
|
2026-05-31 20:29:39 +08:00
|
|
|
|
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
|
|
|
|
|
|
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
|
2026-05-17 15:37:52 +00:00
|
|
|
|
])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-05-17 15:37:52 +00:00
|
|
|
|
return {
|
2026-05-31 20:29:39 +08:00
|
|
|
|
records: assertUsageRecords(recordsResponse.data.records),
|
2026-05-17 15:37:52 +00:00
|
|
|
|
stats: statsResponse.data,
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async getAllUsageRecords(params?: {
|
|
|
|
|
|
start_date?: string
|
|
|
|
|
|
end_date?: string
|
2026-02-04 02:04:54 +08:00
|
|
|
|
preset?: string
|
|
|
|
|
|
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
|
|
|
|
timezone?: string
|
|
|
|
|
|
tz_offset_minutes?: number
|
2026-01-05 19:32:57 +08:00
|
|
|
|
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
|
2026-05-28 20:24:48 +08:00
|
|
|
|
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<{
|
2026-02-22 00:43:41 +08:00
|
|
|
|
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
|
|
|
|
}> {
|
2026-03-03 22:04:40 +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 })
|
2026-05-31 20:29:39 +08:00
|
|
|
|
return response.data
|
2026-03-03 22:04:40 +08:00
|
|
|
|
})
|
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 列表
|
|
|
|
|
|
*/
|
2026-04-16 17:41:32 +08:00
|
|
|
|
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
|
2026-02-22 00:43:41 +08:00
|
|
|
|
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
input_tokens: number
|
2026-04-10 17:44:55 +08:00
|
|
|
|
effective_input_tokens?: number | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
output_tokens: number
|
2026-01-15 12:13:47 +08:00
|
|
|
|
cache_creation_input_tokens?: number | null
|
2026-04-19 02:46:28 +08:00
|
|
|
|
cache_creation_ephemeral_5m_input_tokens?: number | null
|
|
|
|
|
|
cache_creation_ephemeral_1h_input_tokens?: number | null
|
2026-01-15 12:13:47 +08:00
|
|
|
|
cache_read_input_tokens?: number | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
cost: number
|
2026-01-15 12:13:47 +08:00
|
|
|
|
actual_cost?: number | null
|
|
|
|
|
|
rate_multiplier?: number | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
response_time_ms: number | null
|
2026-01-05 10:31:34 +08:00
|
|
|
|
first_byte_time_ms: number | null
|
2026-06-10 09:16:15 +08:00
|
|
|
|
updated_at?: string | null
|
|
|
|
|
|
response_time_updated_at?: string | null
|
2026-04-24 03:07:11 +08:00
|
|
|
|
status_code?: number | null
|
|
|
|
|
|
error_message?: string | null
|
2026-01-05 10:31:34 +08:00
|
|
|
|
provider?: string | null
|
|
|
|
|
|
api_key_name?: string | null
|
2026-04-05 20:23:16 +08:00
|
|
|
|
provider_key_name?: string | null
|
2026-01-28 11:24:37 +08:00
|
|
|
|
api_format?: string | null
|
|
|
|
|
|
endpoint_api_format?: string | null
|
2026-04-23 14:42:51 +08:00
|
|
|
|
is_stream?: boolean | null
|
|
|
|
|
|
upstream_is_stream?: boolean | null
|
|
|
|
|
|
client_requested_stream?: boolean | null
|
|
|
|
|
|
client_is_stream?: boolean | null
|
2026-01-28 11:24:37 +08:00
|
|
|
|
has_format_conversion?: boolean | null
|
2026-04-17 13:38:23 +08:00
|
|
|
|
has_fallback?: boolean | null
|
2026-02-04 03:17:55 +08:00
|
|
|
|
target_model?: string | null
|
2026-07-13 21:45:53 +08:00
|
|
|
|
request_type?: string | null
|
2026-07-17 19:20:16 +08:00
|
|
|
|
requested_reasoning_effort?: string | null
|
2026-05-27 00:36:52 +08:00
|
|
|
|
reasoning_effort?: string | null
|
|
|
|
|
|
service_tier?: string | null
|
2026-07-11 12:27:09 +08:00
|
|
|
|
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
|
|
|
|
}>
|
|
|
|
|
|
}> {
|
2026-04-16 17:41:32 +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 })
|
2026-05-31 20:29:39 +08:00
|
|
|
|
return response.data
|
2026-01-04 22:42:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取活跃度热力图数据(管理员)
|
2026-04-19 15:17:25 +08:00
|
|
|
|
* 历史热力图变化很慢,前端做长缓存,避免自动刷新链路重复请求。
|
2026-01-04 22:42:58 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
return cachedRequest(
|
|
|
|
|
|
'admin-usage-activity-heatmap',
|
|
|
|
|
|
async () => {
|
2026-04-05 20:23:16 +08:00
|
|
|
|
const response = await apiClient.get<ActivityHeatmap | unknown[]>('/api/admin/usage/heatmap')
|
|
|
|
|
|
return normalizeActivityHeatmapResponse(response.data)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
},
|
2026-04-19 15:17:25 +08:00
|
|
|
|
ACTIVITY_HEATMAP_CACHE_TTL_MS
|
2026-03-03 22:04:40 +08:00
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|