mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
337 lines
9.0 KiB
TypeScript
337 lines
9.0 KiB
TypeScript
// 使用统计相关类型定义
|
||
|
||
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<AppType> = [
|
||
"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<string> = 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<string> = 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<Pick<RequestLog, "costMultiplier">>;
|
||
|
||
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;
|
||
}
|