From 2a865aaaa731554b9594cb76867ab18294cf23bd Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:46:22 +0700 Subject: [PATCH] feat(settings): configurable model catalog cache TTL (#8219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- config/quality/file-size-baseline.json | 3 +- .../dashboard/settings/cache/page.tsx | 7 + .../settings/components/CacheSettingsTab.tsx | 156 ++++++++++++ .../(dashboard)/dashboard/settings/page.tsx | 1 + src/app/api/settings/cache-config/route.ts | 39 ++- src/app/api/v1/models/catalog.ts | 37 ++- src/i18n/messages/en.json | 3 + src/lib/db/databaseSettings.ts | 1 + src/shared/components/Header.tsx | 1 + src/shared/components/ModelSelectModal.tsx | 59 ++++- src/shared/constants/sidebarVisibility.ts | 1 + .../constants/sidebarVisibility/sections.ts | 7 + .../constants/sidebarVisibility/types.ts | 1 + src/shared/validation/settingsSchemas.ts | 1 + src/types/databaseSettings.ts | 3 + tests/unit/cache-config-route-8219.test.ts | 83 +++++++ .../cache-settings-tab-bounds-8219.test.tsx | 160 +++++++++++++ ...el-select-modal-connection-filter.test.tsx | 226 ++++++++++++++++++ 18 files changed, 762 insertions(+), 27 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/cache/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx create mode 100644 tests/unit/cache-config-route-8219.test.ts create mode 100644 tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx create mode 100644 tests/unit/ui/model-select-modal-connection-filter.test.tsx diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1f1c902de9..2ecfb8bad7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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, diff --git a/src/app/(dashboard)/dashboard/settings/cache/page.tsx b/src/app/(dashboard)/dashboard/settings/cache/page.tsx new file mode 100644 index 0000000000..1febe5d38b --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/cache/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import CacheSettingsTab from "../components/CacheSettingsTab"; + +export default function SettingsCachePage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx new file mode 100644 index 0000000000..990e3324b8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx @@ -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(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; + }) + .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 ( + +
+
+

{t("modelCatalogCacheTtl")}

+

{t("modelCatalogCacheTtlDescription")}

+
+
+ + { + 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} + /> + ms + + {dirty && ( + + {t("modelCatalogCacheTtlCurrent", { value: savedValue })} + + )} +
+ {validationError &&

{validationError}

} + {message && ( +

+ {message.text} +

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 963f097a3c..c9dba29467 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -6,6 +6,7 @@ const LEGACY_TAB_ROUTES: Record = { 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", diff --git a/src/app/api/settings/cache-config/route.ts b/src/app/api/settings/cache-config/route.ts index 67b300690b..14f6ce9981 100644 --- a/src/app/api/settings/cache-config/route.ts +++ b/src/app/api/settings/cache-config/route.ts @@ -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 = {}; 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)[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 = {}; + const updates: Partial = {}; 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 }); diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index c7e0481dd3..27582391fc 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -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(); const catalogInFlight = new Map>(); @@ -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; status: number }> { +): Promise<{ body: string; headers: Record; 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/-{low,medium,high[,xhigh]}). // Derived from the already key-filtered list so a variant only appears when its real diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 392616d9f4..67a500c361 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/lib/db/databaseSettings.ts b/src/lib/db/databaseSettings.ts index f29ac62856..e18f2a66bd 100644 --- a/src/lib/db/databaseSettings.ts +++ b/src/lib/db/databaseSettings.ts @@ -40,6 +40,7 @@ const LEGACY_FLAT_KEYS: { promptCacheEnabled: ["promptCacheEnabled"], promptCacheStrategy: ["promptCacheStrategy"], alwaysPreserveClientCache: ["alwaysPreserveClientCache"], + modelCatalogCacheTtlMs: ["modelCatalogCacheTtlMs"], }, retention: { quotaSnapshots: ["quotaSnapshots"], diff --git a/src/shared/components/Header.tsx b/src/shared/components/Header.tsx index e3f32772f8..68eb2354ce 100644 --- a/src/shared/components/Header.tsx +++ b/src/shared/components/Header.tsx @@ -98,6 +98,7 @@ const HEADER_DESCRIPTIONS: Partial 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; 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>({}); 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 = {}; + 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({ + + {/* Models grouped by provider - compact */}
{/* 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]) => (
{/* Provider header */}
@@ -587,7 +630,7 @@ export default function ModelSelectModal({
))} - {Object.keys(filteredGroups).length === 0 && filteredCombos.length === 0 && ( + {Object.keys(connectionFilteredGroups).length === 0 && filteredCombos.length === 0 && (
search_off

{t("noModelsFound")}

diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 5de6d26425..60746e586b 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -72,6 +72,7 @@ export const SIDEBAR_ICON_ACCENTS: Partial> = { "settings-advanced": "#F97316", "settings-security": "#EF4444", "settings-feature-flags": "#FACC15", + "settings-cache": "#84CC16", "settings-sidebar": "#38BDF8", docs: "#2563EB", issues: "#DC2626", diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts index 3f817e2462..50a0b73936 100644 --- a/src/shared/constants/sidebarVisibility/sections.ts +++ b/src/shared/constants/sidebarVisibility/sections.ts @@ -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", diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index 584b585738..77a77ee390 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -97,6 +97,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "settings-security", "settings-access-tokens", "settings-feature-flags", + "settings-cache", "settings-sidebar", // Help "docs", diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 1ca0ff5f3f..b39984e545 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -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 diff --git a/src/types/databaseSettings.ts b/src/types/databaseSettings.ts index bb661147eb..a03e1750fe 100644 --- a/src/types/databaseSettings.ts +++ b/src/types/databaseSettings.ts @@ -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 { + 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); + }); +}); diff --git a/tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx b/tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx new file mode 100644 index 0000000000..1815ac6971 --- /dev/null +++ b/tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx @@ -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 { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + await act(async () => { + root.render(); + }); + 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; + + 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"); + }); +}); diff --git a/tests/unit/ui/model-select-modal-connection-filter.test.tsx b/tests/unit/ui/model-select-modal-connection-filter.test.tsx new file mode 100644 index 0000000000..50df375eee --- /dev/null +++ b/tests/unit/ui/model-select-modal-connection-filter.test.tsx @@ -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 +): Promise { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + await act(async () => { + root.render(); + }); + 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"); + }); +});