From b681308259870fd167785377f2cf1f7e063c1a2a Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:47:29 +0530 Subject: [PATCH] feat(api): add hidePaidModels setting to filter paid-only models from /v1/models catalog (#6495) feat(api): add hidePaidModels setting (net +1/-0, test OK). Integrated into release/v3.8.46. --- CHANGELOG.md | 1 + src/app/api/v1/models/catalog.ts | 16 +++++ src/lib/db/settings.ts | 5 ++ tests/unit/models-catalog-hide-paid.test.ts | 66 +++++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 tests/unit/models-catalog-hide-paid.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2514112201..c7c632b783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### 🐛 Bug Fixes +- **feat(api):** add a `hidePaidModels` setting that filters paid-only models out of the `/v1/models` catalog. Regression guard: `tests/unit/models-catalog-hide-paid.test.ts`. (thanks @chirag127) - **fix(api-manager):** the fallback model picker now preserves combos instead of dropping them when a primary model is unavailable. Regression guard: `tests/unit/api-manager-page-static.test.ts`. (thanks @jmengit) - **fix(providers):** recoverable Antigravity / Cloudflare `403` responses are now classified as retryable instead of terminal, so a transient WAF block no longer bans the connection. Regression guard: `tests/unit/errorclassifier-antigravity-403.test.ts`. (thanks @developerjillur) - **fix(mitm):** `sanitizeHeaders` now redacts `Set-Cookie` response headers so upstream session cookies never leak into logs / diagnostics. Regression guard: `tests/unit/mitm-sanitize-headers.test.ts`. (thanks @developerjillur) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 9d3ddac33f..2a7e37d3b1 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -74,6 +74,7 @@ import { import { getVisionCapabilityFields, getCustomVisionCapabilityFields } from "./catalogVision"; import { FALLBACK_ALIAS_TO_PROVIDER, buildAliasMaps } from "./catalogProviderMaps"; import { getModelCatalogAuthRejection, isCodexModelCatalogClient } from "./catalogRequest"; +import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels"; // Public API of this module is preserved after the catalog helper extraction: // `isVisionModelId` (vision-detection-consistency.test.ts) and @@ -234,6 +235,19 @@ async function buildUnifiedModelsResponseCore( aliasOrProviderId; // Issue #96: Allow blocking specific providers from the models list const blockedProviders = normalizeBlockedProviderSet(settings.blockedProviders); + // #6316: Opt-in filter — hide paid-only models via `isFreeModel()`. Only applied to + // PROVIDER_MODELS + OpenRouter loops (where pricing metadata / :free suffix / catalog + // membership is available). Modality registries (embedding/image/rerank/audio/ + // moderation/video/music) represent local capabilities without pricing, so they are + // exempt. Combos + auto/* + synced/custom/alias-backed rows also stay unfiltered — + // extending v1 scope to those requires per-entry pricing lookup not available today. + const hidePaid = settings.hidePaidModels === true; + const shouldHidePaid = (providerKey: string, modelId: string, pricing?: unknown): boolean => { + if (!hidePaid) return false; + const provider = aliasToProviderId[providerKey] || providerKey; + if (!providerHasFreeModels(provider)) return true; + return !isFreeModel(provider, { id: modelId, pricing: pricing as any }); + }; // Get active provider connections let connections = []; @@ -678,6 +692,7 @@ async function buildUnifiedModelsResponseCore( if (!providerSupportsModel(canonicalProviderId, model.id)) continue; const aliasId = `${alias}/${model.id}`; if (getModelIsHidden(canonicalProviderId, model.id)) continue; + if (shouldHidePaid(canonicalProviderId, model.id, (model as any).pricing)) continue; const visionFields = getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id); @@ -887,6 +902,7 @@ async function buildUnifiedModelsResponseCore( ); const modelType = getOpenRouterModelType(inputModalities, outputModalities); const isFree = isOpenRouterFreeModel(openRouterModel); + if (hidePaid && !isFree) continue; const supportedParameters = Array.isArray(openRouterModel.supported_parameters) ? openRouterModel.supported_parameters : []; diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index dd88911355..8c8a8eb569 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -151,6 +151,11 @@ export async function getSettings() { perKeyProxyEnabled: false, customSystemPromptEnabled: false, customSystemPrompt: "", + // #6316: Opt-in filter that hides paid-only models from the /v1/models catalog. + // Uses isFreeModel() from src/shared/utils/freeModels.ts to detect free entries + // (`:free` suffix, zero-price pricing, or FREE_MODEL_BUDGETS membership). Default + // false preserves prior behaviour; opt-in only. + hidePaidModels: false, }; for (const row of rows) { const record = toRecord(row); diff --git a/tests/unit/models-catalog-hide-paid.test.ts b/tests/unit/models-catalog-hide-paid.test.ts new file mode 100644 index 0000000000..5700cc4bd1 --- /dev/null +++ b/tests/unit/models-catalog-hide-paid.test.ts @@ -0,0 +1,66 @@ +/** + * #6316 — `hidePaidModels` settings toggle filters paid-only models from the + * unified `/v1/models` catalog. Uses `isFreeModel()` from + * `src/shared/utils/freeModels.ts` (`:free` suffix, zero-price pricing, or + * FREE_MODEL_BUDGETS membership). Modality registries are exempt (no pricing). + * Rule #18 regression guard for the toggle. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hide-paid-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function fetchCatalog(): Promise> { + const res = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { method: "GET" }) + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { data: Array<{ id: string; type?: string }> }; + return body.data; +} + +test.after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +}); + +test("hidePaidModels default is false + toggles the catalog filter", async () => { + const defaults = await settingsDb.getSettings(); + assert.equal(defaults.hidePaidModels, false); + + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "openai-main", + apiKey: "sk-test", + isActive: true, + }); + + // Chat-only assertion. Modality registries (embedding/image/audio/moderation) + // are exempt from the paid filter by design (no pricing metadata). + const isPaidChat = (m: { id: string; type?: string }) => + (m.type === undefined || m.type === "chat") && + (/^(openai|oa)\/gpt-/.test(m.id) || /^(openai|oa)\/o[1-9]/.test(m.id)); + + await settingsDb.updateSettings({ hidePaidModels: false }); + const off = await fetchCatalog(); + assert.equal(off.some(isPaidChat), true, "expected paid OpenAI chat models when toggle is off"); + + await settingsDb.updateSettings({ hidePaidModels: true }); + const on = await fetchCatalog(); + const leaked = on.filter(isPaidChat).map((m) => m.id); + assert.deepEqual(leaked, [], `paid OpenAI chat aliases leaked: ${leaked.join(", ")}`); +});