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:
@@ -112,6 +112,8 @@ export const MODEL_CAPABILITIES = {
|
||||
"glm-5.3-flash": { vision: true, videoInput: true, pdf: true, reasoning: true, thinkingFormat: "zai", contextWindow: 1000000, maxOutput: 131072 },
|
||||
"glm-4.6v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000, maxOutput: 32768 },
|
||||
"glm-4.5v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 64000, maxOutput: 16384 },
|
||||
// GLM-5.2 has 1M context — pattern *glm-5* only gives 200k, so override here
|
||||
"glm-5.2": { reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 131072 },
|
||||
|
||||
// DeepSeek's first V4 model with image input; text limits match V4-Flash.
|
||||
"deepseek-v4-flash-vision-exp": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 },
|
||||
@@ -211,6 +213,10 @@ export const PROVIDER_CAPABILITIES = {
|
||||
"minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 128000 },
|
||||
"kimi-k2.7": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
|
||||
"kimi-k2.6": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
|
||||
"kimi-k2.5": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 164000, maxOutput: 32000 },
|
||||
"hy3-preview": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 192000, maxOutput: 64000 },
|
||||
"deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
|
||||
"deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 },
|
||||
// Per-model values mirror the server's product-config payload (the plugin
|
||||
// fetches it from copilot.tencent.com; the `models[]` entries carry
|
||||
// maxInputTokens/maxOutputTokens/supportsImages). contextWindow =
|
||||
@@ -232,45 +238,6 @@ export const PROVIDER_CAPABILITIES = {
|
||||
// contract). maxOutput 128000 per the server's product-config payload.
|
||||
"deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 128000 },
|
||||
},
|
||||
// CodeBuddy intl — same gateway catalog as CN, so deepseek-v4.1-flash mirrors
|
||||
// the codebuddy-cn entry (the openai-style reasoning_effort format matters:
|
||||
// the generic *deepseek-v4* pattern would otherwise pick the vendor-native
|
||||
// "deepseek" thinking shape, which the CodeBuddy gateway does not accept).
|
||||
"codebuddy-intl": {
|
||||
"deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 128000 },
|
||||
},
|
||||
// Qoder — upstream exposes opaque internal ids (dfmodel, kmodel, …); the
|
||||
// registry `name` is display-only and capability lookup matches on the raw
|
||||
// id, so every qoder model would fall through to DEFAULT_CAPABILITIES
|
||||
// (200K) without this map. contextWindow follows the real model family's
|
||||
// spec: the /algo/api/v2/model/list max_input_tokens under-reports some
|
||||
// windows (GLM-5.3 / Kimi-K3 / Qwen3.8-Max claim 180K but accept more).
|
||||
// max_output_tokens arrives as 0 for every model, so outputs are
|
||||
// best-guess from the real model family. Vision tags below follow the
|
||||
// upstream is_vl flag. The executor uploads inlined images to
|
||||
// /api/v2/image/upload and leaves image_urls/chat_context.imageUrls null
|
||||
// (same as qodercli). reasoning:true on all of them — every model can
|
||||
// reason; the upstream is_reasoning flag only drives model_config selection.
|
||||
// thinkingFormat keeps the true-model family for documentation/UI, but
|
||||
// thinkingCanDisable:false everywhere: the executor only forwards
|
||||
// messages/tools/max_tokens, and thinking is fixed upstream via
|
||||
// modelConfig.is_reasoning — client thinking intent is dropped, so "none"
|
||||
// must never be offered as an option.
|
||||
"qoder": {
|
||||
"ultimate": { vision: true, reasoning: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // Claude Opus 5
|
||||
"performance": { vision: true, reasoning: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // Claude Sonnet 5
|
||||
"dmodel": { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // DeepSeek-V4-Pro
|
||||
"dfmodel": { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // DeepSeek-V4-Flash
|
||||
"gmodel": { reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // GLM-5.3
|
||||
"gfmodel": { vision: true, reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // GLM-5.3-Flash
|
||||
"kmodel_latest": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Kimi-K3
|
||||
"kmodel": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 65536 }, // Kimi-K2.7-Code
|
||||
"mmodel": { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 512000 }, // MiniMax-M3
|
||||
"qmodel_latest": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.7-Max
|
||||
"qmodel": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.7-Plus
|
||||
"qfmodel": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.8-Flash
|
||||
"qmodel_38max": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.8-Max
|
||||
},
|
||||
// Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output).
|
||||
"poolside": {
|
||||
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 },
|
||||
@@ -357,7 +324,7 @@ export const PATTERN_CAPABILITIES = [
|
||||
{ pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
|
||||
{ pattern: "*qwen*omni*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144, maxOutput: 65536 } },
|
||||
{ pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } },
|
||||
{ pattern: "*qwen*max*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
|
||||
{ pattern: "*qwen*max*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
|
||||
{ pattern: "*qwen3.5*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
|
||||
{ pattern: "*qwen3.6*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
|
||||
{ pattern: "*qwen3.7*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
|
||||
@@ -396,14 +363,15 @@ export const PATTERN_CAPABILITIES = [
|
||||
|
||||
// ── MiniMax (M3 = adaptive; M2.x cannot disable) ─────────────────
|
||||
{ pattern: "*minimax*image*", caps: { imageOutput: true } },
|
||||
{ pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1048576, maxOutput: 512000 } },
|
||||
{ pattern: "*minimax-m2.7*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
|
||||
{ pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1000000, maxOutput: 131072 } },
|
||||
{ pattern: "*minimax-m2.7*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
|
||||
{ pattern: "*minimax-m2.5*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
|
||||
{ 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, 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 } },
|
||||
// ── Xiaomi MiMo (vision + <think>-tag reasoning, always-on, can't disable) ──
|
||||
{ pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 } },
|
||||
{ pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 131072 } },
|
||||
{ pattern: "*mimo*", caps: { vision: true, reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 131072 } },
|
||||
|
||||
// ── Llama (4 = vision/1M; 3.x = text-only/128K) ──────────────────
|
||||
{ pattern: "*llama-4*", caps: { vision: true, contextWindow: 1000000 } },
|
||||
@@ -441,6 +409,52 @@ export const PATTERN_CAPABILITIES = [
|
||||
{ pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Aggregate capabilities for a combo from its constituent model IDs.
|
||||
* Each entry in comboModels is a fully-qualified "provider/model" string.
|
||||
*
|
||||
* Union: vision, pdf, audioInput, videoInput, imageOutput, audioOutput, search
|
||||
* Intersection: tools
|
||||
* Primary: reasoning fields from the first (primary) model
|
||||
* Conservative: contextWindow = min; maxOutput = max
|
||||
*
|
||||
* @param {string[]} comboModels
|
||||
* @param {Object|null} [comboLookup] optional map of combo name → models array for nested resolution
|
||||
* @param {number} [_depth] internal recursion depth guard
|
||||
* @returns {object|null} full capabilities object, or null for empty input
|
||||
*/
|
||||
export function aggregateComboCapabilities(comboModels, comboLookup = null, _depth = 0) {
|
||||
if (!comboModels?.length || _depth > 6) return null;
|
||||
const allCaps = comboModels.map((fullId) => {
|
||||
// Nested combo: bare name (no slash) that exists in the lookup — recurse
|
||||
if (!fullId.includes("/") && comboLookup?.[fullId]) {
|
||||
return aggregateComboCapabilities(comboLookup[fullId], comboLookup, _depth + 1)
|
||||
?? getCapabilitiesForModel(null, fullId);
|
||||
}
|
||||
const slash = fullId.indexOf("/");
|
||||
const provider = slash === -1 ? null : fullId.slice(0, slash);
|
||||
const model = slash === -1 ? fullId : fullId.slice(slash + 1);
|
||||
return getCapabilitiesForModel(provider, model);
|
||||
});
|
||||
const first = allCaps[0];
|
||||
return {
|
||||
vision: allCaps.some((c) => c.vision),
|
||||
pdf: allCaps.some((c) => c.pdf),
|
||||
audioInput: allCaps.some((c) => c.audioInput),
|
||||
videoInput: allCaps.some((c) => c.videoInput),
|
||||
imageOutput: allCaps.some((c) => c.imageOutput),
|
||||
audioOutput: allCaps.some((c) => c.audioOutput),
|
||||
search: allCaps.some((c) => c.search),
|
||||
tools: allCaps.every((c) => c.tools),
|
||||
reasoning: first.reasoning,
|
||||
thinkingFormat: first.thinkingFormat,
|
||||
thinkingCanDisable: first.thinkingCanDisable,
|
||||
thinkingRange: first.thinkingRange,
|
||||
contextWindow: Math.min(...allCaps.map((c) => c.contextWindow)),
|
||||
maxOutput: Math.max(...allCaps.map((c) => c.maxOutput)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve capabilities for a model using the 4-step fallback chain,
|
||||
* merged over DEFAULT_CAPABILITIES so the result is always complete.
|
||||
|
||||
@@ -30,15 +30,26 @@ export default {
|
||||
models: [
|
||||
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
||||
{ id: "moonshotai/Kimi-K2.7-Code", name: "Kimi K2.7 Code" },
|
||||
{ id: "moonshotai/Kimi-K2.7-Code-Highspeed", name: "Kimi K2.7 Code HighSpeed" },
|
||||
{ id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" },
|
||||
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
|
||||
{ id: "zai-org/GLM-5.2", name: "GLM 5.2" },
|
||||
{ id: "zai-org/GLM-5.2-Fast", name: "GLM 5.2 Fast" },
|
||||
{ id: "zai-org/GLM-5.1", name: "GLM 5.1" },
|
||||
{ id: "zai-org/GLM-5", name: "GLM 5" },
|
||||
{ id: "MiniMaxAI/MiniMax-M3", name: "MiniMax M3" },
|
||||
{ id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
{ id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "xiaomi/mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
||||
{ id: "xiaomi/mimo-v2.5", name: "MiMo V2.5" },
|
||||
{ id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max Preview" },
|
||||
{ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" },
|
||||
{ id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max" },
|
||||
{ id: "Qwen/Qwen3.7-Plus", name: "Qwen 3.7 Plus" },
|
||||
{ id: "stepfun/Step-3.7-Flash", name: "Step 3.7 Flash" },
|
||||
{ id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" },
|
||||
{ id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra" },
|
||||
],
|
||||
features: {
|
||||
usage: true,
|
||||
|
||||
@@ -28,6 +28,7 @@ export default {
|
||||
forceStream: true,
|
||||
},
|
||||
models: [
|
||||
{ id: "gpt-5.5", name: "GPT-5.5" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
|
||||
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -114,3 +114,139 @@ describe("getCapabilitiesForModel", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — MiMo (<think>-tag reasoning, always-on)", () => {
|
||||
it("mimo-v2.5 has vision + reasoning + deepseek format, cannot disable", () => {
|
||||
const caps = getCapabilitiesForModel(null, "mimo-v2.5");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("mimo-v2.5-pro has vision (matches *mimo*v2.5* pattern)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "mimo-v2.5-pro");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("xiaomi/mimo-v2.5-pro (vendor-prefixed) has vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "xiaomi/mimo-v2.5-pro");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
});
|
||||
|
||||
it("mimo-omni-x has audioInput via the omni pattern", () => {
|
||||
const caps = getCapabilitiesForModel(null, "mimo-omni-x");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.audioInput).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("generic mimo has vision + reasoning (fallback pattern)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "mimo");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — Qwen max/plus vision", () => {
|
||||
it("qwen3.7-max has vision (*qwen*max* fires before *qwen3.7*)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "qwen3.7-max");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("Qwen3.6-Max-Preview has vision (case-insensitive pattern match)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "Qwen3.6-Max-Preview");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("qwen3.7-plus has vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "qwen3.7-plus");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("qwen3.7 has vision from the qwen3.7 pattern", () => {
|
||||
const caps = getCapabilitiesForModel(null, "qwen3.7");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("qwq has no vision (thinking-only model)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "qwq-32b");
|
||||
expect(caps.vision).toBe(false);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — MiniMax M2.x vision", () => {
|
||||
it("minimax-m2.7 has vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "minimax-m2.7");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("minimax-m2.5 has vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "minimax-m2.5");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("MiniMax-M2.7 has vision (vendor prefix MiniMaxAI/ stripped by route)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "MiniMaxAI/MiniMax-M2.7");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("minimax-m3 has vision (separate pattern)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "minimax-m3");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — DeepSeek V4 text-only", () => {
|
||||
it("deepseek-v4-pro has no vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "deepseek-v4-pro");
|
||||
expect(caps.vision).toBe(false);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
});
|
||||
|
||||
it("deepseek-v4-flash has no vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "deepseek-v4-flash");
|
||||
expect(caps.vision).toBe(false);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("deepseek/deepseek-v4-pro (vendor-prefixed) has no vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "deepseek/deepseek-v4-pro");
|
||||
expect(caps.vision).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — codebuddy-cn provider overrides", () => {
|
||||
it("deepseek-v4-pro via codebuddy-cn uses openai thinking format", () => {
|
||||
const caps = getCapabilitiesForModel("codebuddy-cn", "deepseek-v4-pro");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("openai");
|
||||
expect(caps.thinkingCanDisable).toBe(true);
|
||||
});
|
||||
|
||||
it("minimax-m3 via codebuddy-cn has vision (provider override)", () => {
|
||||
const caps = getCapabilitiesForModel("codebuddy-cn", "minimax-m3");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("openai");
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("unknown provider falls through to pattern matching", () => {
|
||||
const caps = getCapabilitiesForModel("unknown-provider", "mimo-v2.5");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
});
|
||||
});
|
||||
|
||||
155
tests/unit/combo-capabilities.test.js
Normal file
155
tests/unit/combo-capabilities.test.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { aggregateComboCapabilities } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
describe("aggregateComboCapabilities — null / empty", () => {
|
||||
it("returns null for null", () => {
|
||||
expect(aggregateComboCapabilities(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty array", () => {
|
||||
expect(aggregateComboCapabilities([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — single model passthrough", () => {
|
||||
it("single model returns its own capabilities", () => {
|
||||
const caps = aggregateComboCapabilities(["opencode-go/mimo-v2.5"]);
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
expect(caps.contextWindow).toBe(1048576);
|
||||
expect(caps.maxOutput).toBe(131072);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — union fields (vision, audioInput, search)", () => {
|
||||
it("vision is true if any backend has it", () => {
|
||||
// deepseek-v4-pro: no vision; mimo-v2.5: vision
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/deepseek-v4-pro",
|
||||
"opencode-go/mimo-v2.5",
|
||||
]);
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("vision is false if no backend has it", () => {
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/deepseek-v4-pro",
|
||||
"opencode-go/deepseek-v4-flash",
|
||||
]);
|
||||
expect(caps.vision).toBe(false);
|
||||
});
|
||||
|
||||
it("audioInput is true if any backend has it", () => {
|
||||
// mimo-omni has audioInput; mimo-v2.5 does not
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/mimo-omni-test",
|
||||
]);
|
||||
expect(caps.audioInput).toBe(true);
|
||||
});
|
||||
|
||||
it("search is true if any backend has it", () => {
|
||||
// gpt-5: search; mimo-v2.5: no search
|
||||
const caps = aggregateComboCapabilities([
|
||||
"openai/gpt-5",
|
||||
"opencode-go/mimo-v2.5",
|
||||
]);
|
||||
expect(caps.search).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — intersection: tools", () => {
|
||||
it("tools is false if any backend lacks it", () => {
|
||||
// gpt-image-1: tools:false; gpt-5: tools:true
|
||||
const caps = aggregateComboCapabilities([
|
||||
"openai/gpt-5",
|
||||
"openai/gpt-image-1",
|
||||
]);
|
||||
expect(caps.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("tools is true when all backends support it", () => {
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/kimi-k2.5",
|
||||
]);
|
||||
expect(caps.tools).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — primary model drives reasoning fields", () => {
|
||||
it("thinkingFormat comes from the first model", () => {
|
||||
// primary: mimo-v2.5 (deepseek); secondary: kimi-k2.5 (kimi)
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/kimi-k2.5",
|
||||
]);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
expect(caps.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("flipping order changes thinkingFormat to the new primary", () => {
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/kimi-k2.5",
|
||||
"opencode-go/mimo-v2.5",
|
||||
]);
|
||||
expect(caps.thinkingFormat).toBe("kimi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — context/output limits", () => {
|
||||
it("contextWindow is the minimum across all models", () => {
|
||||
// mimo-v2.5: 1048576; kimi-k2.5 (*kimi*k2* pattern): 262144
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/kimi-k2.5",
|
||||
]);
|
||||
expect(caps.contextWindow).toBe(262144);
|
||||
});
|
||||
|
||||
it("maxOutput is the maximum across all models", () => {
|
||||
// mimo-v2.5: 131072; kimi-k2.5 (*kimi*k2* pattern): 262144
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/kimi-k2.5",
|
||||
]);
|
||||
expect(caps.maxOutput).toBe(262144);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — nested combo resolution via comboLookup", () => {
|
||||
it("resolves nested combo and unions vision from its members", () => {
|
||||
const lookup = { "inner-combo": ["opencode-go/deepseek-v4-pro", "opencode-go/mimo-v2.5"] };
|
||||
const caps = aggregateComboCapabilities(["inner-combo"], lookup);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.vision).toBe(true); // mimo brings vision through the lookup
|
||||
});
|
||||
|
||||
it("outer combo gets vision via nested combo containing mimo", () => {
|
||||
const lookup = { "deepseek-v4-pro-fusion": ["opencode-go/deepseek-v4-pro", "opencode-go/mimo-v2.5"] };
|
||||
const caps = aggregateComboCapabilities(["deepseek-v4-pro-fusion", "openai/gpt-5"], lookup);
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("contextWindow is min across all resolved leaves", () => {
|
||||
// deepseek-v4-pro (*deepseek-v4*): 1000000; mimo-v2.5: 1048576 → min = 1000000
|
||||
const lookup = { "inner": ["opencode-go/deepseek-v4-pro"] };
|
||||
const caps = aggregateComboCapabilities(["inner", "opencode-go/mimo-v2.5"], lookup);
|
||||
expect(caps.contextWindow).toBe(1000000);
|
||||
});
|
||||
|
||||
it("handles cycles without throwing", () => {
|
||||
const lookup = { "a": ["b"], "b": ["a"] };
|
||||
expect(() => aggregateComboCapabilities(["a"], lookup)).not.toThrow();
|
||||
});
|
||||
|
||||
it("without comboLookup bare combo name falls through to pattern match", () => {
|
||||
// *deepseek-v4* pattern: reasoning true, vision false
|
||||
const caps = aggregateComboCapabilities(["deepseek-v4-pro-fusion"]);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.vision).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user