mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
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.
This commit is contained in:
committed by
GitHub
parent
f237c07def
commit
fc16dcd6ee
@@ -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)
|
||||
|
||||
@@ -61,15 +61,20 @@ export default function FreeProviderRankingsPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [filter, setFilter] = useState<string>("");
|
||||
const [configuredOnly, setConfiguredOnly] = useState(false);
|
||||
const [configuredProviderIds, setConfiguredProviderIds] = useState<Set<string>>(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<string>();
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -135,30 +118,33 @@ export default function FreeProviderRankingsPage() {
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="configured-only-toggle"
|
||||
className="text-sm text-text-muted select-none cursor-pointer"
|
||||
title={t("configuredOnlyHint")}
|
||||
>
|
||||
{t("configuredOnly")}
|
||||
</label>
|
||||
<button
|
||||
id="configured-only-toggle"
|
||||
role="switch"
|
||||
aria-checked={configuredOnly}
|
||||
onClick={() => setConfiguredOnly(!configuredOnly)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 ${
|
||||
configuredOnly ? "bg-violet-500" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg transition-transform ${
|
||||
configuredOnly ? "translate-x-4" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Availability toggles (default off → show all providers) */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setConfiguredOnly((v) => !v)}
|
||||
aria-pressed={configuredOnly}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
configuredOnly
|
||||
? "bg-emerald-500 border-emerald-500 text-white"
|
||||
: "border-border text-text-muted hover:text-text-main hover:border-emerald-500/50"
|
||||
}`}
|
||||
>
|
||||
{t("filterConfiguredOnly")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAvailableOnly((v) => !v)}
|
||||
aria-pressed={availableOnly}
|
||||
title={t("filterAvailableOnlyHelp")}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
availableOnly
|
||||
? "bg-emerald-500 border-emerald-500 text-white"
|
||||
: "border-border text-text-muted hover:text-text-main hover:border-emerald-500/50"
|
||||
}`}
|
||||
>
|
||||
{t("filterAvailableOnly")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
|
||||
@@ -170,9 +156,9 @@ export default function FreeProviderRankingsPage() {
|
||||
) : (
|
||||
<>
|
||||
{/* Top 3 Podium */}
|
||||
{displayedRankings.length >= 3 && (
|
||||
{rankings.length >= 3 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{displayedRankings.slice(0, 3).map((provider, idx) => (
|
||||
{rankings.slice(0, 3).map((provider, idx) => (
|
||||
<Card key={provider.id} className="relative overflow-hidden">
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-1 ${
|
||||
@@ -216,7 +202,7 @@ export default function FreeProviderRankingsPage() {
|
||||
)}
|
||||
|
||||
{/* Full List */}
|
||||
{displayedRankings.length > 0 && (
|
||||
{rankings.length > 0 && (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -229,11 +215,10 @@ export default function FreeProviderRankingsPage() {
|
||||
<th className="pb-3 font-medium text-right">{t("colAvgScore")}</th>
|
||||
<th className="pb-3 font-medium text-right">{t("colModels")}</th>
|
||||
<th className="pb-3 font-medium text-right">{t("colType")}</th>
|
||||
<th className="pb-3 font-medium text-right">{t("colConfigured")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayedRankings.map((provider, idx) => (
|
||||
{rankings.map((provider, idx) => (
|
||||
<tr key={provider.id} className="border-b border-border/50 last:border-b-0">
|
||||
<td className="py-3 text-text-muted font-mono">{idx + 1}</td>
|
||||
<td className="py-3">
|
||||
@@ -278,17 +263,6 @@ export default function FreeProviderRankingsPage() {
|
||||
{provider.category.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
{configuredProviderIds.has(provider.id) ? (
|
||||
<span className="text-xs px-2 py-1 rounded bg-green-500/10 text-green-500">
|
||||
✓
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 rounded bg-text-muted/10 text-text-muted">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -297,13 +271,9 @@ export default function FreeProviderRankingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{displayedRankings.length === 0 && !error && (
|
||||
{rankings.length === 0 && !error && (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-text-muted">
|
||||
{configuredOnly && rankings.length > 0
|
||||
? t("noConfiguredProviders")
|
||||
: t("emptyState")}
|
||||
</div>
|
||||
<div className="text-center py-12 text-text-muted">{t("emptyState")}</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<string, ConnectionState[]>();
|
||||
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<FreeProviderRanking[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
165
tests/unit/freeProviderRankings-filters.test.ts
Normal file
165
tests/unit/freeProviderRankings-filters.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
Reference in New Issue
Block a user