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() {