diff --git a/changelog.d/fixes/12371-kimi-k3-effort-tiers.md b/changelog.d/fixes/12371-kimi-k3-effort-tiers.md new file mode 100644 index 0000000000..49a3310761 --- /dev/null +++ b/changelog.d/fixes/12371-kimi-k3-effort-tiers.md @@ -0,0 +1 @@ +- **fix(models):** publish `effort_tiers` on Kimi K3's synced base-model entries (`kmca/k3`, `kmca/k3-256k`) so catalog-only clients (OpenCode, plain SDK pickers) can see and select the reasoning tiers (`low`/`high`/`max`) the synced metadata already carried — the `isSkippedEffortProvider` gate no longer suppresses tier visibility on those base entries, while synthetic `-` variant generation stays prevented and Codex/GLM base models remain excluded unchanged ([#12299](https://github.com/diegosouzapw/OmniRoute/issues/12299)) diff --git a/src/app/api/v1/models/syncedCapabilities.ts b/src/app/api/v1/models/syncedCapabilities.ts index 529753a8d9..aac6f9fd27 100644 --- a/src/app/api/v1/models/syncedCapabilities.ts +++ b/src/app/api/v1/models/syncedCapabilities.ts @@ -21,6 +21,12 @@ * `-` catalog entries (open-sse/utils/syncedEffortVariants.ts) — it * never runs over the base entry's `capabilities`, so it cannot substitute * for this check. Required (not optional) so no call site can silently skip it. + * + * #12299 carve-out: Kimi K3's synced base entries (`k3`, `k3-256k` — the kmca + * catalog's `low`/`high`/`max` vocabulary) are exempted from the exclusion so + * catalog-only clients (OpenCode, plain SDK pickers) can see and select their + * tiers. Model-scoped, never provider-wide: Codex, GLM, and non-K3 kimi models + * keep the full exclusion exactly as before this carve-out. */ // Use the same canonical alias as catalogModelPolicy.ts (l.1) — a relative path from // src/app/api/v1/models/ to open-sse/ would need 5 `../` and silently breaks under @@ -39,8 +45,30 @@ interface SyncedCapabilityFlags { supportedThinkingEfforts?: string[]; } +// Model-id pattern for the Kimi K3 family (#12299): the kmca catalog syncs +// `k3`/`k3-256k` (and prefixed forms such as `kmca/k3`). Same shape the +// executor/translator layers use to recognize K3 elsewhere +// (reasoningContentInjector.ts::K3_AUTHENTIC_REASONING_PATTERN). +const KIMI_K3_MODEL_ID_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i; + +/** + * #12299: only Kimi K3's synced BASE entries are exempt from the + * `isSkippedEffortProvider` exclusion. Model-scoped, never provider-wide — + * the exemption requires a kimi-owned provider AND a K3 model id, so Codex, + * GLM, and non-K3 kimi models keep the exclusion contract from #7694. + */ +function isExemptKimiK3BaseModel(sm: SyncedCapabilityFlags, ownedBy: string): boolean { + return ( + ownedBy.startsWith("kimi") && typeof sm.id === "string" && KIMI_K3_MODEL_ID_PATTERN.test(sm.id) + ); +} + function effectiveEffortTiers(sm: SyncedCapabilityFlags, ownedBy: string): string[] | undefined { - if (isSkippedEffortProvider(ownedBy)) return undefined; + // Exclusion gate (#7694): codex/glm/kimi own a conflicting `-{effort}` suffix + // mechanism — the blind opencode-plugin mapping must never see effort_tiers + // for them, or it double-handles the suffix. #12299 narrows only the kimi K3 + // base-model entries out of that gate; everything else stays excluded. + if (isSkippedEffortProvider(ownedBy) && !isExemptKimiK3BaseModel(sm, ownedBy)) return undefined; const learned = sm.id ? getLearnedReasoningEffortForModel(sm.id) : null; const synced = Array.isArray(sm.supportedThinkingEfforts) && sm.supportedThinkingEfforts.length > 0 diff --git a/tests/unit/kimi-k3-effort-tiers-12299.test.ts b/tests/unit/kimi-k3-effort-tiers-12299.test.ts new file mode 100644 index 0000000000..d4e0619c6b --- /dev/null +++ b/tests/unit/kimi-k3-effort-tiers-12299.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { + buildSyncedCapabilities, + mergeSyncedCapabilities, +} from "../../src/app/api/v1/models/syncedCapabilities.ts"; +import { + shouldExposeSyncedEffortVariants, + appendSyncedEffortVariants, +} from "../../open-sse/utils/syncedEffortVariants.ts"; + +// #12299: Kimi K3's supportedThinkingEfforts (["low", "high", "max"]) were +// suppressed on the BASE model by isSkippedEffortProvider in +// effectiveEffortTiers(), leaving catalog-only clients with no tiers to copy. +// Fix: publish effort_tiers on the base model while still preventing +// synthetic - variant generation for kimi providers. + +const KIMI_K3_TIERS = ["low", "high", "max"]; + +test("Kimi K3 base model publishes effort_tiers via buildSyncedCapabilities (#12299)", () => { + const caps = buildSyncedCapabilities( + { id: "k3", supportsThinking: true, supportedThinkingEfforts: KIMI_K3_TIERS }, + "kimi-coding-apikey" + ); + assert.ok(caps, "capabilities must be defined for kimi K3"); + assert.deepEqual( + caps.effort_tiers, + KIMI_K3_TIERS, + "kimi K3 base model must publish effort_tiers low/high/max" + ); +}); + +test("Kimi K3-256k base model publishes effort_tiers via buildSyncedCapabilities (#12299)", () => { + const caps = buildSyncedCapabilities( + { id: "k3-256k", supportsThinking: true, supportedThinkingEfforts: KIMI_K3_TIERS }, + "kimi-coding-apikey" + ); + assert.ok(caps, "capabilities must be defined for kimi K3-256k"); + assert.deepEqual( + caps.effort_tiers, + KIMI_K3_TIERS, + "kimi K3-256k base model must publish effort_tiers low/high/max" + ); +}); + +test("Kimi K3 merge path also publishes effort_tiers (#12299)", () => { + const merged = mergeSyncedCapabilities( + { tool_calling: true }, + { id: "k3", supportsThinking: true, supportedThinkingEfforts: KIMI_K3_TIERS }, + "kimi-coding-apikey" + ); + assert.ok(merged, "merged capabilities must be defined"); + assert.deepEqual( + merged.effort_tiers, + KIMI_K3_TIERS, + "merge path must publish kimi K3 effort_tiers" + ); + assert.equal(merged.tool_calling, true, "existing tool_calling must be preserved"); +}); + +test("shouldExposeSyncedEffortVariants still prevents synthetic kimi variants", () => { + // The base model should NOT generate synthetic - entries + assert.equal( + shouldExposeSyncedEffortVariants({ + id: "kimi/k3", + owned_by: "kimi-coding-apikey", + capabilities: { effort_tiers: KIMI_K3_TIERS }, + }), + false, + "must not generate synthetic kimi/k3-low, kimi/k3-high, etc." + ); +}); + +test("appendSyncedEffortVariants does not create kimi variant entries", () => { + const models = [ + { + id: "kimi-coding-apikey/k3", + owned_by: "kimi-coding-apikey", + capabilities: { effort_tiers: KIMI_K3_TIERS }, + }, + ]; + const result = appendSyncedEffortVariants(models); + assert.equal(result.length, 1, "must not add synthetic variant entries for kimi"); + assert.equal(result[0].id, "kimi-coding-apikey/k3", "original entry must be unchanged"); +}); + +test("kimi K3 static registry tiers match synced metadata", () => { + // Verify the static registry in runtime.ts has the correct tiers + const runtimePath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../open-sse/config/providers/registry/kimi/coding/runtime.ts" + ); + const content = readFileSync(runtimePath, "utf8"); + + // Verify the static thinking policies declare the same tiers + assert.ok( + content.includes('"low", "high", "max"'), + "KIMI_CODE_STATIC_THINKING_POLICIES.k3 must declare low/high/max" + ); +}); diff --git a/tests/unit/synced-capabilities-learned-effort-override.test.ts b/tests/unit/synced-capabilities-learned-effort-override.test.ts index f33d08dc56..16f124b05b 100644 --- a/tests/unit/synced-capabilities-learned-effort-override.test.ts +++ b/tests/unit/synced-capabilities-learned-effort-override.test.ts @@ -62,6 +62,9 @@ test("merge path keeps vision AND applies the learned override", () => { // Exclusion gate (#7694): codex/glm/kimi already own a conflicting // `-{effort}` suffix mechanism — the blind opencode-plugin mapping must never // see effort_tiers for them, learned or synced, or it double-handles the suffix. +// #12299 exempts only Kimi K3's BASE model entries (asserted in +// tests/unit/kimi-k3-effort-tiers-12299.test.ts) — non-K3 kimi models such as +// "excluded-model" below stay excluded alongside codex/glm. for (const ownedBy of ["codex", "glm", "glm-cn", "glmt", "kimi", "kimi-coding-apikey"]) { test(`build: excluded provider "${ownedBy}" never gets effort_tiers (synced)`, () => { const caps = buildSyncedCapabilities(