Files
OmniRoute/src/app/api/settings/combo-defaults/route.ts
Diego Rodrigues de Sa e Souza 8143d8e3a6 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.
2026-07-17 10:40:18 -03:00

143 lines
4.7 KiB
TypeScript

import { NextResponse } from "next/server";
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",
"healthCheckEnabled",
"healthCheckTimeoutMs",
]);
function sanitizeComboRuntimeConfig(config?: Record<string, any> | null) {
if (!config || typeof config !== "object") return {};
return Object.fromEntries(
Object.entries(config).filter(
([key, value]) =>
value !== undefined && value !== null && !LEGACY_COMBO_RESILIENCE_KEYS.has(key)
)
);
}
function sanitizeProviderOverrides(overrides?: Record<string, any> | null) {
if (!overrides || typeof overrides !== "object") return {};
return Object.fromEntries(
Object.entries(overrides).map(([providerId, config]) => [
providerId,
sanitizeComboRuntimeConfig(config),
])
);
}
/**
* GET /api/settings/combo-defaults
* Returns the current combo global defaults and provider overrides
*/
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const settings: any = await getSettings();
const comboDefaults = sanitizeComboRuntimeConfig(settings.comboDefaults);
const providerOverrides = sanitizeProviderOverrides(settings.providerOverrides);
return NextResponse.json({
comboDefaults:
Object.keys(comboDefaults).length > 0
? comboDefaults
: {
strategy: "priority",
maxRetries: 1,
retryDelayMs: 2000,
fallbackDelayMs: 0,
handoffThreshold: 0.85,
handoffModel: "",
maxMessagesForSummary: 30,
maxComboDepth: 3,
trackMetrics: true,
reasoningTokenBufferEnabled: true,
zeroLatencyOptimizationsEnabled: false,
},
providerOverrides,
});
} catch (error) {
console.log("Error fetching combo defaults:", error);
return NextResponse.json({ error: "Failed to fetch combo defaults" }, { status: 500 });
}
}
/**
* PATCH /api/settings/combo-defaults
* Update combo global defaults and/or provider overrides
* Body: { comboDefaults?: {...}, providerOverrides?: {...} }
*/
export async function PATCH(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(updateComboDefaultsSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
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) {
updates.comboDefaults = sanitizeComboRuntimeConfig(body.comboDefaults);
}
if (body.providerOverrides) {
updates.providerOverrides = sanitizeProviderOverrides(body.providerOverrides);
}
const settings: any = await updateSettings(updates);
return NextResponse.json({
comboDefaults: sanitizeComboRuntimeConfig(settings.comboDefaults),
providerOverrides: sanitizeProviderOverrides(settings.providerOverrides),
});
} catch (error) {
console.log("Error updating combo defaults:", error);
return NextResponse.json({ error: "Failed to update combo defaults" }, { status: 500 });
}
}