Compare commits
3
Commits
13e15657af
...
v0.7.23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26cb88ad0e | ||
|
|
50f59cac79 | ||
|
|
ecc98fd8cd |
No files matched your search
@@ -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),列表正常显示余额 / 配额
|
||||
|
||||
@@ -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 个测试通过
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.7.22",
|
||||
"version": "0.7.23",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+4671
-1671
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,7 @@ TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
|
||||
shift
|
||||
|
||||
NOTES_FILE="docs/release-notes/${TAG}.md"
|
||||
[ -f "$NOTES_FILE" ] || NOTES_FILE="docs/release-notes/release-notes-${TAG}.md"
|
||||
[ -f "$NOTES_FILE" ] || NOTES_FILE="release-notes-${TAG}.md"
|
||||
json_escape() {
|
||||
python - "$1" <<'PYEOF'
|
||||
|
||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.22"
|
||||
version = "0.7.23"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.7.22",
|
||||
"version": "0.7.23",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -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<string, Model>;
|
||||
/** 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<ExperimentalEnvStatus>("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<AgentSettings>) => {
|
||||
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."<alias>"]
|
||||
// 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 (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-content-muted text-sm font-medium">{t("agentSettings")}</h3>
|
||||
@@ -105,17 +168,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm text-content-muted">{t("thinkingLevel")}</span>
|
||||
<Segmented
|
||||
options={THINKING_LEVELS.map((lvl) => ({
|
||||
key: lvl,
|
||||
label: t(THINKING_LABELS[lvl]),
|
||||
}))}
|
||||
value={settings.thinking?.effort ?? "medium"}
|
||||
onChange={(effort) =>
|
||||
updateThinking({ effort: effort as NonNullable<AgentSettings["thinking"]>["effort"] })
|
||||
}
|
||||
disabled={!thinkingEnabled}
|
||||
options={effortOptions}
|
||||
value={effort}
|
||||
onChange={(next) => updateThinking({ effort: next })}
|
||||
disabled={!thinkingEnabled || !effortSupport.thinkingSupported}
|
||||
/>
|
||||
</div>
|
||||
{!effortSupport.thinkingSupported && (
|
||||
<p className="text-xs text-amber-500 dark:text-amber-400">
|
||||
{t("thinkingEffortUnsupported")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-content-muted">
|
||||
{t("thinkingEffortDependsOnDefaultModel")}
|
||||
</p>
|
||||
<Checkbox
|
||||
label={t("thinkingKeep")}
|
||||
checked={settings.thinking?.keep === "all"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "../i18n";
|
||||
@@ -142,6 +142,13 @@ export function ProviderEdit({
|
||||
const apiKeyEnvSupported = agent === "kimi_code";
|
||||
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
|
||||
|
||||
// Alias → entry lookup for panels that resolve a model by alias
|
||||
// (AgentSettingsPanel reads the default model's support_efforts).
|
||||
const modelsByAlias = useMemo(
|
||||
() => 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({
|
||||
<AgentSettingsPanel
|
||||
rawOther={rawOther}
|
||||
onChange={onRawOtherChange}
|
||||
models={modelsByAlias}
|
||||
defaultModel={defaultModel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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."<alias>"] 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<string, { name: TranslationKey; desc: TranslationKey }> = {
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(opt.key)}
|
||||
className={`px-3 py-1 text-sm ${
|
||||
value === opt.key
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-input text-content-muted hover:bg-hover-2"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
{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.
|
||||
<span key={opt.key} title={opt.title} className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
disabled={optDisabled}
|
||||
onClick={() => onChange(opt.key)}
|
||||
className={`px-3 py-1 text-sm ${
|
||||
value === opt.key
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-input text-content-muted hover:bg-hover-2"
|
||||
} ${optDisabled ? "cursor-not-allowed opacity-40 hover:bg-input" : ""}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,6 +164,15 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
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",
|
||||
|
||||
@@ -161,6 +161,12 @@ export const zhTranslations = {
|
||||
thinkingLow: "低",
|
||||
thinkingMedium: "中",
|
||||
thinkingHigh: "高",
|
||||
thinkingMax: "Max",
|
||||
thinkingXHigh: "XHigh",
|
||||
thinkingEffortUnsupported: "当前默认模型不支持思考。",
|
||||
thinkingEffortTierUnsupported: "当前默认模型不支持该档位(支持:{levels})。",
|
||||
thinkingEffortTierUndeclared: "该模型未声明思考等级,上游可能拒绝此档位。",
|
||||
thinkingEffortDependsOnDefaultModel: "实际生效的档位取决于当前默认模型。",
|
||||
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
|
||||
loopControlSettings: "循环控制",
|
||||
maxAttemptsPerStep: "单步最大尝试次数",
|
||||
|
||||
@@ -20,7 +20,7 @@ function loopOf(raw: unknown): Record<string, unknown> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<AgentSettings["permission"]>(
|
||||
rawOther,
|
||||
"permission"
|
||||
|
||||
+5540
-2016
File diff suppressed because it is too large.
Load diff
+4671
-1671
File diff suppressed because it is too large.
Load diff
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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."<alias>"] 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<ThinkingEffort, EffortAvailability>;
|
||||
}
|
||||
|
||||
/** Tiers offered unverified when a model declares nothing at all. */
|
||||
const ALWAYS_OFFERED = new Set<string>(["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<string, unknown>).support_efforts
|
||||
);
|
||||
}
|
||||
|
||||
function buildLevels(
|
||||
resolve: (effort: ThinkingEffort) => EffortAvailability
|
||||
): Record<ThinkingEffort, EffortAvailability> {
|
||||
const levels = {} as Record<ThinkingEffort, EffortAvailability>;
|
||||
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" } }
|
||||
),
|
||||
};
|
||||
}
|
||||
+5
-4
@@ -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."<alias>"] 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
|
||||
|
||||
Reference in new issue
Block a user