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:37:55 +07:00
parent 477b2aed0b
commit 5c217d34f3
7 changed files with 419 additions and 70 deletions

View File

@@ -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-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.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-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'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 }, "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 }, "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.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.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 // Per-model values mirror the server's product-config payload (the plugin
// fetches it from copilot.tencent.com; the `models[]` entries carry // fetches it from copilot.tencent.com; the `models[]` entries carry
// maxInputTokens/maxOutputTokens/supportsImages). contextWindow = // maxInputTokens/maxOutputTokens/supportsImages). contextWindow =
@@ -232,45 +238,6 @@ export const PROVIDER_CAPABILITIES = {
// contract). maxOutput 128000 per the server's product-config payload. // 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 }, "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 — OpenAI-compatible, all reasoning-capable (32K max output).
"poolside": { "poolside": {
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 }, "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*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*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*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.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.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 } }, { 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) ───────────────── // ── MiniMax (M3 = adaptive; M2.x cannot disable) ─────────────────
{ pattern: "*minimax*image*", caps: { imageOutput: true } }, { pattern: "*minimax*image*", caps: { imageOutput: true } },
{ pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1048576, maxOutput: 512000 } }, { pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1000000, maxOutput: 131072 } },
{ pattern: "*minimax-m2.7*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, 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 } }, { pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } },
// ── Xiaomi MiMo (vision, 1M / 262K ctx) ────────────────────────── // ── Xiaomi MiMo (vision + <think>-tag reasoning, always-on, can't disable) ──
{ pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: true, contextWindow: 1048576, maxOutput: 131072 } }, { 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, contextWindow: 262144, 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, 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) ────────────────── // ── Llama (4 = vision/1M; 3.x = text-only/128K) ──────────────────
{ pattern: "*llama-4*", caps: { vision: true, contextWindow: 1000000 } }, { pattern: "*llama-4*", caps: { vision: true, contextWindow: 1000000 } },
@@ -441,6 +409,52 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } }, { 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, * Resolve capabilities for a model using the 4-step fallback chain,
* merged over DEFAULT_CAPABILITIES so the result is always complete. * merged over DEFAULT_CAPABILITIES so the result is always complete.

View File

@@ -30,15 +30,26 @@ export default {
models: [ models: [
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, { 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.6", name: "Kimi K2.6" },
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" }, { 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.1", name: "GLM 5.1" },
{ id: "zai-org/GLM-5", name: "GLM 5" }, { 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.7", name: "MiniMax M2.7" },
{ id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }, { 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-Max-Preview", name: "Qwen 3.6 Max Preview" },
{ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }, { 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: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" },
{ id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra" },
], ],
features: { features: {
usage: true, usage: true,

View File

@@ -28,6 +28,7 @@ export default {
forceStream: true, forceStream: true,
}, },
models: [ models: [
{ id: "gpt-5.5", name: "GPT-5.5" },
{ id: "gpt-5.4", name: "GPT-5.4" }, { id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano" }, { id: "gpt-5.4-nano", name: "GPT-5.4 Nano" },

View File

@@ -1,6 +1,6 @@
"use client"; "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 { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; 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 { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select, Toggle } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps"; 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, -, _ // Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
@@ -52,8 +52,8 @@ export default function CombosPage() {
const [activeProviders, setActiveProviders] = useState([]); const [activeProviders, setActiveProviders] = useState([]);
const [comboStrategies, setComboStrategies] = useState({}); const [comboStrategies, setComboStrategies] = useState({});
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER); const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
const { getCaps } = useModelCaps();
const [confirmState, setConfirmState] = useState(null); const [confirmState, setConfirmState] = useState(null);
const { getCaps } = useModelCaps();
const { copied, copy } = useCopyToClipboard(); const { copied, copy } = useCopyToClipboard();
useEffect(() => { useEffect(() => {
@@ -228,20 +228,24 @@ export default function CombosPage() {
</Card> </Card>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{combos.map((combo) => ( {(() => {
<ComboCard const comboByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
key={combo.id} return combos.map((combo) => (
combo={combo} <ComboCard
getCaps={getCaps} key={combo.id}
activeProviders={activeProviders} combo={combo}
copied={copied} getCaps={getCaps}
onCopy={copy} comboByName={comboByName}
onEdit={() => setEditingCombo(combo)} activeProviders={activeProviders}
onDelete={() => handleDelete(combo.id)} copied={copied}
strategy={comboStrategies[combo.name] || {}} onCopy={copy}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)} onEdit={() => setEditingCombo(combo)}
/> onDelete={() => handleDelete(combo.id)}
))} strategy={comboStrategies[combo.name] || {}}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
/>
));
})()}
</div> </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 = [ const STRATEGY_OPTIONS = [
{ value: "fallback", label: "Fallback — try in order" }, { value: "fallback", label: "Fallback — try in order" },
{ value: "round-robin", label: "Round Robin — rotate" }, { value: "round-robin", label: "Round Robin — rotate" },
{ value: "fusion", label: "Fusion — panel + judge" }, { 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 [showJudgeSelect, setShowJudgeSelect] = useState(false);
const current = strategy.fallbackStrategy || "fallback"; const current = strategy.fallbackStrategy || "fallback";
const judge = strategy.judgeModel || ""; const judge = strategy.judgeModel || "";
const isFusion = current === "fusion"; const isFusion = current === "fusion";
const comboCaps = aggregateComboCapabilities(combo.models, comboByName);
return ( return (
<Card padding="sm" className="group"> <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) => ( 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"> <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> <span>{model}</span>
<CapacityBadges caps={getCaps?.(model)} /> <CapacityBadges caps={
comboByName[model]
? aggregateComboCapabilities(comboByName[model], comboByName)
: getCaps?.(model)
} />
</code> </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> <span className="text-[10px] text-text-muted">+{combo.models.length - 3} more</span>
)} )}
</div> </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) */} {/* Fusion: judge picker (Auto = first model) */}
{isFusion && ( {isFusion && (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5"> <div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">

View File

@@ -17,7 +17,7 @@ import { resolveCursorModels } from "open-sse/services/cursorModels.js";
import { resolveZedModels } from "open-sse/shared/zedAuth.js"; import { resolveZedModels } from "open-sse/shared/zedAuth.js";
import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; 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 // Per-provider live model resolvers. Each receives a connection record and
// returns { models: [{ id, name? }, ...] } | null on failure. // returns { models: [{ id, name? }, ...] } | null on failure.
@@ -302,6 +302,9 @@ export async function buildModelsList(kindFilter, options = {}) {
const models = []; 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. // Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch.
for (const combo of combos) { for (const combo of combos) {
if (!comboMatchesKinds(combo, kindFilter)) continue; if (!comboMatchesKinds(combo, kindFilter)) continue;
@@ -312,6 +315,9 @@ export async function buildModelsList(kindFilter, options = {}) {
}; };
if (combo.kind === "webSearch" || combo.kind === "webFetch") { if (combo.kind === "webSearch" || combo.kind === "webFetch") {
entry.kind = combo.kind; entry.kind = combo.kind;
} else {
const comboCaps = aggregateComboCapabilities(combo.models, comboByName);
if (comboCaps) entry.capabilities = comboCaps;
} }
models.push(entry); models.push(entry);
} }
@@ -331,6 +337,7 @@ export async function buildModelsList(kindFilter, options = {}) {
id: `${alias}/${model.id}`, id: `${alias}/${model.id}`,
object: "model", object: "model",
owned_by: alias, 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 // { id, name } — no per-model capability data. Fall back to the same
// pattern-matched capabilities the dashboard uses (useModelCaps.js) so // pattern-matched capabilities the dashboard uses (useModelCaps.js) so
// dynamically-discovered LLM models still surface vision/reasoning/search/tools. // dynamically-discovered LLM models still surface vision/reasoning/search/tools.
const caps = liveCapabilitiesById.get(modelId) const liveCaps = liveCapabilitiesById.get(modelId);
|| capabilitiesFromServiceKind(customKind || liveKind) const serviceCaps = capabilitiesFromServiceKind(customKind || liveKind);
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null); const caps = liveCaps || serviceCaps || (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
if (caps) model.capabilities = caps; if (caps) model.capabilities = caps;
// Token limits under the snake_case names the OpenAI/OpenRouter // Token limits under the snake_case names the OpenAI/OpenRouter
// convention uses. `capabilities.contextWindow` is camelCase and nested, // convention uses. `capabilities.contextWindow` is camelCase and nested,

View File

@@ -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");
});
});

View 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);
});
});