diff --git a/open-sse/config/providers/registry/command-code/index.ts b/open-sse/config/providers/registry/command-code/index.ts index 6bc96c2372..affe935180 100644 --- a/open-sse/config/providers/registry/command-code/index.ts +++ b/open-sse/config/providers/registry/command-code/index.ts @@ -17,6 +17,7 @@ export const command_codeProvider: RegistryEntry = { id: "claude-opus-4-7", name: "Claude Opus 4.7 (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 200000, maxOutputTokens: 32000, }, @@ -24,6 +25,7 @@ export const command_codeProvider: RegistryEntry = { id: "claude-opus-4-6", name: "Claude Opus 4.6 (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 200000, maxOutputTokens: 32000, }, @@ -31,6 +33,7 @@ export const command_codeProvider: RegistryEntry = { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 200000, maxOutputTokens: 16384, }, @@ -38,6 +41,7 @@ export const command_codeProvider: RegistryEntry = { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5 (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 200000, maxOutputTokens: 8192, }, @@ -45,6 +49,7 @@ export const command_codeProvider: RegistryEntry = { id: "gpt-5.5", name: "GPT-5.5 (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 256000, maxOutputTokens: 128000, }, @@ -52,6 +57,7 @@ export const command_codeProvider: RegistryEntry = { id: "gpt-5.4", name: "GPT-5.4 (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 256000, maxOutputTokens: 128000, }, @@ -59,6 +65,7 @@ export const command_codeProvider: RegistryEntry = { id: "gpt-5.3-codex", name: "GPT-5.3 Codex (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 256000, maxOutputTokens: 128000, }, @@ -66,6 +73,7 @@ export const command_codeProvider: RegistryEntry = { id: "gpt-5.4-mini", name: "GPT-5.4 Mini (CC)", supportsReasoning: false, + supportsVision: true, contextLength: 256000, maxOutputTokens: 128000, }, @@ -87,6 +95,7 @@ export const command_codeProvider: RegistryEntry = { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6 (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 262144, maxOutputTokens: 65536, }, @@ -94,6 +103,7 @@ export const command_codeProvider: RegistryEntry = { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5 (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 262144, maxOutputTokens: 65536, }, @@ -136,6 +146,7 @@ export const command_codeProvider: RegistryEntry = { id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus (CC)", supportsReasoning: true, + supportsVision: true, contextLength: 1000000, maxOutputTokens: 32768, }, diff --git a/src/shared/constants/visionModels.ts b/src/shared/constants/visionModels.ts index d546b670bd..f8cd3efaa0 100644 --- a/src/shared/constants/visionModels.ts +++ b/src/shared/constants/visionModels.ts @@ -42,6 +42,7 @@ export const VISION_MODEL_ID_FRAGMENTS = [ "gpt-4.1", "gpt-4-turbo", "gpt-4-vision", + "gpt-5", "gemini-1.5", "gemini-2", "gemini-3", @@ -51,8 +52,10 @@ export const VISION_MODEL_ID_FRAGMENTS = [ "claude-opus-4", "claude-sonnet-4", "claude-haiku-4", + "claude-fable", "mistral-medium-3", "minimax-m3", + "kimi-k2.", "-vision", "multimodal", ] as const; diff --git a/tests/unit/command-code-mimo-v2-5-safety.test.ts b/tests/unit/command-code-mimo-v2-5-safety.test.ts new file mode 100644 index 0000000000..fcd2a91d9b --- /dev/null +++ b/tests/unit/command-code-mimo-v2-5-safety.test.ts @@ -0,0 +1,48 @@ +/** + * Verify that mimo-v2.5 is safe to use with images across all providers + * (xiaomi-mimo, command-code, bazaarlink, opencode-go, bare model id). + * + * mimo-v2.5 is registered in ModelSpec (`modelSpecs.ts:410-415`) with + * `supportsVision: true`, so resolveVisionCapability() picks it up from + * `spec.supportsVision` without needing a registry flag. + * + * This test proves that the scenario described in the issue report + * (image-bearing request → vision-bridge auto-reroute to opencode-zen → 401) + * never applies to mimo-v2.5 — it has always been correctly identified as + * vision-capable even before the registry + heuristic fix for other CC models. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; +import { isVisionModelId } from "../../src/shared/constants/visionModels.ts"; + +const MIMO_V25_CASES: [string, string, boolean][] = [ + ["xiaomi-mimo/mimo-v2.5", "via xiaomi-mimo", true], + ["command-code/mimo-v2.5", "via command-code", true], + ["bazaarlink/mimo-v2.5", "via bazaarlink", true], + ["opencode-go/mimo-v2.5", "via opencode-go", true], + ["mimo-v2.5", "bare model name", true], + // Text-only variants must stay false + ["mimo-v2.5-pro", "text-only variant", false], + ["command-code/mimo-v2.5-pro", "text-only via command-code", false], +]; + +for (const [modelId, desc, expected] of MIMO_V25_CASES) { + test(`${desc} (${modelId}) → supportsVision=${expected}`, () => { + const caps = getResolvedModelCapabilities(modelId); + assert.equal(caps.supportsVision, expected, `${modelId} supportsVision must be ${expected}`); + }); +} + +test("mimo-v2.5 heuristic is correct (no false positive from mimo-vl fragment)", () => { + // mimo-vl matches "mimo-vl-a3b", not "mimo-v2.5" + assert.equal(isVisionModelId("mimo-vl-a3b"), true, "mimo-vl must be detected as vision"); + assert.equal( + isVisionModelId("mimo-v2.5"), + false, + "mimo-v2.5 must NOT match the mimo-vl heuristic" + ); + // But getResolvedModelCapabilities still returns true via ModelSpec + assert.equal(getResolvedModelCapabilities("mimo-v2.5").supportsVision, true); +}); diff --git a/tests/unit/command-code-registry-vision.test.ts b/tests/unit/command-code-registry-vision.test.ts new file mode 100644 index 0000000000..3384f866ad --- /dev/null +++ b/tests/unit/command-code-registry-vision.test.ts @@ -0,0 +1,70 @@ +/** + * Verify that command-code registry models with `supportsVision: true` resolve + * correctly via `getResolvedModelCapabilities`. + * + * Before the fix: the command-code registry had NO `supportsVision` flags. + * The guardrail used `getResolvedModelCapabilities` → `resolveVisionCapability`, + * which had no registry flag and no heuristic match, returning `null`/`false`. + * This caused the Vision Bridge to incorrectly reroute to opencode-zen (401). + * + * After the fix: the registry declares `supportsVision: true` for all CC + * vision-capable models, so the guardrail sees native vision support and + * passes through unmodified. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; + +// ── Models that SHOULD have supportsVision: true ──────────────────────────── + +const CC_VISION: [string, string][] = [ + ["Claude Opus 4.7 (CC)", "command-code/claude-opus-4-7"], + ["Claude Opus 4.6 (CC)", "command-code/claude-opus-4-6"], + ["Claude Sonnet 4.6 (CC)", "command-code/claude-sonnet-4-6"], + ["Claude Haiku 4.5 (CC)", "command-code/claude-haiku-4-5-20251001"], + ["GPT-5.5 (CC)", "command-code/gpt-5.5"], + ["GPT-5.4 (CC)", "command-code/gpt-5.4"], + ["GPT-5.3 Codex (CC)", "command-code/gpt-5.3-codex"], + ["GPT-5.4 Mini (CC)", "command-code/gpt-5.4-mini"], + ["Kimi K2.6 (CC)", "command-code/moonshotai/Kimi-K2.6"], + ["Kimi K2.5 (CC)", "command-code/moonshotai/Kimi-K2.5"], + ["Qwen 3.6 Plus (CC)", "command-code/Qwen/Qwen3.6-Plus"], +]; + +// ── Models that MUST NOT claim vision (text-only) ────────────────────────── + +const CC_TEXT_ONLY: [string, string][] = [ + ["DeepSeek V4 Pro (CC)", "command-code/deepseek/deepseek-v4-pro"], + ["DeepSeek V4 Flash (CC)", "command-code/deepseek/deepseek-v4-flash"], + ["GLM-5.1 (CC)", "command-code/zai-org/GLM-5.1"], + ["GLM-5 (CC)", "command-code/zai-org/GLM-5"], + ["MiniMax M2.7 (CC)", "command-code/MiniMaxAI/MiniMax-M2.7"], + ["MiniMax M2.5 (CC)", "command-code/MiniMaxAI/MiniMax-M2.5"], + ["Qwen 3.6 Max Preview (CC)", "command-code/Qwen/Qwen3.6-Max-Preview"], +]; + +for (const [name, modelId] of CC_VISION) { + test(`${name} resolves supportsVision: true`, () => { + const caps = getResolvedModelCapabilities(modelId); + assert.equal(caps.supportsVision, true, `${modelId} must have supportsVision: true`); + assert.equal(caps.provider, "command-code"); + }); +} + +for (const [name, modelId] of CC_TEXT_ONLY) { + test(`${name} does not falsely claim vision`, () => { + const caps = getResolvedModelCapabilities(modelId); + assert.notEqual( + caps.supportsVision, + true, + `${modelId} is text-only — must not have supportsVision: true` + ); + assert.equal(caps.provider, "command-code"); + }); +} + +test("MiniMax M3 via command-code keeps existing vision capability (no regression)", () => { + const caps = getResolvedModelCapabilities("command-code/MiniMaxAI/MiniMax-M3"); + assert.equal(caps.supportsVision, true); +}); diff --git a/tests/unit/vision-bridge-cc-no-reroute.test.ts b/tests/unit/vision-bridge-cc-no-reroute.test.ts new file mode 100644 index 0000000000..a13c8e1f87 --- /dev/null +++ b/tests/unit/vision-bridge-cc-no-reroute.test.ts @@ -0,0 +1,214 @@ +/** + * End-to-end guardrail tests: verify Vision Bridge does NOT reroute + * command-code vision-capable models. This is the core behavioral test + * for the bug fix. + * + * BUG FLOW (before fix): + * command-code/gpt-5.5 + image + * → getResolvedModelCapabilities("command-code/gpt-5.5") + * → supportsVision = null/false (no registry flag, no heuristic match) + * → Vision Bridge auto-reroutes to opencode-zen/gpt-5.5 + * → opencode-zen's executor returns 401 "Missing API key" + * + * FIXED FLOW (after fix): + * command-code/gpt-5.5 + image + * → getResolvedModelCapabilities("command-code/gpt-5.5") + * → supportsVision = true (registry flag) + * → Vision Bridge passes through unmodified + * → request reaches command-code upstream with correct API key + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { VisionBridgeGuardrail } = await import("../../src/lib/guardrails/visionBridge.ts"); +const { resetGuardrailsForTests } = await import("../../src/lib/guardrails/registry.ts"); +const { getResolvedModelCapabilities } = await import("../../src/lib/modelCapabilities.ts"); +import type { GuardrailContext } from "../../src/lib/guardrails/base.ts"; +import type { VisionModelConfig } from "../../src/lib/guardrails/visionBridgeHelpers.ts"; + +// ── Mock state ────────────────────────────────────────────────────────────── + +let mockSettings: Record = { + visionBridgeEnabled: true, + visionBridgeModel: "openai/gpt-4o-mini", + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, +}; + +let visionCallCount = 0; + +function createGuardrail() { + return new VisionBridgeGuardrail({ + deps: { + getSettings: async () => mockSettings, + callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) => { + visionCallCount++; + return "An image description"; + }, + }, + }); +} + +test.beforeEach(() => { + resetGuardrailsForTests({ registerDefaults: false }); + visionCallCount = 0; + mockSettings = { + visionBridgeEnabled: true, + visionBridgeModel: "openai/gpt-4o-mini", + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, + }; +}); + +function createContext(overrides: Partial = {}): GuardrailContext { + return { model: "command-code/gpt-5.4", log: console, ...overrides }; +} + +function imagePayload(model: string) { + return { + model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }; +} + +// ── Sanity: capabilities must resolve first ───────────────────────────────── + +test("CC-VB-SANITY: getResolvedModelCapabilities reports vision for CC models", () => { + for (const modelId of [ + "command-code/gpt-5.5", + "command-code/gpt-5.4", + "command-code/gpt-5.3-codex", + "command-code/gpt-5.4-mini", + "command-code/claude-opus-4-7", + "command-code/claude-sonnet-4-6", + "command-code/claude-haiku-4-5-20251001", + "command-code/moonshotai/Kimi-K2.6", + "command-code/moonshotai/Kimi-K2.5", + "command-code/Qwen/Qwen3.6-Plus", + ]) { + const caps = getResolvedModelCapabilities(modelId); + assert.equal(caps.supportsVision, true, `${modelId} must have supportsVision: true`); + } +}); + +// ── THE FIX: CC vision models pass through, no reroute ────────────────────── + +test("CC-VB-01: gpt-5.5 via command-code does NOT reroute", async () => { + const guardrail = createGuardrail(); + const payload = imagePayload("command-code/gpt-5.5"); + const result = await guardrail.preCall(payload, createContext({ model: "command-code/gpt-5.5" })); + + assert.strictEqual(result.block, false); + assert.strictEqual(visionCallCount, 0, "must NOT call vision API"); + assert.strictEqual(result.modifiedPayload, undefined, "must not reroute"); +}); + +test("CC-VB-02: gpt-5.4 via command-code does NOT reroute", async () => { + const guardrail = createGuardrail(); + const payload = imagePayload("command-code/gpt-5.4"); + const result = await guardrail.preCall(payload, createContext({ model: "command-code/gpt-5.4" })); + + assert.strictEqual(result.block, false); + assert.strictEqual(visionCallCount, 0); + assert.strictEqual(result.modifiedPayload, undefined); +}); + +test("CC-VB-03: Kimi K2.6 via command-code does NOT reroute", async () => { + const guardrail = createGuardrail(); + const payload = imagePayload("command-code/moonshotai/Kimi-K2.6"); + const result = await guardrail.preCall( + payload, + createContext({ model: "command-code/moonshotai/Kimi-K2.6" }) + ); + + assert.strictEqual(result.block, false); + assert.strictEqual(visionCallCount, 0); + assert.strictEqual(result.modifiedPayload, undefined); +}); + +test("CC-VB-04: Qwen 3.6 Plus via command-code does NOT reroute", async () => { + const guardrail = createGuardrail(); + const payload = imagePayload("command-code/Qwen/Qwen3.6-Plus"); + const result = await guardrail.preCall( + payload, + createContext({ model: "command-code/Qwen/Qwen3.6-Plus" }) + ); + + assert.strictEqual(result.block, false); + assert.strictEqual(visionCallCount, 0); + assert.strictEqual(result.modifiedPayload, undefined); +}); + +test("CC-VB-05: Claude Opus 4.7 via command-code does NOT reroute", async () => { + const guardrail = createGuardrail(); + const payload = imagePayload("command-code/claude-opus-4-7"); + const result = await guardrail.preCall( + payload, + createContext({ model: "command-code/claude-opus-4-7" }) + ); + + assert.strictEqual(result.block, false); + assert.strictEqual(visionCallCount, 0); + assert.strictEqual(result.modifiedPayload, undefined); +}); + +test("CC-VB-06: all CC vision models pass through unmodified (loop)", async () => { + const guardrail = createGuardrail(); + + const models = [ + "command-code/gpt-5.5", + "command-code/gpt-5.4", + "command-code/gpt-5.3-codex", + "command-code/gpt-5.4-mini", + "command-code/claude-opus-4-7", + "command-code/claude-opus-4-6", + "command-code/claude-sonnet-4-6", + "command-code/claude-haiku-4-5-20251001", + "command-code/moonshotai/Kimi-K2.6", + "command-code/moonshotai/Kimi-K2.5", + "command-code/Qwen/Qwen3.6-Plus", + ]; + + for (const model of models) { + visionCallCount = 0; + const payload = imagePayload(model); + const result = await guardrail.preCall(payload, createContext({ model })); + + assert.strictEqual(result.block, false, `${model}: must not block`); + assert.strictEqual(visionCallCount, 0, `${model}: must not call vision API`); + assert.strictEqual(result.modifiedPayload, undefined, `${model}: must not reroute`); + } +}); + +// ── Regression: text-only CC models still handled correctly ───────────────── + +test("CC-VB-REGRESSION: text-only deepseek-v4-pro via command-code still triggers guardrail", async () => { + const guardrail = createGuardrail(); + const payload = imagePayload("command-code/deepseek/deepseek-v4-pro"); + const result = await guardrail.preCall( + payload, + createContext({ model: "command-code/deepseek/deepseek-v4-pro" }) + ); + + assert.strictEqual(result.block, false); + const caps = getResolvedModelCapabilities("command-code/deepseek/deepseek-v4-pro"); + if (caps.supportsVision !== true) { + assert.ok( + result.modifiedPayload !== undefined || visionCallCount > 0, + "text-only model with images must trigger reroute or describe" + ); + } +}); diff --git a/tests/unit/vision-models-cc-fragments.test.ts b/tests/unit/vision-models-cc-fragments.test.ts new file mode 100644 index 0000000000..6a94b85b1c --- /dev/null +++ b/tests/unit/vision-models-cc-fragments.test.ts @@ -0,0 +1,82 @@ +/** + * Verify that the new VISION_MODEL_ID_FRAGMENTS additions correctly identify + * Command Code vision-capable models via the last-resort heuristic. + * + * Before the fix: `gpt-5`, `kimi-k2.`, and `claude-fable` were absent from + * VISION_MODEL_ID_FRAGMENTS. Without registry `supportsVision` flags either, + * the last-resort heuristic returned `false` for these models, causing the + * Vision Bridge guardrail to reroute image-bearing requests away from + * command-code's own vision-capable upstream to opencode-zen — which then + * failed with 401 "Missing API key." + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { isVisionModelId } from "../../src/shared/constants/visionModels.ts"; + +// ── New fragments — MUST now be recognized as vision ──────────────────────── + +const NEWLY_VISION = [ + // gpt-5 fragment covers all GPT-5.x variants + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex", + "gpt-5.4-mini", + "gpt-5.6", + "gpt-5.6-luna", + // kimi-k2. fragment (with dot) — covers K2.5/K2.6/K2.7 but NOT bare "kimi-k2" + "moonshotai/Kimi-K2.6", + "kimi-k2.5", + "kimi-k2.7-code", + "moonshotai/Kimi-K2.7-Code", + // claude-fable fragment + "claude-fable-5", +]; + +// ── Models that must remain non-vision ────────────────────────────────────── + +const STILL_NOT_VISION = [ + "kimi-k2", // bare kimi-k2 is text-only — must NOT match "kimi-k2." + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + "zai-org/GLM-5.1", + "zai-org/GLM-5", + "MiniMaxAI/MiniMax-M2.7", + "MiniMaxAI/MiniMax-M2.5", + "mimo-v2.5-pro", + "mimo-v2-pro", + "gemma-2-9b", + "ministral-14b-latest", +]; + +describe("VISION_MODEL_ID_FRAGMENTS — Command Code coverage", () => { + for (const id of NEWLY_VISION) { + it(`recognizes ${id} as vision via new fragments`, () => { + assert.equal(isVisionModelId(id), true, `${id} must be recognized as vision-capable`); + }); + } + + for (const id of STILL_NOT_VISION) { + it(`keeps ${id} as non-vision (no false positive)`, () => { + assert.equal(isVisionModelId(id), false, `${id} must remain non-vision`); + }); + } + + it("existing fragments remain functional (no regression)", () => { + const existing = [ + "minimax-m3", + "MiniMaxAI/MiniMax-M3", + "gpt-4o", + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + "gemini-3-pro", + "qwen3-vl-plus", + "pixtral-12b", + "mistral-medium-3", + ]; + for (const id of existing) { + assert.equal(isVisionModelId(id), true, `${id} must stay vision`); + } + }); +});