mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
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
This commit is contained in:
1
changelog.d/fixes/11441-discontinued-free-models.md
Normal file
1
changelog.d/fixes/11441-discontinued-free-models.md
Normal file
@@ -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))
|
||||
@@ -69,6 +69,31 @@ export interface FreeModelTotals {
|
||||
|
||||
const RECURRING = new Set<FreeModelFreeType>(["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<FreeModelFreeType, { grantsFreeAccess: boolean }>;
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string> = new Set(
|
||||
FREE_MODEL_BUDGETS.map((m) => m.provider)
|
||||
);
|
||||
export const PROVIDERS_WITH_FREE_MODELS: Set<string> = new Set(FREE_BUDGETS.map((m) => m.provider));
|
||||
|
||||
const FREE_MODEL_IDS_BY_PROVIDER: Map<string, Set<string>> = (() => {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const m of FREE_MODEL_BUDGETS) {
|
||||
for (const m of FREE_BUDGETS) {
|
||||
let set = map.get(m.provider);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
|
||||
141
tests/unit/autoCombo/free-regime-not-read-by-predicate.test.ts
Normal file
141
tests/unit/autoCombo/free-regime-not-read-by-predicate.test.ts
Normal file
@@ -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<FreeModelFreeType, boolean> = {
|
||||
"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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user