From fc16dcd6ee8798f5e925fd3b745f77d70a194bf9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:52:14 -0300 Subject: [PATCH] feat(dashboard): filter Free Provider Rankings by configured/available (#6150) (#6251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(dashboard): configured-only / available-only filters on Free Provider Rankings (#6150) — server-side query params + tested lib helper; supersedes the client-side #6245 toggle with an available-only dimension. Lib logic 11/11 green; UI validated live on VPS. Base-reds only. Integrated into release/v3.8.45. --- CHANGELOG.md | 3 +- .../dashboard/free-provider-rankings/page.tsx | 118 +++++-------- src/app/api/free-provider-rankings/route.ts | 18 +- src/i18n/messages/en.json | 3 + src/lib/freeProviderRankings.ts | 109 +++++++++++- .../unit/freeProviderRankings-filters.test.ts | 165 ++++++++++++++++++ 6 files changed, 334 insertions(+), 82 deletions(-) create mode 100644 tests/unit/freeProviderRankings-filters.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b11764d657..70aac1a59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,10 @@ - **feat(combo):** add an option to **disable session stickiness**, per-combo or globally — round-robin / random combos can rotate to a different connection on every request instead of pinning a whole conversation to one connection by its first-message hash. Resolution precedence per-combo `config.disableSessionStickiness` → global `settings.disableSessionStickiness` → default `false` (preserves the #3825 prompt-cache/504 fix); gates **both** stickiness call sites in `open-sse/services/combo.ts`. Exposed as a global toggle (Combo Defaults) and a per-combo Inherit/on/off control. ([#6168](https://github.com/diegosouzapw/OmniRoute/issues/6168)) Regression guard: `tests/unit/combo-disable-session-stickiness.test.ts`. (thanks @RCrushMe) - **feat(docker):** add the `OMNIROUTE_NO_SUDO` env flag for root-less / user-namespaced deployments — the MITM cert-trust command path (`resolveSudoSpawn` in `src/mitm/systemCommands.ts`) now strips the leading `sudo` when the flag is truthy, in addition to the existing root / sudo-missing cases, so the Proxy Agent runs without `sudo` (the operator trusts the CA manually, e.g. via `NODE_EXTRA_CA_CERTS`). Argv-array `spawn` preserved — no shell interpolation (Hard Rule #13). ([#6122](https://github.com/diegosouzapw/OmniRoute/issues/6122)) Regression guard: `tests/unit/mitm-systemCommands-no-sudo.test.ts`. (thanks @powellnorma) - **feat(providers):** add **Requesty** as an OpenAI-compatible gateway provider (BYOK, base `https://router.requesty.ai/v1`, ~200 free requests/day) — wired through the shared OpenAI-compatible registry with full model passthrough (`open-sse/config/providers/registry/requesty/`, `src/shared/constants/providers/apikey/gateways.ts`). ([#6120](https://github.com/diegosouzapw/OmniRoute/issues/6120)) Regression guard: `tests/unit/requesty-provider.test.ts`. (thanks @chirag127) -- **feat(skills):** add a **GitHub skill-discovery** subsystem — search/score/scan/import agent skills from public GitHub repos that ship `SKILL.md` / `CLAUDE.md` / `.cursorrules` files, exposed as MCP tools (`omniroute_github_skills_search`/`scan`/`install`, gated behind `read:skills`/`write:skills` scopes) and a `GET/POST /api/github-skills` route (host-pinned to `api.github.com`, `encodeURIComponent`-escaped, error bodies sanitized). Registers `omni-github-skills` in the agent-skills catalog. Regression guards: `tests/unit/github-collector.test.ts` + the agent-skills catalog/routes/generator/mcp count suites. ([#6186](https://github.com/diegosouzapw/OmniRoute/pull/6186) — thanks @Moseyuh333) +- **feat(dashboard):** add **configured-only / available-only filters** to the Free Provider Rankings page ([#6150](https://github.com/diegosouzapw/OmniRoute/issues/6150)) — hide providers you haven't configured, or whose connections are all rate-limited / out of quota, via server-side query params (`?configuredOnly` / `?availableOnly` on `GET /api/free-provider-rankings`) backed by a testable lib helper reusing the in-process connection state (no Redis). Both filters default off, so the default view is unchanged; this supersedes the earlier client-side "Configured Only" toggle (#6245) with an available-only dimension and unit-tested logic. Regression guard: `tests/unit/freeProviderRankings-filters.test.ts`. ### 🐛 Bug Fixes -- **combo/streaming: fix intermittent 500s, corrupted SSE, and Gemini malformed-response handling ([#5976](https://github.com/diegosouzapw/OmniRoute/issues/5976)).** Five related fixes on the combo streaming path: (1) the quality check now reads a **clone** of the response so the original stream stays unlocked (kills the random `ERR_INVALID_STATE: ReadableStream is locked` 500s), and the abandoned clone branch is cancelled so it no longer buffers the whole body per request; (2) `withEarlyStreamKeepalive` only emits an in-band error frame when **no** bytes were forwarded yet, so a mid-flight upstream drop no longer corrupts a partially-delivered SSE stream; (3) Gemini `finishReason: "MALFORMED_RESPONSE"` maps to OpenAI `content_filter` and triggers combo failover instead of returning broken-but-"successful" output; (4) `/api/usage/call-logs?correlationId=` uses parameterized substring (`LIKE`) matching so partial IDs resolve; (5) per-model 500s skip model-lockout/cooldown for per-model-quota providers. Plus request-logger UI detail improvements. Regression guards: `tests/unit/{earlyStreamKeepalive,finishReason,combo-provider-cooldown-sibling,combo-context-relay,call-logs-correlation-substring,save-call-log-persistence,validate-response-quality}.test.ts`. ([#6216](https://github.com/diegosouzapw/OmniRoute/pull/6216) — thanks @hartmark) - **chatcore (tools): stop the default 128-tool cap from silently dropping opencode's `task`/MCP tools.** opencode (used as an MCP/agent host) sends a large tool list; when it exceeds the speculative `MAX_TOOLS_LIMIT` (128) default, `truncateToolList` did a blind `tools.slice(0, 128)`, dropping every tool past index 128 — including opencode's built-in `task` tool (subagent launch) and many MCP tools, so models routed through OmniRoute could no longer spawn subagents or reach part of their tools. The cap exists to avoid upstream `400`s for providers with real hard limits (e.g. grok-cli 200), so it is kept for those: detection of the opencode client (`isOpencodeClient` — any `x-opencode-*` header, or `opencode` in the user-agent) now only bypasses the **speculative 128 default**, never a known provider ceiling. Precedence is explicit — a proactive/detected provider limit always truncates (even for opencode); otherwise opencode forwards its full tool list; otherwise the unchanged 128 default applies to every other client. Refactors `getEffectiveToolLimit` into `getKnownToolLimit(provider) ?? DEFAULT_LIMIT` (byte-identical for existing callers) and fixes a cosmetic debug-log that reported the truncated count instead of the original. Regression guard: `tests/unit/tool-limit-detector.test.ts`. - **fix(mitm):** the macOS MITM-cert install check now matches the system keychain again. `security find-certificate -a -Z` prints the SHA-1 as a colon-less hex string, but the installed-check compared it against `getCertFingerprint()`'s colon-separated form, so the substring match never hit — the cert was reported as not-installed and re-prompted for the sudo install on every run. Fingerprints are now normalized (colons stripped, upper-cased) on both sides via the extracted `macCertOutputHasFingerprint` helper. Regression guard: `tests/unit/mitm-cert-mac-fingerprint.test.ts`. ([#6204](https://github.com/diegosouzapw/OmniRoute/pull/6204), closes [#6134](https://github.com/diegosouzapw/OmniRoute/issues/6134) — thanks @rianonehub) diff --git a/src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx b/src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx index 31a6614076..a4689b1569 100644 --- a/src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx +++ b/src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx @@ -61,15 +61,20 @@ export default function FreeProviderRankingsPage() { const [error, setError] = useState(""); const [filter, setFilter] = useState(""); const [configuredOnly, setConfiguredOnly] = useState(false); - const [configuredProviderIds, setConfiguredProviderIds] = useState>(new Set()); + const [availableOnly, setAvailableOnly] = useState(false); const fetchRankings = useCallback( - async (category?: string) => { + async (category?: string, opts?: { configuredOnly?: boolean; availableOnly?: boolean }) => { setLoading(true); setError(""); try { - const url = category - ? `/api/free-provider-rankings?category=${category}` + const params = new URLSearchParams(); + if (category) params.set("category", category); + if (opts?.configuredOnly) params.set("configuredOnly", "1"); + if (opts?.availableOnly) params.set("availableOnly", "1"); + const qs = params.toString(); + const url = qs + ? `/api/free-provider-rankings?${qs}` : "/api/free-provider-rankings"; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -85,30 +90,8 @@ export default function FreeProviderRankingsPage() { ); useEffect(() => { - fetchRankings(filter || undefined); - }, [filter, fetchRankings]); - - useEffect(() => { - let active = true; - fetch("/api/providers") - .then((res) => (res.ok ? res.json() : { connections: [] })) - .then((data) => { - if (!active) return; - const ids = new Set(); - for (const conn of data.connections || []) { - if (conn?.provider) ids.add(conn.provider); - } - setConfiguredProviderIds(ids); - }) - .catch(() => {}); - return () => { - active = false; - }; - }, []); - - const displayedRankings = configuredOnly - ? rankings.filter((r) => configuredProviderIds.has(r.id)) - : rankings; + fetchRankings(filter || undefined, { configuredOnly, availableOnly }); + }, [filter, configuredOnly, availableOnly, fetchRankings]); return (
@@ -135,30 +118,33 @@ export default function FreeProviderRankingsPage() { {t(opt.labelKey)} ))} -
- - -
+
+ + {/* Availability toggles (default off → show all providers) */} +
+ +
{error &&
{error}
} @@ -170,9 +156,9 @@ export default function FreeProviderRankingsPage() { ) : ( <> {/* Top 3 Podium */} - {displayedRankings.length >= 3 && ( + {rankings.length >= 3 && (
- {displayedRankings.slice(0, 3).map((provider, idx) => ( + {rankings.slice(0, 3).map((provider, idx) => (
0 && ( + {rankings.length > 0 && (
@@ -229,11 +215,10 @@ export default function FreeProviderRankingsPage() { - - {displayedRankings.map((provider, idx) => ( + {rankings.map((provider, idx) => ( - ))} @@ -297,13 +271,9 @@ export default function FreeProviderRankingsPage() { )} - {displayedRankings.length === 0 && !error && ( + {rankings.length === 0 && !error && ( -
- {configuredOnly && rankings.length > 0 - ? t("noConfiguredProviders") - : t("emptyState")} -
+
{t("emptyState")}
)} diff --git a/src/app/api/free-provider-rankings/route.ts b/src/app/api/free-provider-rankings/route.ts index 73b21dde8a..e493e179a0 100644 --- a/src/app/api/free-provider-rankings/route.ts +++ b/src/app/api/free-provider-rankings/route.ts @@ -3,6 +3,12 @@ import { z } from "zod"; import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { computeFreeProviderRankings } from "@/lib/freeProviderRankings"; +// Coerce common truthy query-string forms ("1", "true", "yes") to a boolean. +const boolParam = z + .string() + .optional() + .transform((val) => val === "1" || val === "true" || val === "yes"); + const QuerySchema = z.object({ category: z.string().min(1).max(50).optional(), limit: z @@ -13,6 +19,9 @@ const QuerySchema = z.object({ const n = Number(val); return Number.isFinite(n) && n >= 1 ? Math.min(Math.round(n), 100) : 50; }), + // Additive filters (default off → current behavior). `availableOnly` implies configured. + configuredOnly: boolParam, + availableOnly: boolParam, }); export async function OPTIONS() { @@ -24,6 +33,8 @@ export async function GET(request: NextRequest) { const parsed = QuerySchema.safeParse({ category: url.searchParams.get("category") || undefined, limit: url.searchParams.get("limit") || undefined, + configuredOnly: url.searchParams.get("configuredOnly") || undefined, + availableOnly: url.searchParams.get("availableOnly") || undefined, }); if (!parsed.success) { @@ -33,8 +44,11 @@ export async function GET(request: NextRequest) { ); } - const { category, limit } = parsed.data; - const rankings = computeFreeProviderRankings(category, limit); + const { category, limit, configuredOnly, availableOnly } = parsed.data; + const rankings = await computeFreeProviderRankings(category, limit, { + configuredOnly, + availableOnly, + }); return NextResponse.json({ rankings }, { headers: CORS_HEADERS }); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9cc77a13c3..9f1dbb2703 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -9040,6 +9040,9 @@ "colAvgScore": "Avg Score", "colModels": "Models", "colType": "Type", + "filterConfiguredOnly": "Configured only", + "filterAvailableOnly": "Available only", + "filterAvailableOnlyHelp": "Hide providers whose connections are all rate-limited or out of quota.", "configuredOnly": "Configured Only", "configuredOnlyHint": "Show only providers with active connections", "noConfiguredProviders": "No configured providers found. Add a provider connection first.", diff --git a/src/lib/freeProviderRankings.ts b/src/lib/freeProviderRankings.ts index c717d5c474..dfa58fab82 100644 --- a/src/lib/freeProviderRankings.ts +++ b/src/lib/freeProviderRankings.ts @@ -11,6 +11,7 @@ import { NOAUTH_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry"; import { listModelIntelligence } from "./db/modelIntelligence"; +import { getProviderConnections } from "./db/providers"; export interface ProviderModelScore { modelId: string; @@ -160,16 +161,108 @@ export function findMatchingIntelligence( return bestPrefixMatch; } +/** + * Minimal shape of a provider connection needed to decide "configured" / + * "non-exhausted". Matches the camelCase columns returned by + * `getProviderConnections()` (`provider`, `testStatus`, `rateLimitedUntil`). + */ +export interface ConnectionState { + provider: string; + testStatus?: string | null; + rateLimitedUntil?: string | null; +} + +/** + * Options controlling the additive "configured" / "available" filters. + * Both default off (undefined/false) → output identical to current behavior. + */ +export interface FreeProviderRankingFilterOptions { + /** Keep only providers that have ≥1 (active) connection configured. */ + configuredOnly?: boolean; + /** Keep only providers that have ≥1 non-exhausted, non-rate-limited connection (implies configured). */ + availableOnly?: boolean; +} + +// Terminal connection statuses — mirrors `isTerminalConnectionStatus` +// (`src/sse/services/auth.ts`). A connection in one of these states stays +// unavailable until credentials/settings change; it never self-recovers. +const TERMINAL_CONNECTION_STATUSES = new Set(["credits_exhausted", "banned", "expired"]); + +/** + * Pure predicate: is at least one of a provider's connections usable *right now*? + * + * A connection is usable when it is neither terminal (`testStatus` ∉ + * {credits_exhausted, banned, expired}) nor currently rate-limited + * (`rateLimitedUntil` null or in the past — lazy recovery, matching the + * Connection Cooldown rule in CLAUDE.md). + * + * NOTE: granularity is PROVIDER-level (connection = provider+account). Per-model + * quota lockout (model lockout, `open-sse/services/accountFallback.ts`) is a + * deferred Phase 3 and is intentionally NOT consulted here. + */ +export function isProviderUsable(connections: ConnectionState[], now: number = Date.now()): boolean { + return connections.some((conn) => { + const status = (conn.testStatus || "").trim().toLowerCase(); + if (TERMINAL_CONNECTION_STATUSES.has(status)) return false; + if (conn.rateLimitedUntil) { + const until = new Date(conn.rateLimitedUntil).getTime(); + if (Number.isFinite(until) && until > now) return false; + } + return true; + }); +} + +/** + * Pure filter over a ranking list + a snapshot of provider connections. + * + * - `configuredOnly`: keep only providers whose `id` appears in `connections`. + * - `availableOnly` (implies configured): additionally require ≥1 usable + * connection per `isProviderUsable`. + * + * With both flags off/absent the input list is returned unchanged. + * Fully synchronous + side-effect-free so it can be unit-tested without a DB. + */ +export function filterFreeProviderRankings( + rankings: FreeProviderRanking[], + connections: ConnectionState[], + opts: FreeProviderRankingFilterOptions = {}, + now: number = Date.now() +): FreeProviderRanking[] { + const { configuredOnly, availableOnly } = opts; + if (!configuredOnly && !availableOnly) return rankings; + + const byProvider = new Map(); + for (const conn of connections) { + const list = byProvider.get(conn.provider); + if (list) { + list.push(conn); + } else { + byProvider.set(conn.provider, [conn]); + } + } + + return rankings.filter((ranking) => { + const conns = byProvider.get(ranking.id); + if (!conns || conns.length === 0) return false; // not configured + if (availableOnly) return isProviderUsable(conns, now); + return true; // configuredOnly + }); +} + /** * Compute rankings for free providers based on ELO scores. * * @param category - Optional filter for task category (e.g., "coding", "default") * @param limit - Maximum number of providers to return + * @param opts - Optional additive filters (configured-only / available-only). + * When set, live provider-connection state is read from the DB and providers + * with no configured / no usable connection are dropped. Both default off. */ -export function computeFreeProviderRankings( +export async function computeFreeProviderRankings( category?: string, - limit: number = 50 -): FreeProviderRanking[] { + limit: number = 50, + opts: FreeProviderRankingFilterOptions = {} +): Promise { const freeProviders = getFreeProviders(); const intelligenceEntries = listModelIntelligence({ source: "arena_elo", @@ -235,5 +328,13 @@ export function computeFreeProviderRankings( return b.averageScore - a.averageScore; }); - return rankings.slice(0, limit); + // Apply the additive configured/available filters (if requested) BEFORE the + // limit slice, so `limit` counts providers that survive the filter. + let filtered = rankings; + if (opts.configuredOnly || opts.availableOnly) { + const connections = (await getProviderConnections({ isActive: true })) as ConnectionState[]; + filtered = filterFreeProviderRankings(rankings, connections, opts); + } + + return filtered.slice(0, limit); } diff --git a/tests/unit/freeProviderRankings-filters.test.ts b/tests/unit/freeProviderRankings-filters.test.ts new file mode 100644 index 0000000000..2232c6480c --- /dev/null +++ b/tests/unit/freeProviderRankings-filters.test.ts @@ -0,0 +1,165 @@ +/** + * Unit tests for the #6150 "configured / non-exhausted" filters on the Free + * Provider Rankings page. + * + * Targets the PURE helpers `isProviderUsable` + `filterFreeProviderRankings` + * (no DB, no I/O) so the filter logic is exercised in isolation. The async + * `computeFreeProviderRankings` merely wraps these over `getProviderConnections`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isProviderUsable, + filterFreeProviderRankings, + type ConnectionState, + type FreeProviderRanking, +} from "../../src/lib/freeProviderRankings.ts"; + +const FIXED_NOW = 1_700_000_000_000; // deterministic "now" for rate-limit math +const future = () => new Date(FIXED_NOW + 60_000).toISOString(); +const past = () => new Date(FIXED_NOW - 60_000).toISOString(); + +function ranking(id: string): FreeProviderRanking { + return { + id, + name: id, + icon: "", + color: "#000", + category: "apikey", + topModel: null, + averageScore: 0.5, + modelCount: 1, + }; +} + +function conn(provider: string, extra: Partial = {}): ConnectionState { + return { provider, testStatus: "active", rateLimitedUntil: null, ...extra }; +} + +// ──────────────── isProviderUsable ──────────────── + +test("isProviderUsable: healthy connection is usable", () => { + assert.equal(isProviderUsable([conn("glm")], FIXED_NOW), true); +}); + +test("isProviderUsable: empty connection list is not usable", () => { + assert.equal(isProviderUsable([], FIXED_NOW), false); +}); + +test("isProviderUsable: terminal statuses (credits_exhausted/banned/expired) are not usable", () => { + for (const status of ["credits_exhausted", "banned", "expired"]) { + assert.equal( + isProviderUsable([conn("glm", { testStatus: status })], FIXED_NOW), + false, + `expected ${status} to be unusable` + ); + } + // case/whitespace-insensitive normalization + assert.equal(isProviderUsable([conn("glm", { testStatus: " BANNED " })], FIXED_NOW), false); +}); + +test("isProviderUsable: future rateLimitedUntil is not usable; past/null is usable", () => { + assert.equal(isProviderUsable([conn("glm", { rateLimitedUntil: future() })], FIXED_NOW), false); + assert.equal(isProviderUsable([conn("glm", { rateLimitedUntil: past() })], FIXED_NOW), true); + assert.equal(isProviderUsable([conn("glm", { rateLimitedUntil: null })], FIXED_NOW), true); +}); + +test("isProviderUsable: mixed — one usable connection makes the provider usable", () => { + const conns = [ + conn("glm", { testStatus: "credits_exhausted" }), + conn("glm", { rateLimitedUntil: future() }), + conn("glm"), // healthy + ]; + assert.equal(isProviderUsable(conns, FIXED_NOW), true); +}); + +// ──────────────── filterFreeProviderRankings ──────────────── + +const RANKINGS = [ranking("glm"), ranking("groq"), ranking("cerebras")]; + +test("filter: both flags off returns the input unchanged (regression)", () => { + const out = filterFreeProviderRankings(RANKINGS, [], {}, FIXED_NOW); + assert.deepEqual( + out.map((r) => r.id), + ["glm", "groq", "cerebras"] + ); + // identical even when connections exist but no flag is set + const out2 = filterFreeProviderRankings(RANKINGS, [conn("glm")], {}, FIXED_NOW); + assert.equal(out2.length, 3); +}); + +test("filter: configuredOnly keeps only providers with ≥1 connection", () => { + const connections = [conn("glm"), conn("groq", { testStatus: "credits_exhausted" })]; + const out = filterFreeProviderRankings( + RANKINGS, + connections, + { configuredOnly: true }, + FIXED_NOW + ); + // cerebras has no connection → dropped; groq stays (configured, exhaustion ignored) + assert.deepEqual( + out.map((r) => r.id), + ["glm", "groq"] + ); +}); + +test("filter: availableOnly drops exhausted-only provider, keeps healthy", () => { + const connections = [conn("glm"), conn("groq", { testStatus: "credits_exhausted" })]; + const out = filterFreeProviderRankings( + RANKINGS, + connections, + { availableOnly: true }, + FIXED_NOW + ); + assert.deepEqual( + out.map((r) => r.id), + ["glm"] + ); +}); + +test("filter: availableOnly drops rate-limited-only provider; recovers when in the past", () => { + const dropped = filterFreeProviderRankings( + RANKINGS, + [conn("glm", { rateLimitedUntil: future() })], + { availableOnly: true }, + FIXED_NOW + ); + assert.deepEqual( + dropped.map((r) => r.id), + [] + ); + + const recovered = filterFreeProviderRankings( + RANKINGS, + [conn("glm", { rateLimitedUntil: past() })], + { availableOnly: true }, + FIXED_NOW + ); + assert.deepEqual( + recovered.map((r) => r.id), + ["glm"] + ); +}); + +test("filter: availableOnly keeps a provider that has at least one usable connection", () => { + const connections = [ + conn("glm", { testStatus: "banned" }), + conn("glm"), // second connection is healthy + ]; + const out = filterFreeProviderRankings( + RANKINGS, + connections, + { availableOnly: true }, + FIXED_NOW + ); + assert.deepEqual( + out.map((r) => r.id), + ["glm"] + ); +}); + +test("filter: availableOnly implies configured (unconfigured provider excluded)", () => { + // no connections at all → nothing survives availableOnly + const out = filterFreeProviderRankings(RANKINGS, [], { availableOnly: true }, FIXED_NOW); + assert.equal(out.length, 0); +});
{t("colAvgScore")} {t("colModels")} {t("colType")}{t("colConfigured")}
{idx + 1} @@ -278,17 +263,6 @@ export default function FreeProviderRankingsPage() { {provider.category.toUpperCase()} - {configuredProviderIds.has(provider.id) ? ( - - ✓ - - ) : ( - - — - - )} -