diff --git a/changelog.d/fixes/10714-provider-metrics-ghost-deleted-provider.md b/changelog.d/fixes/10714-provider-metrics-ghost-deleted-provider.md new file mode 100644 index 0000000000..050728ec08 --- /dev/null +++ b/changelog.d/fixes/10714-provider-metrics-ghost-deleted-provider.md @@ -0,0 +1 @@ +- fix(db): filter `getProviderMetrics()` to providers with a live `provider_connections` row so a deleted provider stops permanently ghost-haunting the Home "Provider Topology" widget (#10714) diff --git a/src/lib/db/callLogStats.ts b/src/lib/db/callLogStats.ts index 7a8ef741be..e238215e66 100644 --- a/src/lib/db/callLogStats.ts +++ b/src/lib/db/callLogStats.ts @@ -56,7 +56,10 @@ export interface SearchProviderCountRow { /** * Returns one row per provider with call-level aggregates plus last-status - * subselects. Excludes rows where provider is NULL or '-'. + * subselects. Excludes rows where provider is NULL or '-', and excludes + * providers with no live row in `provider_connections` — a deleted provider + * connection must not keep surfacing as a ghost topology node forever from + * its retained historical call_logs rows. See #10714. */ export function getProviderMetrics(): ProviderMetricRow[] { const db = getDbInstance(); @@ -96,6 +99,9 @@ export function getProviderMetrics(): ProviderMetricRow[] { ) as lastErrorStatus FROM call_logs c WHERE c.provider IS NOT NULL AND c.provider != '-' + AND EXISTS ( + SELECT 1 FROM provider_connections pc WHERE pc.provider = c.provider + ) GROUP BY c.provider` ) .all() as ProviderMetricRow[]; diff --git a/tests/unit/db-call-log-stats-3500.test.ts b/tests/unit/db-call-log-stats-3500.test.ts index 5240ea97f4..aa6d585546 100644 --- a/tests/unit/db-call-log-stats-3500.test.ts +++ b/tests/unit/db-call-log-stats-3500.test.ts @@ -93,6 +93,17 @@ test.after(() => { // --------------------------------------------------------------------------- test("#3500 getProviderMetrics — aggregates totals and latency per provider", () => { + // #10714: getProviderMetrics() only surfaces providers with a live + // provider_connections row — seed openai/anthropic connections so their + // call_logs rows are not filtered out as ghost/deleted providers. + const db0 = core.getDbInstance(); + db0.prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?, ?, ?, ?)` + ).run("conn-3500-openai", "openai", new Date().toISOString(), new Date().toISOString()); + db0.prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?, ?, ?, ?)` + ).run("conn-3500-anthropic", "anthropic", new Date().toISOString(), new Date().toISOString()); + // Two openai rows: one success, one error with error_summary const ts1 = "2025-06-01T10:00:00.000Z"; const ts2 = "2025-06-01T11:00:00.000Z"; diff --git a/tests/unit/provider-metrics-deleted-provider.test.ts b/tests/unit/provider-metrics-deleted-provider.test.ts new file mode 100644 index 0000000000..2cfbbbbf7a --- /dev/null +++ b/tests/unit/provider-metrics-deleted-provider.test.ts @@ -0,0 +1,61 @@ +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(), "omniroute-provider-metrics-deleted-") +); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providerMetricsRoute = await import("../../src/app/api/provider-metrics/route.ts"); + +type ProviderMetricsResponse = { + metrics: Record; + topology: { providers: string[]; lastProvider: string; errorProvider: string }; +}; + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetStorage(); +}); +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; +}); + +test("#10714: a provider deleted from provider_connections must NOT keep showing as an errored topology node", async () => { + const db = core.getDbInstance(); + + db.prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?, ?, ?, ?)` + ).run("conn-openai-1", "openai", "2026-08-19T09:00:00.000Z", "2026-08-19T09:00:00.000Z"); + db.prepare( + `INSERT INTO call_logs (id, timestamp, provider, status, duration, error_summary) VALUES (?, ?, ?, ?, ?, ?)` + ).run("openai-error", "2026-08-19T10:00:00.000Z", "openai", 500, 80, "upstream error"); + + // Deleted provider: historical call_logs rows exist, but no provider_connections row. + db.prepare( + `INSERT INTO call_logs (id, timestamp, provider, status, duration, error_summary) VALUES (?, ?, ?, ?, ?, ?)` + ).run("g4f-pollinations-error", "2026-08-19T11:00:00.000Z", "g4f-pollinations", 402, 50, "payment required"); + + const response = await providerMetricsRoute.GET(); + const body = (await response.json()) as ProviderMetricsResponse; + + assert.equal(response.status, 200); + assert.equal(body.topology.providers.includes("g4f-pollinations"), false); + assert.equal(body.metrics["g4f-pollinations"], undefined); + assert.notEqual(body.topology.errorProvider, "g4f-pollinations"); + assert.equal(body.topology.providers.includes("openai"), true); + assert.equal(body.topology.errorProvider, "openai"); +}); diff --git a/tests/unit/provider-metrics-route.test.ts b/tests/unit/provider-metrics-route.test.ts index ca5def5bb5..2d0b2d2543 100644 --- a/tests/unit/provider-metrics-route.test.ts +++ b/tests/unit/provider-metrics-route.test.ts @@ -52,8 +52,17 @@ test.after(() => { } }); +function seedProviderConnection(provider: string) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?, ?, ?, ?)` + ).run(`conn-${provider}`, provider, new Date().toISOString(), new Date().toISOString()); +} + test("GET /api/provider-metrics includes provider recency and error topology", async () => { const db = core.getDbInstance(); + seedProviderConnection("openai"); + seedProviderConnection("anthropic"); db.prepare( `INSERT INTO call_logs (id, timestamp, provider, status, duration, error_summary) VALUES (?, ?, ?, ?, ?, ?)` @@ -91,6 +100,8 @@ test("GET /api/provider-metrics errorProvider must NOT flag a provider whose mos // Bug (pre-fix): errorProvider = "providerA" because lastErrorAt > 0. // Fix (post-fix): errorProvider = "" because lastStatus for providerA is 200. const db = core.getDbInstance(); + seedProviderConnection("providerA"); + seedProviderConnection("providerB"); db.prepare( `INSERT INTO call_logs (id, timestamp, provider, status, duration, error_summary) VALUES (?, ?, ?, ?, ?, ?)`