fix(providers): reconcile Kimi K3 vision when attachment contradicts modalities (#8250) (#8313)

* fix(providers): reconcile Kimi K3 vision when attachment contradicts modalities

Synced models.dev rows for kimi-coding*/k3 can ship attachment=false while
modalities_input still lists image/video. Prefer the modality signal (and
normalize at sync + resolve) so supportsVision, attachment, and exposed
modalities agree.

Closes #8250

* fix(providers): keep Kimi K3 static fallback text-only (#8250)

The Kimi K3 vision reconciliation added supportsVision=true directly
to the kimi-coding registry entry for id "k3". That entry is the
static/stable fallback catalog used when discovered capabilities are
unavailable, and it must stay text-only per #4071 — the vision fix is
already applied correctly on the discovered path via
MODEL_SPECS["kimi-k3"] (aliases: ["k3"]) and
modelCapabilities.ts::resolveVisionCapability.

Restores the invariants guarded by
tests/unit/kimi-k2.7-code-registration.test.ts ("Kimi Code k3 fallback
leaves discovered capabilities unset") and
tests/unit/catalog-updates-v3829-kimi-qwen.test.ts ("kmca stable
fallback only carries documented static capabilities").

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Prudhvi Vuda
2026-07-24 09:18:21 -04:00
committed by GitHub
parent 406f41de30
commit af60f41e99
6 changed files with 274 additions and 12 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -374,6 +374,7 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
},
// ── 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<string, ModelSpec> = {
supportsThinking: true,
supportsTools: true,
supportsVision: true,
aliases: ["k3"],
},
// ── Kimi K2.6 (Moonshot API — 262K native) ──────────────────────

View File

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