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:
diegosouzapw
2026-02-15 21:53:27 -03:00
parent 54aba4c087
commit e4db9bff0e
5 changed files with 151 additions and 29 deletions

View File

@@ -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,
};

View File

@@ -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 */}

View File

@@ -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 }) {

View 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: {} });
}
}

View File

@@ -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",