Compare commits

...
4 Commits
Author SHA1 Message Date
KimiSwitch Dev 26cb88ad0e feat(thinking): 思考等级扩展五档(Max/XHigh)+ 按默认模型 support_efforts 能力感知;删除过时的 max→high 迁移;Segmented 支持逐选项禁用/tooltip — bump to v0.7.23
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-10-02 13:00:35 +08:00
KimiSwitch Dev 50f59cac79 fix(release): publish-gitea.sh 兼容 docs/release-notes/release-notes-<tag>.md 命名(此前 Release 正文回退为 tag 名) 2026-10-02 13:00:33 +08:00
KimiSwitch Dev ecc98fd8cd chore(data): 同步 models-dev 快照至 2026-10-02(8,359 模型 / 225 供应商) 2026-10-02 13:00:32 +08:00
KimiSwitch Dev 13e15657af fix(usage): 列表支持显式模板查询(Sub2API/NewAPI)——对齐后端绕过 usage_kinds 的口径 — bump to v0.7.22
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-29 10:42:13 +08:00
25 changed files with 15380 additions and 5406 deletions

No files matched your search

+9
View File
@@ -6,6 +6,15 @@
--- ---
## 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),列表正常显示余额 / 配额
## v0.7.21 (2026-09-24) ## v0.7.21 (2026-09-24)
- **models.dev 在线同步**:高级设置新增「模型参考数据」卡片——一键在线拉取 models.dev 最新快照(上下文长度 / 能力标记 / 单价),前端与仪表盘计价即时生效,无需等待新版本;支持一键恢复随版本内置数据;显示当前数据来源与版本(同步 / 内置 + 日期 + 模型/供应商数) - **models.dev 在线同步**:高级设置新增「模型参考数据」卡片——一键在线拉取 models.dev 最新快照(上下文长度 / 能力标记 / 单价),前端与仪表盘计价即时生效,无需等待新版本;支持一键恢复随版本内置数据;显示当前数据来源与版本(同步 / 内置 + 日期 + 模型/供应商数)
@@ -0,0 +1,9 @@
# KimiSwitch v0.7.22
## 修复
- **Sub2API / NewAPI 模板查询在供应商列表不显示**:为没有预设识别类型的自定义供应商(如自家中转站)配置「Sub2API 中转站」或「NewAPI 中转站」模板后,配置弹窗里「测试查询」正常返回余额,但回到供应商列表却始终空白。根因:列表的查询与显示门槛只认预设注入的 `usageKinds`,而后端模板查询本就绕过 `usageKinds` 直接执行——前端口径与后端不一致。现在二者对齐,显式模板的供应商在列表正常显示余额 / 配额窗口、支持自动查询间隔与预警阈值。
## 备注
- 仅前端修复(`useUsageQuery` 支持判定 + 卡片渲染门槛),后端查询逻辑无变化;已补 3 个单测覆盖支持判定。
@@ -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
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.7.21", "version": "0.7.23",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+4671 -1671
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -17,6 +17,7 @@ TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
shift shift
NOTES_FILE="docs/release-notes/${TAG}.md" 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" [ -f "$NOTES_FILE" ] || NOTES_FILE="release-notes-${TAG}.md"
json_escape() { json_escape() {
python - "$1" <<'PYEOF' python - "$1" <<'PYEOF'
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.21" version = "0.7.23"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.21" version = "0.7.23"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["codingplan.site"] authors = ["codingplan.site"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.7.21", "version": "0.7.23",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+80 -14
View File
@@ -1,12 +1,20 @@
import { useEffect, useState } from "react"; import { useEffect, useReducer, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh"; import type { TranslationKey } from "../i18n/zh";
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings"; 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 { import type {
AgentSettings, AgentSettings,
ExperimentalEnvStatus, ExperimentalEnvStatus,
Hook, Hook,
Model,
PermissionRule, PermissionRule,
} from "../types"; } from "../types";
import { Card, Checkbox, NumberField, Segmented } from "./ui/controls"; import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
@@ -14,14 +22,18 @@ import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
interface AgentSettingsPanelProps { interface AgentSettingsPanelProps {
rawOther: unknown; rawOther: unknown;
onChange: (nextRawOther: unknown) => void; 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_LABELS: Record<(typeof THINKING_EFFORTS)[number], TranslationKey> = {
const THINKING_LEVELS = ["low", "medium", "high"] as const;
const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = {
low: "thinkingLow", low: "thinkingLow",
medium: "thinkingMedium", medium: "thinkingMedium",
high: "thinkingHigh", high: "thinkingHigh",
max: "thinkingMax",
xhigh: "thinkingXHigh",
}; };
const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const; const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const;
@@ -44,7 +56,7 @@ const COMMON_EVENTS = [
"SessionEnd", "SessionEnd",
] as const; ] as const;
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) { export function AgentSettingsPanel({ rawOther, onChange, models, defaultModel }: AgentSettingsPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const settings = getAgentSettings(rawOther); 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. * v1-engine users; the default (v2) writes only max_attempts_per_step.
*/ */
const [legacyV1, setLegacyV1] = useState(false); 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(() => { useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status") invoke<ExperimentalEnvStatus>("get_experimental_env_status")
@@ -62,6 +77,18 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
.catch(() => setLegacyV1(false)); .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>) => { const update = (patch: Partial<AgentSettings>) => {
onChange(setAgentSettings(rawOther, patch, { legacyV1 })); onChange(setAgentSettings(rawOther, patch, { legacyV1 }));
}; };
@@ -92,6 +119,42 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
const thinkingEnabled = settings.thinking?.enabled ?? true; 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 ( return (
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<h3 className="text-content-muted text-sm font-medium">{t("agentSettings")}</h3> <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"> <div className="flex items-center gap-3 flex-wrap">
<span className="text-sm text-content-muted">{t("thinkingLevel")}</span> <span className="text-sm text-content-muted">{t("thinkingLevel")}</span>
<Segmented <Segmented
options={THINKING_LEVELS.map((lvl) => ({ options={effortOptions}
key: lvl, value={effort}
label: t(THINKING_LABELS[lvl]), onChange={(next) => updateThinking({ effort: next })}
}))} disabled={!thinkingEnabled || !effortSupport.thinkingSupported}
value={settings.thinking?.effort ?? "medium"}
onChange={(effort) =>
updateThinking({ effort: effort as NonNullable<AgentSettings["thinking"]>["effort"] })
}
disabled={!thinkingEnabled}
/> />
</div> </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 <Checkbox
label={t("thinkingKeep")} label={t("thinkingKeep")}
checked={settings.thinking?.keep === "all"} checked={settings.thinking?.keep === "all"}
+10 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useState } from "react"; import { useEffect, useId, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
@@ -142,6 +142,13 @@ export function ProviderEdit({
const apiKeyEnvSupported = agent === "kimi_code"; const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null; 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(() => { useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type); const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return; if (!def) return;
@@ -462,6 +469,8 @@ export function ProviderEdit({
<AgentSettingsPanel <AgentSettingsPanel
rawOther={rawOther} rawOther={rawOther}
onChange={onRawOtherChange} onChange={onRawOtherChange}
models={modelsByAlias}
defaultModel={defaultModel}
/> />
)} )}
</> </>
+4 -2
View File
@@ -96,7 +96,9 @@ function ProviderCard({
provider.usageKinds, provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes, provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false, provider.usageConfig?.enabled === false,
provider.usageConfig?.threshold provider.usageConfig?.threshold,
provider.usageConfig?.templateType === "newapi" ||
provider.usageConfig?.templateType === "sub2api"
); );
const providerModels = Object.values(models).filter( const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name (m) => m.provider === provider.name
@@ -221,7 +223,7 @@ function ProviderCard({
)} )}
{/* compact usage summary — sits left of the switch button, {/* compact usage summary — sits left of the switch button,
mirroring cc-switch's card layout (usage → action buttons) */} mirroring cc-switch's card layout (usage → action buttons) */}
{(provider.usageKinds?.length ?? 0) > 0 && ( {usage.supported && (
<UsageFooter usage={usage} variant="compact" /> <UsageFooter usage={usage} variant="compact" />
)} )}
<button <button
+7 -5
View File
@@ -43,12 +43,15 @@ interface SubagentSettingsPageProps {
onBack: () => void; onBack: () => void;
} }
// Upstream removed the "max" effort tier (auto-migrates to "high"). // Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and
const EFFORTS = ["low", "medium", "high"] as const; // 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> = { const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow", low: "thinkingLow",
medium: "thinkingMedium", medium: "thinkingMedium",
high: "thinkingHigh", high: "thinkingHigh",
max: "thinkingMax",
xhigh: "thinkingXHigh",
}; };
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = { const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
@@ -281,9 +284,8 @@ export function SubagentSettingsPage({
? `${Math.round(n / 1000)}K` ? `${Math.round(n / 1000)}K`
: String(n); : String(n);
const effortLabel = (e: string): string => { const effortLabel = (e: string): string => {
// Stored "max" tiers from old configs are shown as "high" (upstream // Unknown values (hand-written config) are shown verbatim.
// removed the tier; it auto-migrates to "high"). const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]];
const key = EFFORT_LABELS[e === "max" ? "high" : (e as (typeof EFFORTS)[number])];
return key ? t(key) : e; return key ? t(key) : e;
}; };
+12 -6
View File
@@ -106,7 +106,7 @@ export function Segmented({
onChange, onChange,
disabled, disabled,
}: { }: {
options: { key: string; label: string }[]; options: { key: string; label: string; disabled?: boolean; title?: string }[];
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
@@ -117,21 +117,27 @@ export function Segmented({
disabled ? "opacity-50" : "" 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.
<span key={opt.key} title={opt.title} className="inline-flex">
<button <button
key={opt.key}
type="button" type="button"
disabled={disabled} disabled={optDisabled}
onClick={() => onChange(opt.key)} onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${ className={`px-3 py-1 text-sm ${
value === opt.key value === opt.key
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2" : "bg-input text-content-muted hover:bg-hover-2"
}`} } ${optDisabled ? "cursor-not-allowed opacity-40 hover:bg-input" : ""}`}
> >
{opt.label} {opt.label}
</button> </button>
))} </span>
);
})}
</div> </div>
); );
} }
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { isAlertTier, type UsageData } from "./useUsageQuery"; import { isAlertTier, usageQuerySupported, type UsageData } from "./useUsageQuery";
/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */ /** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */
function percentTier(used: number): UsageData { function percentTier(used: number): UsageData {
@@ -42,3 +42,22 @@ describe("isAlertTier", () => {
expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false); expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false);
}); });
}); });
describe("usageQuerySupported", () => {
it("有识别类型即可查询", () => {
expect(usageQuerySupported(["balance:deepseek"])).toBe(true);
expect(usageQuerySupported([], true)).toBe(true);
});
it("显式模板(newapi/sub2api)绕过 usage_kinds——自定义供应商也该查", () => {
expect(usageQuerySupported(undefined, true)).toBe(true);
});
it("无识别类型且非显式模板时不查询", () => {
expect(usageQuerySupported(undefined)).toBe(false);
expect(usageQuerySupported([])).toBe(false);
// auto 模板没有识别到类型 = 不支持(与弹窗短路口径一致)
expect(usageQuerySupported([], false)).toBe(false);
expect(usageQuerySupported(undefined, undefined)).toBe(false);
});
});
+17 -2
View File
@@ -75,15 +75,30 @@ export function isAlertTier(
return d.unit === "%" && d.used != null && d.used >= threshold; return d.unit === "%" && d.used != null && d.used >= threshold;
} }
/**
* 是否值得为该供应商发起用量查询:有预设识别类型,或后端按显式模板
* (newapi / sub2api,二者绕过 usage_kinds)查询。
*/
export function usageQuerySupported(
usageKinds?: string[],
explicitTemplate?: boolean
): boolean {
return (usageKinds?.length ?? 0) > 0 || explicitTemplate === true;
}
export function useUsageQuery( export function useUsageQuery(
agent: Agent, agent: Agent,
providerName: string, providerName: string,
usageKinds?: string[], usageKinds?: string[],
autoIntervalMinutes?: number, autoIntervalMinutes?: number,
disabled?: boolean, disabled?: boolean,
threshold?: number threshold?: number,
/** Backend resolves the query from an explicit template (newapi / sub2api)
* regardless of usageKinds — those bypass kinds entirely, so the row is
* supported even when the provider has no preset-detected kinds. */
explicitTemplate?: boolean
): UsageQueryState { ): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0; const supported = usageQuerySupported(usageKinds, explicitTemplate);
// Cache key includes the agent so a Kimi Code provider and a Pi provider // Cache key includes the agent so a Kimi Code provider and a Pi provider
// with the same name do not clobber each other's cached result. // with the same name do not clobber each other's cached result.
const cacheKey = `${agent}:${providerName}`; const cacheKey = `${agent}:${providerName}`;
+9
View File
@@ -164,6 +164,15 @@ export const enTranslations: Record<TranslationKey, string> = {
thinkingLow: "Low", thinkingLow: "Low",
thinkingMedium: "Medium", thinkingMedium: "Medium",
thinkingHigh: "High", 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.", thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
loopControlSettings: "Loop Control", loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step", maxAttemptsPerStep: "Max attempts per step",
+6
View File
@@ -161,6 +161,12 @@ export const zhTranslations = {
thinkingLow: "低", thinkingLow: "低",
thinkingMedium: "中", thinkingMedium: "中",
thinkingHigh: "高", thinkingHigh: "高",
thinkingMax: "Max",
thinkingXHigh: "XHigh",
thinkingEffortUnsupported: "当前默认模型不支持思考。",
thinkingEffortTierUnsupported: "当前默认模型不支持该档位(支持:{levels})。",
thinkingEffortTierUndeclared: "该模型未声明思考等级,上游可能拒绝此档位。",
thinkingEffortDependsOnDefaultModel: "实际生效的档位取决于当前默认模型。",
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。", thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
loopControlSettings: "循环控制", loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数", maxAttemptsPerStep: "单步最大尝试次数",
+21 -4
View File
@@ -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", () => { describe("getAgentSettings — thinking.keep normalization", () => {
@@ -52,12 +52,29 @@ describe("getAgentSettings — thinking.keep normalization", () => {
}); });
}); });
describe("getAgentSettings — effort \"max\" read mapping", () => { describe("getAgentSettings — thinking.effort read mapping", () => {
it("normalizes a stored \"max\" to \"high\" on read", () => { it("keeps a stored \"max\" as-is (the tier is valid upstream)", () => {
expect(getAgentSettings({ thinking: { effort: "max" } }).thinking?.effort).toBe( 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");
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
-5
View File
@@ -65,11 +65,6 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
if (thinking.keep !== undefined && thinking.keep !== "all") { if (thinking.keep !== undefined && thinking.keep !== "all") {
thinking.keep = "off"; 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"]>( const sectionPermission = getSection<AgentSettings["permission"]>(
rawOther, rawOther,
"permission" "permission"
+5540 -2016
View File
File diff suppressed because it is too large. Load diff
+4671 -1671
View File
File diff suppressed because it is too large. Load diff
+139
View File
@@ -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 });
});
});
+127
View File
@@ -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
View File
@@ -111,11 +111,12 @@ export interface DiscoveredModel {
export interface ThinkingConfig { export interface ThinkingConfig {
enabled?: boolean; enabled?: boolean;
/** /**
* Effort tier. `max` is read-compatible only — upstream removed the tier * Effort tier, forwarded verbatim to OpenAI-compatible upstreams as
* (old configs auto-migrate to `high`); the UI normalizes it and the * `reasoning_effort`. Upstream accepts a free-form string; the UI offers
* serialization path never writes it. * 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", * Keep thinking content. The legacy off values (`false`, `0`, "no", "none",
* `null`) are read-compatible — old configs may carry them; the UI * `null`) are read-compatible — old configs may carry them; the UI