mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
feat: add provider metrics display and model import for passthrough providers
- Fetch and display request metrics (success rate, latency, total requests) on provider overview cards - Create /api/provider-metrics endpoint to aggregate stats from request logs - Add /api/providers/[id]/import-models endpoint to import models from provider's /models API - Add "Import from /models" button on passthrough provider detail pages - Include proper PropTypes for new metrics and alias fields
This commit is contained in:
@@ -15,6 +15,7 @@ export default function HomePageClient({ machineId }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||
const [providerMetrics, setProviderMetrics] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -24,9 +25,10 @@ export default function HomePageClient({ machineId }) {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [provRes, modelsRes] = await Promise.all([
|
||||
const [provRes, modelsRes, metricsRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/models"),
|
||||
fetch("/api/provider-metrics"),
|
||||
]);
|
||||
if (provRes.ok) {
|
||||
const provData = await provRes.json();
|
||||
@@ -36,6 +38,10 @@ export default function HomePageClient({ machineId }) {
|
||||
const modelsData = await modelsRes.json();
|
||||
setModels(modelsData.models || []);
|
||||
}
|
||||
if (metricsRes.ok) {
|
||||
const metricsData = await metricsRes.json();
|
||||
setProviderMetrics(metricsData.metrics || {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error fetching data:", e);
|
||||
} finally {
|
||||
@@ -266,6 +272,7 @@ export default function HomePageClient({ machineId }) {
|
||||
<ProviderOverviewCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
metrics={providerMetrics[item.provider.alias] || providerMetrics[item.id]}
|
||||
onClick={() => setSelectedProvider(item)}
|
||||
/>
|
||||
))}
|
||||
@@ -288,7 +295,7 @@ HomePageClient.propTypes = {
|
||||
machineId: PropTypes.string,
|
||||
};
|
||||
|
||||
function ProviderOverviewCard({ item, onClick }) {
|
||||
function ProviderOverviewCard({ item, metrics, onClick }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const statusVariant =
|
||||
@@ -344,6 +351,16 @@ function ProviderOverviewCard({ item, onClick }) {
|
||||
? "Not configured"
|
||||
: `${item.connected} active · ${item.errors} error`}
|
||||
</p>
|
||||
{metrics && metrics.totalRequests > 0 && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] text-text-muted">
|
||||
<span className="text-emerald-500">{metrics.totalSuccesses}</span>/
|
||||
{metrics.totalRequests} reqs
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">{metrics.successRate}%</span>
|
||||
<span className="text-[10px] text-text-muted">~{metrics.avgLatencyMs}ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
@@ -363,6 +380,7 @@ ProviderOverviewCard.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
color: PropTypes.string,
|
||||
textIcon: PropTypes.string,
|
||||
alias: PropTypes.string,
|
||||
}).isRequired,
|
||||
total: PropTypes.number.isRequired,
|
||||
connected: PropTypes.number.isRequired,
|
||||
@@ -370,6 +388,12 @@ ProviderOverviewCard.propTypes = {
|
||||
modelCount: PropTypes.number.isRequired,
|
||||
authType: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
metrics: PropTypes.shape({
|
||||
totalRequests: PropTypes.number,
|
||||
totalSuccesses: PropTypes.number,
|
||||
successRate: PropTypes.number,
|
||||
avgLatencyMs: PropTypes.number,
|
||||
}),
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -418,14 +418,30 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
if (providerInfo.passthroughModels) {
|
||||
return (
|
||||
<PassthroughModelsSection
|
||||
providerAlias={providerAlias}
|
||||
modelAliases={modelAliases}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="download"
|
||||
onClick={handleImportModels}
|
||||
disabled={!canImportModels || importingModels}
|
||||
>
|
||||
{importingModels ? "Importing..." : "Import from /models"}
|
||||
</Button>
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">Add a connection to enable importing.</span>
|
||||
)}
|
||||
</div>
|
||||
<PassthroughModelsSection
|
||||
providerAlias={providerAlias}
|
||||
modelAliases={modelAliases}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -717,9 +733,7 @@ export default function ProviderDetailPage() {
|
||||
|
||||
{/* Models */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold mb-4">Available Models</h2>
|
||||
{renderModelsSection()}
|
||||
|
||||
{/* Custom Models — available for ALL providers */}
|
||||
|
||||
@@ -202,7 +202,9 @@ export default function ProvidersPage() {
|
||||
{/* OAuth Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">OAuth Providers</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
OAuth Providers <span className="size-2.5 rounded-full bg-blue-500" title="OAuth" />
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelAvailabilityBadge />
|
||||
<button
|
||||
@@ -230,6 +232,7 @@ export default function ProvidersPage() {
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "oauth")}
|
||||
authType="oauth"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -238,7 +241,9 @@ export default function ProvidersPage() {
|
||||
{/* Free Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Free Providers</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
Free Providers <span className="size-2.5 rounded-full bg-green-500" title="Free" />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("free")}
|
||||
disabled={!!testingMode}
|
||||
@@ -263,6 +268,7 @@ export default function ProvidersPage() {
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "oauth")}
|
||||
authType="free"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -271,7 +277,10 @@ export default function ProvidersPage() {
|
||||
{/* API Key Providers — fixed list */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
API Key Providers{" "}
|
||||
<span className="size-2.5 rounded-full bg-amber-500" title="API Key" />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("apikey")}
|
||||
disabled={!!testingMode}
|
||||
@@ -296,6 +305,7 @@ export default function ProvidersPage() {
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -304,7 +314,10 @@ export default function ProvidersPage() {
|
||||
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">API Key Compatible Providers</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
API Key Compatible Providers{" "}
|
||||
<span className="size-2.5 rounded-full bg-orange-500" title="Compatible" />
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
|
||||
<button
|
||||
@@ -355,6 +368,7 @@ export default function ProvidersPage() {
|
||||
providerId={info.id}
|
||||
provider={info}
|
||||
stats={getProviderStats(info.id, "apikey")}
|
||||
authType="compatible"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -407,10 +421,18 @@ export default function ProvidersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({ providerId, provider, stats }) {
|
||||
function ProviderCard({ providerId, provider, stats, authType }) {
|
||||
const { connected, error, errorCode, errorTime } = stats;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
oauth: "bg-blue-500",
|
||||
apikey: "bg-amber-500",
|
||||
compatible: "bg-orange-500",
|
||||
};
|
||||
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
<Card
|
||||
@@ -440,7 +462,13 @@ function ProviderCard({ providerId, provider, stats }) {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
{provider.name}
|
||||
<span
|
||||
className={`size-2 rounded-full ${dotColors[authType] || dotColors.oauth} shrink-0`}
|
||||
title={dotLabels[authType] || "OAuth"}
|
||||
/>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||
@@ -470,15 +498,24 @@ ProviderCard.propTypes = {
|
||||
errorCode: PropTypes.string,
|
||||
errorTime: PropTypes.string,
|
||||
}).isRequired,
|
||||
authType: PropTypes.string,
|
||||
};
|
||||
|
||||
// API Key providers - use image with textIcon fallback (same as OAuth providers)
|
||||
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
|
||||
const { connected, error, errorCode, errorTime } = stats;
|
||||
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
oauth: "bg-blue-500",
|
||||
apikey: "bg-amber-500",
|
||||
compatible: "bg-orange-500",
|
||||
};
|
||||
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
|
||||
|
||||
// Determine icon path: OpenAI Compatible providers use specialized icons
|
||||
const getIconPath = () => {
|
||||
if (isCompatible) {
|
||||
@@ -519,7 +556,13 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
{provider.name}
|
||||
<span
|
||||
className={`size-2 rounded-full ${dotColors[authType] || dotColors.apikey} shrink-0`}
|
||||
title={dotLabels[authType] || "API Key"}
|
||||
/>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{isCompatible && (
|
||||
@@ -560,6 +603,7 @@ ApiKeyProviderCard.propTypes = {
|
||||
errorCode: PropTypes.string,
|
||||
errorTime: PropTypes.string,
|
||||
}).isRequired,
|
||||
authType: PropTypes.string,
|
||||
};
|
||||
|
||||
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
|
||||
40
src/app/api/provider-metrics/route.js
Normal file
40
src/app/api/provider-metrics/route.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDbInstance } from "@/lib/db/core.js";
|
||||
|
||||
/**
|
||||
* GET /api/providers/metrics — Aggregate per-provider stats from call_logs
|
||||
* Returns: { metrics: { [provider]: { totalRequests, totalSuccesses, successRate, avgLatencyMs } } }
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
provider,
|
||||
COUNT(*) as totalRequests,
|
||||
SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) as totalSuccesses,
|
||||
ROUND(AVG(duration)) as avgLatencyMs
|
||||
FROM call_logs
|
||||
WHERE provider IS NOT NULL AND provider != '-'
|
||||
GROUP BY provider`
|
||||
)
|
||||
.all();
|
||||
|
||||
const metrics = {};
|
||||
for (const row of rows) {
|
||||
metrics[row.provider] = {
|
||||
totalRequests: row.totalRequests,
|
||||
totalSuccesses: row.totalSuccesses,
|
||||
successRate:
|
||||
row.totalRequests > 0 ? Math.round((row.totalSuccesses / row.totalRequests) * 100) : 0,
|
||||
avgLatencyMs: row.avgLatencyMs || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({ metrics });
|
||||
} catch (error) {
|
||||
console.error("[providers/metrics] Error:", error);
|
||||
return NextResponse.json({ metrics: {} });
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,13 @@ const STATIC_MODEL_PROVIDERS = {
|
||||
{ id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" },
|
||||
{ id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" },
|
||||
],
|
||||
perplexity: () => [
|
||||
{ id: "sonar", name: "Sonar (Fast Search)" },
|
||||
{ id: "sonar-pro", name: "Sonar Pro (Advanced Search)" },
|
||||
{ id: "sonar-reasoning", name: "Sonar Reasoning (CoT + Search)" },
|
||||
{ id: "sonar-reasoning-pro", name: "Sonar Reasoning Pro (Advanced CoT + Search)" },
|
||||
{ id: "sonar-deep-research", name: "Sonar Deep Research (Expert Analysis)" },
|
||||
],
|
||||
};
|
||||
|
||||
// Provider models endpoints configuration
|
||||
@@ -142,14 +149,7 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
perplexity: {
|
||||
url: "https://api.perplexity.ai/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
|
||||
together: {
|
||||
url: "https://api.together.xyz/v1/models",
|
||||
method: "GET",
|
||||
|
||||
Reference in New Issue
Block a user