diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 526ceb9b..e91ad1f2 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -279,7 +279,7 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } }, // ── Xiaomi MiMo (vision, 1M / 262K ctx) ────────────────────────── - { pattern: "*mimo*v2.5*", caps: { vision: true, contextWindow: 1048576, maxOutput: 131072 } }, + { pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: true, contextWindow: 1048576, maxOutput: 131072 } }, { pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, contextWindow: 262144, maxOutput: 131072 } }, { pattern: "*mimo*", caps: { vision: true, contextWindow: 262144, maxOutput: 131072 } }, diff --git a/open-sse/services/capacityAdapter.js b/open-sse/services/capacityAdapter.js new file mode 100644 index 00000000..7b096f97 --- /dev/null +++ b/open-sse/services/capacityAdapter.js @@ -0,0 +1,173 @@ +/** + * Capacity Adapter — global fallback pools of models per input-modality capability + * (vision / pdf / audioInput / videoInput). + * + * The pool models are appended as extra fallback candidates behind whatever models + * were already going to be tried (a combo's members, or a single target model). + * combo.js's existing reorderByCapabilities then floats a capable pool model to the + * front only when none of the original models can handle the request — so this + * never overrides a combo that already has a member covering the capability. + */ +import { getCapabilitiesForModel } from "../providers/capabilities.js"; + +const CAPABILITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"]; +const HARD_CAPS = new Set(CAPABILITY_KEYS); +const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.5-free"; + +// Normalize a capability entry to { enabled, roundRobin, models }. Backward-compat: +// accept the legacy array form [{model, enabled}] (treated as enabled, fallback). +function normalizeCapEntry(entry) { + if (Array.isArray(entry)) { + return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) }; + } + if (entry && typeof entry === "object") { + return { + enabled: entry.enabled !== false, + roundRobin: !!entry.roundRobin, + models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [], + }; + } + return { enabled: false, roundRobin: false, models: [] }; +} + +// Resolve one capability's full config. Enabled pools with no models fall back +// to DEFAULT_FALLBACK_MODEL so the toggle is never a no-op. +export function getCapacityAdapterConfig(cap, settings) { + const entry = normalizeCapEntry(settings?.capacityAdapter?.[cap]); + if (entry.enabled && entry.models.length === 0) { + return { ...entry, models: [DEFAULT_FALLBACK_MODEL] }; + } + return entry; +} + +// Flatten enabled models across all capability pools, in priority order, deduped. +export function getCapacityAdapterModels(settings) { + const seen = new Set(); + const models = []; + for (const cap of CAPABILITY_KEYS) { + const { enabled, models: pool } = getCapacityAdapterConfig(cap, settings); + if (!enabled) continue; + for (const m of pool) { + if (!seen.has(m)) { + seen.add(m); + models.push(m); + } + } + } + return models; +} + +// Strategy for a capability: "round-robin" when enabled+roundRobin, else "fallback". +export function getCapacityAdapterStrategy(cap, settings) { + const { enabled, roundRobin } = getCapacityAdapterConfig(cap, settings); + return enabled && roundRobin ? "round-robin" : "fallback"; +} + +// Strategy from the request's required capabilities: picks the first capability +// whose adapter pool is enabled and can satisfy a hard requirement. +export function getActiveAdapterStrategy(requiredCapabilities, settings) { + const hard = [...(requiredCapabilities || [])].filter((c) => HARD_CAPS.has(c)); + for (const cap of hard) { + const { enabled, models } = getCapacityAdapterConfig(cap, settings); + if (!enabled || models.length === 0) continue; + return getCapacityAdapterStrategy(cap, settings); + } + return "fallback"; +} + +function modelSatisfies(modelStr, requiredHard) { + const slash = modelStr.indexOf("/"); + const provider = slash > 0 ? modelStr.slice(0, slash) : ""; + const model = slash > 0 ? modelStr.slice(slash + 1) : modelStr; + const caps = getCapabilitiesForModel(provider, model); + return requiredHard.every((c) => caps[c] === true); +} + +// Prepend capacity-adapter models as priority candidates when NONE of the +// original models (combo members, or the single target model) can satisfy the +// request's required capabilities. Adapter models go FIRST (priority); the +// original models follow as fallback. Leaves `models` untouched when the +// original list already covers it (combo.js's reorderByCapabilities handles +// that case via autoSwitch). +export function augmentModelsWithCapacityAdapter(models, requiredCapabilities, settings) { + const hard = [...(requiredCapabilities || [])].filter((c) => HARD_CAPS.has(c)); + if (hard.length === 0 || !Array.isArray(models) || models.length === 0) return models; + if (models.some((m) => modelSatisfies(m, hard))) return models; + + const pool = getCapacityAdapterModels(settings).filter((m) => !models.includes(m) && modelSatisfies(m, hard)); + if (pool.length === 0) return models; + return [...pool, ...models]; +} + +const CHARS_PER_TOKEN = 4; // rough estimate; avoids pulling in a tokenizer dependency +const HEAD_KEEP = 6; // messages after system kept verbatim before dropping the middle + +function blockLength(content) { + if (typeof content === "string") return content.length; + if (Array.isArray(content)) { + return content.reduce((sum, b) => sum + (typeof b?.text === "string" ? b.text.length : 50), 0); + } + return 0; +} + +// Trim history to fit a (possibly smaller) context window by dropping the MIDDLE. +// Preserves: all system/instruction messages (head), and the trailing user run +// carrying the media the switch happened for (tail). Older middle turns between +// the head instructions and the current turn are dropped first. +export function stripHistoryForContext(body, contextWindow) { + const key = Array.isArray(body.messages) ? "messages" + : Array.isArray(body.input) ? "input" + : Array.isArray(body.contents) ? "contents" + : null; + if (!key) return body; + const arr = body[key]; + if (!arr || arr.length === 0) return body; + + const isSystem = (r) => r === "system" || r === "developer"; + const systemMsgs = arr.filter((m) => isSystem(m?.role)); + const rest = arr.filter((m) => !isSystem(m?.role)); + if (rest.length === 0) return body; + + const isAssistant = (r) => r === "assistant" || r === "model"; + let i = rest.length - 1; + while (i >= 0 && !isAssistant(rest[i]?.role)) i--; + const tail = rest.slice(i + 1); // current user turn (has media) — always kept + const older = rest.slice(0, i + 1); // everything before it + if (older.length === 0) return body; + + const contentOf = (m) => m.content ?? m.parts; + // Cap at 80% of the adapter model's context window — leaves room for the response. + const budgetChars = (contextWindow || 200000) * 0.8 * CHARS_PER_TOKEN; + + // Prefer keeping the first HEAD_KEEP messages (initial instructions/context) verbatim; + // only trim further if even that exceeds the adapter model's context window. + const headKept = older.slice(0, HEAD_KEEP); + let total = systemMsgs.concat(headKept, tail).reduce((s, m) => s + blockLength(contentOf(m)), 0); + + // If head + tail overflow, drop head turns from the end (closest to middle) first. + let head = headKept; + while (total > budgetChars && head.length > 0) { + const dropped = head.pop(); + total -= blockLength(contentOf(dropped)); + } + + if (head.length === older.length) return body; + return { ...body, [key]: [...systemMsgs, ...head, ...tail] }; +} + +// Wrap a handleSingleModel callback so calls to a capacity-adapter model strip +// history to fit its context window first. No-op passthrough when the pool is empty. +export function withCapacityAdapterStripping(handleSingleModel, adapterModels) { + const adapterSet = new Set(adapterModels); + if (adapterSet.size === 0) return handleSingleModel; + return (body, modelStr, ...rest) => { + if (adapterSet.has(modelStr)) { + const slash = modelStr.indexOf("/"); + const provider = slash > 0 ? modelStr.slice(0, slash) : ""; + const model = slash > 0 ? modelStr.slice(slash + 1) : modelStr; + const { contextWindow } = getCapabilitiesForModel(provider, model); + body = stripHistoryForContext(body, contextWindow); + } + return handleSingleModel(body, modelStr, ...rest); + }; +} diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index fb4c17d6..fa093bfd 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -5,7 +5,7 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; -import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; +import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select, Toggle } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; @@ -13,6 +13,37 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/sha // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; +// Capacity adapter: global fallback pools of models per input-modality capability. +// A request needing a capability the target model/combo lacks switches straight +// to the first enabled model here instead of erroring or dropping the data. +const CAPACITY_ADAPTER_CAPS = [ + { key: "vision", label: "Vision", icon: "visibility", desc: "Images" }, + // pdf, videoInput temporarily hidden — no translator support yet for those blocks. + { key: "audioInput", label: "Audio", icon: "graphic_eq", desc: "Audio input" }, +]; +const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.5-free"; +const EMPTY_CAP_ENTRY = { enabled: true, roundRobin: false, models: [] }; +const EMPTY_CAPACITY_ADAPTER = { + vision: { ...EMPTY_CAP_ENTRY }, + pdf: { ...EMPTY_CAP_ENTRY }, + audioInput: { ...EMPTY_CAP_ENTRY }, + videoInput: { ...EMPTY_CAP_ENTRY }, +}; +// Backward-compat: legacy stored form was an array of {model, enabled}. +function normalizeCapEntry(entry) { + if (Array.isArray(entry)) { + return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) }; + } + if (entry && typeof entry === "object") { + return { + enabled: entry.enabled !== false, + roundRobin: !!entry.roundRobin, + models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [], + }; + } + return { ...EMPTY_CAP_ENTRY }; +} + export default function CombosPage() { const [combos, setCombos] = useState([]); const [loading, setLoading] = useState(true); @@ -20,6 +51,7 @@ export default function CombosPage() { const [editingCombo, setEditingCombo] = useState(null); const [activeProviders, setActiveProviders] = useState([]); const [comboStrategies, setComboStrategies] = useState({}); + const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER); const { getCaps } = useModelCaps(); const [confirmState, setConfirmState] = useState(null); const { copied, copy } = useCopyToClipboard(); @@ -45,6 +77,12 @@ export default function CombosPage() { setActiveProviders(providersData.connections || []); } setComboStrategies(settingsData.comboStrategies || {}); + const rawAdapter = settingsData.capacityAdapter || {}; + const normalized = {}; + for (const cap of CAPACITY_ADAPTER_CAPS) { + normalized[cap.key] = normalizeCapEntry(rawAdapter[cap.key]); + } + setCapacityAdapter(normalized); } catch (error) { console.log("Error fetching data:", error); } finally { @@ -52,6 +90,19 @@ export default function CombosPage() { } }; + const handleSetCapacityAdapter = async (next) => { + setCapacityAdapter(next); + try { + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ capacityAdapter: next }), + }); + } catch (error) { + console.log("Error updating capacity adapter:", error); + } + }; + const handleCreate = async (data) => { try { const res = await fetch("/api/combos", { @@ -154,7 +205,6 @@ export default function CombosPage() {
  • Fallback — tries models in order (next on failure)
  • Round Robin — rotates models across requests to spread load
  • Fusion — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)
  • -
  • Capacity auto-switch — sends image/PDF/audio requests to a model that supports them first
  • + + + + )) + )} + {models.length > 3 && ( + +{models.length - 3} more + )} + + + + + {/* Actions: Round-robin toggle + Add Model */} +
    + + +
    + + + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAdd} + activeProviders={activeProviders} + title={`Add ${cap.label} Model`} + addedModelValues={models} + capFilter={cap.key} + closeOnSelect={false} + /> + )} + + ); +} + function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id }); const style = { diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 1abe4360..7bae5b9f 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -17,7 +17,14 @@ const DEFAULT_SETTINGS = { comboStrategy: "fallback", comboStickyRoundRobinLimit: 1, comboStrategies: {}, + capacityAdapter: { + vision: { enabled: true, roundRobin: false, models: [] }, + pdf: { enabled: false, roundRobin: false, models: [] }, + audioInput: { enabled: true, roundRobin: false, models: [] }, + videoInput: { enabled: false, roundRobin: false, models: [] }, + }, requireLogin: true, + requireApiKey: true, tunnelDashboardAccess: true, authMode: "password", oidcIssuerUrl: "", diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 3419140b..2cbc11a2 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -30,6 +30,7 @@ export default function ModelSelectModal({ title = "Select Model", modelAliases = {}, kindFilter = null, + capFilter = null, addedModelValues = [], closeOnSelect = true, }) { @@ -397,7 +398,7 @@ export default function ModelSelectModal({ // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) const filteredCombos = useMemo(() => { - if (kindFilter) return []; + if (kindFilter || capFilter) return []; if (!searchQuery.trim()) return combos; const query = searchQuery.toLowerCase(); return combos.filter(c => c.name.toLowerCase().includes(query)); @@ -417,6 +418,11 @@ export default function ModelSelectModal({ const filtered = {}; Object.entries(groupedModels).forEach(([providerId, group]) => { let models = group.models; + // Filter by input-modality capability (vision/pdf/audioInput/videoInput). + if (capFilter) { + models = models.filter((m) => getCaps(m.value)?.[capFilter] === true); + if (models.length === 0) return; + } if (query) { const providerNameMatches = group.name.toLowerCase().includes(query); models = models.filter(