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"), }, }, });