import apiClient from './client' import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache' import type { ActivityHeatmap } from '@/types/activity' import type { ImageProgress } from './requestTrace' 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 export interface UsageRecord { id: string // UUID user_id: string // UUID username?: string provider_id?: string // UUID provider_name?: string model: string request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null service_tier?: string | null actual_service_tier?: string | null input_tokens: number effective_input_tokens?: number output_tokens: number cache_creation_input_tokens?: number cache_creation_ephemeral_5m_input_tokens?: number cache_creation_ephemeral_1h_input_tokens?: number cache_read_input_tokens?: number total_tokens: number cost?: number response_time?: number created_at: string updated_at?: string | null response_time_updated_at?: string | null has_fallback?: boolean // 🆕 是否发生了 fallback client_family?: string | null client_ip?: string | null user_agent?: string | null request_path?: string | null request_path_and_query?: string | null } 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 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 total_cost: number avg_response_time?: number cache_read_tokens?: number cache_hit_rate?: number } export interface UsageByUser { user_id: string // UUID email: string username: string request_count: number total_tokens: number total_cost: number } export interface UsageByProvider { provider_id?: string | null provider_key?: string provider_identity_source?: 'provider_id' | 'legacy_name' provider: string request_count: number total_tokens: number effective_input_tokens?: number total_input_context?: number output_tokens?: number cache_creation_tokens?: number 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 } 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 total_cost: number actual_cost: number avg_response_time_ms: number cache_read_tokens?: number cache_hit_rate?: number } export interface UsageFilters { user_id?: string // UUID provider_id?: string // UUID model?: string search?: string start_date?: string end_date?: string preset?: string granularity?: 'hour' | 'day' | 'week' | 'month' timezone?: string tz_offset_minutes?: number client_family?: string 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): Record { return Object.fromEntries( Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '') ) } function offsetPaginationFromPage(filters?: Pick): { 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 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 } { 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 { 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 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() if (Array.isArray(payload)) { for (const item of payload) { if (!item || typeof item !== 'object') continue const raw = item as Record 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, } } 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('/api/users/me/usage', { params }) return normalizeUsageRecordPage(response.data, pagination) }, async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise { // 为统计数据添加30秒缓存 const cacheKey = `usage-stats-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}` return cachedRequest( cacheKey, async () => { const response = await apiClient.get('/api/admin/usage/stats', { params: filters, timeout: USAGE_ANALYTICS_REQUEST_TIMEOUT_MS, }) return response.data }, options?.skipCache ? 0 : USAGE_ANALYTICS_CACHE_TTL_MS ) }, /** * Get usage aggregation by dimension (RESTful API) * @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format' * @param filters Optional filters */ async getUsageAggregation( groupBy: 'model' | 'user' | 'provider' | 'api_format', filters?: UsageFilters & { limit?: number }, options?: UsageRequestOptions ): Promise { const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}` return cachedRequest( cacheKey, async () => { const response = await apiClient.get('/api/admin/usage/aggregation/stats', { params: { group_by: groupBy, ...filters }, timeout: USAGE_ANALYTICS_REQUEST_TIMEOUT_MS, }) return response.data }, options?.skipCache ? 0 : USAGE_ANALYTICS_CACHE_TTL_MS ) }, // Shorthand methods using getUsageAggregation async getUsageByModel( filters?: UsageFilters & { limit?: number }, options?: UsageRequestOptions ): Promise { return this.getUsageAggregation('model', filters, options) }, async getUsageByUser( filters?: UsageFilters & { limit?: number }, options?: UsageRequestOptions ): Promise { return this.getUsageAggregation('user', filters, options) }, async getUsageByProvider( filters?: UsageFilters & { limit?: number }, options?: UsageRequestOptions ): Promise { return this.getUsageAggregation('provider', filters, options) }, async getUsageByApiFormat( filters?: UsageFilters & { limit?: number }, options?: UsageRequestOptions ): Promise { return this.getUsageAggregation('api_format', filters, options) }, 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('/api/admin/usage/stats', { params: statsParams }), apiClient.get('/api/admin/usage/records', { params: recordParams }), ]) return { records: assertUsageRecords(recordsResponse.data.records), stats: statsResponse.data, } }, async getAllUsageRecords(params?: { start_date?: string end_date?: string preset?: string granularity?: 'hour' | 'day' | 'week' | 'month' timezone?: string tz_offset_minutes?: number search?: string // 通用搜索:用户名、密钥名、模型名、提供商名 user_id?: string // UUID username?: string model?: string provider?: string api_format?: string // API 格式筛选(如 openai:chat, claude:messages) status?: string // 'stream' | 'standard' | 'error' client_family?: string hide_unknown?: boolean include_total?: boolean total_only?: boolean limit?: number offset?: number }): Promise<{ records: Array> total: number limit: number offset: number total_is_estimated?: boolean }> { const key = buildCacheKey('usage:records', params as Record | undefined) return dedupedRequest(key, async () => { const response = await apiClient.get('/api/admin/usage/records', { params }) return response.data }) }, 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 { 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('/api/admin/usage/records', { params: requestParams, }) return assertNumber(response.data.total, 'total') }) }, /** * 获取活跃请求的状态(轻量级接口,用于轮询更新) * @param ids 可选,逗号分隔的请求 ID 列表 */ async getActiveRequests( ids?: string[], timeRange?: Pick ): Promise<{ requests: Array<{ id: string status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' input_tokens: number effective_input_tokens?: number | null 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 cost: number actual_cost?: number | null rate_multiplier?: number | null response_time_ms: number | null first_byte_time_ms: number | null 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 has_fallback?: boolean | null target_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null service_tier?: string | null actual_service_tier?: string | null image_progress?: ImageProgress | null }> }> { const params: Record = {} 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 } const response = await apiClient.get('/api/admin/usage/active', { params }) return response.data }, /** * 获取活跃度热力图数据(管理员) * 历史热力图变化很慢,前端做长缓存,避免自动刷新链路重复请求。 */ async getActivityHeatmap(): Promise { return cachedRequest( 'admin-usage-activity-heatmap', async () => { const response = await apiClient.get('/api/admin/usage/heatmap') return normalizeActivityHeatmapResponse(response.data) }, ACTIVITY_HEATMAP_CACHE_TTL_MS ) } }