mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
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:
1
changelog.d/fixes/12744-free-predicate-guard.md
Normal file
1
changelog.d/fixes/12744-free-predicate-guard.md
Normal 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
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user