From 8143d8e3a63a4155c573b2daaaf8a62fb3a1d724 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:40:18 -0300 Subject: [PATCH] feat: replace free-text model inputs with hidePaid-aware Selects (#6540) (#7229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): replace free-text model inputs with hidePaid-aware Selects (#6540) Swap RoutingTab.webSearchRouteModel, ComboDefaultsTab.handoffModel, and BackgroundDegradationTab's from/to fields from free-text inputs to a new shared ModelSelectField fed by the already hidePaidModels-aware GET /api/models, with an off-catalog "(custom)" fallback so an existing saved value is never silently dropped. ModelRoutingSection's glob pattern field gets a fail-open "matches only paid models" warning instead, since it's a wildcard matcher rather than a single model id. Adds save-time paid-target rejection (PAID_MODEL_TARGET_BLOCKED, 400) on PATCH /api/settings, PATCH /api/settings/combo-defaults, and PUT /api/settings/background-degradation when hidePaidModels is on, failing open for aliases/combo names/unrecognized providers. globToRegex is extracted from lib/db/modelComboMappings.ts into a new dependency-free shared/utils/globPattern.ts so both the DB module and the new client-side pattern heuristic reuse the same regex-building logic. * refactor(6540): extract paid-target guard in background-degradation PUT The inline hidePaid check nested if>if>for>if inside the PUT handler, pushing its cognitive complexity to 16 (>15) — a NEW violation that broke the check:complexity-ratchets gate (891 > baseline 890). Extracted the check into a module-local hasBlockedPaidTarget() helper; behaviour and response body are unchanged. cognitive-complexity back to 890 = baseline. * fix(i18n): mirror paidModelPatternWarning into pt-BR.json (#6540) The new key landed only in en.json; the i18n pt-BR integrity test (no drift, #6695) requires pt-BR.json to carry every en.json key. --- .../features/6540-hidepaid-ui-selects.md | 1 + .../components/BackgroundDegradationTab.tsx | 30 +-- .../settings/components/ComboDefaultsTab.tsx | 9 +- .../settings/components/RoutingTab.tsx | 17 +- .../settings/background-degradation/route.ts | 35 +++- src/app/api/settings/combo-defaults/route.ts | 25 +++ src/app/api/settings/route.ts | 25 +++ src/i18n/messages/en.json | 3 +- src/i18n/messages/pt-BR.json | 1 + src/lib/db/modelComboMappings.ts | 18 +- src/shared/components/ModelRoutingSection.tsx | 30 ++- src/shared/components/ModelSelectField.tsx | 103 ++++++++++ src/shared/components/index.tsx | 1 + src/shared/utils/freeModels.ts | 53 +++++ src/shared/utils/globPattern.ts | 24 +++ tests/unit/glob-pattern-6540.test.ts | 35 ++++ tests/unit/paid-model-target-6540.test.ts | 45 +++++ .../paid-model-target-routes-6540.test.ts | 182 ++++++++++++++++++ .../unit/ui/model-select-field-6540.test.tsx | 102 ++++++++++ 19 files changed, 687 insertions(+), 52 deletions(-) create mode 100644 changelog.d/features/6540-hidepaid-ui-selects.md create mode 100644 src/shared/components/ModelSelectField.tsx create mode 100644 src/shared/utils/globPattern.ts create mode 100644 tests/unit/glob-pattern-6540.test.ts create mode 100644 tests/unit/paid-model-target-6540.test.ts create mode 100644 tests/unit/paid-model-target-routes-6540.test.ts create mode 100644 tests/unit/ui/model-select-field-6540.test.tsx diff --git a/changelog.d/features/6540-hidepaid-ui-selects.md b/changelog.d/features/6540-hidepaid-ui-selects.md new file mode 100644 index 0000000000..ff4bbdd77f --- /dev/null +++ b/changelog.d/features/6540-hidepaid-ui-selects.md @@ -0,0 +1 @@ +- **feat(dashboard):** Replace free-text model inputs in the Routing (web search route), Combo Defaults (handoff model), and Background Degradation tabs with a `hidePaidModels`-aware `ModelSelectField`, add a fail-open "paid-only pattern" warning to the per-model routing rule pattern field, and reject paid-only model targets at save time on `PATCH /api/settings`, `PATCH /api/settings/combo-defaults`, and `PUT /api/settings/background-degradation` when `hidePaidModels` is on ([#6540](https://github.com/diegosouzapw/OmniRoute/issues/6540)) diff --git a/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx b/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx index ef35d57e96..f23e91f65d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Card, Toggle } from "@/shared/components"; +import { Card, ModelSelectField, Toggle } from "@/shared/components"; import { useTranslations } from "next-intl"; export default function BackgroundDegradationTab() { @@ -153,21 +153,21 @@ export default function BackgroundDegradationTab() { {/* Add new mapping */}
- setNewFrom(e.target.value)} - className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none" - /> +
+ +
- setNewTo(e.target.value)} - className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none" - /> +
+ +
diff --git a/src/app/api/settings/background-degradation/route.ts b/src/app/api/settings/background-degradation/route.ts index fc7f526add..87744edd98 100644 --- a/src/app/api/settings/background-degradation/route.ts +++ b/src/app/api/settings/background-degradation/route.ts @@ -4,10 +4,28 @@ import { setBackgroundDegradationConfig, resetStats, } from "@omniroute/open-sse/services/backgroundTaskDetector.ts"; -import { updateSettings } from "@/lib/db/settings"; +import { getSettings, updateSettings } from "@/lib/db/settings"; import { jsonObjectSchema, resetStatsActionSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { isPaidModelTarget } from "@/shared/utils/freeModels"; + +/** + * #6540: is any degradation "to" target a paid-only model while hidePaidModels is on? + * Only the "to" side is checked — "from" is a detection trigger key, not an invocation + * target, so a paid "from" is never blocked. Fails open on "unknown" (aliases/combo + * names), mirroring the settings/combo-defaults routes. + */ +async function hasBlockedPaidTarget( + degradationMap: Record | undefined +): Promise { + if (!degradationMap || typeof degradationMap !== "object") return false; + const currentSettings: any = await getSettings(); + if (currentSettings?.hidePaidModels !== true) return false; + return Object.values(degradationMap).some( + (to) => typeof to === "string" && isPaidModelTarget(to) === "paid" + ); +} /** * GET /api/settings/background-degradation @@ -52,7 +70,20 @@ export async function PUT(request: Request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const config = validation.data; + const config = validation.data as { degradationMap?: Record }; + + if (await hasBlockedPaidTarget(config.degradationMap)) { + return NextResponse.json( + { + error: { + code: "PAID_MODEL_TARGET_BLOCKED", + message: + "This field cannot target a paid-only model while 'Hide paid models' is enabled.", + }, + }, + { status: 400 } + ); + } setBackgroundDegradationConfig(config); diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts index 9e6ca358e4..643be54c0e 100644 --- a/src/app/api/settings/combo-defaults/route.ts +++ b/src/app/api/settings/combo-defaults/route.ts @@ -3,6 +3,7 @@ import { getSettings, updateSettings } from "@/lib/localDb"; import { updateComboDefaultsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { isPaidModelTarget } from "@/shared/utils/freeModels"; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ "timeoutMs", @@ -96,6 +97,30 @@ export async function PATCH(request: Request) { } const body = validation.data; + // #6540: reject a paid-only handoffModel target when hidePaidModels is on. + // Fails open on "unknown" (aliases/combo names) — mirrors the settings + // route's PAID_MODEL_TARGET_BLOCKED check. + if ( + typeof body.comboDefaults?.handoffModel === "string" && + body.comboDefaults.handoffModel.trim() !== "" + ) { + const currentSettings: any = await getSettings(); + if (currentSettings?.hidePaidModels === true) { + if (isPaidModelTarget(body.comboDefaults.handoffModel) === "paid") { + return NextResponse.json( + { + error: { + code: "PAID_MODEL_TARGET_BLOCKED", + message: + "This field cannot target a paid-only model while 'Hide paid models' is enabled.", + }, + }, + { status: 400 } + ); + } + } + } + const updates: Record = {}; if (body.comboDefaults) { diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 00bc0cf398..d4bada962e 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -20,6 +20,7 @@ import { verifyManagementPassword, } from "@/lib/auth/managementPassword"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { isPaidModelTarget } from "@/shared/utils/freeModels"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance"; import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; @@ -296,6 +297,30 @@ export async function PATCH(request: Request) { } } + // #6540: reject a paid-only webSearchRouteModel target when hidePaidModels + // is on. Business-rule check (needs an async DB read), so it runs after + // Zod shape validation rather than as a Zod .refine(). Fails open on + // "unknown" (aliases/combo names) — only a positively-identified paid + // catalog entry is blocked. + if (typeof body.webSearchRouteModel === "string" && body.webSearchRouteModel.trim() !== "") { + const currentSettings = await getSettings(); + if ((currentSettings as Record)?.hidePaidModels === true) { + if (isPaidModelTarget(body.webSearchRouteModel) === "paid") { + emitSettingsFailureAudit(request, actor, "PAID_MODEL_TARGET_BLOCKED", attemptedKeys); + return NextResponse.json( + { + error: { + code: "PAID_MODEL_TARGET_BLOCKED", + message: + "This field cannot target a paid-only model while 'Hide paid models' is enabled.", + }, + }, + { status: 400 } + ); + } + } + } + // Password rotation: hash the new value AFTER the gate has accepted the // currentPassword (or the cold-boot exception fired). The gate already // included `newPassword` in SECURITY_IMPACTING_KEYS, so no separate diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c18e6f206a..e71d54f17a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5645,7 +5645,8 @@ "echoRequestedModelDesc": "When enabled, the response `model` field echoes the alias or combo name the client requested instead of the upstream model name. Fixes strict clients (e.g. Claude Desktop) that reject a response whose model does not match the request.", "webSearchRouteTitle": "Web search routing", "webSearchRouteDesc": "When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable.", - "webSearchRoutePlaceholder": "e.g. openrouter,anthropic/claude-3.5-sonnet", + "webSearchRoutePlaceholder": "Search or select a model…", + "paidModelPatternWarning": "This pattern only matches paid models — enable paid models or adjust the pattern.", "clearLkgpCache": "Clear LKGP Cache", "lkgpCacheCleared": "LKGP cache cleared successfully", "lkgpCacheClearFailed": "Failed to clear LKGP cache", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 2f63d1d6af..9d9f755549 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5608,6 +5608,7 @@ "webSearchRouteTitle": "__MISSING__:Web search routing", "webSearchRouteDesc": "__MISSING__:When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable.", "webSearchRoutePlaceholder": "__MISSING__:e.g. openrouter,anthropic/claude-3.5-sonnet", + "paidModelPatternWarning": "Este padrão corresponde apenas a modelos pagos — habilite modelos pagos ou ajuste o padrão.", "clearLkgpCache": "Clear LKGP Cache", "lkgpCacheCleared": "LKGP cache cleared successfully", "lkgpCacheClearFailed": "Failed to clear LKGP cache", diff --git a/src/lib/db/modelComboMappings.ts b/src/lib/db/modelComboMappings.ts index 1d11c2fcff..ecbdb5291f 100644 --- a/src/lib/db/modelComboMappings.ts +++ b/src/lib/db/modelComboMappings.ts @@ -9,6 +9,7 @@ import { v4 as uuidv4 } from "uuid"; import { getDbInstance } from "./core"; +import { globToRegex } from "@/shared/utils/globPattern"; // ────────────────────────────────────────────────────────── // Types @@ -38,23 +39,6 @@ interface MappingRow { updated_at: string; } -// ────────────────────────────────────────────────────────── -// Glob → RegExp conversion -// ────────────────────────────────────────────────────────── - -/** - * Convert a simple glob pattern to a RegExp. - * Supports `*` (any characters) and `?` (single character). - * Case-insensitive matching. - */ -function globToRegex(pattern: string): RegExp { - const escaped = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex specials - .replace(/\*/g, ".*") // * → .* - .replace(/\?/g, "."); // ? → . - return new RegExp(`^${escaped}$`, "i"); -} - // ────────────────────────────────────────────────────────── // Row mapping // ────────────────────────────────────────────────────────── diff --git a/src/shared/components/ModelRoutingSection.tsx b/src/shared/components/ModelRoutingSection.tsx index f8f597247a..af29a9045f 100644 --- a/src/shared/components/ModelRoutingSection.tsx +++ b/src/shared/components/ModelRoutingSection.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import Card from "./Card"; +import { matchesOnlyPaidModels } from "@/shared/utils/freeModels"; export interface ModelMapping { id: string; @@ -26,6 +27,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos const [loading, setLoading] = useState(true); const [adding, setAdding] = useState(false); const [editingId, setEditingId] = useState(null); + const [hidePaidModels, setHidePaidModels] = useState(false); const combos = externalCombos || internalCombos; // Form state @@ -58,6 +60,21 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos }; }, []); + // #6540: read hidePaidModels once so the pattern field can warn (fail-open) + // when it resolves only to paid model families. + useEffect(() => { + let cancelled = false; + fetch("/api/settings") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled && data) setHidePaidModels(data.hidePaidModels === true); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (externalCombos !== undefined) return; let cancelled = false; @@ -141,6 +158,11 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos } catch {} }; + // #6540: fail-open heuristic — only warn/block when the pattern resolves + // to at least one model AND every match is paid. A pattern matching a + // mix of free and paid models (or nothing recognizable) is left alone. + const patternIsPaidOnly = hidePaidModels && matchesOnlyPaidModels(pattern); + return (
@@ -183,6 +205,12 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary" />

{t("patternHint")}

+ {patternIsPaidOnly && ( +

+ {t("paidModelPatternWarning") || + "This pattern only matches paid models — enable paid models or adjust the pattern."} +

+ )}