diff --git a/changelog.d/fixes/12744-free-predicate-guard.md b/changelog.d/fixes/12744-free-predicate-guard.md new file mode 100644 index 0000000000..a0f979a149 --- /dev/null +++ b/changelog.d/fixes/12744-free-predicate-guard.md @@ -0,0 +1 @@ +- **fix(models):** stop treating provider-supplied `isFree:true`/`:free`/`0/0` flags as trusted unless the provider has a documented free tier — only the shipped free-tier catalog decides otherwise; fetched `isFree:true` trusted only for a free-tier provider, custom `isFree:true` via a trusted path ([#12744](https://github.com/diegosouzapw/OmniRoute/pull/12744)) — thanks @maxmad64bis diff --git a/open-sse/services/autoCombo/paidModelFilter.ts b/open-sse/services/autoCombo/paidModelFilter.ts index d50014021c..cc9e82e050 100644 --- a/open-sse/services/autoCombo/paidModelFilter.ts +++ b/open-sse/services/autoCombo/paidModelFilter.ts @@ -11,7 +11,7 @@ * Kept as a pure, dependency-light function so the filter is unit-testable in * isolation without seeding the DB-backed virtual factory. */ -import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels"; +import { isFreeForProvider } from "@/shared/utils/freeModels"; interface PaidFilterCandidate { provider: string; @@ -22,10 +22,7 @@ interface PaidFilterCandidate { * selected model itself qualifies as free — mirrors `shouldHidePaid` in * `src/app/api/v1/models/catalog.ts`. */ function isFreeCandidate(candidate: PaidFilterCandidate): boolean { - return ( - providerHasFreeModels(candidate.provider) && - isFreeModel(candidate.provider, { id: candidate.model }) - ); + return isFreeForProvider(candidate.provider, { id: candidate.model }); } /** diff --git a/src/app/api/models/openrouter-catalog/route.ts b/src/app/api/models/openrouter-catalog/route.ts index dcb8656cd1..058c72939d 100644 --- a/src/app/api/models/openrouter-catalog/route.ts +++ b/src/app/api/models/openrouter-catalog/route.ts @@ -10,7 +10,7 @@ import { NextRequest, NextResponse } from "next/server"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { getOpenRouterCatalog, refreshOpenRouterCatalog } from "@/lib/catalog/openrouterCatalog"; import { getSettings } from "@/lib/db/settings"; -import { isFreeModel } from "@/shared/utils/freeModels"; +import { isFreeForProvider } from "@/shared/utils/freeModels"; export async function GET(req: NextRequest) { // Require authentication (dashboard/API key) @@ -29,7 +29,9 @@ export async function GET(req: NextRequest) { hidePaid = settings?.hidePaidModels === true; } catch {} const applyFilter = (data: T[]): T[] => - hidePaid ? data.filter((m) => isFreeModel("or", m as { id: string; pricing?: unknown })) : data; + hidePaid + ? data.filter((m) => isFreeForProvider("openrouter", m as { id: string; pricing?: unknown })) + : data; const forceRefresh = req.nextUrl.searchParams.get("refresh") === "true"; diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 3e60e8bea2..075f7c2782 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -122,7 +122,7 @@ import { } from "./catalogRequest"; import { incrementCcDiscoveryHitCount } from "@/lib/db/ccDiscoveryMetrics"; import { isUnifiedChatSourceModelSelectable } from "./catalogModelPolicy"; -import { isFreeModel } from "@/shared/utils/freeModels"; +import { isFreeForProvider } from "@/shared/utils/freeModels"; import { isModelExposureAllowed } from "@/shared/utils/modelExposureList"; import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; @@ -350,11 +350,9 @@ async function buildUnifiedModelsResponseCore( ): boolean => { if (!hidePaid) return false; const provider = aliasToProviderId[providerKey] || providerKey; - // isFree:true is the first door — custom row kept even when its provider is outside FREE_MODEL_BUDGETS. - if (isFreeModel(provider, { id: modelId, pricing: pricing as any, isFree })) return false; - // hidePaid is on and model is non-free → hidden. No need to consult FREE_MODEL_BUDGETS - // separately: paid on a free-capable provider stays hidden, free on a non-budget provider - // already returned above. + // fetched: free only when provider has a documented free tier — catalog hit or guarded heuristics (`:free`/`0`/`isFree:true`); fetched isFree:true not trusted alone + if (isFreeForProvider(provider, { id: modelId, pricing: pricing as any, isFree })) + return false; return true; }; // #11481: opt-in explicit model exposure allow/deny list — same call sites @@ -1659,10 +1657,10 @@ async function buildUnifiedModelsResponseCore( if (model.isHidden === true) continue; if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; - // #6328: apply hidePaidModels to user-defined custom rows too. - // Custom entries do not carry pricing, so shouldHidePaid() decides - // via FREE_MODEL_IDS_BY_PROVIDER — matches synced/PROVIDER_MODELS. + // #6328: apply hidePaidModels to user-defined custom rows too. A local custom + // row flagged isFree:true stays trusted, even outside the free-tier catalog. if ( + (model as { isFree?: unknown }).isFree !== true && shouldHidePaid( canonicalProviderId, modelId, diff --git a/src/shared/utils/freeModels.ts b/src/shared/utils/freeModels.ts index 5f1f1c4a63..279e8cc295 100644 --- a/src/shared/utils/freeModels.ts +++ b/src/shared/utils/freeModels.ts @@ -29,10 +29,10 @@ import { AI_MODELS } from "@/shared/constants/models"; * import modal's preview disagree with the import route that runs on click. * * A provider is considered to "have free models" when it appears in the - * documented free-tier catalog (`FREE_MODEL_BUDGETS`). A single model is - * considered free when its id carries the OpenRouter-style `:free` suffix, when - * both its prompt and completion prices are zero, or when its id is listed as a - * free model for that provider in the catalog. + * documented free-tier catalog (`FREE_MODEL_BUDGETS`). A single model is considered free when — for a provider with a documented + * free tier — its id carries the OpenRouter-style `:free` suffix or both its + * prompt and completion prices are zero (guarded heuristics), or when its id + * is listed as a free model for that provider in the shipped catalog. * * The catalog also records the regime of every entry via `freeType` * (`FreeModelFreeType`). A regime can retire a free tier behind a paid key @@ -84,21 +84,34 @@ export interface FreeModelCandidate { isFree?: boolean; } -/** Whether a single fetched model qualifies as free for the given provider (id or alias). */ -export function isFreeModel(provider: string, model: FreeModelCandidate): boolean { +/** Shipped-catalog entry for this provider (id or alias): trusted on its own. */ +function isCatalogFreeModel(provider: string, modelId: unknown): boolean { + if (typeof modelId !== "string") return false; + return ( + FREE_MODEL_IDS_BY_PROVIDER.get(provider)?.has(modelId) === true || + FREE_MODEL_IDS_BY_PROVIDER.get(resolveProviderId(provider))?.has(modelId) === true + ); +} + +/** Payload-supplied free signals: `isFree:true`, a `:free` id suffix, or zero prices. */ +function hasPayloadFreeSignal(model: FreeModelCandidate): boolean { if (model.isFree === true) return true; if (typeof model.id === "string" && model.id.endsWith(":free")) return true; - if (isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion)) return true; - if (typeof model.id === "string") { - const canonical = resolveProviderId(provider); - if ( - FREE_MODEL_IDS_BY_PROVIDER.get(provider)?.has(model.id) || - FREE_MODEL_IDS_BY_PROVIDER.get(canonical)?.has(model.id) - ) { - return true; - } - } - return false; + return isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion); +} + +/** + * Whether a single fetched model qualifies as free for the given provider (id or alias): a + * shipped-catalog entry, or a payload signal on a provider with a documented free tier. + */ +export function isFreeModel(provider: string, model: FreeModelCandidate): boolean { + if (isCatalogFreeModel(provider, model.id)) return true; + return providerHasFreeModels(provider) && hasPayloadFreeSignal(model); +} + +/** Reusable free predicate for fetched payloads — provider must have a documented free tier. */ +export function isFreeForProvider(provider: string, model: FreeModelCandidate): boolean { + return providerHasFreeModels(provider) && isFreeModel(provider, model); } export interface SelectModelsForImportResult { @@ -191,7 +204,7 @@ export function matchesOnlyPaidModels(pattern: string): boolean { const fullId = `${m.provider}/${m.model}`; if (!regex.test(fullId)) continue; matched = true; - if (isFreeModel(m.provider, { id: m.model })) return false; + if (isFreeForProvider(m.provider, { id: m.model })) return false; } return matched; } diff --git a/tests/unit/free-models-isfree.test.ts b/tests/unit/free-models-isfree.test.ts index 53217ace47..775df3f778 100644 --- a/tests/unit/free-models-isfree.test.ts +++ b/tests/unit/free-models-isfree.test.ts @@ -3,10 +3,12 @@ import assert from "node:assert/strict"; import { isFreeModel, providerHasFreeModels } from "../../src/shared/utils/freeModels.ts"; describe("isFreeModel isFree opt-in", () => { - it("isFree:true → free even without :free/pricing/catalog", () => { - assert.equal(isFreeModel("any", { id: "x", isFree: true }), true); - assert.equal(isFreeModel("openai", { id: "gpt-4o", isFree: true }), true); - assert.equal(isFreeModel("local", { id: "my-model", isFree: true }), true); + it("isFree:true fetched → free only for a provider with a documented free tier", async () => { + const { FREE_MODEL_BUDGETS } = await import("@omniroute/open-sse/config/freeModelCatalog"); + const freeProvider = FREE_MODEL_BUDGETS[0].provider; + assert.equal(isFreeModel("any", { id: "x", isFree: true }), false); + assert.equal(isFreeModel("local", { id: "my-model", isFree: true }), false); + assert.equal(isFreeModel(freeProvider, { id: "x", isFree: true }), true); }); it("isFree:false/null/undefined/1/'true' → not free (strict ===true)", () => { const junk: unknown[] = [false, null, undefined, 1, "true"]; @@ -22,9 +24,42 @@ describe("isFreeModel isFree opt-in", () => { assert.equal(providerHasFreeModels("local"), false); assert.equal(providerHasFreeModels("openai"), providerHasFreeModels("openai")); }); - it(":free and pricing 0 still work when isFree absent", () => { - assert.equal(isFreeModel("any", { id: "foo:free" }), true); - assert.equal(isFreeModel("any", { id: "foo", pricing: { prompt: 0, completion: 0 } }), true); + it(":free and pricing 0 still work when isFree absent — only for free-tier providers", async () => { + const { FREE_MODEL_BUDGETS } = await import("@omniroute/open-sse/config/freeModelCatalog"); + const freeProvider = FREE_MODEL_BUDGETS[0].provider; + assert.equal(isFreeModel("any", { id: "foo:free" }), false); + assert.equal(isFreeModel("local", { id: "foo", pricing: { prompt: 0, completion: 0 } }), false); + assert.equal(isFreeModel(freeProvider, { id: "foo:free" }), true); + assert.equal( + isFreeModel(freeProvider, { id: "foo", pricing: { prompt: 0, completion: 0 } }), + true + ); assert.equal(isFreeModel("any", { id: "foo", pricing: { prompt: 0, completion: 1 } }), false); }); + it("selectModelsForImport matches isFreeForProvider on every catalogued free model", async () => { + // FREE_MODEL_IDS_BY_PROVIDER is module-private, so rebuild (provider, id) pairs from the + // exported FREE_MODEL_BUDGETS: every budgeted provider has a documented free tier. + const { FREE_MODEL_BUDGETS } = await import("@omniroute/open-sse/config/freeModelCatalog"); + const { selectModelsForImport, isFreeForProvider } = + await import("../../src/shared/utils/freeModels.ts"); + for (const { provider } of FREE_MODEL_BUDGETS) { + const m = { id: `${provider}/probe-lock`, pricing: { prompt: 0, completion: 0 } }; + const expected = isFreeForProvider(provider, m); + const { models } = selectModelsForImport(provider, [m], true); + assert.equal(models.length, expected ? 1 : 0, `${provider}/probe-lock`); + } + }); + it("selectModelsForImport drops zero-price models on providers without documented free tier", async () => { + // "local" has no documented free tier, so its zero-price model is not free. + const { selectModelsForImport, providerHasFreeModels } = + await import("../../src/shared/utils/freeModels.ts"); + assert.equal(providerHasFreeModels("local"), false); + const { models, freeFilterEmpty } = selectModelsForImport( + "local", + [{ id: "local/x", pricing: { prompt: 0, completion: 0 } }], + true + ); + assert.deepEqual(models, []); + assert.equal(freeFilterEmpty, true); + }); });