diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index ecab40b1..a76b80ea 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -3,6 +3,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers"; +import { buildUsageProviderList } from "@/shared/utils/usageProviders"; // Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds function isLLMProvider(id) { @@ -232,21 +233,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro for (const node of (nodesData?.nodes || [])) { nodeNameMap[node.id] = node.name; } - const seen = new Set(); - const unique = (d?.connections || []).filter((c) => { - if (c.isActive === false) return false; - if (!isLLMProvider(c.provider)) return false; - if (seen.has(c.provider)) return false; - seen.add(c.provider); - return true; - }).map((c) => ({ - ...c, - nodeName: nodeNameMap[c.provider] || null, + setProviders(buildUsageProviderList({ + connections: d?.connections || [], + freeProviders: FREE_PROVIDERS, + nodeNameMap, + isLLMProvider, })); - const noAuthProviders = Object.values(FREE_PROVIDERS) - .filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id)) - .map((p) => ({ provider: p.id, name: p.name })); - setProviders([...unique, ...noAuthProviders]); }) .catch(() => {}); }, []); diff --git a/src/shared/utils/usageProviders.js b/src/shared/utils/usageProviders.js new file mode 100644 index 00000000..2432df70 --- /dev/null +++ b/src/shared/utils/usageProviders.js @@ -0,0 +1,35 @@ +// Provider list for the Usage page. +// +// Two sources, deduped by provider id: +// 1. Active LLM connections (one entry per provider). +// 2. noAuth free providers that need no connection (e.g. opencode). +// +// Hidden providers are excluded — the Providers page filters `hidden`, so a +// hidden noAuth provider (devin-cli, mimo-free) must not leak into Usage with +// zero connections and zero traffic. +export function buildUsageProviderList({ + connections = [], + freeProviders = {}, + nodeNameMap = {}, + isLLMProvider = () => true, +} = {}) { + const seen = new Set(); + const unique = connections + .filter((c) => { + if (c.isActive === false) return false; + if (!isLLMProvider(c.provider)) return false; + if (seen.has(c.provider)) return false; + seen.add(c.provider); + return true; + }) + .map((c) => ({ + ...c, + nodeName: nodeNameMap[c.provider] || null, + })); + + const noAuthProviders = Object.values(freeProviders) + .filter((p) => p.noAuth && !p.hidden && !seen.has(p.id) && isLLMProvider(p.id)) + .map((p) => ({ provider: p.id, name: p.name })); + + return [...unique, ...noAuthProviders]; +} diff --git a/tests/unit/usage-provider-list.test.js b/tests/unit/usage-provider-list.test.js new file mode 100644 index 00000000..bcfeaefd --- /dev/null +++ b/tests/unit/usage-provider-list.test.js @@ -0,0 +1,80 @@ +// The Usage page lists providers from two sources: +// 1. active LLM connections (deduped by provider id), and +// 2. noAuth free providers that need no connection (e.g. opencode). +// +// Hidden providers (devin-cli, mimo-free) must NOT be auto-added — they are +// excluded from the Providers page, so surfacing them in Usage (with zero +// connections and zero traffic) is a leak. Regression: devin-cli showed up in +// Usage but nowhere in Providers. +import { describe, it, expect } from "vitest"; +import { buildUsageProviderList } from "../../src/shared/utils/usageProviders.js"; + +const isLLM = () => true; + +describe("buildUsageProviderList", () => { + it("does not auto-add hidden noAuth free providers", () => { + const freeProviders = { + opencode: { id: "opencode", name: "OpenCode", noAuth: true }, + "devin-cli": { id: "devin-cli", name: "Devin CLI", noAuth: true, hidden: true }, + "mimo-free": { id: "mimo-free", name: "MiMo Free", noAuth: true, hidden: true }, + }; + + const list = buildUsageProviderList({ + connections: [], + freeProviders, + isLLMProvider: isLLM, + }); + + const ids = list.map((p) => p.provider); + expect(ids).toContain("opencode"); + expect(ids).not.toContain("devin-cli"); + expect(ids).not.toContain("mimo-free"); + }); + + it("includes active LLM connections, deduped by provider", () => { + const list = buildUsageProviderList({ + connections: [ + { provider: "codex", isActive: true }, + { provider: "codex", isActive: true }, + { provider: "zed", isActive: true }, + ], + freeProviders: {}, + isLLMProvider: isLLM, + }); + expect(list.map((p) => p.provider)).toEqual(["codex", "zed"]); + }); + + it("skips inactive connections and non-LLM providers", () => { + const list = buildUsageProviderList({ + connections: [ + { provider: "codex", isActive: false }, + { provider: "whisper", isActive: true }, + ], + freeProviders: {}, + isLLMProvider: (id) => id !== "whisper", + }); + expect(list).toEqual([]); + }); + + it("does not duplicate a free provider that already has a connection", () => { + const freeProviders = { + opencode: { id: "opencode", name: "OpenCode", noAuth: true }, + }; + const list = buildUsageProviderList({ + connections: [{ provider: "opencode", isActive: true }], + freeProviders, + isLLMProvider: isLLM, + }); + expect(list.map((p) => p.provider)).toEqual(["opencode"]); + }); + + it("attaches nodeName from the lookup when present", () => { + const list = buildUsageProviderList({ + connections: [{ provider: "node-1", isActive: true }], + freeProviders: {}, + nodeNameMap: { "node-1": "My Node" }, + isLLMProvider: isLLM, + }); + expect(list[0].nodeName).toBe("My Node"); + }); +});