From 26cb88ad0ed7302e20b00dda2d1f540608912af9 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Fri, 2 Oct 2026 13:00:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(thinking):=20=E6=80=9D=E8=80=83=E7=AD=89?= =?UTF-8?q?=E7=BA=A7=E6=89=A9=E5=B1=95=E4=BA=94=E6=A1=A3=EF=BC=88Max/XHigh?= =?UTF-8?q?=EF=BC=89+=20=E6=8C=89=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B=20su?= =?UTF-8?q?pport=5Fefforts=20=E8=83=BD=E5=8A=9B=E6=84=9F=E7=9F=A5=EF=BC=9B?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=BF=87=E6=97=B6=E7=9A=84=20max=E2=86=92hig?= =?UTF-8?q?h=20=E8=BF=81=E7=A7=BB=EF=BC=9BSegmented=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=80=90=E9=80=89=E9=A1=B9=E7=A6=81=E7=94=A8/tooltip=20?= =?UTF-8?q?=E2=80=94=20bump=20to=20v0.7.23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 + docs/release-notes/release-notes-v0.7.23.md | 18 +++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/AgentSettingsPanel.tsx | 94 +++++++++++-- src/components/ProviderEdit.tsx | 11 +- src/components/SubagentSettingsPage.tsx | 12 +- src/components/ui/controls.tsx | 38 +++--- src/i18n/en.ts | 9 ++ src/i18n/zh.ts | 6 + src/lib/agent-settings.test.ts | 25 +++- src/lib/agent-settings.ts | 5 - src/lib/thinking-efforts.test.ts | 139 ++++++++++++++++++++ src/lib/thinking-efforts.ts | 127 ++++++++++++++++++ src/types/index.ts | 9 +- 17 files changed, 453 insertions(+), 53 deletions(-) create mode 100644 docs/release-notes/release-notes-v0.7.23.md create mode 100644 src/lib/thinking-efforts.test.ts create mode 100644 src/lib/thinking-efforts.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cbff622..e7d4bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ --- +## v0.7.23 (2026-09-30) + +- **思考等级五档 + 能力感知**:全局配置的思考等级扩展为低 / 中 / 高 / Max / XHigh,按当前默认模型声明的 `support_efforts` 自动置灰不支持档位并提示原因;models.dev 标记不支持思考的模型整体禁用思考区;未声明档位的模型保留可选并提示"上游可能拒绝" +- **修正过时适配**:删除 max→high 读取迁移(kimi-code 2.1.1 中 max 为合法档位),存储的 max 与任意手写档位值原样保留显示;子代理页继承档位显示同步修正 + ## v0.7.22 (2026-09-24) - **修复 Sub2API / NewAPI 模板查询不显示**:为无预设识别类型的自定义供应商配置 Sub2API(或 NewAPI)模板后,弹窗测试可查但供应商列表始终不显示——前端列表的查询/显示门槛只看预设 `usageKinds`,显式模板被漏掉;现在与后端口径对齐(模板查询绕过 usage_kinds),列表正常显示余额 / 配额 diff --git a/docs/release-notes/release-notes-v0.7.23.md b/docs/release-notes/release-notes-v0.7.23.md new file mode 100644 index 0000000..2616448 --- /dev/null +++ b/docs/release-notes/release-notes-v0.7.23.md @@ -0,0 +1,18 @@ +# KimiSwitch v0.7.23 + +## 新功能 + +- **思考等级五档 + 能力感知**:全局配置的思考等级由「低 / 中 / 高」三档扩展为「低 / 中 / 高 / Max / XHigh」五档,并按当前默认模型的实际能力自适应—— + - 模型在 config.toml 声明了 `support_efforts` 时,不支持的档位自动置灰并给出原因提示(如 deepseek-v4.1-flash 仅支持「高」) + - models.dev 标记不支持思考(reasoning=false)的模型,整个思考等级区禁用并给出警告 + - 未声明档位的模型保留全部可选,Max / XHigh 附带「上游可能拒绝此档位」提示 + - 等级行下方新增说明:实际生效档位取决于当前默认模型 + +## 修复 + +- **max 档位被误显示为「高」**:此前代码基于过时信息("上游已移除 max 档位")把存储的 `effort = "max"` 读取时强制改为 `high`;实测 kimi-code 2.1.1 中 max 为合法档位(如 kimi-for-coding 默认即为 max),现已原样保留与显示。手写的任意档位值(上游本就是自由字符串)也会作为额外可选项正常显示,不再被丢弃 +- **子代理设置页**:继承档位显示同步修正,max / xhigh 正常显示,未知值按原样展示 + +## 备注 + +- 仅前端改动(新增 `thinking-efforts` 能力判定模块 + `Segmented` 控件逐选项禁用/tooltip),无 Rust 变更;新增 14 个单测覆盖三层能力判定规则,全量 117 个测试通过 diff --git a/package.json b/package.json index ce0e1f9..5374b41 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.7.22", + "version": "0.7.23", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a8a157e..b5e9d9e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1978,7 +1978,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.7.22" +version = "0.7.23" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 775438f..aa14806 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.7.22" +version = "0.7.23" description = "Kimi Switch - model config manager" authors = ["codingplan.site"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 78f04c0..d7d93ce 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.7.22", + "version": "0.7.23", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AgentSettingsPanel.tsx b/src/components/AgentSettingsPanel.tsx index 7524c1a..c4fb147 100644 --- a/src/components/AgentSettingsPanel.tsx +++ b/src/components/AgentSettingsPanel.tsx @@ -1,12 +1,20 @@ -import { useEffect, useState } from "react"; +import { useEffect, useReducer, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { useTranslation } from "../i18n"; import type { TranslationKey } from "../i18n/zh"; import { getAgentSettings, setAgentSettings } from "../lib/agent-settings"; +import { getModelRef, modelsDevReady } from "../lib/models-dev"; +import { + THINKING_EFFORTS, + readSupportEfforts, + resolveThinkingEffortSupport, + type EffortAvailability, +} from "../lib/thinking-efforts"; import type { AgentSettings, ExperimentalEnvStatus, Hook, + Model, PermissionRule, } from "../types"; import { Card, Checkbox, NumberField, Segmented } from "./ui/controls"; @@ -14,14 +22,18 @@ import { Card, Checkbox, NumberField, Segmented } from "./ui/controls"; interface AgentSettingsPanelProps { rawOther: unknown; onChange: (nextRawOther: unknown) => void; + /** Configured models (alias → entry) — used to resolve effort support. */ + models?: Record; + /** Alias of the model the effort tier actually applies to. */ + defaultModel?: string | null; } -// Upstream removed the "max" effort tier (auto-migrates to "high"). -const THINKING_LEVELS = ["low", "medium", "high"] as const; -const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = { +const THINKING_LABELS: Record<(typeof THINKING_EFFORTS)[number], TranslationKey> = { low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", + max: "thinkingMax", + xhigh: "thinkingXHigh", }; const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const; @@ -44,7 +56,7 @@ const COMMON_EVENTS = [ "SessionEnd", ] as const; -export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) { +export function AgentSettingsPanel({ rawOther, onChange, models, defaultModel }: AgentSettingsPanelProps) { const { t } = useTranslation(); const settings = getAgentSettings(rawOther); /** @@ -52,6 +64,9 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro * v1-engine users; the default (v2) writes only max_attempts_per_step. */ const [legacyV1, setLegacyV1] = useState(false); + // The models.dev index loads in the background; re-render once it lands so + // the reasoning flag can disable the thinking area. + const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0); useEffect(() => { invoke("get_experimental_env_status") @@ -62,6 +77,18 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro .catch(() => setLegacyV1(false)); }, []); + useEffect(() => { + // The panel only needs the flag once; App owns the permanent + // onModelsDevReady listener for later hot swaps. + let alive = true; + modelsDevReady().then(() => { + if (alive) forceModelsDevReady(); + }); + return () => { + alive = false; + }; + }, []); + const update = (patch: Partial) => { onChange(setAgentSettings(rawOther, patch, { legacyV1 })); }; @@ -92,6 +119,42 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro const thinkingEnabled = settings.thinking?.enabled ?? true; + // Which tiers the current default model accepts: `[models.""] + // support_efforts` first, then the models.dev `reasoning` flag. + const defaultEntry = defaultModel ? models?.[defaultModel] : undefined; + const effortSupport = resolveThinkingEffortSupport({ + supportEfforts: readSupportEfforts(defaultEntry), + reasoning: defaultEntry ? getModelRef(defaultEntry.model)?.reasoning : undefined, + }); + + const effortNote = (availability: EffortAvailability): string | undefined => { + const note = availability.note; + if (!note) return undefined; + if (note.kind === "thinkingUnsupported") return t("thinkingEffortUnsupported"); + if (note.kind === "tierUnsupported") { + return t("thinkingEffortTierUnsupported", { levels: note.supported.join(", ") }); + } + return t("thinkingEffortTierUndeclared"); + }; + + // Upstream takes a free-form string: keep a hand-written tier visible and + // selectable instead of dropping it from the control. + const effort = settings.thinking?.effort ?? "medium"; + const effortOptions: { + key: string; + label: string; + disabled?: boolean; + title?: string; + }[] = THINKING_EFFORTS.map((tier) => ({ + key: tier, + label: t(THINKING_LABELS[tier]), + disabled: !effortSupport.levels[tier].enabled, + title: effortNote(effortSupport.levels[tier]), + })); + if (!THINKING_EFFORTS.includes(effort as (typeof THINKING_EFFORTS)[number])) { + effortOptions.push({ key: effort, label: effort }); + } + return (

{t("agentSettings")}

@@ -105,17 +168,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
{t("thinkingLevel")} ({ - key: lvl, - label: t(THINKING_LABELS[lvl]), - }))} - value={settings.thinking?.effort ?? "medium"} - onChange={(effort) => - updateThinking({ effort: effort as NonNullable["effort"] }) - } - disabled={!thinkingEnabled} + options={effortOptions} + value={effort} + onChange={(next) => updateThinking({ effort: next })} + disabled={!thinkingEnabled || !effortSupport.thinkingSupported} />
+ {!effortSupport.thinkingSupported && ( +

+ {t("thinkingEffortUnsupported")} +

+ )} +

+ {t("thinkingEffortDependsOnDefaultModel")} +

Object.fromEntries(models.map((m) => [m.alias, m])), + [models] + ); + useEffect(() => { const def = defaultBaseUrl(agent, provider.provider_type); if (!def) return; @@ -462,6 +469,8 @@ export function ProviderEdit({ )} diff --git a/src/components/SubagentSettingsPage.tsx b/src/components/SubagentSettingsPage.tsx index 41a5654..4daaba3 100644 --- a/src/components/SubagentSettingsPage.tsx +++ b/src/components/SubagentSettingsPage.tsx @@ -43,12 +43,15 @@ interface SubagentSettingsPageProps { onBack: () => void; } -// Upstream removed the "max" effort tier (auto-migrates to "high"). -const EFFORTS = ["low", "medium", "high"] as const; +// Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and +// per-model support comes from `[models.""] support_efforts`. +const EFFORTS = ["low", "medium", "high", "max", "xhigh"] as const; const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = { low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", + max: "thinkingMax", + xhigh: "thinkingXHigh", }; const FLAG_LABELS: Record = { @@ -281,9 +284,8 @@ export function SubagentSettingsPage({ ? `${Math.round(n / 1000)}K` : String(n); const effortLabel = (e: string): string => { - // Stored "max" tiers from old configs are shown as "high" (upstream - // removed the tier; it auto-migrates to "high"). - const key = EFFORT_LABELS[e === "max" ? "high" : (e as (typeof EFFORTS)[number])]; + // Unknown values (hand-written config) are shown verbatim. + const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]]; return key ? t(key) : e; }; diff --git a/src/components/ui/controls.tsx b/src/components/ui/controls.tsx index 2aafdb4..e3d0733 100644 --- a/src/components/ui/controls.tsx +++ b/src/components/ui/controls.tsx @@ -106,7 +106,7 @@ export function Segmented({ onChange, disabled, }: { - options: { key: string; label: string }[]; + options: { key: string; label: string; disabled?: boolean; title?: string }[]; value: string; onChange: (value: string) => void; disabled?: boolean; @@ -117,21 +117,27 @@ export function Segmented({ disabled ? "opacity-50" : "" }`} > - {options.map((opt) => ( - - ))} + {options.map((opt) => { + const optDisabled = disabled || opt.disabled === true; + return ( + // The title lives on the wrapper: a disabled button swallows pointer + // events in Chromium, so its own tooltip would never show. + + + + ); + })}
); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c393c57..f2b7c02 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -164,6 +164,15 @@ export const enTranslations: Record = { thinkingLow: "Low", thinkingMedium: "Medium", thinkingHigh: "High", + thinkingMax: "Max", + thinkingXHigh: "XHigh", + thinkingEffortUnsupported: "The current default model does not support thinking.", + thinkingEffortTierUnsupported: + "The current default model does not support this tier (supported: {levels}).", + thinkingEffortTierUndeclared: + "This model declares no thinking effort tiers; the upstream may reject this one.", + thinkingEffortDependsOnDefaultModel: + "The tier that actually applies depends on the current default model.", thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.", loopControlSettings: "Loop Control", maxAttemptsPerStep: "Max attempts per step", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 56e2b40..f49ac25 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -161,6 +161,12 @@ export const zhTranslations = { thinkingLow: "低", thinkingMedium: "中", thinkingHigh: "高", + thinkingMax: "Max", + thinkingXHigh: "XHigh", + thinkingEffortUnsupported: "当前默认模型不支持思考。", + thinkingEffortTierUnsupported: "当前默认模型不支持该档位(支持:{levels})。", + thinkingEffortTierUndeclared: "该模型未声明思考等级,上游可能拒绝此档位。", + thinkingEffortDependsOnDefaultModel: "实际生效的档位取决于当前默认模型。", thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。", loopControlSettings: "循环控制", maxAttemptsPerStep: "单步最大尝试次数", diff --git a/src/lib/agent-settings.test.ts b/src/lib/agent-settings.test.ts index 544f938..0063325 100644 --- a/src/lib/agent-settings.test.ts +++ b/src/lib/agent-settings.test.ts @@ -20,7 +20,7 @@ function loopOf(raw: unknown): Record { } // --------------------------------------------------------------------------- -// Reading — legacy off values normalized to "off", "max" → "high" +// Reading — legacy off values normalized to "off"; effort tiers pass through // --------------------------------------------------------------------------- describe("getAgentSettings — thinking.keep normalization", () => { @@ -52,12 +52,29 @@ describe("getAgentSettings — thinking.keep normalization", () => { }); }); -describe("getAgentSettings — effort \"max\" read mapping", () => { - it("normalizes a stored \"max\" to \"high\" on read", () => { +describe("getAgentSettings — thinking.effort read mapping", () => { + it("keeps a stored \"max\" as-is (the tier is valid upstream)", () => { expect(getAgentSettings({ thinking: { effort: "max" } }).thinking?.effort).toBe( - "high" + "max" ); }); + + it("passes an \"xhigh\" tier through untouched", () => { + expect(getAgentSettings({ thinking: { effort: "xhigh" } }).thinking?.effort).toBe( + "xhigh" + ); + }); + + it("keeps the default \"medium\" when the key is absent", () => { + expect(getAgentSettings({}).thinking?.effort).toBe("medium"); + }); + + it("round-trips an arbitrary hand-written tier on save", () => { + const next = setAgentSettings({ thinking: { effort: "ultra" } }, { + thinking: { effort: "ultra" }, + }); + expect(thinkingOf(next).effort).toBe("ultra"); + }); }); // --------------------------------------------------------------------------- diff --git a/src/lib/agent-settings.ts b/src/lib/agent-settings.ts index 3e5715a..158c046 100644 --- a/src/lib/agent-settings.ts +++ b/src/lib/agent-settings.ts @@ -65,11 +65,6 @@ export function getAgentSettings(rawOther: unknown): AgentSettings { if (thinking.keep !== undefined && thinking.keep !== "all") { thinking.keep = "off"; } - // The "max" effort tier was removed upstream (auto-migrates to "high"); - // old configs still carrying it are shown as "high" (not rewritten on read). - if (thinking.effort === "max") { - thinking.effort = "high"; - } const sectionPermission = getSection( rawOther, "permission" diff --git a/src/lib/thinking-efforts.test.ts b/src/lib/thinking-efforts.test.ts new file mode 100644 index 0000000..70eaf96 --- /dev/null +++ b/src/lib/thinking-efforts.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + THINKING_EFFORTS, + normalizeSupportEfforts, + readSupportEfforts, + resolveThinkingEffortSupport, +} from "./thinking-efforts"; + +// --------------------------------------------------------------------------- +// support_efforts normalization — an absent / malformed key means "unknown", +// never "no tiers supported" +// --------------------------------------------------------------------------- + +describe("normalizeSupportEfforts", () => { + it("keeps a non-empty list of strings", () => { + expect(normalizeSupportEfforts(["low", "high"])).toEqual(["low", "high"]); + }); + + it("drops blanks and non-strings but keeps the order", () => { + expect(normalizeSupportEfforts(["low", "", 3, null, "max"])).toEqual([ + "low", + "max", + ]); + }); + + it("returns null for absent / empty / non-array values", () => { + expect(normalizeSupportEfforts(undefined)).toBeNull(); + expect(normalizeSupportEfforts([])).toBeNull(); + expect(normalizeSupportEfforts([" "])).toBeNull(); + expect(normalizeSupportEfforts("low")).toBeNull(); + }); +}); + +describe("readSupportEfforts", () => { + const model = (raw_other: unknown) => + ({ alias: "a", provider: "p", model: "m", max_context_size: 0, display_name: null, raw_other }) as const; + + it("reads the key from a model entry's preserved config fields", () => { + expect(readSupportEfforts(model({ support_efforts: ["low", "max"] }))).toEqual([ + "low", + "max", + ]); + }); + + it("returns null when the entry or the key is absent", () => { + expect(readSupportEfforts(undefined)).toBeNull(); + expect(readSupportEfforts(model(undefined))).toBeNull(); + expect(readSupportEfforts(model({ other: 1 }))).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Tier resolution — three layers, in priority order +// --------------------------------------------------------------------------- + +describe("resolveThinkingEffortSupport — declared support_efforts wins", () => { + const support = resolveThinkingEffortSupport({ + supportEfforts: ["low", "high", "max"], + reasoning: true, + }); + + it("exposes the declared list and keeps thinking enabled", () => { + expect(support.declared).toEqual(["low", "high", "max"]); + expect(support.thinkingSupported).toBe(true); + }); + + it("enables exactly the declared tiers", () => { + for (const tier of THINKING_EFFORTS) { + expect(support.levels[tier].enabled).toBe(tier === "low" || tier === "high" || tier === "max"); + } + }); + + it("explains a disabled tier with the supported list", () => { + expect(support.levels.medium).toEqual({ + enabled: false, + note: { kind: "tierUnsupported", supported: ["low", "high", "max"] }, + }); + expect(support.levels.xhigh.enabled).toBe(false); + }); + + it("does not disable thinking even when models.dev says reasoning: false", () => { + const conflicting = resolveThinkingEffortSupport({ + supportEfforts: ["high"], + reasoning: false, + }); + expect(conflicting.thinkingSupported).toBe(true); + expect(conflicting.levels.low.enabled).toBe(false); + expect(conflicting.levels.high.enabled).toBe(true); + }); + + it("matches tiers case-insensitively", () => { + const upper = resolveThinkingEffortSupport({ supportEfforts: ["HIGH"] }); + expect(upper.levels.high.enabled).toBe(true); + }); +}); + +describe("resolveThinkingEffortSupport — model without thinking", () => { + const support = resolveThinkingEffortSupport({ reasoning: false }); + + it("disables the whole thinking area", () => { + expect(support.thinkingSupported).toBe(false); + expect(support.declared).toBeNull(); + for (const tier of THINKING_EFFORTS) { + expect(support.levels[tier]).toEqual({ + enabled: false, + note: { kind: "thinkingUnsupported" }, + }); + } + }); +}); + +describe("resolveThinkingEffortSupport — nothing known about the model", () => { + it("offers low/medium/high plain and flags max/xhigh as undeclared", () => { + const support = resolveThinkingEffortSupport({}); + expect(support.thinkingSupported).toBe(true); + expect(support.declared).toBeNull(); + for (const tier of ["low", "medium", "high"] as const) { + expect(support.levels[tier]).toEqual({ enabled: true }); + } + for (const tier of ["max", "xhigh"] as const) { + expect(support.levels[tier]).toEqual({ + enabled: true, + note: { kind: "tierUndeclared" }, + }); + } + }); + + it("treats an undefined reasoning flag as unknown, not as unsupported", () => { + expect( + resolveThinkingEffortSupport({ reasoning: undefined }).thinkingSupported + ).toBe(true); + }); + + it("ignores a malformed support_efforts value and falls back to unknown", () => { + const support = resolveThinkingEffortSupport({ supportEfforts: "low" }); + expect(support.declared).toBeNull(); + expect(support.levels.low).toEqual({ enabled: true }); + }); +}); diff --git a/src/lib/thinking-efforts.ts b/src/lib/thinking-efforts.ts new file mode 100644 index 0000000..1778b21 --- /dev/null +++ b/src/lib/thinking-efforts.ts @@ -0,0 +1,127 @@ +/** + * Thinking-effort capability resolution. + * + * kimi-code's `[thinking] effort` is a free-form string (low/medium/high/ + * max/xhigh in practice) that the CLI forwards verbatim to OpenAI-compatible + * upstreams as `reasoning_effort`. Which tiers a given model accepts is + * declared per model in config.toml (`[models.""] support_efforts`); + * models.dev only carries a boolean `reasoning` flag with no tier list. The + * two sources are merged here so the UI can grey out tiers the current default + * model would reject, without ever rejecting a stored value on read. + */ + +import type { Model } from "../types"; + +export const THINKING_EFFORTS = [ + "low", + "medium", + "high", + "max", + "xhigh", +] as const; + +export type ThinkingEffort = (typeof THINKING_EFFORTS)[number]; + +/** Why a tier is blocked, or (for undeclared models) merely unverified. */ +export type EffortNote = + | { kind: "thinkingUnsupported" } + | { kind: "tierUnsupported"; supported: string[] } + | { kind: "tierUndeclared" }; + +export interface EffortAvailability { + /** Whether the tier can be picked. */ + enabled: boolean; + /** Advisory message — set for blocked tiers and for unverified ones. */ + note?: EffortNote; +} + +export interface ThinkingEffortSupport { + /** False only when the model is known not to support thinking at all. */ + thinkingSupported: boolean; + /** Declared tiers, or null when nothing is known about the model. */ + declared: string[] | null; + levels: Record; +} + +/** Tiers offered unverified when a model declares nothing at all. */ +const ALWAYS_OFFERED = new Set(["low", "medium", "high"]); + +/** + * Normalize a `support_efforts` value into a comparable list, or null when the + * key is absent / malformed (treating it as "unknown", not "none supported"). + */ +export function normalizeSupportEfforts(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const tiers = value + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + return tiers.length > 0 ? tiers : null; +} + +/** Read `support_efforts` from a model entry's preserved config.toml fields. */ +export function readSupportEfforts(model: Model | undefined): string[] | null { + if (!model) return null; + const raw = model.raw_other; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + return normalizeSupportEfforts( + (raw as Record).support_efforts + ); +} + +function buildLevels( + resolve: (effort: ThinkingEffort) => EffortAvailability +): Record { + const levels = {} as Record; + for (const effort of THINKING_EFFORTS) levels[effort] = resolve(effort); + return levels; +} + +/** + * Resolve per-tier availability from the two capability sources, in priority + * order: + * 1. `supportEfforts` declared → tiers follow the list exactly. + * 2. models.dev `reasoning === false` → the whole thinking area is off. + * 3. Nothing known → low/medium/high are offered plain, max/xhigh stay + * selectable but carry a "not declared" note (upstream may reject them). + */ +export function resolveThinkingEffortSupport(input: { + supportEfforts?: unknown; + reasoning?: boolean; +}): ThinkingEffortSupport { + const declared = normalizeSupportEfforts(input.supportEfforts); + if (declared) { + const supported = new Set(declared.map((t) => t.toLowerCase())); + return { + thinkingSupported: true, + declared, + levels: buildLevels((effort) => + supported.has(effort) + ? { enabled: true } + : { + enabled: false, + note: { kind: "tierUnsupported", supported: declared }, + } + ), + }; + } + if (input.reasoning === false) { + return { + thinkingSupported: false, + declared: null, + levels: buildLevels(() => ({ + enabled: false, + note: { kind: "thinkingUnsupported" }, + })), + }; + } + return { + thinkingSupported: true, + declared: null, + levels: buildLevels((effort) => + ALWAYS_OFFERED.has(effort) + ? { enabled: true } + : { enabled: true, note: { kind: "tierUndeclared" } } + ), + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index ac12b12..d0e3c4a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -111,11 +111,12 @@ export interface DiscoveredModel { export interface ThinkingConfig { enabled?: boolean; /** - * Effort tier. `max` is read-compatible only — upstream removed the tier - * (old configs auto-migrate to `high`); the UI normalizes it and the - * serialization path never writes it. + * Effort tier, forwarded verbatim to OpenAI-compatible upstreams as + * `reasoning_effort`. Upstream accepts a free-form string; the UI offers + * low/medium/high/max/xhigh and derives per-model support from + * `[models.""] support_efforts`. */ - effort?: "low" | "medium" | "high" | "max"; + effort?: string; /** * Keep thinking content. The legacy off values (`false`, `0`, "no", "none", * `null`) are read-compatible — old configs may carry them; the UI