Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/adaptive-thinking-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
"@moonshot-ai/kosong": minor
---

Add `KIMI_MODEL_ADAPTIVE_THINKING` (and a matching `adaptive_thinking` model-alias field) to force adaptive thinking (`thinking: { type: 'adaptive' }`) on or off, overriding the Anthropic model-name version inference. This lets custom-named staff endpoints that back an adaptive-capable model opt in even when the model name does not encode a parseable Claude version.
1 change: 1 addition & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ Each entry in the `models` table defines a model alias, keyed by a unique name.
| `capabilities` | `array<string>` | No | Capability tags to add explicitly, for example `thinking`, `image_in`, `video_in`, `audio_in`, `tool_use` |
| `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset |
| `reasoning_key` | `string` | No | `openai` provider only. Override the field name used for reasoning content. By default the provider auto-detects `reasoning_content`, `reasoning_details`, and `reasoning` on incoming responses and serializes thinking back as `reasoning_content` — set this only if your gateway uses a non-standard field name |
| `adaptive_thinking` | `boolean` | No | `anthropic` provider only. Force adaptive thinking (`thinking: { type: 'adaptive' }`) on or off, overriding the model-name version inference. Set `true` for a custom-named endpoint that backs an adaptive-capable model whose name does not encode a parseable Claude version; forcing it on for an endpoint that does not support adaptive thinking makes the API reject the request. Omit to infer from the model name (Claude ≥ 4.6 uses adaptive) |

`capabilities` is unioned with the capabilities that the provider capability registry matches by model-name prefix — entries can only be added, never removed. You usually do not need to set this by hand; reach for it only when the model is not covered by the registry, or when you want to force-enable a particular capability.

Expand Down
3 changes: 3 additions & 0 deletions docs/en/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,12 @@ For testing you can make Kimi Code use a specific model **without editing `confi
| `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) | — |
| `KIMI_MODEL_ADAPTIVE_THINKING` | No | Force adaptive thinking (`thinking: { type: 'adaptive' }`) on or off, overriding the model-name version inference (`anthropic` only) | Inferred from the model name (Claude ≥ 4.6 uses adaptive) |

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.

Set `KIMI_MODEL_ADAPTIVE_THINKING=true` when a custom-named Anthropic-compatible endpoint backs a model that supports adaptive thinking but whose model name does not encode a parseable Claude version (so the automatic inference would otherwise fall back to budget-based thinking). Forcing it on for an endpoint that does **not** support adaptive thinking makes the API reject the request, so leave it unset unless you know the backing model supports it.

```sh
export KIMI_MODEL_NAME="kimi-for-coding"
export KIMI_MODEL_BASE_URL="https://api-staff.msh.team/v1"
Expand Down
1 change: 1 addition & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ custom_headers = { "X-Custom-Header" = "value" }
| `capabilities` | `array<string>` | 否 | 显式追加的模型能力标签,例如 `thinking`、`image_in`、`video_in`、`audio_in`、`tool_use` |
| `display_name` | `string` | 否 | 在 UI 中显示的名称,未设置时回退到 `model` |
| `reasoning_key` | `string` | 否 | 仅 `openai` 供应商。覆盖推理内容所用的字段名。默认情况下供应商会自动识别响应中的 `reasoning_content`、`reasoning_details`、`reasoning`,并以 `reasoning_content` 回传思考内容 —— 只有当网关使用非标准字段名时才需要设置 |
| `adaptive_thinking` | `boolean` | 否 | 仅 `anthropic` 供应商。强制开启或关闭 adaptive thinking(`thinking: { type: 'adaptive' }`),覆盖按模型名推断版本的逻辑。当某个自定义命名的端点背后的模型支持 adaptive、但其名称无法解析出可识别的 Claude 版本时,可设为 `true`;若对不支持 adaptive thinking 的端点强制开启,API 会直接拒绝请求。省略时按模型名推断(Claude ≥ 4.6 使用 adaptive) |

`capabilities` 与供应商 capability registry 按模型名前缀自动匹配出来的能力做并集 —— 只能追加、不能移除。通常无需手写;只有当模型未被 registry 覆盖、或希望强制启用某项能力时才用得到。

Expand Down
3 changes: 3 additions & 0 deletions docs/zh/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,12 @@ OAuth 流程默认连接 Kimi 官方的认证与托管端点,下列变量可
| `KIMI_MODEL_DEFAULT_THINKING` | 否 | 新会话的默认 Thinking 开关 | 未设时跟随全局默认(Thinking 开启) |
| `KIMI_MODEL_THINKING_MODE` | 否 | Thinking 触发策略,可选 `auto`/`on`/`off` | — |
| `KIMI_MODEL_THINKING_EFFORT` | 否 | Thinking 强度(如 `low`/`medium`/`high`/`xhigh`/`max`;实际可用等级由供应商决定) | — |
| `KIMI_MODEL_ADAPTIVE_THINKING` | 否 | 强制开启或关闭 adaptive thinking(`thinking: { type: 'adaptive' }`),覆盖按模型名推断版本的逻辑(仅 `anthropic`) | 按模型名推断(Claude ≥ 4.6 使用 adaptive) |

合成出的条目使用保留键 `__kimi_env__`(供应商)和 `__kimi_env_model__`(模型别名)。当设置了 `KIMI_MODEL_NAME` 但缺少必填变量或变量取值非法时,启动会以清晰的错误信息失败。

当某个自定义命名的 Anthropic 兼容端点背后的模型支持 adaptive thinking、但其模型名无法解析出可识别的 Claude 版本(否则自动推断会回退到 budget 模式)时,设置 `KIMI_MODEL_ADAPTIVE_THINKING=true`。若对**不支持** adaptive thinking 的端点强制开启,API 会直接拒绝请求;因此在不确定背后模型是否支持时,请保持该变量为未设置。

```sh
export KIMI_MODEL_NAME="kimi-for-coding"
export KIMI_MODEL_BASE_URL="https://api-staff.msh.team/v1"
Expand Down
18 changes: 12 additions & 6 deletions packages/agent-core/src/config/env-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,13 @@ function parseCapabilities(raw: string | undefined): string[] | undefined {
// `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 {
// config.toml's existing value.
function parseBooleanVar(raw: string | undefined, varName: string): 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}".`,
);
fail(`${varName} must be a boolean (true/false/1/0/yes/no/on/off), got "${raw}".`);
}
return parsed;
}
Expand Down Expand Up @@ -124,6 +122,10 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env):
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']);
const adaptiveThinking = parseBooleanVar(
env['KIMI_MODEL_ADAPTIVE_THINKING'],
'KIMI_MODEL_ADAPTIVE_THINKING',
);

const alias: ModelAlias = {
provider: ENV_MODEL_PROVIDER_KEY,
Expand All @@ -133,6 +135,7 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env):
...(displayName !== undefined ? { displayName } : {}),
...(maxOutputSize !== undefined ? { maxOutputSize } : {}),
...(reasoningKey !== undefined ? { reasoningKey } : {}),
...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}),
};

const thinkingMode = trimmed(env['KIMI_MODEL_THINKING_MODE']);
Expand All @@ -148,7 +151,10 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env):
...(thinkingEffort !== undefined ? { effort: thinkingEffort } : {}),
}
: config.thinking;
const defaultThinking = parseDefaultThinking(env['KIMI_MODEL_DEFAULT_THINKING']);
const defaultThinking = parseBooleanVar(
env['KIMI_MODEL_DEFAULT_THINKING'],
'KIMI_MODEL_DEFAULT_THINKING',
);

const merged: KimiConfig = {
...config,
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export const ModelAliasSchema = z.object({
capabilities: z.array(z.string()).optional(),
displayName: z.string().optional(),
reasoningKey: z.string().optional(),
// Explicitly declare adaptive-thinking support, overriding the kosong
// model-name version inference. Needed for custom-named Anthropic endpoints
// whose model name does not encode a parseable Claude version.
adaptiveThinking: z.boolean().optional(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make adaptive_thinking advertise thinking support

For the documented custom-named Anthropic case, setting only adaptive_thinking = true leaves capabilities empty. The TUI model picker uses ModelAlias.capabilities directly (apps/kimi-code/src/tui/components/dialogs/model-selector.ts:71-75) and treats aliases without thinking as unsupported, so switching to such an alias forces thinking=false before the request ever reaches the provider; the runtime capability resolver also still relies on declared/detected capabilities (packages/agent-core/src/session/provider-manager.ts:200-205). Please have the new flag imply the thinking capability, or require capabilities = ["thinking"] alongside it in the config docs, so the advertised one-field opt-in actually enables thinking for custom model names.

Useful? React with 👍 / 👎.

});

export type ModelAlias = z.infer<typeof ModelAliasSchema>;
Expand Down
13 changes: 12 additions & 1 deletion packages/agent-core/src/session/provider-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export class ProviderManager implements ModelProvider {
alias.maxOutputSize,
alias.reasoningKey,
this.options.promptCacheKey,
alias.adaptiveThinking,
);

return {
Expand Down Expand Up @@ -200,7 +201,15 @@ function resolveModelCapabilities(
image_in: declared.has('image_in') || detected.image_in,
video_in: declared.has('video_in') || detected.video_in,
audio_in: declared.has('audio_in') || detected.audio_in,
thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking,
// Forcing adaptive thinking implies the model supports thinking, so advertise
// it even when the alias declares no capabilities and the custom model name is
// not in the capability catalog. Otherwise the model picker would classify the
// alias as "thinking unsupported" and disable it.
thinking:
declared.has('thinking') ||
declared.has('always_thinking') ||
alias.adaptiveThinking === true ||
detected.thinking,
tool_use: declared.has('tool_use') || detected.tool_use,
max_context_tokens: alias.maxContextSize,
};
Expand All @@ -213,6 +222,7 @@ function toKosongProviderConfig(
maxOutputSize: number | undefined,
reasoningKey: string | undefined,
promptCacheKey: string | undefined,
adaptiveThinking: boolean | undefined,
): KosongProviderConfig {
switch (provider.type) {
case 'anthropic':
Expand All @@ -222,6 +232,7 @@ function toKosongProviderConfig(
baseUrl: providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL'),
apiKey: providerApiKey(provider),
...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}),
...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}),
...defaultHeadersField(provider.customHeaders),
};
case 'openai':
Expand Down
20 changes: 20 additions & 0 deletions packages/agent-core/test/config/env-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,26 @@ describe('applyEnvModelConfig', () => {
);
});

it('maps KIMI_MODEL_ADAPTIVE_THINKING onto the alias', () => {
expect(
apply({ ...MIN, KIMI_MODEL_ADAPTIVE_THINKING: 'true' })
.models?.[ENV_MODEL_ALIAS_KEY]?.adaptiveThinking,
).toBe(true);
expect(
apply({ ...MIN, KIMI_MODEL_ADAPTIVE_THINKING: 'false' })
.models?.[ENV_MODEL_ALIAS_KEY]?.adaptiveThinking,
).toBe(false);
expect(
apply({ ...MIN }).models?.[ENV_MODEL_ALIAS_KEY]?.adaptiveThinking,
).toBeUndefined();
});

it('rejects an invalid KIMI_MODEL_ADAPTIVE_THINKING', () => {
expectConfigInvalid(() =>
apply({ ...MIN, KIMI_MODEL_ADAPTIVE_THINKING: 'maybe' }),
);
});

it('preserves unrelated config fields', () => {
const base = getDefaultConfig();
base.defaultPermissionMode = 'auto';
Expand Down
98 changes: 98 additions & 0 deletions packages/agent-core/test/harness/runtime-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,53 @@ describe('resolveRuntimeProvider model metadata', () => {
});
});

it('treats alias.adaptiveThinking as advertising the thinking capability', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'okapi-alias': {
provider: 'anthropic',
model: 'coding-model-okapi-0527-vibe',
maxContextSize: 200000,
adaptiveThinking: true,
},
},
},
model: 'okapi-alias',
});

expect(resolved.modelCapabilities.thinking).toBe(true);
});

it('does not advertise thinking for a custom-named alias without adaptiveThinking or a thinking capability', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'okapi-alias': {
provider: 'anthropic',
model: 'coding-model-okapi-0527-vibe',
maxContextSize: 200000,
},
},
},
model: 'okapi-alias',
});

expect(resolved.modelCapabilities.thinking).toBe(false);
});

it('rejects provider model names that are not configured aliases', () => {
expect(() =>
resolveRuntimeProvider({
Expand Down Expand Up @@ -281,6 +328,57 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => {
});
expect('defaultMaxTokens' in resolved.provider).toBe(false);
});

it('forwards alias.adaptiveThinking to the anthropic provider config', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'okapi-alias': {
provider: 'anthropic',
model: 'coding-model-okapi-0527-vibe',
maxContextSize: 200000,
adaptiveThinking: true,
},
},
},
model: 'okapi-alias',
});

expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'coding-model-okapi-0527-vibe',
adaptiveThinking: true,
});
});

it('omits adaptiveThinking when alias.adaptiveThinking is unset', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'opus-alias': {
provider: 'anthropic',
model: 'claude-opus-4-7',
maxContextSize: 200000,
},
},
},
model: 'opus-alias',
});

expect('adaptiveThinking' in resolved.provider).toBe(false);
});
});

describe('resolveRuntimeProvider Kimi request headers', () => {
Expand Down
31 changes: 22 additions & 9 deletions packages/kosong/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export interface AnthropicOptions {
metadata?: Record<string, string> | undefined;
/** Use streaming API. Defaults to true. Set to false for non-streaming (test/fallback). */
stream?: boolean | undefined;
/**
* Explicitly declare whether the model supports adaptive thinking
* (`thinking: { type: 'adaptive' }`), overriding the model-name version
* inference. Useful for custom-named endpoints whose model name does not
* encode a parseable Claude version. Leave undefined to infer from the name.
*/
adaptiveThinking?: boolean | undefined;
clientFactory?: (auth: ProviderRequestAuth) => Anthropic;
}

Expand Down Expand Up @@ -285,22 +292,22 @@ function isOpus47(model: string): boolean {
return version.major === 4 && version.minor === 7;
}

function supportsEffortParam(model: string): boolean {
if (supportsAdaptiveThinking(model)) {
function supportsEffortParam(model: string, adaptive: boolean): boolean {
if (adaptive) {
return true;
}
const normalized = model.toLowerCase();
return normalized.includes('opus-4-5') || normalized.includes('opus-4.5');
}

function clampEffort(effort: ThinkingEffort, model: string): ThinkingEffort {
function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): ThinkingEffort {
if (effort === 'off') {
return effort;
}
if (effort === 'xhigh' && !isOpus47(model)) {
return 'high';
}
if (effort === 'max' && !supportsAdaptiveThinking(model)) {
if (effort === 'max' && !adaptive) {
return 'high';
}
return effort;
Expand Down Expand Up @@ -807,11 +814,13 @@ export class AnthropicChatProvider implements ChatProvider {
private _baseUrl: string | undefined;
private _defaultHeaders: Record<string, string> | undefined;
private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined;
private _adaptiveThinking: boolean | undefined;

constructor(options: AnthropicOptions) {
this._model = options.model;
this._stream = options.stream ?? true;
this._metadata = options.metadata;
this._adaptiveThinking = options.adaptiveThinking;
const apiKey = options.apiKey ?? process.env['ANTHROPIC_API_KEY'];
this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey;
this._baseUrl = options.baseUrl;
Expand Down Expand Up @@ -1020,9 +1029,13 @@ export class AnthropicChatProvider implements ChatProvider {
}

withThinking(effort: ThinkingEffort): AnthropicChatProvider {
// Resolve once: an explicit `adaptiveThinking` option overrides the
// model-name version inference, so custom-named endpoints can opt in/out.
const adaptive = this._adaptiveThinking ?? supportsAdaptiveThinking(this._model);

if (effort === 'off') {
let newBetas = [...(this._generationKwargs.betaFeatures ?? [])];
if (supportsAdaptiveThinking(this._model)) {
if (adaptive) {
newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA);
}
const clone = this._withGenerationKwargs({
Expand All @@ -1033,14 +1046,14 @@ export class AnthropicChatProvider implements ChatProvider {
return clone;
}

const effectiveEffort = clampEffort(effort, this._model);
const effectiveEffort = clampEffort(effort, this._model, adaptive);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve xhigh when adaptive is forced

When adaptiveThinking=true is used for the documented custom-named endpoint case, xhigh still goes through clampEffort with the opaque model name and is downgraded to high because isOpus47(model) cannot recognize the backing model. That makes KIMI_MODEL_ADAPTIVE_THINKING=true fail to fully override name-based inference for custom Claude Opus 4.7/4.8 endpoints that support xhigh, even though max is preserved; the override needs a way to keep xhigh for known-capable custom endpoints or document that it cannot.

Useful? React with 👍 / 👎.

if (effectiveEffort === 'off') {
throw new Error('Non-off thinking effort unexpectedly clamped to off.');
}

let newBetas = [...(this._generationKwargs.betaFeatures ?? [])];

if (supportsAdaptiveThinking(this._model)) {
if (adaptive) {
newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA);
return this._withGenerationKwargs({
thinking: { type: 'adaptive', display: 'summarized' },
Expand All @@ -1053,13 +1066,13 @@ export class AnthropicChatProvider implements ChatProvider {
thinking: { type: 'enabled', budget_tokens: budgetTokensForEffort(effectiveEffort) },
betaFeatures: newBetas,
};
if (supportsEffortParam(this._model)) {
if (supportsEffortParam(this._model, adaptive)) {
kwargs.output_config = { effort: effectiveEffort };
} else {
kwargs.output_config = undefined;
}
const clone = this._withGenerationKwargs(kwargs);
if (!supportsEffortParam(this._model)) {
if (!supportsEffortParam(this._model, adaptive)) {
delete clone._generationKwargs.output_config;
}
return clone;
Expand Down
Loading
Loading