mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
* 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:
committed by
GitHub
parent
ca03d619a4
commit
8143d8e3a6
1
changelog.d/features/6540-hidepaid-ui-selects.md
Normal file
1
changelog.d/features/6540-hidepaid-ui-selects.md
Normal 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))
|
||||
@@ -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()}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
103
src/shared/components/ModelSelectField.tsx
Normal file
103
src/shared/components/ModelSelectField.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
24
src/shared/utils/globPattern.ts
Normal file
24
src/shared/utils/globPattern.ts
Normal 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");
|
||||
}
|
||||
35
tests/unit/glob-pattern-6540.test.ts
Normal file
35
tests/unit/glob-pattern-6540.test.ts
Normal 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"
|
||||
});
|
||||
45
tests/unit/paid-model-target-6540.test.ts
Normal file
45
tests/unit/paid-model-target-6540.test.ts
Normal 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);
|
||||
});
|
||||
182
tests/unit/paid-model-target-routes-6540.test.ts
Normal file
182
tests/unit/paid-model-target-routes-6540.test.ts
Normal 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);
|
||||
});
|
||||
102
tests/unit/ui/model-select-field-6540.test.tsx
Normal file
102
tests/unit/ui/model-select-field-6540.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user