From d2ab1893ed78cf201d78cbab6e6d437eca5c9537 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:09:06 -0300 Subject: [PATCH] feat(catalog): map unmapped free tiers, add navy + aihorde, surface keyless providers (#7840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(catalog): map unmapped free tiers, add navy + aihorde, surface keyless Seven providers whose free tier was documented upstream but never reached our catalog. Five of them we could already route — only the quota was missing. Providers already routable, quota now mapped: - requesty (200 req/day), ovhcloud (2 req/min per IP, anonymous), agnes (permanently free), glm (GLM-4.7/4.5-Flash are Free on the official pricing table). All registered as recurring-uncapped: their free tier is capped in REQUESTS, not tokens, so inventing a token figure would inflate the headline. The "~30M/month" that circulates for GLM belongs to BigModel.cn (a separate Chinese offering) and is deliberately not recorded. New providers: - navy: one shared 150K tokens/day pool (~4.5M/month) drained by a per-model token_multiplier. Registered as a SINGLE pooled row — summing its ~149 free models would overcount ~149x. - aihorde: crowdsourced volunteer GPUs, keyless via the documented anonymous key. No tool calling and a 120s timeout, because requests queue for minutes. Also: - kilo-gateway reconciled against its live /models list (7 -> 13 models) and flagged with the new trainsOnPrompts field: every free Kilo model reports mayTrainOnYourPrompts: true, so the privacy cost now sits next to the quota. - Free-tier page gains search, provider/keyless filters, per-row type badges, a "no API key required" section and a curation-date freshness indicator. - catalogUpdatedAt comes from an explicit FREE_CATALOG_CURATED_AT constant rather than the data file's mtime: a standalone build rewrites timestamps on deploy, which would advertise a months-old catalog as updated today. Net effect on the headline: 462 -> 484 models but 1.371B -> 1.376B tokens, because only navy publishes a token quota. That is the point — coverage grows without the number lying. * refactor(providers): derive one answer for "does this need an API key?" "Works without a credential" lived in three registries that disagreed, and only three providers were classified the same way in all of them: - NOAUTH_PROVIDERS.noAuth -> whether the connect form hides the field - RegistryEntry.authType / anonymousApiKey -> what the executor really sends - FreeModelBudget.freeType === "keyless" -> how the catalog labels it getCredentialRequirement() now derives the answer from the two sources that describe real behaviour, returning none | optional | oauth | required. It adds no list to maintain: registering a provider the usual way is enough. oauth is deliberately NOT "works without a credential" — there is no key to paste, but signing in is still a barrier, and calling it keyless would mislead. anonymousApiKey outranks noAuth: AI Horde ships a documented anonymous key AND honours a real one for higher queue priority, so it is "optional" rather than "none" even though the form hides the field. Fixes one real inconsistency this branch introduced: ovhcloud was catalogued as keyless while its registry demanded a key. Verified live — the anonymous tier answers /chat/completions with no Authorization header, and a BAD key returns 403 instead of degrading, so authType is now "optional" and the executor attaches the header only when a real credential exists. The 10 pre-existing divergences (agy, blackbox, pollinations, puter, qwen-web, …) are frozen in KEYLESS_CATALOG_DRIFT with a stale-entry check: the gate blocks new drift, and fails if a frozen entry stops drifting so the debt list cannot outlive the debt. Resolving each one means confirming upstream behaviour, not editing a list. * fix(dashboard): build "no API key required" from routing, not freeType Probing all ten providers the catalog labels `keyless` (2026-07-20) showed the label answers a different question than the UI was asking: blackbox 401 "No api key passed in." friendliai 401 "no authorization info provided" iflytek 401 Unauthorized sparkdesk 401 Unauthorized puter 401 "Missing authentication token" muse-spark-web 403 (authHeader is a session cookie, not a key) qwen-web 200 but serves the WAF HTML page, not the API liquid 404 — endpoint moved; needs its own audit pollinations 200 with real choices <- genuinely key-free ovhcloud 200, and 403 on a BAD key <- fixed earlier in this branch `freeType: "keyless"` means "free access not quantifiable in tokens" — it sits beside `oauth` in FREE_TIERS.md for exactly that reason. The new section was listing those rows under "No API key required", which would have sent users to providers that reject them. It now derives from getCredentialRequirement(). pollinations was the one real find: it answers with no credential at all, so its registry entry moves from apikey to optional and it leaves the recorded list. The list is computed in the route handler, not the component: deriving it client-side pulled the whole 201-entry provider REGISTRY into the browser bundle. The component takes `noCredentialProviders` from the payload and stays dumb — which is also why the vitest run could not resolve REGISTRY through the `@omniroute/*` alias and silently classified every provider as credentialed. * fix(test,providers): resolve open-sse in vitest; point liquid at its live host vitest.config.ts / vitest.mcp.config.ts had no `@omniroute/open-sse` alias, so imports from open-sse resolved to undefined instead of throwing. Tests stayed green while every lookup silently returned a default — that is how the free-tier card asserted on provider credentials with REGISTRY never loaded. Both configs now mirror the tsconfig paths, and tests/unit/ui/open-sse-alias.test.tsx pins it by asserting on values only reachable through REGISTRY (aihorde's anonymous key, pollinations' optional auth), so a future regression fails loudly. liquid pointed at api.liquid.ai, which stopped serving the API — every path now returns a Vercel 404 HTML page, so routing failed with an unparseable body instead of a clean error. The live OpenAI-compatible host is inference.liquid.ai (403 {"detail":"Not authenticated"} without a key). Both verified 2026-07-20. Swept every free-catalog provider for the same failure. Five more looked dead on a /models probe (agentrouter, coze, kiro, nlpcloud, puter) but answer their chat endpoint with real API JSON — a 404 on /models only means the path is not exposed. They are untouched: liquid was the only genuine casualty. * test(providers): move the APIKEY_PROVIDERS partition count to 180 This PR adds one gateway provider (navy), so the frozen entry-count and the family-partition sum both shift by one. The assertions are moving targets by design — they exist to catch a provider silently landing in two families or in none, not to freeze the catalog size. --- config/quality/test-discovery-baseline.json | 1 - open-sse/config/freeModelCatalog.data.ts | 48 +++- open-sse/config/freeModelCatalog.ts | 6 + open-sse/config/providers/index.ts | 4 + .../providers/registry/aihorde/index.ts | 54 +++++ .../config/providers/registry/liquid/index.ts | 12 +- .../config/providers/registry/navy/index.ts | 30 +++ .../providers/registry/ovhcloud/index.ts | 13 +- .../providers/registry/pollinations/index.ts | 7 +- .../usage/components/FreeBudgetCard.tsx | 206 +++++++++++++++- src/app/api/free-tier/summary/route.ts | 6 + .../constants/providers/apikey/gateways.ts | 19 ++ src/shared/constants/providers/noauth.ts | 20 ++ .../utils/providerCredentialRequirement.ts | 120 ++++++++++ tests/unit/free-budget-card.test.tsx | 117 --------- .../free-catalog-2026-07-expansion.test.ts | 95 ++++++++ tests/unit/free-tier-summary-route.test.ts | 16 ++ .../provider-credential-requirement.test.ts | 91 +++++++ tests/unit/providers-constants-split.test.ts | 15 +- tests/unit/ui/free-budget-card.test.tsx | 222 ++++++++++++++++++ tests/unit/ui/open-sse-alias.test.tsx | 27 +++ vitest.config.ts | 4 + vitest.mcp.config.ts | 4 + 23 files changed, 997 insertions(+), 140 deletions(-) create mode 100644 open-sse/config/providers/registry/aihorde/index.ts create mode 100644 open-sse/config/providers/registry/navy/index.ts create mode 100644 src/shared/utils/providerCredentialRequirement.ts delete mode 100644 tests/unit/free-budget-card.test.tsx create mode 100644 tests/unit/free-catalog-2026-07-expansion.test.ts create mode 100644 tests/unit/provider-credential-requirement.test.ts create mode 100644 tests/unit/ui/free-budget-card.test.tsx create mode 100644 tests/unit/ui/open-sse-alias.test.tsx diff --git a/config/quality/test-discovery-baseline.json b/config/quality/test-discovery-baseline.json index e6cb0d2a49..3f25f88bc2 100644 --- a/config/quality/test-discovery-baseline.json +++ b/config/quality/test-discovery-baseline.json @@ -41,7 +41,6 @@ "tests/unit/dashboard/batch/concept-cards.test.tsx", "tests/unit/dashboard/batch/list-regression.test.tsx", "tests/unit/dashboard/batch/sanitization.test.tsx", - "tests/unit/free-budget-card.test.tsx", "tests/unit/guardrails/visionBridgeRouter.test.tsx", "tests/unit/omni-skills-page.test.tsx", "tests/unit/shared-clipboard.test.tsx", diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index f1725e012e..0e3c97be11 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -1,4 +1,7 @@ // AUTO-GENERATED — refreshed by the 2026-06-17 per-provider free-tier research pass. +// 2026-07-20: added the free tiers of providers we could already route but had +// never mapped (requesty, ovhcloud, agnes, glm), plus two new providers (navy, +// aihorde), and reconciled kilo-gateway against its live /models list. // Source: _tasks/features-v3.8.28/free-tier-research-2026-06-17.raw.json (50-agent web research + adversarial verification). // Methodology: honest pool-deduped recurring tokens. "recurring-uncapped" = permanently free but no // published token cap (rate/concurrency-limited) — NOT summed into the steady headline (see freeModelCatalog.ts). @@ -6,6 +9,15 @@ // Do not edit by hand — re-run the patch generator to refresh. import type { FreeModelBudget } from "./freeModelCatalog.ts"; +/** + * Date this catalog was last curated against provider documentation. + * + * Deliberately a literal instead of the data file's mtime: a standalone build + * rewrites file timestamps on every deploy, which would report a months-old + * catalog as "updated today". Bump this whenever the entries below change. + */ +export const FREE_CATALOG_CURATED_AT = "2026-07-20"; + export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "agentrouter", modelId: "claude-opus-4-6", displayName: "Claude 4.6 Opus", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "claude-haiku-4-5-20251001", displayName: "Claude 4.5 Haiku", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, @@ -242,13 +254,19 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "inference-net", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-monthly", poolKey: "inference-net", tos: "caution" }, { provider: "inference-net", modelId: "deepseek-ai/DeepSeek-R1", displayName: "deepseek-ai/DeepSeek-R1", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-monthly", poolKey: "inference-net", tos: "caution" }, { provider: "inference-net", modelId: "Qwen/Qwen2.5-72B-Instruct", displayName: "Qwen/Qwen2.5-72B-Instruct", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-monthly", poolKey: "inference-net", tos: "caution" }, - { provider: "kilo-gateway", modelId: "kilo-auto/free", displayName: "Kilo Auto Free (auto-router)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, - { provider: "kilo-gateway", modelId: "stepfun/step-3.7-flash:free", displayName: "StepFun Step 3.7 Flash (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, - { provider: "kilo-gateway", modelId: "poolside/laguna-m.1:free", displayName: "Poolside Laguna M.1 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, - { provider: "kilo-gateway", modelId: "poolside/laguna-xs.2:free", displayName: "Poolside Laguna XS.2 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, - { provider: "kilo-gateway", modelId: "nvidia/nemotron-3-ultra-550b-a55b:free", displayName: "NVIDIA Nemotron 3 Ultra (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, - { provider: "kilo-gateway", modelId: "nvidia/nemotron-3-super-120b-a12b:free", displayName: "NVIDIA Nemotron 3 Super (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, - { provider: "kilo-gateway", modelId: "nex-agi/nex-n2-pro:free", displayName: "Nex-N2-Pro (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, + { provider: "kilo-gateway", modelId: "kilo-auto/free", displayName: "Kilo Auto Free (auto-router)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "tencent/hy3:free", displayName: "Tencent Hy3 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "stepfun/step-3.7-flash:free", displayName: "StepFun Step 3.7 Flash (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "openrouter/auto-beta", displayName: "OpenRouter Auto Router (beta)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "openrouter/free", displayName: "OpenRouter Free Models Router", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "poolside/laguna-xs-2.1:free", displayName: "Poolside Laguna XS 2.1 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "poolside/laguna-m.1:free", displayName: "Poolside Laguna M.1 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "cohere/north-mini-code:free", displayName: "Cohere North Mini Code (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "nvidia/nemotron-3-ultra-550b-a55b:free", displayName: "NVIDIA Nemotron 3 Ultra (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "nvidia/nemotron-3-super-120b-a12b:free", displayName: "NVIDIA Nemotron 3 Super (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", displayName: "NVIDIA Nemotron 3 Nano Omni (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "nvidia/nemotron-3.5-content-safety:free", displayName: "NVIDIA Nemotron 3.5 Content Safety (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, + { provider: "kilo-gateway", modelId: "kwaipilot/kat-coder-pro-v2.5:free", displayName: "Kwaipilot KAT-Coder-Pro V2.5 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution", trainsOnPrompts: true }, { provider: "kiro", modelId: "claude-sonnet-4.5", displayName: "Claude Sonnet 4.5", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "claude-haiku-4.5", displayName: "Claude Haiku 4.5", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "deepseek-3.2", displayName: "DeepSeek V3.2", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, @@ -469,4 +487,20 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "vertex", modelId: "GLM-5.1-FP8", displayName: "GLM-5.1 (Vertex Partner)", monthlyTokens: 0, creditTokens: 300000000, freeType: "one-time-initial", poolKey: "vertex", tos: "caution" }, { provider: "vertex", modelId: "claude-opus-4-7", displayName: "Claude Opus 4.7 (Vertex)", monthlyTokens: 0, creditTokens: 300000000, freeType: "one-time-initial", poolKey: "vertex", tos: "caution" }, { provider: "vertex", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 (Vertex)", monthlyTokens: 0, creditTokens: 300000000, freeType: "one-time-initial", poolKey: "vertex", tos: "caution" }, + { provider: "requesty", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B (Requesty free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "requesty-free", tos: "ok" }, + { provider: "requesty", modelId: "mistral/leanstral-1-5", displayName: "Leanstral 1.5 (Requesty free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "requesty-free", tos: "ok" }, + { provider: "requesty", modelId: "nvidia/nemotron-3-nano-30b-a3b", displayName: "Nemotron 3 Nano 30B (Requesty free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "requesty-free", tos: "ok" }, + { provider: "ovhcloud", modelId: "gpt-oss-120b", displayName: "GPT-OSS 120B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" }, + { provider: "ovhcloud", modelId: "gpt-oss-20b", displayName: "GPT-OSS 20B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" }, + { provider: "ovhcloud", modelId: "Qwen3.6-27B", displayName: "Qwen3.6 27B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" }, + { provider: "ovhcloud", modelId: "Mistral-Small-3.2-24B-Instruct-2506", displayName: "Mistral Small 3.2 24B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" }, + { provider: "ovhcloud", modelId: "Qwen2.5-VL-72B-Instruct", displayName: "Qwen2.5 VL 72B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" }, + { provider: "agnes", modelId: "agnes-2.0-flash", displayName: "Agnes 2.0 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" }, + { provider: "agnes", modelId: "agnes-1.5-flash", displayName: "Agnes 1.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" }, + { provider: "glm", modelId: "glm-4.7-flash", displayName: "GLM-4.7-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, + { provider: "glm", modelId: "glm-4.5-flash", displayName: "GLM-4.5-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, + { provider: "navy", modelId: "shared-pool", displayName: "NavyAI free pool (150K tokens/day, shared)", monthlyTokens: 4500000, creditTokens: 0, freeType: "recurring-daily", poolKey: "navy-free", tos: "ok" }, + { provider: "aihorde", modelId: "aphrodite/TheDrummer/Cydonia-24B-v4.3", displayName: "Cydonia 24B (AI Horde)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "aihorde-anon", tos: "ok" }, + { provider: "aihorde", modelId: "aphrodite/TheDrummer/Skyfall-31B-v4.2", displayName: "Skyfall 31B (AI Horde)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "aihorde-anon", tos: "ok" }, + { provider: "aihorde", modelId: "google/gemma-4-31b", displayName: "Gemma 4 31B (AI Horde)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "aihorde-anon", tos: "ok" }, ]; diff --git a/open-sse/config/freeModelCatalog.ts b/open-sse/config/freeModelCatalog.ts index 6e4dfe549f..af8b9b0d96 100644 --- a/open-sse/config/freeModelCatalog.ts +++ b/open-sse/config/freeModelCatalog.ts @@ -20,6 +20,12 @@ export interface FreeModelBudget { freeType: FreeModelFreeType; poolKey: string | null; tos: TosVerdict; + /** + * Provider states it may train on user prompts. Surfaced in the UI so the + * privacy cost of a "free" tier is visible next to the quota. Kilo's gateway + * reports this per model as `mayTrainOnYourPrompts` on its public catalog. + */ + trainsOnPrompts?: boolean; } export interface FreeModelTotals { diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index fba6e696b0..7bbc71969b 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -125,6 +125,7 @@ import { liquidProvider } from "./registry/liquid/index.ts"; import { deepinfraProvider } from "./registry/deepinfra/index.ts"; import { agyProvider } from "./registry/agy/index.ts"; import { agnesProvider } from "./registry/agnes/index.ts"; +import { aihordeProvider } from "./registry/aihorde/index.ts"; import { udioProvider } from "./registry/udio/index.ts"; import { longcatProvider } from "./registry/longcat/index.ts"; import { vertex_partnerProvider } from "./registry/vertex/partner/index.ts"; @@ -196,6 +197,7 @@ import { zenmux_freeProvider } from "./registry/zenmux-free/index.ts"; import { sumopodProvider } from "./registry/sumopod/index.ts"; import { x5labProvider } from "./registry/x5lab/index.ts"; import { kenariProvider } from "./registry/kenari/index.ts"; +import { navyProvider } from "./registry/navy/index.ts"; import { requestyProvider } from "./registry/requesty/index.ts"; import { digitaloceanProvider } from "./registry/digitalocean/index.ts"; import { hcnsecProvider } from "./registry/hcnsec/index.ts"; @@ -324,6 +326,7 @@ export const REGISTRY: Record = { deepinfra: deepinfraProvider, agy: agyProvider, agnes: agnesProvider, + aihorde: aihordeProvider, udio: udioProvider, longcat: longcatProvider, "vertex-partner": vertex_partnerProvider, @@ -397,6 +400,7 @@ export const REGISTRY: Record = { sumopod: sumopodProvider, x5lab: x5labProvider, kenari: kenariProvider, + navy: navyProvider, requesty: requestyProvider, digitalocean: digitaloceanProvider, hcnsec: hcnsecProvider, diff --git a/open-sse/config/providers/registry/aihorde/index.ts b/open-sse/config/providers/registry/aihorde/index.ts new file mode 100644 index 0000000000..6d0b56d9c9 --- /dev/null +++ b/open-sse/config/providers/registry/aihorde/index.ts @@ -0,0 +1,54 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +/** + * AI Horde — crowdsourced inference from volunteer GPU workers (aihorde.net), + * reached through its OpenAI-compatible facade at oai.aihorde.net. + * + * Keyless: the literal `0000000000` is AI Horde's documented anonymous key, so + * it is wired through `anonymousApiKey` (the same hook Kilo uses, #4019). A real + * account key still works and buys higher queue priority via kudos. + * + * Three things make it unlike every other OpenAI-compatible provider: + * - Requests sit in a shared volunteer queue, so latency is minutes, not + * seconds — hence the 120s timeout instead of the default. + * - No tool calling: the workers run raw text-completion backends. + * - Throughput is NOT a quota. It depends on how many workers are online, so + * the free catalog registers it as `recurring-uncapped` (never summed into + * the token headline) rather than inventing an RPM/RPD figure. + * + * Model list changes as workers come and go, so the live catalog is fetched via + * passthrough; the entries below are the ones that have carried steady worker + * threads and only serve as a fallback when discovery fails. + */ +export const aihordeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "aihorde", + baseUrl: "https://oai.aihorde.net/v1/chat/completions", + modelsUrl: "https://oai.aihorde.net/v1/models", + passthroughModels: true, + anonymousApiKey: "0000000000", + timeoutMs: 120_000, + models: [ + { + id: "aphrodite/TheDrummer/Cydonia-24B-v4.3", + name: "Cydonia 24B (AI Horde)", + contextLength: 32768, + toolCalling: false, + unsupportedParams: ["tools", "tool_choice", "parallel_tool_calls"], + }, + { + id: "aphrodite/TheDrummer/Skyfall-31B-v4.2", + name: "Skyfall 31B (AI Horde)", + contextLength: 32768, + toolCalling: false, + unsupportedParams: ["tools", "tool_choice", "parallel_tool_calls"], + }, + { + id: "google/gemma-4-31b", + name: "Gemma 4 31B (AI Horde)", + contextLength: 32768, + toolCalling: false, + unsupportedParams: ["tools", "tool_choice", "parallel_tool_calls"], + }, + ], +}); diff --git a/open-sse/config/providers/registry/liquid/index.ts b/open-sse/config/providers/registry/liquid/index.ts index 0fbe80b3c6..2ab17cbdca 100644 --- a/open-sse/config/providers/registry/liquid/index.ts +++ b/open-sse/config/providers/registry/liquid/index.ts @@ -1,11 +1,21 @@ import type { RegistryEntry } from "../../shared.ts"; +/** + * Liquid AI. + * + * The old api.liquid.ai host stopped serving the API — it now returns a Vercel + * 404 HTML page for every path, so routing here failed with an unparseable body + * rather than a clean error. The live OpenAI-compatible endpoint is + * inference.liquid.ai, which answers 403 {"detail":"Not authenticated"} without + * a key (both verified 2026-07-20). + */ export const liquidProvider: RegistryEntry = { id: "liquid", alias: "liquid", format: "openai", executor: "default", - baseUrl: "https://api.liquid.ai/v1/chat/completions", + baseUrl: "https://inference.liquid.ai/v1/chat/completions", + modelsUrl: "https://inference.liquid.ai/v1/models", authType: "apikey", authHeader: "bearer", models: [{ id: "liquid-lfm-40b", name: "Liquid LFM 40B" }], diff --git a/open-sse/config/providers/registry/navy/index.ts b/open-sse/config/providers/registry/navy/index.ts new file mode 100644 index 0000000000..605feb37e9 --- /dev/null +++ b/open-sse/config/providers/registry/navy/index.ts @@ -0,0 +1,30 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +/** + * NavyAI — OpenAI-compatible aggregator (api.navy). + * + * Free plan is ONE shared pool of 150K tokens/day at 20 RPM, drained by a + * per-model `token_multiplier` that the upstream /v1/models exposes: a 1x model + * spends the full 150K, while `grok-4` (10x) is capped at ~15K real tokens/day. + * That is why the free catalog registers a single shared `navy` pool instead of + * a budget per model — see freeModelCatalog.data.ts. + * + * Upstream rejects requests without an explicit User-Agent, so one is pinned + * here (same reason routeway needs it). + */ +export const navyProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "navy", + baseUrl: "https://api.navy/v1/chat/completions", + modelsUrl: "https://api.navy/v1/models", + passthroughModels: true, + extraHeaders: { "User-Agent": "OmniRoute/1.0" }, + models: [ + { id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B Instruct", contextLength: 131072, toolCalling: true }, + { id: "gemma-4-31b-it", name: "Gemma 4 31B IT", contextLength: 262144, toolCalling: true, supportsVision: true, supportsReasoning: true }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", contextLength: 1048576, toolCalling: true, supportsReasoning: true }, + { id: "deepseek-chat", name: "DeepSeek Chat", contextLength: 131072, toolCalling: true }, + { id: "mistral-small-latest", name: "Mistral Small", contextLength: 262144, toolCalling: true, supportsVision: true, supportsReasoning: true }, + { id: "llama-4-scout", name: "Llama 4 Scout", contextLength: 10000000, toolCalling: true, supportsVision: true }, + ], +}); diff --git a/open-sse/config/providers/registry/ovhcloud/index.ts b/open-sse/config/providers/registry/ovhcloud/index.ts index 2c60d08d9c..78782c7e4a 100644 --- a/open-sse/config/providers/registry/ovhcloud/index.ts +++ b/open-sse/config/providers/registry/ovhcloud/index.ts @@ -1,13 +1,24 @@ import type { RegistryEntry } from "../../shared.ts"; import { CHAT_OPENAI_COMPAT_MODELS } from "../../shared.ts"; +/** + * OVHcloud AI Endpoints. + * + * The key is genuinely optional: the anonymous tier answers /chat/completions + * with no Authorization header at all (2 req/min per IP per model, verified + * live 2026-07-20), while an OVHcloud key raises that to 400 req/min. Sending a + * BAD key is worse than sending none — upstream replies 403 instead of falling + * back — so authType stays "optional" and the executor only attaches the header + * when a real credential exists. + */ export const ovhcloudProvider: RegistryEntry = { id: "ovhcloud", alias: "ovh", format: "openai", executor: "default", baseUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/chat/completions", - authType: "apikey", + modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models", + authType: "optional", authHeader: "bearer", models: CHAT_OPENAI_COMPAT_MODELS.ovhcloud, }; diff --git a/open-sse/config/providers/registry/pollinations/index.ts b/open-sse/config/providers/registry/pollinations/index.ts index 76d0314b9b..6d7e384da5 100644 --- a/open-sse/config/providers/registry/pollinations/index.ts +++ b/open-sse/config/providers/registry/pollinations/index.ts @@ -1,5 +1,10 @@ import type { RegistryEntry } from "../../shared.ts"; +/** + * Pollinations answers /chat/completions with no credential at all (verified + * live 2026-07-20: HTTP 200 with real choices). A token is still accepted and + * lifts the anonymous rate limit, so the key is optional rather than required. + */ export const pollinationsProvider: RegistryEntry = { id: "pollinations", alias: "pol", @@ -13,7 +18,7 @@ export const pollinationsProvider: RegistryEntry = { // NOTE (2026-06): Pollinations now requires API keys for premium models (claude, gemini, midijourney). // Free keyless models: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. // Get a key at https://enter.pollinations.ai - authType: "apikey", + authType: "optional", authHeader: "bearer", models: [ { id: "openai", name: "OpenAI (Pollinations)" }, diff --git a/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx b/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx index 4163bcfe1d..e879a04902 100644 --- a/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import React from "react"; +import { matchesSearch } from "@/shared/utils/turkishText"; // ──────────────────────────────────────────────────────────────────────────── // Types @@ -32,6 +33,15 @@ export interface FreeBudgetData { /** Providers that are permanently free but publish no token cap (rate/concurrency-limited). */ uncappedProviders?: string[]; headline?: string; + /** ISO timestamp of the last catalog update. Absent/null → freshness is not shown. */ + catalogUpdatedAt?: string | null; + /** + * Providers callable with nothing configured, derived server-side from real + * routing behaviour (see shared/utils/providerCredentialRequirement). NOT the + * same as freeType: "keyless", which only means "not quantifiable in tokens" + * — several of those reject anonymous calls with 401/403. + */ + noCredentialProviders?: string[]; } export type FreeBudgetSort = "tokens" | "name" | "provider"; @@ -47,6 +57,27 @@ function fmt(n: number): string { return String(n); } +/** + * Compact "N unit(s) ago" formatting for the catalog freshness indicator. + * Returns null on unparsable input so callers can degrade to "show nothing". + */ +export function relativeTimeFromNow(iso: string, now: number = Date.now()): string | null { + const ts = Date.parse(iso); + if (Number.isNaN(ts)) return null; + const diffMs = now - ts; + if (diffMs < 60_000) return "just now"; + const min = Math.floor(diffMs / 60_000); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + if (day < 30) return `${day}d ago`; + const month = Math.floor(day / 30); + if (month < 12) return `${month}mo ago`; + const year = Math.floor(month / 12); + return `${year}y ago`; +} + const FREE_TYPE_LABEL: Record = { "recurring-daily": "daily", "recurring-monthly": "monthly", @@ -134,6 +165,35 @@ function sortRows(rows: FreeBudgetPerModel[], sort: FreeBudgetSort): FreeBudgetP return copy.sort((a, b) => b.monthlyTokens - a.monthlyTokens || b.creditTokens - a.creditTokens); } +/** + * Substring filter across displayName / modelId / provider (case-insensitive). + * Empty/whitespace-only query is a no-op (returns all rows). + */ +function filterRows( + rows: FreeBudgetPerModel[], + { + search, + providerFilter, + keylessOnly, + noCredentialProviders, + }: { + search: string; + providerFilter: string; + keylessOnly: boolean; + noCredentialProviders: string[]; + } +): FreeBudgetPerModel[] { + let out = rows; + if (keylessOnly) out = out.filter((m) => noCredentialProviders.includes(m.provider)); + if (providerFilter !== "all") out = out.filter((m) => m.provider === providerFilter); + if (search.trim()) { + out = out.filter( + (m) => matchesSearch(m.displayName, search) || matchesSearch(m.modelId, search) || matchesSearch(m.provider, search) + ); + } + return out; +} + function tosBadge(tos: string): { icon: string; cls: string; title: string } | null { if (tos === "avoid") return { icon: "warning", cls: "text-amber-400", title: "ToS-restricted — review terms" }; if (tos === "caution") return { icon: "bolt", cls: "text-text-muted", title: "Caution — personal-use / proxy clauses" }; @@ -154,6 +214,28 @@ function Kpi({ label, value, valueClass }: { label: string; value: string; value ); } +// ──────────────────────────────────────────────────────────────────────────── +// Free-type badge (keyless gets an emerald highlight; the rest stay neutral) +// ──────────────────────────────────────────────────────────────────────────── + +function FreeTypeBadge({ freeType }: { freeType: string }) { + const label = FREE_TYPE_LABEL[freeType] ?? freeType; + const isKeyless = freeType === "keyless"; + return ( + + {isKeyless && lock_open} + {label} + + ); +} + // ──────────────────────────────────────────────────────────────────────────── // Pure view (SSR-testable — no hooks). Sort/filter are controlled via props. // ──────────────────────────────────────────────────────────────────────────── @@ -162,10 +244,16 @@ export function FreeBudgetView({ data, sort = "tokens", hideAvoid = false, + search = "", + providerFilter = "all", + keylessOnly = false, }: { data: FreeBudgetData; sort?: FreeBudgetSort; hideAvoid?: boolean; + search?: string; + providerFilter?: string; + keylessOnly?: boolean; }) { const { steadyRecurringTokens, @@ -175,6 +263,8 @@ export function FreeBudgetView({ perModel, boostMonthlyTokens = 0, uncappedProviders = [], + catalogUpdatedAt, + noCredentialProviders = [], } = data; const pct = steadyRecurringTokens > 0 ? Math.round((remaining / steadyRecurringTokens) * 100) : 0; @@ -184,17 +274,38 @@ export function FreeBudgetView({ const totalBarTokens = barSegments.reduce((s, seg) => s + seg.tokens, 0); const providerColor = colorForProvider(perModel); - // Table rows: only entries with real budget; optional hide-ToS-avoid; sorted. + // "No API key required" — derived from routing behaviour, NOT from + // freeType: "keyless". That field means "free access not quantifiable in + // tokens"; probing the endpoints showed several of those rows (blackbox, + // puter, iflytek, sparkdesk, friendliai, muse-spark-web) answering 401/403 + // with no credential. Listing them here would invite users to call providers + // that reject them. + const keylessModels = perModel.filter((m) => noCredentialProviders.includes(m.provider)); + const keylessProviders = Array.from(new Set(keylessModels.map((m) => m.provider))).sort(); + + // Table rows: only entries with real budget; hide-ToS-avoid + search + provider + keyless filters; sorted. let rows = perModel.filter((m) => m.monthlyTokens > 0 || m.creditTokens > 0); if (hideAvoid) rows = rows.filter((m) => m.tos !== "avoid"); + rows = filterRows(rows, { search, providerFilter, keylessOnly, noCredentialProviders }); rows = sortRows(rows, sort); + const freshness = catalogUpdatedAt ? relativeTimeFromNow(catalogUpdatedAt) : null; + return (
{/* Header */}
savings Free-token budget + {freshness && ( + + · updated {freshness} + + )} {fmt(remaining)} remaining · {pct}% of {fmt(steadyRecurringTokens)} @@ -229,6 +340,34 @@ export function FreeBudgetView({
)} + {/* "No API key required" — highlighted overview of keyless providers */} + {keylessProviders.length > 0 && ( +
+
+ lock_open + No API key required + + ({keylessModels.length} model{keylessModels.length !== 1 ? "s" : ""} · {keylessProviders.length}{" "} + provider{keylessProviders.length !== 1 ? "s" : ""}) + +
+
+ {keylessProviders.map((p) => ( + + {p} + + ))} +
+
+ )} + {/* Boost + uncapped callouts */} {boostMonthlyTokens > 0 && (
@@ -281,6 +420,13 @@ export function FreeBudgetView({ + {rows.length === 0 && ( + + + No models match the current filters. + + + )} {rows.map((m) => { const badge = tosBadge(m.tos); const amount = @@ -306,7 +452,9 @@ export function FreeBudgetView({ {m.displayName} - {FREE_TYPE_LABEL[m.freeType] ?? m.freeType} + + + {amount} {badge && ( @@ -337,6 +485,9 @@ export default function FreeBudgetCard() { const [data, setData] = useState(null); const [sort, setSort] = useState("tokens"); const [hideAvoid, setHideAvoid] = useState(false); + const [search, setSearch] = useState(""); + const [providerFilter, setProviderFilter] = useState("all"); + const [keylessOnly, setKeylessOnly] = useState(false); useEffect(() => { fetch("/api/free-tier/summary") @@ -349,12 +500,50 @@ export default function FreeBudgetCard() { }); }, []); + const providers = useMemo(() => { + if (!data) return []; + return Array.from(new Set(data.perModel.map((m) => m.provider))).sort(); + }, [data]); + if (!data) return null; return (
{/* Controls */} -
+
+ setSearch(e.target.value)} + placeholder="Search model, provider…" + aria-label="Search free models" + data-testid="budget-search-input" + className="rounded border border-border bg-surface px-2 py-1 text-[11px] text-text-main placeholder:text-text-muted min-w-[160px]" + /> + +
- +
); } diff --git a/src/app/api/free-tier/summary/route.ts b/src/app/api/free-tier/summary/route.ts index d497629d47..5a34b5dee4 100644 --- a/src/app/api/free-tier/summary/route.ts +++ b/src/app/api/free-tier/summary/route.ts @@ -1,4 +1,6 @@ import { computeFreeModelTotals } from "@omniroute/open-sse/config/freeModelCatalog.ts"; +import { FREE_CATALOG_CURATED_AT } from "@omniroute/open-sse/config/freeModelCatalog.data.ts"; +import { listNoCredentialProviders } from "@/shared/utils/providerCredentialRequirement"; import { sumUsageTokensThisMonth } from "@/lib/db/usageSummary"; const CORS = { @@ -20,6 +22,10 @@ export async function GET(req: Request): Promise { ...totals, usedThisMonth, remaining: Math.max(0, totals.steadyRecurringTokens - usedThisMonth), + catalogUpdatedAt: FREE_CATALOG_CURATED_AT, + // Computed here, not in the component: deriving it client-side would pull + // the whole provider REGISTRY into the browser bundle. + noCredentialProviders: listNoCredentialProviders(), }; return new Response(JSON.stringify(body), { status: 200, diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 9ed7f51427..8c5e4c4c01 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -713,4 +713,23 @@ export const APIKEY_PROVIDERS_GATEWAYS = { apiHint: "Kenari exposes an OpenAI-compatible chat completions endpoint at https://kenari.id/v1/chat/completions, plus a live /v1/models catalog covering Claude, GPT, DeepSeek, GLM, Kimi and more. OmniRoute uses the OpenAI protocol and lists models via passthrough.", }, + navy: { + id: "navy", + alias: "navy", + name: "NavyAI", + icon: "hub", + color: "#1E3A8A", + textIcon: "NV", + passthroughModels: true, + website: "https://api.navy", + hasFree: true, + freeNote: + "Free plan is one shared 150K tokens/day pool at 20 RPM. Each model carries a " + + "token multiplier, so heavier models drain the pool faster (grok-4 at 10x is ~15K real tokens/day).", + authHint: + "Create a free API key from the NavyAI dashboard, then paste it here as a Bearer token.", + apiHint: + "OpenAI-compatible endpoint at https://api.navy/v1 with a live /v1/models catalog that exposes " + + "per-model token_multiplier and premium flags. Upstream requires an explicit User-Agent header.", + }, }; diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index 2b754a30d9..e7c0dda99e 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -139,6 +139,26 @@ export const NOAUTH_PROVIDERS = { text: "Augment (Auggie CLI) requires the `auggie` binary installed and authenticated locally (`auggie login`). OmniRoute spawns it as a subprocess and never sees or stores your Augment credentials.", }, }, + aihorde: { + id: "aihorde", + alias: "horde", + name: "AI Horde", + icon: "diversity_3", + color: "#8B5CF6", + textIcon: "AH", + website: "https://aihorde.net", + noAuth: true, + hasFree: true, + passthroughModels: true, + serviceKinds: ["llm"], + authHint: + "No API key required — uses AI Horde's documented anonymous key. Adding a free aihorde.net key is optional and only buys higher queue priority (kudos).", + freeNote: + "Crowdsourced inference from volunteer GPUs. Throughput is a shared queue, not a quota: there is no RPM/RPD cap, but waits grow when the network is busy.", + notice: { + text: "AI Horde routes to volunteer-run workers, so responses can take minutes and tool calling is unavailable. Model availability changes as workers come and go.", + }, + }, }; // Provider-level proxy controls are exposed only for transports whose complete diff --git a/src/shared/utils/providerCredentialRequirement.ts b/src/shared/utils/providerCredentialRequirement.ts new file mode 100644 index 0000000000..e35fb2c345 --- /dev/null +++ b/src/shared/utils/providerCredentialRequirement.ts @@ -0,0 +1,120 @@ +/** + * Single answer to "does this provider need a credential?". + * + * Two different questions had been collapsed into the word "keyless": + * + * 1. **Is the free access quantifiable in tokens?** That is what + * `FreeModelBudget.freeType === "keyless"` means — it sits next to `oauth` + * in the docs as "not token-quantifiable", which is why those rows never + * reach the headline. It says nothing about credentials. + * 2. **Can the user call it with nothing configured?** That is this module. + * + * Reading (1) as (2) is not academic: probing the endpoints on 2026-07-20 showed + * blackbox, friendliai, iflytek, sparkdesk and puter answering 401, and + * muse-spark-web 403, with no credential — yet all are `freeType: "keyless"`. + * A UI section built on (1) would invite users to call providers that reject + * them. So anything user-facing about API keys must come from here. + * + * The answer is derived from the two registries that describe real behaviour — + * `NOAUTH_PROVIDERS` (does the connect form ask for a key?) and `RegistryEntry` + * (what does the executor actually send?) — so there is no new list to keep in + * sync: registering a provider the usual way is enough. + */ +import { NOAUTH_PROVIDERS } from "../constants/providers/noauth.ts"; +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; + +export type CredentialRequirement = + /** Never needs a credential — the connect form does not even ask for one. */ + | "none" + /** Works anonymously; a credential is accepted and usually raises the limits. */ + | "optional" + /** No API key to paste, but the user still signs in (OAuth/device flow). */ + | "oauth" + /** Unusable without a credential (API key, cookie or session token). */ + | "required"; + +/** True when the user can call the provider with nothing configured. */ +export function worksWithoutCredential(req: CredentialRequirement): boolean { + return req === "none" || req === "optional"; +} + +export function getCredentialRequirement(providerId: string): CredentialRequirement { + const entry = REGISTRY[providerId]; + + // Checked before `noAuth`: a literal anonymous token means the executor can + // call upstream with no user credential (Kilo's `anonymous`, AI Horde's + // `0000000000`) *and* that a real key is still honoured — AI Horde trades one + // for higher queue priority. That is "optional", not "none", even though the + // connect form hides the field. + if (entry?.anonymousApiKey) return "optional"; + + const noAuth = (NOAUTH_PROVIDERS as Record)[providerId]; + if (noAuth?.noAuth === true) return "none"; + + if (!entry) return "required"; + if (entry.authType === "none") return "none"; + if (entry.authType === "optional") return "optional"; + if (entry.authType === "oauth") return "oauth"; + return "required"; +} + +/** + * Every provider usable with nothing configured, sorted for stable output. + * This — not `freeType === "keyless"` — is what a "no API key required" list + * must be built from. + */ +export function listNoCredentialProviders(): string[] { + const ids = new Set([...Object.keys(NOAUTH_PROVIDERS), ...Object.keys(REGISTRY)]); + return [...ids].filter((id) => worksWithoutCredential(getCredentialRequirement(id))).sort(); +} + +/** + * Providers whose catalog rows are `freeType: "keyless"` while routing still + * needs a credential. + * + * This is NOT a bug list — the two fields answer different questions (see the + * module header). It exists so the mismatch stays visible and measured: each of + * these is a provider whose free access is real but reached through a web/session + * path, and each was confirmed by probing the endpoint. Kept frozen so a genuine + * new mistake still trips the gate. + */ +export const NOT_TOKEN_QUANTIFIABLE_BUT_CREDENTIALED: readonly string[] = [ + "agy", // OAuth sign-in; nothing to paste, but still an account + "blackbox", // probed 2026-07-20 -> 401 "No api key passed in" + "friendliai", // probed -> 401 "no authorization info provided" + "iflytek", // probed -> 401 Unauthorized + "liquid", // probed -> 404: endpoint moved; config needs a separate audit + "muse-spark-web", // probed -> 403; authHeader is a session cookie, not a key + "puter", // probed -> 401 "Missing authentication token" + "qwen-web", // probed -> 200 but serves the WAF HTML page, not the API + "sparkdesk", // probed -> 401 Unauthorized +]; + +export interface KeylessConsistencyReport { + /** Catalog says keyless, routing needs a credential, and it is not recorded. */ + unexpected: string[]; + /** Recorded entries that now work without a credential — drop them. */ + stale: string[]; +} + +/** + * Compare the catalog's `keyless` rows against real routing behaviour. + * Pure function: callers pass the catalog so tests and gates can reuse it. + */ +export function checkKeylessCatalogConsistency( + catalog: readonly { provider: string; freeType: string }[] +): KeylessConsistencyReport { + const labelledKeyless = [ + ...new Set(catalog.filter((m) => m.freeType === "keyless").map((m) => m.provider)), + ].sort(); + + const credentialed = labelledKeyless.filter( + (id) => !worksWithoutCredential(getCredentialRequirement(id)) + ); + + const recorded = new Set(NOT_TOKEN_QUANTIFIABLE_BUT_CREDENTIALED); + return { + unexpected: credentialed.filter((id) => !recorded.has(id)), + stale: [...recorded].filter((id) => !credentialed.includes(id)).sort(), + }; +} diff --git a/tests/unit/free-budget-card.test.tsx b/tests/unit/free-budget-card.test.tsx deleted file mode 100644 index 1fb1f13a87..0000000000 --- a/tests/unit/free-budget-card.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { renderToStaticMarkup } from "react-dom/server"; -import React from "react"; -import { FreeBudgetView } from "../../src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx"; - -const data = { - steadyRecurringTokens: 1_940_000_000, - steadyWithRecurringCreditsTokens: 1_941_000_000, - firstMonthRealisticTokens: 2_530_000_000, - usedThisMonth: 40_000_000, - remaining: 1_900_000_000, - modelCount: 530, - poolCount: 50, - perModel: [ - { provider: "mistral", modelId: "mistral-large", displayName: "Mistral Large", monthlyTokens: 1_000_000_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, - { provider: "kiro", modelId: "kiro", displayName: "Kiro", monthlyTokens: 25_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, - ], -}; - -test("FreeBudgetView renders steady total, remaining, first-month, per-model rows, and ToS-restricted count", () => { - const html = renderToStaticMarkup(React.createElement(FreeBudgetView, { data })); - assert.match(html, /1\.94B/); // steady - assert.match(html, /2\.53B/); // first-month - assert.match(html, /remaining/i); - assert.match(html, /Mistral Large/); - assert.match(html, /1 .*(ToS|restricted)/i); // 1 avoid-flagged model called out -}); - -// Pool-dedup: two models in the same pool → only ONE bar segment for that pool -test("FreeBudgetView bar is pool-deduped: two models sharing a poolKey produce one bar segment", () => { - const sharedPoolData = { - steadyRecurringTokens: 1_000_000_000, - steadyWithRecurringCreditsTokens: 1_000_000_000, - firstMonthRealisticTokens: 1_200_000_000, - usedThisMonth: 0, - remaining: 1_000_000_000, - modelCount: 3, - poolCount: 1, - perModel: [ - // Two models in the same pool — should produce only 1 bar segment - { provider: "gemini", modelId: "gemini-flash", displayName: "Gemini Flash", monthlyTokens: 1_000_000_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "gemini-pool", tos: "ok" }, - { provider: "gemini", modelId: "gemini-pro", displayName: "Gemini Pro", monthlyTokens: 500_000_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "gemini-pool", tos: "ok" }, - // One standalone model (poolKey null) - { provider: "openai", modelId: "gpt-free", displayName: "GPT Free", monthlyTokens: 200_000_000, creditTokens: 0, freeType: "keyless", poolKey: null, tos: "ok" }, - ], - }; - - const html = renderToStaticMarkup(React.createElement(FreeBudgetView, { data: sharedPoolData })); - - // Count bar segment divs by data-testid attribute - const segmentMatches = html.match(/data-testid="bar-segment"/g); - const segmentCount = segmentMatches ? segmentMatches.length : 0; - - // 1 pool-segment (gemini-pool) + 1 loose segment (openai) = 2 total, NOT 3 - assert.equal(segmentCount, 2, `Expected 2 pool-deduped bar segments, got ${segmentCount}`); - - // Table should show all 3 models (informational, per-model not pool-deduped) - assert.match(html, /Gemini Flash/); - assert.match(html, /Gemini Pro/); - assert.match(html, /GPT Free/); -}); - -const layoutData = { - steadyRecurringTokens: 1_540_000_000, - steadyWithRecurringCreditsTokens: 1_540_000_000, - firstMonthRealisticTokens: 2_150_000_000, - usedThisMonth: 12_000_000, - remaining: 1_528_000_000, - modelCount: 4, - poolCount: 3, - boostMonthlyTokens: 24_000_000, - uncappedProviders: ["glm-cn", "kilo-gateway", "siliconflow"], - perModel: [ - { provider: "mistral", modelId: "mistral-small", displayName: "Mistral Small 4", monthlyTokens: 1_000_000_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, - { provider: "llm7", modelId: "llm7", displayName: "LLM7 pool", monthlyTokens: 150_000_000, creditTokens: 0, freeType: "recurring-daily", poolKey: "llm7", tos: "caution" }, - { provider: "kiro", modelId: "kiro", displayName: "Kiro Auto", monthlyTokens: 25_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, - { provider: "together", modelId: "together-signup", displayName: "Together credit", monthlyTokens: 0, creditTokens: 25_000_000, freeType: "one-time-initial", poolKey: "together-signup", tos: "caution" }, - ], -}; - -test("Layout A renders KPI tiles, a per-model table, the boost callout and uncapped chips", () => { - const html = renderToStaticMarkup(React.createElement(FreeBudgetView, { data: layoutData })); - // KPI tiles - assert.match(html, /Steady \/ month/); - assert.match(html, /First month/); - assert.match(html, /Used this month/); - // per-model table present with the model rows - assert.match(html, /data-testid="budget-table"/); - assert.match(html, /Mistral Small 4/); - assert.match(html, /Together credit/); - assert.match(html, /25M credit/); // one-time credit rendered as credit, not steady - // deposit-unlock boost surfaced separately - assert.match(html, /Unlock ~24M more/); - // uncapped providers shown as chips, not summed - assert.match(html, /no published cap/i); - assert.match(html, /siliconflow/); - assert.match(html, /kilo-gateway/); -}); - -// Scope assertions to the table (bar-segment tooltips also contain model names and render first) -const tableOf = (h: string) => h.slice(h.indexOf('data-testid="budget-table"')); - -test("hideAvoid drops ToS-restricted rows from the table but keeps the count callout", () => { - const shown = renderToStaticMarkup(React.createElement(FreeBudgetView, { data: layoutData, hideAvoid: false })); - assert.match(tableOf(shown), /Kiro Auto/); - const hidden = renderToStaticMarkup(React.createElement(FreeBudgetView, { data: layoutData, hideAvoid: true })); - assert.doesNotMatch(tableOf(hidden), /Kiro Auto/); - // the 1-model ToS-restricted callout still reflects the underlying data - assert.match(hidden, /1 model.*ToS-restricted/i); -}); - -test("sort=name orders the table rows alphabetically by display name", () => { - const t = tableOf(renderToStaticMarkup(React.createElement(FreeBudgetView, { data: layoutData, sort: "name" }))); - // Kiro Auto should appear before Mistral Small 4 in the table body when sorted by name - assert.ok(t.indexOf("Kiro Auto") < t.indexOf("Mistral Small 4"), "table rows not sorted by name"); -}); diff --git a/tests/unit/free-catalog-2026-07-expansion.test.ts b/tests/unit/free-catalog-2026-07-expansion.test.ts new file mode 100644 index 0000000000..f3dc29bb7d --- /dev/null +++ b/tests/unit/free-catalog-2026-07-expansion.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + FREE_MODEL_BUDGETS, + computeFreeModelTotals, +} from "@omniroute/open-sse/config/freeModelCatalog.ts"; +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { AI_PROVIDERS } from "@/shared/constants/providers.ts"; + +/** + * 2026-07 expansion: providers whose free tier was documented upstream but never + * mapped here. Each assertion pins a decision that is easy to silently undo. + */ + +const byProvider = (id: string) => FREE_MODEL_BUDGETS.filter((m) => m.provider === id); + +test("navy is registered as ONE shared pool, never per-model", () => { + const rows = byProvider("navy"); + assert.equal(rows.length, 1, "navy must stay a single pooled row"); + + const [pool] = rows; + // Upstream free plan: 150K tokens/day => 4.5M/month, drained by a per-model + // token_multiplier. Summing the ~149 free models would inflate this ~149x — + // exactly the overcounting we refuse to copy. + assert.equal(pool.monthlyTokens, 4_500_000); + assert.equal(pool.freeType, "recurring-daily"); + assert.equal(pool.poolKey, "navy-free"); +}); + +test("ovhcloud and aihorde are keyless (no API key required)", () => { + for (const id of ["ovhcloud", "aihorde"]) { + const rows = byProvider(id); + assert.ok(rows.length > 0, `${id} must be in the free catalog`); + assert.ok( + rows.every((m) => m.freeType === "keyless"), + `${id} free access needs no API key, so every row must be keyless` + ); + } +}); + +test("providers without a published TOKEN quota never inflate the headline", () => { + // These have a real free tier capped in REQUESTS (or a queue), not tokens. + // Registering a guessed token figure is how a catalog starts lying. + for (const id of ["requesty", "agnes", "glm"]) { + const rows = byProvider(id); + assert.ok(rows.length > 0, `${id} must be in the free catalog`); + assert.ok( + rows.every((m) => m.monthlyTokens === 0 && m.creditTokens === 0), + `${id} has no published token quota — it must not carry invented numbers` + ); + assert.ok(rows.every((m) => m.freeType === "recurring-uncapped")); + } + + const totals = computeFreeModelTotals(); + for (const id of ["requesty", "agnes", "glm"]) { + assert.ok( + totals.uncappedProviders.includes(id), + `${id} must still be surfaced as an uncapped provider` + ); + } +}); + +test("kilo free models carry the train-on-prompts warning", () => { + const rows = byProvider("kilo-gateway"); + assert.ok(rows.length >= 13, "kilo catalog should track the live free list"); + // Kilo's public catalog reports mayTrainOnYourPrompts: true on every free + // model. The privacy cost belongs next to the quota, not hidden. + assert.ok( + rows.every((m) => m.trainsOnPrompts === true), + "every kilo free model must be flagged as training on prompts" + ); +}); + +test("new providers are routable and canonically registered", () => { + for (const id of ["navy", "aihorde"]) { + assert.ok(REGISTRY[id], `${id} must exist in the execution REGISTRY`); + assert.ok(AI_PROVIDERS[id], `${id} must exist as a canonical provider`); + } + // aihorde reaches the volunteer queue with its documented anonymous key. + assert.equal(REGISTRY.aihorde.anonymousApiKey, "0000000000"); + // The volunteer queue is minutes-slow; the default timeout would abort it. + assert.equal(REGISTRY.aihorde.timeoutMs, 120_000); +}); + +test("every catalog provider id resolves to a canonical provider", () => { + const unknown = [...new Set(FREE_MODEL_BUDGETS.map((m) => m.provider))].filter( + (id) => !AI_PROVIDERS[id] + ); + assert.deepEqual( + unknown, + [], + `free catalog references providers that do not exist: ${unknown.join(", ")}` + ); +}); diff --git a/tests/unit/free-tier-summary-route.test.ts b/tests/unit/free-tier-summary-route.test.ts index a42b97ec0c..b1cbf0ce16 100644 --- a/tests/unit/free-tier-summary-route.test.ts +++ b/tests/unit/free-tier-summary-route.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { FREE_CATALOG_CURATED_AT } from "@omniroute/open-sse/config/freeModelCatalog.data.ts"; process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-freetier-route-")); @@ -50,3 +51,18 @@ test("GET /api/free-tier/summary excludeTosAvoid filters models", async () => { assert.ok(body.modelCount >= 1); assert.equal(typeof body.usedThisMonth, "number"); }); + +test("summary reports the catalog curation date, not a build timestamp", async () => { + const res = await GET(new Request("http://localhost/api/free-tier/summary")); + const body = await res.json(); + + // Must be the hand-maintained constant, never a file mtime: a standalone + // build rewrites timestamps on deploy, which would advertise a stale catalog + // as freshly updated. + assert.equal(body.catalogUpdatedAt, FREE_CATALOG_CURATED_AT); + assert.ok(!Number.isNaN(Date.parse(body.catalogUpdatedAt)), "must be a parsable date"); + assert.ok( + Date.parse(body.catalogUpdatedAt) <= Date.now(), + "curation date cannot be in the future" + ); +}); diff --git a/tests/unit/provider-credential-requirement.test.ts b/tests/unit/provider-credential-requirement.test.ts new file mode 100644 index 0000000000..27e957c220 --- /dev/null +++ b/tests/unit/provider-credential-requirement.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + NOT_TOKEN_QUANTIFIABLE_BUT_CREDENTIALED, + checkKeylessCatalogConsistency, + getCredentialRequirement, + listNoCredentialProviders, + worksWithoutCredential, +} from "@/shared/utils/providerCredentialRequirement.ts"; +import { FREE_MODEL_BUDGETS } from "@omniroute/open-sse/config/freeModelCatalog.data.ts"; + +test("classifies each credential model from the real registries", () => { + // noAuth: the connect form never asks for a key. + assert.equal(getCredentialRequirement("opencode"), "none"); + // Literal anonymous token: routable with no user credential, key still honoured. + assert.equal(getCredentialRequirement("aihorde"), "optional"); + assert.equal(getCredentialRequirement("kilocode"), "optional"); + // Verified live 2026-07-20: answers with no Authorization header, 403s on a bad key. + assert.equal(getCredentialRequirement("ovhcloud"), "optional"); + // OAuth: nothing to paste, but the user still signs in. + assert.equal(getCredentialRequirement("agy"), "oauth"); + // Ordinary key-gated provider. + assert.equal(getCredentialRequirement("groq"), "required"); + // Unknown ids must fail closed, never be advertised as free access. + assert.equal(getCredentialRequirement("definitely-not-a-provider"), "required"); +}); + +test("worksWithoutCredential excludes oauth — signing in is still a barrier", () => { + assert.equal(worksWithoutCredential("none"), true); + assert.equal(worksWithoutCredential("optional"), true); + assert.equal(worksWithoutCredential("oauth"), false); + assert.equal(worksWithoutCredential("required"), false); +}); + +test("listNoCredentialProviders is derived, not a hand-kept list", () => { + const ids = listNoCredentialProviders(); + assert.ok(ids.length > 0); + assert.ok(ids.includes("opencode")); + assert.ok(ids.includes("ovhcloud")); + assert.ok(ids.includes("aihorde")); + assert.ok(!ids.includes("groq"), "key-gated providers must never be listed"); + assert.deepEqual(ids, [...ids].sort(), "output must be stable for snapshotting"); +}); + +test("free catalog's keyless label matches real routing behaviour", () => { + const report = checkKeylessCatalogConsistency(FREE_MODEL_BUDGETS); + + assert.deepEqual( + report.unexpected, + [], + `these providers are labelled keyless but routing demands a credential: ${report.unexpected.join(", ")}. ` + + `Probe the endpoint and fix the registry instead of widening the recorded list.` + ); + + // Stale-allowlist enforcement: a frozen entry that stopped drifting must be + // removed, otherwise the debt list silently outlives the debt. + assert.deepEqual( + report.stale, + [], + `These now work without a credential — remove them from the recorded list: ${report.stale.join(", ")}` + ); +}); + +test("providers that reject anonymous calls are never advertised as key-free", () => { + // Probed live 2026-07-20 — each returned 401/403 with no credential. They are + // freeType: "keyless" in the catalog (meaning "not token-quantifiable"), so a + // UI section built on that field would invite users to call providers that + // reject them. This is the regression guard for that bug. + for (const id of ["blackbox", "friendliai", "iflytek", "sparkdesk", "puter", "muse-spark-web"]) { + assert.equal( + worksWithoutCredential(getCredentialRequirement(id)), + false, + `${id} answers 401/403 without a credential — it must never be listed as key-free` + ); + } +}); + +test("pollinations is genuinely key-free", () => { + // Probed live 2026-07-20: HTTP 200 with real choices and no credential. + assert.equal(worksWithoutCredential(getCredentialRequirement("pollinations")), true); +}); + +test("the credentialed-but-unquantifiable list only shrinks", () => { + // Frozen at 9 on 2026-07-20 (pollinations left it once its registry entry was + // corrected). Growing this means a mismatch was waved through instead of probed. + assert.ok( + NOT_TOKEN_QUANTIFIABLE_BUT_CREDENTIALED.length <= 9, + `list grew to ${NOT_TOKEN_QUANTIFIABLE_BUT_CREDENTIALED.length} — probe the endpoint and fix the registry instead` + ); +}); diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index 3b3ec0b496..699eab480d 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -1,7 +1,7 @@ // Characterization of the providers.ts catalog split (god-file decomposition): the host became a // barrel that re-exports 10 data catalogs now living under constants/providers/*, and APIKEY is // merged from 6 semantic family files (apikey/.ts). Locks: the public surface (every catalog -// + helpers still exported), the spread-merge integrity (179 APIKEY entries, no loss/dup), and that +// + helpers still exported), the spread-merge integrity (180 APIKEY entries, no loss/dup), and that // load-time Zod validation still runs. Pure-data move → behavior must be identical. // Count was 171 before obsolete provider removals (PR #6675: glhf/kluster/cablyai/inclusionai etc., // 171->167) plus #6126 (ClinePass dual-auth): the API-key-only APIKEY_PROVIDERS_GATEWAYS entry was @@ -10,7 +10,8 @@ // OpenVecta inference-gateway addition brought it back to 167, then #7246 (Chenzk API gateway) // brought it to 168, then more additions brought it to 172, then #6650 (g4f.space no-key gateway: // 5 new sub-path entries — g4f-groq/g4f-gemini/g4f-pollinations/g4f-ollama/g4f-nvidia) brought it -// to 177, then 2 more provider additions in the v3.8.49 cycle brought it to 179. +// to 177, then 2 more provider additions in the v3.8.49 cycle brought it to 179, and the free-catalog +// expansion (#7840, navy) to 180. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -39,12 +40,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 179 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 180 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 179); - assert.equal(new Set(keys).size, 179, "duplicate keys after spread-merge"); + assert.equal(keys.length, 180); + assert.equal(new Set(keys).size, 180, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 179. + // strict partition (every provider in exactly one), so the sum must be exactly 180. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -64,7 +65,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 179 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 179, "families must partition all 179 providers"); + assert.equal(famTotal, 180, "families must partition all 180 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => { diff --git a/tests/unit/ui/free-budget-card.test.tsx b/tests/unit/ui/free-budget-card.test.tsx new file mode 100644 index 0000000000..3509460d5d --- /dev/null +++ b/tests/unit/ui/free-budget-card.test.tsx @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import React from "react"; +import { + FreeBudgetView, + relativeTimeFromNow, + type FreeBudgetData, +} from "../../../src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx"; + +const data: FreeBudgetData = { + steadyRecurringTokens: 1_940_000_000, + steadyWithRecurringCreditsTokens: 1_941_000_000, + firstMonthRealisticTokens: 2_530_000_000, + usedThisMonth: 40_000_000, + remaining: 1_900_000_000, + modelCount: 530, + poolCount: 50, + perModel: [ + { provider: "mistral", modelId: "mistral-large", displayName: "Mistral Large", monthlyTokens: 1_000_000_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, + { provider: "kiro", modelId: "kiro", displayName: "Kiro", monthlyTokens: 25_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, + ], +}; + +describe("FreeBudgetView", () => { + it("renders steady total, remaining, first-month, per-model rows, and ToS-restricted count", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch(/1\.94B/); // steady + expect(html).toMatch(/2\.53B/); // first-month + expect(html).toMatch(/remaining/i); + expect(html).toMatch(/Mistral Large/); + expect(html).toMatch(/1 .*(ToS|restricted)/i); // 1 avoid-flagged model called out + }); + + // Pool-dedup: two models in the same pool → only ONE bar segment for that pool + it("bar is pool-deduped: two models sharing a poolKey produce one bar segment", () => { + const sharedPoolData: FreeBudgetData = { + steadyRecurringTokens: 1_000_000_000, + steadyWithRecurringCreditsTokens: 1_000_000_000, + firstMonthRealisticTokens: 1_200_000_000, + usedThisMonth: 0, + remaining: 1_000_000_000, + modelCount: 3, + poolCount: 1, + perModel: [ + // Two models in the same pool — should produce only 1 bar segment + { provider: "gemini", modelId: "gemini-flash", displayName: "Gemini Flash", monthlyTokens: 1_000_000_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "gemini-pool", tos: "ok" }, + { provider: "gemini", modelId: "gemini-pro", displayName: "Gemini Pro", monthlyTokens: 500_000_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "gemini-pool", tos: "ok" }, + // One standalone model (poolKey null) + { provider: "openai", modelId: "gpt-free", displayName: "GPT Free", monthlyTokens: 200_000_000, creditTokens: 0, freeType: "keyless", poolKey: null, tos: "ok" }, + ], + }; + + const html = renderToStaticMarkup(); + + const segmentMatches = html.match(/data-testid="bar-segment"/g); + const segmentCount = segmentMatches ? segmentMatches.length : 0; + + // 1 pool-segment (gemini-pool) + 1 loose segment (openai) = 2 total, NOT 3 + expect(segmentCount).toBe(2); + + // Table should show all 3 models (informational, per-model not pool-deduped) + expect(html).toMatch(/Gemini Flash/); + expect(html).toMatch(/Gemini Pro/); + expect(html).toMatch(/GPT Free/); + }); +}); + +const layoutData: FreeBudgetData = { + steadyRecurringTokens: 1_540_000_000, + steadyWithRecurringCreditsTokens: 1_540_000_000, + firstMonthRealisticTokens: 2_150_000_000, + usedThisMonth: 12_000_000, + remaining: 1_528_000_000, + modelCount: 4, + poolCount: 3, + boostMonthlyTokens: 24_000_000, + uncappedProviders: ["glm-cn", "kilo-gateway", "siliconflow"], + catalogUpdatedAt: null, + // Server-derived: which of these providers route with nothing configured. + noCredentialProviders: ["pollinations"], + perModel: [ + { provider: "mistral", modelId: "mistral-small", displayName: "Mistral Small 4", monthlyTokens: 1_000_000_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, + { provider: "llm7", modelId: "llm7", displayName: "LLM7 pool", monthlyTokens: 150_000_000, creditTokens: 0, freeType: "recurring-daily", poolKey: "llm7", tos: "caution" }, + { provider: "kiro", modelId: "kiro", displayName: "Kiro Auto", monthlyTokens: 25_000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, + { provider: "together", modelId: "together-signup", displayName: "Together credit", monthlyTokens: 0, creditTokens: 25_000_000, freeType: "one-time-initial", poolKey: "together-signup", tos: "caution" }, + { provider: "kimi", modelId: "kimi-free", displayName: "Kimi Free", monthlyTokens: 50_000_000, creditTokens: 0, freeType: "keyless", poolKey: null, tos: "ok" }, + // Real provider that routes anonymously — the "no API key" section is + // derived from routing behaviour, so a made-up id would (correctly) count + // as credentialed and never appear there. + { provider: "pollinations", modelId: "openai-fast", displayName: "Pollinations Fast", monthlyTokens: 30_000_000, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" }, + ], +}; + +describe("FreeBudgetView — layout, boost, uncapped chips", () => { + it("renders KPI tiles, a per-model table, the boost callout and uncapped chips", () => { + const html = renderToStaticMarkup(); + // KPI tiles + expect(html).toMatch(/Steady \/ month/); + expect(html).toMatch(/First month/); + expect(html).toMatch(/Used this month/); + // per-model table present with the model rows + expect(html).toMatch(/data-testid="budget-table"/); + expect(html).toMatch(/Mistral Small 4/); + expect(html).toMatch(/Together credit/); + expect(html).toMatch(/25M credit/); // one-time credit rendered as credit, not steady + // deposit-unlock boost surfaced separately + expect(html).toMatch(/Unlock ~24M more/); + // uncapped providers shown as chips, not summed + expect(html).toMatch(/no published cap/i); + expect(html).toMatch(/siliconflow/); + expect(html).toMatch(/kilo-gateway/); + }); + + it("does not render the freshness indicator when catalogUpdatedAt is null", () => { + const html = renderToStaticMarkup(); + expect(html).not.toMatch(/data-testid="catalog-freshness"/); + }); + + it("renders 'updated X ago' when catalogUpdatedAt is present", () => { + const html = renderToStaticMarkup( + + ); + expect(html).toMatch(/data-testid="catalog-freshness"/); + expect(html).toMatch(/updated (just now|\d+[a-z]+ ago)/); + }); +}); + +// Scope assertions to the table (bar-segment tooltips also contain model names and render first) +const tableOf = (h: string) => h.slice(h.indexOf('data-testid="budget-table"')); + +describe("FreeBudgetView — filters", () => { + it("hideAvoid drops ToS-restricted rows from the table but keeps the count callout", () => { + const shown = renderToStaticMarkup(); + expect(tableOf(shown)).toMatch(/Kiro Auto/); + const hidden = renderToStaticMarkup(); + expect(tableOf(hidden)).not.toMatch(/Kiro Auto/); + // the 1-model ToS-restricted callout still reflects the underlying data + expect(hidden).toMatch(/1 model.*ToS-restricted/i); + }); + + it("sort=name orders the table rows alphabetically by display name", () => { + const t = tableOf(renderToStaticMarkup()); + // Kiro Auto should appear before Mistral Small 4 in the table body when sorted by name + expect(t.indexOf("Kiro Auto")).toBeLessThan(t.indexOf("Mistral Small 4")); + }); + + it("search filters rows by model name, model id, and provider (case-insensitive)", () => { + const byName = tableOf(renderToStaticMarkup()); + expect(byName).toMatch(/Kimi Free/); + expect(byName).not.toMatch(/Mistral Small 4/); + + const byProvider = tableOf(renderToStaticMarkup()); + expect(byProvider).toMatch(/LLM7 pool/); + expect(byProvider).not.toMatch(/Kimi Free/); + }); + + it("providerFilter restricts the table to a single provider", () => { + const t = tableOf(renderToStaticMarkup()); + expect(t).toMatch(/Kimi Free/); + expect(t).not.toMatch(/Mistral Small 4/); + expect(t).not.toMatch(/LLM7 pool/); + }); + + it("keylessOnly keeps only providers that route with no credential", () => { + // Filters on the server-derived noCredentialProviders list, NOT on + // freeType: "keyless" — "Kimi Free" is catalogued keyless yet is not in + // that list, exactly the case that used to mislead users. + const t = tableOf(renderToStaticMarkup()); + expect(t).toMatch(/Pollinations Fast/); + expect(t).not.toMatch(/Kimi Free/); + expect(t).not.toMatch(/Mistral Small 4/); + expect(t).not.toMatch(/Together credit/); + }); + + it("shows an empty-state row when no model matches the current filters", () => { + const t = tableOf(renderToStaticMarkup()); + expect(t).toMatch(/No models match the current filters/); + }); +}); + +describe("FreeBudgetView — keyless section and badges", () => { + it("renders a 'No API key required' overview section listing keyless providers", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch(/data-testid="keyless-section"/); + expect(html).toMatch(/No API key required/); + expect(html).toMatch(/pollinations/); + }); + + it("omits the keyless section entirely when no model is keyless", () => { + const noKeyless: FreeBudgetData = { + ...layoutData, + perModel: layoutData.perModel.filter((m) => m.provider !== "pollinations"), + }; + const html = renderToStaticMarkup(); + expect(html).not.toMatch(/data-testid="keyless-section"/); + }); + + it("badges the keyless free-type distinctly from other free types", () => { + const html = renderToStaticMarkup(); + const badges = html.match(/data-testid="free-type-badge"[^>]*>[^<]*(?:<[^>]*>[^<]*)*<\/span>/g) ?? []; + expect(badges.some((b) => b.includes("keyless"))).toBe(true); + expect(badges.some((b) => b.includes("daily"))).toBe(true); + }); +}); + +describe("relativeTimeFromNow", () => { + const NOW = Date.parse("2026-07-20T12:00:00.000Z"); + + it("returns null for an unparsable timestamp", () => { + expect(relativeTimeFromNow("not-a-date", NOW)).toBeNull(); + }); + + it("formats sub-minute deltas as 'just now'", () => { + expect(relativeTimeFromNow("2026-07-20T11:59:45.000Z", NOW)).toBe("just now"); + }); + + it("formats minute/hour/day deltas", () => { + expect(relativeTimeFromNow("2026-07-20T11:30:00.000Z", NOW)).toBe("30m ago"); + expect(relativeTimeFromNow("2026-07-20T09:00:00.000Z", NOW)).toBe("3h ago"); + expect(relativeTimeFromNow("2026-07-17T12:00:00.000Z", NOW)).toBe("3d ago"); + }); +}); diff --git a/tests/unit/ui/open-sse-alias.test.tsx b/tests/unit/ui/open-sse-alias.test.tsx new file mode 100644 index 0000000000..87a02d3513 --- /dev/null +++ b/tests/unit/ui/open-sse-alias.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { getCredentialRequirement } from "@/shared/utils/providerCredentialRequirement"; +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; + +/** + * Guards the `@omniroute/open-sse` alias in vitest.config.ts. + * + * Without it, imports from open-sse resolve to undefined instead of throwing, + * so a UI test keeps passing while every lookup silently returns a default. + * That is exactly how the free-tier card tests classified every provider as + * credentialed and still went green. + */ +describe("open-sse alias resolution", () => { + it("REGISTRY is a populated object, not undefined", () => { + expect(REGISTRY).toBeTruthy(); + expect(Object.keys(REGISTRY).length).toBeGreaterThan(100); + }); + + it("code depending on REGISTRY sees the real entries", () => { + // Both are only reachable through REGISTRY: aihorde via anonymousApiKey, + // pollinations via authType: "optional". A broken alias yields "required". + expect(getCredentialRequirement("aihorde")).toBe("optional"); + expect(getCredentialRequirement("pollinations")).toBe("optional"); + expect(getCredentialRequirement("groq")).toBe("required"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 6f8d643da5..87de23a213 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,6 +40,10 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + // Mirrors tsconfig paths. Without it, a UI test importing from open-sse + // resolves to undefined instead of failing loudly — which silently made + // every provider look credentialed in the free-tier card tests. + "@omniroute/open-sse": path.resolve(__dirname, "./open-sse"), }, }, }); diff --git a/vitest.mcp.config.ts b/vitest.mcp.config.ts index 5cfe9ff278..eb3454fd5c 100644 --- a/vitest.mcp.config.ts +++ b/vitest.mcp.config.ts @@ -28,6 +28,10 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + // Mirrors tsconfig paths. Without it, a UI test importing from open-sse + // resolves to undefined instead of failing loudly — which silently made + // every provider look credentialed in the free-tier card tests. + "@omniroute/open-sse": path.resolve(__dirname, "./open-sse"), }, }, });