feat(capabilities): model capability metadata on /v1/models, combo aggregation, pattern fixes
- Export aggregateComboCapabilities: union for vision/audio/search/pdf, intersection for tools, primary-model for reasoning fields, min contextWindow, max maxOutput - Support nested combo resolution in aggregateComboCapabilities via comboLookup with depth guard (max 6) - Wire capability metadata to all /v1/models entries and combos - Show aggregated ctx/max metadata line and capability badges on combo chips - Pattern fixes: MiMo v2.5/omni reasoning, qwen max/plus vision, minimax m2.x vision - Sync commandcode model catalog and add openai gpt-5.5 - Add unit tests for capability patterns and combo capability aggregation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
@@ -8,7 +8,7 @@ import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifi
|
||||
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";
|
||||
import { aggregateComboCapabilities } from "open-sse/providers/capabilities.js";
|
||||
|
||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
|
||||
@@ -52,8 +52,8 @@ export default function CombosPage() {
|
||||
const [activeProviders, setActiveProviders] = useState([]);
|
||||
const [comboStrategies, setComboStrategies] = useState({});
|
||||
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
|
||||
const { getCaps } = useModelCaps();
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const { getCaps } = useModelCaps();
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,7 +70,7 @@ export default function CombosPage() {
|
||||
const combosData = await combosRes.json();
|
||||
const providersData = await providersRes.json();
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
|
||||
|
||||
// Only LLM combos here - webSearch/webFetch combos belong to media-providers/web
|
||||
if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm"));
|
||||
if (providersRes.ok) {
|
||||
@@ -228,20 +228,24 @@ export default function CombosPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{combos.map((combo) => (
|
||||
<ComboCard
|
||||
key={combo.id}
|
||||
combo={combo}
|
||||
getCaps={getCaps}
|
||||
activeProviders={activeProviders}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onEdit={() => setEditingCombo(combo)}
|
||||
onDelete={() => handleDelete(combo.id)}
|
||||
strategy={comboStrategies[combo.name] || {}}
|
||||
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const comboByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
|
||||
return combos.map((combo) => (
|
||||
<ComboCard
|
||||
key={combo.id}
|
||||
combo={combo}
|
||||
getCaps={getCaps}
|
||||
comboByName={comboByName}
|
||||
activeProviders={activeProviders}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onEdit={() => setEditingCombo(combo)}
|
||||
onDelete={() => handleDelete(combo.id)}
|
||||
strategy={comboStrategies[combo.name] || {}}
|
||||
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -288,17 +292,27 @@ export default function CombosPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const fmtK = (n) => {
|
||||
if (!n) return "?";
|
||||
if (n >= 1000000) {
|
||||
const m = n / 1000000;
|
||||
return `${Number.isInteger(m) ? m : m.toFixed(1)}M`;
|
||||
}
|
||||
return `${Math.round(n / 1000)}k`;
|
||||
};
|
||||
|
||||
const STRATEGY_OPTIONS = [
|
||||
{ value: "fallback", label: "Fallback — try in order" },
|
||||
{ value: "round-robin", label: "Round Robin — rotate" },
|
||||
{ value: "fusion", label: "Fusion — panel + judge" },
|
||||
];
|
||||
|
||||
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
|
||||
function ComboCard({ combo, getCaps, comboByName = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
|
||||
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
|
||||
const current = strategy.fallbackStrategy || "fallback";
|
||||
const judge = strategy.judgeModel || "";
|
||||
const isFusion = current === "fusion";
|
||||
const comboCaps = aggregateComboCapabilities(combo.models, comboByName);
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="group">
|
||||
@@ -316,7 +330,11 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
|
||||
combo.models.slice(0, 3).map((model, index) => (
|
||||
<code key={index} className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5">
|
||||
<span>{model}</span>
|
||||
<CapacityBadges caps={getCaps?.(model)} />
|
||||
<CapacityBadges caps={
|
||||
comboByName[model]
|
||||
? aggregateComboCapabilities(comboByName[model], comboByName)
|
||||
: getCaps?.(model)
|
||||
} />
|
||||
</code>
|
||||
))
|
||||
)}
|
||||
@@ -324,6 +342,13 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
|
||||
<span className="text-[10px] text-text-muted">+{combo.models.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
{comboCaps && (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-text-muted">
|
||||
<span>ctx {fmtK(comboCaps.contextWindow)}</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>max {fmtK(comboCaps.maxOutput)}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Fusion: judge picker (Auto = first model) */}
|
||||
{isFusion && (
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
|
||||
@@ -17,7 +17,7 @@ import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
||||
import { resolveZedModels } from "open-sse/shared/zedAuth.js";
|
||||
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
import { capabilitiesFromServiceKind, getCapabilitiesForModel, aggregateComboCapabilities } from "open-sse/providers/capabilities.js";
|
||||
|
||||
// Per-provider live model resolvers. Each receives a connection record and
|
||||
// returns { models: [{ id, name? }, ...] } | null on failure.
|
||||
@@ -302,6 +302,9 @@ export async function buildModelsList(kindFilter, options = {}) {
|
||||
|
||||
const models = [];
|
||||
|
||||
// Lookup map so aggregateComboCapabilities can recursively resolve nested combos
|
||||
const comboByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
|
||||
|
||||
// Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch.
|
||||
for (const combo of combos) {
|
||||
if (!comboMatchesKinds(combo, kindFilter)) continue;
|
||||
@@ -312,6 +315,9 @@ export async function buildModelsList(kindFilter, options = {}) {
|
||||
};
|
||||
if (combo.kind === "webSearch" || combo.kind === "webFetch") {
|
||||
entry.kind = combo.kind;
|
||||
} else {
|
||||
const comboCaps = aggregateComboCapabilities(combo.models, comboByName);
|
||||
if (comboCaps) entry.capabilities = comboCaps;
|
||||
}
|
||||
models.push(entry);
|
||||
}
|
||||
@@ -331,6 +337,7 @@ export async function buildModelsList(kindFilter, options = {}) {
|
||||
id: `${alias}/${model.id}`,
|
||||
object: "model",
|
||||
owned_by: alias,
|
||||
capabilities: getCapabilitiesForModel(alias, model.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -491,9 +498,9 @@ export async function buildModelsList(kindFilter, options = {}) {
|
||||
// { id, name } — no per-model capability data. Fall back to the same
|
||||
// pattern-matched capabilities the dashboard uses (useModelCaps.js) so
|
||||
// dynamically-discovered LLM models still surface vision/reasoning/search/tools.
|
||||
const caps = liveCapabilitiesById.get(modelId)
|
||||
|| capabilitiesFromServiceKind(customKind || liveKind)
|
||||
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
|
||||
const liveCaps = liveCapabilitiesById.get(modelId);
|
||||
const serviceCaps = capabilitiesFromServiceKind(customKind || liveKind);
|
||||
const caps = liveCaps || serviceCaps || (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
|
||||
if (caps) model.capabilities = caps;
|
||||
// Token limits under the snake_case names the OpenAI/OpenRouter
|
||||
// convention uses. `capabilities.contextWindow` is camelCase and nested,
|
||||
|
||||
Reference in New Issue
Block a user