diff --git a/changelog.d/fixes/command-code-effort-capabilities.md b/changelog.d/fixes/command-code-effort-capabilities.md new file mode 100644 index 0000000000..38057107ef --- /dev/null +++ b/changelog.d/fixes/command-code-effort-capabilities.md @@ -0,0 +1 @@ +- fix(combo): resolve effort-suffixed command-code variants (e.g. `deepseek-v4-flash-max`) to their base model for capability lookups, so tool-bearing combo requests keep the declared priority order instead of reordering behind models with confirmed capabilities diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 727122d204..9d9572a7b1 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -254,6 +254,39 @@ function leafModelId(modelId: string | null | undefined): string | null { return leaf && leaf !== modelId ? leaf : null; } +/** + * Effort suffixes the catalog synthesizes as `-` variant ids from a + * base model's `supportedThinkingEfforts` (mirrors REGISTERED_EFFORT_SUFFIXES + * in open-sse/utils/registeredEffortVariants.ts, plus `minimal` for muse). + */ +const EFFORT_VARIANT_SUFFIXES = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +/** + * Strip a trailing effort-tier suffix off a model id (e.g. + * `deepseek-v4-flash-max` → `deepseek-v4-flash`). Longest token first so + * `xhigh` is matched before `high`. Returns null when no known suffix matches + * or the id would be left empty. + */ +function stripKnownEffortSuffix(modelId: string): string | null { + const normalized = String(modelId || "").trim(); + if (!normalized) return null; + for (const suffix of EFFORT_VARIANT_SUFFIXES) { + const token = `-${suffix}`; + if (normalized.length > token.length && normalized.endsWith(token)) { + return normalized.slice(0, -token.length); + } + } + return null; +} + function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSpec | undefined { if (modelId) { const byCanonical = getModelSpec(modelId); @@ -704,15 +737,38 @@ export function getResolvedModelCapabilities( // persisted override never feeds back into the comparison that (re)writes it. const usePersistedOverrides = options?.persistedOverrides !== false; const resolved = resolveCapabilityInput(input); - const spec = getStaticSpec(resolved.model, resolved.rawModel); - const registryModel = getRegistryModel(resolved.provider, resolved.model); - const synced = getSyncedCapabilityForResolved( + let spec = getStaticSpec(resolved.model, resolved.rawModel); + let registryModel = getRegistryModel(resolved.provider, resolved.model); + let synced = getSyncedCapabilityForResolved( resolved.provider, resolved.model, resolved.rawModel, snapshot ); + // Effort-suffixed variants (e.g. command-code `deepseek-v4-flash-max`, + // `meta/muse-spark-1.2-contributor-xhigh`) are synthesized in the catalog + // from the base model's `supportedThinkingEfforts`; they have no registry + // row, synced row, or static spec of their own. Without a base-model + // fallback the variant resolves with NULL tool/vision/context capabilities, + // so a tool-bearing combo request treats the target as incompatible and + // silently reorders it behind models with confirmed capabilities. Resolve + // the variant's capabilities from its base model when every direct source + // misses. + if (!spec && !registryModel && !synced && resolved.provider && resolved.model) { + const baseModelId = stripKnownEffortSuffix(resolved.model); + if (baseModelId && baseModelId !== resolved.model) { + spec = getStaticSpec(baseModelId, resolved.rawModel); + registryModel = getRegistryModel(resolved.provider, baseModelId); + synced = getSyncedCapabilityForResolved( + resolved.provider, + baseModelId, + resolved.rawModel, + snapshot + ); + } + } + const modalitiesInput = parseModalities(synced?.modalities_input); const modalitiesOutput = parseModalities(synced?.modalities_output); const lookupKey = diff --git a/tests/unit/command-code-registry-vision.test.ts b/tests/unit/command-code-registry-vision.test.ts index ab7f193876..c86b7b5baa 100644 --- a/tests/unit/command-code-registry-vision.test.ts +++ b/tests/unit/command-code-registry-vision.test.ts @@ -74,3 +74,53 @@ test("MiniMax M3 via command-code keeps existing vision capability (no regressio const caps = getResolvedModelCapabilities("command-code/MiniMaxAI/MiniMax-M3"); assert.equal(caps.supportsVision, true); }); + +test("command-code effort-suffixed variants resolve capabilities from their base model", async () => { + // Effort variants (e.g. `-max`, `-xhigh`) are synthesized from the base's + // supportedThinkingEfforts and have no registry/synced row of their own. + // Without the base-model fallback they resolve NULL tool/vision/context, + // which makes a tool-bearing combo request drop them behind confirmed + // targets (observed: orchestrator tried opencode-go/mimo-v2.5-max at + // position 2 while command-code deepseek sat unused at its declared + // priority position 2). + // + // Seed the models.dev capability store (the source getResolvedModelCapabilities + // reads for tool/vision/context) with the base models, then verify the + // effort-suffixed variants inherit those capabilities via the base fallback. + const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); + modelsDevSync.saveModelsDevCapabilities({ + "command-code": { + "deepseek/deepseek-v4-flash": { + tool_call: true, + reasoning: true, + attachment: false, + limit_context: 1000000, + limit_input: 1000000, + limit_output: 131072, + }, + "meta/muse-spark-1.2-contributor": { + tool_call: true, + reasoning: true, + attachment: true, + limit_context: 1048576, + limit_input: 1048576, + limit_output: 1048576, + }, + }, + }); + + const cases: Array<[string, boolean]> = [ + ["command-code/deepseek/deepseek-v4-flash-max", false], // text-only base + ["command-code/deepseek/deepseek-v4-flash-high", false], + ["command-code/meta/muse-spark-1.2-contributor-xhigh", true], // vision base + ["command-code/meta/muse-spark-1.2-contributor-high", true], + ]; + for (const [modelId, vision] of cases) { + const caps = getResolvedModelCapabilities(modelId); + assert.equal(caps.provider, "command-code", `${modelId} provider`); + assert.equal(caps.supportsTools, true, `${modelId} must inherit tool support from base`); + assert.equal(caps.supportsVision, vision, `${modelId} must inherit vision from base`); + assert.equal(typeof caps.contextWindow, "number", `${modelId} must inherit a context window`); + assert.equal(caps.supportsThinking, true, `${modelId} must inherit reasoning from base`); + } +});