Files
metapi/src/server/services/modelAnalysisService.ts
T

215 lines
5.3 KiB
TypeScript

import { formatLocalDate, parseStoredUtcDateTime } from './localTimeService.js';
const DAY_MS = 24 * 60 * 60 * 1000;
interface ProxyLogLike {
createdAt: string | null;
modelActual: string | null;
modelRequested: string | null;
status: string | null;
latencyMs: number | null;
totalTokens: number | null;
estimatedCost: number | null;
}
interface ModelAnalysisResult {
window: {
start: string;
end: string;
days: number;
};
totals: {
calls: number;
tokens: number;
spend: number;
};
spendDistribution: Array<{
model: string;
spend: number;
calls: number;
}>;
spendTrend: Array<{
day: string;
spend: number;
}>;
callsDistribution: Array<{
model: string;
calls: number;
share: number;
}>;
callRanking: Array<{
model: string;
calls: number;
successRate: number;
avgLatencyMs: number;
spend: number;
tokens: number;
}>;
}
interface BuildOptions {
now?: Date;
days?: number;
maxModels?: number;
}
interface MutableModelStats {
model: string;
calls: number;
success: number;
latencyTotal: number;
tokens: number;
spend: number;
}
function startOfLocalDay(value: Date): Date {
return new Date(
value.getFullYear(),
value.getMonth(),
value.getDate(),
0,
0,
0,
0,
);
}
function dayKey(value: Date): string {
return formatLocalDate(value);
}
function toSafeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function toPositiveInt(value: unknown): number {
return Math.max(0, Math.round(toSafeNumber(value)));
}
function round(value: number, digits = 2): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function resolveModelName(log: ProxyLogLike): string {
const raw = (log.modelActual || log.modelRequested || '').trim();
return raw.length > 0 ? raw : 'unknown';
}
function resolveSpend(log: ProxyLogLike, tokens: number): number {
const explicit = toSafeNumber(log.estimatedCost);
if (explicit > 0) return explicit;
if (tokens <= 0) return 0;
// Fallback to OneAPI/NewAPI quota conversion when explicit cost is absent.
return tokens / 500000;
}
export function buildModelAnalysis(logs: ProxyLogLike[], options: BuildOptions = {}): ModelAnalysisResult {
const now = options.now ?? new Date();
const days = Math.max(1, options.days ?? 7);
const maxModels = Math.max(1, options.maxModels ?? 10);
const endDay = startOfLocalDay(now);
const startDay = new Date(endDay.getTime() - (days - 1) * DAY_MS);
const dayKeys: string[] = [];
for (let i = 0; i < days; i++) {
dayKeys.push(dayKey(new Date(startDay.getTime() + i * DAY_MS)));
}
const daySet = new Set(dayKeys);
const spendTrendMap = new Map<string, number>(dayKeys.map((key) => [key, 0]));
const modelMap = new Map<string, MutableModelStats>();
for (const log of logs) {
if (!log?.createdAt) continue;
const createdAt = parseStoredUtcDateTime(log.createdAt);
if (!createdAt) continue;
const createdAtDay = dayKey(createdAt);
if (!daySet.has(createdAtDay)) continue;
const model = resolveModelName(log);
const tokens = toPositiveInt(log.totalTokens);
const spend = Math.max(0, resolveSpend(log, tokens));
const latency = toPositiveInt(log.latencyMs);
const isSuccess = (log.status || '').toLowerCase() === 'success';
const stat = modelMap.get(model) ?? {
model,
calls: 0,
success: 0,
latencyTotal: 0,
tokens: 0,
spend: 0,
};
stat.calls += 1;
stat.success += isSuccess ? 1 : 0;
stat.latencyTotal += latency;
stat.tokens += tokens;
stat.spend += spend;
modelMap.set(model, stat);
spendTrendMap.set(createdAtDay, (spendTrendMap.get(createdAtDay) ?? 0) + spend);
}
const stats = Array.from(modelMap.values());
const totalCalls = stats.reduce((sum, item) => sum + item.calls, 0);
const totalTokens = stats.reduce((sum, item) => sum + item.tokens, 0);
const totalSpend = round(stats.reduce((sum, item) => sum + item.spend, 0), 6);
const spendDistribution = [...stats]
.sort((a, b) => b.spend - a.spend)
.slice(0, maxModels)
.map((item) => ({
model: item.model,
spend: round(item.spend, 6),
calls: item.calls,
}));
const callsDistribution = [...stats]
.sort((a, b) => b.calls - a.calls)
.slice(0, maxModels)
.map((item) => ({
model: item.model,
calls: item.calls,
share: totalCalls > 0 ? round((item.calls / totalCalls) * 100, 2) : 0,
}));
const callRanking = [...stats]
.sort((a, b) => b.calls - a.calls)
.slice(0, maxModels)
.map((item) => ({
model: item.model,
calls: item.calls,
successRate: item.calls > 0 ? round((item.success / item.calls) * 100, 2) : 0,
avgLatencyMs: item.calls > 0 ? Math.round(item.latencyTotal / item.calls) : 0,
spend: round(item.spend, 6),
tokens: item.tokens,
}));
const spendTrend = dayKeys.map((day) => ({
day,
spend: round(spendTrendMap.get(day) ?? 0, 6),
}));
return {
window: {
start: dayKeys[0],
end: dayKeys[dayKeys.length - 1],
days,
},
totals: {
calls: totalCalls,
tokens: totalTokens,
spend: totalSpend,
},
spendDistribution,
spendTrend,
callsDistribution,
callRanking,
};
}