diff --git a/.kimi/prompts/gen-changelog.md b/.kimi/prompts/gen-changelog.md index 3b12b8b94..ea53533e7 100644 --- a/.kimi/prompts/gen-changelog.md +++ b/.kimi/prompts/gen-changelog.md @@ -1 +1 @@ -根据当前分支相对于 main 分支的修改,在的 @CHANGELOG.md 文件中添加更新日志条目,遵循现有的格式和风格。 +根据当前分支相对于 main 分支的修改,在的根目录 CHANGELOG.md 或相应 package/sdk 等目录的 CHANGELOG.md 文件中添加更新日志条目,遵循现有的格式和风格。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1217d0d1e..aa8f625e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Use `/model` to toggle thinking mode instead of Tab key +- Config: Add `default_thinking` config option (auto-migrated from metadata) +- LLM: Add `always_thinking` capability for models that always use thinking mode + ## 0.76 (2026-01-12) - Tool: Make `ReadFile` tool description reflect model capabilities for image/video support diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 8fa43186d..43fceac90 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -25,6 +25,7 @@ The configuration file contains the following top-level configuration items: | Item | Type | Description | | --- | --- | --- | | `default_model` | `string` | Default model name, must be a model defined in `models` | +| `default_thinking` | `boolean` | Whether to enable thinking mode by default (defaults to `false`) | | `providers` | `table` | API provider configuration | | `models` | `table` | Model configuration | | `loop_control` | `table` | Agent loop control parameters | @@ -35,6 +36,7 @@ The configuration file contains the following top-level configuration items: ```toml default_model = "kimi-for-coding" +default_thinking = false [providers.kimi-for-coding] type = "kimi" diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 6037e7b94..5c5f6d49b 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -53,7 +53,7 @@ export KIMI_MODEL_MAX_CONTEXT_SIZE="262144" ### `KIMI_MODEL_CAPABILITIES` -Overrides the model's `capabilities` field in the configuration file. Multiple capabilities are comma-separated, supported values are `thinking`, `image_in`, and `video_in`. +Overrides the model's `capabilities` field in the configuration file. Multiple capabilities are comma-separated, supported values are `thinking`, `always_thinking`, `image_in`, and `video_in`. ```sh export KIMI_MODEL_CAPABILITIES="thinking,image_in" diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index c3767e43c..a934877b3 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -108,7 +108,8 @@ The `capabilities` field in model configuration declares the capabilities suppor | Capability | Description | | --- | --- | -| `thinking` | Supports thinking mode (deep reasoning) | +| `thinking` | Supports thinking mode (deep reasoning), can be toggled | +| `always_thinking` | Always uses thinking mode (cannot be disabled) | | `image_in` | Supports image input | | `video_in` | Supports video input | @@ -122,7 +123,11 @@ capabilities = ["thinking", "image_in"] ### `thinking` -When thinking mode is enabled, the model performs deeper reasoning before answering, suitable for complex problems. In shell mode, you can toggle thinking mode with the `Tab` key, or control it at startup with `--thinking` / `--no-thinking` flags. +Declares that the model supports thinking mode. When enabled, the model performs deeper reasoning before answering, suitable for complex problems. In shell mode, you can use the `/model` command to switch models and thinking mode, or control it at startup with `--thinking` / `--no-thinking` flags. + +### `always_thinking` + +Indicates the model always uses thinking mode and cannot be disabled. For example, models with "thinking" in their name like `kimi-k2-thinking-turbo` typically have this capability. When using such models, the `/model` command won't prompt for thinking mode toggle. ### `image_in` diff --git a/docs/en/faq.md b/docs/en/faq.md index 481ab5277..366a085c9 100644 --- a/docs/en/faq.md +++ b/docs/en/faq.md @@ -33,15 +33,6 @@ If you need to change working directory: - **Use `--work-dir` flag**: Specify working directory at startup, like `kimi --work-dir /path/to/project`. - **Use absolute paths in commands**: Execute commands with absolute paths directly, like `ls /path/to/dir`. -### Thinking mode unavailable - -When pressing `Tab` to toggle thinking mode, if you see "Thinking mode requires a model with thinking capability", it means the current model doesn't support thinking mode. - -Solutions: - -- **Switch to a supported model**: Use `/setup` to select a model that supports thinking (like `kimi-k2-thinking-turbo`). -- **Check model capability configuration**: If you're sure the model supports thinking, you can force enable it with the environment variable `KIMI_MODEL_CAPABILITIES=thinking`. - ### Image paste fails When using `Ctrl-V` to paste an image, if you see "Current model does not support image input", it means the current model doesn't support image input. diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 27ae8c875..005949dd2 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -29,14 +29,14 @@ In shell mode, each command executes independently. Commands that change the env Thinking mode allows the AI to think more deeply before responding, suitable for handling complex problems. -In agent mode, press `Tab` to toggle thinking mode on or off. The status bar at the bottom will show a notification after switching. You can also enable it at startup with the `--thinking` flag: +You can use the `/model` command to switch models and thinking mode. After selecting a model, if the model supports thinking mode, the system will ask whether to enable it. You can also enable it at startup with the `--thinking` flag: ```sh kimi --thinking ``` ::: tip -Thinking mode requires support from the current model. +Thinking mode requires support from the current model. Some models (like `kimi-k2-thinking-turbo`) always use thinking mode and cannot be disabled. ::: ## Multi-line input diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index df08b7aa9..e029c26fe 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -7,7 +7,6 @@ Kimi CLI shell mode supports the following keyboard shortcuts. | Shortcut | Function | |----------|----------| | `Ctrl-X` | Toggle agent/shell mode | -| `Tab` | Toggle thinking mode | | `Ctrl-/` | Show help | | `Ctrl-J` | Insert newline | | `Alt-Enter` | Insert newline (same as `Ctrl-J`) | @@ -25,20 +24,9 @@ Press `Ctrl-X` in the input box to switch between two modes: - **Shell mode**: Input is executed as local shell command The prompt changes based on current mode: -- Agent mode: `✨` or `💫` (thinking mode) +- Agent mode: `✨` (normal) or `💫` (thinking mode) - Shell mode: `$` -### `Tab`: Toggle thinking mode - -In agent mode, when the input box is empty or no completion menu is displayed, press `Tab` to toggle thinking mode. - -- When thinking mode is enabled, the prompt changes to `💫` -- A notification is displayed when switching - -::: tip -Thinking mode requires model support. -::: - ### `Ctrl-/`: Show help Press `Ctrl-/` in the input box to quickly display help information, equivalent to entering the `/help` command. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index cb06106b6..5a11581fb 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -43,11 +43,11 @@ After configuration, settings are automatically saved to `~/.kimi/config.toml` a ### `/model` -Switch the default model. +Switch models and thinking mode. -This command first refreshes the available models list from the API platform. When called without arguments, displays an interactive selection interface; you can also specify a model name directly, e.g., `/model kimi-k2`. +This command first refreshes the available models list from the API platform. When called without arguments, displays an interactive selection interface where you first select a model, then choose whether to enable thinking mode (if the model supports it). -After selecting a new model, Kimi CLI will automatically update the configuration file and reload. +After selection, Kimi CLI will automatically update the configuration file and reload. ::: tip This command is only available when using the default configuration file. If a configuration was specified via `--config` or `--config-file`, this command cannot be used. diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 76c648720..67e13063a 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,10 @@ This page documents the changes in each Kimi CLI release. ## Unreleased +- Shell: Use `/model` to toggle thinking mode instead of Tab key +- Config: Add `default_thinking` config option (auto-migrated from metadata) +- LLM: Add `always_thinking` capability for models that always use thinking mode + ## 0.76 (2026-01-12) - Tool: Make `ReadFile` tool description reflect model capabilities for image/video support diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index a7e44b192..7f61f8dce 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -25,6 +25,7 @@ kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models" | 配置项 | 类型 | 说明 | | --- | --- | --- | | `default_model` | `string` | 默认使用的模型名称,必须是 `models` 中定义的模型 | +| `default_thinking` | `boolean` | 默认是否开启 Thinking 模式(默认为 `false`) | | `providers` | `table` | API 供应商配置 | | `models` | `table` | 模型配置 | | `loop_control` | `table` | Agent 循环控制参数 | @@ -35,6 +36,7 @@ kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models" ```toml default_model = "kimi-for-coding" +default_thinking = false [providers.kimi-for-coding] type = "kimi" diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index d803959fc..c6bc8926a 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -53,7 +53,7 @@ export KIMI_MODEL_MAX_CONTEXT_SIZE="262144" ### `KIMI_MODEL_CAPABILITIES` -覆盖配置文件中模型的 `capabilities` 字段。多个能力用逗号分隔,支持的值为 `thinking`、`image_in` 和 `video_in`。 +覆盖配置文件中模型的 `capabilities` 字段。多个能力用逗号分隔,支持的值为 `thinking`、`always_thinking`、`image_in` 和 `video_in`。 ```sh export KIMI_MODEL_CAPABILITIES="thinking,image_in" diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index 2a51c0700..69fcf9e47 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -108,7 +108,8 @@ env = { GOOGLE_CLOUD_PROJECT = "your-project-id" } | 能力 | 说明 | | --- | --- | -| `thinking` | 支持 thinking 模式(深度思考) | +| `thinking` | 支持 Thinking 模式(深度思考),可开关 | +| `always_thinking` | 始终使用 Thinking 模式(不可关闭) | | `image_in` | 支持图片输入 | | `video_in` | 支持视频输入 | @@ -122,7 +123,11 @@ capabilities = ["thinking", "image_in"] ### `thinking` -启用 Thinking 模式后,模型会在回答前进行更深入的推理,适合复杂问题。在 Shell 模式下,可以通过 `Tab` 键切换 Thinking 模式,或在启动时通过 `--thinking` / `--no-thinking` 参数控制。 +声明模型支持 Thinking 模式。启用后,模型会在回答前进行更深入的推理,适合复杂问题。在 Shell 模式下,可以通过 `/model` 命令切换模型和 Thinking 模式,或在启动时通过 `--thinking` / `--no-thinking` 参数控制。 + +### `always_thinking` + +表示模型始终使用 Thinking 模式,无法关闭。例如 `kimi-k2-thinking-turbo` 等名称中包含 "thinking" 的模型通常具有此能力。使用这类模型时,`/model` 命令不会提示选择 Thinking 模式的开关。 ### `image_in` diff --git a/docs/zh/faq.md b/docs/zh/faq.md index bc5d6ee16..11ee29a88 100644 --- a/docs/zh/faq.md +++ b/docs/zh/faq.md @@ -33,15 +33,6 @@ API 密钥无效可能的原因: - **使用 `--work-dir` 参数**:启动时指定工作目录,如 `kimi --work-dir /path/to/project`。 - **在命令中使用绝对路径**:直接使用绝对路径执行命令,如 `ls /path/to/dir`。 -### Thinking 模式不可用 - -按 `Tab` 键切换 Thinking 模式时,如果提示 "Thinking mode requires a model with thinking capability",说明当前模型不支持 Thinking 模式。 - -解决方法: - -- **切换到支持的模型**:使用 `/setup` 选择支持 Thinking 的模型(如 `kimi-k2-thinking-turbo`)。 -- **检查模型能力配置**:如果你确定模型支持 Thinking,可以通过环境变量 `KIMI_MODEL_CAPABILITIES=thinking` 强制启用。 - ### 粘贴图片失败 使用 `Ctrl-V` 粘贴图片时,如果提示 "Current model does not support image input",说明当前模型不支持图片输入。 diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 04678f4fe..cf454297e 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -29,14 +29,14 @@ Shell 模式中每个命令独立执行,`cd`、`export` 等改变环境的命 Thinking 模式让 AI 在回答前进行更深入的思考,适合处理复杂问题。 -在 Agent 模式下按 `Tab` 键可以切换 Thinking 模式的开关状态,切换后底部状态栏会显示提示。也可以在启动时通过 `--thinking` 参数启用: +你可以通过 `/model` 命令切换模型和 Thinking 模式。在选择模型后,如果模型支持 Thinking 模式,系统会询问是否开启。也可以在启动时通过 `--thinking` 参数启用: ```sh kimi --thinking ``` ::: tip 提示 -Thinking 模式需要当前模型支持。 +Thinking 模式需要当前模型支持。部分模型(如 `kimi-k2-thinking-turbo`)始终使用 Thinking 模式,无法关闭。 ::: ## 多行输入 diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index 40c8fcabe..0aa8f8631 100644 --- a/docs/zh/reference/keyboard.md +++ b/docs/zh/reference/keyboard.md @@ -7,7 +7,6 @@ Kimi CLI Shell 模式支持以下键盘快捷键。 | 快捷键 | 功能 | |--------|------| | `Ctrl-X` | 切换 Agent/Shell 模式 | -| `Tab` | 切换 Thinking 模式 | | `Ctrl-/` | 显示帮助 | | `Ctrl-J` | 插入换行 | | `Alt-Enter` | 插入换行(同 `Ctrl-J`) | @@ -25,20 +24,9 @@ Kimi CLI Shell 模式支持以下键盘快捷键。 - **Shell 模式**:输入作为本地 Shell 命令执行 提示符会根据当前模式变化: -- Agent 模式:`✨` 或 `💫`(thinking 模式) +- Agent 模式:`✨`(普通)或 `💫`(Thinking 模式) - Shell 模式:`$` -### `Tab`:切换 Thinking 模式 - -在 Agent 模式下,当输入框为空或没有显示补全菜单时,按 `Tab` 可切换 Thinking 模式。 - -- 启用 Thinking 模式时,提示符变为 `💫` -- 切换时会显示提示信息 - -::: tip 提示 -Thinking 模式需要模型支持。 -::: - ### `Ctrl-/`:显示帮助 在输入框中按 `Ctrl-/` 可快速显示帮助信息,等价于输入 `/help` 命令。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 40673311c..a8189b9c7 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -43,11 +43,11 @@ ### `/model` -切换默认模型。 +切换模型和 Thinking 模式。 -此命令会先从 API 平台刷新可用模型列表。不带参数调用时,显示交互式选择界面;也可以直接指定模型名称,如 `/model kimi-k2`。 +此命令会先从 API 平台刷新可用模型列表。不带参数调用时,显示交互式选择界面,首先选择模型,然后选择是否开启 Thinking 模式(如果模型支持)。 -选择新模型后,Kimi CLI 会自动更新配置文件并重新加载。 +选择完成后,Kimi CLI 会自动更新配置文件并重新加载。 ::: tip 提示 此命令仅在使用默认配置文件时可用。如果通过 `--config` 或 `--config-file` 指定了配置,则无法使用此命令。 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index f7f09cc96..3dac65ed2 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,10 @@ ## 未发布 +- Shell:使用 `/model` 命令切换 Thinking 模式,取代 Tab 键 +- Config:添加 `default_thinking` 配置项(从 metadata 自动迁移) +- LLM:为始终使用 Thinking 模式的模型添加 `always_thinking` 能力 + ## 0.76 (2026-01-12) - Tool:让 `ReadFile` 工具描述根据模型能力动态反映图片/视频支持情况 diff --git a/packages/kosong/CHANGELOG.md b/packages/kosong/CHANGELOG.md index ff4a55cf4..f74a16c23 100644 --- a/packages/kosong/CHANGELOG.md +++ b/packages/kosong/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add `thinking_effort` property to `ChatProvider` protocol to query current thinking effort level + ## 0.37.0 (2026-01-08) - Change `TokenUsage` from dataclass to pydantic BaseModel. diff --git a/packages/kosong/src/kosong/chat_provider/__init__.py b/packages/kosong/src/kosong/chat_provider/__init__.py index cba9eab47..622564ad8 100644 --- a/packages/kosong/src/kosong/chat_provider/__init__.py +++ b/packages/kosong/src/kosong/chat_provider/__init__.py @@ -23,6 +23,13 @@ def model_name(self) -> str: """ ... + @property + def thinking_effort(self) -> "ThinkingEffort | None": + """ + The current thinking effort level. Returns None if not explicitly set. + """ + ... + async def generate( self, system_prompt: str, diff --git a/packages/kosong/src/kosong/chat_provider/chaos.py b/packages/kosong/src/kosong/chat_provider/chaos.py index c1bc37777..0436781a9 100644 --- a/packages/kosong/src/kosong/chat_provider/chaos.py +++ b/packages/kosong/src/kosong/chat_provider/chaos.py @@ -175,6 +175,10 @@ def model_name(self) -> str: return f"chaos({self._provider.model_name})" return self._provider.model_name + @property + def thinking_effort(self) -> ThinkingEffort | None: + return self._provider.thinking_effort + def with_thinking(self, effort: ThinkingEffort) -> "ChaosChatProvider": return ChaosChatProvider(self._provider.with_thinking(effort), self._chaos_config) diff --git a/packages/kosong/src/kosong/chat_provider/echo.py b/packages/kosong/src/kosong/chat_provider/echo.py index 28a013c29..4c5800264 100644 --- a/packages/kosong/src/kosong/chat_provider/echo.py +++ b/packages/kosong/src/kosong/chat_provider/echo.py @@ -72,6 +72,10 @@ class EchoChatProvider: def model_name(self) -> str: return "echo" + @property + def thinking_effort(self) -> ThinkingEffort | None: + return None + async def generate( self, system_prompt: str, diff --git a/packages/kosong/src/kosong/chat_provider/kimi.py b/packages/kosong/src/kosong/chat_provider/kimi.py index bb736fab2..15720c4f4 100644 --- a/packages/kosong/src/kosong/chat_provider/kimi.py +++ b/packages/kosong/src/kosong/chat_provider/kimi.py @@ -98,6 +98,21 @@ def __init__( def model_name(self) -> str: return self.model + @property + def thinking_effort(self) -> ThinkingEffort | None: + reasoning_effort = self._generation_kwargs.get("reasoning_effort") + if reasoning_effort is None: + return None + match reasoning_effort: + case "low": + return "low" + case "medium": + return "medium" + case "high": + return "high" + case _: + return "off" + async def generate( self, system_prompt: str, diff --git a/packages/kosong/src/kosong/chat_provider/mock.py b/packages/kosong/src/kosong/chat_provider/mock.py index 65e09fdbf..b75828707 100644 --- a/packages/kosong/src/kosong/chat_provider/mock.py +++ b/packages/kosong/src/kosong/chat_provider/mock.py @@ -36,6 +36,10 @@ def __init__( def model_name(self) -> str: return "mock" + @property + def thinking_effort(self) -> ThinkingEffort | None: + return None + async def generate( self, system_prompt: str, diff --git a/packages/kosong/src/kosong/chat_provider/openai_common.py b/packages/kosong/src/kosong/chat_provider/openai_common.py index 284dc7583..a8a95d74b 100644 --- a/packages/kosong/src/kosong/chat_provider/openai_common.py +++ b/packages/kosong/src/kosong/chat_provider/openai_common.py @@ -44,6 +44,18 @@ def thinking_effort_to_reasoning_effort(effort: ThinkingEffort) -> ReasoningEffo return "high" +def reasoning_effort_to_thinking_effort(effort: ReasoningEffort) -> ThinkingEffort: + match effort: + case "low" | "minimal": + return "low" + case "medium": + return "medium" + case "high" | "xhigh": + return "high" + case "none" | None: + return "off" + + def tool_to_openai(tool: Tool) -> ChatCompletionToolParam: """Convert a single tool to OpenAI tool format.""" # simply `model_dump` because the `Tool` type is OpenAI-compatible diff --git a/packages/kosong/src/kosong/contrib/chat_provider/anthropic.py b/packages/kosong/src/kosong/contrib/chat_provider/anthropic.py index ad5237579..b41ef4a19 100644 --- a/packages/kosong/src/kosong/contrib/chat_provider/anthropic.py +++ b/packages/kosong/src/kosong/contrib/chat_provider/anthropic.py @@ -142,6 +142,20 @@ def __init__( def model_name(self) -> str: return self._model + @property + def thinking_effort(self) -> "ThinkingEffort | None": + thinking_config = self._generation_kwargs.get("thinking") + if thinking_config is None: + return None + if thinking_config["type"] == "disabled": + return "off" + budget = thinking_config["budget_tokens"] + if budget <= 1024: + return "low" + if budget <= 4096: + return "medium" + return "high" + async def generate( self, system_prompt: str, diff --git a/packages/kosong/src/kosong/contrib/chat_provider/google_genai.py b/packages/kosong/src/kosong/contrib/chat_provider/google_genai.py index f1ea1f73e..9e71f08f6 100644 --- a/packages/kosong/src/kosong/contrib/chat_provider/google_genai.py +++ b/packages/kosong/src/kosong/contrib/chat_provider/google_genai.py @@ -105,6 +105,37 @@ def __init__( def model_name(self) -> str: return self._model + @property + def thinking_effort(self) -> "ThinkingEffort | None": + thinking_config = self._generation_kwargs.get("thinking_config") + if thinking_config is None: + return None + + # For gemini-3 models that use thinking_level + thinking_level = thinking_config.thinking_level + if thinking_level is not None: + match thinking_level: + case ThinkingLevel.LOW | ThinkingLevel.MINIMAL: + return "low" + case ThinkingLevel.MEDIUM: + return "medium" + case ThinkingLevel.HIGH: + return "high" + case _: + return None + + # For other models that use thinking_budget + thinking_budget = thinking_config.thinking_budget + if thinking_budget is not None: + if thinking_budget == 0: + return "off" + if thinking_budget <= 1024: + return "low" + if thinking_budget <= 4096: + return "medium" + return "high" + return None + async def generate( self, system_prompt: str, diff --git a/packages/kosong/src/kosong/contrib/chat_provider/openai_legacy.py b/packages/kosong/src/kosong/contrib/chat_provider/openai_legacy.py index 8cf59237d..d312f59c3 100644 --- a/packages/kosong/src/kosong/contrib/chat_provider/openai_legacy.py +++ b/packages/kosong/src/kosong/contrib/chat_provider/openai_legacy.py @@ -17,6 +17,7 @@ from kosong.chat_provider import ChatProvider, StreamedMessagePart, ThinkingEffort, TokenUsage from kosong.chat_provider.openai_common import ( convert_error, + reasoning_effort_to_thinking_effort, thinking_effort_to_reasoning_effort, tool_to_openai, ) @@ -92,6 +93,12 @@ def __init__( def model_name(self) -> str: return self.model + @property + def thinking_effort(self) -> ThinkingEffort | None: + if isinstance(self._reasoning_effort, Omit): + return None + return reasoning_effort_to_thinking_effort(self._reasoning_effort) + async def generate( self, system_prompt: str, diff --git a/packages/kosong/src/kosong/contrib/chat_provider/openai_responses.py b/packages/kosong/src/kosong/contrib/chat_provider/openai_responses.py index c59602b3e..8e485750d 100644 --- a/packages/kosong/src/kosong/contrib/chat_provider/openai_responses.py +++ b/packages/kosong/src/kosong/contrib/chat_provider/openai_responses.py @@ -31,7 +31,11 @@ from openai.types.shared_params.responses_model import ResponsesModel from kosong.chat_provider import ChatProvider, StreamedMessagePart, ThinkingEffort, TokenUsage -from kosong.chat_provider.openai_common import convert_error, thinking_effort_to_reasoning_effort +from kosong.chat_provider.openai_common import ( + convert_error, + reasoning_effort_to_thinking_effort, + thinking_effort_to_reasoning_effort, +) from kosong.contrib.chat_provider.common import ToolMessageConversion from kosong.message import ( AudioURLPart, @@ -126,6 +130,13 @@ def __init__( def model_name(self) -> str: return self._model + @property + def thinking_effort(self) -> ThinkingEffort | None: + reasoning_effort = self._generation_kwargs.get("reasoning_effort") + if reasoning_effort is None: + return None + return reasoning_effort_to_thinking_effort(reasoning_effort) + async def generate( self, system_prompt: str, diff --git a/src/kimi_cli/acp/server.py b/src/kimi_cli/acp/server.py index 100dd7152..87b92e18e 100644 --- a/src/kimi_cli/acp/server.py +++ b/src/kimi_cli/acp/server.py @@ -18,7 +18,6 @@ from kimi_cli.config import LLMModel, save_config from kimi_cli.constant import NAME, VERSION from kimi_cli.llm import create_llm, derive_model_capabilities -from kimi_cli.metadata import load_metadata, save_metadata from kimi_cli.session import Session from kimi_cli.soul.slash import registry as soul_slash_registry from kimi_cli.soul.toolset import KimiToolset @@ -100,17 +99,15 @@ async def new_session( session = await Session.create(KaosPath.unsafe_from_local_path(Path(cwd))) - metadata = load_metadata() mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers) cli_instance = await KimiCLI.create( session, mcp_configs=[mcp_config], - thinking=metadata.thinking, ) config = cli_instance.soul.runtime.config acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities) acp_session = ACPSession(session.id, cli_instance, self.conn, kaos=acp_kaos) - model_id_conv = _ModelIDConv(config.default_model, metadata.thinking) + model_id_conv = _ModelIDConv(config.default_model, config.default_thinking) self.sessions[session.id] = (acp_session, model_id_conv) if isinstance(cli_instance.soul.agent.toolset, KimiToolset): @@ -172,17 +169,15 @@ async def load_session( ) raise acp.RequestError.invalid_params({"session_id": "Session not found"}) - metadata = load_metadata() mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers) cli_instance = await KimiCLI.create( session, mcp_configs=[mcp_config], - thinking=metadata.thinking, ) config = cli_instance.soul.runtime.config acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities) acp_session = ACPSession(session.id, cli_instance, self.conn, kaos=acp_kaos) - model_id_conv = _ModelIDConv(config.default_model, metadata.thinking) + model_id_conv = _ModelIDConv(config.default_model, config.default_thinking) self.sessions[session.id] = (acp_session, model_id_conv) if isinstance(cli_instance.soul.agent.toolset, KimiToolset): @@ -254,16 +249,14 @@ async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) new_provider, new_model, session_id=acp_session.id, + thinking=model_id_conv.thinking, ) cli_instance.soul.runtime.llm = new_llm - cli_instance.soul.set_thinking(model_id_conv.thinking) config.default_model = model_id_conv.model_key + config.default_thinking = model_id_conv.thinking assert config.is_from_default_location, "`kimi acp` must use the default config location" save_config(cli_instance.soul.runtime.config) - metadata = load_metadata() - metadata.thinking = model_id_conv.thinking - save_metadata(metadata) async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateResponse | None: raise NotImplementedError diff --git a/src/kimi_cli/app.py b/src/kimi_cli/app.py index 7cea86dbd..88513820a 100644 --- a/src/kimi_cli/app.py +++ b/src/kimi_cli/app.py @@ -17,7 +17,7 @@ from kimi_cli.llm import augment_provider_with_env_vars, create_llm from kimi_cli.session import Session from kimi_cli.share import get_share_dir -from kimi_cli.soul import LLMNotSet, LLMNotSupported, run_soul +from kimi_cli.soul import run_soul from kimi_cli.soul.agent import Runtime, load_agent from kimi_cli.soul.context import Context from kimi_cli.soul.kimisoul import KimiSoul @@ -54,7 +54,7 @@ async def create( mcp_configs: list[MCPConfig] | list[dict[str, Any]] | None = None, config: Config | Path | None = None, model_name: str | None = None, - thinking: bool = False, + thinking: bool | None = None, agent_file: Path | None = None, skills_dir: Path | None = None, max_steps_per_turn: int | None = None, @@ -72,7 +72,7 @@ async def create( config (Config | Path | None, optional): Configuration to use, or path to config file. Defaults to None. model_name (str | None, optional): Name of the model to use. Defaults to None. - thinking (bool, optional): Whether to enable thinking mode. Defaults to False. + thinking (bool | None, optional): Whether to enable thinking mode. Defaults to None. agent_file (Path | None, optional): Path to the agent file. Defaults to None. skills_dir (Path | None, optional): Path to the skills directory. Defaults to None. max_steps_per_turn (int | None, optional): Maximum number of steps in one turn. @@ -122,10 +122,14 @@ async def create( assert model is not None env_overrides = augment_provider_with_env_vars(provider, model) - llm = create_llm(provider, model, session_id=session.id) + # determine thinking mode + thinking = config.default_thinking if thinking is None else thinking + + llm = create_llm(provider, model, thinking=thinking, session_id=session.id) if llm is not None: logger.info("Using LLM provider: {provider}", provider=provider) logger.info("Using LLM model: {model}", model=model) + logger.info("Thinking mode: {thinking}", thinking=thinking) runtime = await Runtime.create(config, llm, session, yolo, skills_dir) @@ -137,10 +141,6 @@ async def create( await context.restore() soul = KimiSoul(agent, context=context) - try: - soul.set_thinking(thinking) - except (LLMNotSet, LLMNotSupported) as e: - logger.warning("Failed to enable thinking mode: {error}", error=e) return KimiCLI(soul, runtime, env_overrides) def __init__( diff --git a/src/kimi_cli/cli/__init__.py b/src/kimi_cli/cli/__init__.py index 900c784c7..08aaf1d8c 100644 --- a/src/kimi_cli/cli/__init__.py +++ b/src/kimi_cli/cli/__init__.py @@ -110,6 +110,13 @@ def kimi( help="LLM model to use. Default: default model set in config file.", ), ] = None, + thinking: Annotated[ + bool | None, + typer.Option( + "--thinking/--no-thinking", + help="Enable thinking mode. Default: default thinking mode set in config file.", + ), + ] = None, local_work_dir: Annotated[ Path | None, typer.Option( @@ -238,13 +245,6 @@ def kimi( help="Automatically approve all actions. Default: no.", ), ] = False, - thinking: Annotated[ - bool | None, - typer.Option( - "--thinking/--no-thinking", - help="Enable thinking mode if supported. Default: same as last time.", - ), - ] = None, skills_dir: Annotated[ Path | None, typer.Option( @@ -441,19 +441,13 @@ async def _run(session_id: str | None) -> bool: session = await Session.create(work_dir) logger.info("Created new session: {session_id}", session_id=session.id) - if thinking is None: - metadata = load_metadata() - thinking_mode = metadata.thinking - else: - thinking_mode = thinking - instance = await KimiCLI.create( session, yolo=yolo or (ui == "print"), # print mode implies yolo mcp_configs=mcp_configs, config=config, model_name=model_name, - thinking=thinking_mode, + thinking=thinking, agent_file=agent_file, skills_dir=skills_dir, max_steps_per_turn=max_steps_per_turn, @@ -505,9 +499,6 @@ async def _run(session_id: str | None) -> bool: else: work_dir_meta.last_session_id = session.id - # Update thinking mode - metadata.thinking = instance.soul.thinking - save_metadata(metadata) return succeeded diff --git a/src/kimi_cli/config.py b/src/kimi_cli/config.py index 92b1b53cd..3b65e0f1e 100644 --- a/src/kimi_cli/config.py +++ b/src/kimi_cli/config.py @@ -120,6 +120,7 @@ class Config(BaseModel): exclude=True, ) default_model: str = Field(default="", description="Default model to use") + default_thinking: bool = Field(default=False, description="Default thinking mode") models: dict[str, LLMModel] = Field(default_factory=dict, description="List of LLM models") providers: dict[str, LLMProvider] = Field( default_factory=dict, description="List of LLM providers" @@ -200,9 +201,50 @@ def load_config(config_file: Path | None = None) -> Config: except ValidationError as e: raise ConfigError(f"Invalid configuration file: {e}") from e config.is_from_default_location = is_default_config_file + + # Migrate thinking setting from metadata to config (one-time migration) + if is_default_config_file: + _migrate_thinking_from_metadata(config, config_file) + return config +def _migrate_thinking_from_metadata(config: Config, config_file: Path) -> None: + """Migrate thinking setting from metadata.json to config.toml (one-time migration).""" + from kimi_cli.share import get_share_dir + + metadata_file = get_share_dir() / "kimi.json" + if not metadata_file.exists(): + return + + try: + with open(metadata_file, encoding="utf-8") as f: + metadata = json.load(f) + except (json.JSONDecodeError, OSError): + return + + try: + if "thinking" not in metadata: + return + thinking_value = metadata.pop("thinking") + except (KeyError, TypeError): + return + + logger.info( + "Migrating thinking setting from metadata to config: {thinking}", + thinking=thinking_value, + ) + + config.default_thinking = bool(thinking_value) + save_config(config, config_file) + + try: + with open(metadata_file, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + except OSError as e: + logger.warning("Failed to update metadata after migration: {error}", error=e) + + def load_config_from_string(config_string: str) -> Config: """ Load configuration from a TOML or JSON string. diff --git a/src/kimi_cli/llm.py b/src/kimi_cli/llm.py index 82a26446b..306e0ca99 100644 --- a/src/kimi_cli/llm.py +++ b/src/kimi_cli/llm.py @@ -24,7 +24,7 @@ "_chaos", ] -type ModelCapability = Literal["image_in", "video_in", "thinking"] +type ModelCapability = Literal["image_in", "video_in", "thinking", "always_thinking"] ALL_MODEL_CAPABILITIES: set[ModelCapability] = set(get_args(ModelCapability.__value__)) @@ -86,6 +86,7 @@ def create_llm( provider: LLMProvider, model: LLMModel, *, + thinking: bool | None = None, session_id: str | None = None, ) -> LLM | None: if provider.type != "_echo" and (not provider.base_url or not model.model): @@ -184,17 +185,30 @@ def create_llm( ), ) + capabilities = derive_model_capabilities(model) + + # Apply thinking if specified or if model always requires thinking + if "always_thinking" in capabilities or (thinking is True and "thinking" in capabilities): + chat_provider = chat_provider.with_thinking("high") + elif thinking is False: + chat_provider = chat_provider.with_thinking("off") + # If thinking is None and model doesn't always think, leave as-is (default behavior) + return LLM( chat_provider=chat_provider, max_context_size=model.max_context_size, - capabilities=derive_model_capabilities(model), + capabilities=capabilities, model_config=model, provider_config=provider, ) def derive_model_capabilities(model: LLMModel) -> set[ModelCapability]: - capabilities = model.capabilities or set() - if model.model in {"kimi-for-coding", "kimi-code"} or "thinking" in model.model: + capabilities = set(model.capabilities or ()) + # Models with "thinking" in their name are always-thinking models + if "thinking" in model.model.lower() or "reason" in model.model.lower(): + capabilities.update(("thinking", "always_thinking")) + # These models support thinking but can be toggled on/off + elif model.model in {"kimi-for-coding", "kimi-code"}: capabilities.add("thinking") return capabilities diff --git a/src/kimi_cli/metadata.py b/src/kimi_cli/metadata.py index 102e703e1..8f31b1302 100644 --- a/src/kimi_cli/metadata.py +++ b/src/kimi_cli/metadata.py @@ -7,7 +7,7 @@ from kaos import get_current_kaos from kaos.local import local_kaos from kaos.path import KaosPath -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from kimi_cli.share import get_share_dir from kimi_cli.utils.logging import logger @@ -42,12 +42,11 @@ def sessions_dir(self) -> Path: class Metadata(BaseModel): """Kimi metadata structure.""" + model_config = ConfigDict(extra="ignore") + work_dirs: list[WorkDirMeta] = Field(default_factory=list[WorkDirMeta]) """Work directory list.""" - thinking: bool = False - """Whether the last session was in thinking mode.""" - def get_work_dir_meta(self, path: KaosPath) -> WorkDirMeta | None: """Get the metadata for a work directory.""" for wd in self.work_dirs: diff --git a/src/kimi_cli/platforms.py b/src/kimi_cli/platforms.py index e2b4e03de..d28f7dac7 100644 --- a/src/kimi_cli/platforms.py +++ b/src/kimi_cli/platforms.py @@ -3,12 +3,33 @@ from typing import Any, NamedTuple, cast import aiohttp +from pydantic import BaseModel from kimi_cli.config import Config, LLMModel, save_config +from kimi_cli.llm import ModelCapability from kimi_cli.utils.aiohttp import new_client_session from kimi_cli.utils.logging import logger +class ModelInfo(BaseModel): + """Model information returned from the API.""" + + id: str + context_length: int = 0 + supports_reasoning: bool = False + + @property + def capabilities(self) -> set[ModelCapability]: + """Derive capabilities from model info.""" + caps: set[ModelCapability] = set() + if self.supports_reasoning: + caps.add("thinking") + # Models with "thinking" in name are always-thinking + if "thinking" in self.id.lower(): + caps.update(("thinking", "always_thinking")) + return caps + + class Platform(NamedTuple): id: str name: str @@ -119,7 +140,7 @@ async def refresh_managed_models(config: Config) -> bool: return changed -async def list_models(platform: Platform, api_key: str) -> list[dict[str, Any]]: +async def list_models(platform: Platform, api_key: str) -> list[ModelInfo]: async with new_client_session() as session: models = await _list_models( session, @@ -129,7 +150,7 @@ async def list_models(platform: Platform, api_key: str) -> list[dict[str, Any]]: if platform.allowed_prefixes is None: return models prefixes = tuple(platform.allowed_prefixes) - return [model for model in models if str(model.get("id", "")).startswith(prefixes)] + return [model for model in models if model.id.startswith(prefixes)] async def _list_models( @@ -137,7 +158,7 @@ async def _list_models( *, base_url: str, api_key: str, -) -> list[dict[str, Any]]: +) -> list[ModelInfo]: models_url = f"{base_url.rstrip('/')}/models" try: async with session.get( @@ -152,33 +173,44 @@ async def _list_models( data = resp_json.get("data") if not isinstance(data, list): raise ValueError(f"Unexpected models response for {base_url}") - return cast(list[dict[str, Any]], data) + + result: list[ModelInfo] = [] + for item in cast(list[dict[str, Any]], data): + model_id = item.get("id") + if not model_id: + continue + result.append( + ModelInfo( + id=str(model_id), + context_length=int(item.get("context_length") or 0), + supports_reasoning=bool(item.get("supports_reasoning")), + ) + ) + return result def _apply_models( config: Config, provider_key: str, platform_id: str, - models: list[dict[str, Any]], + models: list[ModelInfo], ) -> bool: changed = False model_keys: list[str] = [] for model in models: - model_id = str(model.get("id", "")) - if not model_id: - continue - model_key = managed_model_key(platform_id, model_id) + model_key = managed_model_key(platform_id, model.id) model_keys.append(model_key) - max_context_size = model.get("context_length") existing = config.models.get(model_key) + capabilities = model.capabilities or None # empty set -> None if existing is None: config.models[model_key] = LLMModel( provider=provider_key, - model=model_id, - max_context_size=int(max_context_size or 0), + model=model.id, + max_context_size=model.context_length, + capabilities=capabilities, ) changed = True continue @@ -186,11 +218,14 @@ def _apply_models( if existing.provider != provider_key: existing.provider = provider_key changed = True - if existing.model != model_id: - existing.model = model_id + if existing.model != model.id: + existing.model = model.id + changed = True + if existing.max_context_size != model.context_length: + existing.max_context_size = model.context_length changed = True - if max_context_size is not None and existing.max_context_size != max_context_size: - existing.max_context_size = int(max_context_size) + if existing.capabilities != capabilities: + existing.capabilities = capabilities changed = True removed_default = False diff --git a/src/kimi_cli/soul/__init__.py b/src/kimi_cli/soul/__init__.py index f7cb56646..09d90dcc0 100644 --- a/src/kimi_cli/soul/__init__.py +++ b/src/kimi_cli/soul/__init__.py @@ -66,12 +66,20 @@ def name(self) -> str: @property def model_name(self) -> str: - """The name of the LLM model used by the soul. Empty string indicates no LLM configured.""" + """The name of the LLM model used by the soul. Empty string if LLM is not set.""" ... @property def model_capabilities(self) -> set[ModelCapability] | None: - """The capabilities of the LLM model used by the soul. None indicates no LLM configured.""" + """The capabilities of the LLM model used by the soul. None if LLM is not set.""" + ... + + @property + def thinking(self) -> bool | None: + """ + Whether thinking mode is currently enabled. + None if LLM is not set or thinking mode is not set explicitly. + """ ... @property diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 15afb004c..955c452ce 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -16,7 +16,6 @@ APIEmptyResponseError, APIStatusError, APITimeoutError, - ThinkingEffort, ) from kosong.message import Message from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter @@ -114,7 +113,6 @@ def __init__( self._reserved_tokens = RESERVED_TOKENS if self._runtime.llm is not None: assert self._reserved_tokens <= self._runtime.llm.max_context_size - self._thinking_effort: ThinkingEffort = "off" for tool in agent.toolset.tools: if tool.name == SendDMail_NAME: @@ -139,6 +137,15 @@ def model_capabilities(self) -> set[ModelCapability] | None: return None return self._runtime.llm.capabilities + @property + def thinking(self) -> bool | None: + """Whether thinking mode is enabled.""" + if self._runtime.llm is None: + return None + if thinking_effort := self._runtime.llm.chat_provider.thinking_effort: + return thinking_effort != "off" + return None + @property def status(self) -> StatusSnapshot: return StatusSnapshot( @@ -168,25 +175,6 @@ def _context_usage(self) -> float: def wire_file(self) -> Path: return self._runtime.session.wire_file - @property - def thinking(self) -> bool: - """Whether thinking mode is enabled.""" - return self._thinking_effort != "off" - - def set_thinking(self, enabled: bool) -> None: - """ - Enable/disable thinking mode for the soul. - - Raises: - LLMNotSet: When the LLM is not set. - LLMNotSupported: When the LLM does not support thinking mode. - """ - if self._runtime.llm is None: - raise LLMNotSet() - if enabled and "thinking" not in self._runtime.llm.capabilities: - raise LLMNotSupported(self._runtime.llm, ["thinking"]) - self._thinking_effort = "high" if enabled else "off" - async def _checkpoint(self): await self._context.checkpoint(self._checkpoint_with_user_message) @@ -385,7 +373,7 @@ async def _step(self) -> StepOutcome | None: async def _kosong_step_with_retry() -> StepResult: # run an LLM step (may be interrupted) return await kosong.step( - chat_provider.with_thinking(self._thinking_effort), + chat_provider, self._agent.system_prompt, self._agent.toolset, self._context.history, diff --git a/src/kimi_cli/soul/slash.py b/src/kimi_cli/soul/slash.py index 279473d3b..34ed69424 100644 --- a/src/kimi_cli/soul/slash.py +++ b/src/kimi_cli/soul/slash.py @@ -38,7 +38,6 @@ async def init(soul: KimiSoul, args: str): with tempfile.TemporaryDirectory() as temp_dir: tmp_context = Context(file_backend=Path(temp_dir) / "context.jsonl") tmp_soul = KimiSoul(soul.agent, context=tmp_context) - tmp_soul.set_thinking(soul.thinking) await tmp_soul.run(prompts.INIT) agents_md = load_agents_md(soul.runtime.builtin_args.KIMI_WORK_DIR) diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index 7215a4591..974dc699e 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -70,7 +70,7 @@ async def run(self, command: str | None = None) -> bool: status_provider=lambda: self.soul.status, model_capabilities=self.soul.model_capabilities or set(), model_name=self.soul.model_name, - initial_thinking=isinstance(self.soul, KimiSoul) and self.soul.thinking, + thinking=self.soul.thinking or False, agent_mode_slash_commands=list(self._available_slash_commands.values()), shell_mode_slash_commands=shell_mode_registry.list_commands(), ) as prompt_session: @@ -107,7 +107,7 @@ async def run(self, command: str | None = None) -> bool: await self._run_slash_command(slash_cmd_call) continue - await self._run_soul_command(user_input.content, user_input.thinking) + await self._run_soul_command(user_input.content) finally: ensure_tty_sane() @@ -197,22 +197,14 @@ async def _run_slash_command(self, command_call: SlashCommandCall) -> None: console.print(f"[red]Unknown error: {e}[/red]") raise # re-raise unknown error - async def _run_soul_command( - self, - user_input: str | list[ContentPart], - thinking: bool | None = None, - ) -> bool: + async def _run_soul_command(self, user_input: str | list[ContentPart]) -> bool: """ Run the soul and handle any known exceptions. Returns: bool: Whether the run is successful. """ - logger.info( - "Running soul with user input: {user_input}, thinking {thinking}", - user_input=user_input, - thinking=thinking, - ) + logger.info("Running soul with user input: {user_input}", user_input=user_input) cancel_event = asyncio.Event() @@ -224,9 +216,6 @@ def _handler(): remove_sigint = install_sigint_handler(loop, _handler) try: - if isinstance(self.soul, KimiSoul) and thinking is not None: - self.soul.set_thinking(thinking) - await run_soul( self.soul, user_input, diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 48f29c9d3..2083c6e2e 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -32,7 +32,7 @@ merge_completers, ) from prompt_toolkit.document import Document -from prompt_toolkit.filters import Condition, has_completions +from prompt_toolkit.filters import has_completions from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.history import InMemoryHistory from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent @@ -406,7 +406,6 @@ def __str__(self) -> str: class UserInput(BaseModel): mode: PromptMode - thinking: bool command: str """The plain text representation of the user input.""" content: list[ContentPart] @@ -465,15 +464,6 @@ def _current_toast(position: Literal["left", "right"] = "left") -> _ToastEntry | return queue[0] -def _toast_thinking(thinking: bool) -> None: - toast( - f"thinking {'on' if thinking else 'off'}, tab to toggle", - duration=3.0, - topic="thinking", - immediate=True, - ) - - _ATTACHMENT_PLACEHOLDER_RE = re.compile( r"\[(?Pimage):(?P[a-zA-Z0-9_\-\.]+)(?:,(?P\d+)x(?P\d+))?\]" ) @@ -495,7 +485,7 @@ def __init__( status_provider: Callable[[], StatusSnapshot], model_capabilities: set[ModelCapability], model_name: str | None, - initial_thinking: bool, + thinking: bool, agent_mode_slash_commands: Sequence[SlashCommand[Any]], shell_mode_slash_commands: Sequence[SlashCommand[Any]], ) -> None: @@ -508,7 +498,7 @@ def __init__( self._model_name = model_name self._last_history_content: str | None = None self._mode: PromptMode = PromptMode.AGENT - self._thinking = initial_thinking + self._thinking = thinking self._attachment_parts: dict[str, ContentPart] = {} """Mapping from attachment id to ContentPart.""" @@ -573,24 +563,6 @@ def _(event: KeyPressEvent) -> None: else: clipboard = None - @Condition - def is_agent_mode() -> bool: - return self._mode == PromptMode.AGENT - - _toast_thinking(self._thinking) - - @_kb.add("tab", filter=~has_completions & is_agent_mode, eager=True) - def _(event: KeyPressEvent) -> None: - """Toggle thinking mode when Tab is pressed and no completions are shown.""" - if "thinking" not in self._model_capabilities: - console.print( - "[yellow]Thinking mode is not supported by the selected LLM model[/yellow]" - ) - return - self._thinking = not self._thinking - _toast_thinking(self._thinking) - event.app.invalidate() - @_kb.add("c-_", eager=True) # Ctrl-/ sends Ctrl-_ in most terminals def _(event: KeyPressEvent) -> None: """Show help by submitting /help command.""" @@ -746,7 +718,6 @@ async def prompt(self) -> UserInput: return UserInput( mode=self._mode, - thinking=self._thinking, content=content, command=command, ) diff --git a/src/kimi_cli/ui/shell/setup.py b/src/kimi_cli/ui/shell/setup.py index 09555b67f..8bcb1ab25 100644 --- a/src/kimi_cli/ui/shell/setup.py +++ b/src/kimi_cli/ui/shell/setup.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, NamedTuple from loguru import logger from prompt_toolkit import PromptSession @@ -18,6 +18,7 @@ ) from kimi_cli.platforms import ( PLATFORMS, + ModelInfo, Platform, get_platform_by_name, list_models, @@ -41,7 +42,7 @@ async def setup(app: Shell, args: str): config = load_config() provider_key = managed_provider_key(result.platform.id) - model_key = managed_model_key(result.platform.id, result.selected_model_id) + model_key = managed_model_key(result.platform.id, result.selected_model.id) config.providers[provider_key] = LLMProvider( type="kimi", base_url=result.platform.base_url, @@ -51,15 +52,15 @@ async def setup(app: Shell, args: str): if model.provider == provider_key: del config.models[key] for model_info in result.models: - model_id = model_info.get("id") - if not model_id: - continue - config.models[managed_model_key(result.platform.id, str(model_id))] = LLMModel( + capabilities = model_info.capabilities or None + config.models[managed_model_key(result.platform.id, model_info.id)] = LLMModel( provider=provider_key, - model=str(model_id), - max_context_size=int(model_info.get("context_length") or 0), + model=model_info.id, + max_context_size=model_info.context_length, + capabilities=capabilities, ) config.default_model = model_key + config.default_thinking = result.thinking if result.platform.search_url: config.services.moonshot_search = MoonshotSearchConfig( @@ -86,14 +87,15 @@ async def setup(app: Shell, args: str): class _SetupResult(NamedTuple): platform: Platform api_key: SecretStr - selected_model_id: str - models: list[dict[str, Any]] + selected_model: ModelInfo + models: list[ModelInfo] + thinking: bool async def _setup() -> _SetupResult | None: # select the API platform platform_name = await _prompt_choice( - header="Select the API platform", + header="Select a platform (↑↓ navigate, Enter select, Ctrl+C cancel):", choices=[platform.name for platform in PLATFORMS], ) if not platform_name: @@ -119,26 +121,44 @@ async def _setup() -> _SetupResult | None: return None # select the model - model_map = {model["id"]: model for model in models if model.get("id")} - model_ids = list(model_map) - - if not model_ids: + if not models: console.print("[red]No models available for the selected platform[/red]") return None + model_map = {model.id: model for model in models} model_id = await _prompt_choice( - header="Select the model", - choices=model_ids, + header="Select a model (↑↓ navigate, Enter select, Ctrl+C cancel):", + choices=list(model_map), ) if not model_id: console.print("[red]No model selected[/red]") return None + selected_model = model_map[model_id] + + # Determine thinking mode based on model capabilities + capabilities = selected_model.capabilities + thinking: bool + + if "always_thinking" in capabilities: + thinking = True + elif "thinking" in capabilities: + thinking_selection = await _prompt_choice( + header="Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):", + choices=["off", "on"], + ) + if not thinking_selection: + return None + thinking = thinking_selection == "on" + else: + thinking = False + return _SetupResult( platform=platform, api_key=SecretStr(api_key), - selected_model_id=model_id, - models=list(model_map.values()), + selected_model=selected_model, + models=models, + thinking=thinking, ) diff --git a/src/kimi_cli/ui/shell/slash.py b/src/kimi_cli/ui/shell/slash.py index 81822f161..c93e4ef73 100644 --- a/src/kimi_cli/ui/shell/slash.py +++ b/src/kimi_cli/ui/shell/slash.py @@ -50,7 +50,6 @@ def exit(app: Shell, args: str): _KEYBOARD_SHORTCUTS = [ ("Ctrl-X", "Toggle agent/shell mode"), - ("Tab", "Toggle thinking mode"), ("Ctrl-J / Alt-Enter", "Insert newline"), ("Ctrl-V", "Paste (supports images)"), ("Ctrl-D", "Exit"), @@ -143,8 +142,8 @@ def version(app: Shell, args: str): @registry.command async def model(app: Shell, args: str): - """List or switch LLM models""" - import shlex + """Switch LLM model or thinking mode""" + from kimi_cli.llm import derive_model_capabilities soul = _ensure_kimi_soul(app) if soul is None: @@ -157,79 +156,106 @@ async def model(app: Shell, args: str): console.print('[yellow]No models configured, send "/setup" to configure.[/yellow]') return - current_model = soul.runtime.llm.model_config if soul.runtime.llm else None - current_model_name: str | None = None - if current_model is not None: - for name, model in config.models.items(): - if model == current_model: - current_model_name = name + if not config.is_from_default_location: + console.print( + "[yellow]Model switching requires the default config file; " + "restart without --config/--config-file.[/yellow]" + ) + return + + # Find current model/thinking from runtime (may be overridden by --model/--thinking) + curr_model_cfg = soul.runtime.llm.model_config if soul.runtime.llm else None + curr_model_name: str | None = None + if curr_model_cfg is not None: + for name, model_cfg in config.models.items(): + if model_cfg == curr_model_cfg: + curr_model_name = name break - assert current_model_name is not None - - raw_args = args.strip() - if not raw_args: - choices: list[tuple[str, str]] = [] - for name in sorted(config.models): - model = config.models[name] - provider_label = get_platform_name_for_provider(model.provider) or model.provider - marker = " (current)" if name == current_model_name else "" - label = f"{model.model} ({provider_label}){marker}" - choices.append((name, label)) + curr_thinking = soul.thinking + + # Step 1: Select model + model_choices: list[tuple[str, str]] = [] + for name in sorted(config.models): + model_cfg = config.models[name] + provider_label = get_platform_name_for_provider(model_cfg.provider) or model_cfg.provider + marker = " (current)" if name == curr_model_name else "" + label = f"{model_cfg.model} ({provider_label}){marker}" + model_choices.append((name, label)) + + try: + selected_model_name = await ChoiceInput( + message="Select a model (↑↓ navigate, Enter select, Ctrl+C cancel):", + options=model_choices, + default=curr_model_name or model_choices[0][0], + ).prompt_async() + except (EOFError, KeyboardInterrupt): + return + + if not selected_model_name: + return + selected_model_cfg = config.models[selected_model_name] + selected_provider = config.providers.get(selected_model_cfg.provider) + if selected_provider is None: + console.print(f"[red]Provider not found: {selected_model_cfg.provider}[/red]") + return + + # Step 2: Determine thinking mode + capabilities = derive_model_capabilities(selected_model_cfg) + new_thinking: bool + + if "always_thinking" in capabilities: + new_thinking = True + elif "thinking" in capabilities: + thinking_choices: list[tuple[str, str]] = [ + ("off", "off" + (" (current)" if not curr_thinking else "")), + ("on", "on" + (" (current)" if curr_thinking else "")), + ] try: - selection = await ChoiceInput( - message=("Select a model to switch to (↑↓ navigate, Enter select, Ctrl+C cancel):"), - options=choices, - default=current_model_name or choices[0][0], + thinking_selection = await ChoiceInput( + message="Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):", + options=thinking_choices, + default="on" if curr_thinking else "off", ).prompt_async() except (EOFError, KeyboardInterrupt): return - if not selection: + if not thinking_selection: return - model_name = selection + new_thinking = thinking_selection == "on" else: - try: - parsed_args = shlex.split(raw_args) - except ValueError: - console.print("[red]Usage: /model [/red]") - return - if len(parsed_args) != 1: - console.print("[red]Usage: /model [/red]") - return - model_name = parsed_args[0] - if model_name not in config.models: - console.print(f"[red]Unknown model: {model_name}[/red]") - return - - if current_model_name == model_name: - console.print(f"[yellow]Already using model {model_name}.[/yellow]") - return + new_thinking = False - model = config.models[model_name] - provider = config.providers.get(model.provider) - if provider is None: - console.print(f"[red]Provider not found for model: {model.provider}[/red]") - return + # Check if anything changed + model_changed = curr_model_name != selected_model_name + thinking_changed = curr_thinking != new_thinking - if not config.is_from_default_location: + if not model_changed and not thinking_changed: console.print( - "[yellow]Model switching requires the default config file; " - "restart without --config/--config-file.[/yellow]" + f"[yellow]Already using {selected_model_name} " + f"with thinking {'on' if new_thinking else 'off'}.[/yellow]" ) return - previous_model = config.default_model - config.default_model = model_name + # Save and reload + prev_model = config.default_model + prev_thinking = config.default_thinking + config.default_model = selected_model_name + config.default_thinking = new_thinking try: save_config(config) except OSError as exc: - config.default_model = previous_model - console.print(f"[red]Failed to save default config: {exc}[/red]") + config.default_model = prev_model + config.default_thinking = prev_thinking + console.print(f"[red]Failed to save config: {exc}[/red]") return - console.print(f"[green]Switched to model {model_name}. Reloading...[/green]") + console.print( + f"[green]Switched to {selected_model_name} " + f"with thinking {'on' if new_thinking else 'off'}. " + "Reloading...[/green]" + ) raise Reload(session_id=soul.runtime.session.id) diff --git a/tests/test_config.py b/tests/test_config.py index dbcbf2f3d..75e24f0ab 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,6 +17,7 @@ def test_default_config(): assert config == snapshot( Config( default_model="", + default_thinking=False, models={}, providers={}, services=Services(), @@ -29,6 +30,7 @@ def test_default_config_dump(): assert config.model_dump() == snapshot( { "default_model": "", + "default_thinking": False, "models": {}, "providers": {}, "loop_control": { diff --git a/tests/test_kimisoul_ralph_loop.py b/tests/test_kimisoul_ralph_loop.py index 106e9b8ad..89b34fd3f 100644 --- a/tests/test_kimisoul_ralph_loop.py +++ b/tests/test_kimisoul_ralph_loop.py @@ -77,6 +77,10 @@ def __init__(self, sequences: Sequence[Sequence[StreamedMessagePart]]) -> None: def model_name(self) -> str: return "sequence" + @property + def thinking_effort(self) -> ThinkingEffort | None: + return None + async def generate( self, system_prompt: str,