From e4db9bff0e01cb40bc06aec77a2923acb9d8197a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 15 Feb 2026 21:53:27 -0300 Subject: [PATCH] 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 --- .../(dashboard)/dashboard/HomePageClient.js | 28 ++++++++- .../dashboard/providers/[id]/page.js | 36 +++++++---- .../(dashboard)/dashboard/providers/page.js | 60 ++++++++++++++++--- src/app/api/provider-metrics/route.js | 40 +++++++++++++ src/app/api/providers/[id]/models/route.js | 16 ++--- 5 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 src/app/api/provider-metrics/route.js diff --git a/src/app/(dashboard)/dashboard/HomePageClient.js b/src/app/(dashboard)/dashboard/HomePageClient.js index f7e3cec30f..5c0cbd666f 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.js +++ b/src/app/(dashboard)/dashboard/HomePageClient.js @@ -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 }) { 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`}

+ {metrics && metrics.totalRequests > 0 && ( +
+ + {metrics.totalSuccesses}/ + {metrics.totalRequests} reqs + + {metrics.successRate}% + ~{metrics.avgLatencyMs}ms +
+ )}
@@ -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, }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 0c49fdd8e5..068a0a8d64 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -418,14 +418,30 @@ export default function ProviderDetailPage() { } if (providerInfo.passthroughModels) { return ( - +
+
+ + {!canImportModels && ( + Add a connection to enable importing. + )} +
+ +
); } @@ -717,9 +733,7 @@ export default function ProviderDetailPage() { {/* Models */} -

- {providerInfo.passthroughModels ? "Model Aliases" : "Available Models"} -

+

Available Models

{renderModelsSection()} {/* Custom Models — available for ALL providers */} diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index bcf68e117a..c4780c5c64 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -202,7 +202,9 @@ export default function ProvidersPage() { {/* OAuth Providers */}
-

OAuth Providers

+

+ OAuth Providers +

@@ -238,7 +241,9 @@ export default function ProvidersPage() { {/* Free Providers */}
-

Free Providers

+

+ Free Providers +

@@ -271,7 +277,10 @@ export default function ProvidersPage() { {/* API Key Providers — fixed list */}
-

API Key Providers

+

+ API Key Providers{" "} + +

@@ -304,7 +314,10 @@ export default function ProvidersPage() { {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
-

API Key Compatible Providers

+

+ API Key Compatible Providers{" "} + +

{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
@@ -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 (
-

{provider.name}

+

+ {provider.name} + +

{getStatusDisplay(connected, error, errorCode)} {errorTime && • {errorTime}} @@ -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 }) { )}
-

{provider.name}

+

+ {provider.name} + +

{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 }) { diff --git a/src/app/api/provider-metrics/route.js b/src/app/api/provider-metrics/route.js new file mode 100644 index 0000000000..6dcadc1ca3 --- /dev/null +++ b/src/app/api/provider-metrics/route.js @@ -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: {} }); + } +} diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index e27e7e9858..c92bac5b9f 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -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",