@@ -231,7 +259,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
diff --git a/src/shared/components/ModelSelectField.tsx b/src/shared/components/ModelSelectField.tsx
new file mode 100644
index 0000000000..f29d1c83c6
--- /dev/null
+++ b/src/shared/components/ModelSelectField.tsx
@@ -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({ 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 (
+ 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 (
+ 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}
+ />
+ );
+}
diff --git a/src/shared/components/index.tsx b/src/shared/components/index.tsx
index 774165b1ce..2aa5a2f4e0 100644
--- a/src/shared/components/index.tsx
+++ b/src/shared/components/index.tsx
@@ -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";
diff --git a/src/shared/utils/freeModels.ts b/src/shared/utils/freeModels.ts
index 4aac12a10e..5da7d8868c 100644
--- a/src/shared/utils/freeModels.ts
+++ b/src/shared/utils/freeModels.ts
@@ -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(
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;
+}
diff --git a/src/shared/utils/globPattern.ts b/src/shared/utils/globPattern.ts
new file mode 100644
index 0000000000..d750f9b536
--- /dev/null
+++ b/src/shared/utils/globPattern.ts
@@ -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");
+}
diff --git a/tests/unit/glob-pattern-6540.test.ts b/tests/unit/glob-pattern-6540.test.ts
new file mode 100644
index 0000000000..5aeefa36c6
--- /dev/null
+++ b/tests/unit/glob-pattern-6540.test.ts
@@ -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"
+});
diff --git a/tests/unit/paid-model-target-6540.test.ts b/tests/unit/paid-model-target-6540.test.ts
new file mode 100644
index 0000000000..d8783f975b
--- /dev/null
+++ b/tests/unit/paid-model-target-6540.test.ts
@@ -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);
+});
diff --git a/tests/unit/paid-model-target-routes-6540.test.ts b/tests/unit/paid-model-target-routes-6540.test.ts
new file mode 100644
index 0000000000..63e216e84a
--- /dev/null
+++ b/tests/unit/paid-model-target-routes-6540.test.ts
@@ -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);
+});
diff --git a/tests/unit/ui/model-select-field-6540.test.tsx b/tests/unit/ui/model-select-field-6540.test.tsx
new file mode 100644
index 0000000000..f22357da50
--- /dev/null
+++ b/tests/unit/ui/model-select-field-6540.test.tsx
@@ -0,0 +1,102 @@
+// @vitest-environment jsdom
+//
+// #6540 — ModelSelectField renders the hidePaid-filtered `/api/models` catalog
+// as a , 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; 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 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( {}} />);
+ 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( {}} />);
+ 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( {}} />);
+ await waitFor(() => el.querySelector("input") !== null);
+
+ expect(el.querySelector("select")).toBeNull();
+ expect((el.querySelector("input") as HTMLInputElement).value).toBe("some-value");
+ });
+});