From 42c209797c384318eeebdd820fda22740f81a665 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 25 Aug 2026 00:57:24 +0200 Subject: [PATCH] fix(free-models): stop reporting discontinued Pollinations models as free The shared isFreeModel() predicate ignored each catalog entry's freeType, so Pollinations models marked "discontinued" (moved behind a paid key) were still reported free and could be routed under hidePaidModels. Add a compiler-checked grantsFreeAccess(freeType) table and use it in both the routing predicate and strictZeroCostFilter. Tests: tests/unit/autoCombo/free-regime-not-read-by-predicate.test.ts --- .../fixes/11441-discontinued-free-models.md | 1 + open-sse/config/freeModelCatalog.ts | 25 ++++ .../autoCombo/strictZeroCostFilter.ts | 3 +- src/shared/utils/freeModels.ts | 19 ++- .../free-regime-not-read-by-predicate.test.ts | 141 ++++++++++++++++++ 5 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/11441-discontinued-free-models.md create mode 100644 tests/unit/autoCombo/free-regime-not-read-by-predicate.test.ts diff --git a/changelog.d/fixes/11441-discontinued-free-models.md b/changelog.d/fixes/11441-discontinued-free-models.md new file mode 100644 index 0000000000..25a38a2505 --- /dev/null +++ b/changelog.d/fixes/11441-discontinued-free-models.md @@ -0,0 +1 @@ +- **fix(free-models):** the shared `isFreeModel()` predicate no longer reports catalog entries marked `freeType: "discontinued"` as free, so `hidePaidModels` can't route to Pollinations' seven premium models that now require a paid key ([#11441](https://github.com/diegosouzapw/OmniRoute/pull/11441)) diff --git a/open-sse/config/freeModelCatalog.ts b/open-sse/config/freeModelCatalog.ts index 103f3775f7..7c101a1120 100644 --- a/open-sse/config/freeModelCatalog.ts +++ b/open-sse/config/freeModelCatalog.ts @@ -69,6 +69,31 @@ export interface FreeModelTotals { const RECURRING = new Set(["recurring-daily", "recurring-monthly", "keyless"]); +/** + * What each free-tier regime engages for "can I route here without paying?". + * Exhaustive by construction: adding a member to `FreeModelFreeType` will not + * compile until it is classified here. `discontinued` is the one regime a + * provider uses to retire a free tier behind a paid key — it does NOT grant + * free access, and the shared predicate (`isFreeModel`) must read this instead + * of treating every catalogued id as free. `RECURRING` (above) answers a + * different question (which regimes feed the headline token totals) and is left + * independent on purpose — deriving it from this table would silently change + * the homepage totals. + */ +const FREE_REGIME_TRAITS = { + "recurring-daily": { grantsFreeAccess: true }, + "recurring-monthly": { grantsFreeAccess: true }, + "recurring-credit": { grantsFreeAccess: true }, + "recurring-uncapped": { grantsFreeAccess: true }, + "one-time-initial": { grantsFreeAccess: true }, + keyless: { grantsFreeAccess: true }, + discontinued: { grantsFreeAccess: false }, +} satisfies Record; + +export function grantsFreeAccess(freeType: FreeModelFreeType): boolean { + return FREE_REGIME_TRAITS[freeType].grantsFreeAccess; +} + /** * Deposit-unlock boosts: a one-time small top-up that permanently raises a * provider's recurring free quota. Kept OUT of the steady headline and surfaced diff --git a/open-sse/services/autoCombo/strictZeroCostFilter.ts b/open-sse/services/autoCombo/strictZeroCostFilter.ts index c9bc601bc0..288d7f1782 100644 --- a/open-sse/services/autoCombo/strictZeroCostFilter.ts +++ b/open-sse/services/autoCombo/strictZeroCostFilter.ts @@ -50,6 +50,7 @@ */ import { FREE_MODEL_BUDGETS, + grantsFreeAccess, type FreeModelBudget, } from "@omniroute/open-sse/config/freeModelCatalog.ts"; import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "./resilienceCandidateFilter"; @@ -184,7 +185,7 @@ export function evaluateCandidateConnections( // entries today, so it will correctly exclude). if (isGenuineNoAuthCandidate) return [SYNTHETIC_NOAUTH_CONNECTION_ID]; } - if (budgetEntry.freeType === "discontinued") return []; + if (!grantsFreeAccess(budgetEntry.freeType)) return []; if (isGenuineNoAuthCandidate) return []; // no-auth path but a non-keyless catalog entry: contradictory metadata, fail closed // Every remaining freeType (recurring-*, one-time-initial, a keyless entry diff --git a/src/shared/utils/freeModels.ts b/src/shared/utils/freeModels.ts index 5da7d8868c..564565fe5f 100644 --- a/src/shared/utils/freeModels.ts +++ b/src/shared/utils/freeModels.ts @@ -1,4 +1,4 @@ -import { FREE_MODEL_BUDGETS } from "@omniroute/open-sse/config/freeModelCatalog"; +import { FREE_MODEL_BUDGETS, grantsFreeAccess } from "@omniroute/open-sse/config/freeModelCatalog"; import { resolveProviderId } from "@/shared/constants/providers"; import { globToRegex } from "@/shared/utils/globPattern"; import { AI_MODELS } from "@/shared/constants/models"; @@ -12,16 +12,25 @@ import { AI_MODELS } from "@/shared/constants/models"; * 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. + * + * The catalog also records the regime of every entry via `freeType` + * (`FreeModelFreeType`). A regime can retire a free tier behind a paid key + * (`discontinued`); `grantsFreeAccess` is the single predicate that decides + * whether a regime still grants free access, and the two structures below are + * derived only from entries whose regime grants it — so a `discontinued` entry + * is never reported free, and a future regime that forgets to be classified + * fails to compile rather than defaulting silently. */ +/** Catalogued entries whose regime still grants free access. */ +const FREE_BUDGETS = FREE_MODEL_BUDGETS.filter((m) => grantsFreeAccess(m.freeType)); + /** Provider ids that have at least one documented free model. */ -export const PROVIDERS_WITH_FREE_MODELS: Set = new Set( - FREE_MODEL_BUDGETS.map((m) => m.provider) -); +export const PROVIDERS_WITH_FREE_MODELS: Set = new Set(FREE_BUDGETS.map((m) => m.provider)); const FREE_MODEL_IDS_BY_PROVIDER: Map> = (() => { const map = new Map>(); - for (const m of FREE_MODEL_BUDGETS) { + for (const m of FREE_BUDGETS) { let set = map.get(m.provider); if (!set) { set = new Set(); diff --git a/tests/unit/autoCombo/free-regime-not-read-by-predicate.test.ts b/tests/unit/autoCombo/free-regime-not-read-by-predicate.test.ts new file mode 100644 index 0000000000..3d6a88873c --- /dev/null +++ b/tests/unit/autoCombo/free-regime-not-read-by-predicate.test.ts @@ -0,0 +1,141 @@ +/** + * Follow-up to #6328 / #6495 / #6512 — the shared free-model predicate ignored + * the catalog's own `freeType`, so entries a provider has since put behind a + * paid key were still reported free. + * + * The catalog already records the regime of every entry, and + * `strictZeroCostFilter` already reads it. These guards pin the same rule into + * the predicate that `hidePaidModels` and `/v1/models` go through. + */ +import { test } from "vitest"; +import assert from "node:assert/strict"; + +import { + FREE_MODEL_BUDGETS, + grantsFreeAccess, + type FreeModelFreeType, +} from "../../../open-sse/config/freeModelCatalog.ts"; +import { isFreeModel, providerHasFreeModels } from "../../../src/shared/utils/freeModels.ts"; +import { filterPaidOnlyCandidates } from "../../../open-sse/services/autoCombo/paidModelFilter.ts"; +import { + evaluateCandidateConnections, + findBudgetEntry, +} from "../../../open-sse/services/autoCombo/strictZeroCostFilter.ts"; + +/** Catalogued under `pollinations` as `discontinued`: the provider moved them + * behind an API key, and their `displayName` says so. */ +const DISCONTINUED = [ + "gemini", + "gemini-fast", + "midijourney", + "midijourney-large", + "claude-fast", + "claude", + "claude-large", +]; + +/** Same provider, still keyless — the guard against over-filtering. */ +const STILL_FREE = ["openai", "openai-fast", "qwen-coder", "mistral", "deepseek"]; + +test("a model the catalog marks discontinued is not free", () => { + for (const id of DISCONTINUED) { + assert.equal( + isFreeModel("pollinations", { id }), + false, + `pollinations/${id} is catalogued discontinued and must not qualify as free` + ); + } +}); + +test("the provider's still-free models are untouched", () => { + for (const id of STILL_FREE) { + assert.equal( + isFreeModel("pollinations", { id }), + true, + `pollinations/${id} is catalogued keyless and must stay free` + ); + } +}); + +test("the provider itself still counts as having free models", () => { + assert.equal( + providerHasFreeModels("pollinations"), + true, + "pollinations keeps ten keyless entries; only the discontinued ones change" + ); +}); + +test("hidePaidModels drops them from the auto/* candidate pool", () => { + const discontinued = { provider: "pollinations", model: "claude" }; + const stillFree = { provider: "pollinations", model: "openai" }; + + assert.deepEqual( + filterPaidOnlyCandidates([discontinued, stillFree], true), + [stillFree], + "an operator who asked not to route to paid models must not get one that needs a paid key" + ); + assert.deepEqual( + filterPaidOnlyCandidates([discontinued, stillFree], false), + [discontinued, stillFree], + "opt-in off stays an identity no-op" + ); +}); + +test("no provider loses its free status", () => { + const withFreeRegime = new Set( + FREE_MODEL_BUDGETS.filter((m) => grantsFreeAccess(m.freeType)).map((m) => m.provider) + ); + const lost = [...new Set(FREE_MODEL_BUDGETS.map((m) => m.provider))].filter( + (p) => !withFreeRegime.has(p) + ); + assert.deepEqual( + lost, + [], + "no catalogued provider is discontinued across the board today; if one ever is, decide deliberately" + ); +}); + +test("every regime is classified, with the expected verdict", () => { + const expected: Record = { + "recurring-daily": true, + "recurring-monthly": true, + "recurring-credit": true, + "recurring-uncapped": true, + "one-time-initial": true, + keyless: true, + discontinued: false, + }; + for (const [freeType, verdict] of Object.entries(expected)) { + assert.equal( + grantsFreeAccess(freeType as FreeModelFreeType), + verdict, + `${freeType} must be classified ${verdict}` + ); + } +}); + +test("the strict filter (G1c) excludes a discontinued entry, matching its prior literal", () => { + const budgetEntry = findBudgetEntry({ provider: "pollinations", model: "claude" }); + assert.ok(budgetEntry, "discontinued pollinations/claude must be in the catalog"); + assert.equal(budgetEntry.freeType, "discontinued", "sanity: the entry this guard protects"); + + // A discontinued entry must be excluded by the strict filter regardless of + // connection safety — it collapses the regime to "no free access" before any + // quota lookup, exactly as the previous `freeType === "discontinued"` literal did. + const excluded = evaluateCandidateConnections( + { provider: "pollinations", model: "claude", connectionId: "some-real-conn" }, + budgetEntry, + () => ({ + status: "SAFE", + remainingFreeAllowance: 1000, + resetAt: null, + checkedAt: new Date().toISOString(), + }), + { minRemainingAllowance: 0, maxStateAgeMs: 1e9 } + ); + assert.deepEqual( + excluded, + [], + "a discontinued entry is excluded by the strict filter, independent of connection safety" + ); +});