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:
Minh Ha
2026-09-21 19:41:29 +07:00
parent 477b2aed0b
commit 5c217d34f3
7 changed files with 419 additions and 70 deletions
+45 -20
View File
@@ -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">
+11 -4
View File
@@ -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,