feat(settings): configurable model catalog cache TTL (#8219)

* feat(settings): configurable model catalog cache TTL

Add modelCatalogCacheTtlMs to DatabaseSettings with default 1500ms.
Extend cache-config API route to accept the new field.
Replace hardcoded catalog cache TTL with dynamic settings value.
Add 'Cache' settings tab at /dashboard/settings/cache with sidebar entry,
i18n keys, header description, and legacy route redirect.

* feat(combo): add model connection filter toggle to ModelSelectModal

Adds a 'Show configured only' checkbox below the search bar in
ModelSelectModal that filters each provider group's models through
hasEligibleConnectionForModel. Toggle state persists in localStorage.

- Import hasEligibleConnectionForModel from domain/connectionModelRules
- showConfiguredOnly state + localStorage persistence
- connectionFilteredGroups memo layered on filteredGroups
- Renders both provider section and empty state from connectionFilteredGroups

* test(combo): add connection filter toggle tests for ModelSelectModal

Three test cases: (1) hide excluded models when toggle on,
(2) show empty state when all models excluded,
(3) drop provider group when all its models excluded.
All 3 tests pass.

* fix(settings): correct cache-config route import + add route/tab coverage

The cache-config route imported get/update helpers from a nonexistent
module (@/lib/localDb/databaseSettings) and called an undefined
updateSettings() in PUT, crashing every request. Import the real
databaseSettings module (matching the sibling database/route.ts
convention) and call updateDatabaseSettings(); idempotencyWindowMs is
routed through the flat @/lib/db/settings module instead, since that is
where it is actually read at runtime (idempotencyLayer.ts,
runtimeSettings.ts) — it was never part of the databaseSettings "cache"
section type.

Also fixes a dashboard-typecheck regression in ModelSelectModal.tsx: the
new connection-filter toggle called hasEligibleConnectionForModel() with
activeProviders entries typed too narrowly to include
providerSpecificData, which real connection objects carry at runtime.

Adds:
- tests/unit/cache-config-route-8219.test.ts: GET/PUT resolve without
  crashing, modelCatalogCacheTtlMs and idempotencyWindowMs round-trip.
- tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx: CacheSettingsTab
  min/max TTL bounds (100ms/60000ms) gate the Save button and surface a
  validation message; in-bounds values PUT correctly.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): rebaseline sections.ts for #8219 own-growth

---------

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Paijo
2026-07-23 18:46:22 +07:00
committed by GitHub
parent d59eec6b19
commit 2a865aaaa7
18 changed files with 762 additions and 27 deletions

View File

@@ -325,7 +325,8 @@
"src/lib/localDb.ts": 808,
"_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).",
"_rebaseline_2026_07_21_8034_compression_exclusions_sidebar": "#8034 (compression exclusions dashboard tab) own growth: sections.ts 796->806 (+10, one new COMPRESSION_CONTEXT_GROUP sidebar item linking /dashboard/compression/exclusions). The file was already 796/800 before this PR (organic growth from prior sidebar entries), so a single new nav item pushed it 6 lines over cap. Freezing at 806 (cannot grow further); the sidebar item array is data, not extractable logic.",
"src/shared/constants/sidebarVisibility/sections.ts": 806,
"_rebaseline_2026_07_23_8219_cache_ttl_settings_sidebar": "#8219 (@oyi77) own growth: sections.ts 806->813 (+7) — configurable model-catalog cache-TTL settings adds a new sidebar nav entry + its visibility wiring. Sidebar item array is data, not extractable logic; frozen at new size.",
"src/shared/constants/sidebarVisibility/sections.ts": 813,
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size.",
"_rebaseline_2026_07_22_8081_reasoning_placeholder_guard": "#8081 (@Dingding-leo) own growth: openai-responses.ts 1125->1137 (+12) restructuring the reasoning-placeholder guard so it skips only the empty content block and still emits finish_reason/tool_calls in the same chunk. Cohesive translator wiring; frozen at new size.",
"open-sse/services/usage/antigravity.ts": 802,

View File

@@ -0,0 +1,7 @@
"use client";
import CacheSettingsTab from "../components/CacheSettingsTab";
export default function SettingsCachePage() {
return <CacheSettingsTab />;
}

View File

@@ -0,0 +1,156 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Button, Card } from "@/shared/components";
import { useTranslations } from "next-intl";
type Message = { type: "success" | "error"; text: string };
interface CacheConfigResponse {
modelCatalogCacheTtlMs: number;
[key: string]: unknown;
}
const DEFAULT_TTL_MS = 1500;
const MIN_TTL_MS = 100;
const MAX_TTL_MS = 60000;
export default function CacheSettingsTab() {
const t = useTranslations("settings");
const [value, setValue] = useState(String(DEFAULT_TTL_MS));
const [savedValue, setSavedValue] = useState(String(DEFAULT_TTL_MS));
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
useEffect(() => {
let active = true;
fetch("/api/settings/cache-config")
.then((response) => {
if (!response.ok) throw new Error(`Cache config API returned ${response.status}`);
return response.json() as Promise<CacheConfigResponse>;
})
.then((config) => {
if (!active) return;
const ms = config.modelCatalogCacheTtlMs ?? DEFAULT_TTL_MS;
const str = typeof ms === "number" && Number.isFinite(ms) ? String(ms) : String(DEFAULT_TTL_MS);
setValue(str);
setSavedValue(str);
})
.catch((error) => {
console.error("Failed to load cache config:", error);
if (active) setMessage({ type: "error", text: t("cacheConfigLoadFailed") });
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [t]);
const dirty = value.trim() !== savedValue;
const saveTtl = useCallback(async () => {
if (!dirty) return;
const parsed = Number(value.trim());
if (!Number.isInteger(parsed)) return;
if (parsed < MIN_TTL_MS || parsed > MAX_TTL_MS) return;
setSaving(true);
setMessage(null);
try {
const response = await fetch("/api/settings/cache-config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelCatalogCacheTtlMs: parsed }),
});
if (!response.ok) throw new Error(`Cache config API returned ${response.status}`);
const config = (await response.json()) as CacheConfigResponse;
const saved = String(config.modelCatalogCacheTtlMs ?? parsed);
setValue(saved);
setSavedValue(saved);
setMessage({ type: "success", text: t("cacheConfigSaveSuccess") });
} catch (error) {
console.error("Failed to save cache config:", error);
setMessage({ type: "error", text: t("cacheConfigSaveFailed") });
} finally {
setSaving(false);
}
}, [dirty, t, value]);
const validationError = (() => {
const trimmed = value.trim();
if (!trimmed) return "Required";
const parsed = Number(trimmed);
if (!Number.isInteger(parsed)) return t("modelCatalogTtlWholeNumberError");
if (parsed < MIN_TTL_MS) return t("modelCatalogTtlMinimumError", { min: MIN_TTL_MS });
if (parsed > MAX_TTL_MS) return t("modelCatalogTtlMaximumError", { max: MAX_TTL_MS });
return null;
})();
return (
<Card className="p-6 mt-4">
<div className="flex flex-col gap-3">
<div>
<p className="font-medium">{t("modelCatalogCacheTtl")}</p>
<p className="text-sm text-text-muted mt-1">{t("modelCatalogCacheTtlDescription")}</p>
</div>
<div className="flex items-center gap-3">
<label htmlFor="model-catalog-ttl-ms" className="sr-only">
{t("modelCatalogCacheTtlLabel")}
</label>
<input
id="model-catalog-ttl-ms"
type="number"
min={MIN_TTL_MS}
max={MAX_TTL_MS}
step={100}
value={value}
onChange={(event) => {
setValue(event.target.value);
setMessage(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && dirty) void saveTtl();
}}
className="w-32 px-3 py-1.5 rounded bg-surface-2 border border-border text-sm text-text-primary"
disabled={loading || saving}
/>
<span className="text-xs text-text-muted">ms</span>
<Button
size="sm"
variant="primary"
disabled={loading || Boolean(validationError) || !dirty}
onClick={saveTtl}
>
{saving ? t("modelCatalogCacheTtlSaving") : t("modelCatalogCacheTtlSave")}
</Button>
{dirty && (
<span className="text-xs text-text-muted">
{t("modelCatalogCacheTtlCurrent", { value: savedValue })}
</span>
)}
</div>
{validationError && <p className="text-xs text-red-500">{validationError}</p>}
{message && (
<p
className={`text-xs ${
message.type === "success"
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}`}
>
{message.text}
</p>
)}
</div>
</Card>
);
}

View File

@@ -6,6 +6,7 @@ const LEGACY_TAB_ROUTES: Record<string, string> = {
appearance: "/dashboard/settings/appearance",
featureFlags: "/dashboard/settings/feature-flags",
"feature-flags": "/dashboard/settings/feature-flags",
cache: "/dashboard/settings/cache",
general: "/dashboard/settings/general",
resilience: "/dashboard/settings/resilience",
routing: "/dashboard/settings/routing",

View File

@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import {
getDatabaseSettings,
updateDatabaseSettings,
type UserDatabaseSettings,
} from "@/lib/db/databaseSettings";
import { getSettings, updateSettings } from "@/lib/db/settings";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { z } from "zod";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -12,6 +17,7 @@ const cacheConfigUpdateSchema = z.object({
promptCacheStrategy: z.enum(["auto", "system-only", "manual"]).optional(),
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
idempotencyWindowMs: z.number().positive().optional(),
modelCatalogCacheTtlMs: z.number().positive().optional(),
});
const CACHE_CONFIG_KEYS = [
@@ -22,6 +28,7 @@ const CACHE_CONFIG_KEYS = [
"promptCacheStrategy",
"alwaysPreserveClientCache",
"idempotencyWindowMs",
"modelCatalogCacheTtlMs",
] as const;
const DEFAULTS = {
@@ -32,6 +39,7 @@ const DEFAULTS = {
promptCacheStrategy: "auto",
alwaysPreserveClientCache: "auto",
idempotencyWindowMs: 5000,
modelCatalogCacheTtlMs: 1500,
};
export async function GET(request: NextRequest) {
@@ -40,10 +48,19 @@ export async function GET(request: NextRequest) {
}
try {
const settings = await getSettings();
const dbSettings = getDatabaseSettings();
const cache = dbSettings.cache ?? {};
// idempotencyWindowMs is not part of the databaseSettings "cache" section —
// it lives in the flat general settings (src/lib/db/settings.ts), which is
// where src/lib/idempotencyLayer.ts actually reads it from.
const flatSettings = await getSettings();
const config: Record<string, unknown> = {};
for (const key of CACHE_CONFIG_KEYS) {
config[key] = settings[key] ?? DEFAULTS[key];
if (key === "idempotencyWindowMs") {
config[key] = flatSettings.idempotencyWindowMs ?? DEFAULTS[key];
} else {
config[key] = (cache as Record<string, unknown>)[key] ?? DEFAULTS[key];
}
}
return NextResponse.json(config);
} catch (error) {
@@ -69,7 +86,7 @@ export async function PUT(request: NextRequest) {
return validation.response;
}
const updates: Record<string, unknown> = {};
const updates: Partial<UserDatabaseSettings["cache"]> = {};
const body = validation.data;
if (body.semanticCacheEnabled !== undefined) {
@@ -90,11 +107,21 @@ export async function PUT(request: NextRequest) {
if (body.alwaysPreserveClientCache !== undefined) {
updates.alwaysPreserveClientCache = body.alwaysPreserveClientCache;
}
if (body.modelCatalogCacheTtlMs !== undefined) {
updates.modelCatalogCacheTtlMs = body.modelCatalogCacheTtlMs;
}
// updateDatabaseSettings() calls invalidateDbCache("settings") internally,
// which bumps the model-catalog cache version so in-flight responses pick
// up the fresh TTL — no separate version bump needed here.
updateDatabaseSettings({ cache: updates });
// idempotencyWindowMs is not part of the databaseSettings "cache" section —
// persist it through the flat general settings module instead (see GET).
if (body.idempotencyWindowMs !== undefined) {
updates.idempotencyWindowMs = body.idempotencyWindowMs;
await updateSettings({ idempotencyWindowMs: body.idempotencyWindowMs });
}
await updateSettings(updates);
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });

View File

@@ -8,6 +8,7 @@ import {
getCachedProviderNodes,
getModelIsHidden,
getModelAliases,
getDatabaseSettings,
} from "@/lib/localDb";
import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView";
import { extractAliasBackedModels } from "./aliasBackedModels";
@@ -118,7 +119,7 @@ type CachedCatalog = {
status: number;
expiresAt: number;
};
const CATALOG_CACHE_TTL_MS = 1500; // ~one request-latency window; safe vs SDK bursts
const CATALOG_CACHE_TTL_MS_DEFAULT = 1500; // fallback; overridden by settings
const catalogCache = new Map<string, CachedCatalog>();
const catalogInFlight = new Map<string, Promise<CachedCatalog>>();
@@ -141,7 +142,8 @@ function buildCatalogCacheKey(request: Request): string {
const prefix = url.searchParams.get("prefix") || "";
const apiKey = extractApiKey(request) || "";
const isCodex = isCodexModelCatalogClient(request) ? "1" : "0";
return `${prefix}|${isCodex}|${apiKey}`;
const configuredOnly = url.searchParams.get("configuredOnly") === "true" ? "1" : "0";
return `${prefix}|${isCodex}|${apiKey}|${configuredOnly}`;
}
// Tracks the model-catalog cache version (src/lib/db/readCache.ts) as of the last
@@ -219,7 +221,6 @@ export async function getUnifiedModelsResponse(
headers: mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders),
});
}
let inflight = catalogInFlight.get(cacheKey);
if (!inflight) {
inflight = buildCatalogPayload(request).then((payload) => {
@@ -227,7 +228,7 @@ export async function getUnifiedModelsResponse(
body: payload.body,
headers: payload.headers,
status: payload.status,
expiresAt: Date.now() + CATALOG_CACHE_TTL_MS,
expiresAt: Date.now() + payload.cacheTTL,
});
return payload;
});
@@ -259,7 +260,7 @@ export async function getUnifiedModelsResponse(
async function buildCatalogPayload(
request: Request
): Promise<{ body: string; headers: Record<string, string>; status: number }> {
): Promise<{ body: string; headers: Record<string, string>; status: number; cacheTTL: number }> {
_catalogBuilderRuns++;
const built = await buildUnifiedModelsResponseCore(request);
const body = await built.text();
@@ -267,13 +268,16 @@ async function buildCatalogPayload(
built.headers.forEach((value, key) => {
headers[key] = value;
});
// buildUnifiedModelsResponseCore() itself returns a real error Response (status 500)
// when the builder crashes (e.g. a DB read throws) instead of throwing — status must
// be captured and replayed through the cache/coalescing wrapper above, otherwise the
// caller-facing Response (built with a fresh `new Response(...)`, defaulting to 200)
// silently downgrades a genuine server error into an HTTP 200 with an `error`-shaped
// JSON body.
return { body, headers, status: built.status };
// Read the configurable cache TTL from database settings.
// Falls back to the hardcoded default if not set or on error.
let cacheTTL = CATALOG_CACHE_TTL_MS_DEFAULT;
try {
const dbSettings = await getDatabaseSettings();
cacheTTL = dbSettings.cache?.modelCatalogCacheTtlMs ?? CATALOG_CACHE_TTL_MS_DEFAULT;
} catch {
// Swallow — use default TTL on DB error
}
return { body, headers, status: built.status, cacheTTL };
}
/**
@@ -1468,6 +1472,15 @@ async function buildUnifiedModelsResponseCore(
finalModels = filtered;
}
}
// ?configuredOnly — hide models that have no eligible DB connection.
// Applied after the API-key filter so the key filter runs first, then
// variants are only generated for surviving models.
if (new URL(request.url).searchParams.get("configuredOnly") === "true") {
finalModels = finalModels.filter((m) => {
if (!m.root) return true;
return hasEligibleConnectionForModel(connections, m.root);
});
}
// Advertise Claude reasoning-effort variants (claude/<model>-{low,medium,high[,xhigh]}).
// Derived from the already key-filtered list so a variant only appears when its real

View File

@@ -1102,6 +1102,7 @@
"settingsSecurity": "Security",
"settingsAccessTokens": "Access Tokens",
"settingsFeatureFlags": "Feature Flags",
"settingsCache": "Cache",
"settingsAuthz": "Authz",
"settingsRouting": "Routing",
"settingsResilience": "Resilience",
@@ -1186,6 +1187,7 @@
"settingsSecuritySubtitle": "Auth and encryption",
"settingsAccessTokensSubtitle": "Scoped CLI tokens for remote mode",
"settingsFeatureFlagsSubtitle": "Toggle system capabilities",
"settingsCacheSubtitle": "Model catalog and response caching",
"settingsSidebar": "Sidebar",
"settingsSidebarSubtitle": "Customize sidebar layout",
"settingsAuthzSubtitle": "Route inventory and bypass policy",
@@ -1548,6 +1550,7 @@
"settingsGeneralDescription": "Storage, database, backups, and retention settings",
"settingsAppearanceDescription": "Theme, branding, and visual customization",
"settingsAiDescription": "AI behaviors, thinking budgets, vision, and memory settings",
"settingsCacheDescription": "TTL for model catalog cache entries",
"settingsSecurityDescription": "Authentication, authorization, and access control settings",
"featureFlags": "Feature Flags",
"featureFlagsDescription": "Control system capabilities and experimental features",

View File

@@ -40,6 +40,7 @@ const LEGACY_FLAT_KEYS: {
promptCacheEnabled: ["promptCacheEnabled"],
promptCacheStrategy: ["promptCacheStrategy"],
alwaysPreserveClientCache: ["alwaysPreserveClientCache"],
modelCatalogCacheTtlMs: ["modelCatalogCacheTtlMs"],
},
retention: {
quotaSnapshots: ["quotaSnapshots"],

View File

@@ -98,6 +98,7 @@ const HEADER_DESCRIPTIONS: Partial<Record<HideableSidebarItemId | "omni-skills",
"settings-security": "settingsSecurityDescription",
"settings-routing": "settingsRoutingDescription",
"settings-resilience": "settingsResilienceDescription",
"settings-cache": "settingsCacheDescription",
"settings-advanced": "settingsAdvancedDescription",
// Proxy sub-pages
"mitm-proxy": "mitmProxyDescription",

View File

@@ -18,6 +18,7 @@ import {
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules";
// Provider order: OAuth first, then no-auth, then API Key (matches dashboard/providers)
const PROVIDER_ORDER = [
@@ -39,7 +40,15 @@ type ModelSelectModalProps = {
onDeselect?: (model: unknown) => void;
selectedModel?: string;
selectedModels?: string[];
activeProviders?: Array<{ provider: string; id?: string | number }>;
activeProviders?: Array<{
provider: string;
id?: string | number;
// Present on real connection objects (see fetchConnections() callers);
// consumed by hasEligibleConnectionForModel() for the "configured only"
// filter toggle below (#8219 dashboard-typecheck fix — the prop type was
// too narrow for the field the new filter actually reads).
providerSpecificData?: unknown;
}>;
title?: string;
modelAliases?: Record<string, string>;
addedModelValues?: string[];
@@ -84,6 +93,14 @@ export default function ModelSelectModal({
// keyed by provider id. Merged into the alias/custom/fallback list below and
// tagged with the `auto` source badge. Ported from upstream PR
// decolua/9router#2018 (Hamsa_M).
const [showConfiguredOnly, setShowConfiguredOnly] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("modelSelectShowConfiguredOnly") === "true";
});
useEffect(() => {
localStorage.setItem("modelSelectShowConfiguredOnly", String(showConfiguredOnly));
}, [showConfiguredOnly]);
const [fetchedModels, setFetchedModels] = useState<Record<string, any[]>>({});
const fetchCombos = async () => {
@@ -165,8 +182,7 @@ export default function ModelSelectModal({
const loadCustomProviderModels = async () => {
const customProviderIds = activeProviders
.filter(
(p) =>
isOpenAICompatibleProvider(p.provider) || isAnthropicCompatibleProvider(p.provider)
(p) => isOpenAICompatibleProvider(p.provider) || isAnthropicCompatibleProvider(p.provider)
)
.map((p) => p.provider);
@@ -236,9 +252,7 @@ export default function ModelSelectModal({
// any explicitly hidden by the operator (#7156 — the legacy picker
// must respect the same isHidden flag the Precision Builder and
// /v1/models catalog already honor).
const providerCustomModels = (customModels[providerId] || []).filter(
(cm) => !cm.isHidden
);
const providerCustomModels = (customModels[providerId] || []).filter((cm) => !cm.isHidden);
if (providerInfo.passthroughModels) {
// Passthrough aliases are stored prefixed by the canonical providerId
@@ -427,6 +441,25 @@ export default function ModelSelectModal({
return filtered;
}, [groupedModels, searchQuery]);
// Filter models by connection eligibility when toggle is on
const connectionFilteredGroups = useMemo(() => {
if (!showConfiguredOnly) return filteredGroups;
const result: Record<string, any> = {};
Object.entries(filteredGroups).forEach(([providerId, group]: [string, any]) => {
const providerConnections = activeProviders.filter((p: any) => p.provider === providerId);
if (providerConnections.length === 0) return;
const eligibleModels = group.models.filter((model: any) =>
hasEligibleConnectionForModel(providerConnections, model.id)
);
if (eligibleModels.length > 0) {
result[providerId] = { ...group, models: eligibleModels };
}
});
return result;
}, [filteredGroups, showConfiguredOnly, activeProviders]);
const resolvedSelectedModels = multiSelect
? selectedModels
: selectedModel
@@ -508,6 +541,16 @@ export default function ModelSelectModal({
</div>
</div>
<label className="flex items-center gap-1.5 mt-1.5 text-xs text-text-muted cursor-pointer">
<input
type="checkbox"
checked={showConfiguredOnly}
onChange={(e) => setShowConfiguredOnly(e.target.checked)}
className="rounded border-border"
/>
{t("showConfiguredOnly")}
</label>
{/* Models grouped by provider - compact */}
<div className="max-h-[300px] overflow-y-auto space-y-3">
{/* Combos section - always first */}
@@ -545,7 +588,7 @@ export default function ModelSelectModal({
)}
{/* Provider models */}
{Object.entries(filteredGroups).map(([providerId, group]: [string, any]) => (
{Object.entries(connectionFilteredGroups).map(([providerId, group]: [string, any]) => (
<div key={providerId}>
{/* Provider header */}
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5">
@@ -587,7 +630,7 @@ export default function ModelSelectModal({
</div>
))}
{Object.keys(filteredGroups).length === 0 && filteredCombos.length === 0 && (
{Object.keys(connectionFilteredGroups).length === 0 && filteredCombos.length === 0 && (
<div className="text-center py-4 text-text-muted">
<span className="material-symbols-outlined text-2xl mb-1 block">search_off</span>
<p className="text-xs">{t("noModelsFound")}</p>

View File

@@ -72,6 +72,7 @@ export const SIDEBAR_ICON_ACCENTS: Partial<Record<SidebarItemId, string>> = {
"settings-advanced": "#F97316",
"settings-security": "#EF4444",
"settings-feature-flags": "#FACC15",
"settings-cache": "#84CC16",
"settings-sidebar": "#38BDF8",
docs: "#2563EB",
issues: "#DC2626",

View File

@@ -696,6 +696,13 @@ const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [
subtitleKey: "settingsFeatureFlagsSubtitle",
icon: "flag",
},
{
id: "settings-cache",
href: "/dashboard/settings/cache",
i18nKey: "settingsCache",
subtitleKey: "settingsCacheSubtitle",
icon: "memory",
},
{
id: "settings-sidebar",
href: "/dashboard/settings/sidebar",

View File

@@ -97,6 +97,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"settings-security",
"settings-access-tokens",
"settings-feature-flags",
"settings-cache",
"settings-sidebar",
// Help
"docs",

View File

@@ -404,6 +404,7 @@ export const databaseSettingsSchema = z
promptCacheEnabled: z.boolean(),
promptCacheStrategy: z.literal("auto").or(z.literal("system-only")).or(z.literal("manual")),
alwaysPreserveClientCache: z.literal("auto").or(z.literal("always")).or(z.literal("never")),
modelCatalogCacheTtlMs: z.number().int().min(500).max(60000),
}),
// Retention settings

View File

@@ -35,6 +35,8 @@ export interface DatabaseSettings {
promptCacheEnabled: boolean;
promptCacheStrategy: "auto" | "system-only" | "manual";
alwaysPreserveClientCache: "auto" | "always" | "never";
/** Model catalog /v1/models response cache TTL in milliseconds. */
modelCatalogCacheTtlMs: number;
};
/** 5. Retention (per-table cleanup policies) */
@@ -101,6 +103,7 @@ export const DEFAULT_DATABASE_SETTINGS: Omit<DatabaseSettings, "location" | "sta
promptCacheEnabled: true,
promptCacheStrategy: "auto",
alwaysPreserveClientCache: "auto",
modelCatalogCacheTtlMs: 1500,
},
retention: {
quotaSnapshots: 7,

View File

@@ -0,0 +1,83 @@
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";
// Regression guard for #8219: the cache-config route imported
// `updateSettings`/`getDatabaseSettings`/`updateDatabaseSettings` from a
// non-existent module (`@/lib/localDb/databaseSettings`) and called an
// undefined `updateSettings` in PUT. Any request crashed the route with a
// ReferenceError before this fix. This test proves:
// 1. The route module resolves without throwing on import.
// 2. GET/PUT resolve without crashing.
// 3. `modelCatalogCacheTtlMs` round-trips through PUT then GET.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cache-config-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../src/lib/db/core.ts");
function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function makeJsonRequest(method: string, body?: unknown): Request {
return new Request("http://localhost/api/settings/cache-config", {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
test.beforeEach(() => {
resetStorage();
});
test.after(() => {
resetStorage();
});
test("cache-config route resolves and modelCatalogCacheTtlMs round-trips", async (t) => {
// Importing the route module must not throw (RED before the fix: the
// module-level import of a nonexistent path crashed at import time).
const cacheConfigRoute = await import("../../src/app/api/settings/cache-config/route.ts");
await t.test("GET resolves without crashing and returns defaults", async () => {
const response = await cacheConfigRoute.GET(makeJsonRequest("GET") as never);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(typeof body.modelCatalogCacheTtlMs, "number");
});
await t.test("PUT resolves without crashing and persists the new TTL", async () => {
const putResponse = await cacheConfigRoute.PUT(
makeJsonRequest("PUT", { modelCatalogCacheTtlMs: 4242 }) as never
);
const putBody = await putResponse.json();
assert.equal(putResponse.status, 200);
assert.equal(putBody.ok, true);
const getResponse = await cacheConfigRoute.GET(makeJsonRequest("GET") as never);
const getBody = await getResponse.json();
assert.equal(getResponse.status, 200);
assert.equal(getBody.modelCatalogCacheTtlMs, 4242, "modelCatalogCacheTtlMs must round-trip");
});
await t.test("PUT persists idempotencyWindowMs via the flat settings module", async () => {
const putResponse = await cacheConfigRoute.PUT(
makeJsonRequest("PUT", { idempotencyWindowMs: 9000 }) as never
);
assert.equal(putResponse.status, 200);
const getResponse = await cacheConfigRoute.GET(makeJsonRequest("GET") as never);
const getBody = await getResponse.json();
assert.equal(getBody.idempotencyWindowMs, 9000);
});
});

View File

@@ -0,0 +1,160 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CacheSettingsTab from "@/app/(dashboard)/dashboard/settings/components/CacheSettingsTab";
// Regression coverage for #8219: CacheSettingsTab's client-side min/max TTL
// bounds (MIN_TTL_MS=100, MAX_TTL_MS=60000) gate the Save button and surface
// a validation message. This test proves those bounds actually work end to
// end against the (now-fixed) /api/settings/cache-config route.
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const roots: Array<{ root: Root; el: HTMLDivElement }> = [];
async function render(): Promise<HTMLDivElement> {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<CacheSettingsTab />);
});
roots.push({ root, el });
return el;
}
function getInput(container: HTMLDivElement): HTMLInputElement {
const input = container.querySelector("#model-catalog-ttl-ms");
if (!input) throw new Error("TTL input not found");
return input as HTMLInputElement;
}
function getSaveButton(container: HTMLDivElement): HTMLButtonElement {
const buttons = Array.from(container.querySelectorAll("button"));
const button = buttons.find((b) => b.textContent?.includes("modelCatalogCacheTtlSave"));
if (!button) throw new Error("Save button not found");
return button as HTMLButtonElement;
}
async function setInputValue(container: HTMLDivElement, value: string) {
const input = getInput(container);
// NOTE: this must be a *synchronous* act() — wrapping the dispatchEvent in
// `act(async () => { ... })` silently no-ops the value change here (the
// event fires but React's commit doesn't flush before the callback
// resolves), leaving the input showing its pre-dispatch value.
act(() => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function waitFor(predicate: () => boolean, label: string) {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > 2000) {
throw new Error(`Timed out waiting for: ${label}`);
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
}
describe("CacheSettingsTab TTL bounds", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/settings/cache-config")) {
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body));
return new Response(
JSON.stringify({ ok: true, modelCatalogCacheTtlMs: body.modelCatalogCacheTtlMs }),
{ status: 200 }
);
}
return new Response(JSON.stringify({ modelCatalogCacheTtlMs: 1500 }), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
for (const { root, el } of roots.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.unstubAllGlobals();
});
it("loads the persisted TTL and enables Save only once a value is dirty and valid", async () => {
const container = await render();
await waitFor(() => getInput(container).value === "1500", "initial TTL to load");
expect(getSaveButton(container).disabled).toBe(true); // not dirty yet
});
it("disables Save and shows an error below the minimum bound (100ms)", async () => {
const container = await render();
await waitFor(() => getInput(container).value === "1500", "initial TTL to load");
await setInputValue(container, "50");
await waitFor(
() => container.textContent?.includes("modelCatalogTtlMinimumError") ?? false,
"minimum bound error to render"
);
expect(getSaveButton(container).disabled).toBe(true);
});
it("disables Save and shows an error above the maximum bound (60000ms)", async () => {
const container = await render();
await waitFor(() => getInput(container).value === "1500", "initial TTL to load");
await setInputValue(container, "70000");
await waitFor(
() => container.textContent?.includes("modelCatalogTtlMaximumError") ?? false,
"maximum bound error to render"
);
expect(getSaveButton(container).disabled).toBe(true);
});
it("enables Save for an in-bounds value and PUTs modelCatalogCacheTtlMs", async () => {
const container = await render();
await waitFor(() => getInput(container).value === "1500", "initial TTL to load");
await setInputValue(container, "5000");
await waitFor(() => !getSaveButton(container).disabled, "Save to become enabled");
act(() => {
getSaveButton(container).click();
});
await waitFor(() => {
const putCall = fetchMock.mock.calls.find(
(call) =>
String(call[0]).includes("/api/settings/cache-config") && call[1]?.method === "PUT"
);
return Boolean(putCall);
}, "PUT request to be issued");
const putCall = fetchMock.mock.calls.find(
(call) => String(call[0]).includes("/api/settings/cache-config") && call[1]?.method === "PUT"
);
expect(putCall).toBeTruthy();
expect(JSON.parse(String(putCall?.[1]?.body))).toEqual({ modelCatalogCacheTtlMs: 5000 });
await waitFor(() => getInput(container).value === "5000", "input to reflect saved value");
});
});

View File

@@ -0,0 +1,226 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelSelectModal from "@/shared/components/ModelSelectModal";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const roots: Array<{ root: Root; el: HTMLDivElement }> = [];
async function render(
props: React.ComponentProps<typeof ModelSelectModal>
): Promise<HTMLDivElement> {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<ModelSelectModal {...props} />);
});
roots.push({ root, el });
return el;
}
beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/combos"))
return new Response(JSON.stringify({ combos: [] }), { status: 200 });
if (url.includes("/api/provider-nodes"))
return new Response(JSON.stringify({ nodes: [] }), { status: 200 });
if (url.includes("/api/provider-models")) {
return new Response(
JSON.stringify({
models: {
requesty: [
{ id: "m1", name: "Model One" },
{ id: "m2", name: "Model Two" },
{ id: "m3", name: "Model Three" },
],
},
modelCompatOverrides: [],
}),
{ status: 200 }
);
}
return new Response(JSON.stringify({}), { status: 200 });
})
);
});
afterEach(() => {
localStorage.removeItem("modelSelectShowConfiguredOnly");
for (const { root, el } of roots.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("ModelSelectModal connection filter toggle", () => {
it("hides models excluded by all connections when toggle is on", async () => {
const el = await render({
isOpen: true,
onClose: vi.fn(),
onSelect: vi.fn(),
activeProviders: [
{
provider: "requesty",
id: "conn-1",
providerSpecificData: { excludedModels: ["m3"] },
} as any,
],
modelAliases: {},
title: "Add model to combo",
});
// Wait for custom models to load from the mocked API
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
// With toggle off (default) — all models visible
expect(el.textContent).toContain("Model One");
expect(el.textContent).toContain("Model Two");
expect(el.textContent).toContain("Model Three");
expect(el.textContent).toContain("showConfiguredOnly");
// Find and click the checkbox
const checkbox = el.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).not.toBeNull();
await act(async () => {
checkbox.click();
});
// Wait for React state update and re-render
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
// Toggle on — m3 excluded by "conn-1" should be hidden
expect(el.textContent).toContain("Model One");
expect(el.textContent).toContain("Model Two");
expect(el.textContent).not.toContain("Model Three");
// Toggle off again should restore m3
await act(async () => {
checkbox.click();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(el.textContent).toContain("Model One");
expect(el.textContent).toContain("Model Two");
expect(el.textContent).toContain("Model Three");
});
it("shows empty state when toggle on and all models are excluded by connections", async () => {
const el = await render({
isOpen: true,
onClose: vi.fn(),
onSelect: vi.fn(),
activeProviders: [
{
provider: "requesty",
id: "conn-1",
providerSpecificData: { excludedModels: ["m1", "m2", "m3"] },
} as any,
],
modelAliases: {},
title: "Add model to combo",
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
// Toggle off (default) — all models visible
expect(el.textContent).toContain("Model One");
expect(el.textContent).toContain("Model Three");
const checkbox = el.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).not.toBeNull();
// Toggle on — all models excluded
await act(async () => {
checkbox.click();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(el.textContent).not.toContain("Model One");
expect(el.textContent).not.toContain("Model Two");
expect(el.textContent).not.toContain("Model Three");
expect(el.textContent).toContain("noModelsFound");
// Toggle off — models return
await act(async () => {
checkbox.click();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(el.textContent).toContain("Model One");
expect(el.textContent).toContain("Model Three");
});
it("drops provider group when all its models are excluded by connections", async () => {
const el = await render({
isOpen: true,
onClose: vi.fn(),
onSelect: vi.fn(),
activeProviders: [
{
provider: "requesty",
id: "conn-1",
providerSpecificData: { excludedModels: [] },
} as any,
{
provider: "openai",
id: "conn-2",
providerSpecificData: { excludedModels: ["*"] },
} as any,
],
modelAliases: {},
title: "Add model to combo",
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
// Toggle off — both provider groups visible
expect(el.textContent).toContain("Model One");
// openai has system models — but we just verify at least one is shown
expect(el.textContent).toContain("showConfiguredOnly");
const checkbox = el.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).not.toBeNull();
// Toggle on — openai all models excluded via "*" wildcard → group dropped
await act(async () => {
checkbox.click();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
// requesty models still visible
expect(el.textContent).toContain("Model One");
expect(el.textContent).toContain("Model Two");
expect(el.textContent).toContain("Model Three");
// openai models excluded — provider group dropped
expect(el.textContent).not.toContain("noModelsFound");
});
});