diff --git a/changelog.d/fixes/8250-kimi-k3-vision-capability.md b/changelog.d/fixes/8250-kimi-k3-vision-capability.md new file mode 100644 index 0000000000..af34851725 --- /dev/null +++ b/changelog.d/fixes/8250-kimi-k3-vision-capability.md @@ -0,0 +1 @@ +- **fix(providers):** reconcile Kimi K3 vision metadata when synced `attachment=false` contradicts image/video `modalities_input`, so `supportsVision`, `attachment`, and exposed modalities agree ([#8250](https://github.com/diegosouzapw/OmniRoute/issues/8250)) — thanks @Prudhvivuda diff --git a/open-sse/config/providers/registry/kimi/coding/index.ts b/open-sse/config/providers/registry/kimi/coding/index.ts index e866a02923..6b2471f435 100644 --- a/open-sse/config/providers/registry/kimi/coding/index.ts +++ b/open-sse/config/providers/registry/kimi/coding/index.ts @@ -9,6 +9,11 @@ export const KIMI_CODING_MODELS: RegistryModel[] = [ name: "Kimi K3", contextLength: 1048576, supportsReasoning: true, + // NOTE: supportsVision intentionally left unset here — this static/stable + // fallback catalog must stay text-only per #4071. The Kimi K3 vision + // capability is applied on the discovered path via MODEL_SPECS["kimi-k3"] + // (aliases: ["k3"]) and modelCapabilities.ts::resolveVisionCapability + // (#8250), not on this registry fallback entry. }, { id: "kimi-for-coding", diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 546ee2051c..79a1a75f2d 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -366,6 +366,14 @@ function isKnownTextOnlyDespiteSync(modelId: string | null | undefined): boolean return KNOWN_TEXT_ONLY_DESPITE_SYNC.some((pattern) => pattern.test(id)); } +/** True when a modality list declares image and/or video input/output. */ +function modalitiesDeclareVision(modalities: readonly string[]): boolean { + return modalities.some((entry) => { + const lower = String(entry).toLowerCase(); + return lower.includes("image") || lower.includes("video"); + }); +} + function resolveVisionCapability( spec: ModelSpec | undefined, registryModel: { supportsVision?: boolean } | null, @@ -384,6 +392,13 @@ function resolveVisionCapability( if (isKnownTextOnlyDespiteSync(modelId)) return false; if (typeof synced?.attachment === "boolean") { + // #8250: models.dev sometimes ships attachment=false alongside image/video + // modalities (observed for Kimi K3). Prefer the richer modality signal over + // the contradictory false flag so supportsVision / attachment / modalities + // can be reconciled to a single vision-capable verdict. + if (synced.attachment === false && modalitiesDeclareVision(allModalities)) { + return true; + } return synced.attachment; } @@ -507,6 +522,22 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo const maxTokenOverride = getMaxTokenCapabilityOverride(resolved); + const supportsVision = resolveVisionCapability( + spec, + registryModel, + synced, + modalitiesInput, + modalitiesOutput, + lookupKey + ); + + // #8250: when resolve promoted vision over a contradictory attachment=false, + // expose attachment=true so catalog / Vision Bridge / clients see one verdict. + let attachment = synced?.attachment ?? null; + if (supportsVision === true && attachment === false) { + attachment = true; + } + return { provider: resolved.provider, model: resolved.model, @@ -515,16 +546,9 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo reasoning: supportsThinking ?? heuristicReasoning(lookupKey), supportsThinking, supportsTools, - supportsVision: resolveVisionCapability( - spec, - registryModel, - synced, - modalitiesInput, - modalitiesOutput, - lookupKey - ), + supportsVision, supportsMaxTokens: heuristicMaxTokens(lookupKey), - attachment: synced?.attachment ?? null, + attachment, structuredOutput: synced?.structured_output ?? null, temperature: synced?.temperature ?? null, contextWindow, diff --git a/src/lib/modelsDevSync/transform.ts b/src/lib/modelsDevSync/transform.ts index 77d20ac46e..dffce5bd33 100644 --- a/src/lib/modelsDevSync/transform.ts +++ b/src/lib/modelsDevSync/transform.ts @@ -243,14 +243,30 @@ export function transformModelsDevToCapabilities(raw: ModelsDevData): Capabiliti const omniRouteProviders = mapProviderId(providerId); for (const [modelId, model] of Object.entries(providerData.models || {})) { + const modalitiesInput = model.modalities?.input ?? []; + const modalitiesOutput = model.modalities?.output ?? []; + // #8250: models.dev can ship attachment=false while modalities.input still + // lists image/video (Kimi K3). Normalize at sync so persisted rows stay + // internally consistent before resolve-time reconciliation. + let attachment = model.attachment ?? null; + if ( + attachment === false && + [...modalitiesInput, ...modalitiesOutput].some((entry) => { + const lower = String(entry).toLowerCase(); + return lower.includes("image") || lower.includes("video"); + }) + ) { + attachment = true; + } + const cap: ModelCapabilityEntry = { tool_call: model.tool_call ?? null, reasoning: model.reasoning ?? null, - attachment: model.attachment ?? null, + attachment, structured_output: model.structured_output ?? null, temperature: model.temperature ?? null, - modalities_input: JSON.stringify(model.modalities?.input ?? []), - modalities_output: JSON.stringify(model.modalities?.output ?? []), + modalities_input: JSON.stringify(modalitiesInput), + modalities_output: JSON.stringify(modalitiesOutput), knowledge_cutoff: model.knowledge ?? null, release_date: model.release_date ?? null, last_updated: model.last_updated ?? null, diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 48bdb3392f..c4703a30be 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -374,6 +374,7 @@ export const MODEL_SPECS: Record = { }, // ── Kimi K3 (Moonshot API — 1M context/output, native vision) ──── + // `k3` is the Kimi Coding / kimi-coding-apikey wire id (#8250). "kimi-k3": { maxOutputTokens: 1048576, contextWindow: 1048576, @@ -381,6 +382,7 @@ export const MODEL_SPECS: Record = { supportsThinking: true, supportsTools: true, supportsVision: true, + aliases: ["k3"], }, // ── Kimi K2.6 (Moonshot API — 262K native) ────────────────────── diff --git a/tests/unit/model-capabilities-kimi-k3-vision-8250.test.ts b/tests/unit/model-capabilities-kimi-k3-vision-8250.test.ts new file mode 100644 index 0000000000..4e4175c476 --- /dev/null +++ b/tests/unit/model-capabilities-kimi-k3-vision-8250.test.ts @@ -0,0 +1,214 @@ +/** + * #8250 — Kimi K3 synced capability rows can contradict themselves: + * attachment=false AND modalities_input includes image/video + * + * `resolveVisionCapability` used to prefer the boolean `attachment` flag, so K3 + * resolved as non-vision while `/v1/models` and clients still saw image/video + * input modalities. Vision Bridge, combo compatibility, and the catalog then + * disagreed. + * + * Authoritative truth for Kimi K3 is vision-capable (Moonshot native vision; + * `MODEL_SPECS["kimi-k3"].supportsVision === true`). Contradictory synced rows + * must be reconciled so `attachment`, `supportsVision`, and exposed + * `modalitiesInput` agree — both for already-persisted DB rows and at models.dev + * transform/sync time. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kimi-k3-vision-8250-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); +const { transformModelsDevToCapabilities } = + await import("../../src/lib/modelsDevSync/transform.ts"); +const modelCapabilities = await import("../../src/lib/modelCapabilities.ts"); + +function buildCapability(overrides = {}) { + return { + tool_call: null, + reasoning: null, + attachment: null, + structured_output: null, + temperature: null, + modalities_input: "[]", + modalities_output: "[]", + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: null, + limit_context: null, + limit_input: null, + limit_output: null, + interleaved_field: null, + ...overrides, + }; +} + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +/** Mirrors the contradictory deployed row reported in #8250. */ +function seedContradictoryK3Capabilities() { + modelsDevSync.saveModelsDevCapabilities({ + "kimi-coding-apikey": { + k3: buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text", "image", "video"]), + modalities_output: JSON.stringify(["text"]), + reasoning: true, + tool_call: true, + limit_context: 1048576, + status: "stable", + }), + }, + "kimi-coding": { + k3: buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text", "image", "video"]), + modalities_output: JSON.stringify(["text"]), + reasoning: true, + tool_call: true, + limit_context: 1048576, + status: "stable", + }), + }, + kmc: { + k3: buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text", "image", "video"]), + modalities_output: JSON.stringify(["text"]), + reasoning: true, + tool_call: true, + limit_context: 1048576, + status: "stable", + }), + }, + kmca: { + k3: buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text", "image", "video"]), + modalities_output: JSON.stringify(["text"]), + reasoning: true, + tool_call: true, + limit_context: 1048576, + status: "stable", + }), + }, + }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#8250 kimi-coding-apikey/k3: attachment=false + image modalities → vision=true and fields agree", () => { + seedContradictoryK3Capabilities(); + const caps = modelCapabilities.getResolvedModelCapabilities("kimi-coding-apikey/k3"); + + assert.equal(caps.supportsVision, true, "K3 must resolve as vision-capable"); + assert.equal(caps.attachment, true, "exposed attachment must agree with supportsVision"); + assert.ok( + caps.modalitiesInput.some((m) => m.toLowerCase().includes("image")), + "input modalities must keep image after reconcile" + ); + assert.ok( + caps.modalitiesInput.some((m) => m.toLowerCase().includes("video")), + "input modalities must keep video after reconcile" + ); +}); + +test("#8250 alias providers (kimi-coding / kmc / kmca) reconcile the same way", () => { + seedContradictoryK3Capabilities(); + for (const id of ["kimi-coding/k3", "kmc/k3", "kmca/k3"]) { + const caps = modelCapabilities.getResolvedModelCapabilities(id); + assert.equal(caps.supportsVision, true, `${id} supportsVision`); + assert.equal(caps.attachment, true, `${id} attachment`); + assert.ok( + caps.modalitiesInput.some((m) => /image/i.test(m)), + `${id} modalitiesInput keeps image` + ); + } +}); + +test("#8250 sync transform promotes attachment=false when modalities declare image/video", () => { + const caps = transformModelsDevToCapabilities({ + "kimi-for-coding": { + id: "kimi-for-coding", + models: { + k3: { + id: "k3", + name: "Kimi K3", + attachment: false, + reasoning: true, + tool_call: true, + release_date: "2026-01-01", + last_updated: "2026-01-01", + open_weights: false, + limit: { context: 1048576, output: 1048576 }, + modalities: { input: ["text", "image", "video"], output: ["text"] }, + }, + }, + }, + } as never); + + // Mapped onto OmniRoute provider ids for kimi-for-coding + const row = caps["kimi-coding-apikey"]?.k3 ?? caps["kimi-coding"]?.k3; + assert.ok(row, "expected transformed k3 capability under a kimi-coding* provider"); + assert.equal(row.attachment, true, "sync must normalize attachment to true"); + assert.deepEqual(JSON.parse(row.modalities_input), ["text", "image", "video"]); +}); + +test("#8250 sync transform leaves consistent text-only rows alone", () => { + const caps = transformModelsDevToCapabilities({ + minimal: { + id: "minimal", + models: { + "text-only": { + id: "text-only", + name: "Text Only", + attachment: false, + reasoning: false, + tool_call: false, + release_date: "2026-01-01", + last_updated: "2026-01-01", + open_weights: false, + limit: { context: 4096, output: 2048 }, + modalities: { input: ["text"], output: ["text"] }, + }, + }, + }, + } as never); + + assert.equal(caps.minimal["text-only"].attachment, false); +}); + +test("#8250 known text-only override still beats wrong image modalities (#4071)", () => { + // Guard: the #8250 modalities-over-false-attachment reconcile must NOT undo + // the #4071 hard text-only override for mimo-v2.5-pro. + modelsDevSync.saveModelsDevCapabilities({ + "xiaomi-mimo": { + "mimo-v2.5-pro": buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text", "image"]), + modalities_output: JSON.stringify(["text"]), + }), + }, + }); + const pro = modelCapabilities.getResolvedModelCapabilities("xiaomi-mimo/mimo-v2.5-pro"); + assert.equal(pro.supportsVision, false, "text-only override must still win"); +});