Skip to content
5 changes: 5 additions & 0 deletions .changeset/wise-otters-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix Thinking effort routing so non-Kimi providers preserve configured values for upstream validation, while Kimi models validate runtime selections, fall back safely during model resolution, and synchronize the effective effort back to clients.
46 changes: 30 additions & 16 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,8 @@ async function performModelSwitch(
const modelChanged = alias !== prevModel;
const effortChanged = effort !== prevEffort;
const runtimeChanged = modelChanged || effortChanged;
const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]);
let effectiveAlias = alias;
let effectiveEffort = effort;

const session = host.session;
try {
Expand All @@ -416,22 +417,35 @@ async function performModelSwitch(
if (effort !== prevEffort) {
await session.setThinking(effort);
}
const status = await session.getStatus();
effectiveAlias = status.model ?? alias;
effectiveEffort = status.thinkingEffort;
}
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Failed to switch model: ${msg}`);
return;
}

host.setAppState({ model: alias, thinkingEffort: effort });
if (session === undefined) {
effectiveAlias = host.state.appState.model;
effectiveEffort = host.state.appState.thinkingEffort;
}
const effectiveModelChanged = effectiveAlias !== prevModel;
const effectiveEffortChanged = effectiveEffort !== prevEffort;
const displayName = modelDisplayName(
effectiveAlias,
host.state.appState.availableModels[effectiveAlias],
);
host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort });
if (session === undefined && runtimeChanged) {
if (alias !== prevModel) {
host.track('model_switch', { model: alias });
if (effectiveModelChanged) {
host.track('model_switch', { model: effectiveAlias });
}
if (effort !== prevEffort) {
if (effectiveEffortChanged) {
host.track('thinking_toggle', {
enabled: effort !== 'off',
effort,
enabled: effectiveEffort !== 'off',
effort: effectiveEffort,
from: prevEffort,
});
}
Expand All @@ -440,7 +454,7 @@ async function performModelSwitch(
let persisted = false;
if (persist) {
try {
persisted = await persistModelSelection(host, alias, effort);
persisted = await persistModelSelection(host, effectiveAlias, effectiveEffort);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the selected effort, not env-forced status

In an active session with KIMI_MODEL_THINKING_EFFORT set, session.getStatus() returns the provider-effective effort, including the env-only forced value. Passing effectiveEffort into persistModelSelection makes /model save that env override into config.toml when the user chooses to persist the selection, so a temporary shell override such as max becomes the permanent default even if the user selected a different supported effort. Keep using the effective value for display/state, but persist the selected/base effort with Kimi fallback applied before env forcing.

Useful? React with 👍 / 👎.

} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`);
Expand All @@ -449,18 +463,18 @@ async function performModelSwitch(
}

let status: string;
if (modelChanged) {
if (effectiveModelChanged) {
status = persist
? `Switched to ${displayName} with thinking ${effort}.`
: `Switched to ${displayName} with thinking ${effort} for this session only.`;
} else if (effortChanged) {
? `Switched to ${displayName} with thinking ${effectiveEffort}.`
: `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`;
} else if (effectiveEffortChanged) {
status = persist
? `Thinking set to ${effort}.`
: `Thinking set to ${effort} for this session only.`;
? `Thinking set to ${effectiveEffort}.`
: `Thinking set to ${effectiveEffort} for this session only.`;
} else if (persist && persisted) {
status = `Saved ${displayName} with thinking ${effort} as default.`;
status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`;
} else {
status = `Already using ${displayName} with thinking ${effort}.`;
status = `Already using ${displayName} with thinking ${effectiveEffort}.`;
}
host.showStatus(status, 'success');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ export class SessionEventHandler {
patch.permissionMode = event.permission;
}
if (event.model !== undefined) patch.model = event.model;
if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort;
if (Object.keys(patch).length > 0) this.host.setAppState(patch);
if (event.swarmMode === false) {
this.host.state.swarmModeEntry = undefined;
Expand Down
93 changes: 89 additions & 4 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ function makeStartupInput(): KimiTUIStartupInput {
}

function makeSession(overrides: Record<string, unknown> = {}) {
let model = 'k2';
let thinkingEffort = 'off';
return {
id: 'ses-1',
model: 'k2',
Expand All @@ -151,8 +153,8 @@ function makeSession(overrides: Record<string, unknown> = {}) {
cancel: vi.fn(async () => {}),
cancelCompaction: vi.fn(async () => {}),
getStatus: vi.fn(async () => ({
model: 'k2',
thinkingEffort: 'off',
model,
thinkingEffort,
permission: 'manual',
planMode: false,
contextTokens: 0,
Expand All @@ -162,8 +164,12 @@ function makeSession(overrides: Record<string, unknown> = {}) {
getGoal: vi.fn(async () => ({ goal: null })),
setApprovalHandler: vi.fn(),
setQuestionHandler: vi.fn(),
setModel: vi.fn(async () => {}),
setThinking: vi.fn(async () => {}),
setModel: vi.fn(async (alias: string) => {
model = alias;
}),
setThinking: vi.fn(async (effort: string) => {
thinkingEffort = effort;
}),
setPermission: vi.fn(async () => {}),
setPlanMode: vi.fn(async () => {}),
setSwarmMode: vi.fn(async () => {}),
Expand Down Expand Up @@ -2987,6 +2993,24 @@ command = "vim"
expect(stripSgr(renderTranscript(driver))).toContain('LLM not set');
});

it('applies the effective thinking effort from status updates', async () => {
const { driver } = await makeDriver();

driver.sessionEventHandler.handleEvent(
{
type: 'agent.status.updated',
agentId: 'main',
sessionId: 'ses-1',
model: 'turbo',
thinkingEffort: 'mid',
} as Event,
vi.fn(),
);

expect(driver.state.appState.model).toBe('turbo');
expect(driver.state.appState.thinkingEffort).toBe('mid');
});

it('renders swarm mode markers from /swarm commands, not tool-triggered status updates', async () => {
const { driver } = await makeDriver();

Expand Down Expand Up @@ -4588,6 +4612,67 @@ command = "vim"
expect(driver.state.appState.thinkingEffort).toBe('on');
});

it('uses the effective effort returned after a model-switch fallback', async () => {
let switched = false;
const session = makeSession({
getStatus: vi.fn(async () => ({
model: switched ? 'turbo' : 'k2',
thinkingEffort: switched ? 'mid' : 'ultra',
permission: 'manual',
planMode: false,
contextTokens: 0,
maxContextTokens: 100,
contextUsage: 0,
})),
setModel: vi.fn(async () => {
switched = true;
}),
});
const setConfig = vi.fn(async () => ({ providers: {} }));
const { driver } = await makeDriver(session, {
getConfig: vi.fn(async () => ({
models: {
k2: {
provider: 'managed:kimi-code',
model: 'kimi-k2',
maxContextSize: 100,
capabilities: ['thinking'],
supportEfforts: ['low', 'high', 'ultra'],
defaultEffort: 'ultra',
},
turbo: {
provider: 'managed:kimi-code',
model: 'kimi-turbo',
maxContextSize: 100,
capabilities: ['thinking'],
supportEfforts: ['low', 'mid', 'high'],
defaultEffort: 'mid',
},
},
defaultModel: 'k2',
thinking: { enabled: true, effort: 'ultra' },
})),
setConfig,
});

driver.handleUserInput('/model turbo');

await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent);
});
(driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r');

await vi.waitFor(() => {
expect(setConfig).toHaveBeenCalledWith({
defaultModel: 'turbo',
thinking: { enabled: true, effort: 'mid' },
});
});
expect(driver.state.appState.model).toBe('turbo');
expect(driver.state.appState.thinkingEffort).toBe('mid');
expect(renderTranscript(driver)).toContain('Switched to kimi-turbo with thinking mid.');
});

it('persists /model selection even when runtime state is unchanged', async () => {
const session = makeSession();
const setConfig = vi.fn(async () => ({ providers: {} }));
Expand Down
4 changes: 2 additions & 2 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Each entry in the `models` table defines a model alias (the name used in `defaul
| `max_context_size` | `integer` | Yes | Maximum context length in tokens; must be at least 1 |
| `max_output_size` | `integer` | No | Per-request output token cap (maps to `max_tokens`). Currently only the `anthropic` provider honors it. When set for a Claude model, this explicit value overrides the built-in server-side maximum |
| `capabilities` | `array<string>` | No | Capability tags to add explicitly: `thinking`, `always_thinking`, `image_in`, `video_in`, `audio_in`, `tool_use`. Unioned with the capabilities auto-detected by the provider — entries can only be added, never removed |
| `support_efforts` | `array<string>` | No | Thinking effort levels declared by the model catalog. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."<alias>".overrides] support_efforts` instead |
| `support_efforts` | `array<string>` | No | Thinking effort levels the model accepts. For `kimi`, selecting another value at runtime fails; when model resolution carries an unsupported configured or previous value, the session falls back to the target model's `default_effort` and reports that effective value to the UI. A Thinking-capable Kimi model without this field uses boolean `on` / `off`. Other providers pass concrete values unchanged when their protocol has a native effort field; protocols that expose only levels or token budgets perform the required format conversion. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."<alias>".overrides] support_efforts` instead |
| `default_effort` | `string` | No | Default thinking effort for the model. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."<alias>".overrides] default_effort` instead |
| `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset |
| `reasoning_key` | `string` | No | `openai` provider only. Override the field name used for reasoning content when the gateway returns it under a non-standard name; by default `reasoning_content`, `reasoning_details`, and `reasoning` are auto-detected |
Expand Down Expand Up @@ -181,7 +181,7 @@ You can also switch models temporarily without touching the config file — by s
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Whether Thinking is enabled by default for new sessions; set to `false` to force Thinking off |
| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`); the levels actually available depend on the model's declared `support_efforts`, and unrecognized values are ignored by the provider |
| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`). Non-Kimi providers do not remap concrete effort values when the upstream protocol accepts them; if the provider rejects the value, choose one that the model supports. Protocols that expose only levels or token budgets still require format conversion. Kimi models with `support_efforts` fall back to their model default when this configured value is not listed; Kimi models without that list treat every enabled value as boolean `on` |
| `keep` | `string` | `"all"` | Preserved Thinking passthrough. On `kimi` it is sent as `thinking.keep`; on `anthropic` (Claude and Kimi's Anthropic-compatible mode) it is sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API; an off-value disables keep and returns to the standard endpoint). `"all"` preserves prior turns' reasoning (`reasoning_content` / Anthropic thinking blocks); set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; only injected while Thinking is on |

### Deprecated fields
Expand Down
4 changes: 2 additions & 2 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ KIMI_BASE_URL = "https://api.moonshot.ai/v1"
| `max_context_size` | `integer` | 是 | 最大上下文长度(token 数),必须 ≥ 1 |
| `max_output_size` | `integer` | 否 | 单次请求的输出 token 上限(对应 `max_tokens`)。目前仅 `anthropic` 供应商读取。为 Claude 模型设置后,这个显式值会覆盖内置的服务端最大值 |
| `capabilities` | `array<string>` | 否 | 显式追加的能力标签:`thinking`、`always_thinking`、`image_in`、`video_in`、`audio_in`、`tool_use`。与供应商自动识别的能力取并集,只能追加不能移除 |
| `support_efforts` | `array<string>` | 否 | 模型目录声明的 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."<alias>".overrides] support_efforts` |
| `support_efforts` | `array<string>` | 否 | 模型接受的 Thinking 档位。对 `kimi` 而言,在运行时选择列表外的值会报错;模型解析时若配置值或之前的值不受目标模型支持,会回落到目标模型的 `default_effort`,并将该有效值同步给 UI。支持 Thinking 但没有此字段的 Kimi 模型使用布尔 `on` / `off`。其他 provider 在协议提供原生 effort 字段时会原样传递具体值;协议仅提供等级或 token budget 时,只做必要的格式转换。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."<alias>".overrides] support_efforts` |
| `default_effort` | `string` | 否 | 模型的默认 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."<alias>".overrides] default_effort` |
| `display_name` | `string` | 否 | UI 中显示的名称,未设时回退到 `model` |
| `reasoning_key` | `string` | 否 | 仅 `openai` 供应商。当网关用非标准字段名返回推理内容时才需要设置;默认自动识别 `reasoning_content` / `reasoning_details` / `reasoning` |
Expand Down Expand Up @@ -181,7 +181,7 @@ display_name = "Kimi for Coding (custom)"
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | 新会话是否默认开启 Thinking,设为 `false` 可强制关闭 |
| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`),实际可用等级取决于模型声明的 `support_efforts`,未识别的值会被供应商忽略 |
| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`)。非 Kimi provider 在上游协议接受具体 effort 值时不会改写该值;如果上游拒绝,请改成该模型支持的档位。协议仅提供等级或 token budget 时,仍需做格式转换。对于带 `support_efforts` 的 Kimi 模型,若该配置值不在列表中,会回落到模型默认档位;没有该列表的 Kimi 模型会把任意开启值视为布尔 `on` |
| `keep` | `string` | `"all"` | 保留思考透传。在 `kimi` 上以 `thinking.keep` 发送;在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API;关值可禁用 keep 并回到标准端点)。`"all"` 会保留历史轮次的思考内容(`reasoning_content` / Anthropic thinking blocks);传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅在 Thinking 开启时注入 |

### 已废弃字段
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
const originalHistory = [...this.context.get()];
const tokensBefore = estimateTokensForMessages(originalHistory);
let retryCount = 0;
let thinkingEffort = this.profile.data().thinkingLevel;

try {
const signal = active.abortController.signal;
Expand All @@ -500,6 +501,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
await this.hooks.onWillCompact.run(active);

const resolvedModel = this.profile.resolveModelContext();
thinkingEffort = resolvedModel.thinkingLevel;
const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens;
const defaultCompactionCap =
maxContextTokens > 0
Expand Down Expand Up @@ -615,7 +617,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
dropped_count: result.droppedCount,
retry_count: retryCount,
round: 1,
thinking_effort: this.profile.data().thinkingLevel,
thinking_effort: thinkingEffort,
...usageTelemetry(attempt.usage),
};
this.telemetry.track2('compaction_finished', properties);
Expand All @@ -628,7 +630,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
duration_ms: Date.now() - startedAt,
round: 1,
retry_count: retryCount,
thinking_effort: this.profile.data().thinkingLevel,
thinking_effort: thinkingEffort,
error_type: error instanceof Error ? error.name : 'Unknown',
});
if (
Expand Down
17 changes: 13 additions & 4 deletions packages/agent-core-v2/src/agent/profile/configSection.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
/**
* `profile` domain (L4) — `thinking` config-section env bindings.
*
* Declares the `KIMI_MODEL_THINKING_EFFORT` environment binding (gated on
* `KIMI_MODEL_NAME`). Applied to the effective `thinking` value by `config`.
* Declares the env-only `KIMI_MODEL_THINKING_EFFORT` force override. Applied
* to the effective `thinking` value by `config` and stripped before
* persistence.
*/

import { z } from 'zod';

import { envBindings } from '#/app/config/config';
import { type ConfigStripEnv, envBindings } from '#/app/config/config';
import { registerConfigSection } from '#/app/config/configSectionContributions';

export const THINKING_SECTION = 'thinking';

export const ThinkingConfigSchema = z.object({
enabled: z.boolean().optional(),
effort: z.string().optional(),
forcedEffort: z.string().optional(),
keep: z.string().optional(),
});

export type ThinkingConfig = z.infer<typeof ThinkingConfigSchema>;

export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, {
effort: 'KIMI_MODEL_THINKING_EFFORT',
forcedEffort: 'KIMI_MODEL_THINKING_EFFORT',
});

export const stripThinkingEnv: ConfigStripEnv<ThinkingConfig> = (value) => {
const result = { ...value };
delete result.forcedEffort;
return result;
};

registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, {
env: thinkingEnvBindings,
stripEnv: stripThinkingEnv,
});
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/agent/profile/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export interface IAgentProfileService {
refreshSystemPrompt(): Promise<void>;
getAgentsMdWarning(): string | undefined;
data(): ProfileData;
getEffectiveThinkingLevel(): ThinkingEffort;
resolveModelContext(): ProfileModelContext;
getProvider(): Model;
resolveModel(): Model | undefined;
Expand Down
Loading
Loading