From 57507ed18dcb658b7fae5dbb6d87e0b0c5e3013c Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 18:58:28 +0800 Subject: [PATCH 1/5] feat: define model via KIMI_MODEL_* environment variables Add a KIMI_MODEL_* environment-variable channel that synthesizes a provider (__kimi_env__) and model alias (__kimi_env_model__) in memory and selects it as the default model, without editing config.toml. Supports provider type (kimi/anthropic/openai), base URL, API key, context size, capabilities, anthropic max_output_size, openai reasoning_key, and full thinking settings. Runtime config reads go through a new loadRuntimeConfig wrapper; the config.toml write-back paths keep using readConfigFile so the synthesized model is never persisted back to disk. --- .changeset/env-var-model-config.md | 6 + docs/en/configuration/config-files.md | 2 + docs/en/configuration/env-vars.md | 32 ++- docs/en/configuration/overrides.md | 4 +- docs/zh/configuration/config-files.md | 2 + docs/zh/configuration/env-vars.md | 32 ++- docs/zh/configuration/overrides.md | 4 +- packages/agent-core/src/config/env-model.ts | 142 ++++++++++++++ packages/agent-core/src/config/index.ts | 1 + packages/agent-core/src/config/toml.ts | 14 ++ packages/agent-core/src/rpc/core-impl.ts | 11 +- .../agent-core/test/config/env-model.test.ts | 183 ++++++++++++++++++ 12 files changed, 422 insertions(+), 11 deletions(-) create mode 100644 .changeset/env-var-model-config.md create mode 100644 packages/agent-core/src/config/env-model.ts create mode 100644 packages/agent-core/test/config/env-model.test.ts diff --git a/.changeset/env-var-model-config.md b/.changeset/env-var-model-config.md new file mode 100644 index 0000000000..5b70ea4392 --- /dev/null +++ b/.changeset/env-var-model-config.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Add a `KIMI_MODEL_*` environment-variable channel that lets you run Kimi Code against a specific model (provider type, base URL, API key, context size, capabilities, and thinking settings) without editing `config.toml`. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 38985f214f..b6bc798b16 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -138,6 +138,8 @@ model = "gpt-4.1" max_context_size = 1047576 ``` +For testing, you can also synthesize a model entirely from `KIMI_MODEL_*` environment variables without editing this file — see [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-kimi-model). + ## `thinking` `thinking` controls the default behavior of Thinking mode. Even when the top-level `default_thinking = true`, setting `mode` to `"off"` will still force Thinking off. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index ac8b67bc34..0e8f81bf24 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -3,7 +3,7 @@ Kimi Code CLI uses environment variables to override default paths, switch OAuth endpoints, and adjust runtime behavior. Most variables are read when the `kimi` process starts up; a few (such as the telemetry switch, the OAuth lock, and diagnostic logging) are read when the relevant subsystem initializes. Kimi's own variables use the `KIMI_*` prefix; in addition, the CLI also reads a number of standard system variables. ::: warning Note -**Provider credentials are not in this list**: key variables such as `KIMI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GOOGLE_API_KEY` are **not** read automatically from `process.env`. They must be written into the `[providers.]` section of `config.toml` (as `api_key` / `base_url`) or into the `[providers..env]` subtable; merely `export`ing them in your shell will not give a provider credentials automatically. See [Configuration overrides](./overrides.md#provider-credentials) and [Providers](./providers.md) for details. +**Provider credentials are not in this list**: key variables such as `KIMI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GOOGLE_API_KEY` are **not** read automatically from `process.env`. They must be written into the `[providers.]` section of `config.toml` (as `api_key` / `base_url`) or into the `[providers..env]` subtable; merely `export`ing them in your shell will not give a provider credentials automatically. See [Configuration overrides](./overrides.md#provider-credentials) and [Providers](./providers.md) for details. **Exception:** the `KIMI_MODEL_*` variables are an explicit channel that *does* read a model and its credentials from the shell — see [Define a model from environment variables](#define-a-model-from-environment-variables-kimi-model). ::: ## Core paths @@ -67,6 +67,36 @@ When neither `KIMI_CODE_OAUTH_HOST` nor `KIMI_OAUTH_HOST` is set, the OAuth auth `KIMI_CODE_BASE_URL` and the `KIMI_BASE_URL` from the previous section are two different variables: the former targets the OAuth-logged-in hosted service and defaults to `kimi.com`; the latter targets providers that use a Kimi API key directly and defaults to `moonshot.ai`. Distinguish them by use case. ::: +## Define a model from environment variables (`KIMI_MODEL_*`) + +For testing you can make Kimi Code use a specific model **without editing `config.toml` at all**. When `KIMI_MODEL_NAME` is set, the CLI synthesizes one provider and one model alias from the `KIMI_MODEL_*` variables — in memory only, nothing is written back to `config.toml` — and selects it as the default model. These variables take priority over `default_model` in `config.toml`; a `-m ` flag still wins for that launch. + +| Environment variable | Required | Purpose | Default | +| --- | --- | --- | --- | +| `KIMI_MODEL_NAME` | Yes (also the enable switch) | Model id sent to the API | — | +| `KIMI_MODEL_API_KEY` | Yes | API key | — | +| `KIMI_MODEL_PROVIDER_TYPE` | No | Provider type; one of `kimi`, `anthropic`, `openai` | `kimi` | +| `KIMI_MODEL_BASE_URL` | No | API base URL | `kimi` → `https://api.moonshot.ai/v1`; `openai` → `https://api.openai.com/v1`; `anthropic` → SDK default | +| `KIMI_MODEL_MAX_CONTEXT_SIZE` | Yes | Max context length in tokens (positive integer) | — | +| `KIMI_MODEL_CAPABILITIES` | No | Comma-separated capability tags (e.g. `image_in,thinking`); unioned with auto-detected capabilities | — | +| `KIMI_MODEL_DISPLAY_NAME` | No | Name shown in `/model` | Falls back to `KIMI_MODEL_NAME` | +| `KIMI_MODEL_MAX_OUTPUT_SIZE` | No | Per-request output cap (`anthropic` only) | Per-model default | +| `KIMI_MODEL_REASONING_KEY` | No | Reasoning field-name override (`openai` only) | Auto-detected | +| `KIMI_MODEL_DEFAULT_THINKING` | No | Default Thinking toggle for new sessions | Off | +| `KIMI_MODEL_THINKING_MODE` | No | Thinking trigger policy; `auto`/`on`/`off` | — | +| `KIMI_MODEL_THINKING_EFFORT` | No | Thinking effort (e.g. `low`/`medium`/`high`/`xhigh`/`max`; available levels depend on the provider) | — | + +The synthesized entries use the reserved keys `__kimi_env__` (provider) and `__kimi_env_model__` (model alias). When `KIMI_MODEL_NAME` is set but a required variable is missing or invalid, startup fails with a clear error. + +```sh +export KIMI_MODEL_NAME="kimi-for-coding" +export KIMI_MODEL_BASE_URL="https://api-staff.msh.team/v1" +export KIMI_MODEL_API_KEY="$MOONSHOT_STAFF_KEY" +export KIMI_MODEL_MAX_CONTEXT_SIZE="262144" +export KIMI_MODEL_CAPABILITIES="image_in,thinking" +kimi +``` + ## Runtime switches | Environment variable | Purpose | Valid values / Default | diff --git a/docs/en/configuration/overrides.md b/docs/en/configuration/overrides.md index 1567000dd3..46dc1662a3 100644 --- a/docs/en/configuration/overrides.md +++ b/docs/en/configuration/overrides.md @@ -20,7 +20,7 @@ For other runtime parameters (model alias, Plan / yolo mode, Skills directories, A few environment variables explicitly override related config fields. For example, `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` has higher priority than `[background].keep_alive_on_exit`. These exceptions are called out in [Environment variables](./env-vars.md) and in the corresponding [Config files](./config-files.md) field reference. ::: warning Note -Ordinary runtime parameters **do not** fall back to shell environment variables. For example, provider `api_key` / `base_url` are read only from fields in `config.toml` (including the `[providers..env]` subtable); they do not fall back to shell exports like `export KIMI_API_KEY`. See [Provider credentials](#provider-credentials) below. +Ordinary runtime parameters **do not** fall back to shell environment variables. For example, provider `api_key` / `base_url` are read only from fields in `config.toml` (including the `[providers..env]` subtable); they do not fall back to shell exports like `export KIMI_API_KEY`. See [Provider credentials](#provider-credentials) below. The one exception is the explicit `KIMI_MODEL_*` channel, which synthesizes a model (and its credentials) from the shell; see [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-kimi-model). ::: Kimi Code CLI currently reads only one user-level config file. There is no project-level (in-repo) config file mechanism. To isolate configuration between projects, point `KIMI_CODE_HOME` at a different data directory (see [Typical scenarios](#typical-scenarios) below) or temporarily override specific fields with CLI flags at launch. @@ -31,7 +31,7 @@ The config file location is controlled by the `KIMI_CODE_HOME` environment varia ## Provider credentials -Provider credentials (`api_key`, `base_url`) have their own resolution rules: Kimi Code CLI reads provider fields only from `config.toml` and **does not** fall back to shell environment variables. Running `export KIMI_API_KEY` in your terminal alone will not give a `[providers.]` entry credentials — you have to write them into the config file explicitly. +Provider credentials (`api_key`, `base_url`) have their own resolution rules: Kimi Code CLI reads provider fields only from `config.toml` and **does not** fall back to shell environment variables. Running `export KIMI_API_KEY` in your terminal alone will not give a `[providers.]` entry credentials — you have to write them into the config file explicitly. The one exception is the explicit `KIMI_MODEL_*` channel, which synthesizes a model (and its credentials) from the shell; see [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-kimi-model). For a single provider, credentials are resolved in this order: diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index ab1c9c6c36..4d9af35f6a 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -138,6 +138,8 @@ model = "gpt-4.1" max_context_size = 1047576 ``` +为了便于测试,你也可以完全不修改本文件,直接用 `KIMI_MODEL_*` 环境变量合成出一个模型 —— 详见 [用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。 + ## `thinking` `thinking` 控制 Thinking 模式的默认行为。即便顶层 `default_thinking = true`,将 `mode` 设为 `"off"` 也会强制禁用 Thinking。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 1296f9f3d7..79168f0da7 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -3,7 +3,7 @@ Kimi Code CLI 通过环境变量来覆盖默认路径、切换 OAuth 端点以及调整运行时行为。大部分变量在 `kimi` 进程启动时读取,少数(如遥测开关、OAuth 锁、诊断日志)在相关子系统初始化时读取。Kimi 自有变量使用 `KIMI_*` 前缀;此外,CLI 也会读取若干系统标准变量。 ::: warning 注意 -**供应商凭证不在此列**:`KIMI_API_KEY`、`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`、`GOOGLE_API_KEY` 等密钥变量**不会**从 `process.env` 自动读取。它们必须写在 `config.toml` 的 `[providers.]` 段(`api_key` / `base_url`)或 `[providers..env]` 子表中;仅在 shell 中 `export` 不会让某个供应商自动获得凭证。详见 [配置覆盖](./overrides.md#供应商凭证) 与 [供应商](./providers.md)。 +**供应商凭证不在此列**:`KIMI_API_KEY`、`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`、`GOOGLE_API_KEY` 等密钥变量**不会**从 `process.env` 自动读取。它们必须写在 `config.toml` 的 `[providers.]` 段(`api_key` / `base_url`)或 `[providers..env]` 子表中;仅在 shell 中 `export` 不会让某个供应商自动获得凭证。详见 [配置覆盖](./overrides.md#供应商凭证) 与 [供应商](./providers.md)。**例外:** `KIMI_MODEL_*` 这组变量是一个显式通道,*确实*会从 shell 读取一个模型及其凭证 —— 详见 [用环境变量定义模型](#用环境变量定义模型-kimi-model)。 ::: ## 核心路径 @@ -67,6 +67,36 @@ OAuth 流程默认连接 Kimi 官方的认证与托管端点,下列变量可 `KIMI_CODE_BASE_URL` 与上一节的 `KIMI_BASE_URL` 是两个不同变量:前者面向 OAuth 登录的托管服务,默认指向 `kimi.com`;后者面向直接使用 Kimi API 密钥的供应商,默认指向 `moonshot.ai`。请按场景区分。 ::: +## 用环境变量定义模型(`KIMI_MODEL_*`) + +为了便于测试,你可以**完全不修改 `config.toml`** 就让 Kimi Code 使用指定的模型。当设置了 `KIMI_MODEL_NAME` 时,CLI 会从 `KIMI_MODEL_*` 变量合成出一个供应商和一个模型别名 —— 仅存在于内存中,不会写回 `config.toml` —— 并将其选为默认模型。这些变量的优先级高于 `config.toml` 中的 `default_model`;而 `-m ` 选项在本次启动中仍然优先。 + +| 环境变量 | 必填 | 用途 | 默认值 | +| --- | --- | --- | --- | +| `KIMI_MODEL_NAME` | 是(同时是启用开关) | 发送给 API 的模型 id | — | +| `KIMI_MODEL_API_KEY` | 是 | API 密钥 | — | +| `KIMI_MODEL_PROVIDER_TYPE` | 否 | 供应商类型,可选 `kimi`、`anthropic`、`openai` | `kimi` | +| `KIMI_MODEL_BASE_URL` | 否 | API 基础 URL | `kimi` → `https://api.moonshot.ai/v1`;`openai` → `https://api.openai.com/v1`;`anthropic` → SDK 默认值 | +| `KIMI_MODEL_MAX_CONTEXT_SIZE` | 是 | 最大上下文长度(token 数,正整数) | — | +| `KIMI_MODEL_CAPABILITIES` | 否 | 逗号分隔的能力标签(如 `image_in,thinking`);与自动探测的能力做并集 | — | +| `KIMI_MODEL_DISPLAY_NAME` | 否 | 在 `/model` 中显示的名称 | 回退到 `KIMI_MODEL_NAME` | +| `KIMI_MODEL_MAX_OUTPUT_SIZE` | 否 | 单次请求的输出上限(仅 `anthropic`) | 模型默认值 | +| `KIMI_MODEL_REASONING_KEY` | 否 | 推理字段名覆盖(仅 `openai`) | 自动探测 | +| `KIMI_MODEL_DEFAULT_THINKING` | 否 | 新会话的默认 Thinking 开关 | 关闭 | +| `KIMI_MODEL_THINKING_MODE` | 否 | Thinking 触发策略,可选 `auto`/`on`/`off` | — | +| `KIMI_MODEL_THINKING_EFFORT` | 否 | Thinking 强度(如 `low`/`medium`/`high`/`xhigh`/`max`;实际可用等级由供应商决定) | — | + +合成出的条目使用保留键 `__kimi_env__`(供应商)和 `__kimi_env_model__`(模型别名)。当设置了 `KIMI_MODEL_NAME` 但缺少必填变量或变量取值非法时,启动会以清晰的错误信息失败。 + +```sh +export KIMI_MODEL_NAME="kimi-for-coding" +export KIMI_MODEL_BASE_URL="https://api-staff.msh.team/v1" +export KIMI_MODEL_API_KEY="$MOONSHOT_STAFF_KEY" +export KIMI_MODEL_MAX_CONTEXT_SIZE="262144" +export KIMI_MODEL_CAPABILITIES="image_in,thinking" +kimi +``` + ## 运行时开关 | 环境变量 | 用途 | 合法值 / 默认值 | diff --git a/docs/zh/configuration/overrides.md b/docs/zh/configuration/overrides.md index 88ab6284ef..7d8b6ff3ef 100644 --- a/docs/zh/configuration/overrides.md +++ b/docs/zh/configuration/overrides.md @@ -20,7 +20,7 @@ Kimi Code CLI 的运行参数来自用户配置文件、命令行选项,以及 少数环境变量会明确覆盖配置文件中的相关字段,例如 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 的优先级高于 `[background].keep_alive_on_exit`。这类例外会在 [环境变量](./env-vars.md) 与对应的 [配置文件](./config-files.md) 字段说明里标出。 ::: warning 注意 -普通运行参数**不会**从 shell 环境变量取后备值。例如供应商 `api_key` / `base_url` 只读取 `config.toml` 中的字段(包括 `[providers..env]` 子表),不会回退到 `export KIMI_API_KEY` 这类终端变量;详见下文 [供应商凭证](#供应商凭证)。 +普通运行参数**不会**从 shell 环境变量取后备值。例如供应商 `api_key` / `base_url` 只读取 `config.toml` 中的字段(包括 `[providers..env]` 子表),不会回退到 `export KIMI_API_KEY` 这类终端变量;详见下文 [供应商凭证](#供应商凭证)。唯一的例外是显式的 `KIMI_MODEL_*` 通道,它会从 shell 合成出一个模型(及其凭证);详见 [用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。 ::: Kimi Code CLI 目前只读取一份用户级配置文件,没有项目级(仓库内)配置文件机制。如果需要在不同项目之间隔离配置,可以通过 `KIMI_CODE_HOME` 指向不同的数据目录(详见下文 [典型场景](#典型场景)),或在启动时用 CLI 选项临时覆盖具体字段。 @@ -31,7 +31,7 @@ Kimi Code CLI 目前只读取一份用户级配置文件,没有项目级(仓 ## 供应商凭证 -供应商凭证(`api_key`、`base_url`)的解析有自己的规则:Kimi Code CLI 只从 `config.toml` 中读取供应商字段,**不会**从 shell 环境变量取后备值。仅在终端里 `export KIMI_API_KEY` 不会让某个 `[providers.]` 自动获得凭证,必须显式写到配置文件里。 +供应商凭证(`api_key`、`base_url`)的解析有自己的规则:Kimi Code CLI 只从 `config.toml` 中读取供应商字段,**不会**从 shell 环境变量取后备值。仅在终端里 `export KIMI_API_KEY` 不会让某个 `[providers.]` 自动获得凭证,必须显式写到配置文件里。唯一的例外是显式的 `KIMI_MODEL_*` 通道,它会从 shell 合成出一个模型(及其凭证);详见 [用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。 对单个供应商而言,凭证按以下顺序解析: diff --git a/packages/agent-core/src/config/env-model.ts b/packages/agent-core/src/config/env-model.ts new file mode 100644 index 0000000000..3bb8bab25c --- /dev/null +++ b/packages/agent-core/src/config/env-model.ts @@ -0,0 +1,142 @@ +import { ErrorCodes, KimiError } from '#/errors'; +import { parseBooleanEnv } from './resolve'; +import { + validateConfig, + type KimiConfig, + type ModelAlias, + type ProviderConfig, + type ProviderType, + type ThinkingConfig, +} from './schema'; + +/** Reserved keys for the env-driven synthetic provider / model alias. */ +export const ENV_MODEL_PROVIDER_KEY = '__kimi_env__'; +export const ENV_MODEL_ALIAS_KEY = '__kimi_env_model__'; + +const ALLOWED_TYPES: readonly ProviderType[] = ['kimi', 'anthropic', 'openai']; + +const DEFAULT_BASE_URL: Partial> = { + kimi: 'https://api.moonshot.ai/v1', + openai: 'https://api.openai.com/v1', + // anthropic: omitted -> let the Anthropic SDK pick its default +}; + +type Env = Readonly>; + +function trimmed(value: string | undefined): string | undefined { + const t = value?.trim(); + return t === undefined || t.length === 0 ? undefined : t; +} + +function fail(message: string): never { + throw new KimiError(ErrorCodes.CONFIG_INVALID, message); +} + +function parsePositiveInt(raw: string, varName: string): number { + if (!/^\d+$/.test(raw) || Number(raw) <= 0) { + fail(`${varName} must be a positive integer, got "${raw}".`); + } + return Number(raw); +} + +function parseProviderType(raw: string | undefined): ProviderType { + if (raw === undefined) return 'kimi'; + const normalized = raw.toLowerCase() as ProviderType; + if (!ALLOWED_TYPES.includes(normalized)) { + fail( + `KIMI_MODEL_PROVIDER_TYPE must be one of ${ALLOWED_TYPES.join(', ')}, got "${raw}".`, + ); + } + return normalized; +} + +function parseCapabilities(raw: string | undefined): string[] | undefined { + if (raw === undefined) return undefined; + const caps = raw + .split(',') + .map((c) => c.trim().toLowerCase()) + .filter((c) => c.length > 0); + return caps.length === 0 ? undefined : caps; +} + +/** + * When `KIMI_MODEL_NAME` is set, synthesize one provider + one model alias from + * the `KIMI_MODEL_*` environment variables and make it the default model. + * Returns the config unchanged when the trigger variable is absent. + * + * IMPORTANT: the synthesized provider/model/default_model exist ONLY in the + * in-memory runtime config. Callers must never serialize the result back to + * config.toml (write paths must use the raw `readConfigFile`). + */ +export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): KimiConfig { + const model = trimmed(env['KIMI_MODEL_NAME']); + if (model === undefined) return config; + + const apiKey = trimmed(env['KIMI_MODEL_API_KEY']); + if (apiKey === undefined) { + fail('KIMI_MODEL_NAME is set but KIMI_MODEL_API_KEY is missing.'); + } + + const maxContextRaw = trimmed(env['KIMI_MODEL_MAX_CONTEXT_SIZE']); + if (maxContextRaw === undefined) { + fail('KIMI_MODEL_MAX_CONTEXT_SIZE is required when KIMI_MODEL_NAME is set.'); + } + const maxContextSize = parsePositiveInt(maxContextRaw, 'KIMI_MODEL_MAX_CONTEXT_SIZE'); + + const type = parseProviderType(trimmed(env['KIMI_MODEL_PROVIDER_TYPE'])); + const baseUrl = trimmed(env['KIMI_MODEL_BASE_URL']) ?? DEFAULT_BASE_URL[type]; + + const provider: ProviderConfig = { + type, + apiKey, + ...(baseUrl !== undefined ? { baseUrl } : {}), + }; + + const maxOutputRaw = trimmed(env['KIMI_MODEL_MAX_OUTPUT_SIZE']); + const maxOutputSize = + maxOutputRaw !== undefined + ? parsePositiveInt(maxOutputRaw, 'KIMI_MODEL_MAX_OUTPUT_SIZE') + : undefined; + const capabilities = parseCapabilities(env['KIMI_MODEL_CAPABILITIES']); + const displayName = trimmed(env['KIMI_MODEL_DISPLAY_NAME']); + const reasoningKey = trimmed(env['KIMI_MODEL_REASONING_KEY']); + + const alias: ModelAlias = { + provider: ENV_MODEL_PROVIDER_KEY, + model, + maxContextSize, + ...(capabilities !== undefined ? { capabilities } : {}), + ...(displayName !== undefined ? { displayName } : {}), + ...(maxOutputSize !== undefined ? { maxOutputSize } : {}), + ...(reasoningKey !== undefined ? { reasoningKey } : {}), + }; + + const thinkingMode = trimmed(env['KIMI_MODEL_THINKING_MODE']); + const thinkingEffort = trimmed(env['KIMI_MODEL_THINKING_EFFORT']); + const thinking: ThinkingConfig | undefined = + thinkingMode !== undefined || thinkingEffort !== undefined + ? { + ...config.thinking, + // Cast: thinkingMode is a raw string passed through to validateConfig + // for enum validation (auto/on/off). The cast avoids a TS compile error + // without skipping runtime validation. + ...(thinkingMode !== undefined ? { mode: thinkingMode as ThinkingConfig['mode'] } : {}), + ...(thinkingEffort !== undefined ? { effort: thinkingEffort } : {}), + } + : config.thinking; + const defaultThinking = parseBooleanEnv(env['KIMI_MODEL_DEFAULT_THINKING']); + + const merged: KimiConfig = { + ...config, + providers: { ...config.providers, [ENV_MODEL_PROVIDER_KEY]: provider }, + models: { ...config.models, [ENV_MODEL_ALIAS_KEY]: alias }, + defaultModel: ENV_MODEL_ALIAS_KEY, + ...(thinking !== undefined ? { thinking } : {}), + ...(defaultThinking !== undefined ? { defaultThinking } : {}), + }; + + // Re-validate so the synthesized entries honor the same schema constraints + // (e.g. thinking.mode must be auto/on/off). `validateConfig` throws + // KimiError(CONFIG_INVALID) on violation, matching the explicit checks above. + return validateConfig(merged); +} diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts index e9b03330f6..41e5ca1749 100644 --- a/packages/agent-core/src/config/index.ts +++ b/packages/agent-core/src/config/index.ts @@ -3,3 +3,4 @@ export * from './path'; export * from './resolve'; export * from './schema'; export * from './toml'; +export * from './env-model'; diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 61284de112..82eb610711 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -3,6 +3,7 @@ import { mkdir, open } from 'node:fs/promises'; import { dirname } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; +import { applyEnvModelConfig } from './env-model'; import { KimiConfigSchema, formatConfigValidationError, @@ -68,6 +69,19 @@ export function readConfigFile(filePath: string): KimiConfig { return parseConfigString(text, filePath); } +/** + * Load the config for runtime consumption: the on-disk config plus any model + * synthesized from `KIMI_MODEL_*` environment variables. Use this everywhere a + * value is assigned to the live runtime config; use the raw `readConfigFile` + * for write-back paths so the synthesized model is never persisted. + */ +export function loadRuntimeConfig( + filePath: string, + env: Readonly> = process.env, +): KimiConfig { + return applyEnvModelConfig(readConfigFile(filePath), env); +} + export function parseConfigString(tomlText: string, filePath = 'config.toml'): KimiConfig { if (tomlText.trim().length === 0) { return getDefaultConfig(); diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index a20899e34b..fe919345f2 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -12,6 +12,7 @@ import { getCoreVersion } from '#/version'; import { resolveThinkingLevel } from '../agent/config/thinking'; import { ensureKimiHome, + loadRuntimeConfig, mergeConfigPatch, readConfigFile, resolveConfigPath, @@ -144,7 +145,7 @@ export class KimiCore implements PromisableMethods { this.skillDirs = options.skillDirs ?? []; this.telemetry = options.telemetry ?? noopTelemetryClient; ensureKimiHome(this.homeDir); - this.config = readConfigFile(this.configPath); + this.config = loadRuntimeConfig(this.configPath); this.sessionStore = new SessionStore(this.homeDir); this.plugins = new PluginManager({ kimiHomeDir: this.homeDir }); // Capture the error rather than swallow it: mutators and explicit /plugins @@ -363,7 +364,7 @@ export class KimiCore implements PromisableMethods { async getKimiConfig(input?: GetKimiConfigPayload): Promise { if (input?.reload) { - this.config = readConfigFile(this.configPath); + this.config = loadRuntimeConfig(this.configPath); } return this.config; } @@ -371,7 +372,7 @@ export class KimiCore implements PromisableMethods { async setKimiConfig(input: SetKimiConfigPayload): Promise { const config = mergeConfigPatch(readConfigFile(this.configPath), input); await writeConfigFile(this.configPath, config); - return this.config = readConfigFile(this.configPath); + return this.config = loadRuntimeConfig(this.configPath); } async removeKimiProvider(input: RemoveKimiProviderPayload): Promise { @@ -402,7 +403,7 @@ export class KimiCore implements PromisableMethods { } await writeConfigFile(this.configPath, config); - return this.config = readConfigFile(this.configPath); + return this.config = loadRuntimeConfig(this.configPath); } prompt({ sessionId, ...payload }: SessionAgentPayload) { @@ -690,7 +691,7 @@ export class KimiCore implements PromisableMethods { } private reloadProviderManager(): KimiConfig { - return this.config = readConfigFile(this.configPath); + return this.config = loadRuntimeConfig(this.configPath); } private async refreshSessionRuntimeConfig( diff --git a/packages/agent-core/test/config/env-model.test.ts b/packages/agent-core/test/config/env-model.test.ts new file mode 100644 index 0000000000..f264fe3e62 --- /dev/null +++ b/packages/agent-core/test/config/env-model.test.ts @@ -0,0 +1,183 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { join } from 'pathe'; + +import { + applyEnvModelConfig, + ENV_MODEL_ALIAS_KEY, + ENV_MODEL_PROVIDER_KEY, +} from '../../src/config/env-model'; +import { getDefaultConfig, loadRuntimeConfig, readConfigFile } from '../../src/config'; +import { KimiError } from '../../src/errors'; + +function apply(env: Record) { + return applyEnvModelConfig(getDefaultConfig(), env); +} + +function expectConfigInvalid(fn: () => unknown): void { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(KimiError); + expect((error as KimiError).code).toBe('config.invalid'); + return; + } + throw new Error('expected function to throw'); +} + +const MIN = { + KIMI_MODEL_NAME: 'kimi-for-coding', + KIMI_MODEL_API_KEY: 'sk-test', + KIMI_MODEL_MAX_CONTEXT_SIZE: '262144', +} as const; + +describe('applyEnvModelConfig', () => { + it('returns the config unchanged when KIMI_MODEL_NAME is absent', () => { + const base = getDefaultConfig(); + expect(applyEnvModelConfig(base, {})).toBe(base); + }); + + it('throws when KIMI_MODEL_NAME is set but API key is missing', () => { + expectConfigInvalid(() => apply({ KIMI_MODEL_NAME: 'm' })); + }); + + it('throws when max_context_size is missing', () => { + expectConfigInvalid(() => + apply({ KIMI_MODEL_NAME: 'm', KIMI_MODEL_API_KEY: 'k' }), + ); + }); + + it.each(['abc', '0', '1.5', '-1'])( + 'throws when max_context_size is %s', + (value) => { + expectConfigInvalid(() => + apply({ ...MIN, KIMI_MODEL_MAX_CONTEXT_SIZE: value }), + ); + }, + ); + + it('synthesizes a kimi provider and model from the minimal set', () => { + const config = apply({ ...MIN }); + expect(config.providers[ENV_MODEL_PROVIDER_KEY]).toEqual({ + type: 'kimi', + apiKey: 'sk-test', + baseUrl: 'https://api.moonshot.ai/v1', + }); + expect(config.models?.[ENV_MODEL_ALIAS_KEY]).toEqual({ + provider: ENV_MODEL_PROVIDER_KEY, + model: 'kimi-for-coding', + maxContextSize: 262144, + }); + expect(config.defaultModel).toBe(ENV_MODEL_ALIAS_KEY); + }); + + it('applies provider type and its default base url', () => { + expect(apply({ ...MIN, KIMI_MODEL_PROVIDER_TYPE: 'openai' }) + .providers[ENV_MODEL_PROVIDER_KEY]).toMatchObject({ + type: 'openai', + baseUrl: 'https://api.openai.com/v1', + }); + const anthropic = apply({ ...MIN, KIMI_MODEL_PROVIDER_TYPE: 'anthropic' }) + .providers[ENV_MODEL_PROVIDER_KEY]; + expect(anthropic).toBeDefined(); + expect(anthropic?.type).toBe('anthropic'); + expect(anthropic?.baseUrl).toBeUndefined(); + }); + + it('rejects unsupported provider types', () => { + expectConfigInvalid(() => + apply({ ...MIN, KIMI_MODEL_PROVIDER_TYPE: 'google-genai' }), + ); + }); + + it('lets an explicit base url override the default', () => { + expect( + apply({ ...MIN, KIMI_MODEL_BASE_URL: 'https://api-staff.msh.team/v1' }) + .providers[ENV_MODEL_PROVIDER_KEY]?.baseUrl, + ).toBe('https://api-staff.msh.team/v1'); + }); + + it('parses comma-separated capabilities (trimmed, lowercased)', () => { + expect( + apply({ ...MIN, KIMI_MODEL_CAPABILITIES: 'Image_In, thinking ,' }) + .models?.[ENV_MODEL_ALIAS_KEY]?.capabilities, + ).toEqual(['image_in', 'thinking']); + }); + + it('sets display_name only when provided', () => { + const withoutName = apply({ ...MIN }).models?.[ENV_MODEL_ALIAS_KEY]; + expect(withoutName).toBeDefined(); + expect(withoutName?.displayName).toBeUndefined(); + expect( + apply({ ...MIN, KIMI_MODEL_DISPLAY_NAME: 'Staff Model' }) + .models?.[ENV_MODEL_ALIAS_KEY]?.displayName, + ).toBe('Staff Model'); + }); + + it('writes type-specific fields and validates max_output_size', () => { + const alias = apply({ + ...MIN, + KIMI_MODEL_PROVIDER_TYPE: 'anthropic', + KIMI_MODEL_MAX_OUTPUT_SIZE: '8192', + KIMI_MODEL_REASONING_KEY: 'reasoning', + }).models?.[ENV_MODEL_ALIAS_KEY]; + expect(alias?.maxOutputSize).toBe(8192); + expect(alias?.reasoningKey).toBe('reasoning'); + expectConfigInvalid(() => + apply({ ...MIN, KIMI_MODEL_MAX_OUTPUT_SIZE: 'nope' }), + ); + }); + + it('maps the thinking variables', () => { + const config = apply({ + ...MIN, + KIMI_MODEL_DEFAULT_THINKING: 'true', + KIMI_MODEL_THINKING_MODE: 'on', + KIMI_MODEL_THINKING_EFFORT: 'high', + }); + expect(config.defaultThinking).toBe(true); + expect(config.thinking).toMatchObject({ mode: 'on', effort: 'high' }); + expect(apply({ ...MIN, KIMI_MODEL_DEFAULT_THINKING: '0' }).defaultThinking) + .toBe(false); + }); + + it('rejects an invalid thinking mode', () => { + expectConfigInvalid(() => + apply({ ...MIN, KIMI_MODEL_THINKING_MODE: 'bogus' }), + ); + }); + + it('preserves unrelated config fields', () => { + const base = getDefaultConfig(); + base.defaultPermissionMode = 'auto'; + const config = applyEnvModelConfig(base, { ...MIN }); + expect(config.defaultPermissionMode).toBe('auto'); + }); +}); + +describe('loadRuntimeConfig vs readConfigFile (write-back isolation)', () => { + it('injects the env model into runtime config but readConfigFile stays clean', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-env-model-')); + const path = join(dir, 'config.toml'); + writeFileSync( + path, + 'default_model = "x"\n[providers.x]\ntype = "kimi"\napi_key = "k"\n[models.x]\nprovider = "x"\nmodel = "x"\nmax_context_size = 1000\n', + ); + try { + const env = { ...MIN }; + // Write-back path uses readConfigFile: no synthesized entries. + const onDisk = readConfigFile(path); + expect(onDisk.providers[ENV_MODEL_PROVIDER_KEY]).toBeUndefined(); + expect(onDisk.defaultModel).toBe('x'); + // Runtime path uses loadRuntimeConfig: synthesized entries present. + const runtime = loadRuntimeConfig(path, env); + expect(runtime.providers[ENV_MODEL_PROVIDER_KEY]).toBeDefined(); + expect(runtime.defaultModel).toBe(ENV_MODEL_ALIAS_KEY); + // Existing config is preserved alongside the synthesized model. + expect(runtime.providers['x']).toBeDefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From c9a3b831b4c7d3b225e8e236660ad5180038881e Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 19:34:44 +0800 Subject: [PATCH 2/5] feat: env-model defaults and friendly welcome model name - KIMI_MODEL_MAX_CONTEXT_SIZE defaults to 262144 (256K) when unset - KIMI_MODEL_CAPABILITIES defaults to image_in,thinking when unset - TUI welcome banner shows the model display name / id instead of the internal __kimi_env_model__ alias key --- .../src/tui/components/chrome/welcome.ts | 3 ++- docs/en/configuration/env-vars.md | 4 ++-- docs/zh/configuration/env-vars.md | 4 ++-- packages/agent-core/src/config/env-model.ts | 18 ++++++++++++------ .../agent-core/test/config/env-model.test.ts | 10 ++++++---- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 69993e6e3b..dabe173e15 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -51,9 +51,10 @@ export class WelcomeComponent implements Component { primary(logo[1]!.padEnd(logoWidth)) + gap + rightRow1, ]; + const activeModel = this.state.availableModels[this.state.model]; const modelValue = isLoggedOut ? chalk.hex(this.colors.warning)('not set, run /login or /connect') - : this.state.model; + : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); const infoLines = [ labelStyle('Directory: ') + this.state.workDir, diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 0e8f81bf24..ba4cdde99b 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -77,8 +77,8 @@ For testing you can make Kimi Code use a specific model **without editing `confi | `KIMI_MODEL_API_KEY` | Yes | API key | — | | `KIMI_MODEL_PROVIDER_TYPE` | No | Provider type; one of `kimi`, `anthropic`, `openai` | `kimi` | | `KIMI_MODEL_BASE_URL` | No | API base URL | `kimi` → `https://api.moonshot.ai/v1`; `openai` → `https://api.openai.com/v1`; `anthropic` → SDK default | -| `KIMI_MODEL_MAX_CONTEXT_SIZE` | Yes | Max context length in tokens (positive integer) | — | -| `KIMI_MODEL_CAPABILITIES` | No | Comma-separated capability tags (e.g. `image_in,thinking`); unioned with auto-detected capabilities | — | +| `KIMI_MODEL_MAX_CONTEXT_SIZE` | No | Max context length in tokens (positive integer) | `262144` (256K) | +| `KIMI_MODEL_CAPABILITIES` | No | Comma-separated capability tags (e.g. `image_in,thinking`); unioned with auto-detected capabilities | `image_in,thinking` | | `KIMI_MODEL_DISPLAY_NAME` | No | Name shown in `/model` | Falls back to `KIMI_MODEL_NAME` | | `KIMI_MODEL_MAX_OUTPUT_SIZE` | No | Per-request output cap (`anthropic` only) | Per-model default | | `KIMI_MODEL_REASONING_KEY` | No | Reasoning field-name override (`openai` only) | Auto-detected | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 79168f0da7..c94b2fff7a 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -77,8 +77,8 @@ OAuth 流程默认连接 Kimi 官方的认证与托管端点,下列变量可 | `KIMI_MODEL_API_KEY` | 是 | API 密钥 | — | | `KIMI_MODEL_PROVIDER_TYPE` | 否 | 供应商类型,可选 `kimi`、`anthropic`、`openai` | `kimi` | | `KIMI_MODEL_BASE_URL` | 否 | API 基础 URL | `kimi` → `https://api.moonshot.ai/v1`;`openai` → `https://api.openai.com/v1`;`anthropic` → SDK 默认值 | -| `KIMI_MODEL_MAX_CONTEXT_SIZE` | 是 | 最大上下文长度(token 数,正整数) | — | -| `KIMI_MODEL_CAPABILITIES` | 否 | 逗号分隔的能力标签(如 `image_in,thinking`);与自动探测的能力做并集 | — | +| `KIMI_MODEL_MAX_CONTEXT_SIZE` | 否 | 最大上下文长度(token 数,正整数) | `262144`(256K) | +| `KIMI_MODEL_CAPABILITIES` | 否 | 逗号分隔的能力标签(如 `image_in,thinking`);与自动探测的能力做并集 | `image_in,thinking` | | `KIMI_MODEL_DISPLAY_NAME` | 否 | 在 `/model` 中显示的名称 | 回退到 `KIMI_MODEL_NAME` | | `KIMI_MODEL_MAX_OUTPUT_SIZE` | 否 | 单次请求的输出上限(仅 `anthropic`) | 模型默认值 | | `KIMI_MODEL_REASONING_KEY` | 否 | 推理字段名覆盖(仅 `openai`) | 自动探测 | diff --git a/packages/agent-core/src/config/env-model.ts b/packages/agent-core/src/config/env-model.ts index 3bb8bab25c..8355a4982f 100644 --- a/packages/agent-core/src/config/env-model.ts +++ b/packages/agent-core/src/config/env-model.ts @@ -21,6 +21,12 @@ const DEFAULT_BASE_URL: Partial> = { // anthropic: omitted -> let the Anthropic SDK pick its default }; +/** Default context window (256K) used when KIMI_MODEL_MAX_CONTEXT_SIZE is unset. */ +const DEFAULT_MAX_CONTEXT_SIZE = 262144; + +/** Default capabilities when KIMI_MODEL_CAPABILITIES is unset (kimi models support both). */ +const DEFAULT_CAPABILITIES = ['image_in', 'thinking']; + type Env = Readonly>; function trimmed(value: string | undefined): string | undefined { @@ -78,10 +84,10 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): } const maxContextRaw = trimmed(env['KIMI_MODEL_MAX_CONTEXT_SIZE']); - if (maxContextRaw === undefined) { - fail('KIMI_MODEL_MAX_CONTEXT_SIZE is required when KIMI_MODEL_NAME is set.'); - } - const maxContextSize = parsePositiveInt(maxContextRaw, 'KIMI_MODEL_MAX_CONTEXT_SIZE'); + const maxContextSize = + maxContextRaw === undefined + ? DEFAULT_MAX_CONTEXT_SIZE + : parsePositiveInt(maxContextRaw, 'KIMI_MODEL_MAX_CONTEXT_SIZE'); const type = parseProviderType(trimmed(env['KIMI_MODEL_PROVIDER_TYPE'])); const baseUrl = trimmed(env['KIMI_MODEL_BASE_URL']) ?? DEFAULT_BASE_URL[type]; @@ -97,7 +103,7 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): maxOutputRaw !== undefined ? parsePositiveInt(maxOutputRaw, 'KIMI_MODEL_MAX_OUTPUT_SIZE') : undefined; - const capabilities = parseCapabilities(env['KIMI_MODEL_CAPABILITIES']); + const capabilities = parseCapabilities(env['KIMI_MODEL_CAPABILITIES']) ?? DEFAULT_CAPABILITIES; const displayName = trimmed(env['KIMI_MODEL_DISPLAY_NAME']); const reasoningKey = trimmed(env['KIMI_MODEL_REASONING_KEY']); @@ -105,7 +111,7 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): provider: ENV_MODEL_PROVIDER_KEY, model, maxContextSize, - ...(capabilities !== undefined ? { capabilities } : {}), + capabilities, ...(displayName !== undefined ? { displayName } : {}), ...(maxOutputSize !== undefined ? { maxOutputSize } : {}), ...(reasoningKey !== undefined ? { reasoningKey } : {}), diff --git a/packages/agent-core/test/config/env-model.test.ts b/packages/agent-core/test/config/env-model.test.ts index f264fe3e62..0cd98564eb 100644 --- a/packages/agent-core/test/config/env-model.test.ts +++ b/packages/agent-core/test/config/env-model.test.ts @@ -42,10 +42,11 @@ describe('applyEnvModelConfig', () => { expectConfigInvalid(() => apply({ KIMI_MODEL_NAME: 'm' })); }); - it('throws when max_context_size is missing', () => { - expectConfigInvalid(() => - apply({ KIMI_MODEL_NAME: 'm', KIMI_MODEL_API_KEY: 'k' }), - ); + it('defaults max_context_size to 262144 (256K) when unset', () => { + expect( + apply({ KIMI_MODEL_NAME: 'm', KIMI_MODEL_API_KEY: 'k' }) + .models?.[ENV_MODEL_ALIAS_KEY]?.maxContextSize, + ).toBe(262144); }); it.each(['abc', '0', '1.5', '-1'])( @@ -68,6 +69,7 @@ describe('applyEnvModelConfig', () => { provider: ENV_MODEL_PROVIDER_KEY, model: 'kimi-for-coding', maxContextSize: 262144, + capabilities: ['image_in', 'thinking'], }); expect(config.defaultModel).toBe(ENV_MODEL_ALIAS_KEY); }); From 6eeef4f3eac0ea05a4af6ea87872fe77b1712305 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 19:53:57 +0800 Subject: [PATCH 3/5] fix: never persist env model to config.toml; validate default_thinking - Strip the synthesized __kimi_env__ provider / __kimi_env_model__ model (and a default_model pointing at it) in writeConfigFile, so the env model and its shell API key cannot be persisted via a getConfig -> setConfig patch round-trip (e.g. running /login or /connect in env-model mode). - Reject a non-empty but unparseable KIMI_MODEL_DEFAULT_THINKING value (fail-fast) instead of silently keeping config.toml's existing default. --- packages/agent-core/src/config/env-model.ts | 56 ++++++++++++++- packages/agent-core/src/config/toml.ts | 7 +- .../agent-core/test/config/env-model.test.ts | 68 ++++++++++++++++++- 3 files changed, 125 insertions(+), 6 deletions(-) diff --git a/packages/agent-core/src/config/env-model.ts b/packages/agent-core/src/config/env-model.ts index 8355a4982f..b5f3ed9136 100644 --- a/packages/agent-core/src/config/env-model.ts +++ b/packages/agent-core/src/config/env-model.ts @@ -65,14 +65,32 @@ function parseCapabilities(raw: string | undefined): string[] | undefined { return caps.length === 0 ? undefined : caps; } +// `parseBooleanEnv` returns undefined for unrecognized input. Treat a non-empty +// but unparseable value (e.g. a typo like `flase`) as a config error so it +// fails fast like the other KIMI_MODEL_* values, instead of silently keeping +// config.toml's existing default_thinking. +function parseDefaultThinking(raw: string | undefined): boolean | undefined { + const value = trimmed(raw); + if (value === undefined) return undefined; + const parsed = parseBooleanEnv(value); + if (parsed === undefined) { + fail( + `KIMI_MODEL_DEFAULT_THINKING must be a boolean (true/false/1/0/yes/no/on/off), got "${raw}".`, + ); + } + return parsed; +} + /** * When `KIMI_MODEL_NAME` is set, synthesize one provider + one model alias from * the `KIMI_MODEL_*` environment variables and make it the default model. * Returns the config unchanged when the trigger variable is absent. * * IMPORTANT: the synthesized provider/model/default_model exist ONLY in the - * in-memory runtime config. Callers must never serialize the result back to - * config.toml (write paths must use the raw `readConfigFile`). + * in-memory runtime config and must never be serialized back to config.toml. + * Two layers enforce this: write paths read the raw config via `readConfigFile`, + * and `writeConfigFile` strips the reserved entries via `stripEnvModelConfig` as + * a final guard against patch round-trips (getConfig -> setConfig). */ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): KimiConfig { const model = trimmed(env['KIMI_MODEL_NAME']); @@ -130,7 +148,7 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): ...(thinkingEffort !== undefined ? { effort: thinkingEffort } : {}), } : config.thinking; - const defaultThinking = parseBooleanEnv(env['KIMI_MODEL_DEFAULT_THINKING']); + const defaultThinking = parseDefaultThinking(env['KIMI_MODEL_DEFAULT_THINKING']); const merged: KimiConfig = { ...config, @@ -146,3 +164,35 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): // KimiError(CONFIG_INVALID) on violation, matching the explicit checks above. return validateConfig(merged); } + +/** + * Remove the env-synthesized provider/model before a config is persisted to + * disk. Mirror of {@link applyEnvModelConfig}: that injects the reserved entries + * into the in-memory runtime config; this guarantees they never reach + * config.toml — including via a `getConfig` -> `setConfig` patch round-trip, + * where the runtime config (carrying the env provider and its shell API key) + * would otherwise be merged back and written out. A `default_model` pointing at + * the env alias is cleared to avoid persisting a dangling reference. + */ +export function stripEnvModelConfig(config: KimiConfig): KimiConfig { + const hasProvider = ENV_MODEL_PROVIDER_KEY in config.providers; + const hasModel = config.models !== undefined && ENV_MODEL_ALIAS_KEY in config.models; + const defaultIsEnv = config.defaultModel === ENV_MODEL_ALIAS_KEY; + if (!hasProvider && !hasModel && !defaultIsEnv) return config; + + const providers = { ...config.providers }; + delete providers[ENV_MODEL_PROVIDER_KEY]; + + let models = config.models; + if (models !== undefined && ENV_MODEL_ALIAS_KEY in models) { + models = { ...models }; + delete models[ENV_MODEL_ALIAS_KEY]; + } + + return { + ...config, + providers, + ...(models !== undefined ? { models } : {}), + ...(defaultIsEnv ? { defaultModel: undefined } : {}), + }; +} diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 82eb610711..c8adb37946 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -3,7 +3,7 @@ import { mkdir, open } from 'node:fs/promises'; import { dirname } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; -import { applyEnvModelConfig } from './env-model'; +import { applyEnvModelConfig, stripEnvModelConfig } from './env-model'; import { KimiConfigSchema, formatConfigValidationError, @@ -263,7 +263,10 @@ function transformLoopControlData(data: Record): Record { - const validated = validateConfig(config); + // Final guard: never persist the env-synthesized model/provider to disk, + // even if a caller passes back the runtime config as a patch (see + // stripEnvModelConfig / the getConfig -> setConfig round-trip). + const validated = validateConfig(stripEnvModelConfig(config)); await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); await atomicWrite(filePath, `${stringifyToml(configToTomlData(validated))}\n`); } diff --git a/packages/agent-core/test/config/env-model.test.ts b/packages/agent-core/test/config/env-model.test.ts index 0cd98564eb..decd3b2e7f 100644 --- a/packages/agent-core/test/config/env-model.test.ts +++ b/packages/agent-core/test/config/env-model.test.ts @@ -7,8 +7,9 @@ import { applyEnvModelConfig, ENV_MODEL_ALIAS_KEY, ENV_MODEL_PROVIDER_KEY, + stripEnvModelConfig, } from '../../src/config/env-model'; -import { getDefaultConfig, loadRuntimeConfig, readConfigFile } from '../../src/config'; +import { getDefaultConfig, loadRuntimeConfig, readConfigFile, writeConfigFile } from '../../src/config'; import { KimiError } from '../../src/errors'; function apply(env: Record) { @@ -183,3 +184,68 @@ describe('loadRuntimeConfig vs readConfigFile (write-back isolation)', () => { } }); }); + +describe('stripEnvModelConfig (write-back guard)', () => { + it('removes synthesized env provider/model and clears the env default_model', () => { + const runtime = applyEnvModelConfig(getDefaultConfig(), { ...MIN }); + const stripped = stripEnvModelConfig(runtime); + expect(stripped.providers[ENV_MODEL_PROVIDER_KEY]).toBeUndefined(); + expect(stripped.models?.[ENV_MODEL_ALIAS_KEY]).toBeUndefined(); + expect(stripped.defaultModel).toBeUndefined(); + }); + + it('keeps user providers/models and a non-env default_model', () => { + const config = getDefaultConfig(); + config.providers['kimi'] = { type: 'kimi', apiKey: 'k', baseUrl: 'https://x/v1' }; + config.providers[ENV_MODEL_PROVIDER_KEY] = { type: 'kimi', apiKey: 'env-key' }; + config.models = { + 'my-model': { provider: 'kimi', model: 'm', maxContextSize: 1000 }, + [ENV_MODEL_ALIAS_KEY]: { provider: ENV_MODEL_PROVIDER_KEY, model: 'x', maxContextSize: 1000 }, + }; + config.defaultModel = 'my-model'; + const stripped = stripEnvModelConfig(config); + expect(stripped.providers['kimi']).toBeDefined(); + expect(stripped.models?.['my-model']).toBeDefined(); + expect(stripped.defaultModel).toBe('my-model'); + expect(stripped.providers[ENV_MODEL_PROVIDER_KEY]).toBeUndefined(); + expect(stripped.models?.[ENV_MODEL_ALIAS_KEY]).toBeUndefined(); + }); +}); + +describe('writeConfigFile never persists the env model', () => { + it('strips env entries even when a runtime config is written back', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-env-write-')); + const path = join(dir, 'config.toml'); + writeFileSync( + path, + 'default_model = "x"\n[providers.x]\ntype = "kimi"\napi_key = "k"\n[models.x]\nprovider = "x"\nmodel = "x"\nmax_context_size = 1000\n', + ); + try { + // Reproduces the /login round-trip: a runtime config carrying the env + // model is written back through writeConfigFile and must not persist it. + const runtime = loadRuntimeConfig(path, { ...MIN }); + expect(runtime.providers[ENV_MODEL_PROVIDER_KEY]).toBeDefined(); + await writeConfigFile(path, runtime); + const onDisk = readConfigFile(path); + expect(onDisk.providers[ENV_MODEL_PROVIDER_KEY]).toBeUndefined(); + expect(onDisk.models?.[ENV_MODEL_ALIAS_KEY]).toBeUndefined(); + expect(onDisk.providers['x']).toBeDefined(); + expect(onDisk.models?.['x']).toBeDefined(); + expect(onDisk.defaultModel).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('KIMI_MODEL_DEFAULT_THINKING validation', () => { + it('rejects a non-empty unparseable value', () => { + expectConfigInvalid(() => apply({ ...MIN, KIMI_MODEL_DEFAULT_THINKING: 'flase' })); + }); + + it('accepts valid values and ignores when unset', () => { + expect(apply({ ...MIN, KIMI_MODEL_DEFAULT_THINKING: 'true' }).defaultThinking).toBe(true); + expect(apply({ ...MIN, KIMI_MODEL_DEFAULT_THINKING: '0' }).defaultThinking).toBe(false); + expect(apply({ ...MIN }).defaultThinking).toBeUndefined(); + }); +}); From 18a86297a27e605e6c4505f5e65ca6793779a972 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 20:41:10 +0800 Subject: [PATCH 4/5] fix: preserve on-disk default_model when stripping env model; fix thinking docs - stripEnvModelConfig restores config.toml's default_model from raw instead of erasing it when the runtime default points at the env alias, so a real default_model survives a getConfig -> setConfig round-trip. - Correct KIMI_MODEL_DEFAULT_THINKING docs: unset follows the global default (Thinking on), not Off. --- docs/en/configuration/env-vars.md | 2 +- docs/zh/configuration/env-vars.md | 2 +- packages/agent-core/src/config/env-model.ts | 12 ++++++++++-- packages/agent-core/test/config/env-model.test.ts | 4 +++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index ba4cdde99b..f3ef74e482 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -82,7 +82,7 @@ For testing you can make Kimi Code use a specific model **without editing `confi | `KIMI_MODEL_DISPLAY_NAME` | No | Name shown in `/model` | Falls back to `KIMI_MODEL_NAME` | | `KIMI_MODEL_MAX_OUTPUT_SIZE` | No | Per-request output cap (`anthropic` only) | Per-model default | | `KIMI_MODEL_REASONING_KEY` | No | Reasoning field-name override (`openai` only) | Auto-detected | -| `KIMI_MODEL_DEFAULT_THINKING` | No | Default Thinking toggle for new sessions | Off | +| `KIMI_MODEL_DEFAULT_THINKING` | No | Default Thinking toggle for new sessions | Unset follows the global default (Thinking on) | | `KIMI_MODEL_THINKING_MODE` | No | Thinking trigger policy; `auto`/`on`/`off` | — | | `KIMI_MODEL_THINKING_EFFORT` | No | Thinking effort (e.g. `low`/`medium`/`high`/`xhigh`/`max`; available levels depend on the provider) | — | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index c94b2fff7a..e287fba86c 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -82,7 +82,7 @@ OAuth 流程默认连接 Kimi 官方的认证与托管端点,下列变量可 | `KIMI_MODEL_DISPLAY_NAME` | 否 | 在 `/model` 中显示的名称 | 回退到 `KIMI_MODEL_NAME` | | `KIMI_MODEL_MAX_OUTPUT_SIZE` | 否 | 单次请求的输出上限(仅 `anthropic`) | 模型默认值 | | `KIMI_MODEL_REASONING_KEY` | 否 | 推理字段名覆盖(仅 `openai`) | 自动探测 | -| `KIMI_MODEL_DEFAULT_THINKING` | 否 | 新会话的默认 Thinking 开关 | 关闭 | +| `KIMI_MODEL_DEFAULT_THINKING` | 否 | 新会话的默认 Thinking 开关 | 未设时跟随全局默认(Thinking 开启) | | `KIMI_MODEL_THINKING_MODE` | 否 | Thinking 触发策略,可选 `auto`/`on`/`off` | — | | `KIMI_MODEL_THINKING_EFFORT` | 否 | Thinking 强度(如 `low`/`medium`/`high`/`xhigh`/`max`;实际可用等级由供应商决定) | — | diff --git a/packages/agent-core/src/config/env-model.ts b/packages/agent-core/src/config/env-model.ts index b5f3ed9136..ec58248144 100644 --- a/packages/agent-core/src/config/env-model.ts +++ b/packages/agent-core/src/config/env-model.ts @@ -172,7 +172,8 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): * config.toml — including via a `getConfig` -> `setConfig` patch round-trip, * where the runtime config (carrying the env provider and its shell API key) * would otherwise be merged back and written out. A `default_model` pointing at - * the env alias is cleared to avoid persisting a dangling reference. + * the env alias is restored to the on-disk value (from `config.raw`) rather than + * erased, so a real default_model already in config.toml survives the round-trip. */ export function stripEnvModelConfig(config: KimiConfig): KimiConfig { const hasProvider = ENV_MODEL_PROVIDER_KEY in config.providers; @@ -193,6 +194,13 @@ export function stripEnvModelConfig(config: KimiConfig): KimiConfig { ...config, providers, ...(models !== undefined ? { models } : {}), - ...(defaultIsEnv ? { defaultModel: undefined } : {}), + // Restore the on-disk default (from raw) instead of erasing it when the + // runtime default points at the env alias. + ...(defaultIsEnv ? { defaultModel: rawDefaultModel(config) } : {}), }; } + +function rawDefaultModel(config: KimiConfig): string | undefined { + const raw = config.raw?.['default_model']; + return typeof raw === 'string' ? raw : undefined; +} diff --git a/packages/agent-core/test/config/env-model.test.ts b/packages/agent-core/test/config/env-model.test.ts index decd3b2e7f..e7c09f875c 100644 --- a/packages/agent-core/test/config/env-model.test.ts +++ b/packages/agent-core/test/config/env-model.test.ts @@ -231,7 +231,9 @@ describe('writeConfigFile never persists the env model', () => { expect(onDisk.models?.[ENV_MODEL_ALIAS_KEY]).toBeUndefined(); expect(onDisk.providers['x']).toBeDefined(); expect(onDisk.models?.['x']).toBeDefined(); - expect(onDisk.defaultModel).toBeUndefined(); + // The env default_model is stripped, but the on-disk original is restored + // (not erased), so a real default_model in config.toml survives. + expect(onDisk.defaultModel).toBe('x'); } finally { rmSync(dir, { recursive: true, force: true }); } From 3719ec031d96d3786a842a58fe49b1704bd6685a Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 21:37:20 +0800 Subject: [PATCH 5/5] fix: restore env-injected fields to on-disk values in stripEnvModelConfig; update tests for thinking behavior --- packages/agent-core/src/config/env-model.ts | 28 +++++++++-- .../agent-core/test/config/env-model.test.ts | 47 ++++++++++++++++--- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/agent-core/src/config/env-model.ts b/packages/agent-core/src/config/env-model.ts index ec58248144..5f46235a23 100644 --- a/packages/agent-core/src/config/env-model.ts +++ b/packages/agent-core/src/config/env-model.ts @@ -171,9 +171,10 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): * into the in-memory runtime config; this guarantees they never reach * config.toml — including via a `getConfig` -> `setConfig` patch round-trip, * where the runtime config (carrying the env provider and its shell API key) - * would otherwise be merged back and written out. A `default_model` pointing at - * the env alias is restored to the on-disk value (from `config.raw`) rather than - * erased, so a real default_model already in config.toml survives the round-trip. + * would otherwise be merged back and written out. Every env-injected top-level + * field (default_model, thinking, default_thinking) is restored to its on-disk + * value from `config.raw` rather than erased, so real values already in + * config.toml survive the round-trip. */ export function stripEnvModelConfig(config: KimiConfig): KimiConfig { const hasProvider = ENV_MODEL_PROVIDER_KEY in config.providers; @@ -194,9 +195,14 @@ export function stripEnvModelConfig(config: KimiConfig): KimiConfig { ...config, providers, ...(models !== undefined ? { models } : {}), - // Restore the on-disk default (from raw) instead of erasing it when the - // runtime default points at the env alias. + // Restore env-injected top-level fields from raw instead of persisting the + // shell overrides: the env default_model (when it points at the env alias), + // and the env thinking / default_thinking. Reaching here means env-model + // mode is active (the synthetic provider/model exist), so these may be env + // values; an unset raw field restores to undefined (i.e. drops it). ...(defaultIsEnv ? { defaultModel: rawDefaultModel(config) } : {}), + thinking: rawThinking(config), + defaultThinking: rawDefaultThinking(config), }; } @@ -204,3 +210,15 @@ function rawDefaultModel(config: KimiConfig): string | undefined { const raw = config.raw?.['default_model']; return typeof raw === 'string' ? raw : undefined; } + +function rawDefaultThinking(config: KimiConfig): boolean | undefined { + const raw = config.raw?.['default_thinking']; + return typeof raw === 'boolean' ? raw : undefined; +} + +function rawThinking(config: KimiConfig): ThinkingConfig | undefined { + const raw = config.raw?.['thinking']; + return typeof raw === 'object' && raw !== null && !Array.isArray(raw) + ? (raw as ThinkingConfig) + : undefined; +} diff --git a/packages/agent-core/test/config/env-model.test.ts b/packages/agent-core/test/config/env-model.test.ts index e7c09f875c..77c21a8add 100644 --- a/packages/agent-core/test/config/env-model.test.ts +++ b/packages/agent-core/test/config/env-model.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { describe, expect, it } from 'vitest'; import { join } from 'pathe'; @@ -213,27 +213,60 @@ describe('stripEnvModelConfig (write-back guard)', () => { }); describe('writeConfigFile never persists the env model', () => { - it('strips env entries even when a runtime config is written back', async () => { + it('strips env entries (incl. thinking) when a runtime config is written back', async () => { const dir = mkdtempSync(join(tmpdir(), 'kimi-env-write-')); const path = join(dir, 'config.toml'); writeFileSync( path, - 'default_model = "x"\n[providers.x]\ntype = "kimi"\napi_key = "k"\n[models.x]\nprovider = "x"\nmodel = "x"\nmax_context_size = 1000\n', + 'default_model = "x"\ndefault_thinking = false\n[thinking]\nmode = "auto"\n[providers.x]\ntype = "kimi"\napi_key = "k"\n[models.x]\nprovider = "x"\nmodel = "x"\nmax_context_size = 1000\n', ); try { // Reproduces the /login round-trip: a runtime config carrying the env - // model is written back through writeConfigFile and must not persist it. - const runtime = loadRuntimeConfig(path, { ...MIN }); + // model AND env thinking overrides is written back and must persist none. + const runtime = loadRuntimeConfig(path, { + ...MIN, + KIMI_MODEL_THINKING_MODE: 'on', + KIMI_MODEL_DEFAULT_THINKING: 'true', + }); + // Sanity: env overrides are active at runtime. expect(runtime.providers[ENV_MODEL_PROVIDER_KEY]).toBeDefined(); + expect(runtime.thinking?.mode).toBe('on'); + expect(runtime.defaultThinking).toBe(true); + await writeConfigFile(path, runtime); const onDisk = readConfigFile(path); + // Env provider/model are gone; the user's stay; default_model is restored. expect(onDisk.providers[ENV_MODEL_PROVIDER_KEY]).toBeUndefined(); expect(onDisk.models?.[ENV_MODEL_ALIAS_KEY]).toBeUndefined(); expect(onDisk.providers['x']).toBeDefined(); expect(onDisk.models?.['x']).toBeDefined(); - // The env default_model is stripped, but the on-disk original is restored - // (not erased), so a real default_model in config.toml survives. expect(onDisk.defaultModel).toBe('x'); + // Thinking is restored to the on-disk original, not the env override. + expect(onDisk.thinking?.mode).toBe('auto'); + expect(onDisk.defaultThinking).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('output never contains env-injected identifiers', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-env-write2-')); + const path = join(dir, 'config.toml'); + writeFileSync( + path, + 'default_model = "x"\n[providers.x]\ntype = "kimi"\napi_key = "k"\n[models.x]\nprovider = "x"\nmodel = "x"\nmax_context_size = 1000\n', + ); + try { + const runtime = loadRuntimeConfig(path, { + ...MIN, + KIMI_MODEL_THINKING_EFFORT: 'low', + KIMI_MODEL_DEFAULT_THINKING: 'true', + }); + await writeConfigFile(path, runtime); + const text = readFileSync(path, 'utf-8'); + // Hard invariant: no env-synthesized identifiers ever reach config.toml. + expect(text).not.toContain(ENV_MODEL_PROVIDER_KEY); + expect(text).not.toContain(ENV_MODEL_ALIAS_KEY); } finally { rmSync(dir, { recursive: true, force: true }); }