fix(models): stop treating provider-supplied free flags as trusted without a documented free tier (#12744)

Validado numa worktree combinada com a onda de roteamento/free-tier desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-api-typecheck OK (288), check-file-size OK após rebaseline, 84/85 nos testes focados no runner Node.

Parar de honrar `isFree:true`, `:free` e `0/0` vindos do upstream **antes** de consultar o catálogo é a inversão certa: hoje um provider sem tier livre documentado consegue se declarar grátis e o listing diverge do roteador `auto/*`. Checar as heurísticas depois do hit de catálogo fecha a porta sem quebrar o caminho de linhas custom locais, que continuam confiáveis pelo caminho próprio.

O `isFreeModel("or", …)` com um alias que não existe é o tipo de bug que passa despercebido porque falha silenciosamente para o lado permissivo.

**Integração:** o `decideHidePaid` que o #12795 extraiu passou a usar o seu `isFreeForProvider` por id, em vez de OR-ear os dois aliases num único `freeProvider`. A forma por id é a garantia que esta PR estabelece, então ela prevaleceu.
This commit is contained in:
Dizzle
2026-09-10 14:12:07 +02:00
committed by GitHub
parent 1db1bf3a9d
commit 39f9fc06c2
6 changed files with 87 additions and 41 deletions

View File

@@ -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

View File

@@ -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 });
}
/**

View File

@@ -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 = <T extends { id?: string }>(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";

View File

@@ -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,

View File

@@ -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<T extends FreeModelCandidate> {
@@ -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;
}

View File

@@ -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);
});
});