mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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,
|
||||
|
||||
7
src/app/(dashboard)/dashboard/settings/cache/page.tsx
vendored
Normal file
7
src/app/(dashboard)/dashboard/settings/cache/page.tsx
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import CacheSettingsTab from "../components/CacheSettingsTab";
|
||||
|
||||
export default function SettingsCachePage() {
|
||||
return <CacheSettingsTab />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,6 +40,7 @@ const LEGACY_FLAT_KEYS: {
|
||||
promptCacheEnabled: ["promptCacheEnabled"],
|
||||
promptCacheStrategy: ["promptCacheStrategy"],
|
||||
alwaysPreserveClientCache: ["alwaysPreserveClientCache"],
|
||||
modelCatalogCacheTtlMs: ["modelCatalogCacheTtlMs"],
|
||||
},
|
||||
retention: {
|
||||
quotaSnapshots: ["quotaSnapshots"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -97,6 +97,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"settings-security",
|
||||
"settings-access-tokens",
|
||||
"settings-feature-flags",
|
||||
"settings-cache",
|
||||
"settings-sidebar",
|
||||
// Help
|
||||
"docs",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
83
tests/unit/cache-config-route-8219.test.ts
Normal file
83
tests/unit/cache-config-route-8219.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
160
tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx
Normal file
160
tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
226
tests/unit/ui/model-select-modal-connection-filter.test.tsx
Normal file
226
tests/unit/ui/model-select-modal-connection-filter.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user