fix(auto-combo): respect model visibility (isHidden) in auto-combo candidate pool (#4558)

Integrated into release/v3.8.33
This commit is contained in:
Hernan Javier Ardila Sanchez
2026-06-21 23:34:01 +02:00
committed by GitHub
parent cf6546e6d3
commit 6b5590e8f4
8 changed files with 347 additions and 59 deletions

View File

@@ -129,7 +129,7 @@
"open-sse/services/batchProcessor.ts": 828,
"open-sse/services/browserBackedChat.ts": 850,
"open-sse/services/claudeCodeCompatible.ts": 1202,
"open-sse/services/combo.ts": 2695,
"open-sse/services/combo.ts": 2701,
"open-sse/services/rateLimitManager.ts": 1035,
"open-sse/services/tokenRefresh.ts": 1997,
"open-sse/services/usage.ts": 3450,
@@ -162,6 +162,7 @@
"src/app/(dashboard)/dashboard/providers/page.tsx": 1927,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1198,
"src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819,
"src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx": 846,
"src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 974,
"src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 898,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012,
@@ -181,7 +182,7 @@
"src/lib/db/apiKeys.ts": 1662,
"src/lib/db/core.ts": 1825,
"src/lib/db/migrationRunner.ts": 1125,
"src/lib/db/models.ts": 1221,
"src/lib/db/models.ts": 1259,
"src/lib/db/providers.ts": 1050,
"src/lib/db/proxies.ts": 1048,
"src/lib/db/settings.ts": 1149,

View File

@@ -17,6 +17,7 @@ import {
type AutoCategory,
type AutoTier,
} from "./suffixComposition";
import { getHiddenModelsByProvider } from "@/models";
/** #4235 Phase B: optional category/tier overlay for `auto/<category>:<tier>` combos. */
export interface AutoComboSpec {
@@ -235,6 +236,7 @@ export async function createVirtualAutoCombo(
const blockedProviders = new Set(
Array.isArray(settings.blockedProviders) ? (settings.blockedProviders as string[]) : []
);
const hiddenModelsMap = getHiddenModelsByProvider();
const validConnections = connections.filter(hasUsableConnectionCredential);
@@ -250,6 +252,10 @@ export async function createVirtualAutoCombo(
}
if (!modelId) continue; // Skip providers without a model
// Skip models that the user has hidden in the dashboard
const hiddenModels = hiddenModelsMap.get(conn.provider);
if (hiddenModels?.has(modelId)) continue;
candidatePool.push({
provider: conn.provider,
connectionId: conn.id,

View File

@@ -42,6 +42,7 @@ import {
getHandoff,
} from "../../src/lib/db/contextHandoffs.ts";
import { extractSessionAffinityKey } from "@/sse/services/auth";
import { getHiddenModelsByProvider } from "@/models";
import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings";
import { fetchCodexQuota } from "./codexQuotaFetcher.ts";
import {
@@ -318,6 +319,7 @@ export async function buildAutoCandidates(
resetWindowConfig: ResetWindowConfig = resolveResetWindowConfig(null),
resilienceSettings: ResilienceSettings | null = null
): Promise<AutoProviderCandidate[]> {
const hiddenModelsMap = getHiddenModelsByProvider();
const metrics = getComboMetrics(comboName);
// Opt-in hard quota cutoff (default OFF). When disabled, candidates are never
// dropped for low quota here — the soft quota penalty + connection cooldown still
@@ -513,7 +515,11 @@ export async function buildAutoCandidates(
})
);
return candidates;
// Filter out candidates whose model is hidden by the user in the dashboard
return candidates.filter((c) => {
const hiddenModels = hiddenModelsMap.get(c.provider);
return !hiddenModels?.has(c.model);
});
}
/**

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import {
@@ -102,7 +102,13 @@ export default function ComboDefaultsTab() {
});
const [codexSessionAffinityTtlMs, setCodexSessionAffinityTtlMs] = useState(0);
const [providerOverrides, setProviderOverrides] = useState<any>({});
const [newOverrideProvider, setNewOverrideProvider] = useState("");
const [availableProviders, setAvailableProviders] = useState<{ id: string; provider: string }[]>(
[]
);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [highlightedIdx, setHighlightedIdx] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<{ type: "success" | "error" | ""; message: string }>({
type: "",
@@ -129,6 +135,27 @@ export default function ComboDefaultsTab() {
Promise.all([
fetch("/api/settings/combo-defaults").then((res) => res.json()),
fetch("/api/settings").then((res) => res.json()),
fetch("/api/providers")
.then((res) => res.json())
.then((providers: any[]) => {
// Filter: include a provider only if at least one of its connections is active.
// Disabled providers (all connections inactive) are excluded.
const byProvider = new Map<string, any[]>();
for (const p of providers) {
if (!p.provider) continue;
const list = byProvider.get(p.provider) || [];
list.push(p);
byProvider.set(p.provider, list);
}
const activeProviders = Array.from(byProvider.entries())
.filter(([, conns]) => conns.some((c) => c.isActive !== false))
.map(([name]) => name)
.sort();
setAvailableProviders(activeProviders.map((p) => ({ id: p, provider: p })));
})
.catch(() => {
/* providers fetch is non-critical */
}),
])
.then(([comboData, settingsData]) => {
setComboDefaults((prev) => ({
@@ -153,6 +180,17 @@ export default function ComboDefaultsTab() {
.catch((err) => console.error("Failed to fetch combo defaults:", err));
}, []);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const showStatus = (type: "success" | "error", message: string) => {
setStatus({ type, message });
setTimeout(() => setStatus({ type: "", message: "" }), 2500);
@@ -204,14 +242,16 @@ export default function ComboDefaultsTab() {
}
};
const addProviderOverride = () => {
const name = newOverrideProvider.trim().toLowerCase();
if (!name || providerOverrides[name]) return;
setProviderOverrides((prev) => ({ ...prev, [name]: { maxRetries: 1 } }));
setNewOverrideProvider("");
const addProviderOverride = (name: string) => {
const trimmed = name.trim().toLowerCase();
if (!trimmed || providerOverrides[trimmed]) return;
setProviderOverrides((prev) => ({ ...prev, [trimmed]: { maxRetries: 1 } }));
setDropdownOpen(false);
setSearchQuery("");
setHighlightedIdx(0);
};
const removeProviderOverride = (provider) => {
const removeProviderOverride = (provider: string) => {
setProviderOverrides((prev) => {
const copy = { ...prev };
delete copy[provider];
@@ -219,6 +259,55 @@ export default function ComboDefaultsTab() {
});
};
// Reorder a provider override by rebuilding the object in the new order.
// direction: -1 = move up, +1 = move down
const moveProviderOverride = (provider: string, direction: -1 | 1) => {
setProviderOverrides((prev) => {
const keys = Object.keys(prev);
const idx = keys.indexOf(provider);
if (idx < 0) return prev;
const target = idx + direction;
if (target < 0 || target >= keys.length) return prev;
// Swap positions
[keys[idx], keys[target]] = [keys[target], keys[idx]];
// Rebuild object in new order
const reordered: Record<string, any> = {};
for (const k of keys) {
reordered[k] = prev[k];
}
return reordered;
});
};
// Filtered provider list — excludes already-added ones, filtered by search query
const filteredProviders = availableProviders.filter(
(p) =>
!providerOverrides[p.provider] && p.provider.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleDropdownKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIdx((prev) => Math.min(prev + 1, filteredProviders.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIdx((prev) => Math.max(prev - 1, 0));
break;
case "Enter":
e.preventDefault();
if (filteredProviders[highlightedIdx]) {
addProviderOverride(filteredProviders[highlightedIdx].provider);
}
break;
case "Escape":
e.preventDefault();
setDropdownOpen(false);
break;
}
};
return (
<Card>
<div className="flex items-center gap-3 mb-4">
@@ -625,57 +714,122 @@ export default function ComboDefaultsTab() {
<p className="font-medium text-sm mb-2">{t("providerOverrides")}</p>
<p className="text-xs text-text-muted mb-3">{t("providerOverridesDesc")}</p>
{Object.entries(providerOverrides).map(([provider, config]: [string, any]) => (
<div
key={provider}
className="flex items-center gap-2 mb-2 p-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]"
>
<span className="text-xs font-mono font-medium min-w-[80px]">{provider}</span>
<Input
type="number"
min="0"
max="5"
value={config.maxRetries ?? 1}
onChange={(e) =>
setProviderOverrides((prev) => ({
...prev,
[provider]: { ...prev[provider], maxRetries: parseInt(e.target.value) || 0 },
}))
}
className="text-xs w-16"
aria-label={t("providerMaxRetriesAria", { provider })}
/>
<span className="text-[10px] text-text-muted">{t("retries")}</span>
<button
onClick={() => removeProviderOverride(provider)}
className="ml-auto text-red-400 hover:text-red-500 transition-colors"
aria-label={t("removeProviderOverrideAria", { provider })}
{Object.entries(providerOverrides).map(
([provider, config]: [string, any], index: number) => (
<div
key={provider}
className="flex items-center gap-1.5 mb-2 p-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]"
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
close
</span>
</button>
</div>
))}
{/* Reorder arrows (combo-builder pattern) */}
<div className="flex flex-col gap-0.5">
<button
onClick={() => moveProviderOverride(provider, -1)}
disabled={index === 0}
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move up"
>
<span className="material-symbols-outlined text-[12px]">arrow_upward</span>
</button>
<button
onClick={() => moveProviderOverride(provider, 1)}
disabled={index === Object.keys(providerOverrides).length - 1}
className={`p-0.5 rounded ${index === Object.keys(providerOverrides).length - 1 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move down"
>
<span className="material-symbols-outlined text-[12px]">arrow_downward</span>
</button>
</div>
<span className="text-xs font-mono font-medium min-w-[80px]">{provider}</span>
<Input
type="number"
min="0"
max="5"
value={config.maxRetries ?? 1}
onChange={(e) =>
setProviderOverrides((prev) => ({
...prev,
[provider]: { ...prev[provider], maxRetries: parseInt(e.target.value) || 0 },
}))
}
className="text-xs w-16"
aria-label={t("providerMaxRetriesAria", { provider })}
/>
<span className="text-[10px] text-text-muted">{t("retries")}</span>
<button
onClick={() => removeProviderOverride(provider)}
className="ml-auto text-red-400 hover:text-red-500 transition-colors"
aria-label={t("removeProviderOverrideAria", { provider })}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
close
</span>
</button>
</div>
)
)}
<div className="flex items-center gap-2 mt-2">
<Input
type="text"
placeholder={t("newProviderNamePlaceholder")}
value={newOverrideProvider}
onChange={(e) => setNewOverrideProvider(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addProviderOverride()}
className="text-xs flex-1"
aria-label={t("newProviderNameAria")}
/>
<Button
variant="outline"
size="sm"
onClick={addProviderOverride}
disabled={!newOverrideProvider.trim()}
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 px-3 py-2 text-xs rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] hover:bg-black/[0.05] dark:hover:bg-white/[0.05] transition-colors w-full mt-2"
>
{tc("add")}
</Button>
<span className="flex-1 text-left text-text-muted">
{t("selectProviderPlaceholder") || "Select provider..."}
</span>
<span
className="material-symbols-outlined text-[16px] transition-transform"
style={{ transform: dropdownOpen ? "rotate(180deg)" : "none" }}
>
expand_more
</span>
</button>
{dropdownOpen && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border/50 bg-white dark:bg-gray-900 shadow-lg overflow-hidden">
<div className="p-2 border-b border-border/50">
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setHighlightedIdx(0);
}}
className="w-full px-2 py-1.5 text-xs rounded-md border border-border/50 bg-transparent outline-none focus:border-amber-500 transition-colors"
placeholder={t("searchProviderPlaceholder") || "Search providers..."}
aria-label={t("searchProviderAria") || "Search providers"}
onKeyDown={handleDropdownKeyDown}
autoFocus
/>
</div>
<ul role="listbox" className="max-h-48 overflow-auto py-1">
{filteredProviders.length === 0 ? (
<li className="px-3 py-2 text-xs text-text-muted text-center">
{availableProviders.filter((p) => !providerOverrides[p.provider]).length === 0
? "All providers added"
: "No providers found"}
</li>
) : (
filteredProviders.map((p, idx) => (
<li
key={p.provider}
role="option"
aria-selected={idx === highlightedIdx}
className={`px-3 py-2 text-xs cursor-pointer transition-colors ${
idx === highlightedIdx
? "bg-black/[0.05] dark:bg-white/[0.05] font-medium"
: "hover:bg-black/[0.03] dark:hover:bg-white/[0.03]"
}`}
onClick={() => addProviderOverride(p.provider)}
onMouseEnter={() => setHighlightedIdx(idx)}
>
{p.provider}
</li>
))
)}
</ul>
</div>
)}
</div>
</div>

View File

@@ -1101,6 +1101,44 @@ export function getModelIsHidden(providerId: string, modelId: string): boolean {
return Boolean(co?.isHidden);
}
/**
* Get a map of provider ID → set of hidden model IDs from all modelCompatOverrides
* and customModels. Used by auto-combo candidate building to skip user-hidden models.
* Single bulk DB query — not N+1 per model.
*/
export function getHiddenModelsByProvider(): Map<string, Set<string>> {
const db = getDbInstance();
const result = new Map<string, Set<string>>();
// Query all rows from key_value for both namespaces
const rows = db
.prepare(
"SELECT key, value FROM key_value WHERE namespace IN ('modelCompatOverrides', 'customModels')"
)
.all() as Array<{ key: string; value: string | null }>;
for (const row of rows) {
if (!row.value) continue;
try {
const parsed = JSON.parse(row.value);
if (!Array.isArray(parsed)) continue;
for (const entry of parsed) {
if (entry && typeof entry === "object" && entry.isHidden) {
const modelId = entry.id;
if (typeof modelId === "string" && modelId.length > 0) {
if (!result.has(row.key)) result.set(row.key, new Set());
result.get(row.key)!.add(modelId);
}
}
}
} catch {
// Skip malformed entries
}
}
return result;
}
/**
* #3782 — Check if a model was DELETED (trash) rather than merely eye-hidden.
*

View File

@@ -64,6 +64,7 @@ export {
getModelUpstreamExtraHeaders,
getModelIsHidden,
setModelIsHidden,
getHiddenModelsByProvider,
// Synced Available Models
getSyncedAvailableModels,

View File

@@ -24,4 +24,5 @@ export {
validateApiKey,
isCloudEnabled,
resolveProxyForProvider,
getHiddenModelsByProvider,
} from "@/lib/localDb";

View File

@@ -0,0 +1,81 @@
/**
* #4558 — Auto-combo must respect model visibility (isHidden).
*
* When an operator hides a model with the EYE/visibility toggle
* (`mergeModelCompatOverride(provider, model, { isHidden: true })`, written to
* the `modelCompatOverrides` key_value namespace), that model must be excluded
* from the AUTO-combo candidate pool. Both auto paths consume the same seam:
* - `open-sse/services/combo.ts::buildAutoCandidates` (combo.ts:322,520-521)
* - `open-sse/services/autoCombo/virtualFactory.ts` (virtualFactory.ts:239,256-257)
* via the new bulk `getHiddenModelsByProvider()` map (single query, not N+1).
*
* This guards that seam: a hidden model is present in the map's per-provider set
* while a visible sibling is not, and that toggling visibility back off
* (`isHidden: null`) removes it from the map again. Without the fix the map is
* empty and the auto pool would keep serving hidden models.
*/
import test, { before, after } from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
// Hermetic DB: this test writes overrides into the `modelCompatOverrides`
// key_value namespace. Without an isolated DATA_DIR it would leak that state
// into the shared dev/CI database. Point DATA_DIR at a throwaway dir before any
// import that opens the SQLite handle (CLAUDE.md "Database Handles in Tests").
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-hidden-4558-"));
process.env.DATA_DIR = tmpDir;
const { mergeModelCompatOverride, getHiddenModelsByProvider, getModelIsHidden } = await import(
"../../src/lib/localDb.ts"
);
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
before(() => {
resetDbInstance();
});
after(() => {
resetDbInstance();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
const PROVIDER = "openai";
const HIDDEN_MODEL = "gpt-hidden-preview";
const VISIBLE_MODEL = "gpt-visible-4o";
test("getHiddenModelsByProvider: empty before any model is hidden", () => {
const map = getHiddenModelsByProvider();
assert.equal(map.get(PROVIDER)?.has(HIDDEN_MODEL) ?? false, false);
});
test("a hidden model lands in the provider's hidden set; a visible sibling does not", () => {
// Hide one model, leave a sibling visible (overridden for an unrelated reason).
mergeModelCompatOverride(PROVIDER, HIDDEN_MODEL, { isHidden: true });
mergeModelCompatOverride(PROVIDER, VISIBLE_MODEL, { normalizeToolCallId: true });
// Sanity: the per-model read agrees.
assert.equal(getModelIsHidden(PROVIDER, HIDDEN_MODEL), true);
assert.equal(getModelIsHidden(PROVIDER, VISIBLE_MODEL), false);
const map = getHiddenModelsByProvider();
const hiddenForProvider = map.get(PROVIDER);
assert.ok(hiddenForProvider, "expected an entry for the provider");
assert.equal(hiddenForProvider.has(HIDDEN_MODEL), true, "hidden model must be in the set");
assert.equal(
hiddenForProvider.has(VISIBLE_MODEL),
false,
"visible model must NOT be in the hidden set"
);
});
test("un-hiding a model (isHidden: null) removes it from the map", () => {
mergeModelCompatOverride(PROVIDER, HIDDEN_MODEL, { isHidden: null });
const map = getHiddenModelsByProvider();
assert.equal(
map.get(PROVIDER)?.has(HIDDEN_MODEL) ?? false,
false,
"un-hidden model must drop out of the hidden set"
);
});