Files
OmniRoute/src/lib/freeProviderRankings.ts
Dizzle 1f09e2f9b3 feat(rankings): report what a free provider actually served (#10926)
Validado no worktree combinado: mesmos gates + testes focados verdes. Extensão opt-in bem desenhada sobre #10909 (dimensão de uso real via call_logs). CI vermelho é o base-red já rastreado em #9985.
2026-08-21 08:25:23 -03:00

562 lines
20 KiB
TypeScript

/**
* freeProviderRankings.ts — Compute rankings for free providers based on model ELO scores.
*
* Joins free providers (no-auth, OAuth, API key) with their models from the registry
* and their intelligence scores from the `model_intelligence` DB table.
*
* Uses flexible matching to bridge naming gaps between registry model IDs
* and Arena-normalized model names (e.g., "kimi-k2.6" vs "kimi-k2").
*/
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";
import { getProviderUsageSince, type ProviderUsageRow } from "./db/callLogStats";
import { getCustomModels } from "./db/models";
// Type-only: reuse the health vocabulary instead of forking it.
import { RANGE_MS } from "./monitoring/providerHealthMatrix";
import type {
ProviderHealthState,
ProviderHealthMatrixRange,
} from "./monitoring/providerHealthMatrix";
import type { ProviderAuthType } from "./freeProviderRankingsAuthType";
// Re-exported for backward-compat / same-module ergonomics (#6915) — the
// actual implementations live in `freeProviderRankingsAuthType.ts` (DB-free,
// safe to import from "use client" pages; see that file's header comment).
export type { ProviderAuthType } from "./freeProviderRankingsAuthType";
// Re-exported for consumers of `reliability`; the definition stays in monitoring.
export type { ProviderHealthState } from "./monitoring/providerHealthMatrix";
export {
filterRankingsByAuthType,
sortRankingsAuthTypeFirst,
} from "./freeProviderRankingsAuthType";
export interface ProviderModelScore {
modelId: string;
modelName: string;
score: number;
eloRaw: number | null;
confidence: string | null;
category: string;
}
export interface FreeProviderRanking {
id: string;
name: string;
icon: string;
color: string;
textIcon?: string;
category: ProviderAuthType;
topModel: ProviderModelScore | null;
averageScore: number;
modelCount: number;
/** Present only when connection state was loaded (filters active). See `ProviderReliability`. */
reliability?: ProviderReliability;
}
/**
* Get all free providers from all categories.
*/
function getFreeProviders() {
const providers: Array<{
id: string;
name: string;
icon: string;
color: string;
textIcon?: string;
category: ProviderAuthType;
}> = [];
// No-auth providers are always free
for (const [id, p] of Object.entries(NOAUTH_PROVIDERS)) {
providers.push({
id,
name: p.name,
icon: p.icon,
color: p.color,
textIcon: p.textIcon,
category: "noauth",
});
}
// OAuth providers with free tier
for (const [id, p] of Object.entries(OAUTH_PROVIDERS)) {
if ("hasFree" in p && p.hasFree) {
providers.push({
id,
name: p.name,
icon: p.icon,
color: p.color,
textIcon: "textIcon" in p ? (p as any).textIcon : undefined,
category: "oauth",
});
}
}
// API key providers with free tier
for (const [id, p] of Object.entries(APIKEY_PROVIDERS)) {
if ("hasFree" in p && p.hasFree) {
providers.push({
id,
name: p.name,
icon: p.icon,
color: p.color,
textIcon: "textIcon" in p ? (p as any).textIcon : undefined,
category: "apikey",
});
}
}
return providers;
}
/** Minimal shape shared by registry models and user-added custom models. */
export interface RankableModel {
id: string;
name: string;
}
/**
* Pure merge: combine a provider's static registry models with its
* user-added custom models, de-duplicating by `id` (registry entry wins on
* collision — a custom model overriding a known catalog ID keeps the
* catalog's richer metadata upstream, only the extra IDs are additive here).
*
* Exported so the #6368 fix ("custom models missing from Free Provider
* Rankings under configured/available filters") can be unit-tested without a
* DB: the ranking builder no longer only walks the static registry — it also
* folds in whatever the operator configured as a custom model for that
* provider, mirroring how #6150's connection-based filters already treat
* "configured" as DB/runtime state rather than catalog membership.
*/
export function mergeProviderModels(
registryModels: RankableModel[],
customModels: RankableModel[]
): RankableModel[] {
if (customModels.length === 0) return registryModels;
const seen = new Set(registryModels.map((m) => m.id));
const merged = registryModels.slice();
for (const custom of customModels) {
if (!custom?.id || seen.has(custom.id)) continue;
seen.add(custom.id);
merged.push({ id: custom.id, name: custom.name || custom.id });
}
return merged;
}
/**
* Get models for a provider: static registry models plus any user-added
* custom models for that provider (#6368 — custom models were previously
* invisible to the ranking builder, so they never survived the
* configured/available filters even when actually configured+available).
*/
async function getProviderModels(providerId: string): Promise<RankableModel[]> {
const entry = REGISTRY[providerId];
const registryModels = entry?.models ?? [];
const customModels = (await getCustomModels(providerId)) as RankableModel[];
return mergeProviderModels(registryModels, Array.isArray(customModels) ? customModels : []);
}
/**
* Strip trailing version suffixes from a model ID for fuzzy matching.
* E.g., "kimi-k2.6" → "kimi-k2", "gpt-5.5" → "gpt-5"
*/
export function stripVersionSuffix(id: string): string {
return id.replace(/\.\d+(\.\d+)*$/, "");
}
/**
* Find the best matching intelligence entry for a registry model ID.
*
* Strategy (in order):
* 1. Exact match on normalized model ID
* 2. Exact match on model ID with version suffix stripped
* 3. Prefix match (intelligence entry model is a prefix of registry ID)
*
* @param modelId - The registry model ID (e.g., "kimi-k2.6")
* @param intelMap - Map of normalized model names → intelligence entries
* @returns The best matching intelligence entry, or null
*/
export function findMatchingIntelligence(
modelId: string,
intelMap: Map<
string,
Array<{ score: number; eloRaw: number | null; confidence: string | null; category: string }>
>
): { score: number; eloRaw: number | null; confidence: string | null; category: string } | null {
const normalizedId = modelId.toLowerCase();
// Strategy 1: Exact match
const exactMatches = intelMap.get(normalizedId);
if (exactMatches && exactMatches.length > 0) {
return exactMatches.reduce((prev, curr) => (curr.score > prev.score ? curr : prev));
}
// Strategy 2: Strip version suffix and match
const stripped = stripVersionSuffix(normalizedId);
if (stripped !== normalizedId) {
const strippedMatches = intelMap.get(stripped);
if (strippedMatches && strippedMatches.length > 0) {
return strippedMatches.reduce((prev, curr) => (curr.score > prev.score ? curr : prev));
}
}
// Strategy 3: Prefix match (intelligence entry model is a prefix of registry ID)
let bestPrefixMatch: {
score: number;
eloRaw: number | null;
confidence: string | null;
category: string;
} | null = null;
for (const [modelName, entries] of intelMap) {
if (normalizedId.startsWith(modelName + "-") || normalizedId.startsWith(modelName + ".")) {
const best = entries.reduce((prev, curr) => (curr.score > prev.score ? curr : prev));
if (!bestPrefixMatch || best.score > bestPrefixMatch.score) {
bestPrefixMatch = best;
}
}
}
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;
}
/**
* Second, additive dimension exposed on each ranking when connection state is
* loaded (configured/available filters active). Derived from data the ranking
* builder already holds — zero extra query.
*
* States use `ProviderHealthState` (`src/lib/monitoring/providerHealthMatrix.ts`)
* so both surfaces describe a provider the same way. The raw signals stay
* verbatim next to the state: `testStatus` is written on failure paths only and
* reset to `active` by an explicit connection test or a re-auth, so it can
* outlive the actual recovery.
*/
export interface ProviderReliability {
/** Same triplet `ProviderHealthMatrixAccount` exposes, one per connection. */
connections: Array<{
testStatus: string | null;
rateLimitedUntil: string | null;
state: ProviderHealthState;
}>;
/** Provider aggregate; absent entirely for providers with no loaded connection. */
state: ProviderHealthState;
/**
* What the provider actually served over a window, from `call_logs`. Present
* only when the caller asks for it (`withUsage`). Complements `state`, which
* describes the connection right now and cannot see a provider that answers
* every call with an error.
*/
usage?: ProviderUsage;
}
export interface ProviderUsage {
requests: number;
successes: number;
/** `null` below `MIN_USAGE_REQUESTS` — too small a sample to state a rate. */
successRate: number | null;
avgLatencyMs: number | null;
lastRequestAt: string | null;
windowHours: number;
}
/**
* Below this many requests in the window, no rate is reported: 1 failure out of
* 2 calls is not "50% broken", and a provider nobody called is not "0% healthy".
*/
const MIN_USAGE_REQUESTS = 5;
/**
* 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;
/**
* Also report what each provider actually served (`reliability.usage`).
* Off by default: it costs one aggregate query over `call_logs`, which a
* caller that only needs the ranking should not pay.
*/
withUsage?: boolean;
/** Window for `withUsage`. Defaults to `24h`, the health matrix's own default. */
usageRange?: ProviderHealthMatrixRange;
}
/** Group connection states by provider id (shared by filter and reliability attach). */
function groupConnectionsByProvider(
connections: ConnectionState[]
): Map<string, ConnectionState[]> {
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 byProvider;
}
// 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) => classifyConnection(conn, now) === "healthy");
}
/**
* One connection, classified as `classifyAccount` does (health matrix): terminal
* status ⇒ `down`, live cooldown ⇒ `degraded`, else `healthy`. Model lockouts are
* not loaded here, so — as in `isProviderUsable` — they are not consulted.
* The filter reuses this, so it cannot drift from the reported state.
*/
function classifyConnection(conn: ConnectionState, now: number): ProviderHealthState {
const status = (conn.testStatus || "").trim().toLowerCase();
if (TERMINAL_CONNECTION_STATUSES.has(status)) return "down";
if (conn.rateLimitedUntil) {
const until = new Date(conn.rateLimitedUntil).getTime();
if (Number.isFinite(until) && until > now) return "degraded";
}
return "healthy";
}
/** Mirrors `classifyProvider`, minus its circuit-breaker input (not loaded here). */
function classifyProviderConnections(states: ProviderHealthState[]): ProviderHealthState {
if (states.length > 0 && states.every((state) => state === "down")) return "down";
if (states.some((state) => state !== "healthy")) return "degraded";
return "healthy";
}
/**
* 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 = groupConnectionsByProvider(connections);
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
});
}
/**
* Pure enrichment: attach `reliability` to every ranking with a loaded
* connection. Rankings without one are returned unchanged, never mutated.
*/
export function attachProviderReliability(
rankings: FreeProviderRanking[],
connections: ConnectionState[],
now: number = Date.now()
): FreeProviderRanking[] {
const byProvider = groupConnectionsByProvider(connections);
return rankings.map((ranking) => {
const conns = byProvider.get(ranking.id);
if (!conns || conns.length === 0) return ranking;
const states = conns.map((c) => classifyConnection(c, now));
return {
...ranking,
reliability: {
connections: conns.map((c, i) => ({
testStatus: c.testStatus ?? null,
rateLimitedUntil: c.rateLimitedUntil ?? null,
state: states[i],
})),
state: classifyProviderConnections(states),
},
};
});
}
/**
* Pure enrichment: attach `usage` to the `reliability` of every ranking that has
* a row in the windowed aggregate. Rankings without `reliability` (no connection
* loaded) are returned unchanged, never mutated.
*/
export function attachProviderUsage(
rankings: FreeProviderRanking[],
usageRows: ProviderUsageRow[],
windowHours: number
): FreeProviderRanking[] {
const byProvider = new Map(usageRows.map((row) => [row.provider, row]));
return rankings.map((ranking) => {
const row = byProvider.get(ranking.id);
if (!row || !ranking.reliability) return ranking;
return {
...ranking,
reliability: {
...ranking.reliability,
usage: {
requests: row.requests,
successes: row.successes,
successRate: row.requests >= MIN_USAGE_REQUESTS ? row.successes / row.requests : null,
avgLatencyMs: row.avgLatencyMs ?? null,
lastRequestAt: row.lastRequestAt ?? null,
windowHours,
},
},
};
});
}
/**
* 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 async function computeFreeProviderRankings(
category?: string,
limit: number = 50,
opts: FreeProviderRankingFilterOptions = {}
): Promise<FreeProviderRanking[]> {
const freeProviders = getFreeProviders();
const intelligenceEntries = listModelIntelligence({
source: "arena_elo",
category: category || undefined,
});
// Create a map for fast lookup: model name → intelligence entries
const intelMap = new Map<string, typeof intelligenceEntries>();
for (const entry of intelligenceEntries) {
const modelKey = entry.model.toLowerCase();
if (!intelMap.has(modelKey)) {
intelMap.set(modelKey, []);
}
intelMap.get(modelKey)!.push(entry);
}
const rankings: FreeProviderRanking[] = [];
for (const provider of freeProviders) {
const models = await getProviderModels(provider.id);
if (models.length === 0) continue;
const modelScores: ProviderModelScore[] = [];
for (const model of models) {
const match = findMatchingIntelligence(model.id, intelMap);
if (match) {
modelScores.push({
modelId: model.id,
modelName: model.name,
score: match.score,
eloRaw: match.eloRaw,
confidence: match.confidence,
category: match.category,
});
}
}
if (modelScores.length === 0) continue;
// Sort models by score descending
modelScores.sort((a, b) => b.score - a.score);
const topModel = modelScores[0];
const averageScore = modelScores.reduce((sum, m) => sum + m.score, 0) / modelScores.length;
rankings.push({
...provider,
topModel,
averageScore,
modelCount: modelScores.length,
});
}
// Sort providers by top model score descending, then by average score
rankings.sort((a, b) => {
if (a.topModel && b.topModel) {
return b.topModel.score - a.topModel.score;
}
if (a.topModel) return -1;
if (b.topModel) return 1;
return b.averageScore - a.averageScore;
});
// 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) {
// `getProviderConnections` returns a loose JsonRecord[]; ConnectionState is a
// structural subset of it, so TS needs the explicit `unknown` hop (TS2352).
const connections = (await getProviderConnections({
isActive: true,
})) as unknown as ConnectionState[];
filtered = filterFreeProviderRankings(rankings, connections, opts);
// Second dimension, same snapshot: annotation only, sort and scores untouched.
// `availableOnly` already drops providers with no healthy connection, so under
// it `state` is never `down`; `down` needs `configuredOnly` alone.
filtered = attachProviderReliability(filtered, connections);
// Third dimension, opt-in: what the provider actually served. `state` above
// reads the connection as it stands now and cannot see a provider that
// answers every call with an error — only the call log can.
if (opts.withUsage) {
const range = opts.usageRange ?? "24h";
const windowMs = RANGE_MS[range];
const since = new Date(Date.now() - windowMs).toISOString();
filtered = attachProviderUsage(
filtered,
getProviderUsageSince(since),
windowMs / (60 * 60 * 1000)
);
}
}
return filtered.slice(0, limit);
}