From e01ac3b8bfe88162b4c854bc71715b4829797ead Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 13 Jul 2026 21:03:17 +0800 Subject: [PATCH 1/7] fix: preserve provider thinking effort values --- .changeset/wise-otters-think.md | 5 + docs/en/configuration/config-files.md | 4 +- docs/zh/configuration/config-files.md | 4 +- .../src/app/llmProtocol/errors.ts | 29 +++- .../src/app/llmProtocol/provider.ts | 7 + .../app/llmProtocol/providers/anthropic.ts | 158 ++++++----------- .../src/app/llmProtocol/providers/kimi.ts | 6 + .../llmProtocol/providers/openai-common.ts | 49 +----- .../llmProtocol/providers/openai-legacy.ts | 16 +- .../llmProtocol/providers/openai-responses.ts | 17 +- .../src/app/model/modelResolverService.ts | 5 +- .../test/app/model/modelResolver.test.ts | 38 ++++ .../protocol/protocolAdapterRegistry.test.ts | 19 ++ .../src/session/provider-manager.ts | 3 + packages/kosong/src/errors.ts | 30 +++- packages/kosong/src/provider.ts | 6 +- packages/kosong/src/providers/anthropic.ts | 164 ++++++------------ packages/kosong/src/providers/kimi.ts | 10 +- .../kosong/src/providers/openai-common.ts | 51 +----- .../kosong/src/providers/openai-legacy.ts | 16 +- .../kosong/src/providers/openai-responses.ts | 17 +- packages/kosong/test/anthropic.test.ts | 159 +++++++++++------ packages/kosong/test/kimi.test.ts | 3 +- .../kosong/test/openai-common-errors.test.ts | 64 +------ packages/kosong/test/openai-legacy.test.ts | 52 +++++- packages/kosong/test/openai-responses.test.ts | 36 +++- 26 files changed, 499 insertions(+), 469 deletions(-) create mode 100644 .changeset/wise-otters-think.md diff --git a/.changeset/wise-otters-think.md b/.changeset/wise-otters-think.md new file mode 100644 index 0000000000..f352cff88a --- /dev/null +++ b/.changeset/wise-otters-think.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Thinking effort routing so non-Kimi providers preserve configured effort values for upstream validation, while Kimi providers only send efforts declared in `support_efforts`. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 0528a1e475..2dab9ea313 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -140,7 +140,7 @@ Each entry in the `models` table defines a model alias (the name used in `defaul | `max_context_size` | `integer` | Yes | Maximum context length in tokens; must be at least 1 | | `max_output_size` | `integer` | No | Per-request output token cap (maps to `max_tokens`). Currently only the `anthropic` provider honors it. When set for a Claude model, this explicit value overrides the built-in server-side maximum | | `capabilities` | `array` | No | Capability tags to add explicitly: `thinking`, `always_thinking`, `image_in`, `video_in`, `audio_in`, `tool_use`. Unioned with the capabilities auto-detected by the provider — entries can only be added, never removed | -| `support_efforts` | `array` | No | Thinking effort levels declared by the model catalog. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] support_efforts` instead | +| `support_efforts` | `array` | No | Thinking effort levels the model accepts. The `kimi` provider uses this list as the only set of effort values it sends. Other providers pass concrete values unchanged when their protocol has a native effort field; protocols that expose only levels or token budgets perform the required format conversion. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] support_efforts` instead | | `default_effort` | `string` | No | Default thinking effort for the model. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] default_effort` instead | | `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset | | `reasoning_key` | `string` | No | `openai` provider only. Override the field name used for reasoning content when the gateway returns it under a non-standard name; by default `reasoning_content`, `reasoning_details`, and `reasoning` are auto-detected | @@ -181,7 +181,7 @@ You can also switch models temporarily without touching the config file — by s | Field | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | Whether Thinking is enabled by default for new sessions; set to `false` to force Thinking off | -| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`); the levels actually available depend on the model's declared `support_efforts`, and unrecognized values are ignored by the provider | +| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`). Non-Kimi providers do not remap concrete effort values when the upstream protocol accepts them; if the provider rejects the value, choose one that the model supports. Protocols that expose only levels or token budgets still require format conversion. The `kimi` provider only sends efforts listed in the model's `support_efforts`; otherwise it enables Thinking without an effort and the server uses the model default | | `keep` | `string` | `"all"` | Preserved Thinking passthrough. On `kimi` it is sent as `thinking.keep`; on `anthropic` (Claude and Kimi's Anthropic-compatible mode) it is sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API; an off-value disables keep and returns to the standard endpoint). `"all"` preserves prior turns' reasoning (`reasoning_content` / Anthropic thinking blocks); set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; only injected while Thinking is on | ### Deprecated fields diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 14c9d14671..c46a4dd688 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -140,7 +140,7 @@ KIMI_BASE_URL = "https://api.moonshot.ai/v1" | `max_context_size` | `integer` | 是 | 最大上下文长度(token 数),必须 ≥ 1 | | `max_output_size` | `integer` | 否 | 单次请求的输出 token 上限(对应 `max_tokens`)。目前仅 `anthropic` 供应商读取。为 Claude 模型设置后,这个显式值会覆盖内置的服务端最大值 | | `capabilities` | `array` | 否 | 显式追加的能力标签:`thinking`、`always_thinking`、`image_in`、`video_in`、`audio_in`、`tool_use`。与供应商自动识别的能力取并集,只能追加不能移除 | -| `support_efforts` | `array` | 否 | 模型目录声明的 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] support_efforts` | +| `support_efforts` | `array` | 否 | 模型接受的 Thinking 档位。`kimi` provider 只会发送这个列表里的档位。其他 provider 在协议提供原生 effort 字段时会原样传递具体值;协议仅提供等级或 token budget 时,只做必要的格式转换。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] support_efforts` | | `default_effort` | `string` | 否 | 模型的默认 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] default_effort` | | `display_name` | `string` | 否 | UI 中显示的名称,未设时回退到 `model` | | `reasoning_key` | `string` | 否 | 仅 `openai` 供应商。当网关用非标准字段名返回推理内容时才需要设置;默认自动识别 `reasoning_content` / `reasoning_details` / `reasoning` | @@ -181,7 +181,7 @@ display_name = "Kimi for Coding (custom)" | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | 新会话是否默认开启 Thinking,设为 `false` 可强制关闭 | -| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`),实际可用等级取决于模型声明的 `support_efforts`,未识别的值会被供应商忽略 | +| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`)。非 Kimi provider 在上游协议接受具体 effort 值时不会改写该值;如果上游拒绝,请改成该模型支持的档位。协议仅提供等级或 token budget 时,仍需做格式转换。`kimi` provider 只会发送模型 `support_efforts` 中声明的档位;否则只开启 Thinking、不带 effort,由服务端使用模型默认值 | | `keep` | `string` | `"all"` | 保留思考透传。在 `kimi` 上以 `thinking.keep` 发送;在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API;关值可禁用 keep 并回到标准端点)。`"all"` 会保留历史轮次的思考内容(`reasoning_content` / Anthropic thinking blocks);传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅在 Thinking 开启时注入 | ### 已废弃字段 diff --git a/packages/agent-core-v2/src/app/llmProtocol/errors.ts b/packages/agent-core-v2/src/app/llmProtocol/errors.ts index 371f592e73..3223c9740d 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/errors.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/errors.ts @@ -192,6 +192,29 @@ const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ /request (?:body )?too large/, ] as const; +const THINKING_EFFORT_CONFIG_DOCS_URL = + 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; + +const THINKING_EFFORT_STATUS_MESSAGE_PATTERNS = [ + /reasoning[_ .-]?effort/, + /thinking[_ .-]?effort/, + /output_config[\s\S]*effort/, + /unsupported[\s\S]*effort/, + /invalid[\s\S]*effort/, +] as const; + +function appendThinkingEffortConfigHint(statusCode: number, message: string): string { + if (statusCode !== 400 && statusCode !== 422) return message; + const lowerMessage = message.toLowerCase(); + if (!THINKING_EFFORT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage))) { + return message; + } + if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; + return `${message} + +The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +} + export function isContextOverflowErrorCode(code: string | null | undefined): boolean { return code === 'context_length_exceeded'; } @@ -215,7 +238,11 @@ export function normalizeAPIStatusError( if (isProviderOverloadStatusError(statusCode, message)) { return new APIProviderOverloadedError(statusCode, message, requestId); } - return new APIStatusError(statusCode, message, requestId); + return new APIStatusError( + statusCode, + appendThinkingEffortConfigHint(statusCode, message), + requestId, + ); } export function isContextOverflowStatusError(statusCode: number, message: string): boolean { diff --git a/packages/agent-core-v2/src/app/llmProtocol/provider.ts b/packages/agent-core-v2/src/app/llmProtocol/provider.ts index 2eac04abfd..55bd4b60ef 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/provider.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/provider.ts @@ -2,6 +2,13 @@ import type { Message, StreamedMessagePart, VideoURLPart } from './message'; import type { Tool } from './tool'; import type { TokenUsage } from './usage'; +/** + * Thinking effort passed to `ChatProvider.withThinking`. + * + * `'off'` and `'on'` are local control signals. Other strings are concrete + * model effort values; non-Kimi providers pass them to the upstream API + * verbatim, while Kimi sends only values declared by `support_efforts`. + */ export type ThinkingEffort = 'off' | 'on' | (string & {}); export type JsonSchemaObject = Record; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index f6fcfc8459..56960faedd 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -95,6 +95,8 @@ export interface AnthropicOptions { * encode a parseable Claude version. Leave undefined to infer from the name. */ adaptiveThinking?: boolean | undefined; + /** Kimi-managed endpoint effort set. When present, only declared values send. */ + supportEfforts?: readonly string[]; /** * Use the Anthropic **beta** Messages API (`client.beta.messages.create`, * `POST /v1/messages?beta=true`) instead of the standard Messages API. @@ -122,13 +124,12 @@ interface AnthropicContextManagement { edits: Array<{ type: string; keep?: unknown }>; } -type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; - const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; -const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/; const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const; +const THINKING_EFFORT_CONFIG_DOCS_URL = + 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), maxLength: 64, @@ -320,15 +321,6 @@ export function resolveDefaultMaxTokens(model: string, override?: number): numbe return override === undefined ? ceiling : Math.min(override, ceiling); } -function parseVersion(match: RegExpExecArray): { major: number; minor: number } { - const majorRaw = match[1]; - const minorRaw = match[2]; - if (majorRaw === undefined || minorRaw === undefined) { - throw new Error('Model version regex did not capture major and minor versions.'); - } - return { major: Number.parseInt(majorRaw, 10), minor: Number.parseInt(minorRaw, 10) }; -} - function versionAtLeast( version: { major: number; minor: number }, minimum: { major: number; minor: number }, @@ -352,15 +344,6 @@ function supportsAdaptiveThinking(model: string): boolean { ); } -function isOpus47(model: string): boolean { - const match = OPUS_VERSION_RE.exec(model.toLowerCase()); - if (match === null) { - return false; - } - const version = parseVersion(match); - return version.major === 4 && version.minor === 7; -} - function isFableModel(model: string): boolean { return parseClaudeAliasVersion(model)?.family === 'fable'; } @@ -373,43 +356,22 @@ function supportsEffortParam(model: string, adaptive: boolean): boolean { return normalized.includes('opus-4-5') || normalized.includes('opus-4.5'); } -function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): ThinkingEffort { - if (effort === 'off') { - return effort; - } - if (effort === 'xhigh' && !isOpus47(model) && !isFableModel(model)) { - return 'high'; - } - if (effort === 'max' && !adaptive) { - return 'high'; - } - if ( - effort !== 'low' && - effort !== 'medium' && - effort !== 'high' && - effort !== 'xhigh' && - effort !== 'max' - ) { - return 'high'; - } - return effort; -} - function budgetTokensForEffort(effort: ThinkingEffort): number { switch (effort) { case 'low': return 1024; case 'medium': return 4096; + case 'on': case 'high': return 32_000; - case 'off': - case 'xhigh': - case 'max': - throw new Error(`Unsupported budget-based thinking effort: ${effort}`); + default: + throw new Error( + `Anthropic budget-based thinking cannot express effort "${effort}". Use low, medium, or high, or configure an adaptive / effort-param-capable model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`, + ); } - throw new Error(`Unknown thinking effort: ${String(effort)}`); } + const CACHE_CONTROL = { type: 'ephemeral' as const }; type CacheableBlock = ContentBlockParam & { cache_control?: { type: 'ephemeral' } }; @@ -958,6 +920,7 @@ export class AnthropicChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; + private readonly _supportEfforts: readonly string[]; private _betaApi: boolean; private _explicitMaxTokens: boolean; @@ -966,6 +929,7 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; + this._supportEfforts = options.supportEfforts ?? []; this._betaApi = options.betaApi ?? false; this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; @@ -985,35 +949,18 @@ export class AnthropicChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - const thinkingConfig = this._generationKwargs.thinking; - if (thinkingConfig === undefined || thinkingConfig === null) { - return null; - } - if (thinkingConfig.type === 'disabled') { - return 'off'; - } - if (thinkingConfig.type === 'adaptive') { - const effort = this._generationKwargs.output_config?.effort; - if (effort === undefined || effort === null) { - return 'high'; - } - switch (effort) { - case 'low': - case 'medium': - case 'high': - case 'xhigh': - case 'max': - return effort; - } - } - // budget-based - const budget = (thinkingConfig as { budget_tokens?: number }).budget_tokens ?? 0; - if (budget <= 1024) { - return 'low'; - } - if (budget <= 4096) { - return 'medium'; - } + const thinking = this._generationKwargs.thinking; + if (thinking === undefined || thinking === null) return null; + if (thinking.type === 'disabled') return 'off'; + + const effort = this._generationKwargs.output_config?.effort; + if (typeof effort === 'string' && effort.length > 0) return effort; + if (thinking.type === 'adaptive') return 'on'; + + const budget = (thinking as { budget_tokens?: number }).budget_tokens; + if (budget === undefined) return 'on'; + if (budget <= 1024) return 'low'; + if (budget <= 4096) return 'medium'; return 'high'; } @@ -1257,48 +1204,41 @@ export class AnthropicChatProvider implements ChatProvider { // 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); + let thinking: MessageCreateParams['thinking']; + let outputConfig: MessageCreateParams['output_config'] | undefined; if (effort === 'off') { - let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; - if (adaptive) { - newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); - } - const clone = this._withGenerationKwargs({ - thinking: { type: 'disabled' }, - betaFeatures: newBetas, - }); - delete clone._generationKwargs.output_config; - return clone; - } - - const clamped = clampEffort(effort, this._model, adaptive); - if (clamped === 'off') { - throw new Error('Non-off thinking effort unexpectedly clamped to off.'); + thinking = { type: 'disabled' }; + } else if (this._supportEfforts.length > 0) { + thinking = { type: 'enabled' } as MessageCreateParams['thinking']; + outputConfig = this._supportEfforts.includes(effort) + ? ({ effort } as MessageCreateParams['output_config']) + : undefined; + } else if (adaptive) { + thinking = { type: 'adaptive', display: 'summarized' }; + outputConfig = + effort === 'on' + ? undefined + : ({ effort } as MessageCreateParams['output_config']); + } else { + thinking = { type: 'enabled', budget_tokens: budgetTokensForEffort(effort) }; + outputConfig = + supportsEffortParam(this._model, adaptive) && effort !== 'on' + ? ({ effort } as MessageCreateParams['output_config']) + : undefined; } - const effectiveEffort = clamped as AnthropicEffort; let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; - if (adaptive) { newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); - return this._withGenerationKwargs({ - thinking: { type: 'adaptive', display: 'summarized' }, - output_config: { effort: effectiveEffort }, - betaFeatures: newBetas, - }); } - - const kwargs: Partial = { - thinking: { type: 'enabled', budget_tokens: budgetTokensForEffort(effectiveEffort) }, + const clone = this._withGenerationKwargs({ + thinking, betaFeatures: newBetas, - }; - if (supportsEffortParam(this._model, adaptive)) { - kwargs.output_config = { effort: effectiveEffort }; + }); + if (outputConfig !== undefined) { + clone._generationKwargs.output_config = outputConfig; } else { - kwargs.output_config = undefined; - } - const clone = this._withGenerationKwargs(kwargs); - if (!supportsEffortParam(this._model, adaptive)) { delete clone._generationKwargs.output_config; } return clone; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index 66ce209697..b954ae7e3b 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -47,6 +47,9 @@ export interface KimiOptions { stream?: boolean | undefined; defaultHeaders?: Record | undefined; generationKwargs?: GenerationKwargs | undefined; + /** Efforts the model advertises (e.g. ["low", "high", "max"]). When + * present and non-empty, withThinking sends the chosen effort only when it + * is in this set; otherwise only thinking.type is sent. */ supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -532,6 +535,9 @@ export class KimiChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): KimiChatProvider { + // Only efforts the endpoint declares via `support_efforts` go on the wire. + // When the request is not declared, omit effort and let Kimi apply the + // model's default effort. let thinking: ThinkingConfig; if (effort === 'off') { thinking = { type: 'disabled' }; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts index 25e18e52d6..8100a47736 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts @@ -7,7 +7,7 @@ import { } from '../errors'; import { extractText } from '../message'; import type { ContentPart, Message } from '../message'; -import type { FinishReason, ThinkingEffort } from '../provider'; +import type { FinishReason } from '../provider'; import type { Tool } from '../tool'; import type { TokenUsage } from '../usage'; import { @@ -145,53 +145,6 @@ export function isFunctionToolCall( ): tc is T & FunctionToolCallShape { return tc.type === 'function'; } -/** - * Map kosong `ThinkingEffort` to OpenAI `reasoning_effort` string. - */ -export function thinkingEffortToReasoningEffort(effort: ThinkingEffort): string | undefined { - switch (effort) { - case 'off': - return undefined; - case 'low': - return 'low'; - case 'medium': - return 'medium'; - case 'high': - return 'high'; - case 'xhigh': - case 'max': - return 'xhigh'; - default: - return undefined; - } -} - -/** - * Map OpenAI `reasoning_effort` string back to kosong `ThinkingEffort`. - */ -export function reasoningEffortToThinkingEffort( - reasoning: string | undefined, -): ThinkingEffort | null { - if (reasoning === undefined || reasoning === null) { - return null; - } - switch (reasoning) { - case 'low': - case 'minimal': - return 'low'; - case 'medium': - return 'medium'; - case 'high': - return 'high'; - case 'xhigh': - case 'max': - return 'xhigh'; - case 'none': - return 'off'; - default: - return 'off'; - } -} /** * Extract `TokenUsage` from an OpenAI-compatible usage object. */ diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts index 4b7ada386d..5b1d9044e1 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts @@ -25,8 +25,6 @@ import { TOOL_RESULT_MEDIA_PLACEHOLDER, TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, - reasoningEffortToThinkingEffort, - thinkingEffortToReasoningEffort, toolToOpenAI, } from './openai-common'; import { @@ -85,6 +83,8 @@ export interface OpenAILegacyOptions { httpClient?: unknown; defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; + /** Kimi-managed endpoint effort set. When present, only declared values send. */ + supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -470,6 +470,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _reasoningKey: string | undefined; private _reasoningEffort: string | undefined; + private readonly _supportEfforts: readonly string[]; private _generationKwargs: OpenAILegacyGenerationKwargs; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; @@ -493,6 +494,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { ? normalizedReasoningKey : undefined; this._reasoningEffort = undefined; + this._supportEfforts = options.supportEfforts ?? []; this._generationKwargs = options.maxTokens !== undefined ? completionTokenKwargs(this._model, options.maxTokens) : {}; this._toolMessageConversion = options.toolMessageConversion ?? null; @@ -507,7 +509,8 @@ export class OpenAILegacyChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - return reasoningEffortToThinkingEffort(this._reasoningEffort); + if (this._reasoningEffort === undefined) return null; + return this._reasoningEffort === 'none' ? 'off' : this._reasoningEffort; } get maxCompletionTokens(): number | undefined { @@ -608,7 +611,12 @@ export class OpenAILegacyChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAILegacyChatProvider { - const reasoningEffort = thinkingEffortToReasoningEffort(effort); + const reasoningEffort = + effort === 'off' || + effort === 'on' || + (this._supportEfforts.length > 0 && !this._supportEfforts.includes(effort)) + ? undefined + : effort; const clone = this._clone(); clone._reasoningEffort = reasoningEffort; return clone; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index f392dacd84..a9bf2c1899 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -26,8 +26,6 @@ import { TOOL_RESULT_MEDIA_PLACEHOLDER, TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, - reasoningEffortToThinkingEffort, - thinkingEffortToReasoningEffort, } from './openai-common'; import { mergeRequestHeaders, @@ -342,6 +340,8 @@ export interface OpenAIResponsesOptions { httpClient?: unknown; defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; + /** Kimi-managed endpoint effort set. When present, only declared values send. */ + supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -1002,6 +1002,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; private _generationKwargs: OpenAIResponsesGenerationKwargs; + private readonly _supportEfforts: readonly string[]; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; private _httpClient: unknown; @@ -1015,6 +1016,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._model = options.model; this._stream = true; // Responses API always supports streaming this._generationKwargs = {}; + this._supportEfforts = options.supportEfforts ?? []; this._toolMessageConversion = options.toolMessageConversion ?? null; this._httpClient = options.httpClient; this._clientFactory = options.clientFactory; @@ -1031,7 +1033,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - return reasoningEffortToThinkingEffort(this._generationKwargs.reasoning_effort); + const effort = this._generationKwargs.reasoning_effort; + if (effort === undefined) return null; + return effort === 'none' ? 'off' : effort; } get maxCompletionTokens(): number | undefined { @@ -1124,7 +1128,12 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAIResponsesChatProvider { - const reasoningEffort = thinkingEffortToReasoningEffort(effort); + const reasoningEffort = + effort === 'off' || + effort === 'on' || + (this._supportEfforts.length > 0 && !this._supportEfforts.includes(effort)) + ? undefined + : effort; const clone = this._clone(); clone._generationKwargs = { ...clone._generationKwargs, diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 00d0deea19..dabe174e10 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -376,7 +376,6 @@ function buildProtocolProviderOptions( break; } case 'kimi': - if (model.supportEfforts !== undefined) options.supportEfforts = model.supportEfforts; break; case 'vertexai': { const project = vertexAIProject(provider); @@ -395,6 +394,10 @@ function buildProtocolProviderOptions( } } + if (provider?.type === 'kimi' && model.supportEfforts !== undefined) { + options.supportEfforts = model.supportEfforts; + } + return Object.values(options).some((value) => value !== undefined) ? options : undefined; diff --git a/packages/agent-core-v2/test/app/model/modelResolver.test.ts b/packages/agent-core-v2/test/app/model/modelResolver.test.ts index 7fc9eaf64c..34a7ea196a 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver.test.ts @@ -629,6 +629,44 @@ describe('ModelResolverService', () => { }); }); + it('does not pass supportEfforts through for non-Kimi providers', async () => { + providers['p'] = { type: 'anthropic', baseUrl: 'https://example.test', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'kimi-for-coding', + maxContextSize: 1000, + supportEfforts: ['low', 'high', 'max'], + }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ + protocol: 'anthropic', + }); + const providerOptions = config?.['providerOptions'] as + | { readonly supportEfforts?: readonly string[] } + | undefined; + expect(providerOptions?.supportEfforts).toBeUndefined(); + }); + + it('passes Kimi supportEfforts through when Kimi uses the Anthropic protocol', async () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + protocol: 'anthropic', + model: 'kimi-for-coding', + maxContextSize: 1000, + supportEfforts: ['low', 'high', 'max'], + }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ + protocol: 'anthropic', + providerOptions: { supportEfforts: ['low', 'high', 'max'] }, + }); + }); + it('passes Vertex service-account options and derives location from the baseUrl', async () => { providers['p'] = { type: 'vertexai', diff --git a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts index f5be6ed65d..91cdd4b90f 100644 --- a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts +++ b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts @@ -70,6 +70,10 @@ describe('ProtocolAdapterRegistry', () => { extra_body: { thinking: { type: 'enabled' } }, }); expect(provider.withThinking('medium').thinkingEffort).toBe('on'); + expect(Reflect.get(provider.withThinking('xhigh'), '_generationKwargs')).toEqual({ + extra_body: { thinking: { type: 'enabled' } }, + }); + expect(provider.withThinking('xhigh').thinkingEffort).toBe('on'); expect( Reflect.get(provider.withThinking('high').withThinking('off'), '_generationKwargs'), ).toEqual({ @@ -78,6 +82,21 @@ describe('ProtocolAdapterRegistry', () => { expect(provider.withThinking('high').withThinking('off').thinkingEffort).toBe('off'); }); + it('maps supportEfforts into OpenAI provider config', () => { + const provider = new ProtocolAdapterRegistry().createChatProvider({ + protocol: 'openai', + baseUrl: 'https://example.test/v1', + modelName: 'kimi-for-coding', + apiKey: 'sk', + providerOptions: { supportEfforts: ['low', 'high', 'max'] }, + }); + + expect(Reflect.get(provider.withThinking('max'), '_reasoningEffort')).toBe('max'); + expect(provider.withThinking('max').thinkingEffort).toBe('max'); + expect(Reflect.get(provider.withThinking('medium'), '_reasoningEffort')).toBeUndefined(); + expect(provider.withThinking('medium').thinkingEffort).toBeNull(); + }); + it('maps providerOptions into Vertex provider config', () => { const provider = new ProtocolAdapterRegistry().createChatProvider({ protocol: 'vertexai', diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 71f3e7e022..da06917654 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -271,6 +271,7 @@ function toKosongProviderConfig( ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), ...(betaApi !== undefined ? { betaApi } : {}), + ...(provider.type === 'kimi' && supportEfforts !== undefined ? { supportEfforts } : {}), // Session affinity: Anthropic's analog of OpenAI `prompt_cache_key` is // `metadata.user_id` on the Messages API (cache-affinity / end-user id). ...(promptCacheKey !== undefined ? { metadata: { user_id: promptCacheKey } } : {}), @@ -295,6 +296,7 @@ function toKosongProviderConfig( baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), reasoningKey, + ...(provider.type === 'kimi' && supportEfforts !== undefined ? { supportEfforts } : {}), ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), @@ -333,6 +335,7 @@ function toKosongProviderConfig( model, baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), + ...(provider.type === 'kimi' && supportEfforts !== undefined ? { supportEfforts } : {}), ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 770228b789..44aa249375 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -297,6 +297,29 @@ const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ /request (?:body )?too large/, ] as const; +const THINKING_EFFORT_CONFIG_DOCS_URL = + 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; + +const THINKING_EFFORT_STATUS_MESSAGE_PATTERNS = [ + /reasoning[_ .-]?effort/, + /thinking[_ .-]?effort/, + /output_config[\s\S]*effort/, + /unsupported[\s\S]*effort/, + /invalid[\s\S]*effort/, +] as const; + +function appendThinkingEffortConfigHint(statusCode: number, message: string): string { + if (statusCode !== 400 && statusCode !== 422) return message; + const lowerMessage = message.toLowerCase(); + if (!THINKING_EFFORT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage))) { + return message; + } + if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; + return `${message} + +The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +} + export function isContextOverflowErrorCode(code: string | null | undefined): boolean { return code === 'context_length_exceeded'; } @@ -318,7 +341,12 @@ export function normalizeAPIStatusError( if (isRequestTooLargeStatusError(statusCode, message)) { return new APIRequestTooLargeError(statusCode, message, requestId, retryAfterMs); } - return new APIStatusError(statusCode, message, requestId, retryAfterMs); + return new APIStatusError( + statusCode, + appendThinkingEffortConfigHint(statusCode, message), + requestId, + retryAfterMs, + ); } /** diff --git a/packages/kosong/src/provider.ts b/packages/kosong/src/provider.ts index 995e79bf76..9b4227eca6 100644 --- a/packages/kosong/src/provider.ts +++ b/packages/kosong/src/provider.ts @@ -30,9 +30,9 @@ export type ResponseFormat = JsonObjectResponseFormat | JsonSchemaResponseFormat * `string` at runtime; it exists purely as a semantic marker that a value is * expected to be `'off'`, `'on'`, or a model-declared effort. * - * The model's `support_efforts` is the single source of truth for which - * efforts are valid — providers normalize any unrecognized effort by omitting - * the effort on the wire rather than rejecting it. + * Kimi's native provider uses the model's `support_efforts` as the set of + * effort values it may send. Non-Kimi providers pass concrete effort strings + * through to their upstream API and let the provider validate them. */ export type ThinkingEffort = 'off' | 'on' | (string & {}); diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 54733d060e..b75d8a4d47 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -96,6 +96,8 @@ export interface AnthropicOptions { * encode a parseable Claude version. Leave undefined to infer from the name. */ adaptiveThinking?: boolean | undefined; + /** Kimi-managed endpoint effort set. When present, only declared values send. */ + supportEfforts?: readonly string[] | undefined; /** * Use the Anthropic **beta** Messages API (`client.beta.messages.create`, * `POST /v1/messages?beta=true`) instead of the standard Messages API. @@ -129,16 +131,12 @@ interface AnthropicContextManagement { edits: Array<{ type: string; keep?: unknown }>; } -// Anthropic's native effort values. `ThinkingEffort` is an open string, so after -// clamping (and ruling out 'off') we narrow to this concrete set before writing -// `output_config.effort` / computing a token budget. -type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; - const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; -const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/; const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const; +const THINKING_EFFORT_CONFIG_DOCS_URL = + 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), maxLength: 64, @@ -330,15 +328,6 @@ export function resolveDefaultMaxTokens(model: string, override?: number): numbe return override === undefined ? ceiling : Math.min(override, ceiling); } -function parseVersion(match: RegExpExecArray): { major: number; minor: number } { - const majorRaw = match[1]; - const minorRaw = match[2]; - if (majorRaw === undefined || minorRaw === undefined) { - throw new Error('Model version regex did not capture major and minor versions.'); - } - return { major: Number.parseInt(majorRaw, 10), minor: Number.parseInt(minorRaw, 10) }; -} - function versionAtLeast( version: { major: number; minor: number }, minimum: { major: number; minor: number }, @@ -362,15 +351,6 @@ function supportsAdaptiveThinking(model: string): boolean { ); } -function isOpus47(model: string): boolean { - const match = OPUS_VERSION_RE.exec(model.toLowerCase()); - if (match === null) { - return false; - } - const version = parseVersion(match); - return version.major === 4 && version.minor === 7; -} - function isFableModel(model: string): boolean { return parseClaudeAliasVersion(model)?.family === 'fable'; } @@ -383,46 +363,22 @@ function supportsEffortParam(model: string, adaptive: boolean): boolean { return normalized.includes('opus-4-5') || normalized.includes('opus-4.5'); } -function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): ThinkingEffort { - if (effort === 'off') { - return effort; - } - if (effort === 'xhigh' && !isOpus47(model) && !isFableModel(model)) { - return 'high'; - } - if (effort === 'max' && !adaptive) { - return 'high'; - } - // 'on' (boolean models) or any effort Anthropic does not recognize: fall - // back to 'high' so budgetTokensForEffort / output_config.effort never see - // an unsupported value. - if ( - effort !== 'low' && - effort !== 'medium' && - effort !== 'high' && - effort !== 'xhigh' && - effort !== 'max' - ) { - return 'high'; - } - return effort; -} - function budgetTokensForEffort(effort: ThinkingEffort): number { switch (effort) { case 'low': return 1024; case 'medium': return 4096; + case 'on': case 'high': return 32_000; - case 'off': - case 'xhigh': - case 'max': - throw new Error(`Unsupported budget-based thinking effort: ${effort}`); + default: + throw new Error( + `Anthropic budget-based thinking cannot express effort "${effort}". Use low, medium, or high, or configure an adaptive / effort-param-capable model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`, + ); } - throw new Error(`Unknown thinking effort: ${String(effort)}`); } + const CACHE_CONTROL = { type: 'ephemeral' as const }; type CacheableBlock = ContentBlockParam & { cache_control?: { type: 'ephemeral' } }; @@ -996,6 +952,7 @@ export class AnthropicChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; + private readonly _supportEfforts: readonly string[]; private _betaApi: boolean; private _explicitMaxTokens: boolean; @@ -1004,6 +961,7 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; + this._supportEfforts = options.supportEfforts ?? []; this._betaApi = options.betaApi ?? false; this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; @@ -1023,35 +981,18 @@ export class AnthropicChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - const thinkingConfig = this._generationKwargs.thinking; - if (thinkingConfig === undefined || thinkingConfig === null) { - return null; - } - if (thinkingConfig.type === 'disabled') { - return 'off'; - } - if (thinkingConfig.type === 'adaptive') { - const effort = this._generationKwargs.output_config?.effort; - if (effort === undefined || effort === null) { - return 'high'; - } - switch (effort) { - case 'low': - case 'medium': - case 'high': - case 'xhigh': - case 'max': - return effort; - } - } - // budget-based - const budget = (thinkingConfig as { budget_tokens?: number }).budget_tokens ?? 0; - if (budget <= 1024) { - return 'low'; - } - if (budget <= 4096) { - return 'medium'; - } + const thinking = this._generationKwargs.thinking; + if (thinking === undefined || thinking === null) return null; + if (thinking.type === 'disabled') return 'off'; + + const effort = this._generationKwargs.output_config?.effort; + if (typeof effort === 'string' && effort.length > 0) return effort; + if (thinking.type === 'adaptive') return 'on'; + + const budget = (thinking as { budget_tokens?: number }).budget_tokens; + if (budget === undefined) return 'on'; + if (budget <= 1024) return 'low'; + if (budget <= 4096) return 'medium'; return 'high'; } @@ -1305,48 +1246,41 @@ export class AnthropicChatProvider implements ChatProvider { // 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); + let thinking: MessageCreateParams['thinking']; + let outputConfig: MessageCreateParams['output_config'] | undefined; if (effort === 'off') { - let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; - if (adaptive) { - newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); - } - const clone = this._withGenerationKwargs({ - thinking: { type: 'disabled' }, - betaFeatures: newBetas, - }); - delete clone._generationKwargs.output_config; - return clone; - } - - const clamped = clampEffort(effort, this._model, adaptive); - if (clamped === 'off') { - throw new Error('Non-off thinking effort unexpectedly clamped to off.'); + thinking = { type: 'disabled' }; + } else if (this._supportEfforts.length > 0) { + thinking = { type: 'enabled' } as MessageCreateParams['thinking']; + outputConfig = this._supportEfforts.includes(effort) + ? ({ effort } as MessageCreateParams['output_config']) + : undefined; + } else if (adaptive) { + thinking = { type: 'adaptive', display: 'summarized' }; + outputConfig = + effort === 'on' + ? undefined + : ({ effort } as MessageCreateParams['output_config']); + } else { + thinking = { type: 'enabled', budget_tokens: budgetTokensForEffort(effort) }; + outputConfig = + supportsEffortParam(this._model, adaptive) && effort !== 'on' + ? ({ effort } as MessageCreateParams['output_config']) + : undefined; } - const effectiveEffort = clamped as AnthropicEffort; let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; - if (adaptive) { newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); - return this._withGenerationKwargs({ - thinking: { type: 'adaptive', display: 'summarized' }, - output_config: { effort: effectiveEffort }, - betaFeatures: newBetas, - }); } - - const kwargs: Partial = { - thinking: { type: 'enabled', budget_tokens: budgetTokensForEffort(effectiveEffort) }, + const clone = this._withGenerationKwargs({ + thinking, betaFeatures: newBetas, - }; - if (supportsEffortParam(this._model, adaptive)) { - kwargs.output_config = { effort: effectiveEffort }; + }); + if (outputConfig !== undefined) { + clone._generationKwargs.output_config = outputConfig; } else { - kwargs.output_config = undefined; - } - const clone = this._withGenerationKwargs(kwargs); - if (!supportsEffortParam(this._model, adaptive)) { delete clone._generationKwargs.output_config; } return clone; diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index 44f63268c6..dcb794af91 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -48,8 +48,8 @@ export interface KimiOptions { defaultHeaders?: Record | undefined; generationKwargs?: GenerationKwargs | undefined; /** Efforts the model advertises (e.g. ["low", "high", "max"]). When - * present and non-empty, withThinking sends the chosen effort on the wire; - * when absent/empty, only thinking.type is sent. */ + * present and non-empty, withThinking sends the chosen effort only when it + * is in this set; otherwise only thinking.type is sent. */ supportEfforts?: readonly string[] | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -549,13 +549,13 @@ export class KimiChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): KimiChatProvider { + // Only efforts the endpoint declares via `support_efforts` go on the wire. + // When the request is not declared, omit effort and let Kimi apply the + // model's default effort. let thinking: ThinkingConfig; if (effort === 'off') { thinking = { type: 'disabled' }; } else { - // Only efforts the model explicitly declares via `support_efforts` are - // sent on the wire. When `support_efforts` is absent/empty, or the - // requested effort is not declared, only thinking.type is sent. thinking = this._supportEfforts.includes(effort) ? { type: 'enabled', effort } : { type: 'enabled' }; diff --git a/packages/kosong/src/providers/openai-common.ts b/packages/kosong/src/providers/openai-common.ts index 2541b11e54..5bf9be7fa2 100644 --- a/packages/kosong/src/providers/openai-common.ts +++ b/packages/kosong/src/providers/openai-common.ts @@ -8,7 +8,7 @@ import { } from '#/errors'; import { extractText } from '#/message'; import type { ContentPart, Message } from '#/message'; -import type { FinishReason, ThinkingEffort } from '#/provider'; +import type { FinishReason } from '#/provider'; import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; import { @@ -151,56 +151,7 @@ export function isFunctionToolCall( ): tc is T & FunctionToolCallShape { return tc.type === 'function'; } -/** - * Map kosong `ThinkingEffort` to OpenAI `reasoning_effort` string. - */ -export function thinkingEffortToReasoningEffort(effort: ThinkingEffort): string | undefined { - switch (effort) { - case 'off': - return undefined; - case 'low': - return 'low'; - case 'medium': - return 'medium'; - case 'high': - return 'high'; - case 'xhigh': - case 'max': - return 'xhigh'; - default: - // 'on' (boolean models) or any model-declared effort OpenAI does not - // recognize: send no reasoning_effort and let the model use its own - // default, rather than throwing on a value the model itself advertised. - return undefined; - } -} -/** - * Map OpenAI `reasoning_effort` string back to kosong `ThinkingEffort`. - */ -export function reasoningEffortToThinkingEffort( - reasoning: string | undefined, -): ThinkingEffort | null { - if (reasoning === undefined || reasoning === null) { - return null; - } - switch (reasoning) { - case 'low': - case 'minimal': - return 'low'; - case 'medium': - return 'medium'; - case 'high': - return 'high'; - case 'xhigh': - case 'max': - return 'xhigh'; - case 'none': - return 'off'; - default: - return 'off'; - } -} /** * Extract `TokenUsage` from an OpenAI-compatible usage object. */ diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 35d759051c..aa28876df4 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -25,8 +25,6 @@ import { TOOL_RESULT_MEDIA_PLACEHOLDER, TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, - reasoningEffortToThinkingEffort, - thinkingEffortToReasoningEffort, toolToOpenAI, } from './openai-common'; import { @@ -100,6 +98,8 @@ export interface OpenAILegacyOptions { httpClient?: unknown; defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; + /** Kimi-managed endpoint effort set. When present, only declared values send. */ + supportEfforts?: readonly string[] | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -483,6 +483,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _reasoningKey: string | undefined; private _reasoningEffort: string | undefined; + private readonly _supportEfforts: readonly string[]; private _generationKwargs: OpenAILegacyGenerationKwargs; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; @@ -506,6 +507,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { ? normalizedReasoningKey : undefined; this._reasoningEffort = undefined; + this._supportEfforts = options.supportEfforts ?? []; this._generationKwargs = options.maxTokens !== undefined ? completionTokenKwargs(this._model, options.maxTokens) : {}; this._toolMessageConversion = options.toolMessageConversion ?? null; @@ -520,7 +522,8 @@ export class OpenAILegacyChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - return reasoningEffortToThinkingEffort(this._reasoningEffort); + if (this._reasoningEffort === undefined) return null; + return this._reasoningEffort === 'none' ? 'off' : this._reasoningEffort; } get modelParameters(): Record { @@ -617,7 +620,12 @@ export class OpenAILegacyChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAILegacyChatProvider { - const reasoningEffort = thinkingEffortToReasoningEffort(effort); + const reasoningEffort = + effort === 'off' || + effort === 'on' || + (this._supportEfforts.length > 0 && !this._supportEfforts.includes(effort)) + ? undefined + : effort; const clone = this._clone(); clone._reasoningEffort = reasoningEffort; return clone; diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 78bda29316..be104e27f7 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -26,8 +26,6 @@ import { TOOL_RESULT_MEDIA_PLACEHOLDER, TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, - reasoningEffortToThinkingEffort, - thinkingEffortToReasoningEffort, } from './openai-common'; import { mergeRequestHeaders, @@ -349,6 +347,8 @@ export interface OpenAIResponsesOptions { httpClient?: unknown; defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; + /** Kimi-managed endpoint effort set. When present, only declared values send. */ + supportEfforts?: readonly string[] | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -1018,6 +1018,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; private _generationKwargs: OpenAIResponsesGenerationKwargs; + private readonly _supportEfforts: readonly string[]; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; private _httpClient: unknown; @@ -1031,6 +1032,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._model = options.model; this._stream = true; // Responses API always supports streaming this._generationKwargs = {}; + this._supportEfforts = options.supportEfforts ?? []; this._toolMessageConversion = options.toolMessageConversion ?? null; this._httpClient = options.httpClient; this._clientFactory = options.clientFactory; @@ -1047,7 +1049,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - return reasoningEffortToThinkingEffort(this._generationKwargs.reasoning_effort); + const effort = this._generationKwargs.reasoning_effort; + if (effort === undefined) return null; + return effort === 'none' ? 'off' : effort; } get modelParameters(): Record { @@ -1136,7 +1140,12 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAIResponsesChatProvider { - const reasoningEffort = thinkingEffortToReasoningEffort(effort); + const reasoningEffort = + effort === 'off' || + effort === 'on' || + (this._supportEfforts.length > 0 && !this._supportEfforts.includes(effort)) + ? undefined + : effort; const clone = this._clone(); clone._generationKwargs = { ...clone._generationKwargs, diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index ca90054399..a2190c759f 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1567,12 +1567,12 @@ describe('AnthropicChatProvider', () => { expect(body['output_config']).toEqual({ effort: 'high' }); }); - it('future 4.6+ model uses adaptive thinking and clamps xhigh to high', async () => { + it('future 4.6+ model uses adaptive thinking and passes xhigh through', async () => { const provider = createProvider('claude-sonnet-4-8').withThinking('xhigh'); const body = await captureRequestBody(provider, '', [], thinkHistory); expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); - expect(body['output_config']).toEqual({ effort: 'high' }); + expect(body['output_config']).toEqual({ effort: 'xhigh' }); }); it('opus-4-6 supports max effort', async () => { @@ -1597,7 +1597,7 @@ describe('AnthropicChatProvider', () => { expect(body['output_config']).toEqual({ effort: 'high' }); }); - it('forced adaptive allows max effort without clamping to high', async () => { + it('forced adaptive passes max effort through', async () => { const provider = new AnthropicChatProvider({ model: 'coding-model-okapi-0527-vibe', apiKey: 'test-key', @@ -1624,6 +1624,65 @@ describe('AnthropicChatProvider', () => { expect(body['output_config']).toBeUndefined(); }); + it('declared support_efforts win over model-name heuristics (Kimi max)', async () => { + // Kimi's Anthropic-compatible endpoint declares ["low","high","max"]: + // max must reach the wire verbatim — no adaptive + // inference, no budget_tokens. + const provider = new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + supportEfforts: ['low', 'high', 'max'], + }).withThinking('max'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'enabled' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('declared support_efforts omit undeclared efforts', async () => { + const provider = new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + supportEfforts: ['low', 'high', 'max'], + }); + for (const requested of ['xhigh', 'medium', 'on'] as const) { + const body = await captureRequestBody(provider.withThinking(requested), '', [], thinkHistory); + expect(body['thinking']).toEqual({ type: 'enabled' }); + expect(body['output_config']).toBeUndefined(); + } + }); + + it('declared support_efforts keep thinking off clean', async () => { + const provider = new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + supportEfforts: ['low', 'high', 'max'], + }).withThinking('off'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'disabled' }); + expect(body['output_config']).toBeUndefined(); + }); + + it('thinkingEffort reads back declared efforts and boolean enabled fallback', () => { + const provider = new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + supportEfforts: ['low', 'high', 'max'], + }); + expect(provider.withThinking('max').thinkingEffort).toBe('max'); + expect(provider.withThinking('xhigh').thinkingEffort).toBe('on'); + expect(provider.withThinking('off').thinkingEffort).toBe('off'); + }); + it('adaptiveThinking=false forces budget on a 4.6 model name', async () => { const provider = new AnthropicChatProvider({ model: 'claude-opus-4-6', @@ -1638,22 +1697,18 @@ describe('AnthropicChatProvider', () => { expect(body['output_config']).toBeUndefined(); }); - it('pre-4.6 model clamps xhigh and max to high without output_config', async () => { + it('pre-4.6 budget model rejects xhigh and max', () => { for (const effort of ['xhigh', 'max'] as const) { - const provider = createProvider('claude-sonnet-4-5').withThinking(effort); - const body = await captureRequestBody(provider, '', [], thinkHistory); - - expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); - expect(body['output_config']).toBeUndefined(); + expect(() => createProvider('claude-sonnet-4-5').withThinking(effort)).toThrow( + /budget-based thinking cannot express effort/, + ); } }); - it('opus-4-5 sends legacy budget thinking with clamped effort output_config', async () => { - const provider = createProvider('claude-opus-4-5').withThinking('xhigh'); - const body = await captureRequestBody(provider, '', [], thinkHistory); - - expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); - expect(body['output_config']).toEqual({ effort: 'high' }); + it('opus-4-5 rejects xhigh', () => { + expect(() => createProvider('claude-opus-4-5').withThinking('xhigh')).toThrow( + /budget-based thinking cannot express effort/, + ); }); it('opus-4-6 with thinking off -> disabled', async () => { @@ -1715,11 +1770,10 @@ describe('AnthropicChatProvider', () => { const body = await captureRequestBody(provider, '', [], thinkHistory); expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); - // xhigh is opus-4-7-only; clamps to high on future 4.8 until proven otherwise - expect(body['output_config']).toEqual({ effort: 'high' }); + expect(body['output_config']).toEqual({ effort: 'xhigh' }); }); - it('opus-4-7 + high stays high without clamping', async () => { + it('opus-4-7 + high stays high', async () => { const provider = createProvider('claude-opus-4-7').withThinking('high'); const body = await captureRequestBody(provider, '', [], thinkHistory); @@ -1749,11 +1803,10 @@ describe('AnthropicChatProvider', () => { ['claude-opus-4-7', 'high', 'high'], ['claude-opus-4-7', 'xhigh', 'xhigh'], ['claude-opus-4-7', 'max', 'max'], - // pre-4.7 opus: xhigh and max clamp to high/max respectively (xhigh -> high, max passes since adaptive) - ['claude-opus-4-6', 'xhigh', 'high'], + ['claude-opus-4-6', 'xhigh', 'xhigh'], ['claude-opus-4-6', 'max', 'max'], ] as const)( - 'clampEffort wire body: %s + %s -> output_config.effort=%s', + 'adaptive wire body: %s + %s -> output_config.effort=%s', async (model, effort, expected) => { const provider = createProvider(model).withThinking(effort); const body = await captureRequestBody(provider, '', [], thinkHistory); @@ -1762,7 +1815,7 @@ describe('AnthropicChatProvider', () => { }, ); - it('clampEffort wire body: sonnet-4-5 (non-adaptive) has no output_config', async () => { + it('legacy wire body: sonnet-4-5 (non-adaptive) has no output_config', async () => { const provider = createProvider('claude-sonnet-4-5').withThinking('high'); const body = await captureRequestBody(provider, '', [], thinkHistory); @@ -1885,36 +1938,31 @@ describe('AnthropicChatProvider', () => { }); }); - // Effort clamping per model capability: adaptive-capable models - // pass max effort through, others cap at high. - describe('clamp effort matrix', () => { + // Effort handling per model capability: adaptive-capable models pass + // concrete efforts through; legacy budget models can only express + // low/medium/high. + describe('effort matrix', () => { it.each([ - // Opus 4.7: full range including xhigh and max ['claude-opus-4-7', 'low', 'low'], ['claude-opus-4-7', 'medium', 'medium'], ['claude-opus-4-7', 'high', 'high'], ['claude-opus-4-7', 'xhigh', 'xhigh'], ['claude-opus-4-7', 'max', 'max'], ['claude-opus-4-7-20260301', 'xhigh', 'xhigh'], - // Opus 4.6: max supported, xhigh clamps to high ['claude-opus-4-6', 'max', 'max'], - ['claude-opus-4-6', 'xhigh', 'high'], + ['claude-opus-4-6', 'xhigh', 'xhigh'], ['claude-opus-4-6-20260205', 'max', 'max'], - // Sonnet 4.6 ['claude-sonnet-4-6', 'max', 'max'], - ['claude-sonnet-4-6', 'xhigh', 'high'], - // low/medium/high passthrough + ['claude-sonnet-4-6', 'xhigh', 'xhigh'], ['claude-opus-4-6', 'medium', 'medium'], - // Fable 5: full range including xhigh and max ['claude-fable-5', 'xhigh', 'xhigh'], ['claude-fable-5', 'max', 'max'], - // Future 4.8+: inherits max but xhigh clamps to high - ['claude-opus-4-8', 'xhigh', 'high'], + ['claude-opus-4-8', 'xhigh', 'xhigh'], ['claude-opus-4-8', 'max', 'max'], ['claude-opus-5-0', 'max', 'max'], - ['claude-opus-5-0', 'xhigh', 'high'], + ['claude-opus-5-0', 'xhigh', 'xhigh'], ] as const)( - 'clamp adaptive: %s + %s -> effort=%s', + 'adaptive pass-through: %s + %s -> effort=%s', async (model, effort, expected) => { const provider = createProvider(model).withThinking(effort); const body = await captureRequestBody(provider, '', [], thinkHistory); @@ -1923,26 +1971,33 @@ describe('AnthropicChatProvider', () => { }, ); - // Pre-4.6 non-adaptive models: effort clamps in legacy budget mode. - // output_config presence depends on _supports_effort_param; opus-4-5 - // supports effort, sonnet/haiku-4 do not. it.each([ - ['claude-opus-4-5', 'max', 'high', true], - ['claude-opus-4-5', 'xhigh', 'high', true], - ['claude-opus-4-5', 'high', 'high', true], - ['claude-sonnet-4-20250514', 'max', 'high', false], - ['claude-sonnet-4-20250514', 'xhigh', 'high', false], - ['claude-sonnet-4-20250514', 'low', 'low', false], - ['claude-sonnet-4-5', 'xhigh', 'high', false], - ['claude-haiku-4-5', 'max', 'high', false], + ['claude-opus-4-5', 'max'], + ['claude-opus-4-5', 'xhigh'], + ['claude-sonnet-4-20250514', 'max'], + ['claude-sonnet-4-20250514', 'xhigh'], + ['claude-sonnet-4-5', 'xhigh'], + ['claude-haiku-4-5', 'max'], ] as const)( - 'clamp legacy: %s + %s -> effort=%s (supports=%s)', - async (model, effort, expected, supports) => { + 'legacy budget rejects unsupported effort: %s + %s', + (model, effort) => { + expect(() => createProvider(model).withThinking(effort)).toThrow( + /budget-based thinking cannot express effort/, + ); + }, + ); + + it.each([ + ['claude-opus-4-5', 'high', true], + ['claude-sonnet-4-20250514', 'low', false], + ] as const)( + 'legacy budget accepts supported effort: %s + %s (supports=%s)', + async (model, effort, supports) => { const provider = createProvider(model).withThinking(effort); const body = await captureRequestBody(provider, '', [], thinkHistory); if (supports) { - expect(body['output_config']).toEqual({ effort: expected }); + expect(body['output_config']).toEqual({ effort }); } else { expect(body['output_config']).toBeUndefined(); } @@ -2053,9 +2108,9 @@ describe('AnthropicChatProvider', () => { expect(max.thinkingEffort).toBe('max'); }); - it('reports clamped adaptive effort', () => { + it('reports adaptive effort verbatim', () => { const provider = createProvider('claude-sonnet-4-6').withThinking('xhigh'); - expect(provider.thinkingEffort).toBe('high'); + expect(provider.thinkingEffort).toBe('xhigh'); }); it('pre-4.6 budget-based efforts', () => { diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index ff9577754e..f214971c13 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -805,7 +805,8 @@ describe('KimiChatProvider', () => { it('effort-capable model omits effort for efforts not declared in support_efforts', async () => { // 'xhigh' / 'on' / 'foo' are not in ['low', 'high', 'max'], so the - // provider normalizes them to "enabled, no effort" instead of rejecting. + // provider sends enabled thinking without an effort and lets Kimi use + // the model default. for (const effort of ['xhigh', 'on', 'foo']) { const provider = createProvider(false, ['low', 'high', 'max']).withThinking(effort); const history: Message[] = [ diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index 7f4501ad70..f8cd126e9d 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -6,13 +6,12 @@ import { APITimeoutError, ChatProviderError, isRetryableGenerateError, + normalizeAPIStatusError, } from '#/errors'; import type { ContentPart } from '#/message'; import { convertContentPart, convertOpenAIError, - reasoningEffortToThinkingEffort, - thinkingEffortToReasoningEffort, } from '#/providers/openai-common'; import { OpenAILegacyChatProvider, OpenAILegacyStreamedMessage } from '#/providers/openai-legacy'; import { @@ -340,59 +339,14 @@ describe('convertContentPart', () => { expect(() => convertContentPart(bogus)).toThrow(/Unknown content part type/); }); }); -describe('thinkingEffortToReasoningEffort', () => { - it('maps off -> undefined', () => { - expect(thinkingEffortToReasoningEffort('off')).toBeUndefined(); - }); - it('maps low -> "low"', () => { - expect(thinkingEffortToReasoningEffort('low')).toBe('low'); - }); - it('maps medium -> "medium"', () => { - expect(thinkingEffortToReasoningEffort('medium')).toBe('medium'); - }); - it('maps high -> "high"', () => { - expect(thinkingEffortToReasoningEffort('high')).toBe('high'); - }); - it('maps xhigh -> "xhigh"', () => { - expect(thinkingEffortToReasoningEffort('xhigh')).toBe('xhigh'); - }); - it('maps max -> "xhigh"', () => { - expect(thinkingEffortToReasoningEffort('max')).toBe('xhigh'); - }); - it('normalizes unknown effort to undefined', () => { - // Unknown / model-declared efforts (including 'on') are tolerated: the - // provider omits reasoning_effort and lets the model use its own default. - expect(thinkingEffortToReasoningEffort('extreme' as never)).toBeUndefined(); - }); -}); -describe('reasoningEffortToThinkingEffort', () => { - it('returns null for undefined', () => { - const effort: string | undefined = undefined; - expect(reasoningEffortToThinkingEffort(effort)).toBeNull(); - }); - it('maps "low" -> low', () => { - expect(reasoningEffortToThinkingEffort('low')).toBe('low'); - }); - it('maps "minimal" -> low (alias)', () => { - expect(reasoningEffortToThinkingEffort('minimal')).toBe('low'); - }); - it('maps "medium" -> medium', () => { - expect(reasoningEffortToThinkingEffort('medium')).toBe('medium'); - }); - it('maps "high" -> high', () => { - expect(reasoningEffortToThinkingEffort('high')).toBe('high'); - }); - it('maps "xhigh" -> xhigh', () => { - expect(reasoningEffortToThinkingEffort('xhigh')).toBe('xhigh'); - }); - it('maps "max" -> xhigh (alias)', () => { - expect(reasoningEffortToThinkingEffort('max')).toBe('xhigh'); - }); - it('maps "none" -> off', () => { - expect(reasoningEffortToThinkingEffort('none')).toBe('off'); - }); - it('unknown values fall back to off', () => { - expect(reasoningEffortToThinkingEffort('ultra')).toBe('off'); +describe('normalizeAPIStatusError thinking effort guidance', () => { + it('adds configuration guidance when a provider rejects reasoning_effort', () => { + const error = normalizeAPIStatusError(400, 'Invalid reasoning_effort: xhigh'); + + expect(error.message).toContain('Non-Kimi providers receive effort strings'); + expect(error.message).toContain( + 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking', + ); }); }); describe('convertOpenAIError: non-Error values', () => { diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 1356261b3a..be6568e019 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -27,6 +27,7 @@ function createProvider( stream: boolean; reasoningKey: string; model: string; + supportEfforts: readonly string[]; }>, ): OpenAILegacyChatProvider { return new OpenAILegacyChatProvider({ @@ -34,6 +35,7 @@ function createProvider( apiKey: 'test-key', stream: options?.stream ?? false, reasoningKey: options?.reasoningKey, + supportEfforts: options?.supportEfforts, }); } @@ -923,7 +925,7 @@ describe('OpenAILegacyChatProvider', () => { }, ); - it('.withThinking("max") maps to xhigh without model-specific clamping', async () => { + it('.withThinking("max") passes max through verbatim', async () => { const history: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, ]; @@ -947,9 +949,51 @@ describe('OpenAILegacyChatProvider', () => { history, ); - expect(openAIChatModel['reasoning_effort']).toBe('xhigh'); - expect(openAIProModel['reasoning_effort']).toBe('xhigh'); - expect(deepSeekModel['reasoning_effort']).toBe('xhigh'); + expect(openAIChatModel['reasoning_effort']).toBe('max'); + expect(openAIProModel['reasoning_effort']).toBe('max'); + expect(deepSeekModel['reasoning_effort']).toBe('max'); + }); + + it('passes max through verbatim', async () => { + const provider = createProvider({ model: 'kimi-for-coding' }).withThinking('max'); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect(body['reasoning_effort']).toBe('max'); + expect(provider.thinkingEffort).toBe('max'); + }); + + it('passes concrete effort strings through verbatim', async () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + for (const requested of ['xhigh', 'medium', 'extreme'] as const) { + const body = await captureRequestBody( + createProvider({ model: 'kimi-for-coding' }).withThinking(requested), + '', + [], + history, + ); + expect(body['reasoning_effort']).toBe(requested); + } + }); + + it('omits undeclared efforts for Kimi-style supportEfforts', async () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + const provider = createProvider({ + model: 'kimi-for-coding', + supportEfforts: ['low', 'high', 'max'], + }); + + const maxBody = await captureRequestBody(provider.withThinking('max'), '', [], history); + const xhighBody = await captureRequestBody(provider.withThinking('xhigh'), '', [], history); + + expect(maxBody['reasoning_effort']).toBe('max'); + expect(xhighBody['reasoning_effort']).toBeUndefined(); }); }); diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 7b13edcb50..f2c711fb62 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -1041,9 +1041,7 @@ describe('OpenAIResponsesChatProvider', () => { expect(body['reasoning']).toEqual({ effort: 'xhigh', summary: 'auto' }); }); - it('with_thinking("max") on gpt-5.1-codex-max clamps up to xhigh on the wire', async () => { - // Regression guard: "max" used to fall back to "high"; for OpenAI it - // must clamp up to their highest supported effort, xhigh. + it('with_thinking("max") passes max through to the wire', async () => { const provider = new OpenAIResponsesChatProvider({ model: 'gpt-5.1-codex-max', apiKey: 'test-key', @@ -1053,7 +1051,37 @@ describe('OpenAIResponsesChatProvider', () => { ]; const body = await captureRequestBody(provider, '', [], history); - expect((body['reasoning'] as Record)['effort']).toBe('xhigh'); + expect((body['reasoning'] as Record)['effort']).toBe('max'); + }); + + it('passes concrete effort strings through verbatim', async () => { + const provider = new OpenAIResponsesChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + }).withThinking('extreme'); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect((body['reasoning'] as Record)['effort']).toBe('extreme'); + expect(provider.thinkingEffort).toBe('extreme'); + }); + + it('omits undeclared efforts for Kimi-style supportEfforts', async () => { + const provider = new OpenAIResponsesChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + supportEfforts: ['low', 'high', 'max'], + }); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + const maxBody = await captureRequestBody(provider.withThinking('max'), '', [], history); + const xhighBody = await captureRequestBody(provider.withThinking('xhigh'), '', [], history); + + expect((maxBody['reasoning'] as Record)['effort']).toBe('max'); + expect(xhighBody['reasoning']).toBeUndefined(); }); }); From 2c7ff324fc05ed388be5ef70fb10f3a4b715ee3b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 13 Jul 2026 21:48:12 +0800 Subject: [PATCH 2/7] fix: resolve Kimi thinking effort fallbacks --- .changeset/wise-otters-think.md | 2 +- apps/kimi-code/src/tui/commands/config.ts | 46 +++++--- .../tui/controllers/session-event-handler.ts | 1 + .../test/tui/kimi-tui-message-flow.test.ts | 93 +++++++++++++++- docs/en/configuration/config-files.md | 4 +- docs/zh/configuration/config-files.md | 4 +- .../src/agent/profile/profileService.ts | 28 +++-- .../src/agent/profile/thinking.ts | 19 +++- .../agent-core-v2/src/agent/usage/usageOps.ts | 1 + .../src/app/llmProtocol/provider.ts | 4 +- .../app/llmProtocol/providers/anthropic.ts | 14 +-- .../src/app/llmProtocol/providers/kimi.ts | 13 +-- .../llmProtocol/providers/openai-legacy.ts | 11 +- .../llmProtocol/providers/openai-responses.ts | 11 +- .../agent-core-v2/src/app/model/modelImpl.ts | 4 + .../src/app/model/modelInstance.ts | 1 + .../src/app/model/modelResolverService.ts | 14 ++- .../agent-core-v2/src/app/model/thinking.ts | 52 +++++++-- .../src/app/protocol/protocol.ts | 2 +- .../fullCompaction/fullCompaction.test.ts | 8 +- .../agent/llmRequester/llmRequester.test.ts | 12 +- .../test/agent/profile/binding.test.ts | 12 +- .../test/agent/profile/config-state.test.ts | 48 +++++++- .../test/agent/profile/thinking.test.ts | 45 +++++++- .../test/app/config/config.test.ts | 2 +- .../test/app/model/modelResolver.test.ts | 30 +++-- .../protocol/protocolAdapterRegistry.test.ts | 21 ++-- packages/agent-core/src/agent/config/index.ts | 105 +++++++++++++----- .../agent-core/src/agent/config/thinking.ts | 53 ++++++++- packages/agent-core/src/agent/index.ts | 5 +- .../src/session/provider-manager.ts | 11 +- .../test/agent/compaction/full.test.ts | 8 +- .../test/agent/config-state.test.ts | 73 ++++++++++-- packages/agent-core/test/agent/config.test.ts | 6 +- .../test/agent/config/thinking.test.ts | 33 +++++- .../test/agent/llm-request-recorder.test.ts | 11 +- .../test/config/kimi-env-params.test.ts | 4 +- .../test/harness/runtime-provider.test.ts | 8 +- packages/kosong/src/provider.ts | 6 +- packages/kosong/src/providers/anthropic.ts | 14 +-- packages/kosong/src/providers/kimi.ts | 13 +-- .../kosong/src/providers/openai-legacy.ts | 11 +- .../kosong/src/providers/openai-responses.ts | 11 +- packages/kosong/test/anthropic.test.ts | 25 ++--- packages/kosong/test/kimi.test.ts | 49 ++++---- packages/kosong/test/openai-legacy.test.ts | 7 +- packages/kosong/test/openai-responses.test.ts | 5 +- packages/protocol/src/events.ts | 2 + 48 files changed, 670 insertions(+), 292 deletions(-) diff --git a/.changeset/wise-otters-think.md b/.changeset/wise-otters-think.md index f352cff88a..2ece900b63 100644 --- a/.changeset/wise-otters-think.md +++ b/.changeset/wise-otters-think.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix Thinking effort routing so non-Kimi providers preserve configured effort values for upstream validation, while Kimi providers only send efforts declared in `support_efforts`. +Fix Thinking effort routing so non-Kimi providers preserve configured values for upstream validation, while Kimi models validate runtime selections, fall back safely during model resolution, and synchronize the effective effort back to clients. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9d95974193..2d86fa180c 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -403,7 +403,8 @@ async function performModelSwitch( const modelChanged = alias !== prevModel; const effortChanged = effort !== prevEffort; const runtimeChanged = modelChanged || effortChanged; - const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + let effectiveAlias = alias; + let effectiveEffort = effort; const session = host.session; try { @@ -416,6 +417,9 @@ async function performModelSwitch( if (effort !== prevEffort) { await session.setThinking(effort); } + const status = await session.getStatus(); + effectiveAlias = status.model ?? alias; + effectiveEffort = status.thinkingEffort; } } catch (error) { const msg = formatErrorMessage(error); @@ -423,15 +427,25 @@ async function performModelSwitch( return; } - host.setAppState({ model: alias, thinkingEffort: effort }); + if (session === undefined) { + effectiveAlias = host.state.appState.model; + effectiveEffort = host.state.appState.thinkingEffort; + } + const effectiveModelChanged = effectiveAlias !== prevModel; + const effectiveEffortChanged = effectiveEffort !== prevEffort; + const displayName = modelDisplayName( + effectiveAlias, + host.state.appState.availableModels[effectiveAlias], + ); + host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort }); if (session === undefined && runtimeChanged) { - if (alias !== prevModel) { - host.track('model_switch', { model: alias }); + if (effectiveModelChanged) { + host.track('model_switch', { model: effectiveAlias }); } - if (effort !== prevEffort) { + if (effectiveEffortChanged) { host.track('thinking_toggle', { - enabled: effort !== 'off', - effort, + enabled: effectiveEffort !== 'off', + effort: effectiveEffort, from: prevEffort, }); } @@ -440,7 +454,7 @@ async function performModelSwitch( let persisted = false; if (persist) { try { - persisted = await persistModelSelection(host, alias, effort); + persisted = await persistModelSelection(host, effectiveAlias, effectiveEffort); } catch (error) { const msg = formatErrorMessage(error); host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); @@ -449,18 +463,18 @@ async function performModelSwitch( } let status: string; - if (modelChanged) { + if (effectiveModelChanged) { status = persist - ? `Switched to ${displayName} with thinking ${effort}.` - : `Switched to ${displayName} with thinking ${effort} for this session only.`; - } else if (effortChanged) { + ? `Switched to ${displayName} with thinking ${effectiveEffort}.` + : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`; + } else if (effectiveEffortChanged) { status = persist - ? `Thinking set to ${effort}.` - : `Thinking set to ${effort} for this session only.`; + ? `Thinking set to ${effectiveEffort}.` + : `Thinking set to ${effectiveEffort} for this session only.`; } else if (persist && persisted) { - status = `Saved ${displayName} with thinking ${effort} as default.`; + status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`; } else { - status = `Already using ${displayName} with thinking ${effort}.`; + status = `Already using ${displayName} with thinking ${effectiveEffort}.`; } host.showStatus(status, 'success'); } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 626116f957..82257c86cf 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -615,6 +615,7 @@ export class SessionEventHandler { patch.permissionMode = event.permission; } if (event.model !== undefined) patch.model = event.model; + if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort; if (Object.keys(patch).length > 0) this.host.setAppState(patch); if (event.swarmMode === false) { this.host.state.swarmModeEntry = undefined; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index ef18d45de9..a67f17abdb 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -139,6 +139,8 @@ function makeStartupInput(): KimiTUIStartupInput { } function makeSession(overrides: Record = {}) { + let model = 'k2'; + let thinkingEffort = 'off'; return { id: 'ses-1', model: 'k2', @@ -151,8 +153,8 @@ function makeSession(overrides: Record = {}) { cancel: vi.fn(async () => {}), cancelCompaction: vi.fn(async () => {}), getStatus: vi.fn(async () => ({ - model: 'k2', - thinkingEffort: 'off', + model, + thinkingEffort, permission: 'manual', planMode: false, contextTokens: 0, @@ -162,8 +164,12 @@ function makeSession(overrides: Record = {}) { getGoal: vi.fn(async () => ({ goal: null })), setApprovalHandler: vi.fn(), setQuestionHandler: vi.fn(), - setModel: vi.fn(async () => {}), - setThinking: vi.fn(async () => {}), + setModel: vi.fn(async (alias: string) => { + model = alias; + }), + setThinking: vi.fn(async (effort: string) => { + thinkingEffort = effort; + }), setPermission: vi.fn(async () => {}), setPlanMode: vi.fn(async () => {}), setSwarmMode: vi.fn(async () => {}), @@ -2768,6 +2774,24 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); }); + it('applies the effective thinking effort from status updates', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + model: 'turbo', + thinkingEffort: 'mid', + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('mid'); + }); + it('renders swarm mode markers from /swarm commands, not tool-triggered status updates', async () => { const { driver } = await makeDriver(); @@ -4369,6 +4393,67 @@ command = "vim" expect(driver.state.appState.thinkingEffort).toBe('on'); }); + it('uses the effective effort returned after a model-switch fallback', async () => { + let switched = false; + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: switched ? 'turbo' : 'k2', + thinkingEffort: switched ? 'mid' : 'ultra', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setModel: vi.fn(async () => { + switched = true; + }), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'ultra'], + defaultEffort: 'ultra', + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'mid', 'high'], + defaultEffort: 'mid', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'ultra' }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: true, effort: 'mid' }, + }); + }); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('mid'); + expect(renderTranscript(driver)).toContain('Switched to kimi-turbo with thinking mid.'); + }); + it('persists /model selection even when runtime state is unchanged', async () => { const session = makeSession(); const setConfig = vi.fn(async () => ({ providers: {} })); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 2dab9ea313..fc44dee1e3 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -140,7 +140,7 @@ Each entry in the `models` table defines a model alias (the name used in `defaul | `max_context_size` | `integer` | Yes | Maximum context length in tokens; must be at least 1 | | `max_output_size` | `integer` | No | Per-request output token cap (maps to `max_tokens`). Currently only the `anthropic` provider honors it. When set for a Claude model, this explicit value overrides the built-in server-side maximum | | `capabilities` | `array` | No | Capability tags to add explicitly: `thinking`, `always_thinking`, `image_in`, `video_in`, `audio_in`, `tool_use`. Unioned with the capabilities auto-detected by the provider — entries can only be added, never removed | -| `support_efforts` | `array` | No | Thinking effort levels the model accepts. The `kimi` provider uses this list as the only set of effort values it sends. Other providers pass concrete values unchanged when their protocol has a native effort field; protocols that expose only levels or token budgets perform the required format conversion. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] support_efforts` instead | +| `support_efforts` | `array` | No | Thinking effort levels the model accepts. For `kimi`, selecting another value at runtime fails; when model resolution carries an unsupported configured or previous value, the session falls back to the target model's `default_effort` and reports that effective value to the UI. A Thinking-capable Kimi model without this field uses boolean `on` / `off`. Other providers pass concrete values unchanged when their protocol has a native effort field; protocols that expose only levels or token budgets perform the required format conversion. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] support_efforts` instead | | `default_effort` | `string` | No | Default thinking effort for the model. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] default_effort` instead | | `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset | | `reasoning_key` | `string` | No | `openai` provider only. Override the field name used for reasoning content when the gateway returns it under a non-standard name; by default `reasoning_content`, `reasoning_details`, and `reasoning` are auto-detected | @@ -181,7 +181,7 @@ You can also switch models temporarily without touching the config file — by s | Field | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | Whether Thinking is enabled by default for new sessions; set to `false` to force Thinking off | -| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`). Non-Kimi providers do not remap concrete effort values when the upstream protocol accepts them; if the provider rejects the value, choose one that the model supports. Protocols that expose only levels or token budgets still require format conversion. The `kimi` provider only sends efforts listed in the model's `support_efforts`; otherwise it enables Thinking without an effort and the server uses the model default | +| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`). Non-Kimi providers do not remap concrete effort values when the upstream protocol accepts them; if the provider rejects the value, choose one that the model supports. Protocols that expose only levels or token budgets still require format conversion. Kimi models with `support_efforts` fall back to their model default when this configured value is not listed; Kimi models without that list treat every enabled value as boolean `on` | | `keep` | `string` | `"all"` | Preserved Thinking passthrough. On `kimi` it is sent as `thinking.keep`; on `anthropic` (Claude and Kimi's Anthropic-compatible mode) it is sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API; an off-value disables keep and returns to the standard endpoint). `"all"` preserves prior turns' reasoning (`reasoning_content` / Anthropic thinking blocks); set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; only injected while Thinking is on | ### Deprecated fields diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index c46a4dd688..336f63a314 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -140,7 +140,7 @@ KIMI_BASE_URL = "https://api.moonshot.ai/v1" | `max_context_size` | `integer` | 是 | 最大上下文长度(token 数),必须 ≥ 1 | | `max_output_size` | `integer` | 否 | 单次请求的输出 token 上限(对应 `max_tokens`)。目前仅 `anthropic` 供应商读取。为 Claude 模型设置后,这个显式值会覆盖内置的服务端最大值 | | `capabilities` | `array` | 否 | 显式追加的能力标签:`thinking`、`always_thinking`、`image_in`、`video_in`、`audio_in`、`tool_use`。与供应商自动识别的能力取并集,只能追加不能移除 | -| `support_efforts` | `array` | 否 | 模型接受的 Thinking 档位。`kimi` provider 只会发送这个列表里的档位。其他 provider 在协议提供原生 effort 字段时会原样传递具体值;协议仅提供等级或 token budget 时,只做必要的格式转换。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] support_efforts` | +| `support_efforts` | `array` | 否 | 模型接受的 Thinking 档位。对 `kimi` 而言,在运行时选择列表外的值会报错;模型解析时若配置值或之前的值不受目标模型支持,会回落到目标模型的 `default_effort`,并将该有效值同步给 UI。支持 Thinking 但没有此字段的 Kimi 模型使用布尔 `on` / `off`。其他 provider 在协议提供原生 effort 字段时会原样传递具体值;协议仅提供等级或 token budget 时,只做必要的格式转换。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] support_efforts` | | `default_effort` | `string` | 否 | 模型的默认 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] default_effort` | | `display_name` | `string` | 否 | UI 中显示的名称,未设时回退到 `model` | | `reasoning_key` | `string` | 否 | 仅 `openai` 供应商。当网关用非标准字段名返回推理内容时才需要设置;默认自动识别 `reasoning_content` / `reasoning_details` / `reasoning` | @@ -181,7 +181,7 @@ display_name = "Kimi for Coding (custom)" | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | 新会话是否默认开启 Thinking,设为 `false` 可强制关闭 | -| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`)。非 Kimi provider 在上游协议接受具体 effort 值时不会改写该值;如果上游拒绝,请改成该模型支持的档位。协议仅提供等级或 token budget 时,仍需做格式转换。`kimi` provider 只会发送模型 `support_efforts` 中声明的档位;否则只开启 Thinking、不带 effort,由服务端使用模型默认值 | +| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`)。非 Kimi provider 在上游协议接受具体 effort 值时不会改写该值;如果上游拒绝,请改成该模型支持的档位。协议仅提供等级或 token budget 时,仍需做格式转换。对于带 `support_efforts` 的 Kimi 模型,若该配置值不在列表中,会回落到模型默认档位;没有该列表的 Kimi 模型会把任意开启值视为布尔 `on` | | `keep` | `string` | `"all"` | 保留思考透传。在 `kimi` 上以 `thinking.keep` 发送;在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API;关值可禁用 keep 并回到标准端点)。`"all"` 会保留历史轮次的思考内容(`reasoning_content` / Anthropic thinking blocks);传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅在 Thinking 开启时注入 | ### 已废弃字段 diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 353b35b2a8..d247699f8e 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -37,7 +37,7 @@ import picomatch from 'picomatch'; import { ErrorCodes, Error2 } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { resolveThinkingEffort, resolveThinkingKeep } from './thinking'; +import { resolveThinkingEffort, resolveThinkingKeep, supportsThinkingEffort } from './thinking'; import type { LoopControl } from '#/agent/loop/configSection'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -64,7 +64,7 @@ import type { ProfileSetModelResult, ProfileUpdateData, } from './profile'; -import { IAgentProfileService } from './profile'; +import { IAgentProfileService, ProfileError, ProfileErrors } from './profile'; import { THINKING_SECTION, type ThinkingConfig, @@ -207,6 +207,15 @@ export class AgentProfileService implements IAgentProfileService { setThinking(level: string): void { const previousEffort = this.thinkingLevel; + const model = this.tryResolveRawModel(); + if (!supportsThinkingEffort(level as ThinkingEffort, model)) { + const efforts = model?.supportEfforts ?? []; + const supported = efforts.length === 0 ? 'off' : ['off', ...efforts].join(', '); + throw new ProfileError( + ProfileErrors.codes.MODEL_CONFIG_INVALID, + `Thinking effort "${level}" is not supported by model "${this.modelAlias}". Supported efforts: ${supported}.`, + ); + } this.update({ thinkingLevel: level }); const effort = this.thinkingLevel; if (effort !== previousEffort) { @@ -401,10 +410,12 @@ export class AgentProfileService implements IAgentProfileService { if (changed.cwd !== undefined) payload.cwd = changed.cwd; if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; - if (changed.thinkingLevel !== undefined) { - const model = this.resolveModelForThinking(changed.modelAlias); + if (changed.thinkingLevel !== undefined || changed.modelAlias !== undefined) { + const model = this.resolveModelForThinking(changed.modelAlias ?? this.modelAlias); + const requested = + changed.thinkingLevel ?? (this.modelAlias === undefined ? undefined : this.thinkingLevel); payload.thinkingEffort = resolveThinkingEffort( - changed.thinkingLevel, + requested, this.config.get(THINKING_SECTION), model, ); @@ -424,7 +435,9 @@ export class AgentProfileService implements IAgentProfileService { const protocol = this.tryResolveRawModel()?.protocol; this.telemetryContext.set({ provider_type: protocol, protocol }); } - this.emitStatusUpdated(); + this.emitStatusUpdated( + changed.modelAlias !== undefined || changed.thinkingLevel !== undefined, + ); } private setActiveTools(names: readonly string[]): void { @@ -434,7 +447,7 @@ export class AgentProfileService implements IAgentProfileService { this.wire.dispatch(setActiveTools({ names: [...names] })); } - private emitStatusUpdated(): void { + private emitStatusUpdated(includeThinkingEffort = false): void { const custom = this.optionsValue.emitStatusUpdated; if (custom !== undefined) { custom(); @@ -444,6 +457,7 @@ export class AgentProfileService implements IAgentProfileService { this.eventBus.publish({ type: 'agent.status.updated', model: this.modelAlias, + thinkingEffort: includeThinkingEffort ? this.thinkingLevel : undefined, maxContextTokens: this.getModelCapabilities().max_context_tokens, }); } diff --git a/packages/agent-core-v2/src/agent/profile/thinking.ts b/packages/agent-core-v2/src/agent/profile/thinking.ts index 93baab3a91..0cf9066b49 100644 --- a/packages/agent-core-v2/src/agent/profile/thinking.ts +++ b/packages/agent-core-v2/src/agent/profile/thinking.ts @@ -7,16 +7,29 @@ */ import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; -import { type ModelThinkingMetadata, resolveThinkingEffortForModel } from '#/app/model/thinking'; +import { + modelSupportsThinkingEffort, + type ModelThinkingMetadata, + resolveThinkingEffortForModel, +} from '#/app/model/thinking'; import type { ThinkingConfig } from './configSection'; +type ThinkingModel = ModelThinkingMetadata & { readonly providerType?: string }; + export function resolveThinkingEffort( requested: string | undefined, defaults: ThinkingConfig | undefined, - model?: ModelThinkingMetadata, + model?: ThinkingModel, ): ThinkingEffort { - return resolveThinkingEffortForModel(requested, defaults, model); + return resolveThinkingEffortForModel(requested, defaults, model, model?.providerType === 'kimi'); +} + +export function supportsThinkingEffort( + effort: ThinkingEffort, + model: ThinkingModel | undefined, +): boolean { + return modelSupportsThinkingEffort(effort, model, model?.providerType === 'kimi'); } const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index a36b10daed..e955f555f8 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -32,6 +32,7 @@ declare module '#/app/event/eventBus' { swarmMode?: boolean; planMode?: boolean; model?: string; + thinkingEffort?: string; maxContextTokens?: number; contextTokens?: number; phase?: AgentPhase; diff --git a/packages/agent-core-v2/src/app/llmProtocol/provider.ts b/packages/agent-core-v2/src/app/llmProtocol/provider.ts index 55bd4b60ef..70ef79c1d4 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/provider.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/provider.ts @@ -6,8 +6,8 @@ import type { TokenUsage } from './usage'; * Thinking effort passed to `ChatProvider.withThinking`. * * `'off'` and `'on'` are local control signals. Other strings are concrete - * model effort values; non-Kimi providers pass them to the upstream API - * verbatim, while Kimi sends only values declared by `support_efforts`. + * model effort values. Protocol adapters receive an already-resolved value and + * preserve concrete efforts when their upstream protocol has a native field. */ export type ThinkingEffort = 'off' | 'on' | (string & {}); diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index 56960faedd..2b754d0c1d 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -95,8 +95,7 @@ export interface AnthropicOptions { * encode a parseable Claude version. Leave undefined to infer from the name. */ adaptiveThinking?: boolean | undefined; - /** Kimi-managed endpoint effort set. When present, only declared values send. */ - supportEfforts?: readonly string[]; + kimiThinking?: boolean | undefined; /** * Use the Anthropic **beta** Messages API (`client.beta.messages.create`, * `POST /v1/messages?beta=true`) instead of the standard Messages API. @@ -920,7 +919,7 @@ export class AnthropicChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; - private readonly _supportEfforts: readonly string[]; + private readonly _kimiThinking: boolean; private _betaApi: boolean; private _explicitMaxTokens: boolean; @@ -929,7 +928,7 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; - this._supportEfforts = options.supportEfforts ?? []; + this._kimiThinking = options.kimiThinking ?? false; this._betaApi = options.betaApi ?? false; this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; @@ -1209,11 +1208,10 @@ export class AnthropicChatProvider implements ChatProvider { if (effort === 'off') { thinking = { type: 'disabled' }; - } else if (this._supportEfforts.length > 0) { + } else if (this._kimiThinking) { thinking = { type: 'enabled' } as MessageCreateParams['thinking']; - outputConfig = this._supportEfforts.includes(effort) - ? ({ effort } as MessageCreateParams['output_config']) - : undefined; + outputConfig = + effort === 'on' ? undefined : ({ effort } as MessageCreateParams['output_config']); } else if (adaptive) { thinking = { type: 'adaptive', display: 'summarized' }; outputConfig = diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index b954ae7e3b..4f5ab8a8ef 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -47,10 +47,6 @@ export interface KimiOptions { stream?: boolean | undefined; defaultHeaders?: Record | undefined; generationKwargs?: GenerationKwargs | undefined; - /** Efforts the model advertises (e.g. ["low", "high", "max"]). When - * present and non-empty, withThinking sends the chosen effort only when it - * is in this set; otherwise only thinking.type is sent. */ - supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -390,7 +386,6 @@ export class KimiChatProvider implements ChatProvider { private _baseUrl: string; private _defaultHeaders: Record | undefined; private _generationKwargs: GenerationKwargs; - private readonly _supportEfforts: readonly string[]; private _client: OpenAI | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; private _files: KimiFiles | undefined; @@ -404,7 +399,6 @@ export class KimiChatProvider implements ChatProvider { this._model = options.model; this._stream = options.stream ?? true; this._generationKwargs = { ...options.generationKwargs }; - this._supportEfforts = options.supportEfforts ?? []; this._client = this._apiKey === undefined ? undefined @@ -535,16 +529,11 @@ export class KimiChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): KimiChatProvider { - // Only efforts the endpoint declares via `support_efforts` go on the wire. - // When the request is not declared, omit effort and let Kimi apply the - // model's default effort. let thinking: ThinkingConfig; if (effort === 'off') { thinking = { type: 'disabled' }; } else { - thinking = this._supportEfforts.includes(effort) - ? { type: 'enabled', effort } - : { type: 'enabled' }; + thinking = effort === 'on' ? { type: 'enabled' } : { type: 'enabled', effort }; } const oldExtra = this._generationKwargs.extra_body ?? {}; const keep = oldExtra.thinking?.keep; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts index 5b1d9044e1..6bdcf91442 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts @@ -83,8 +83,6 @@ export interface OpenAILegacyOptions { httpClient?: unknown; defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; - /** Kimi-managed endpoint effort set. When present, only declared values send. */ - supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -470,7 +468,6 @@ export class OpenAILegacyChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _reasoningKey: string | undefined; private _reasoningEffort: string | undefined; - private readonly _supportEfforts: readonly string[]; private _generationKwargs: OpenAILegacyGenerationKwargs; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; @@ -494,7 +491,6 @@ export class OpenAILegacyChatProvider implements ChatProvider { ? normalizedReasoningKey : undefined; this._reasoningEffort = undefined; - this._supportEfforts = options.supportEfforts ?? []; this._generationKwargs = options.maxTokens !== undefined ? completionTokenKwargs(this._model, options.maxTokens) : {}; this._toolMessageConversion = options.toolMessageConversion ?? null; @@ -611,12 +607,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAILegacyChatProvider { - const reasoningEffort = - effort === 'off' || - effort === 'on' || - (this._supportEfforts.length > 0 && !this._supportEfforts.includes(effort)) - ? undefined - : effort; + const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort; const clone = this._clone(); clone._reasoningEffort = reasoningEffort; return clone; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index a9bf2c1899..2da81d100a 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -340,8 +340,6 @@ export interface OpenAIResponsesOptions { httpClient?: unknown; defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; - /** Kimi-managed endpoint effort set. When present, only declared values send. */ - supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -1002,7 +1000,6 @@ export class OpenAIResponsesChatProvider implements ChatProvider { private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; private _generationKwargs: OpenAIResponsesGenerationKwargs; - private readonly _supportEfforts: readonly string[]; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; private _httpClient: unknown; @@ -1016,7 +1013,6 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._model = options.model; this._stream = true; // Responses API always supports streaming this._generationKwargs = {}; - this._supportEfforts = options.supportEfforts ?? []; this._toolMessageConversion = options.toolMessageConversion ?? null; this._httpClient = options.httpClient; this._clientFactory = options.clientFactory; @@ -1128,12 +1124,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAIResponsesChatProvider { - const reasoningEffort = - effort === 'off' || - effort === 'on' || - (this._supportEfforts.length > 0 && !this._supportEfforts.includes(effort)) - ? undefined - : effort; + const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort; const clone = this._clone(); clone._generationKwargs = { ...clone._generationKwargs, diff --git a/packages/agent-core-v2/src/app/model/modelImpl.ts b/packages/agent-core-v2/src/app/model/modelImpl.ts index 3c68f457f2..3bb4995186 100644 --- a/packages/agent-core-v2/src/app/model/modelImpl.ts +++ b/packages/agent-core-v2/src/app/model/modelImpl.ts @@ -49,6 +49,7 @@ export interface ModelImplInit { readonly supportEfforts?: readonly string[]; readonly defaultEffort?: string; readonly alwaysThinking: boolean; + readonly providerType?: string; readonly providerName: string; readonly authProvider: AuthProvider; readonly protocolRegistry: ProtocolAdapterRegistry; @@ -72,6 +73,7 @@ export class ModelImpl implements Model { readonly authProvider: AuthProvider; readonly thinkingEffort: ThinkingEffort | null; readonly alwaysThinking: boolean; + readonly providerType?: string; readonly providerName: string; private readonly protocolRegistry: ProtocolAdapterRegistry; @@ -105,6 +107,7 @@ export class ModelImpl implements Model { this.providerOptions = init.providerOptions ?? {}; this.transforms = transforms; this.alwaysThinking = init.alwaysThinking; + this.providerType = init.providerType; this.providerName = init.providerName; // thinkingEffort is materialized via `withThinking` — the transform chain // owns the actual value applied to the underlying ChatProvider; we track @@ -133,6 +136,7 @@ export class ModelImpl implements Model { supportEfforts: this.supportEfforts, defaultEffort: this.defaultEffort, alwaysThinking: this.alwaysThinking, + providerType: this.providerType, providerName: this.providerName, authProvider: this.authProvider, protocolRegistry: this.protocolRegistry, diff --git a/packages/agent-core-v2/src/app/model/modelInstance.ts b/packages/agent-core-v2/src/app/model/modelInstance.ts index 6e19d07cb1..5ccd31603a 100644 --- a/packages/agent-core-v2/src/app/model/modelInstance.ts +++ b/packages/agent-core-v2/src/app/model/modelInstance.ts @@ -105,6 +105,7 @@ export interface Model { * `thinkingLevel` is `off`. */ readonly alwaysThinking: boolean; + readonly providerType?: string; /** * The config-side Provider id this Model resolves against (the entry in * `[providers.*]`). For flat-case Models, this is the origin derived from diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index dabe174e10..a08780866a 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -148,6 +148,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { supportEfforts: model.supportEfforts, defaultEffort: model.defaultEffort, alwaysThinking, + providerType: providerConfig?.type, providerName, authProvider, protocolRegistry: this.protocolRegistry as ProtocolAdapterRegistry, @@ -159,7 +160,11 @@ export class ModelResolverService extends Disposable implements IModelResolver { // same `thinking` config). Required for models whose // endpoint rejects a request that omits thinking (e.g. kimi-k2.7 over the // Anthropic protocol returns 400 unless `thinking.type === 'enabled'`). - const effort = this.resolveDefaultThinking(model, alwaysThinking); + const effort = this.resolveDefaultThinking( + model, + alwaysThinking, + providerConfig?.type === 'kimi', + ); return effort === 'off' ? impl : impl.withThinking(effort); } @@ -174,6 +179,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { private resolveDefaultThinking( model: ModelConfig, alwaysThinking: boolean, + kimiProvider: boolean, ): ThinkingEffort { const thinking = this.config.get('thinking'); return resolveThinkingEffortForModel( @@ -183,6 +189,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { effort: thinking?.effort, }, { ...model, alwaysThinking }, + kimiProvider, ); } @@ -368,6 +375,7 @@ function buildProtocolProviderOptions( case 'anthropic': if (model.maxOutputSize !== undefined) options.defaultMaxTokens = model.maxOutputSize; if (model.adaptiveThinking !== undefined) options.adaptiveThinking = model.adaptiveThinking; + if (provider?.type === 'kimi') options.kimiThinking = true; if (model.betaApi !== undefined) options.betaApi = model.betaApi; break; case 'openai': { @@ -394,10 +402,6 @@ function buildProtocolProviderOptions( } } - if (provider?.type === 'kimi' && model.supportEfforts !== undefined) { - options.supportEfforts = model.supportEfforts; - } - return Object.values(options).some((value) => value !== undefined) ? options : undefined; diff --git a/packages/agent-core-v2/src/app/model/thinking.ts b/packages/agent-core-v2/src/app/model/thinking.ts index 3c373d11bf..2be5f27079 100644 --- a/packages/agent-core-v2/src/app/model/thinking.ts +++ b/packages/agent-core-v2/src/app/model/thinking.ts @@ -55,6 +55,10 @@ function middleOf(values: readonly string[]): string { return values[Math.floor(values.length / 2)]!; } +function effortsFor(model: ModelThinkingMetadata | undefined): readonly string[] { + return model?.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined) ?? []; +} + export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): boolean { if (model === undefined) return false; return ( @@ -69,25 +73,57 @@ export function defaultThinkingEffortForModel( model: ModelThinkingMetadata | undefined, ): ThinkingEffort { if (model === undefined || !modelSupportsThinking(model)) return 'off'; - const efforts = model.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined); - if (efforts !== undefined && efforts.length > 0) { - return (nonEmpty(model.defaultEffort) ?? middleOf(efforts)) as ThinkingEffort; + const efforts = effortsFor(model); + if (efforts.length > 0) { + const declaredDefault = nonEmpty(model.defaultEffort); + return (declaredDefault !== undefined && efforts.includes(declaredDefault) + ? declaredDefault + : middleOf(efforts)) as ThinkingEffort; } return 'on'; } +export function modelSupportsThinkingEffort( + effort: ThinkingEffort, + model: ModelThinkingMetadata | undefined, + kimiProvider: boolean, +): boolean { + if (!kimiProvider || effort === 'off') return true; + if (!modelSupportsThinking(model)) return false; + const efforts = effortsFor(model); + return efforts.length === 0 || effort === 'on' || efforts.includes(effort); +} + +function normalizeThinkingEffortForModel( + effort: ThinkingEffort, + model: ModelThinkingMetadata | undefined, + kimiProvider: boolean, +): ThinkingEffort { + if (effort === 'off' && model?.alwaysThinking !== true) return 'off'; + const efforts = effortsFor(model); + if (!kimiProvider) { + return effort === 'on' && efforts.length > 0 + ? defaultThinkingEffortForModel(model) + : effort; + } + if (!modelSupportsThinking(model)) return 'off'; + if (efforts.length === 0) return 'on'; + if (effort === 'on' || !efforts.includes(effort)) { + return defaultThinkingEffortForModel(model); + } + return effort; +} + export function resolveThinkingEffortForModel( requested: string | undefined, defaults: ThinkingDefaults | undefined, model: ModelThinkingMetadata | undefined, + kimiProvider = false, ): ThinkingEffort { const configured = nonEmpty(defaults?.effort) as ThinkingEffort | undefined; const normalized = nonEmpty(requested)?.toLowerCase(); let effort: ThinkingEffort; if (normalized !== undefined) { - // A requested effort is taken verbatim — including 'on', which is a valid - // wire value for boolean thinking models. Normalizing 'on' to a concrete - // effort is the UI boundary's job, not the resolver's (v1 parity). effort = normalized as ThinkingEffort; } else if (defaults?.enabled === false) { effort = 'off'; @@ -100,7 +136,7 @@ export function resolveThinkingEffortForModel( // is still honored — `enabled = false` only expresses the intent to // disable, it should not also discard a chosen effort. Fall back to the // model default only when no effort is configured. - return configured ?? defaultThinkingEffortForModel(model); + effort = configured ?? defaultThinkingEffortForModel(model); } - return effort; + return normalizeThinkingEffortForModel(effort, model, kimiProvider); } diff --git a/packages/agent-core-v2/src/app/protocol/protocol.ts b/packages/agent-core-v2/src/app/protocol/protocol.ts index 3bfb234382..8b2a6f95e6 100644 --- a/packages/agent-core-v2/src/app/protocol/protocol.ts +++ b/packages/agent-core-v2/src/app/protocol/protocol.ts @@ -34,9 +34,9 @@ export interface ProtocolProviderOptions { readonly reasoningKey?: string; readonly defaultMaxTokens?: number; readonly adaptiveThinking?: boolean; + readonly kimiThinking?: boolean; readonly betaApi?: boolean; readonly metadata?: Readonly>; - readonly supportEfforts?: readonly string[]; readonly vertexai?: boolean; readonly project?: string; readonly location?: string; diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index a48a5c5b1e..bc13b33976 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -2170,16 +2170,14 @@ describe('FullCompaction', () => { await ctx.untilTurnEnd(); expect(callCount).toBe(3); - // The catalogued model declares no supportEfforts, so the Kimi provider - // normalizes to boolean thinking and reports 'on' rather than the - // requested 'high'. The stored thinkingLevel still carries 'high' across - // compaction, which is asserted through telemetry below. + // A Kimi model without supportEfforts is boolean-only, so the effective + // state and every compaction request use 'on'. expect(providerThinkingEfforts).toEqual(['on', 'on', 'on']); expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - thinking_effort: 'high', + thinking_effort: 'on', }), }); }); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts index c6da46fa29..e326be8449 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -165,6 +165,16 @@ describe('LLMRequester service migration coverage', () => { }); it('records the resolved Kimi thinking keep default when thinking is enabled', async () => { + ctx.configure({ + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 1_000_000, + }, + }); ctx.get(IAgentProfileService).update({ thinkingLevel: 'high' }); ctx.mockNextResponse({ type: 'text', text: 'thinking response' }); @@ -172,7 +182,7 @@ describe('LLMRequester service migration coverage', () => { expect(wireEvents(ctx, 'llm.request')).toHaveLength(1); expect(wireEvents(ctx, 'llm.request')[0]?.args).toMatchObject({ - thinkingEffort: 'high', + thinkingEffort: 'on', thinkingKeep: 'all', }); }); diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index d5747b8a23..f01f068153 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -69,6 +69,16 @@ describe('AgentProfileService.bind', () => { }, hostEnvironmentServices(homeDir), ); + ctx.configure({ + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 1_000_000, + }, + }); const svc = ctx.get(IAgentProfileService); await ctx.get(IAgentWireService).flush(); const start = persistence.records.length; @@ -103,7 +113,7 @@ describe('AgentProfileService.bind', () => { expect(records[2]).toMatchObject({ type: 'config.update', modelAlias: MOCK_MODEL, - thinkingEffort: 'low', + thinkingEffort: 'on', }); expect(records[2]).not.toHaveProperty('thinkingLevel'); }); diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 5c1717cb2f..e51c94fad8 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -65,6 +65,7 @@ describe('ConfigState model capabilities', () => { provider: 'kimi', model: 'kimi-for-coding', maxContextSize: 1_000_000, + supportEfforts: ['low', 'high'], capabilities: ['image_in', 'video_in', 'thinking', 'tool_use'], }, }, @@ -98,6 +99,8 @@ describe('ConfigState model capabilities', () => { provider: 'kimi', model: 'kimi-for-coding', maxContextSize: 1_000_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], }, }, }; @@ -265,6 +268,14 @@ describe('ConfigState thinking clamp for always-thinking models', () => { supportEfforts: ['low', 'medium', 'max'], defaultEffort: 'max', }, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'ultra'], + defaultEffort: 'ultra', + }, }, }; capturedProvider = undefined; @@ -319,10 +330,10 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('off'); }); - it('keeps an explicit on request verbatim (normalization is the UI boundary)', () => { + it('resolves an explicit on request to the model default effort', () => { profile.update({ modelAlias: 'kimi-code/custom', thinkingLevel: 'on' }); - expect(profile.data().thinkingLevel).toBe('on'); + expect(profile.data().thinkingLevel).toBe('max'); }); it('re-clamps when switching to an always-on model after thinking was off', () => { @@ -332,6 +343,32 @@ describe('ConfigState thinking clamp for always-thinking models', () => { profile.update({ modelAlias: 'kimi-code/deep' }); expect(profile.data().thinkingLevel).toBe('high'); }); + + it('falls back to the target default when a model switch carries an unsupported effort', () => { + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'ultra' }); + + profile.update({ modelAlias: 'kimi-code/custom' }); + + expect(profile.data().thinkingLevel).toBe('max'); + }); + + it('projects an inherited concrete effort to on when switching to a boolean model', () => { + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'ultra' }); + + profile.update({ modelAlias: 'kimi-code/toggle' }); + + expect(profile.data().thinkingLevel).toBe('on'); + }); + + it('rejects an unsupported effort explicitly set on the current Kimi model', () => { + profile.update({ modelAlias: 'kimi-code/custom' }); + + expect(() => { + profile.setThinking('ultra'); + }).toThrow( + 'Thinking effort "ultra" is not supported by model "kimi-code/custom"', + ); + }); }); describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => { @@ -345,7 +382,12 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = kimiConfig = { providers: { kimi: { type: 'kimi', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' } }, models: { - 'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 }, + 'kimi-code': { + provider: 'kimi', + model: 'kimi-code', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, }, }; capturedProvider = undefined; diff --git a/packages/agent-core-v2/test/agent/profile/thinking.test.ts b/packages/agent-core-v2/test/agent/profile/thinking.test.ts index 3aa0ef9829..528234d4b0 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveThinkingEffort } from '#/agent/profile/thinking'; +import { resolveThinkingEffort, supportsThinkingEffort } from '#/agent/profile/thinking'; import { defaultThinkingEffortForModel } from '#/app/model/thinking'; const booleanModel = { capabilities: ['thinking'] }; @@ -10,7 +10,7 @@ const effortModel = { }; const effortModelWithDefault = { capabilities: ['thinking'], - supportEfforts: ['low', 'high'], + supportEfforts: ['low', 'high', 'max'], defaultEffort: 'max', }; const alwaysThinkingModel = { @@ -36,6 +36,16 @@ describe('defaultThinkingEffortForModel', () => { expect(defaultThinkingEffortForModel(effortModelWithDefault)).toBe('max'); }); + it('ignores a defaultEffort that is not declared in supportEfforts', () => { + expect( + defaultThinkingEffortForModel({ + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'max', + }), + ).toBe('high'); + }); + it('falls back to the middle supportEfforts entry when defaultEffort is absent', () => { // odd length -> exact middle expect(defaultThinkingEffortForModel(effortModel)).toBe('medium'); @@ -63,9 +73,7 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort('low', undefined, effortModel)).toBe('low'); expect(resolveThinkingEffort('on', { enabled: false }, booleanModel)).toBe('on'); expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off'); - // 'on' is a valid wire value, not a request for the configured effort — - // normalizing it to a concrete effort is the UI boundary's job (v1 parity). - expect(resolveThinkingEffort('on', { effort: 'medium' }, effortModel)).toBe('on'); + expect(resolveThinkingEffort('on', { effort: 'medium' }, effortModel)).toBe('medium'); }); it('returns off when config.enabled is false and no effort is requested', () => { @@ -124,4 +132,31 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort(' Medium ', undefined)).toBe('medium'); expect(resolveThinkingEffort('OFF', { effort: 'high' })).toBe('off'); }); + + it('falls back to the model default for an unsupported Kimi effort', () => { + expect( + resolveThinkingEffort('ultra', undefined, { + ...effortModel, + providerType: 'kimi', + }), + ).toBe('medium'); + }); + + it('projects a concrete effort to on for a boolean-only Kimi model', () => { + expect( + resolveThinkingEffort('ultra', undefined, { + ...booleanModel, + providerType: 'kimi', + }), + ).toBe('on'); + }); + + it('reports unsupported concrete efforts only for Kimi effort models', () => { + expect( + supportsThinkingEffort('ultra', { ...effortModel, providerType: 'kimi' }), + ).toBe(false); + expect( + supportsThinkingEffort('ultra', { ...effortModel, providerType: 'openai' }), + ).toBe(true); + }); }); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index d39a9df79d..768dcc14da 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -118,7 +118,7 @@ describe('Agent config', () => { await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({ systemPrompt: 'Changed profile prompt.', - thinkingLevel: 'high', + thinkingLevel: 'on', modelCapabilities: nextCapability, }); }); diff --git a/packages/agent-core-v2/test/app/model/modelResolver.test.ts b/packages/agent-core-v2/test/app/model/modelResolver.test.ts index 34a7ea196a..beb0f185ec 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver.test.ts @@ -594,7 +594,7 @@ describe('ModelResolverService', () => { }); }); - it('passes Kimi supportEfforts through to the protocol adapter', async () => { + it('keeps Kimi supportEfforts as model metadata instead of adapter options', async () => { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; models['m'] = { provider: 'p', @@ -603,15 +603,12 @@ describe('ModelResolverService', () => { supportEfforts: ['low', 'high', 'max'], }; - const config = await resolveAndCreateProvider(); + const model = ix.get(IModelResolver).resolve('m'); - expect(config).toMatchObject({ - protocol: 'kimi', - providerOptions: { supportEfforts: ['low', 'high', 'max'] }, - }); + expect(model.supportEfforts).toEqual(['low', 'high', 'max']); }); - it('passes overridden Kimi supportEfforts through to the protocol adapter', async () => { + it('applies overridden Kimi supportEfforts to model metadata', async () => { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; models['m'] = { provider: 'p', @@ -621,12 +618,9 @@ describe('ModelResolverService', () => { overrides: { supportEfforts: ['low', 'high'] }, }; - const config = await resolveAndCreateProvider(); + const model = ix.get(IModelResolver).resolve('m'); - expect(config).toMatchObject({ - protocol: 'kimi', - providerOptions: { supportEfforts: ['low', 'high'] }, - }); + expect(model.supportEfforts).toEqual(['low', 'high']); }); it('does not pass supportEfforts through for non-Kimi providers', async () => { @@ -649,7 +643,7 @@ describe('ModelResolverService', () => { expect(providerOptions?.supportEfforts).toBeUndefined(); }); - it('passes Kimi supportEfforts through when Kimi uses the Anthropic protocol', async () => { + it('marks the Anthropic adapter when it transports a Kimi provider', async () => { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test', apiKey: 'sk' }; models['m'] = { provider: 'p', @@ -663,7 +657,7 @@ describe('ModelResolverService', () => { expect(config).toMatchObject({ protocol: 'anthropic', - providerOptions: { supportEfforts: ['low', 'high', 'max'] }, + providerOptions: { kimiThinking: true }, }); }); @@ -758,13 +752,17 @@ describe('ModelResolverService', () => { }); describe('default thinking', () => { - function resolveEffort(capabilities?: string[]): string | null { + function resolveEffort( + capabilities?: string[], + supportEfforts?: string[], + ): string | null { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000, ...(capabilities === undefined ? {} : { capabilities }), + supportEfforts, }; return ix.get(IModelResolver).resolve('m').thinkingEffort; } @@ -811,7 +809,7 @@ describe('ModelResolverService', () => { it('uses the configured thinking.effort', () => { configValues['thinking'] = { effort: 'medium' }; - expect(resolveEffort()).toBe('medium'); + expect(resolveEffort(['thinking'], ['low', 'medium', 'high'])).toBe('medium'); }); it('clamps an explicit off back to on for always_thinking models', () => { diff --git a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts index 91cdd4b90f..7b3be26a23 100644 --- a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts +++ b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts @@ -41,6 +41,7 @@ describe('ProtocolAdapterRegistry', () => { providerOptions: { defaultMaxTokens: 12345, adaptiveThinking: false, + kimiThinking: true, betaApi: true, metadata: { user_id: 'session-test' }, }, @@ -48,32 +49,31 @@ describe('ProtocolAdapterRegistry', () => { expect(Reflect.get(provider, '_generationKwargs')).toMatchObject({ max_tokens: 12345 }); expect(Reflect.get(provider, '_adaptiveThinking')).toBe(false); + expect(Reflect.get(provider, '_kimiThinking')).toBe(true); expect(Reflect.get(provider, '_betaApi')).toBe(true); expect(Reflect.get(provider, '_metadata')).toEqual({ user_id: 'session-test' }); }); - it('maps providerOptions into Kimi provider config', () => { + it('passes concrete efforts through the Kimi provider config', () => { const provider = new ProtocolAdapterRegistry().createChatProvider({ protocol: 'kimi', baseUrl: 'https://example.test/v1', modelName: 'kimi-for-coding', apiKey: 'sk', - providerOptions: { supportEfforts: ['low', 'high', 'max'] }, }); - expect(Reflect.get(provider, '_supportEfforts')).toEqual(['low', 'high', 'max']); expect(Reflect.get(provider.withThinking('high'), '_generationKwargs')).toEqual({ extra_body: { thinking: { type: 'enabled', effort: 'high' } }, }); expect(provider.withThinking('high').thinkingEffort).toBe('high'); expect(Reflect.get(provider.withThinking('medium'), '_generationKwargs')).toEqual({ - extra_body: { thinking: { type: 'enabled' } }, + extra_body: { thinking: { type: 'enabled', effort: 'medium' } }, }); - expect(provider.withThinking('medium').thinkingEffort).toBe('on'); + expect(provider.withThinking('medium').thinkingEffort).toBe('medium'); expect(Reflect.get(provider.withThinking('xhigh'), '_generationKwargs')).toEqual({ - extra_body: { thinking: { type: 'enabled' } }, + extra_body: { thinking: { type: 'enabled', effort: 'xhigh' } }, }); - expect(provider.withThinking('xhigh').thinkingEffort).toBe('on'); + expect(provider.withThinking('xhigh').thinkingEffort).toBe('xhigh'); expect( Reflect.get(provider.withThinking('high').withThinking('off'), '_generationKwargs'), ).toEqual({ @@ -82,19 +82,18 @@ describe('ProtocolAdapterRegistry', () => { expect(provider.withThinking('high').withThinking('off').thinkingEffort).toBe('off'); }); - it('maps supportEfforts into OpenAI provider config', () => { + it('passes concrete efforts through the OpenAI provider config', () => { const provider = new ProtocolAdapterRegistry().createChatProvider({ protocol: 'openai', baseUrl: 'https://example.test/v1', modelName: 'kimi-for-coding', apiKey: 'sk', - providerOptions: { supportEfforts: ['low', 'high', 'max'] }, }); expect(Reflect.get(provider.withThinking('max'), '_reasoningEffort')).toBe('max'); expect(provider.withThinking('max').thinkingEffort).toBe('max'); - expect(Reflect.get(provider.withThinking('medium'), '_reasoningEffort')).toBeUndefined(); - expect(provider.withThinking('medium').thinkingEffort).toBeNull(); + expect(Reflect.get(provider.withThinking('medium'), '_reasoningEffort')).toBe('medium'); + expect(provider.withThinking('medium').thinkingEffort).toBe('medium'); }); it('maps providerOptions into Vertex provider config', () => { diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index d4724b6fc2..9a06b2ad79 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -16,7 +16,11 @@ import { import type { Agent } from '..'; import { ErrorCodes, KimiError } from '../../errors'; import type { AgentConfigData, AgentConfigUpdateData } from './types'; -import { resolveThinkingEffort, type ThinkingEffort } from './thinking'; +import { + resolveThinkingEffort, + supportsThinkingEffort, + type ThinkingEffort, +} from './thinking'; import type { ModelAlias } from '../../config/schema'; import type { ResolvedRuntimeProvider } from '../../session/provider-manager'; @@ -38,13 +42,36 @@ export class ConfigState { update(changed: AgentConfigUpdateData): void { if (Object.keys(changed).length === 0) return; + const targetAlias = changed.modelAlias ?? this._modelAlias; + const targetProvider = this.tryResolvedProviderConfigFor(targetAlias); + const targetModel = this.modelForThinking(targetAlias, targetProvider); + const kimiProvider = targetProvider?.type === 'kimi'; + let thinkingEffort: ThinkingEffort | undefined; + if (changed.thinkingEffort !== undefined) { + thinkingEffort = resolveThinkingEffort( + changed.thinkingEffort, + this.agent.kimiConfig?.thinking, + targetModel, + kimiProvider, + ); + } else if (changed.modelAlias !== undefined) { + thinkingEffort = resolveThinkingEffort( + this._modelAlias === undefined ? undefined : this._thinkingEffort, + this.agent.kimiConfig?.thinking, + targetModel, + kimiProvider, + ); + } + const effectiveChanged = + thinkingEffort === undefined ? changed : { ...changed, thinkingEffort }; + this.agent.records.logRecord({ type: 'config.update', - ...changed, + ...effectiveChanged, }); this.agent.replayBuilder.push({ type: 'config_updated', - config: changed, + config: effectiveChanged, }); if (changed.cwd) { this._cwd = changed.cwd; @@ -56,32 +83,28 @@ export class ConfigState { if (changed.profileName) { this._profileName = changed.profileName; } - if (changed.thinkingEffort !== undefined) { - // Resolve through the single source of truth so the always_thinking - // clamp and any future normalization apply uniformly — whether the - // level comes from createSession, setThinking RPC, or subagent - // inheritance. - this._thinkingEffort = resolveThinkingEffort( - changed.thinkingEffort, - this.agent.kimiConfig?.thinking, - this.currentModel, - ); - } else if (changed.modelAlias !== undefined) { - // Re-apply the always_thinking clamp against the new model so a stale - // 'off' cannot survive a switch onto an always-thinking alias. - this._thinkingEffort = resolveThinkingEffort( - this._thinkingEffort, - this.agent.kimiConfig?.thinking, - this.currentModel, - ); - } + if (thinkingEffort !== undefined) this._thinkingEffort = thinkingEffort; if (changed.systemPrompt !== undefined) { this._systemPrompt = changed.systemPrompt; } if (this.hasProvider && (changed.cwd !== undefined || changed.modelAlias)) { this.agent.tools.initializeBuiltinTools(); } - this.agent.emitStatusUpdated(); + this.agent.emitStatusUpdated(thinkingEffort !== undefined); + } + + setThinkingEffort(effort: ThinkingEffort): void { + const model = this.currentModel; + const kimiProvider = this.tryResolvedProviderConfig()?.type === 'kimi'; + if (!supportsThinkingEffort(effort, model, kimiProvider)) { + const efforts = model?.supportEfforts ?? []; + const supported = efforts.length === 0 ? 'off' : ['off', ...efforts].join(', '); + throw new KimiError( + ErrorCodes.MODEL_CONFIG_INVALID, + `Thinking effort "${effort}" is not supported by model "${this.modelAlias}". Supported efforts: ${supported}.`, + ); + } + this.update({ thinkingEffort: effort }); } data(): AgentConfigData { @@ -157,9 +180,31 @@ export class ConfigState { } private get currentModel(): ModelAlias | undefined { - const alias = this._modelAlias; - if (alias === undefined) return undefined; - return this.agent.kimiConfig?.models?.[alias]; + const resolved = this.tryResolvedProviderConfig(); + return this.modelForThinking(this._modelAlias, resolved); + } + + private modelForThinking( + alias: string | undefined, + resolved: ResolvedRuntimeProvider | undefined, + ): ModelAlias | undefined { + if (resolved !== undefined) { + const capabilities = resolved.alwaysThinking + ? ['always_thinking'] + : resolved.modelCapabilities.thinking + ? ['thinking'] + : []; + return { + provider: resolved.providerName, + model: resolved.provider.model, + maxContextSize: Math.max(resolved.modelCapabilities.max_context_tokens, 1), + capabilities, + supportEfforts: + resolved.supportEfforts === undefined ? undefined : [...resolved.supportEfforts], + defaultEffort: resolved.defaultEffort, + }; + } + return alias === undefined ? undefined : this.agent.kimiConfig?.models?.[alias]; } get profileName(): string | undefined { @@ -184,8 +229,14 @@ export class ConfigState { } private tryResolvedProviderConfig(): ResolvedRuntimeProvider | undefined { + return this.tryResolvedProviderConfigFor(this._modelAlias); + } + + private tryResolvedProviderConfigFor( + alias: string | undefined, + ): ResolvedRuntimeProvider | undefined { try { - return this.resolvedProviderConfig; + return alias === undefined ? undefined : this.agent.modelProvider?.resolveProviderConfig(alias); } catch { return undefined; } diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 3b41632818..9b5e64bb71 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -19,6 +19,11 @@ function middleOf(efforts: readonly string[]): string { return efforts[Math.floor(efforts.length / 2)]!; } +function effortsFor(model: ModelAlias | undefined): readonly string[] { + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.supportEfforts?.filter((effort) => effort.length > 0) ?? []; +} + /** * Resolve the default thinking effort for a model from its declared metadata: * - models that do not support thinking (or an unknown model) -> `'off'` @@ -32,13 +37,52 @@ function middleOf(efforts: readonly string[]): string { export function defaultThinkingEffortFor(model: ModelAlias | undefined): ThinkingEffort { const effective = model === undefined ? undefined : effectiveModelAlias(model); if (!supportsThinking(effective)) return 'off'; - const efforts = effective?.supportEfforts; - if (efforts !== undefined && efforts.length > 0) { - return effective?.defaultEffort ?? middleOf(efforts); + const efforts = effortsFor(effective); + if (efforts.length > 0) { + const declaredDefault = effective?.defaultEffort; + return declaredDefault !== undefined && efforts.includes(declaredDefault) + ? declaredDefault + : middleOf(efforts); } return 'on'; } +export function supportsThinkingEffort( + effort: ThinkingEffort, + model: ModelAlias | undefined, + kimiProvider: boolean, +): boolean { + if (!kimiProvider || effort === 'off') return true; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + if (!supportsThinking(effective)) return false; + const efforts = effortsFor(effective); + return efforts.length === 0 || effort === 'on' || efforts.includes(effort); +} + +function normalizeThinkingEffortForModel( + effort: ThinkingEffort, + model: ModelAlias | undefined, + kimiProvider: boolean, +): ThinkingEffort { + const effective = model === undefined ? undefined : effectiveModelAlias(model); + if (effort === 'off' && effective?.capabilities?.includes('always_thinking') !== true) { + return 'off'; + } + + const efforts = effortsFor(effective); + if (!kimiProvider) { + return effort === 'on' && efforts.length > 0 + ? defaultThinkingEffortFor(effective) + : effort; + } + if (!supportsThinking(effective)) return 'off'; + if (efforts.length === 0) return 'on'; + if (effort === 'on' || !efforts.includes(effort)) { + return defaultThinkingEffortFor(effective); + } + return effort; +} + /** * Resolve the effective thinking effort for a session. * @@ -55,6 +99,7 @@ export function resolveThinkingEffort( requested: ThinkingEffort | undefined, config: ThinkingConfig | undefined, model: ModelAlias | undefined, + kimiProvider = false, ): ThinkingEffort { const effectiveModel = model === undefined ? undefined : effectiveModelAlias(model); let effort: ThinkingEffort; @@ -74,5 +119,5 @@ export function resolveThinkingEffort( effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel); } - return effort; + return normalizeThinkingEffortForModel(effort, effectiveModel, kimiProvider); } diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 8019dc8f30..49da2182c0 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -407,7 +407,7 @@ export class Agent { }, setThinking: (payload) => { const previousEffort = this.config.thinkingEffort; - this.config.update({ thinkingEffort: payload.effort }); + this.config.setThinkingEffort(payload.effort); const effort = this.config.thinkingEffort; if (effort !== previousEffort) { this.telemetry.track('thinking_toggle', { @@ -549,7 +549,7 @@ export class Agent { void this.rpc?.emitEvent?.(event); } - emitStatusUpdated(): void { + emitStatusUpdated(includeThinkingEffort = false): void { if (this.records.restoring) return; if (!this.config.hasModel) return; @@ -565,6 +565,7 @@ export class Agent { this.emitEvent({ type: 'agent.status.updated', model, + thinkingEffort: includeThinkingEffort ? this.config.thinkingEffort : undefined, contextTokens, maxContextTokens, contextUsage, diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index da06917654..5a84fdeaa9 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -27,6 +27,8 @@ export interface ResolvedRuntimeProvider { readonly modelCapabilities: ModelCapability; /** Declared 'always_thinking' capability — the model cannot disable thinking. */ readonly alwaysThinking?: boolean; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; readonly maxOutputSize?: number; /** Configured provider wire type (`provider.type`), before any model-level protocol override. */ readonly type: ProviderType; @@ -129,7 +131,6 @@ export class ProviderManager implements ModelProvider { this.options.promptCacheKey, effectiveAlias.adaptiveThinking, alias.betaApi, - effectiveAlias.supportEfforts, ); return { @@ -139,6 +140,8 @@ export class ProviderManager implements ModelProvider { alwaysThinking: (effectiveAlias.capabilities ?? []).some( (c) => c.trim().toLowerCase() === 'always_thinking', ), + supportEfforts: effectiveAlias.supportEfforts, + defaultEffort: effectiveAlias.defaultEffort, maxOutputSize: effectiveAlias.maxOutputSize, type: providerConfig.type, protocol: alias.protocol, @@ -253,7 +256,6 @@ function toKosongProviderConfig( promptCacheKey: string | undefined, adaptiveThinking: boolean | undefined, betaApi: boolean | undefined, - supportEfforts: readonly string[] | undefined, ): KosongProviderConfig { const effectiveType = modelProtocol === 'anthropic' ? 'anthropic' : provider.type; const envCustomHeaders = parseKimiCodeCustomHeaders(); @@ -270,8 +272,8 @@ function toKosongProviderConfig( apiKey: providerApiKey(provider), ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), + ...(provider.type === 'kimi' ? { kimiThinking: true } : {}), ...(betaApi !== undefined ? { betaApi } : {}), - ...(provider.type === 'kimi' && supportEfforts !== undefined ? { supportEfforts } : {}), // Session affinity: Anthropic's analog of OpenAI `prompt_cache_key` is // `metadata.user_id` on the Messages API (cache-affinity / end-user id). ...(promptCacheKey !== undefined ? { metadata: { user_id: promptCacheKey } } : {}), @@ -296,7 +298,6 @@ function toKosongProviderConfig( baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), reasoningKey, - ...(provider.type === 'kimi' && supportEfforts !== undefined ? { supportEfforts } : {}), ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), @@ -310,7 +311,6 @@ function toKosongProviderConfig( baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), generationKwargs: { prompt_cache_key: promptCacheKey }, - supportEfforts, ...defaultHeadersField({ ...envCustomHeaders, ...kimiRequestHeaders, @@ -335,7 +335,6 @@ function toKosongProviderConfig( model, baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), - ...(provider.type === 'kimi' && supportEfforts !== undefined ? { supportEfforts } : {}), ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index ac886f3dad..e238b6eefe 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -2021,16 +2021,14 @@ describe('FullCompaction', () => { await ctx.untilTurnEnd(); expect(callCount).toBe(3); - // The catalogued model declares no supportEfforts, so the kimi provider - // normalizes to boolean thinking and reports 'on' rather than the - // requested 'high'. The agent's stored thinkingEffort ('high') is still - // carried across the compaction (see the record assertion below). + // A Kimi model without supportEfforts is boolean-only, so the effective + // state and every compaction request use 'on'. expect(providerThinkingEfforts).toEqual(['on', 'on', 'on']); expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - thinking_effort: 'high', + thinking_effort: 'on', }), }); }); diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 7a0d169f0d..62f31e9aa6 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -202,6 +202,22 @@ describe('ConfigState thinking clamp for always-thinking models', () => { maxContextSize: 128_000, capabilities: ['thinking'], }, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'ultra'], + defaultEffort: 'ultra', + }, + 'kimi-code/standard': { + provider: 'kimi', + model: 'kimi-standard', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'mid', 'high'], + defaultEffort: 'mid', + }, }, }; return testAgent({ @@ -246,19 +262,53 @@ describe('ConfigState thinking clamp for always-thinking models', () => { ctx.agent.config.update({ modelAlias: 'kimi-code/deep' }); expect(ctx.agent.config.thinkingEffort).toBe('on'); }); + + it('falls back to the target default when a model switch carries an unsupported effort', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/ultra', thinkingEffort: 'ultra' }); + + ctx.agent.config.update({ modelAlias: 'kimi-code/standard' }); + + expect(ctx.agent.config.thinkingEffort).toBe('mid'); + }); + + it('projects an inherited concrete effort to on when switching to a boolean model', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/ultra', thinkingEffort: 'ultra' }); + + ctx.agent.config.update({ modelAlias: 'kimi-code/toggle' }); + + expect(ctx.agent.config.thinkingEffort).toBe('on'); + }); + + it('rejects an unsupported effort explicitly set on the current Kimi model', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/standard' }); + + expect(() => { + ctx.agent.config.setThinkingEffort('ultra'); + }).toThrow( + 'Thinking effort "ultra" is not supported by model "kimi-code/standard"', + ); + }); }); describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => { function kimiAgent() { - return testAgent({ - providerManager: new ProviderManager({ - config: { - providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, - models: { - 'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 }, - }, + const config: KimiConfig = { + providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, + models: { + 'kimi-code': { + provider: 'kimi', + model: 'kimi-code', + maxContextSize: 128_000, + capabilities: ['thinking'], }, - }), + }, + }; + return testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), }); } @@ -268,7 +318,12 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = const config: KimiConfig = { providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, models: { - 'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 }, + 'kimi-code': { + provider: 'kimi', + model: 'kimi-code', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, }, ...(keep !== undefined ? { thinking: { keep } } : {}), }; diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index 9e54282152..fc6cde5629 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -57,7 +57,7 @@ describe('Agent config', () => { await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({ provider: nextProvider, systemPrompt: 'Changed profile prompt.', - thinkingEffort: 'high', + thinkingEffort: 'on', modelCapabilities: nextCapability, }); await ctx.expectResumeMatches(); @@ -164,8 +164,8 @@ describe('Agent config', () => { ctx.mockNextResponse({ type: 'text', text: 'Still using the original turn config.' }); expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(` [wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf original-result", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "