// 使用统计相关类型定义 export interface TokenUsage { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; } export interface RequestLog { requestId: string; providerId: string; providerName?: string; appType: string; model: string; requestModel?: string; /** 写入时实际用于计价的模型名;路由接管 + request 计价模式下可能与 model 不同 */ pricingModel?: string; /** 0=legacy, 1=input includes cache buckets, 2=input is already fresh. */ inputTokenSemantics: 0 | 1 | 2; costMultiplier: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; inputCostUsd: string; outputCostUsd: string; cacheReadCostUsd: string; cacheCreationCostUsd: string; totalCostUsd: string; isStreaming: boolean; latencyMs: number; firstTokenMs?: number; durationMs?: number; statusCode: number; errorMessage?: string; createdAt: number; dataSource?: string; } export interface SessionSyncResult { imported: number; skipped: number; filesScanned: number; suspectedDuplicates: number; deferredFiles: number; errors: string[]; } export interface DataSourceSummary { dataSource: string; requestCount: number; totalCostUsd: string; } export interface PaginatedLogs { data: RequestLog[]; total: number; page: number; pageSize: number; } export interface ModelPricing { modelId: string; displayName: string; inputCostPerMillion: string; outputCostPerMillion: string; cacheReadCostPerMillion: string; cacheCreationCostPerMillion: string; } export interface ModelsDevSyncConfig { autoSyncEnabled: boolean; includeCommonModels: boolean; selectedModelKeys: string[]; excludedCommonModelKeys: string[]; lastSyncAt: number | null; lastSyncError: string | null; } export interface ModelsDevSyncState { config: ModelsDevSyncConfig; configPath: string; } export interface UsageSummary { totalRequests: number; totalCost: string; totalInputTokens: number; totalOutputTokens: number; totalCacheCreationTokens: number; totalCacheReadTokens: number; successRate: number; /** input + output + cache_creation + cache_read, all cache-normalized */ realTotalTokens: number; /** cache_read / (input + cache_creation + cache_read), range 0–1 */ cacheHitRate: number; } export interface UsageSummaryByApp { appType: string; summary: UsageSummary; } export interface DailyStats { date: string; requestCount: number; totalCost: string; totalTokens: number; totalInputTokens: number; totalOutputTokens: number; totalCacheCreationTokens: number; totalCacheReadTokens: number; } export interface ProviderStats { providerId: string; providerName: string; requestCount: number; totalTokens: number; totalCost: string; successRate: number; avgLatencyMs: number; } export interface ModelStats { model: string; requestCount: number; totalTokens: number; totalCost: string; avgCostPerRequest: string; } export interface LogFilters { appType?: string; providerName?: string; model?: string; statusCode?: number; startDate?: number; endDate?: number; } /** * Dashboard 顶栏的全局筛选维度,作用于 Hero / 趋势图 / 三个统计 Tab。 * * - `providerName` 按展示名精确匹配(与 Provider 统计列表同口径,含 * "Claude (Session)" 等会话占位名); * - `model` 按「有效计价模型」匹配(pricing_model 优先、回落 model, * 与模型统计的分组口径一致)。 */ export interface UsageScopeFilters { appType?: string; providerName?: string; model?: string; } export interface ProviderLimitStatus { providerId: string; dailyUsage: string; dailyLimit?: string; dailyExceeded: boolean; monthlyUsage: string; monthlyLimit?: string; monthlyExceeded: boolean; } export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom"; export interface UsageRangeSelection { preset: UsageRangePreset; customStartDate?: number; customEndDate?: number; /** When true (custom mode only), endDate resolves to "now" instead of the * fixed customEndDate snapshot, and the end-time field becomes read-only. */ liveEndTime?: boolean; } /** * App types surfaced as dashboard filter buttons. * * `claude-desktop` is intentionally NOT listed: the Desktop gateway's proxy * traffic is still recorded under its own `app_type` (preserving route-takeover * billing audit — the request detail panel shows the real value), but the * dashboard folds it into `claude` for display. It is the embedded Claude Code * runtime running inside the Desktop shell, and Desktop *chat* usage never * passes through this app at all, so a separate "Claude Desktop" bucket would * only ever show a partial number and mislead users into reading it as the * Desktop's full usage. The backend collapses `claude-desktop → claude` in * every dashboard query (see `folded_app_type_sql`). * `opencode` / `openclaw` / `hermes` have no proxy handler at all — they * appear only as managed apps elsewhere. */ export type AppType = | "claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "pi"; export type AppTypeFilter = "all" | AppType; export const KNOWN_APP_TYPES: ReadonlyArray = [ "claude", "codex", "gemini", "grokbuild", "opencode", "pi", ]; /** * App types whose proxy uses an OpenAI-style protocol. Two consequences: * * 1. `inputTokens` already includes the cached portion (must subtract * `cacheReadTokens` to get fresh-input semantics — see * [getFreshInputTokens]). * 2. The protocol does not report cache _creation_ separately, only cache * _reads_. So `cacheCreationTokens` is always 0 for these app types and * the UI should label it as N/A rather than 0. * * Mirror of the Rust `CACHE_INCLUSIVE_APP_TYPES` whitelist. */ export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet = new Set([ "codex", "gemini", "grokbuild", ]); /** * Apps whose wire protocol is selected per request rather than fixed by the * app. A summary row cannot prove that every request reported cache creation, * especially after detail rows have been rolled up, so the UI must present * cache-write totals as partial rather than as an authoritative zero. */ export const CACHE_PROTOCOL_MIXED_APP_TYPES: ReadonlySet = new Set([ "pi", ]); export type CacheWriteAvailability = "ok" | "partial" | "na"; export function getCacheWriteAvailability( appTypes: readonly string[], ): CacheWriteAvailability { if (appTypes.length === 0) return "ok"; if (appTypes.some((appType) => CACHE_PROTOCOL_MIXED_APP_TYPES.has(appType))) { return "partial"; } const unavailable = appTypes.filter((appType) => CACHE_INCLUSIVE_APP_TYPES.has(appType), ).length; if (unavailable === appTypes.length) return "na"; return unavailable === 0 ? "ok" : "partial"; } /** Subset of request-log fields needed to derive cache-normalized input. */ export interface CacheNormalizableLog { appType: string; inputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; inputTokenSemantics: 0 | 1 | 2; } /** * For a single request log, return the input token count with cache reads * removed. Anthropic-style providers already report `inputTokens` without * cache, so they pass through unchanged. */ export function getFreshInputTokens(log: CacheNormalizableLog): number { if (log.inputTokenSemantics === 2) return log.inputTokens; if (log.inputTokenSemantics === 1) { return Math.max( 0, log.inputTokens - log.cacheReadTokens - log.cacheCreationTokens, ); } if ( CACHE_INCLUSIVE_APP_TYPES.has(log.appType) && log.inputTokens >= log.cacheReadTokens ) { return log.inputTokens - log.cacheReadTokens; } return log.inputTokens; } export const NON_NEGATIVE_DECIMAL_REGEX = /^\d+(?:\.\d+)?$/; export function isNonNegativeDecimalString(value: string): boolean { const trimmed = value.trim(); if (!NON_NEGATIVE_DECIMAL_REGEX.test(trimmed)) return false; return Number.isFinite(Number(trimmed)); } type UsageCostLog = Pick< RequestLog, | "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheCreationTokens" | "totalCostUsd" | "statusCode" > & Partial>; export function hasUsageTokens(log: UsageCostLog): boolean { return ( log.inputTokens > 0 || log.outputTokens > 0 || log.cacheReadTokens > 0 || log.cacheCreationTokens > 0 ); } export function isUnpricedUsage(log: UsageCostLog): boolean { const totalCost = Number.parseFloat(log.totalCostUsd); const multiplier = log.costMultiplier == null ? undefined : Number.parseFloat(log.costMultiplier); return ( log.statusCode >= 200 && log.statusCode < 300 && hasUsageTokens(log) && Number.isFinite(totalCost) && (!Number.isFinite(multiplier) || multiplier !== 0) && totalCost === 0 ); } export interface StatsFilters { timeRange: UsageRangePreset; providerId?: string; appType?: string; }