feat(dashboard): provider stats API endpoint and dashboard page (#3175)

Provider stats dashboard + API (SQL moved to db module per Hard Rule #5, +test). Integrated into release/v3.8.10.
This commit is contained in:
PizzaV
2026-06-04 23:21:52 +02:00
committed by GitHub
parent 9d3c0d10af
commit 76838f6594
6 changed files with 735 additions and 0 deletions

View File

@@ -0,0 +1,499 @@
"use client";
/**
* Provider Stats Dashboard
*
* Shows per-provider and per-model statistics aggregated from call_logs,
* plus in-memory combo metrics and telemetry data.
*/
import { useState, useEffect, useCallback, Fragment } from "react";
import { Card } from "@/shared/components";
interface ProviderStat {
provider: string;
totalRequests: number;
successfulRequests: number;
avgLatencyMs: number;
totalTokensIn: number;
totalTokensOut: number;
}
interface ModelStat {
provider: string;
model: string;
requests: number;
avgLatencyMs: number;
successfulRequests: number;
}
interface ToolLatencyStat {
avgTtftAfterToolMs: number;
avgGapAfterToolMs: number;
measurementCount: number;
}
type SortKey = "totalRequests" | "successfulRequests" | "avgLatencyMs" | "totalTokensIn" | "totalTokensOut" | "avgTtftAfterToolMs" | "avgGapAfterToolMs";
type SortDir = "asc" | "desc";
function formatNumber(n: number | null): string {
if (n == null) return "0";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toString();
}
function formatLatency(ms: number | null): string {
if (ms == null) return "—";
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms)}ms`;
}
function successRate(successful: number, total: number): string {
if (total === 0) return "—";
return `${((successful / total) * 100).toFixed(1)}%`;
}
export default function ProviderStatsPage() {
const [data, setData] = useState<{
providers: ProviderStat[];
models: ModelStat[];
comboMetrics: Record<string, unknown>;
telemetry: Record<string, unknown>;
toolLatency: Record<string, ToolLatencyStat>;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [sortKey, setSortKey] = useState<SortKey>("totalRequests");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const res = await fetch("/api/provider-stats");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setData(json);
setError(null);
setLastRefresh(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, [fetchData]);
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir(sortDir === "desc" ? "asc" : "desc");
} else {
setSortKey(key);
setSortDir("desc");
}
};
// Compute summary stats
const totalRequests = data?.providers.reduce((s, p) => s + (p.totalRequests || 0), 0) ?? 0;
const totalSuccessful = data?.providers.reduce((s, p) => s + (p.successfulRequests || 0), 0) ?? 0;
const avgLatency =
data?.providers.length
? Math.round(
data.providers.reduce((s, p) => s + (p.avgLatencyMs || 0), 0) / data.providers.length
)
: 0;
const activeProviders = data?.providers.length ?? 0;
// Sorted providers
const sortedProviders = [...(data?.providers ?? [])].sort((a, b) => {
if (sortKey === "avgTtftAfterToolMs" || sortKey === "avgGapAfterToolMs") {
const aLatency = data?.toolLatency?.[a.provider] ?? null;
const bLatency = data?.toolLatency?.[b.provider] ?? null;
const va = aLatency ? (aLatency[sortKey] as number) ?? 0 : 0;
const vb = bLatency ? (bLatency[sortKey] as number) ?? 0 : 0;
return sortDir === "desc" ? vb - va : va - vb;
}
const va = (a[sortKey] as number) ?? 0;
const vb = (b[sortKey] as number) ?? 0;
return sortDir === "desc" ? vb - va : va - vb;
});
// Group models by provider
const modelsByProvider = new Map<string, ModelStat[]>();
for (const m of data?.models ?? []) {
if (!modelsByProvider.has(m.provider)) modelsByProvider.set(m.provider, []);
modelsByProvider.get(m.provider)!.push(m);
}
// Sort helper icon
const SortIcon = ({ column }: { column: SortKey }) => {
if (sortKey !== column) return null;
return (
<span className="material-symbols-outlined text-[14px] ml-1 align-middle text-primary">
{sortDir === "desc" ? "arrow_downward" : "arrow_upward"}
</span>
);
};
if (!data && !error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-text-muted mt-4">Loading provider stats...</p>
</div>
</div>
);
}
if (error && !data) {
return (
<div>
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-6 text-center">
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2">error</span>
<p className="text-red-400">Failed to load provider stats: {error}</p>
<button
onClick={fetchData}
className="mt-4 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors"
>
Retry
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-end gap-3">
{lastRefresh && (
<span className="text-xs text-text-muted">
Updated {lastRefresh.toLocaleTimeString()}
</span>
)}
<button
onClick={fetchData}
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
title="Refresh"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[18px]">analytics</span>
</div>
<span className="text-sm text-text-muted">Total Requests</span>
</div>
<p className="text-xl font-semibold text-text-main">{formatNumber(totalRequests)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[18px]">timer</span>
</div>
<span className="text-sm text-text-muted">Avg Latency</span>
</div>
<p className="text-xl font-semibold text-text-main">{formatLatency(avgLatency)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center size-8 rounded-lg bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[18px]">check_circle</span>
</div>
<span className="text-sm text-text-muted">Success Rate</span>
</div>
<p className="text-xl font-semibold text-text-main">
{successRate(totalSuccessful, totalRequests)}
</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center size-8 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[18px]">dns</span>
</div>
<span className="text-sm text-text-muted">Active Providers</span>
</div>
<p className="text-xl font-semibold text-text-main">{activeProviders}</p>
</Card>
</div>
{/* Provider Table */}
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">table_chart</span>
Provider Breakdown
</h2>
<span className="text-xs text-text-muted">{activeProviders} providers</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 text-text-muted font-medium">Provider</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalRequests")}
>
Requests <SortIcon column="totalRequests" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("successfulRequests")}
>
Success <SortIcon column="successfulRequests" />
</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Rate</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgLatencyMs")}
>
Avg Latency <SortIcon column="avgLatencyMs" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalTokensIn")}
>
Tokens In <SortIcon column="totalTokensIn" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalTokensOut")}
>
Tokens Out <SortIcon column="totalTokensOut" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgTtftAfterToolMs")}
>
TTFT After Tool <SortIcon column="avgTtftAfterToolMs" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgGapAfterToolMs")}
>
Gap After Tool <SortIcon column="avgGapAfterToolMs" />
</th>
<th className="py-2 px-3 w-8" />
</tr>
</thead>
<tbody>
{sortedProviders.map((p) => {
const isExpanded = expandedProvider === p.provider;
const models = modelsByProvider.get(p.provider) ?? [];
const rate = p.totalRequests > 0 ? (p.successfulRequests / p.totalRequests) * 100 : 0;
return (
<Fragment key={p.provider}>
<tr
className="border-b border-border/50 hover:bg-surface/50 transition-colors cursor-pointer"
onClick={() =>
setExpandedProvider(isExpanded ? null : models.length > 0 ? p.provider : null)
}
>
<td className="py-2.5 px-3 font-medium text-text-main">
{p.provider}
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-text-main">
{formatNumber(p.totalRequests)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-text-main">
{formatNumber(p.successfulRequests)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums">
<span
className={
rate >= 99
? "text-green-500"
: rate >= 95
? "text-amber-500"
: "text-red-500"
}
>
{rate.toFixed(1)}%
</span>
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-text-main">
{formatLatency(p.avgLatencyMs)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-text-muted">
{formatNumber(p.totalTokensIn)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-text-muted">
{formatNumber(p.totalTokensOut)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-text-main">
{formatLatency(data?.toolLatency?.[p.provider]?.avgTtftAfterToolMs ?? null)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-text-main">
{formatLatency(data?.toolLatency?.[p.provider]?.avgGapAfterToolMs ?? null)}
</td>
<td className="py-2.5 px-3 text-center">
{models.length > 0 && (
<span
className={`material-symbols-outlined text-[16px] text-text-muted transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
>
chevron_right
</span>
)}
</td>
</tr>
{isExpanded && models.length > 0 && (
<tr key={`${p.provider}-models`}>
<td colSpan={10} className="p-0">
<div className="bg-black/[0.02] dark:bg-white/[0.02] border-b border-border/30">
<table className="w-full text-xs">
<thead>
<tr className="text-text-muted">
<th className="text-left py-1.5 px-6 pl-12 font-medium">Model</th>
<th className="text-right py-1.5 px-3 font-medium">Requests</th>
<th className="text-right py-1.5 px-3 font-medium">Success</th>
<th className="text-right py-1.5 px-3 font-medium">Rate</th>
<th className="text-right py-1.5 px-3 font-medium">Avg Latency</th>
<th className="px-3 w-8" />
</tr>
</thead>
<tbody>
{models.map((m) => {
const mRate =
m.requests > 0 ? (m.successfulRequests / m.requests) * 100 : 0;
return (
<tr key={m.model} className="border-t border-border/20">
<td className="py-1.5 px-6 pl-12 font-mono text-text-main">
{m.model}
</td>
<td className="py-1.5 px-3 text-right tabular-nums text-text-main">
{formatNumber(m.requests)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums text-text-main">
{formatNumber(m.successfulRequests)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums">
<span
className={
mRate >= 99
? "text-green-500"
: mRate >= 95
? "text-amber-500"
: "text-red-500"
}
>
{mRate.toFixed(1)}%
</span>
</td>
<td className="py-1.5 px-3 text-right tabular-nums text-text-main">
{formatLatency(m.avgLatencyMs)}
</td>
<td className="px-3 w-8" />
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
{sortedProviders.length === 0 && (
<tr>
<td colSpan={10} className="py-8 text-center text-text-muted">
No provider data recorded yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
{/* In-Memory Metrics */}
{data?.comboMetrics && Object.keys(data.comboMetrics).length > 0 && (
<Card
title="Combo Metrics"
subtitle="Per-combo latency and throughput from streaming"
icon="speed"
className="p-5"
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 text-text-muted font-medium">Combo</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Requests</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Avg TTFT</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Avg Total</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Success</th>
</tr>
</thead>
<tbody>
{Object.entries(data.comboMetrics).map(([name, m]: [string, any]) => (
<tr key={name} className="border-b border-border/50 hover:bg-surface/50 transition-colors">
<td className="py-2 px-3 font-medium text-text-main">{name}</td>
<td className="py-2 px-3 text-right tabular-nums text-text-main">
{formatNumber(m.requestCount ?? m.totalRequests ?? 0)}
</td>
<td className="py-2 px-3 text-right tabular-nums text-text-main">
{formatLatency(m.avgTtft ?? m.ttftMs ?? null)}
</td>
<td className="py-2 px-3 text-right tabular-nums text-text-main">
{formatLatency(m.avgLatency ?? m.avgTotalMs ?? null)}
</td>
<td className="py-2 px-3 text-right tabular-nums">
{typeof m.successRate === "number" ? `${(m.successRate * 100).toFixed(1)}%` : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Telemetry Phase Breakdown */}
{data?.telemetry && Object.keys(data.telemetry).length > 0 && (
<Card
title="Request Telemetry"
subtitle="7-phase pipeline breakdown (last 5 minutes)"
icon="route"
className="p-5"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(data.telemetry).map(([key, val]: [string, any]) => {
if (val == null || typeof val === "object") return null;
return (
<div
key={key}
className="rounded-lg border border-border/40 bg-surface/30 p-3"
>
<p className="text-xs text-text-muted uppercase tracking-wide">
{key.replace(/([A-Z])/g, " $1").replace(/_/g, " ")}
</p>
<p className="text-lg font-semibold text-text-main mt-1 tabular-nums">
{typeof val === "number" ? formatNumber(val) : String(val)}
</p>
</div>
);
})}
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { getProviderCallStats, getModelCallStats } from "@/lib/db/providerStats";
import { AI_PROVIDERS } from "@/shared/constants/providers";
export async function GET() {
try {
// Hard Rule #5: SQL lives in src/lib/db/providerStats.ts, not inline here.
const providerStats = getProviderCallStats();
const modelStats = getModelCallStats();
let comboMetrics: Record<string, unknown> = {};
try {
const { getAllComboMetrics } = await import(
"@omniroute/open-sse/services/comboMetrics.ts"
);
comboMetrics = getAllComboMetrics() as Record<string, unknown>;
} catch {}
let telemetry: Record<string, unknown> = {};
try {
const { getTelemetrySummary } = await import("@/shared/utils/requestTelemetry");
telemetry = getTelemetrySummary(300000) as Record<string, unknown>;
} catch {}
let toolLatency: Record<string, unknown> = {};
try {
const { getToolLatencyByProvider } = await import(
"@omniroute/open-sse/services/toolLatencyTracker"
);
toolLatency = getToolLatencyByProvider() as Record<string, unknown>;
} catch {}
const resolveName = (provider: string, nodeName: string | null) => {
if (nodeName?.trim()) return nodeName.trim();
const info = AI_PROVIDERS[provider as keyof typeof AI_PROVIDERS];
return info?.name || provider;
};
const providers = providerStats.map((p: any) => ({
...p,
provider: resolveName(p.provider, p.nodeName),
}));
const models = modelStats.map((m: any) => ({
...m,
provider: resolveName(m.provider, m.nodeName),
}));
return NextResponse.json({ providers, models, comboMetrics, telemetry, toolLatency });
} catch (error) {
console.error("Error fetching provider stats:", error);
return NextResponse.json({ error: "Failed to fetch provider stats" }, { status: 500 });
}
}

View File

@@ -1027,6 +1027,8 @@
"analyticsCompressionSubtitle": "Token savings stats",
"analyticsSearchSubtitle": "Search tool analytics",
"analyticsEvalsSubtitle": "Eval suite results",
"providerStats": "Provider Stats",
"providerStatsSubtitle": "Latency and performance metrics",
"logsSubtitle": "Application logs",
"logsProxySubtitle": "Proxy traffic logs",
"consoleLogsSubtitle": "Console output",

View File

@@ -0,0 +1,68 @@
import { getDbInstance } from "./core";
/**
* Provider/model call statistics aggregated from `call_logs`.
*
* Hard Rule #5: routes must not embed raw SQL — these queries live here so the
* /api/provider-stats route can delegate. Read-only aggregation; no writes.
*/
export interface ProviderCallStat {
provider: string;
nodeName: string | null;
totalRequests: number;
successfulRequests: number;
avgLatencyMs: number | null;
totalTokensIn: number | null;
totalTokensOut: number | null;
}
export interface ModelCallStat {
provider: string;
nodeName: string | null;
model: string;
requests: number;
avgLatencyMs: number | null;
successfulRequests: number;
}
export function getProviderCallStats(): ProviderCallStat[] {
const db = getDbInstance();
return db
.prepare(
`SELECT
c.provider,
pn.name AS nodeName,
COUNT(*) AS totalRequests,
SUM(CASE WHEN c.status >= 200 AND c.status < 400 THEN 1 ELSE 0 END) AS successfulRequests,
ROUND(AVG(c.duration)) AS avgLatencyMs,
SUM(c.tokens_in) AS totalTokensIn,
SUM(c.tokens_out) AS totalTokensOut
FROM call_logs c
LEFT JOIN provider_nodes pn ON pn.id = c.provider
WHERE c.provider IS NOT NULL AND c.provider != '-'
GROUP BY c.provider
ORDER BY totalRequests DESC`
)
.all() as ProviderCallStat[];
}
export function getModelCallStats(): ModelCallStat[] {
const db = getDbInstance();
return db
.prepare(
`SELECT
c.provider,
pn.name AS nodeName,
c.model,
COUNT(*) AS requests,
ROUND(AVG(c.duration)) AS avgLatencyMs,
SUM(CASE WHEN c.status >= 200 AND c.status < 400 THEN 1 ELSE 0 END) AS successfulRequests
FROM call_logs c
LEFT JOIN provider_nodes pn ON pn.id = c.provider
WHERE c.provider IS NOT NULL AND c.model IS NOT NULL
GROUP BY c.provider, c.model
ORDER BY c.provider, requests DESC`
)
.all() as ModelCallStat[];
}

View File

@@ -35,6 +35,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"analytics-compression",
"analytics-search",
"analytics-evals",
"provider-stats",
// Monitoring — flat
"activity",
"logs",
@@ -374,6 +375,13 @@ const ANALYTICS_ITEMS: readonly SidebarItemDefinition[] = [
subtitleKey: "analyticsEvalsSubtitle",
icon: "labs",
},
{
id: "provider-stats",
href: "/dashboard/provider-stats",
i18nKey: "providerStats",
subtitleKey: "providerStatsSubtitle",
icon: "speed",
},
];
const MONITORING_ITEMS: readonly SidebarItemDefinition[] = [

View File

@@ -0,0 +1,104 @@
/**
* #3175 — provider/model call statistics aggregated from call_logs.
* Guards the SQL extracted out of the /api/provider-stats route (Hard Rule #5).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-db-provider-stats-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const stats = await import("../../src/lib/db/providerStats.ts");
function insertCallLog(row: Record<string, unknown>) {
const db = core.getDbInstance();
const full = {
method: "POST",
path: "/v1/chat/completions",
status: 200,
model: "openai/gpt-4.1",
requested_model: null,
provider: "openai",
account: null,
connection_id: null,
duration: 100,
tokens_in: 10,
tokens_out: 20,
cache_source: "upstream",
source_format: null,
target_format: null,
api_key_id: null,
api_key_name: null,
combo_name: null,
combo_step_id: null,
combo_execution_key: null,
error_summary: null,
detail_state: "none",
artifact_relpath: null,
artifact_size_bytes: null,
artifact_sha256: null,
has_request_body: 0,
has_response_body: 0,
has_pipeline_details: 0,
request_summary: null,
...row,
id: row.id ?? `log-${Math.random().toString(16).slice(2)}`,
timestamp: row.timestamp ?? new Date().toISOString(),
};
db.prepare(
`INSERT INTO call_logs (
id, timestamp, method, path, status, model, requested_model, provider, account,
connection_id, duration, tokens_in, tokens_out, cache_source, source_format, target_format,
api_key_id, api_key_name, combo_name, combo_step_id, combo_execution_key,
error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256,
has_request_body, has_response_body, has_pipeline_details, request_summary
) VALUES (
@id, @timestamp, @method, @path, @status, @model, @requested_model, @provider, @account,
@connection_id, @duration, @tokens_in, @tokens_out, @cache_source, @source_format, @target_format,
@api_key_id, @api_key_name, @combo_name, @combo_step_id, @combo_execution_key,
@error_summary, @detail_state, @artifact_relpath, @artifact_size_bytes, @artifact_sha256,
@has_request_body, @has_response_body, @has_pipeline_details, @request_summary
)`
).run(full);
}
test.before(() => {
core.resetDbInstance();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#3175 getProviderCallStats aggregates totals, success and latency per provider", () => {
insertCallLog({ provider: "openai", status: 200, duration: 100, tokens_in: 10, tokens_out: 20 });
insertCallLog({ provider: "openai", status: 500, duration: 300, tokens_in: 5, tokens_out: 0 });
insertCallLog({ provider: "anthropic", status: 200, duration: 50, tokens_in: 1, tokens_out: 2 });
// excluded: provider '-' / null
insertCallLog({ provider: "-", status: 200 });
const rows = stats.getProviderCallStats();
const openai = rows.find((r) => r.provider === "openai");
assert.ok(openai, "openai stats present");
assert.equal(openai.totalRequests, 2);
assert.equal(openai.successfulRequests, 1); // only the 200
assert.equal(openai.avgLatencyMs, 200); // (100+300)/2
assert.equal(openai.totalTokensIn, 15);
assert.equal(openai.totalTokensOut, 20);
assert.ok(!rows.some((r) => r.provider === "-"), "provider '-' is excluded");
// ordered by totalRequests desc → openai (2) before anthropic (1)
assert.equal(rows[0].provider, "openai");
});
test("#3175 getModelCallStats groups by provider+model", () => {
const rows = stats.getModelCallStats();
const m = rows.find((r) => r.model === "openai/gpt-4.1" && r.provider === "openai");
assert.ok(m, "model row present");
assert.equal(m.requests, 2);
assert.equal(m.successfulRequests, 1);
});