feat: replace free-text model inputs with hidePaid-aware Selects (#6540) (#7229)

* 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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 10:40:18 -03:00
committed by GitHub
parent ca03d619a4
commit 8143d8e3a6
19 changed files with 687 additions and 52 deletions

View File

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

View File

@@ -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 */}
<div className="flex items-center gap-2 mb-3">
<input
type="text"
placeholder={t("premiumModel") || "Premium model"}
value={newFrom}
onChange={(e) => 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"
/>
<div className="flex-1">
<ModelSelectField
value={newFrom}
onChange={setNewFrom}
placeholder={t("premiumModel") || "Premium model"}
/>
</div>
<span className="text-text-muted text-lg"></span>
<input
type="text"
placeholder={t("cheapModel") || "Cheap model"}
value={newTo}
onChange={(e) => 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"
/>
<div className="flex-1">
<ModelSelectField
value={newTo}
onChange={setNewTo}
placeholder={t("cheapModel") || "Cheap model"}
/>
</div>
<button
onClick={addMapping}
disabled={saving || !newFrom.trim() || !newTo.trim()}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { Card, Button, Input, ModelSelectField, Toggle } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import { matchesSearch } from "@/shared/utils/turkishText";
import FusionDefaultsFields from "./FusionDefaultsFields";
@@ -632,15 +632,14 @@ export default function ComboDefaultsTab() {
}
className="text-sm"
/>
<Input
<ModelSelectField
label={translateOrFallback(t, "contextRelaySummaryModel", "Summary Model")}
type="text"
value={comboDefaults.handoffModel ?? ""}
placeholder="codex/gpt-5.6-sol"
onChange={(e) =>
onChange={(v) =>
setComboDefaults((prev) => ({
...prev,
handoffModel: e.target.value,
handoffModel: v,
}))
}
className="text-sm"

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Card, Collapsible, Input, Select, Toggle } from "@/shared/components";
import ModelSelectField from "@/shared/components/ModelSelectField";
import { useTranslations } from "next-intl";
import { useNotificationStore } from "@/store/notificationStore";
import {
@@ -1502,18 +1503,12 @@ export default function RoutingTab() {
"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."}
</p>
<div className="mt-3">
<Input
value={
typeof settings.webSearchRouteModel === "string"
? settings.webSearchRouteModel
: ""
}
onChange={(e) => updateSetting({ webSearchRouteModel: e.target.value })}
placeholder={
t("webSearchRoutePlaceholder") || "e.g. openrouter,anthropic/claude-3.5-sonnet"
}
<ModelSelectField
value={String(settings.webSearchRouteModel ?? "")}
onChange={(v) => updateSetting({ webSearchRouteModel: v })}
placeholder={t("webSearchRoutePlaceholder") || "Search or select a model…"}
disabled={loading}
aria-label={t("webSearchRouteTitle") || "Web search routing model"}
ariaLabel={t("webSearchRouteTitle") || "Web search routing model"}
/>
</div>
</div>

View File

@@ -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<string, string> | undefined
): Promise<boolean> {
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<string, string> };
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);

View File

@@ -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<string, any> = {};
if (body.comboDefaults) {

View File

@@ -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<string, unknown>)?.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

View File

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

View File

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

View File

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

View File

@@ -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<string | null>(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 (
<Card>
<div className="flex items-center justify-between gap-4">
@@ -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"
/>
<p className="text-[9px] text-text-muted mt-0.5">{t("patternHint")}</p>
{patternIsPaidOnly && (
<p className="text-[9px] text-amber-500 mt-0.5">
{t("paidModelPatternWarning") ||
"This pattern only matches paid models — enable paid models or adjust the pattern."}
</p>
)}
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
@@ -231,7 +259,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
<div className="flex items-center gap-2 mt-2.5">
<button
onClick={handleSave}
disabled={!pattern.trim() || !comboId}
disabled={!pattern.trim() || !comboId || patternIsPaidOnly}
className="px-3 py-1 text-xs font-medium rounded-lg bg-primary text-white
hover:bg-primary/90 disabled:opacity-40 transition-colors"
>

View File

@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import Select from "./Select";
import Input from "./Input";
interface ApiModel {
provider: string;
model: string;
fullModel?: string;
}
export interface ModelSelectFieldProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
label?: React.ReactNode;
placeholder?: string;
ariaLabel?: string;
/** Render a plain text fallback (custom option / off-catalog) — default true. */
allowCustom?: boolean;
className?: string;
}
interface FetchState {
status: "loading" | "ready" | "error";
options: { value: string; label: string }[];
}
/**
* hidePaid-aware model picker (#6540). Loads options from `GET /api/models`
* (already filters by `hidePaidModels`) instead of a static catalog. Falls
* back to a plain text `Input` when the fetch fails so the field never
* becomes unusable, and injects a "(custom)" option for an existing saved
* value that isn't present in the fetched catalog (typo, deprecated model,
* alias/combo name) so it is never silently dropped on save.
*/
export default function ModelSelectField({
value,
onChange,
disabled = false,
label,
placeholder,
ariaLabel,
allowCustom = true,
className,
}: ModelSelectFieldProps) {
const [state, setState] = useState<FetchState>({ status: "loading", options: [] });
useEffect(() => {
let cancelled = false;
fetch("/api/models")
.then((res) => (res.ok ? res.json() : Promise.reject(new Error("fetch failed"))))
.then((data) => {
if (cancelled) return;
const models: ApiModel[] = Array.isArray(data?.models) ? data.models : [];
const options = models.map((m) => {
const full = m.fullModel || `${m.provider}/${m.model}`;
return { value: full, label: full };
});
setState({ status: "ready", options });
})
.catch(() => {
if (!cancelled) setState({ status: "error", options: [] });
});
return () => {
cancelled = true;
};
}, []);
if (state.status === "error" && allowCustom) {
return (
<Input
label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
aria-label={ariaLabel}
className={className}
/>
);
}
const hasKnownValue = value === "" || state.options.some((o) => o.value === value);
const options =
!hasKnownValue && allowCustom
? [{ value, label: `${value} (custom)` }, ...state.options]
: state.options;
return (
<Select
label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
options={options}
placeholder={state.status === "loading" ? "Loading models…" : placeholder || "Select a model"}
disabled={disabled || state.status === "loading"}
aria-label={ariaLabel}
className={className}
/>
);
}

View File

@@ -18,6 +18,7 @@ export { default as Header } from "./Header";
export { default as Footer } from "./Footer";
export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as ModelSelectField } from "./ModelSelectField";
export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as UsageStats } from "./UsageStats";
export { default as UsageAnalytics } from "./UsageAnalytics";

View File

@@ -1,5 +1,7 @@
import { FREE_MODEL_BUDGETS } 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";
/**
* Free-model detection shared between the "import only free models" connection
@@ -110,3 +112,54 @@ export function selectModelsForImport<T extends FreeModelCandidate>(
const freeFilterEmpty = fetchedModels.length > 0 && models.length === 0;
return { models, freeFilterEmpty };
}
// ──────────────────────────────────────────────────────────
// hidePaidModels save-time validation (#6540)
// ──────────────────────────────────────────────────────────
export type PaidModelTargetVerdict = "paid" | "free" | "unknown";
/**
* Classify a settings-style model string ("provider/model" or
* "provider,model") as paid/free/unknown against the documented free
* catalog. Fails open ("unknown") for anything that doesn't cleanly parse
* into a (provider, model) pair, or whose provider isn't in the free
* catalog at all — this covers aliases, combo names, and custom/synced
* rows, mirroring the exemptions `catalog.ts`'s `shouldHidePaid` already
* makes for those row types.
*/
export function isPaidModelTarget(value: string): PaidModelTargetVerdict {
if (typeof value !== "string" || value.trim() === "") return "unknown";
const separator = value.includes("/") ? "/" : value.includes(",") ? "," : null;
if (!separator) return "unknown";
const [provider, ...rest] = value.split(separator);
const model = rest.join(separator);
if (!provider || !model) return "unknown";
if (!providerHasFreeModels(provider)) return "unknown";
return isFreeModel(provider, { id: model }) ? "free" : "paid";
}
/**
* Whether a glob `pattern` (as used by `ModelRoutingSection`'s per-model
* combo mappings) resolves ONLY to paid models in the catalog. Fails open
* (returns `false`) when the pattern matches nothing recognizable, or when
* at least one match is free — only an all-paid match set is flagged, so a
* mixed-catalog pattern is never blocked.
*/
export function matchesOnlyPaidModels(pattern: string): boolean {
if (typeof pattern !== "string" || pattern.trim() === "") return false;
let regex: RegExp;
try {
regex = globToRegex(pattern);
} catch {
return false;
}
let matched = false;
for (const m of AI_MODELS) {
const fullId = `${m.provider}/${m.model}`;
if (!regex.test(fullId)) continue;
matched = true;
if (isFreeModel(m.provider, { id: m.model })) return false;
}
return matched;
}

View File

@@ -0,0 +1,24 @@
/**
* Shared glob → RegExp conversion.
*
* Extracted from `src/lib/db/modelComboMappings.ts` (#6540) so client-side
* code (which cannot import that module — it pulls in `getDbInstance` /
* server-only DB wiring) can reuse the exact same pattern-matching semantics
* instead of duplicating the regex-building logic (the repo's ReDoS
* convention warns against duplicated ad-hoc regex construction).
*/
/**
* Convert a simple glob pattern to a RegExp.
* Supports `*` (any characters) and `?` (single character).
* Case-insensitive matching. Bounded, non-catastrophic-backtracking: all
* regex specials are escaped before the glob wildcards are substituted, so
* there is no nested-quantifier construction.
*/
export function globToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex specials
.replace(/\*/g, ".*") // * → .*
.replace(/\?/g, "."); // ? → .
return new RegExp(`^${escaped}$`, "i");
}

View File

@@ -0,0 +1,35 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { globToRegex } from "@/shared/utils/globPattern";
test("globToRegex — * matches any sequence of characters", () => {
const re = globToRegex("claude-sonnet*");
assert.equal(re.test("claude-sonnet-4"), true);
assert.equal(re.test("claude-sonnet"), true);
assert.equal(re.test("claude-opus-4"), false);
});
test("globToRegex — ? matches exactly one character", () => {
const re = globToRegex("gpt-?");
assert.equal(re.test("gpt-4"), true);
assert.equal(re.test("gpt-40"), false);
assert.equal(re.test("gpt-"), false);
});
test("globToRegex — case-insensitive", () => {
const re = globToRegex("Claude-Sonnet*");
assert.equal(re.test("claude-sonnet-4"), true);
assert.equal(re.test("CLAUDE-SONNET-4"), true);
});
test("globToRegex — anchored (no partial match)", () => {
const re = globToRegex("sonnet");
assert.equal(re.test("claude-sonnet-4"), false);
assert.equal(re.test("sonnet"), true);
});
test("globToRegex — escapes regex special characters", () => {
const re = globToRegex("gpt-4.1");
assert.equal(re.test("gpt-4.1"), true);
assert.equal(re.test("gpt-4X1"), false); // literal dot, not "any char"
});

View File

@@ -0,0 +1,45 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isPaidModelTarget, matchesOnlyPaidModels } from "@/shared/utils/freeModels";
test("isPaidModelTarget — documented free model → 'free'", () => {
assert.equal(isPaidModelTarget("openrouter/auto"), "free");
});
test("isPaidModelTarget — provider in free catalog but model not listed free → 'paid'", () => {
assert.equal(isPaidModelTarget("together/Qwen/Qwen3-235B-A22B"), "paid");
});
test("isPaidModelTarget — no separator (combo/alias name) → 'unknown' (fail open)", () => {
assert.equal(isPaidModelTarget("my-combo-name"), "unknown");
});
test("isPaidModelTarget — provider not in free catalog at all → 'unknown' (fail open)", () => {
assert.equal(isPaidModelTarget("totally-unknown-provider/whatever"), "unknown");
});
test("isPaidModelTarget — comma-separated form matches slash form", () => {
assert.equal(isPaidModelTarget("openrouter,auto"), isPaidModelTarget("openrouter/auto"));
});
test("isPaidModelTarget — empty/non-string input → 'unknown'", () => {
assert.equal(isPaidModelTarget(""), "unknown");
// @ts-expect-error — exercising runtime guard against non-string input
assert.equal(isPaidModelTarget(undefined), "unknown");
});
test("matchesOnlyPaidModels — true when every match is paid", () => {
assert.equal(matchesOnlyPaidModels("together/*"), true);
});
test("matchesOnlyPaidModels — false when at least one match is free", () => {
assert.equal(matchesOnlyPaidModels("openrouter/*"), false);
});
test("matchesOnlyPaidModels — false (fail open) when there are zero matches", () => {
assert.equal(matchesOnlyPaidModels("zzz-totally-nonexistent-pattern-*"), false);
});
test("matchesOnlyPaidModels — false on empty pattern", () => {
assert.equal(matchesOnlyPaidModels(""), false);
});

View File

@@ -0,0 +1,182 @@
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";
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-paid-target-routes-6540-"));
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 settingsRoute = await import("../../src/app/api/settings/route.ts");
const comboDefaultsRoute = await import("../../src/app/api/settings/combo-defaults/route.ts");
const backgroundDegradationRoute = await import(
"../../src/app/api/settings/background-degradation/route.ts"
);
// A provider present in the free-model catalog (so providerHasFreeModels is
// true) but a model id that is NOT one of its documented free models.
const PAID_TARGET = "together/Qwen/Qwen3-235B-A22B";
// A documented free model.
const FREE_TARGET = "openrouter/auto";
// No "/" or "," — a combo/alias name, fails open ("unknown").
const UNKNOWN_TARGET = "my-combo-alias";
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── PATCH /api/settings — webSearchRouteModel ──────────────────────────────
test("PATCH /api/settings blocks a paid webSearchRouteModel when hidePaidModels is on", async () => {
await settingsDb.updateSettings({ hidePaidModels: true });
const response = await settingsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings", {
method: "PATCH",
body: { webSearchRouteModel: PAID_TARGET },
})
);
assert.equal(response.status, 400);
const body = await response.json();
assert.equal(body.error.code, "PAID_MODEL_TARGET_BLOCKED");
});
test("PATCH /api/settings allows the same paid webSearchRouteModel when hidePaidModels is off", async () => {
await settingsDb.updateSettings({ hidePaidModels: false });
const response = await settingsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings", {
method: "PATCH",
body: { webSearchRouteModel: PAID_TARGET },
})
);
assert.equal(response.status, 200);
});
test("PATCH /api/settings allows an unknown/alias webSearchRouteModel even when hidePaidModels is on (fail open)", async () => {
await settingsDb.updateSettings({ hidePaidModels: true });
const response = await settingsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings", {
method: "PATCH",
body: { webSearchRouteModel: UNKNOWN_TARGET },
})
);
assert.equal(response.status, 200);
});
test("PATCH /api/settings allows a free webSearchRouteModel when hidePaidModels is on", async () => {
await settingsDb.updateSettings({ hidePaidModels: true });
const response = await settingsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings", {
method: "PATCH",
body: { webSearchRouteModel: FREE_TARGET },
})
);
assert.equal(response.status, 200);
});
// ── PATCH /api/settings/combo-defaults — handoffModel ──────────────────────
test("PATCH /api/settings/combo-defaults blocks a paid handoffModel when hidePaidModels is on", async () => {
await settingsDb.updateSettings({ hidePaidModels: true });
const response = await comboDefaultsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings/combo-defaults", {
method: "PATCH",
body: { comboDefaults: { handoffModel: PAID_TARGET } },
})
);
assert.equal(response.status, 400);
const body = await response.json();
assert.equal(body.error.code, "PAID_MODEL_TARGET_BLOCKED");
});
test("PATCH /api/settings/combo-defaults allows the same paid handoffModel when hidePaidModels is off", async () => {
await settingsDb.updateSettings({ hidePaidModels: false });
const response = await comboDefaultsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings/combo-defaults", {
method: "PATCH",
body: { comboDefaults: { handoffModel: PAID_TARGET } },
})
);
assert.equal(response.status, 200);
});
test("PATCH /api/settings/combo-defaults allows an unknown/alias handoffModel even when hidePaidModels is on (fail open)", async () => {
await settingsDb.updateSettings({ hidePaidModels: true });
const response = await comboDefaultsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings/combo-defaults", {
method: "PATCH",
body: { comboDefaults: { handoffModel: UNKNOWN_TARGET } },
})
);
assert.equal(response.status, 200);
});
// ── PUT /api/settings/background-degradation — degradationMap "to" values ──
test("PUT /api/settings/background-degradation blocks a paid degradationMap 'to' value when hidePaidModels is on", async () => {
await settingsDb.updateSettings({ hidePaidModels: true });
const response = await backgroundDegradationRoute.PUT(
await makeManagementSessionRequest("http://localhost/api/settings/background-degradation", {
method: "PUT",
body: { degradationMap: { "premium-model": PAID_TARGET } },
})
);
assert.equal(response.status, 400);
const body = await response.json();
assert.equal(body.error.code, "PAID_MODEL_TARGET_BLOCKED");
});
test("PUT /api/settings/background-degradation allows the same paid 'to' value when hidePaidModels is off", async () => {
await settingsDb.updateSettings({ hidePaidModels: false });
const response = await backgroundDegradationRoute.PUT(
await makeManagementSessionRequest("http://localhost/api/settings/background-degradation", {
method: "PUT",
body: { degradationMap: { "premium-model": PAID_TARGET } },
})
);
assert.equal(response.status, 200);
});
test("PUT /api/settings/background-degradation does NOT block a paid 'from' value (detection trigger, not invocation target)", async () => {
await settingsDb.updateSettings({ hidePaidModels: true });
const response = await backgroundDegradationRoute.PUT(
await makeManagementSessionRequest("http://localhost/api/settings/background-degradation", {
method: "PUT",
body: { degradationMap: { [PAID_TARGET]: FREE_TARGET } },
})
);
assert.equal(response.status, 200);
});

View File

@@ -0,0 +1,102 @@
// @vitest-environment jsdom
//
// #6540 — ModelSelectField renders the hidePaid-filtered `/api/models` catalog
// as a <select>, preserves an off-catalog saved value via a "(custom)" option
// instead of silently dropping it, and falls back to a plain text input when
// the fetch fails so the field never becomes unusable.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, afterEach } from "vitest";
const { default: ModelSelectField } = await import("../../../src/shared/components/ModelSelectField");
function okJson(data: unknown) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(data) } as Response);
}
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function render(el: React.ReactElement) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(el);
});
containers.push({ root, el: container });
return container;
}
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
const start = Date.now();
while (!fn()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, 20));
}
}
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.unstubAllGlobals();
});
describe("ModelSelectField (#6540)", () => {
it("renders a <select> populated from the fetched /api/models catalog", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
okJson({
models: [
{ provider: "openrouter", model: "auto", fullModel: "openrouter/auto" },
{ provider: "openai", model: "gpt-5", fullModel: "openai/gpt-5" },
],
})
)
);
const el = render(<ModelSelectField value="" onChange={() => {}} />);
const select = () => el.querySelector("select");
await waitFor(() => (select()?.querySelectorAll("option").length ?? 0) >= 3); // placeholder + 2
const optionValues = Array.from(select()!.querySelectorAll("option")).map(
(o) => (o as HTMLOptionElement).value
);
expect(optionValues).toContain("openrouter/auto");
expect(optionValues).toContain("openai/gpt-5");
});
it('injects a "(custom)" option and keeps it selected when value is off-catalog', async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
okJson({ models: [{ provider: "openrouter", model: "auto", fullModel: "openrouter/auto" }] })
)
);
const el = render(<ModelSelectField value="legacy/deprecated-model" onChange={() => {}} />);
const select = () => el.querySelector("select") as HTMLSelectElement | null;
await waitFor(() => (select()?.querySelectorAll("option").length ?? 0) >= 2);
const options = Array.from(select()!.querySelectorAll("option")).map(
(o) => (o as HTMLOptionElement).value
);
expect(options).toContain("legacy/deprecated-model");
expect(select()!.value).toBe("legacy/deprecated-model");
});
it("falls back to a text input when the /api/models fetch fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("network error")))
);
const el = render(<ModelSelectField value="some-value" onChange={() => {}} />);
await waitFor(() => el.querySelector("input") !== null);
expect(el.querySelector("select")).toBeNull();
expect((el.querySelector("input") as HTMLInputElement).value).toBe("some-value");
});
});