mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-16 20:59:20 +00:00
fix: resolve and synchronize thinking effort (#1625)
* fix: preserve provider thinking effort values * fix: resolve Kimi thinking effort fallbacks * fix: use resolved Kimi effort in v2 requests * fix: align Kimi effort resolution paths * fix: synchronize forced Kimi effort state * fix: synchronize forced Kimi effort in v2 state * fix: tolerate unresolved models in v2 status
This commit is contained in:
parent
268fd41734
commit
d158e0a7ac
61 changed files with 1719 additions and 593 deletions
5
.changeset/wise-otters-think.md
Normal file
5
.changeset/wise-otters-think.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix Thinking effort routing so non-Kimi providers preserve configured values for upstream validation, while Kimi models validate runtime selections, fall back safely during model resolution, and synchronize the effective effort back to clients.
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -139,6 +139,8 @@ function makeStartupInput(): KimiTUIStartupInput {
|
|||
}
|
||||
|
||||
function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
let model = 'k2';
|
||||
let thinkingEffort = 'off';
|
||||
return {
|
||||
id: 'ses-1',
|
||||
model: 'k2',
|
||||
|
|
@ -151,8 +153,8 @@ function makeSession(overrides: Record<string, unknown> = {}) {
|
|||
cancel: vi.fn(async () => {}),
|
||||
cancelCompaction: vi.fn(async () => {}),
|
||||
getStatus: vi.fn(async () => ({
|
||||
model: 'k2',
|
||||
thinkingEffort: 'off',
|
||||
model,
|
||||
thinkingEffort,
|
||||
permission: 'manual',
|
||||
planMode: false,
|
||||
contextTokens: 0,
|
||||
|
|
@ -162,8 +164,12 @@ function makeSession(overrides: Record<string, unknown> = {}) {
|
|||
getGoal: vi.fn(async () => ({ goal: null })),
|
||||
setApprovalHandler: vi.fn(),
|
||||
setQuestionHandler: vi.fn(),
|
||||
setModel: vi.fn(async () => {}),
|
||||
setThinking: vi.fn(async () => {}),
|
||||
setModel: vi.fn(async (alias: string) => {
|
||||
model = alias;
|
||||
}),
|
||||
setThinking: vi.fn(async (effort: string) => {
|
||||
thinkingEffort = effort;
|
||||
}),
|
||||
setPermission: vi.fn(async () => {}),
|
||||
setPlanMode: vi.fn(async () => {}),
|
||||
setSwarmMode: vi.fn(async () => {}),
|
||||
|
|
@ -2987,6 +2993,24 @@ command = "vim"
|
|||
expect(stripSgr(renderTranscript(driver))).toContain('LLM not set');
|
||||
});
|
||||
|
||||
it('applies the effective thinking effort from status updates', async () => {
|
||||
const { driver } = await makeDriver();
|
||||
|
||||
driver.sessionEventHandler.handleEvent(
|
||||
{
|
||||
type: 'agent.status.updated',
|
||||
agentId: 'main',
|
||||
sessionId: 'ses-1',
|
||||
model: 'turbo',
|
||||
thinkingEffort: 'mid',
|
||||
} as Event,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(driver.state.appState.model).toBe('turbo');
|
||||
expect(driver.state.appState.thinkingEffort).toBe('mid');
|
||||
});
|
||||
|
||||
it('renders swarm mode markers from /swarm commands, not tool-triggered status updates', async () => {
|
||||
const { driver } = await makeDriver();
|
||||
|
||||
|
|
@ -4588,6 +4612,67 @@ command = "vim"
|
|||
expect(driver.state.appState.thinkingEffort).toBe('on');
|
||||
});
|
||||
|
||||
it('uses the effective effort returned after a model-switch fallback', async () => {
|
||||
let switched = false;
|
||||
const session = makeSession({
|
||||
getStatus: vi.fn(async () => ({
|
||||
model: switched ? 'turbo' : 'k2',
|
||||
thinkingEffort: switched ? 'mid' : 'ultra',
|
||||
permission: 'manual',
|
||||
planMode: false,
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 100,
|
||||
contextUsage: 0,
|
||||
})),
|
||||
setModel: vi.fn(async () => {
|
||||
switched = true;
|
||||
}),
|
||||
});
|
||||
const setConfig = vi.fn(async () => ({ providers: {} }));
|
||||
const { driver } = await makeDriver(session, {
|
||||
getConfig: vi.fn(async () => ({
|
||||
models: {
|
||||
k2: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 100,
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high', 'ultra'],
|
||||
defaultEffort: 'ultra',
|
||||
},
|
||||
turbo: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-turbo',
|
||||
maxContextSize: 100,
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'mid', 'high'],
|
||||
defaultEffort: 'mid',
|
||||
},
|
||||
},
|
||||
defaultModel: 'k2',
|
||||
thinking: { enabled: true, effort: 'ultra' },
|
||||
})),
|
||||
setConfig,
|
||||
});
|
||||
|
||||
driver.handleUserInput('/model turbo');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
});
|
||||
(driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setConfig).toHaveBeenCalledWith({
|
||||
defaultModel: 'turbo',
|
||||
thinking: { enabled: true, effort: 'mid' },
|
||||
});
|
||||
});
|
||||
expect(driver.state.appState.model).toBe('turbo');
|
||||
expect(driver.state.appState.thinkingEffort).toBe('mid');
|
||||
expect(renderTranscript(driver)).toContain('Switched to kimi-turbo with thinking mid.');
|
||||
});
|
||||
|
||||
it('persists /model selection even when runtime state is unchanged', async () => {
|
||||
const session = makeSession();
|
||||
const setConfig = vi.fn(async () => ({ providers: {} }));
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ Each entry in the `models` table defines a model alias (the name used in `defaul
|
|||
| `max_context_size` | `integer` | Yes | Maximum context length in tokens; must be at least 1 |
|
||||
| `max_output_size` | `integer` | No | Per-request output token cap (maps to `max_tokens`). Currently only the `anthropic` provider honors it. When set for a Claude model, this explicit value overrides the built-in server-side maximum |
|
||||
| `capabilities` | `array<string>` | No | Capability tags to add explicitly: `thinking`, `always_thinking`, `image_in`, `video_in`, `audio_in`, `tool_use`. Unioned with the capabilities auto-detected by the provider — entries can only be added, never removed |
|
||||
| `support_efforts` | `array<string>` | No | Thinking effort levels declared by the model catalog. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."<alias>".overrides] support_efforts` instead |
|
||||
| `support_efforts` | `array<string>` | No | Thinking effort levels the model accepts. For `kimi`, selecting another value at runtime fails; when model resolution carries an unsupported configured or previous value, the session falls back to the target model's `default_effort` and reports that effective value to the UI. A Thinking-capable Kimi model without this field uses boolean `on` / `off`. Other providers pass concrete values unchanged when their protocol has a native effort field; protocols that expose only levels or token budgets perform the required format conversion. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."<alias>".overrides] support_efforts` instead |
|
||||
| `default_effort` | `string` | No | Default thinking effort for the model. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."<alias>".overrides] default_effort` instead |
|
||||
| `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset |
|
||||
| `reasoning_key` | `string` | No | `openai` provider only. Override the field name used for reasoning content when the gateway returns it under a non-standard name; by default `reasoning_content`, `reasoning_details`, and `reasoning` are auto-detected |
|
||||
|
|
@ -181,7 +181,7 @@ You can also switch models temporarily without touching the config file — by s
|
|||
| Field | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled` | `boolean` | `true` | Whether Thinking is enabled by default for new sessions; set to `false` to force Thinking off |
|
||||
| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`); the levels actually available depend on the model's declared `support_efforts`, and unrecognized values are ignored by the provider |
|
||||
| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`). Non-Kimi providers do not remap concrete effort values when the upstream protocol accepts them; if the provider rejects the value, choose one that the model supports. Protocols that expose only levels or token budgets still require format conversion. Kimi models with `support_efforts` fall back to their model default when this configured value is not listed; Kimi models without that list treat every enabled value as boolean `on` |
|
||||
| `keep` | `string` | `"all"` | Preserved Thinking passthrough. On `kimi` it is sent as `thinking.keep`; on `anthropic` (Claude and Kimi's Anthropic-compatible mode) it is sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API; an off-value disables keep and returns to the standard endpoint). `"all"` preserves prior turns' reasoning (`reasoning_content` / Anthropic thinking blocks); set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; only injected while Thinking is on |
|
||||
|
||||
### Deprecated fields
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ KIMI_BASE_URL = "https://api.moonshot.ai/v1"
|
|||
| `max_context_size` | `integer` | 是 | 最大上下文长度(token 数),必须 ≥ 1 |
|
||||
| `max_output_size` | `integer` | 否 | 单次请求的输出 token 上限(对应 `max_tokens`)。目前仅 `anthropic` 供应商读取。为 Claude 模型设置后,这个显式值会覆盖内置的服务端最大值 |
|
||||
| `capabilities` | `array<string>` | 否 | 显式追加的能力标签:`thinking`、`always_thinking`、`image_in`、`video_in`、`audio_in`、`tool_use`。与供应商自动识别的能力取并集,只能追加不能移除 |
|
||||
| `support_efforts` | `array<string>` | 否 | 模型目录声明的 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."<alias>".overrides] support_efforts` |
|
||||
| `support_efforts` | `array<string>` | 否 | 模型接受的 Thinking 档位。对 `kimi` 而言,在运行时选择列表外的值会报错;模型解析时若配置值或之前的值不受目标模型支持,会回落到目标模型的 `default_effort`,并将该有效值同步给 UI。支持 Thinking 但没有此字段的 Kimi 模型使用布尔 `on` / `off`。其他 provider 在协议提供原生 effort 字段时会原样传递具体值;协议仅提供等级或 token budget 时,只做必要的格式转换。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."<alias>".overrides] support_efforts` |
|
||||
| `default_effort` | `string` | 否 | 模型的默认 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."<alias>".overrides] default_effort` |
|
||||
| `display_name` | `string` | 否 | UI 中显示的名称,未设时回退到 `model` |
|
||||
| `reasoning_key` | `string` | 否 | 仅 `openai` 供应商。当网关用非标准字段名返回推理内容时才需要设置;默认自动识别 `reasoning_content` / `reasoning_details` / `reasoning` |
|
||||
|
|
@ -181,7 +181,7 @@ display_name = "Kimi for Coding (custom)"
|
|||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled` | `boolean` | `true` | 新会话是否默认开启 Thinking,设为 `false` 可强制关闭 |
|
||||
| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`),实际可用等级取决于模型声明的 `support_efforts`,未识别的值会被供应商忽略 |
|
||||
| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`)。非 Kimi provider 在上游协议接受具体 effort 值时不会改写该值;如果上游拒绝,请改成该模型支持的档位。协议仅提供等级或 token budget 时,仍需做格式转换。对于带 `support_efforts` 的 Kimi 模型,若该配置值不在列表中,会回落到模型默认档位;没有该列表的 Kimi 模型会把任意开启值视为布尔 `on` |
|
||||
| `keep` | `string` | `"all"` | 保留思考透传。在 `kimi` 上以 `thinking.keep` 发送;在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API;关值可禁用 keep 并回到标准端点)。`"all"` 会保留历史轮次的思考内容(`reasoning_content` / Anthropic thinking blocks);传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅在 Thinking 开启时注入 |
|
||||
|
||||
### 已废弃字段
|
||||
|
|
|
|||
|
|
@ -492,6 +492,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
const originalHistory = [...this.context.get()];
|
||||
const tokensBefore = estimateTokensForMessages(originalHistory);
|
||||
let retryCount = 0;
|
||||
let thinkingEffort = this.profile.data().thinkingLevel;
|
||||
|
||||
try {
|
||||
const signal = active.abortController.signal;
|
||||
|
|
@ -500,6 +501,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
await this.hooks.onWillCompact.run(active);
|
||||
|
||||
const resolvedModel = this.profile.resolveModelContext();
|
||||
thinkingEffort = resolvedModel.thinkingLevel;
|
||||
const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens;
|
||||
const defaultCompactionCap =
|
||||
maxContextTokens > 0
|
||||
|
|
@ -615,7 +617,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
dropped_count: result.droppedCount,
|
||||
retry_count: retryCount,
|
||||
round: 1,
|
||||
thinking_effort: this.profile.data().thinkingLevel,
|
||||
thinking_effort: thinkingEffort,
|
||||
...usageTelemetry(attempt.usage),
|
||||
};
|
||||
this.telemetry.track2('compaction_finished', properties);
|
||||
|
|
@ -628,7 +630,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
duration_ms: Date.now() - startedAt,
|
||||
round: 1,
|
||||
retry_count: retryCount,
|
||||
thinking_effort: this.profile.data().thinkingLevel,
|
||||
thinking_effort: thinkingEffort,
|
||||
error_type: error instanceof Error ? error.name : 'Unknown',
|
||||
});
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
/**
|
||||
* `profile` domain (L4) — `thinking` config-section env bindings.
|
||||
*
|
||||
* Declares the `KIMI_MODEL_THINKING_EFFORT` environment binding (gated on
|
||||
* `KIMI_MODEL_NAME`). Applied to the effective `thinking` value by `config`.
|
||||
* Declares the env-only `KIMI_MODEL_THINKING_EFFORT` force override. Applied
|
||||
* to the effective `thinking` value by `config` and stripped before
|
||||
* persistence.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { envBindings } from '#/app/config/config';
|
||||
import { type ConfigStripEnv, envBindings } from '#/app/config/config';
|
||||
import { registerConfigSection } from '#/app/config/configSectionContributions';
|
||||
|
||||
export const THINKING_SECTION = 'thinking';
|
||||
|
|
@ -15,15 +16,23 @@ export const THINKING_SECTION = 'thinking';
|
|||
export const ThinkingConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
effort: z.string().optional(),
|
||||
forcedEffort: z.string().optional(),
|
||||
keep: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ThinkingConfig = z.infer<typeof ThinkingConfigSchema>;
|
||||
|
||||
export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, {
|
||||
effort: 'KIMI_MODEL_THINKING_EFFORT',
|
||||
forcedEffort: 'KIMI_MODEL_THINKING_EFFORT',
|
||||
});
|
||||
|
||||
export const stripThinkingEnv: ConfigStripEnv<ThinkingConfig> = (value) => {
|
||||
const result = { ...value };
|
||||
delete result.forcedEffort;
|
||||
return result;
|
||||
};
|
||||
|
||||
registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, {
|
||||
env: thinkingEnvBindings,
|
||||
stripEnv: stripThinkingEnv,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export interface IAgentProfileService {
|
|||
refreshSystemPrompt(): Promise<void>;
|
||||
getAgentsMdWarning(): string | undefined;
|
||||
data(): ProfileData;
|
||||
getEffectiveThinkingLevel(): ThinkingEffort;
|
||||
resolveModelContext(): ProfileModelContext;
|
||||
getProvider(): Model;
|
||||
resolveModel(): Model | undefined;
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@
|
|||
* Op (`configUpdate`) for the agent's persistent configuration slice.
|
||||
*
|
||||
* Declares the persistent profile config — `cwd`, `modelAlias`, `profileName`,
|
||||
* the resolved thinking effort, and `systemPrompt` — as a wire Model (initial
|
||||
* `defaultProfileModel()`), plus the single Op whose `apply` is a pure merge of
|
||||
* an already-resolved payload. Live records carry `thinkingEffort` (matching
|
||||
* the resolved base thinking effort, and `systemPrompt` — as a wire Model
|
||||
* (initial `defaultProfileModel()`), plus the single Op whose `apply` is a pure
|
||||
* merge of an already-resolved payload. Live records carry `thinkingEffort` (matching
|
||||
* the v1 wire field); legacy replay still accepts `thinkingLevel`. The value is
|
||||
* resolved to a `ThinkingEffort` at the call site (via `resolveThinkingEffort` +
|
||||
* the `thinking` config section) and carried in the payload, so `apply` stays
|
||||
* pure and a resumed agent restores
|
||||
* the persisted resolved value rather than re-resolving against a possibly-
|
||||
* drifted config. `modelCapabilities` is intentionally NOT in the Model — it is
|
||||
* pure and a resumed agent restores the persisted base value rather than
|
||||
* re-resolving against a possibly-drifted config. Runtime-only Kimi env forcing
|
||||
* is projected by `AgentProfileService`; keeping it out of this Model prevents
|
||||
* that Kimi-only value from leaking through model switches or agent forks.
|
||||
* `modelCapabilities` is intentionally NOT in the Model — it is
|
||||
* derived live from `IModelResolver` so resume never pins stale capabilities.
|
||||
* Each `apply` returns the same reference when nothing changes so the wire's
|
||||
* reference-equality gate stays quiet. The `chdir` side effect and the
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
* Owns the active agent's model alias, thinking level, system prompt, and
|
||||
* active-tool set; resolves the runnable god-object Model through the App-
|
||||
* scope `IModelResolver`, persists the persistent config slice (`cwd` /
|
||||
* `modelAlias` / `profileName` / resolved `thinkingLevel` / `systemPrompt`) in
|
||||
* the `wire` `ProfileModel` through the `config.update` Op and the persisted
|
||||
* active-tool set in the `wire` `ActiveToolsModel` through the
|
||||
* `modelAlias` / `profileName` / resolved base `thinkingLevel` /
|
||||
* `systemPrompt`) in the `wire` `ProfileModel` through the `config.update` Op
|
||||
* and the persisted active-tool set in the `wire` `ActiveToolsModel` through the
|
||||
* `tools.set_active_tools` Op (`wire.dispatch`), and reads both through
|
||||
* `wire.getModel`. The effective active-tool set read by consumers is the
|
||||
* persisted base (`ActiveToolsModel`, rebuilt by `wire.replay`) overlaid with
|
||||
|
|
@ -32,12 +32,16 @@ import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/a
|
|||
import { type Model } from '#/app/model/modelInstance';
|
||||
import { type KimiModelOverrides } from '#/app/model/modelOverrides';
|
||||
import { IModelResolver } from '#/app/model/modelResolver';
|
||||
import {
|
||||
normalizeRequestedThinkingEffort,
|
||||
resolveKimiThinkingEffortOverride,
|
||||
} from '#/app/model/thinking';
|
||||
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 +68,7 @@ import type {
|
|||
ProfileSetModelResult,
|
||||
ProfileUpdateData,
|
||||
} from './profile';
|
||||
import { IAgentProfileService } from './profile';
|
||||
import { IAgentProfileService, ProfileError, ProfileErrors } from './profile';
|
||||
import {
|
||||
THINKING_SECTION,
|
||||
type ThinkingConfig,
|
||||
|
|
@ -190,7 +194,17 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
|
||||
setThinking(level: string): void {
|
||||
const previousEffort = this.thinkingLevel;
|
||||
this.update({ thinkingLevel: level });
|
||||
const model = this.tryResolveRawModel();
|
||||
const normalized = normalizeRequestedThinkingEffort(level);
|
||||
if (normalized !== undefined && !supportsThinkingEffort(normalized, 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: normalized ?? level });
|
||||
const effort = this.thinkingLevel;
|
||||
if (effort !== previousEffort) {
|
||||
this.telemetry.track2('thinking_toggle', {
|
||||
|
|
@ -252,6 +266,10 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
};
|
||||
}
|
||||
|
||||
getEffectiveThinkingLevel(): ThinkingEffort {
|
||||
return this.resolveThinkingState(this.tryResolveRawModel()).effective;
|
||||
}
|
||||
|
||||
resolveModelContext(): ProfileModelContext {
|
||||
const modelAlias = this.model;
|
||||
const model = this.modelFactory.resolve(modelAlias);
|
||||
|
|
@ -261,7 +279,7 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
modelCapabilities: model.capabilities,
|
||||
maxOutputSize: model.maxOutputSize,
|
||||
alwaysThinking: model.alwaysThinking || undefined,
|
||||
thinkingLevel: this.thinkingLevel,
|
||||
thinkingLevel: this.resolveThinkingState(model).effective,
|
||||
reservedContextSize: loopControl?.reservedContextSize,
|
||||
compactionTriggerRatio: loopControl?.compactionTriggerRatio,
|
||||
};
|
||||
|
|
@ -282,12 +300,8 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
resolveModel(): Model | undefined {
|
||||
if (this.modelAlias === undefined) return undefined;
|
||||
let model: Model = this.modelFactory.resolve(this.modelAlias);
|
||||
const thinkingLevel = this.thinkingLevel;
|
||||
const thinking = this.resolveThinkingState(model);
|
||||
const thinkingConfig = this.config.get<ThinkingConfig>(THINKING_SECTION);
|
||||
const forcedKimiThinkingEffort =
|
||||
model.protocol === 'kimi' && thinkingLevel !== 'off'
|
||||
? normalizeKimiThinkingEffort(thinkingConfig?.effort)
|
||||
: undefined;
|
||||
const kwargs: GenerationKwargs = {};
|
||||
if (model.protocol === 'kimi') {
|
||||
kwargs.prompt_cache_key = this.sessionContext.sessionId;
|
||||
|
|
@ -304,24 +318,24 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
const keep = resolveThinkingKeep(
|
||||
overrides?.thinkingKeep,
|
||||
thinkingConfig?.keep,
|
||||
thinkingLevel,
|
||||
thinking.effective,
|
||||
);
|
||||
if (keep !== undefined) {
|
||||
if (model.protocol === 'kimi' && forcedKimiThinkingEffort === undefined) {
|
||||
if (model.protocol === 'kimi' && thinking.forced === undefined) {
|
||||
kwargs.extra_body = { thinking: { keep } };
|
||||
} else if (model.protocol === 'anthropic') {
|
||||
model = model.withThinkingKeep(keep);
|
||||
}
|
||||
}
|
||||
if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs);
|
||||
model = model.withThinking(forcedKimiThinkingEffort ?? thinkingLevel);
|
||||
if (forcedKimiThinkingEffort !== undefined) {
|
||||
const thinking: { type: 'enabled'; effort: string; keep?: string } = {
|
||||
model = model.withThinking(thinking.effective);
|
||||
if (model.protocol === 'kimi' && thinking.forced !== undefined) {
|
||||
const requestThinking: { type: 'enabled'; effort: string; keep?: string } = {
|
||||
type: 'enabled',
|
||||
effort: forcedKimiThinkingEffort,
|
||||
effort: thinking.forced,
|
||||
};
|
||||
if (keep !== undefined) thinking.keep = keep;
|
||||
model = model.withGenerationKwargs({ extra_body: { thinking } });
|
||||
if (keep !== undefined) requestThinking.keep = keep;
|
||||
model = model.withGenerationKwargs({ extra_body: { thinking: requestThinking } });
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
|
@ -384,10 +398,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<ThinkingConfig>(THINKING_SECTION),
|
||||
model,
|
||||
);
|
||||
|
|
@ -404,7 +420,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 {
|
||||
|
|
@ -412,7 +430,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();
|
||||
|
|
@ -422,6 +440,9 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
this.eventBus.publish({
|
||||
type: 'agent.status.updated',
|
||||
model: this.modelAlias,
|
||||
thinkingEffort: includeThinkingEffort
|
||||
? this.getEffectiveThinkingLevel()
|
||||
: undefined,
|
||||
maxContextTokens: this.getModelCapabilities().max_context_tokens,
|
||||
});
|
||||
}
|
||||
|
|
@ -466,6 +487,19 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
return stored;
|
||||
}
|
||||
|
||||
private resolveThinkingState(model: Model | undefined): {
|
||||
readonly effective: ThinkingEffort;
|
||||
readonly forced: ThinkingEffort | undefined;
|
||||
} {
|
||||
const base = this.thinkingLevel;
|
||||
const forced = resolveKimiThinkingEffortOverride(
|
||||
this.config.get<ThinkingConfig>(THINKING_SECTION)?.forcedEffort,
|
||||
base,
|
||||
model?.providerType === 'kimi',
|
||||
);
|
||||
return { effective: forced ?? base, forced };
|
||||
}
|
||||
|
||||
private get alwaysThinkingModel(): boolean {
|
||||
return this.tryResolveRawModel()?.alwaysThinking === true;
|
||||
}
|
||||
|
|
@ -543,11 +577,6 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeKimiThinkingEffort(raw: string | undefined): ThinkingEffort | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentProfileService,
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ declare module '#/app/event/eventBus' {
|
|||
swarmMode?: boolean;
|
||||
planMode?: boolean;
|
||||
model?: string;
|
||||
thinkingEffort?: string;
|
||||
maxContextTokens?: number;
|
||||
contextTokens?: number;
|
||||
phase?: AgentPhase;
|
||||
|
|
|
|||
|
|
@ -197,6 +197,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';
|
||||
}
|
||||
|
|
@ -219,7 +242,12 @@ export function normalizeAPIStatusError(
|
|||
if (isProviderOverloadStatusError(statusCode, message)) {
|
||||
return new APIProviderOverloadedError(statusCode, message, requestId, retryAfterMs);
|
||||
}
|
||||
return new APIStatusError(statusCode, message, requestId, retryAfterMs);
|
||||
return new APIStatusError(
|
||||
statusCode,
|
||||
appendThinkingEffortConfigHint(statusCode, message),
|
||||
requestId,
|
||||
retryAfterMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseRetryAfterMs(headers: unknown): number | null {
|
||||
|
|
|
|||
|
|
@ -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. 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 & {});
|
||||
|
||||
export type JsonSchemaObject = Record<string, unknown>;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export interface AnthropicOptions {
|
|||
metadata?: Record<string, string> | undefined;
|
||||
stream?: boolean | undefined;
|
||||
adaptiveThinking?: boolean | undefined;
|
||||
kimiThinking?: boolean | undefined;
|
||||
betaApi?: boolean | undefined;
|
||||
clientFactory?: (auth: ProviderRequestAuth) => Anthropic;
|
||||
}
|
||||
|
|
@ -320,6 +321,7 @@ function budgetTokensForEffort(effort: ThinkingEffort): number {
|
|||
}
|
||||
throw new Error(`Unknown thinking effort: ${String(effort)}`);
|
||||
}
|
||||
|
||||
const CACHE_CONTROL = { type: 'ephemeral' as const };
|
||||
|
||||
type CacheableBlock = ContentBlockParam & { cache_control?: { type: 'ephemeral' } };
|
||||
|
|
@ -828,6 +830,7 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
private _defaultHeaders: Record<string, string | null> | undefined;
|
||||
private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined;
|
||||
private _adaptiveThinking: boolean | undefined;
|
||||
private readonly _kimiThinking: boolean;
|
||||
private _betaApi: boolean;
|
||||
private _explicitMaxTokens: boolean;
|
||||
|
||||
|
|
@ -836,6 +839,7 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
this._stream = options.stream ?? true;
|
||||
this._metadata = options.metadata;
|
||||
this._adaptiveThinking = options.adaptiveThinking;
|
||||
this._kimiThinking = options.kimiThinking ?? false;
|
||||
this._betaApi = options.betaApi ?? false;
|
||||
this._apiKey =
|
||||
options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey;
|
||||
|
|
@ -862,21 +866,17 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
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;
|
||||
}
|
||||
const effort = this._generationKwargs.output_config?.effort;
|
||||
if (typeof effort === 'string' && effort.length > 0) {
|
||||
return effort;
|
||||
}
|
||||
if (thinkingConfig.type === 'adaptive') {
|
||||
return 'high';
|
||||
}
|
||||
const budget = (thinkingConfig as { budget_tokens?: number }).budget_tokens;
|
||||
if (budget === undefined) {
|
||||
return 'on';
|
||||
}
|
||||
const budget = (thinkingConfig as { budget_tokens?: number }).budget_tokens ?? 0;
|
||||
if (budget <= 1024) {
|
||||
return 'low';
|
||||
}
|
||||
|
|
@ -1111,16 +1111,32 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
return clone;
|
||||
}
|
||||
|
||||
let newBetas = [...(this._generationKwargs.betaFeatures ?? [])];
|
||||
if (adaptive) {
|
||||
newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA);
|
||||
}
|
||||
if (this._kimiThinking) {
|
||||
const clone = this._withGenerationKwargs({
|
||||
thinking: { type: 'enabled' } as MessageCreateParams['thinking'],
|
||||
betaFeatures: newBetas,
|
||||
});
|
||||
if (effort === 'on') {
|
||||
delete clone._generationKwargs.output_config;
|
||||
} else {
|
||||
clone._generationKwargs.output_config = {
|
||||
effort,
|
||||
} as MessageCreateParams['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.');
|
||||
}
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ export interface KimiOptions {
|
|||
stream?: boolean | undefined;
|
||||
defaultHeaders?: Record<string, string> | undefined;
|
||||
generationKwargs?: GenerationKwargs | undefined;
|
||||
supportEfforts?: readonly string[];
|
||||
clientFactory?: (auth: ProviderRequestAuth) => OpenAI;
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +361,6 @@ export class KimiChatProvider implements ChatProvider {
|
|||
private _baseUrl: string;
|
||||
private _defaultHeaders: Record<string, string> | undefined;
|
||||
private _generationKwargs: GenerationKwargs;
|
||||
private readonly _supportEfforts: readonly string[];
|
||||
private _client: OpenAI | undefined;
|
||||
private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined;
|
||||
private _files: KimiFiles | undefined;
|
||||
|
|
@ -376,7 +374,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
|
||||
|
|
@ -501,9 +498,7 @@ export class KimiChatProvider implements ChatProvider {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ import {
|
|||
TOOL_RESULT_MEDIA_PLACEHOLDER,
|
||||
TOOL_RESULT_MEDIA_PROMPT,
|
||||
type ToolMessageConversion,
|
||||
reasoningEffortToThinkingEffort,
|
||||
thinkingEffortToReasoningEffort,
|
||||
toolToOpenAI,
|
||||
} from './openai-common';
|
||||
import {
|
||||
|
|
@ -469,7 +467,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 {
|
||||
|
|
@ -561,7 +560,7 @@ export class OpenAILegacyChatProvider implements ChatProvider {
|
|||
}
|
||||
|
||||
withThinking(effort: ThinkingEffort): OpenAILegacyChatProvider {
|
||||
const reasoningEffort = thinkingEffortToReasoningEffort(effort);
|
||||
const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort;
|
||||
const clone = this._clone();
|
||||
clone._reasoningEffort = reasoningEffort;
|
||||
return clone;
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ import {
|
|||
TOOL_RESULT_MEDIA_PLACEHOLDER,
|
||||
TOOL_RESULT_MEDIA_PROMPT,
|
||||
type ToolMessageConversion,
|
||||
reasoningEffortToThinkingEffort,
|
||||
thinkingEffortToReasoningEffort,
|
||||
} from './openai-common';
|
||||
import {
|
||||
mergeRequestHeaders,
|
||||
|
|
@ -994,7 +992,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 {
|
||||
|
|
@ -1086,7 +1086,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider {
|
|||
}
|
||||
|
||||
withThinking(effort: ThinkingEffort): OpenAIResponsesChatProvider {
|
||||
const reasoningEffort = thinkingEffortToReasoningEffort(effort);
|
||||
const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort;
|
||||
const clone = this._clone();
|
||||
clone._generationKwargs = {
|
||||
...clone._generationKwargs,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,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;
|
||||
|
|
@ -77,6 +78,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;
|
||||
|
|
@ -104,6 +106,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;
|
||||
this.thinkingEffort = null;
|
||||
}
|
||||
|
|
@ -129,6 +132,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,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export interface Model {
|
|||
readonly thinkingEffort: ThinkingEffort | null;
|
||||
readonly maxCompletionTokens?: number;
|
||||
readonly alwaysThinking: boolean;
|
||||
readonly providerType?: string;
|
||||
readonly providerName: string;
|
||||
|
||||
readonly authProvider: AuthProvider;
|
||||
|
|
|
|||
|
|
@ -45,11 +45,15 @@ import {
|
|||
import type { AuthProvider, Model } from './modelInstance';
|
||||
import { IModelResolver } from './modelResolver';
|
||||
import { ModelImpl, StaticAuthProvider } from './modelImpl';
|
||||
import { resolveThinkingEffortForModel } from './thinking';
|
||||
import {
|
||||
resolveKimiThinkingEffortOverride,
|
||||
resolveThinkingEffortForModel,
|
||||
} from './thinking';
|
||||
|
||||
interface ThinkingSection {
|
||||
readonly enabled?: boolean;
|
||||
readonly effort?: string;
|
||||
readonly forcedEffort?: string;
|
||||
}
|
||||
|
||||
type MutableProtocolProviderOptions = {
|
||||
|
|
@ -93,6 +97,7 @@ export class ModelResolverService extends Disposable implements IModelResolver {
|
|||
const authProvider = this.buildAuthProvider(providerName, auth);
|
||||
|
||||
const protocol = this.resolveProtocol(id, model, providerConfig);
|
||||
const providerType = providerConfig?.type ?? protocol;
|
||||
const resolvedBaseUrl =
|
||||
model.protocol === 'anthropic' && rawBaseUrl !== undefined
|
||||
? stripTrailingV1(rawBaseUrl)
|
||||
|
|
@ -145,28 +150,38 @@ export class ModelResolverService extends Disposable implements IModelResolver {
|
|||
supportEfforts: model.supportEfforts,
|
||||
defaultEffort: model.defaultEffort,
|
||||
alwaysThinking,
|
||||
providerType,
|
||||
providerName,
|
||||
authProvider,
|
||||
protocolRegistry: this.protocolRegistry as ProtocolAdapterRegistry,
|
||||
providerOptions,
|
||||
});
|
||||
|
||||
const effort = this.resolveDefaultThinking(model, alwaysThinking);
|
||||
const effort = this.resolveDefaultThinking(
|
||||
model,
|
||||
alwaysThinking,
|
||||
providerType === 'kimi',
|
||||
);
|
||||
return effort === 'off' ? impl : impl.withThinking(effort);
|
||||
}
|
||||
|
||||
private resolveDefaultThinking(
|
||||
model: ModelConfig,
|
||||
alwaysThinking: boolean,
|
||||
kimiProvider: boolean,
|
||||
): ThinkingEffort {
|
||||
const thinking = this.config.get<ThinkingSection | undefined>('thinking');
|
||||
return resolveThinkingEffortForModel(
|
||||
const effort = resolveThinkingEffortForModel(
|
||||
undefined,
|
||||
{
|
||||
enabled: thinking?.enabled,
|
||||
effort: thinking?.effort,
|
||||
},
|
||||
{ ...model, alwaysThinking },
|
||||
kimiProvider,
|
||||
);
|
||||
return (
|
||||
resolveKimiThinkingEffortOverride(thinking?.forcedEffort, effort, kimiProvider) ?? effort
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -320,6 +335,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': {
|
||||
|
|
@ -328,7 +344,6 @@ function buildProtocolProviderOptions(
|
|||
break;
|
||||
}
|
||||
case 'kimi':
|
||||
if (model.supportEfforts !== undefined) options.supportEfforts = model.supportEfforts;
|
||||
break;
|
||||
case 'vertexai': {
|
||||
const project = vertexAIProject(provider);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* `model` domain (L2) — model-aware thinking effort resolution.
|
||||
*
|
||||
* Resolves the effective thinking effort from request/config defaults plus the
|
||||
* model's declared thinking metadata. Shared by `modelResolver` and the
|
||||
* Agent-scope `profile` domain so both paths keep v1-compatible defaults.
|
||||
* Resolves the effective thinking effort from request/config defaults, Kimi's
|
||||
* operational wire override, and the model's declared thinking metadata.
|
||||
* Shared by `modelResolver` and the Agent-scope `profile` domain so both paths
|
||||
* keep v1-compatible defaults.
|
||||
*/
|
||||
|
||||
import type { ModelCapability } from '#/app/llmProtocol/capability';
|
||||
|
|
@ -27,6 +28,21 @@ function nonEmpty(value: string | undefined): string | undefined {
|
|||
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
|
||||
}
|
||||
|
||||
export function normalizeRequestedThinkingEffort(
|
||||
requested: string | undefined,
|
||||
): ThinkingEffort | undefined {
|
||||
return nonEmpty(requested)?.toLowerCase() as ThinkingEffort | undefined;
|
||||
}
|
||||
|
||||
export function resolveKimiThinkingEffortOverride(
|
||||
forced: string | undefined,
|
||||
effective: ThinkingEffort,
|
||||
kimiProvider: boolean,
|
||||
): ThinkingEffort | undefined {
|
||||
if (!kimiProvider || effective === 'off') return undefined;
|
||||
return nonEmpty(forced) as ThinkingEffort | undefined;
|
||||
}
|
||||
|
||||
function hasCapability(
|
||||
capabilities: ModelThinkingMetadata['capabilities'],
|
||||
capability: string,
|
||||
|
|
@ -55,6 +71,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,23 +89,58 @@ 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();
|
||||
const normalized = normalizeRequestedThinkingEffort(requested);
|
||||
let effort: ThinkingEffort;
|
||||
if (normalized !== undefined) {
|
||||
effort = normalized as ThinkingEffort;
|
||||
effort = normalized;
|
||||
} else if (defaults?.enabled === false) {
|
||||
effort = 'off';
|
||||
} else {
|
||||
|
|
@ -93,7 +148,7 @@ export function resolveThinkingEffortForModel(
|
|||
}
|
||||
|
||||
if (effort === 'off' && model?.alwaysThinking === true) {
|
||||
return configured ?? defaultThinkingEffortForModel(model);
|
||||
effort = configured ?? defaultThinkingEffortForModel(model);
|
||||
}
|
||||
return effort;
|
||||
return normalizeThinkingEffortForModel(effort, model, kimiProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>>;
|
||||
readonly supportEfforts?: readonly string[];
|
||||
readonly vertexai?: boolean;
|
||||
readonly project?: string;
|
||||
readonly location?: string;
|
||||
|
|
|
|||
|
|
@ -161,7 +161,6 @@ export class SessionLegacyService implements ISessionLegacyService {
|
|||
const plan = agent.accessor.get(IAgentPlanService);
|
||||
const swarm = agent.accessor.get(IAgentSwarmService);
|
||||
|
||||
const profileData = profile.data();
|
||||
const model = profile.getModel();
|
||||
const caps = profile.getModelCapabilities() as { max_context_tokens?: number };
|
||||
const maxTokens =
|
||||
|
|
@ -172,7 +171,7 @@ export class SessionLegacyService implements ISessionLegacyService {
|
|||
return {
|
||||
status: session?.accessor.get(ISessionActivity).status() ?? 'idle',
|
||||
model: model === '' ? undefined : model,
|
||||
thinking_level: profileData.thinkingLevel,
|
||||
thinking_level: profile.getEffectiveThinkingLevel(),
|
||||
permission: permission.mode,
|
||||
plan_mode: planData !== null,
|
||||
swarm_mode: swarm.isActive,
|
||||
|
|
|
|||
|
|
@ -2224,7 +2224,7 @@ describe('FullCompaction', () => {
|
|||
event: 'compaction_finished',
|
||||
properties: expect.objectContaining({
|
||||
source: 'auto',
|
||||
thinking_effort: 'high',
|
||||
thinking_effort: 'on',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -161,6 +161,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' });
|
||||
|
||||
|
|
@ -168,11 +178,40 @@ 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',
|
||||
});
|
||||
});
|
||||
|
||||
it('records the env-forced Kimi effort used by the provider', async () => {
|
||||
await ctx.dispose();
|
||||
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
|
||||
ctx = createTestAgent();
|
||||
llmRequester = ctx.get(IAgentLLMRequesterService);
|
||||
ctx.configure({
|
||||
modelCapabilities: {
|
||||
image_in: false,
|
||||
video_in: false,
|
||||
audio_in: false,
|
||||
thinking: true,
|
||||
tool_use: true,
|
||||
max_context_tokens: 1_000_000,
|
||||
},
|
||||
});
|
||||
const profile = ctx.get(IAgentProfileService);
|
||||
profile.update({ thinkingLevel: 'high' });
|
||||
expect(profile.data().thinkingLevel).toBe('on');
|
||||
expect(profile.resolveModelContext().thinkingLevel).toBe('max');
|
||||
ctx.mockNextResponse({ type: 'text', text: 'forced thinking response' });
|
||||
|
||||
await llmRequester.request();
|
||||
|
||||
expect(wireEvents(ctx, 'llm.request')).toHaveLength(1);
|
||||
expect(wireEvents(ctx, 'llm.request')[0]?.args).toMatchObject({
|
||||
thinkingEffort: 'max',
|
||||
});
|
||||
});
|
||||
|
||||
it('records strict projection resends as separate outbound requests', async () => {
|
||||
await ctx.dispose();
|
||||
let calls = 0;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,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;
|
||||
|
|
@ -97,7 +107,7 @@ describe('AgentProfileService.bind', () => {
|
|||
expect(records[2]).toMatchObject({
|
||||
type: 'config.update',
|
||||
modelAlias: MOCK_MODEL,
|
||||
thinkingEffort: 'low',
|
||||
thinkingEffort: 'on',
|
||||
});
|
||||
expect(records[2]).not.toHaveProperty('thinkingLevel');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import type { ModelConfig } from '#/app/model/model';
|
||||
import {
|
||||
configServices,
|
||||
createTestAgent,
|
||||
|
|
@ -14,6 +15,8 @@ import {
|
|||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
|
||||
type TestKimiConfig = ReturnType<Parameters<typeof configServices>[0]>;
|
||||
type TestProtocolModelConfig = NonNullable<TestKimiConfig['models']>[string] &
|
||||
Pick<ModelConfig, 'protocol'>;
|
||||
type GenerateFn = Parameters<typeof llmGenerateServices>[0];
|
||||
|
||||
function defaultGenerate(): ReturnType<GenerateFn> {
|
||||
|
|
@ -65,6 +68,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 +102,8 @@ describe('ConfigState model capabilities', () => {
|
|||
provider: 'kimi',
|
||||
model: 'kimi-for-coding',
|
||||
maxContextSize: 1_000_000,
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -261,6 +267,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;
|
||||
|
|
@ -312,10 +326,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', () => {
|
||||
|
|
@ -325,6 +339,51 @@ 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"',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[' HIGH ', 'high'],
|
||||
['OFF', 'off'],
|
||||
])('normalizes runtime effort %j to %s before validation', (input, expected) => {
|
||||
profile.update({ modelAlias: 'kimi-code/ultra' });
|
||||
|
||||
profile.setThinking(input);
|
||||
|
||||
expect(profile.data().thinkingLevel).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses the model default when the runtime effort is blank', () => {
|
||||
profile.update({ modelAlias: 'kimi-code/custom', thinkingLevel: 'low' });
|
||||
|
||||
profile.setThinking(' ');
|
||||
|
||||
expect(profile.data().thinkingLevel).toBe('max');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => {
|
||||
|
|
@ -338,7 +397,20 @@ 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'],
|
||||
},
|
||||
'kimi-code-anthropic': {
|
||||
provider: 'kimi',
|
||||
protocol: 'anthropic',
|
||||
model: 'kimi-code-anthropic',
|
||||
maxContextSize: 128_000,
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high'],
|
||||
} as TestProtocolModelConfig,
|
||||
},
|
||||
};
|
||||
capturedProvider = undefined;
|
||||
|
|
@ -413,4 +485,29 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () =
|
|||
};
|
||||
expect(gen.extra_body?.thinking?.keep).toBeUndefined();
|
||||
});
|
||||
|
||||
it('injects forced effort through the Anthropic protocol for a Kimi provider', async () => {
|
||||
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
|
||||
createAgentWithEnv();
|
||||
|
||||
profile.update({ modelAlias: 'kimi-code-anthropic', thinkingLevel: 'high' });
|
||||
expect(profile.data().thinkingLevel).toBe('high');
|
||||
expect(profile.resolveModelContext().thinkingLevel).toBe('max');
|
||||
const statusEvent = ctx?.allEvents.findLast(
|
||||
(event) =>
|
||||
event.event === 'agent.status.updated' &&
|
||||
(event.args as { thinkingEffort?: unknown } | undefined)?.thinkingEffort !== undefined,
|
||||
);
|
||||
expect(statusEvent?.args).toMatchObject({
|
||||
model: 'kimi-code-anthropic',
|
||||
thinkingEffort: 'max',
|
||||
});
|
||||
|
||||
await requester.request({}, undefined, new AbortController().signal);
|
||||
|
||||
expect(capturedProvider).toMatchObject({
|
||||
name: 'anthropic',
|
||||
thinkingEffort: 'max',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ function createRecordingModel(
|
|||
providerOptions: unknown[] = [],
|
||||
protocol: Model['protocol'] = 'kimi',
|
||||
thinkingKeeps: string[] = [],
|
||||
providerType: string | undefined = protocol === 'kimi' ? 'kimi' : undefined,
|
||||
): Model {
|
||||
const build = (thinkingEffort: ThinkingEffort | null): Model => ({
|
||||
id: 'kimi-code',
|
||||
|
|
@ -161,8 +162,12 @@ function createRecordingModel(
|
|||
max_context_tokens: 1000,
|
||||
},
|
||||
maxContextSize: 1000,
|
||||
supportEfforts:
|
||||
providerType === 'kimi' ? ['low', 'medium', 'high', 'max'] : undefined,
|
||||
defaultEffort: providerType === 'kimi' ? 'high' : undefined,
|
||||
thinkingEffort,
|
||||
alwaysThinking: false,
|
||||
providerType,
|
||||
providerName: 'kimi',
|
||||
authProvider: { getAuth: async () => undefined },
|
||||
withThinking: (effort) => {
|
||||
|
|
@ -281,6 +286,18 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
expect(modelOf(host.wire).thinkingLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('returns the persisted effort when a replayed model alias no longer resolves', async () => {
|
||||
const host = buildHost('profile-replay-removed-model');
|
||||
|
||||
await host.wire.replay({
|
||||
type: 'config.update',
|
||||
modelAlias: 'removed-model',
|
||||
thinkingEffort: 'high',
|
||||
});
|
||||
|
||||
expect(host.svc.getEffectiveThinkingLevel()).toBe('high');
|
||||
});
|
||||
|
||||
it('rejects conflicting config.update thinking aliases during replay', async () => {
|
||||
const host = buildHost('profile-replay-conflicting-thinking-aliases');
|
||||
|
||||
|
|
@ -318,7 +335,32 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('forces configured Kimi thinking effort outside declared support_efforts', () => {
|
||||
it('uses the resolved Kimi effort instead of the configured default', () => {
|
||||
const generationKwargs: GenerationKwargs[] = [];
|
||||
const thinkingEfforts: ThinkingEffort[] = [];
|
||||
modelResolver = {
|
||||
_serviceBrand: undefined,
|
||||
resolve: () => createRecordingModel(generationKwargs, thinkingEfforts),
|
||||
findByName: () => [],
|
||||
};
|
||||
const host = buildHost('profile-thinking-effort-resolved');
|
||||
host.svc.configure({ emitStatusUpdated: () => undefined });
|
||||
configValues['thinking'] = { effort: ' max ' };
|
||||
|
||||
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
|
||||
const model = host.svc.resolveModel();
|
||||
|
||||
expect(model?.thinkingEffort).toBe('high');
|
||||
expect(thinkingEfforts).toEqual(['high']);
|
||||
expect(generationKwargs).toEqual([
|
||||
{
|
||||
prompt_cache_key: 'session-test',
|
||||
extra_body: { thinking: { keep: 'all' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('forces the environment Kimi effort instead of the resolved effort', () => {
|
||||
const generationKwargs: GenerationKwargs[] = [];
|
||||
const thinkingEfforts: ThinkingEffort[] = [];
|
||||
modelResolver = {
|
||||
|
|
@ -328,9 +370,13 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
};
|
||||
const host = buildHost('profile-thinking-effort-force');
|
||||
host.svc.configure({ emitStatusUpdated: () => undefined });
|
||||
configValues['thinking'] = { effort: ' max ' };
|
||||
configValues['thinking'] = { effort: 'low', forcedEffort: ' max ' };
|
||||
|
||||
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
|
||||
expect(host.svc.data().thinkingLevel).toBe('high');
|
||||
expect(modelOf(host.wire).thinkingLevel).toBe('high');
|
||||
expect(host.svc.resolveModelContext().thinkingLevel).toBe('max');
|
||||
|
||||
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'on' });
|
||||
const model = host.svc.resolveModel();
|
||||
|
||||
expect(model?.thinkingEffort).toBe('max');
|
||||
|
|
@ -341,6 +387,34 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('does not leak a forced Kimi effort when switching to a non-Kimi model', () => {
|
||||
const kimiThinkingEfforts: ThinkingEffort[] = [];
|
||||
const otherThinkingEfforts: ThinkingEffort[] = [];
|
||||
modelResolver = {
|
||||
_serviceBrand: undefined,
|
||||
resolve: (alias) =>
|
||||
alias === 'kimi-code'
|
||||
? createRecordingModel([], kimiThinkingEfforts)
|
||||
: createRecordingModel([], otherThinkingEfforts, [], 'anthropic'),
|
||||
findByName: () => [],
|
||||
};
|
||||
const host = buildHost('profile-thinking-effort-force-switch');
|
||||
host.svc.configure({ emitStatusUpdated: () => undefined });
|
||||
configValues['thinking'] = { forcedEffort: 'max' };
|
||||
|
||||
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
|
||||
expect(host.svc.data().thinkingLevel).toBe('high');
|
||||
expect(host.svc.resolveModelContext().thinkingLevel).toBe('max');
|
||||
expect(host.svc.resolveModel()?.thinkingEffort).toBe('max');
|
||||
|
||||
host.svc.update({ modelAlias: 'other-code' });
|
||||
expect(host.svc.data().thinkingLevel).toBe('high');
|
||||
expect(host.svc.resolveModelContext().thinkingLevel).toBe('high');
|
||||
expect(host.svc.resolveModel()?.thinkingEffort).toBe('high');
|
||||
expect(kimiThinkingEfforts).toEqual(['max']);
|
||||
expect(otherThinkingEfforts).toEqual(['high']);
|
||||
});
|
||||
|
||||
it('applies thinking.keep model override on the Anthropic path', () => {
|
||||
const generationKwargs: GenerationKwargs[] = [];
|
||||
const thinkingEfforts: ThinkingEffort[] = [];
|
||||
|
|
@ -372,6 +446,38 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
expect(generationKwargs).toEqual([{ temperature: 0.3 }]);
|
||||
});
|
||||
|
||||
it('forces Kimi effort through Anthropic without Kimi generation kwargs', () => {
|
||||
const generationKwargs: GenerationKwargs[] = [];
|
||||
const thinkingEfforts: ThinkingEffort[] = [];
|
||||
const providerOptions: unknown[] = [];
|
||||
const thinkingKeeps: string[] = [];
|
||||
modelResolver = {
|
||||
_serviceBrand: undefined,
|
||||
resolve: () =>
|
||||
createRecordingModel(
|
||||
generationKwargs,
|
||||
thinkingEfforts,
|
||||
providerOptions,
|
||||
'anthropic',
|
||||
thinkingKeeps,
|
||||
'kimi',
|
||||
),
|
||||
findByName: () => [],
|
||||
};
|
||||
const host = buildHost('profile-thinking-effort-force-anthropic');
|
||||
host.svc.configure({ emitStatusUpdated: () => undefined });
|
||||
configValues['thinking'] = { forcedEffort: 'max' };
|
||||
|
||||
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
|
||||
const model = host.svc.resolveModel();
|
||||
|
||||
expect(model?.thinkingEffort).toBe('max');
|
||||
expect(thinkingEfforts).toEqual(['max']);
|
||||
expect(thinkingKeeps).toEqual(['all']);
|
||||
expect(providerOptions).toEqual([{ metadata: { user_id: 'session-test' } }]);
|
||||
expect(generationKwargs).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults thinking.keep to "all" when thinking is enabled on Kimi', () => {
|
||||
const generationKwargs: GenerationKwargs[] = [];
|
||||
const thinkingEfforts: ThinkingEffort[] = [];
|
||||
|
|
@ -448,9 +554,11 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
};
|
||||
const host = buildHost('profile-thinking-keep-off');
|
||||
host.svc.configure({ emitStatusUpdated: () => undefined });
|
||||
configValues['thinking'] = { forcedEffort: 'max' };
|
||||
configValues['modelOverrides'] = { temperature: 0.3, thinkingKeep: 'all' };
|
||||
|
||||
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'off' });
|
||||
expect(host.svc.resolveModelContext().thinkingLevel).toBe('off');
|
||||
host.svc.resolveModel();
|
||||
|
||||
expect(thinkingEfforts).toEqual(['off']);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
expect(defaultThinkingEffortForModel(effortModel)).toBe('medium');
|
||||
expect(
|
||||
|
|
@ -61,7 +71,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');
|
||||
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', () => {
|
||||
|
|
@ -116,4 +126,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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ import '#/agent/permissionMode/configSection';
|
|||
import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection';
|
||||
import '#/agent/media/configSection';
|
||||
import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection';
|
||||
import {
|
||||
THINKING_SECTION,
|
||||
type ThinkingConfig,
|
||||
} from '#/agent/profile/configSection';
|
||||
import {
|
||||
KEEP_ALIVE_ON_EXIT_ENV,
|
||||
resolveAgentTaskConfig,
|
||||
|
|
@ -120,7 +124,7 @@ describe('Agent config', () => {
|
|||
|
||||
await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({
|
||||
systemPrompt: 'Changed profile prompt.',
|
||||
thinkingLevel: 'high',
|
||||
thinkingLevel: 'on',
|
||||
modelCapabilities: nextCapability,
|
||||
});
|
||||
});
|
||||
|
|
@ -354,6 +358,49 @@ describe('ConfigService env overlay (live)', () => {
|
|||
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('keeps the Kimi effort force separate from the configured effort', async () => {
|
||||
const env: Record<string, string> = { KIMI_MODEL_THINKING_EFFORT: 'max' };
|
||||
const disposables = new DisposableStore();
|
||||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(ILogService, stubLog());
|
||||
ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env));
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore));
|
||||
ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry));
|
||||
ix.set(IConfigService, new SyncDescriptor(ConfigService));
|
||||
const config = ix.get(IConfigService);
|
||||
await config.ready;
|
||||
await config.set(THINKING_SECTION, { effort: 'low' });
|
||||
|
||||
expect(config.get<ThinkingConfig>(THINKING_SECTION)).toEqual({
|
||||
effort: 'low',
|
||||
forcedEffort: 'max',
|
||||
});
|
||||
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('strips the Kimi effort force before persisting thinking config', async () => {
|
||||
const disposables = new DisposableStore();
|
||||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(ILogService, stubLog());
|
||||
ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg'));
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore));
|
||||
ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry));
|
||||
ix.set(IConfigService, new SyncDescriptor(ConfigService));
|
||||
const config = ix.get(IConfigService);
|
||||
await config.ready;
|
||||
|
||||
await config.set(THINKING_SECTION, { effort: 'low', forcedEffort: 'max' });
|
||||
|
||||
expect(config.inspect<ThinkingConfig>(THINKING_SECTION).userValue).toEqual({
|
||||
effort: 'low',
|
||||
});
|
||||
|
||||
disposables.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill config sections', () => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { IProtocolAdapterRegistry, type ProtocolAdapterConfig } from '#/app/prot
|
|||
|
||||
let generateImpl: ChatProvider['generate'];
|
||||
let uploadVideoImpl: NonNullable<ChatProvider['uploadVideo']> | undefined;
|
||||
let appliedThinkingEfforts: string[];
|
||||
|
||||
describe('ModelResolverService', () => {
|
||||
let disposables: DisposableStore;
|
||||
|
|
@ -50,6 +51,7 @@ describe('ModelResolverService', () => {
|
|||
configValues = {};
|
||||
resolveTokenProvider = vi.fn();
|
||||
createdProtocolConfigs = [];
|
||||
appliedThinkingEfforts = [];
|
||||
generateImpl = async () => ({
|
||||
id: null,
|
||||
usage: null,
|
||||
|
|
@ -588,7 +590,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',
|
||||
|
|
@ -597,15 +599,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',
|
||||
|
|
@ -615,11 +614,46 @@ describe('ModelResolverService', () => {
|
|||
overrides: { supportEfforts: ['low', 'high'] },
|
||||
};
|
||||
|
||||
const model = ix.get(IModelResolver).resolve('m');
|
||||
|
||||
expect(model.supportEfforts).toEqual(['low', 'high']);
|
||||
});
|
||||
|
||||
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: 'kimi',
|
||||
providerOptions: { supportEfforts: ['low', 'high'] },
|
||||
protocol: 'anthropic',
|
||||
});
|
||||
const providerOptions = config?.['providerOptions'] as
|
||||
| { readonly supportEfforts?: readonly string[] }
|
||||
| undefined;
|
||||
expect(providerOptions?.supportEfforts).toBeUndefined();
|
||||
});
|
||||
|
||||
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',
|
||||
protocol: 'anthropic',
|
||||
model: 'kimi-for-coding',
|
||||
maxContextSize: 1000,
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
};
|
||||
|
||||
const config = await resolveAndCreateProvider();
|
||||
|
||||
expect(config).toMatchObject({
|
||||
protocol: 'anthropic',
|
||||
providerOptions: { kimiThinking: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -714,13 +748,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;
|
||||
}
|
||||
|
|
@ -767,7 +805,70 @@ 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('derives Kimi effort semantics for a flat kimi-protocol model', () => {
|
||||
configValues['thinking'] = { effort: 'ultra' };
|
||||
models['m'] = {
|
||||
protocol: 'kimi',
|
||||
baseUrl: 'https://example.test/v1',
|
||||
apiKey: 'sk',
|
||||
model: 'wire-name',
|
||||
maxContextSize: 1000,
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'medium', 'high'],
|
||||
defaultEffort: 'medium',
|
||||
};
|
||||
|
||||
const model = ix.get(IModelResolver).resolve('m');
|
||||
|
||||
expect(model.providerType).toBe('kimi');
|
||||
expect(model.thinkingEffort).toBe('medium');
|
||||
});
|
||||
|
||||
it('applies the forced effort to a direct Kimi-over-Anthropic request', async () => {
|
||||
configValues['thinking'] = { effort: 'low', forcedEffort: 'max' };
|
||||
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
|
||||
models['m'] = {
|
||||
provider: 'p',
|
||||
protocol: 'anthropic',
|
||||
model: 'wire-name',
|
||||
maxContextSize: 1000,
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high'],
|
||||
};
|
||||
|
||||
const model = ix.get(IModelResolver).resolve('m');
|
||||
for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) {
|
||||
void _event;
|
||||
}
|
||||
|
||||
expect(model.thinkingEffort).toBe('max');
|
||||
expect(appliedThinkingEfforts).toEqual(['max']);
|
||||
expect(createdProtocolConfigs[0]).toMatchObject({
|
||||
protocol: 'anthropic',
|
||||
providerOptions: { kimiThinking: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores the forced Kimi effort when thinking is off', async () => {
|
||||
configValues['thinking'] = { enabled: false, forcedEffort: 'max' };
|
||||
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
|
||||
models['m'] = {
|
||||
provider: 'p',
|
||||
model: 'wire-name',
|
||||
maxContextSize: 1000,
|
||||
capabilities: ['thinking'],
|
||||
};
|
||||
|
||||
const model = ix.get(IModelResolver).resolve('m');
|
||||
for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) {
|
||||
void _event;
|
||||
}
|
||||
|
||||
expect(model.thinkingEffort).toBeNull();
|
||||
expect(appliedThinkingEfforts).toEqual([]);
|
||||
});
|
||||
|
||||
it('clamps an explicit off back to on for always_thinking models', () => {
|
||||
|
|
@ -894,7 +995,8 @@ const fakeChatProvider: ChatProvider = {
|
|||
if (uploadVideoImpl === undefined) throw new Error('uploadVideo not configured');
|
||||
return uploadVideoImpl(input, options);
|
||||
},
|
||||
withThinking() {
|
||||
withThinking(effort) {
|
||||
appliedThinkingEfforts.push(effort);
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe('ProtocolAdapterRegistry', () => {
|
|||
providerOptions: {
|
||||
defaultMaxTokens: 12345,
|
||||
adaptiveThinking: false,
|
||||
kimiThinking: true,
|
||||
betaApi: true,
|
||||
metadata: { user_id: 'session-test' },
|
||||
},
|
||||
|
|
@ -48,28 +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', effort: 'xhigh' } },
|
||||
});
|
||||
expect(provider.withThinking('xhigh').thinkingEffort).toBe('xhigh');
|
||||
expect(
|
||||
Reflect.get(provider.withThinking('high').withThinking('off'), '_generationKwargs'),
|
||||
).toEqual({
|
||||
|
|
@ -78,6 +82,20 @@ describe('ProtocolAdapterRegistry', () => {
|
|||
expect(provider.withThinking('high').withThinking('off').thinkingEffort).toBe('off');
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
expect(Reflect.get(provider.withThinking('max'), '_reasoningEffort')).toBe('max');
|
||||
expect(provider.withThinking('max').thinkingEffort).toBe('max');
|
||||
expect(Reflect.get(provider.withThinking('medium'), '_reasoningEffort')).toBe('medium');
|
||||
expect(provider.withThinking('medium').thinkingEffort).toBe('medium');
|
||||
});
|
||||
|
||||
it('maps providerOptions into Vertex provider config', () => {
|
||||
const provider = new ProtocolAdapterRegistry().createChatProvider({
|
||||
protocol: 'vertexai',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Session legacy status scenarios.
|
||||
*
|
||||
* Resolves the edge adapter through DI and exercises its public status contract
|
||||
* with real scope-handle traversal. Agent/session domain collaborators are
|
||||
* narrow stubs so the scenario can model a persisted alias removed from the
|
||||
* current model catalog.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import { IAgentPlanService } from '#/agent/plan/plan';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentSwarmService } from '#/agent/swarm/swarm';
|
||||
import { UNKNOWN_CAPABILITY } from '#/app/llmProtocol/capability';
|
||||
import { ISessionLegacyService } from '#/app/sessionLegacy/sessionLegacy';
|
||||
import { SessionLegacyService } from '#/app/sessionLegacy/sessionLegacyService';
|
||||
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
|
||||
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { ISessionCronService } from '#/session/cron/sessionCronService';
|
||||
import { ISessionActivity } from '#/session/sessionActivity/sessionActivity';
|
||||
|
||||
function accessor(
|
||||
entries: ReadonlyArray<readonly [ServiceIdentifier<unknown>, unknown]>,
|
||||
): ServicesAccessor {
|
||||
return {
|
||||
get<T>(id: ServiceIdentifier<T>): T {
|
||||
for (const [key, value] of entries) {
|
||||
if (key === id) return value as T;
|
||||
}
|
||||
throw new Error(`Unexpected service request: ${String(id)}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Session legacy status (best-effort runtime state)', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('returns the persisted effort when the saved model alias no longer resolves', async () => {
|
||||
const profile = {
|
||||
_serviceBrand: undefined,
|
||||
data: () => ({
|
||||
cwd: '/workspace',
|
||||
modelAlias: 'removed-model',
|
||||
modelCapabilities: UNKNOWN_CAPABILITY,
|
||||
thinkingLevel: 'high',
|
||||
systemPrompt: '',
|
||||
}),
|
||||
getModel: () => 'removed-model',
|
||||
getModelCapabilities: () => UNKNOWN_CAPABILITY,
|
||||
getEffectiveThinkingLevel: () => 'high',
|
||||
resolveModelContext: () => {
|
||||
throw new Error('removed-model cannot be resolved');
|
||||
},
|
||||
} as unknown as IAgentProfileService;
|
||||
const agent: IAgentScopeHandle = {
|
||||
id: 'main',
|
||||
kind: LifecycleScope.Agent,
|
||||
accessor: accessor([
|
||||
[IAgentProfileService, profile],
|
||||
[IAgentContextSizeService, { get: () => ({ size: 25, measured: 20, estimated: 5 }) }],
|
||||
[IAgentPermissionModeService, { mode: 'manual' }],
|
||||
[IAgentPlanService, { status: () => Promise.resolve(null) }],
|
||||
[IAgentSwarmService, { isActive: false }],
|
||||
]),
|
||||
dispose: () => {},
|
||||
};
|
||||
const agents = {
|
||||
whenReady: () => Promise.resolve(agent),
|
||||
} as unknown as IAgentLifecycleService;
|
||||
const session: ISessionScopeHandle = {
|
||||
id: 'session-test',
|
||||
kind: LifecycleScope.Session,
|
||||
accessor: accessor([
|
||||
[IAgentLifecycleService, agents],
|
||||
[ISessionCronService, { _serviceBrand: undefined }],
|
||||
[ISessionActivity, { status: () => 'idle' }],
|
||||
]),
|
||||
dispose: () => {},
|
||||
};
|
||||
ix.stub(ISessionLifecycleService, {
|
||||
resume: () => Promise.resolve(session),
|
||||
get: () => session,
|
||||
});
|
||||
ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService));
|
||||
|
||||
const status = await ix.get(ISessionLegacyService).status('session-test');
|
||||
|
||||
expect(status).toMatchObject({
|
||||
status: 'idle',
|
||||
model: 'removed-model',
|
||||
thinking_level: 'high',
|
||||
max_context_tokens: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2077,23 +2077,29 @@ function configWithEnvOverrides(config: KimiConfig): KimiConfig {
|
|||
parseEnvCompletionTokens(process.env['KIMI_MODEL_MAX_TOKENS']);
|
||||
const temperature = parseEnvFloat(process.env['KIMI_MODEL_TEMPERATURE']);
|
||||
const topP = parseEnvFloat(process.env['KIMI_MODEL_TOP_P']);
|
||||
const forcedEffort = process.env['KIMI_MODEL_THINKING_EFFORT']?.trim();
|
||||
const thinkingKeep = process.env['KIMI_MODEL_THINKING_KEEP']?.trim();
|
||||
const cron = cronEnvOverrides(asMutableRecord(config['cron']));
|
||||
if (
|
||||
maxCompletionTokens === undefined &&
|
||||
temperature === undefined &&
|
||||
topP === undefined &&
|
||||
(forcedEffort === undefined || forcedEffort.length === 0) &&
|
||||
(thinkingKeep === undefined || thinkingKeep.length === 0) &&
|
||||
cron === undefined
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
const modelOverrides = asMutableRecord(config['modelOverrides']);
|
||||
const thinking = asMutableRecord(config['thinking']);
|
||||
if (temperature !== undefined) modelOverrides['temperature'] = temperature;
|
||||
if (topP !== undefined) modelOverrides['topP'] = topP;
|
||||
if (thinkingKeep !== undefined && thinkingKeep.length > 0) {
|
||||
modelOverrides['thinkingKeep'] = thinkingKeep;
|
||||
}
|
||||
if (forcedEffort !== undefined && forcedEffort.length > 0) {
|
||||
thinking['forcedEffort'] = forcedEffort;
|
||||
}
|
||||
if (maxCompletionTokens !== undefined) {
|
||||
modelOverrides['maxCompletionTokens'] = maxCompletionTokens;
|
||||
}
|
||||
|
|
@ -2101,6 +2107,8 @@ function configWithEnvOverrides(config: KimiConfig): KimiConfig {
|
|||
...config,
|
||||
cron: cron ?? config['cron'],
|
||||
modelOverrides,
|
||||
thinking:
|
||||
forcedEffort !== undefined && forcedEffort.length > 0 ? thinking : config['thinking'],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,14 +9,18 @@ import {
|
|||
import {
|
||||
applyAnthropicThinkingKeep,
|
||||
applyKimiEnvSamplingParams,
|
||||
applyKimiEnvThinkingEffort,
|
||||
applyKimiEnvThinkingKeep,
|
||||
resolveKimiEnvThinkingEffort,
|
||||
} from '#/config/kimi-env-params';
|
||||
|
||||
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';
|
||||
|
||||
|
|
@ -27,6 +31,7 @@ export class ConfigState {
|
|||
private _cwd: string;
|
||||
private _modelAlias: string | undefined;
|
||||
private _profileName: string | undefined;
|
||||
private _unforcedThinkingEffort: ThinkingEffort = 'off';
|
||||
private _thinkingEffort: ThinkingEffort = 'off';
|
||||
private _systemPrompt: string = '';
|
||||
|
||||
|
|
@ -38,13 +43,42 @@ 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 unforcedThinkingEffort: ThinkingEffort | undefined;
|
||||
let thinkingEffort: ThinkingEffort | undefined;
|
||||
if (changed.thinkingEffort !== undefined) {
|
||||
unforcedThinkingEffort = resolveThinkingEffort(
|
||||
changed.thinkingEffort,
|
||||
this.agent.kimiConfig?.thinking,
|
||||
targetModel,
|
||||
kimiProvider,
|
||||
);
|
||||
} else if (changed.modelAlias !== undefined) {
|
||||
unforcedThinkingEffort = resolveThinkingEffort(
|
||||
this._modelAlias === undefined ? undefined : this._unforcedThinkingEffort,
|
||||
this.agent.kimiConfig?.thinking,
|
||||
targetModel,
|
||||
kimiProvider,
|
||||
);
|
||||
}
|
||||
if (unforcedThinkingEffort !== undefined) {
|
||||
thinkingEffort =
|
||||
resolveKimiEnvThinkingEffort(unforcedThinkingEffort, kimiProvider) ??
|
||||
unforcedThinkingEffort;
|
||||
}
|
||||
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,24 +90,9 @@ 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 (unforcedThinkingEffort !== undefined && thinkingEffort !== undefined) {
|
||||
this._unforcedThinkingEffort = unforcedThinkingEffort;
|
||||
this._thinkingEffort = thinkingEffort;
|
||||
}
|
||||
if (changed.systemPrompt !== undefined) {
|
||||
this._systemPrompt = changed.systemPrompt;
|
||||
|
|
@ -81,7 +100,21 @@ export class ConfigState {
|
|||
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 {
|
||||
|
|
@ -122,16 +155,16 @@ export class ConfigState {
|
|||
// from config.provider — the main loop AND full-history compaction — carries it:
|
||||
// - withThinking: preserve thinking during compaction (#464)
|
||||
// - sampling params: KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P
|
||||
// - thinking.effort: KIMI_MODEL_THINKING_EFFORT (forces an effort, only while thinking is on)
|
||||
// - thinking.effort: the resolved ConfigState value, including the
|
||||
// KIMI_MODEL_THINKING_EFFORT override while thinking is on
|
||||
// - thinking.keep: env KIMI_MODEL_THINKING_KEEP > config thinking.keep > default "all"
|
||||
// (only while thinking is on). Drives Kimi's `thinking.keep` and, on the
|
||||
// Anthropic path, a `context_management` `clear_thinking_20251015` edit.
|
||||
const provider = createProvider(this.providerConfig).withThinking(this.thinkingEffort);
|
||||
const withSampling = applyKimiEnvSamplingParams(provider);
|
||||
const withEffort = applyKimiEnvThinkingEffort(withSampling, this.thinkingEffort);
|
||||
const configKeep = this.agent.kimiConfig?.thinking?.keep;
|
||||
const withKimiKeep = applyKimiEnvThinkingKeep(
|
||||
withEffort,
|
||||
withSampling,
|
||||
this.thinkingEffort,
|
||||
undefined,
|
||||
configKeep,
|
||||
|
|
@ -157,9 +190,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 +239,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -38,24 +38,21 @@ export function applyKimiEnvSamplingParams(
|
|||
}
|
||||
|
||||
/**
|
||||
* Force a specific thinking effort via `KIMI_MODEL_THINKING_EFFORT`, bypassing
|
||||
* the model's declared `support_efforts`. Applied in `ConfigState.provider`
|
||||
* after `withThinking`, and only while thinking is on — effort has no meaning
|
||||
* when thinking is disabled. The value is forwarded verbatim as
|
||||
* `thinking.effort`, so callers can target a model that accepts an effort but
|
||||
* does not advertise one via `support_efforts`.
|
||||
* Resolve the operational `KIMI_MODEL_THINKING_EFFORT` override after the
|
||||
* model-aware effort has been resolved. The override intentionally bypasses
|
||||
* `support_efforts`, but cannot turn Thinking on after the user disabled it.
|
||||
*
|
||||
* Non-Kimi providers — and an unset/blank value — are returned unchanged.
|
||||
* Provider identity is supplied separately from the wire adapter so a Kimi
|
||||
* provider routed through the Anthropic protocol still receives Kimi semantics.
|
||||
*/
|
||||
export function applyKimiEnvThinkingEffort(
|
||||
provider: ChatProvider,
|
||||
export function resolveKimiEnvThinkingEffort(
|
||||
thinkingEffort: ThinkingEffort,
|
||||
kimiProvider: boolean,
|
||||
env: Env = process.env,
|
||||
): ChatProvider {
|
||||
if (!(provider instanceof KimiChatProvider)) return provider;
|
||||
): ThinkingEffort | undefined {
|
||||
if (!kimiProvider || thinkingEffort === 'off') return undefined;
|
||||
const effort = env['KIMI_MODEL_THINKING_EFFORT']?.trim();
|
||||
if (effort === undefined || effort.length === 0 || thinkingEffort === 'off') return provider;
|
||||
return provider.withExtraBody({ thinking: { effort } });
|
||||
return effort === undefined || effort.length === 0 ? undefined : effort;
|
||||
}
|
||||
|
||||
const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']);
|
||||
|
|
|
|||
|
|
@ -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,6 +272,7 @@ function toKosongProviderConfig(
|
|||
apiKey: providerApiKey(provider),
|
||||
...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}),
|
||||
...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}),
|
||||
...(provider.type === 'kimi' ? { kimiThinking: true } : {}),
|
||||
...(betaApi !== undefined ? { betaApi } : {}),
|
||||
// Session affinity: Anthropic's analog of OpenAI `prompt_cache_key` is
|
||||
// `metadata.user_id` on the Messages API (cache-affinity / end-user id).
|
||||
|
|
@ -308,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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { emptyUsage } from '@moonshot-ai/kosong';
|
||||
|
||||
import { InMemoryAgentRecordPersistence } from '../../src/agent/records';
|
||||
import { ProviderManager } from '../../src/session/provider-manager';
|
||||
import type { KimiConfig } from '../../src/config';
|
||||
import {
|
||||
applyEnvModelConfig,
|
||||
ENV_MODEL_ALIAS_KEY,
|
||||
getDefaultConfig,
|
||||
type KimiConfig,
|
||||
} from '../../src/config';
|
||||
import { testAgent } from './harness';
|
||||
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
|
||||
|
||||
|
|
@ -202,6 +208,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 +268,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 +324,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 } } : {}),
|
||||
};
|
||||
|
|
@ -386,7 +447,7 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () =
|
|||
}
|
||||
});
|
||||
|
||||
it('injects KIMI_MODEL_THINKING_EFFORT into config.provider when thinking is on', () => {
|
||||
it('keeps the forced Kimi effort synchronized between state and provider', () => {
|
||||
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
|
||||
try {
|
||||
const ctx = kimiAgent();
|
||||
|
|
@ -396,12 +457,110 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () =
|
|||
const gen = Reflect.get(provider as object, '_generationKwargs') as {
|
||||
extra_body?: { thinking?: { type?: string; effort?: string } };
|
||||
};
|
||||
expect(ctx.agent.config.data().thinkingEffort).toBe('max');
|
||||
expect(provider.thinkingEffort).toBe('max');
|
||||
expect(gen.extra_body?.thinking).toMatchObject({ type: 'enabled', effort: 'max' });
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports the forced effort for an env-synthesized boolean Kimi model', () => {
|
||||
vi.stubEnv('KIMI_MODEL_NAME', 'kimi-for-coding');
|
||||
vi.stubEnv('KIMI_MODEL_API_KEY', 'test-key');
|
||||
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
|
||||
try {
|
||||
const config = applyEnvModelConfig(getDefaultConfig());
|
||||
const persistence = new InMemoryAgentRecordPersistence();
|
||||
const ctx = testAgent({
|
||||
initialConfig: config,
|
||||
persistence,
|
||||
providerManager: new ProviderManager({ config }),
|
||||
});
|
||||
|
||||
ctx.agent.config.update({ modelAlias: ENV_MODEL_ALIAS_KEY });
|
||||
|
||||
expect(ctx.agent.config.data().thinkingEffort).toBe('max');
|
||||
expect(persistence.records).toContainEqual(
|
||||
expect.objectContaining({ type: 'config.update', thinkingEffort: 'max' }),
|
||||
);
|
||||
expect(ctx.agent.config.provider.thinkingEffort).toBe('max');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the Kimi force through an Anthropic protocol override', () => {
|
||||
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
|
||||
try {
|
||||
const config: KimiConfig = {
|
||||
providers: { kimi: { type: 'kimi', apiKey: 'test-key' } },
|
||||
models: {
|
||||
'kimi-code-anthropic': {
|
||||
provider: 'kimi',
|
||||
protocol: 'anthropic',
|
||||
model: 'kimi-code',
|
||||
maxContextSize: 128_000,
|
||||
capabilities: ['thinking'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx = testAgent({
|
||||
initialConfig: config,
|
||||
providerManager: new ProviderManager({ config }),
|
||||
});
|
||||
|
||||
ctx.agent.config.update({
|
||||
modelAlias: 'kimi-code-anthropic',
|
||||
thinkingEffort: 'high',
|
||||
});
|
||||
|
||||
expect(ctx.agent.config.data().thinkingEffort).toBe('max');
|
||||
expect(ctx.agent.config.provider.thinkingEffort).toBe('max');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not carry the Kimi force into a non-Kimi model switch', () => {
|
||||
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
|
||||
try {
|
||||
const config: KimiConfig = {
|
||||
providers: {
|
||||
kimi: { type: 'kimi', apiKey: 'test-key' },
|
||||
anthropic: { type: 'anthropic', apiKey: 'test-key' },
|
||||
},
|
||||
models: {
|
||||
'kimi-code': {
|
||||
provider: 'kimi',
|
||||
model: 'kimi-code',
|
||||
maxContextSize: 128_000,
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high'],
|
||||
},
|
||||
claude: {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
maxContextSize: 200_000,
|
||||
capabilities: ['thinking'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx = testAgent({
|
||||
initialConfig: config,
|
||||
providerManager: new ProviderManager({ config }),
|
||||
});
|
||||
ctx.agent.config.update({ modelAlias: 'kimi-code', thinkingEffort: 'high' });
|
||||
|
||||
ctx.agent.config.update({ modelAlias: 'claude' });
|
||||
|
||||
expect(ctx.agent.config.data().thinkingEffort).toBe('high');
|
||||
expect(ctx.agent.config.provider.thinkingEffort).toBe('high');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT inject KIMI_MODEL_THINKING_EFFORT into config.provider when thinking is off', () => {
|
||||
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
|
||||
try {
|
||||
|
|
@ -412,6 +571,8 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () =
|
|||
const gen = Reflect.get(provider as object, '_generationKwargs') as {
|
||||
extra_body?: { thinking?: { effort?: string } };
|
||||
};
|
||||
expect(ctx.agent.config.data().thinkingEffort).toBe('off');
|
||||
expect(provider.thinkingEffort).toBe('off');
|
||||
expect(gen.extra_body?.thinking?.effort).toBeUndefined();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
|
|
|
|||
|
|
@ -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": "<time>" }
|
||||
[wire] config.update { "modelAlias": "changed-model", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual" }
|
||||
[wire] config.update { "modelAlias": "changed-model", "thinkingEffort": "off", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "changed-model", "thinkingEffort": "off", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual" }
|
||||
[wire] config.update { "systemPrompt": "Changed system prompt.", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual" }
|
||||
[wire] tools.set_active_tools { "names": [], "time": "<time>" }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ModelAlias } from '../../../src/config';
|
||||
import { defaultThinkingEffortFor, resolveThinkingEffort } from '../../../src/agent/config/thinking';
|
||||
import {
|
||||
defaultThinkingEffortFor,
|
||||
resolveThinkingEffort,
|
||||
supportsThinkingEffort,
|
||||
} from '../../../src/agent/config/thinking';
|
||||
|
||||
function model(overrides: Partial<ModelAlias> = {}): ModelAlias {
|
||||
return {
|
||||
|
|
@ -19,7 +23,7 @@ const effortModel = model({
|
|||
});
|
||||
const effortModelWithDefault = model({
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high'],
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'max',
|
||||
});
|
||||
const alwaysThinkingModel = model({ capabilities: ['thinking', 'always_thinking'] });
|
||||
|
|
@ -41,6 +45,18 @@ describe('defaultThinkingEffortFor', () => {
|
|||
expect(defaultThinkingEffortFor(effortModelWithDefault)).toBe('max');
|
||||
});
|
||||
|
||||
it('ignores a defaultEffort that is not declared in supportEfforts', () => {
|
||||
expect(
|
||||
defaultThinkingEffortFor(
|
||||
model({
|
||||
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(defaultThinkingEffortFor(effortModel)).toBe('medium');
|
||||
|
|
@ -109,6 +125,19 @@ describe('resolveThinkingEffort', () => {
|
|||
expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off');
|
||||
expect(resolveThinkingEffort(undefined, { enabled: false }, booleanModel)).toBe('off');
|
||||
});
|
||||
|
||||
it('falls back to the model default for an unsupported Kimi effort', () => {
|
||||
expect(resolveThinkingEffort('ultra', undefined, effortModel, true)).toBe('medium');
|
||||
});
|
||||
|
||||
it('projects a concrete effort to on for a boolean-only Kimi model', () => {
|
||||
expect(resolveThinkingEffort('ultra', undefined, booleanModel, true)).toBe('on');
|
||||
});
|
||||
|
||||
it('reports unsupported concrete efforts only for Kimi effort models', () => {
|
||||
expect(supportsThinkingEffort('ultra', effortModel, true)).toBe(false);
|
||||
expect(supportsThinkingEffort('ultra', effortModel, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultThinkingEffortFor overrides', () => {
|
||||
|
|
|
|||
|
|
@ -132,7 +132,16 @@ describe('llm request trace records', () => {
|
|||
try {
|
||||
const persistence = new InMemoryAgentRecordPersistence();
|
||||
const ctx = testAgent({ persistence });
|
||||
ctx.configure();
|
||||
ctx.configure({
|
||||
modelCapabilities: {
|
||||
image_in: false,
|
||||
video_in: false,
|
||||
audio_in: false,
|
||||
thinking: true,
|
||||
tool_use: true,
|
||||
max_context_tokens: 1_000_000,
|
||||
},
|
||||
});
|
||||
ctx.agent.config.update({ thinkingEffort: 'high' });
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { describe, expect, it } from 'vitest';
|
|||
import {
|
||||
applyAnthropicThinkingKeep,
|
||||
applyKimiEnvSamplingParams,
|
||||
applyKimiEnvThinkingEffort,
|
||||
applyKimiEnvThinkingKeep,
|
||||
resolveKimiEnvThinkingEffort,
|
||||
} from '../../src/config/kimi-env-params';
|
||||
import { KimiError } from '../../src/errors';
|
||||
|
||||
|
|
@ -113,44 +113,38 @@ describe('applyKimiEnvThinkingKeep', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('applyKimiEnvThinkingEffort', () => {
|
||||
it('injects thinking.effort when thinking is on', () => {
|
||||
const out = applyKimiEnvThinkingEffort(kimi(), 'high', {
|
||||
KIMI_MODEL_THINKING_EFFORT: 'max',
|
||||
});
|
||||
expect(genState(out).extra_body?.thinking?.effort).toBe('max');
|
||||
});
|
||||
|
||||
it('forces the effort even when the model does not declare it', () => {
|
||||
// kimi() has no support_efforts, so withThinking('high') carries no effort;
|
||||
// the env var injects one anyway, bypassing the support_efforts gate.
|
||||
const provider = kimi().withThinking('high');
|
||||
const out = applyKimiEnvThinkingEffort(provider, 'high', {
|
||||
KIMI_MODEL_THINKING_EFFORT: 'max',
|
||||
});
|
||||
expect(genState(out).extra_body?.thinking).toEqual({ type: 'enabled', effort: 'max' });
|
||||
});
|
||||
|
||||
it('does NOT inject thinking.effort when thinking is off', () => {
|
||||
const out = applyKimiEnvThinkingEffort(kimi(), 'off', {
|
||||
KIMI_MODEL_THINKING_EFFORT: 'max',
|
||||
});
|
||||
expect(genState(out).extra_body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the same provider when the env var is unset or blank', () => {
|
||||
const provider = kimi();
|
||||
expect(applyKimiEnvThinkingEffort(provider, 'high', {})).toBe(provider);
|
||||
describe('resolveKimiEnvThinkingEffort', () => {
|
||||
it('returns the trimmed force override for an enabled Kimi model', () => {
|
||||
expect(
|
||||
applyKimiEnvThinkingEffort(provider, 'high', { KIMI_MODEL_THINKING_EFFORT: ' ' }),
|
||||
).toBe(provider);
|
||||
resolveKimiEnvThinkingEffort('high', true, {
|
||||
KIMI_MODEL_THINKING_EFFORT: ' max ',
|
||||
}),
|
||||
).toBe('max');
|
||||
});
|
||||
|
||||
it('leaves non-kimi providers untouched', () => {
|
||||
const stub = { name: 'stub' } as unknown as ChatProvider;
|
||||
it('does not override an explicit off effort', () => {
|
||||
expect(
|
||||
applyKimiEnvThinkingEffort(stub, 'high', { KIMI_MODEL_THINKING_EFFORT: 'max' }),
|
||||
).toBe(stub);
|
||||
resolveKimiEnvThinkingEffort('off', true, {
|
||||
KIMI_MODEL_THINKING_EFFORT: 'max',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores an unset or blank force override', () => {
|
||||
expect(resolveKimiEnvThinkingEffort('high', true, {})).toBeUndefined();
|
||||
expect(
|
||||
resolveKimiEnvThinkingEffort('high', true, {
|
||||
KIMI_MODEL_THINKING_EFFORT: ' ',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not apply the Kimi force override to another provider', () => {
|
||||
expect(
|
||||
resolveKimiEnvThinkingEffort('high', false, {
|
||||
KIMI_MODEL_THINKING_EFFORT: 'max',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1040,7 +1040,7 @@ describe('per-model protocol routing', () => {
|
|||
});
|
||||
|
||||
describe('resolveRuntimeProvider model overrides', () => {
|
||||
it('passes overridden supportEfforts to the kimi provider config', () => {
|
||||
it('keeps supportEfforts out of the kimi provider config', () => {
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
|
|
@ -1054,9 +1054,7 @@ describe('resolveRuntimeProvider model overrides', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'kimi',
|
||||
supportEfforts: ['low', 'high'],
|
||||
});
|
||||
expect(resolved.provider).toMatchObject({ type: 'kimi' });
|
||||
expect(resolved.provider).not.toHaveProperty('supportEfforts');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
* Provider adapters receive an already-resolved effort. Adapters with a native
|
||||
* effort field pass concrete strings through to their upstream API; model
|
||||
* compatibility and fallback are resolved before this boundary.
|
||||
*/
|
||||
export type ThinkingEffort = 'off' | 'on' | (string & {});
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export interface AnthropicOptions {
|
|||
* encode a parseable Claude version. Leave undefined to infer from the name.
|
||||
*/
|
||||
adaptiveThinking?: boolean | undefined;
|
||||
kimiThinking?: boolean | undefined;
|
||||
/**
|
||||
* Use the Anthropic **beta** Messages API (`client.beta.messages.create`,
|
||||
* `POST /v1/messages?beta=true`) instead of the standard Messages API.
|
||||
|
|
@ -129,16 +130,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 +327,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 +350,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 +362,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' } };
|
||||
|
|
@ -995,6 +950,7 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
private _defaultHeaders: Record<string, string | null> | undefined;
|
||||
private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined;
|
||||
private _adaptiveThinking: boolean | undefined;
|
||||
private readonly _kimiThinking: boolean;
|
||||
private _betaApi: boolean;
|
||||
private _explicitMaxTokens: boolean;
|
||||
|
||||
|
|
@ -1003,6 +959,7 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
this._stream = options.stream ?? true;
|
||||
this._metadata = options.metadata;
|
||||
this._adaptiveThinking = options.adaptiveThinking;
|
||||
this._kimiThinking = options.kimiThinking ?? false;
|
||||
this._betaApi = options.betaApi ?? false;
|
||||
this._apiKey =
|
||||
options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey;
|
||||
|
|
@ -1022,35 +979,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';
|
||||
}
|
||||
|
||||
|
|
@ -1304,48 +1244,40 @@ 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;
|
||||
thinking = { type: 'disabled' };
|
||||
} else if (this._kimiThinking) {
|
||||
thinking = { type: 'enabled' } as MessageCreateParams['thinking'];
|
||||
outputConfig =
|
||||
effort === 'on' ? undefined : ({ effort } as MessageCreateParams['output_config']);
|
||||
} 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 clamped = clampEffort(effort, this._model, adaptive);
|
||||
if (clamped === 'off') {
|
||||
throw new Error('Non-off thinking effort unexpectedly clamped to off.');
|
||||
}
|
||||
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<AnthropicGenerationKwargs> = {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -47,10 +47,6 @@ export interface KimiOptions {
|
|||
stream?: boolean | undefined;
|
||||
defaultHeaders?: Record<string, string> | 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. */
|
||||
supportEfforts?: readonly string[] | undefined;
|
||||
clientFactory?: (auth: ProviderRequestAuth) => OpenAI;
|
||||
}
|
||||
|
||||
|
|
@ -407,7 +403,6 @@ export class KimiChatProvider implements ChatProvider {
|
|||
private _baseUrl: string;
|
||||
private _defaultHeaders: Record<string, string> | undefined;
|
||||
private _generationKwargs: GenerationKwargs;
|
||||
private readonly _supportEfforts: readonly string[];
|
||||
private _client: OpenAI | undefined;
|
||||
private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined;
|
||||
private _files: KimiFiles | undefined;
|
||||
|
|
@ -421,7 +416,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
|
||||
|
|
@ -558,12 +552,7 @@ export class KimiChatProvider implements ChatProvider {
|
|||
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' };
|
||||
thinking = effort === 'on' ? { type: 'enabled' } : { type: 'enabled', effort };
|
||||
}
|
||||
// Replace extra_body.thinking wholesale so a stale `effort` from a previous
|
||||
// withThinking call can never linger on a disabled or non-effort thinking
|
||||
|
|
|
|||
|
|
@ -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<T extends { type: string }>(
|
|||
): 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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ import {
|
|||
TOOL_RESULT_MEDIA_PLACEHOLDER,
|
||||
TOOL_RESULT_MEDIA_PROMPT,
|
||||
type ToolMessageConversion,
|
||||
reasoningEffortToThinkingEffort,
|
||||
thinkingEffortToReasoningEffort,
|
||||
toolToOpenAI,
|
||||
} from './openai-common';
|
||||
import {
|
||||
|
|
@ -522,7 +520,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<string, unknown> {
|
||||
|
|
@ -619,7 +618,7 @@ export class OpenAILegacyChatProvider implements ChatProvider {
|
|||
}
|
||||
|
||||
withThinking(effort: ThinkingEffort): OpenAILegacyChatProvider {
|
||||
const reasoningEffort = thinkingEffortToReasoningEffort(effort);
|
||||
const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort;
|
||||
const clone = this._clone();
|
||||
clone._reasoningEffort = reasoningEffort;
|
||||
return clone;
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ import {
|
|||
TOOL_RESULT_MEDIA_PLACEHOLDER,
|
||||
TOOL_RESULT_MEDIA_PROMPT,
|
||||
type ToolMessageConversion,
|
||||
reasoningEffortToThinkingEffort,
|
||||
thinkingEffortToReasoningEffort,
|
||||
} from './openai-common';
|
||||
import {
|
||||
mergeRequestHeaders,
|
||||
|
|
@ -1056,7 +1054,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<string, unknown> {
|
||||
|
|
@ -1145,7 +1145,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider {
|
|||
}
|
||||
|
||||
withThinking(effort: ThinkingEffort): OpenAIResponsesChatProvider {
|
||||
const reasoningEffort = thinkingEffortToReasoningEffort(effort);
|
||||
const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort;
|
||||
const clone = this._clone();
|
||||
clone._generationKwargs = {
|
||||
...clone._generationKwargs,
|
||||
|
|
|
|||
|
|
@ -1585,12 +1585,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 () => {
|
||||
|
|
@ -1615,7 +1615,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',
|
||||
|
|
@ -1642,6 +1642,64 @@ describe('AnthropicChatProvider', () => {
|
|||
expect(body['output_config']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Kimi thinking mode sends concrete effort without budget conversion', async () => {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'kimi-for-coding',
|
||||
apiKey: 'test-key',
|
||||
defaultMaxTokens: 1024,
|
||||
stream: false,
|
||||
kimiThinking: true,
|
||||
}).withThinking('max');
|
||||
const body = await captureRequestBody(provider, '', [], thinkHistory);
|
||||
|
||||
expect(body['thinking']).toEqual({ type: 'enabled' });
|
||||
expect(body['output_config']).toEqual({ effort: 'max' });
|
||||
});
|
||||
|
||||
it('Kimi thinking mode passes concrete efforts through and omits only on', async () => {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'kimi-for-coding',
|
||||
apiKey: 'test-key',
|
||||
defaultMaxTokens: 1024,
|
||||
stream: false,
|
||||
kimiThinking: true,
|
||||
});
|
||||
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']).toEqual(
|
||||
requested === 'on' ? undefined : { effort: requested },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('Kimi thinking mode keeps thinking off clean', async () => {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'kimi-for-coding',
|
||||
apiKey: 'test-key',
|
||||
defaultMaxTokens: 1024,
|
||||
stream: false,
|
||||
kimiThinking: true,
|
||||
}).withThinking('off');
|
||||
const body = await captureRequestBody(provider, '', [], thinkHistory);
|
||||
|
||||
expect(body['thinking']).toEqual({ type: 'disabled' });
|
||||
expect(body['output_config']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('thinkingEffort reads back Kimi concrete efforts and boolean on', () => {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'kimi-for-coding',
|
||||
apiKey: 'test-key',
|
||||
defaultMaxTokens: 1024,
|
||||
stream: false,
|
||||
kimiThinking: true,
|
||||
});
|
||||
expect(provider.withThinking('max').thinkingEffort).toBe('max');
|
||||
expect(provider.withThinking('xhigh').thinkingEffort).toBe('xhigh');
|
||||
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',
|
||||
|
|
@ -1656,22 +1714,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 () => {
|
||||
|
|
@ -1733,11 +1787,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);
|
||||
|
||||
|
|
@ -1767,11 +1820,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);
|
||||
|
|
@ -1780,7 +1832,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);
|
||||
|
||||
|
|
@ -1903,36 +1955,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);
|
||||
|
|
@ -1941,26 +1988,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();
|
||||
}
|
||||
|
|
@ -2071,9 +2125,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', () => {
|
||||
|
|
|
|||
|
|
@ -23,15 +23,11 @@ function makeChatCompletionResponse(model: string = 'test-model') {
|
|||
};
|
||||
}
|
||||
|
||||
function createProvider(
|
||||
stream: boolean = false,
|
||||
supportEfforts?: readonly string[],
|
||||
): KimiChatProvider {
|
||||
function createProvider(stream: boolean = false): KimiChatProvider {
|
||||
return new KimiChatProvider({
|
||||
model: 'kimi-k2-turbo-preview',
|
||||
apiKey: 'test-key',
|
||||
stream,
|
||||
supportEfforts,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -901,7 +897,7 @@ describe('KimiChatProvider', () => {
|
|||
|
||||
expect(getGenerationState(provider)).toEqual({
|
||||
extra_body: {
|
||||
thinking: { type: 'enabled' },
|
||||
thinking: { type: 'enabled', effort: 'high' },
|
||||
},
|
||||
max_tokens: 512,
|
||||
});
|
||||
|
|
@ -938,7 +934,7 @@ describe('KimiChatProvider', () => {
|
|||
});
|
||||
|
||||
describe('with thinking', () => {
|
||||
it('model without support_efforts omits effort', async () => {
|
||||
it('sends concrete effort strings verbatim', async () => {
|
||||
const provider = createProvider().withThinking('high');
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
|
||||
|
|
@ -946,12 +942,12 @@ describe('KimiChatProvider', () => {
|
|||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(body['reasoning_effort']).toBeUndefined();
|
||||
expect(body['thinking']).toEqual({ type: 'enabled' });
|
||||
expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' });
|
||||
expect(body['extra_body']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('effort-capable model sends thinking.effort', async () => {
|
||||
const provider = createProvider(false, ['low', 'high', 'max']).withThinking('high');
|
||||
const provider = createProvider().withThinking('high');
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
|
||||
];
|
||||
|
|
@ -962,7 +958,7 @@ describe('KimiChatProvider', () => {
|
|||
});
|
||||
|
||||
it('effort-capable model passes max through to thinking.effort (no clamp)', async () => {
|
||||
const provider = createProvider(false, ['low', 'high', 'max']).withThinking('max');
|
||||
const provider = createProvider().withThinking('max');
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
|
||||
];
|
||||
|
|
@ -972,7 +968,7 @@ describe('KimiChatProvider', () => {
|
|||
});
|
||||
|
||||
it('hoists thinking disabled and clears reasoning_effort for off', async () => {
|
||||
const provider = createProvider(false, ['low', 'high', 'max']).withThinking('off');
|
||||
const provider = createProvider().withThinking('off');
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
|
||||
];
|
||||
|
|
@ -983,22 +979,22 @@ describe('KimiChatProvider', () => {
|
|||
expect(body['extra_body']).toBeUndefined();
|
||||
});
|
||||
|
||||
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.
|
||||
it('omits the effort only for the boolean on signal', async () => {
|
||||
for (const effort of ['xhigh', 'on', 'foo']) {
|
||||
const provider = createProvider(false, ['low', 'high', 'max']).withThinking(effort);
|
||||
const provider = createProvider().withThinking(effort);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
expect(body['reasoning_effort']).toBeUndefined();
|
||||
expect(body['thinking']).toEqual({ type: 'enabled' });
|
||||
expect(body['thinking']).toEqual(
|
||||
effort === 'on' ? { type: 'enabled' } : { type: 'enabled', effort },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('thinkingEffort property reflects the configured effort', () => {
|
||||
const provider = createProvider(false, ['low', 'high', 'max']);
|
||||
const provider = createProvider();
|
||||
expect(provider.thinkingEffort).toBeNull();
|
||||
|
||||
expect(provider.withThinking('high').thinkingEffort).toBe('high');
|
||||
|
|
@ -1007,17 +1003,15 @@ describe('KimiChatProvider', () => {
|
|||
expect(provider.withThinking('off').thinkingEffort).toBe('off');
|
||||
});
|
||||
|
||||
it("thinkingEffort falls back to 'on' when support_efforts is absent", () => {
|
||||
it("thinkingEffort reports 'on' only for the boolean on signal", () => {
|
||||
const provider = createProvider();
|
||||
// Without declared efforts the wire object carries no `effort`, so the
|
||||
// getter reports boolean-thinking ("on") for any non-off effort.
|
||||
expect(provider.withThinking('high').thinkingEffort).toBe('on');
|
||||
expect(provider.withThinking('high').thinkingEffort).toBe('high');
|
||||
expect(provider.withThinking('on').thinkingEffort).toBe('on');
|
||||
expect(provider.withThinking('off').thinkingEffort).toBe('off');
|
||||
});
|
||||
|
||||
it('replaces the previous thinking effort when called again', () => {
|
||||
const provider = createProvider(false, ['low', 'high', 'max'])
|
||||
const provider = createProvider()
|
||||
.withThinking('high')
|
||||
.withThinking('off');
|
||||
|
||||
|
|
@ -1572,7 +1566,7 @@ describe('KimiChatProvider', () => {
|
|||
|
||||
describe('withThinking medium', () => {
|
||||
it('maps medium -> thinking.effort=medium for an effort-capable model', () => {
|
||||
const provider = createProvider(false, ['low', 'medium', 'high']).withThinking('medium');
|
||||
const provider = createProvider().withThinking('medium');
|
||||
expect(provider.thinkingEffort).toBe('medium');
|
||||
});
|
||||
});
|
||||
|
|
@ -1590,7 +1584,7 @@ describe('KimiChatProvider', () => {
|
|||
});
|
||||
|
||||
it('field-merges thinking when called after withThinking', async () => {
|
||||
const provider = createProvider(false, ['low', 'high', 'max'])
|
||||
const provider = createProvider()
|
||||
.withThinking('high')
|
||||
.withExtraBody({ thinking: { keep: 'all' } });
|
||||
const history: Message[] = [
|
||||
|
|
@ -1619,7 +1613,7 @@ describe('KimiChatProvider', () => {
|
|||
.withThinking('high');
|
||||
|
||||
expect(getGenerationState(provider).extra_body).toEqual({
|
||||
thinking: { type: 'enabled', keep: 'all' },
|
||||
thinking: { type: 'enabled', effort: 'high', keep: 'all' },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1632,7 +1626,7 @@ describe('KimiChatProvider', () => {
|
|||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(body['thinking']).toEqual({ type: 'enabled' });
|
||||
expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' });
|
||||
});
|
||||
|
||||
it('treats empty thinking patch as noop, preserving prior withThinking', async () => {
|
||||
|
|
@ -1642,7 +1636,7 @@ describe('KimiChatProvider', () => {
|
|||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(body['thinking']).toEqual({ type: 'enabled' });
|
||||
expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -923,7 +923,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 +947,50 @@ 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('does not filter concrete efforts through a client-side allow-list', async () => {
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
|
||||
];
|
||||
const provider = createProvider({
|
||||
model: 'kimi-for-coding',
|
||||
});
|
||||
|
||||
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']).toBe('xhigh');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1060,9 +1060,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',
|
||||
|
|
@ -1072,7 +1070,36 @@ describe('OpenAIResponsesChatProvider', () => {
|
|||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect((body['reasoning'] as Record<string, unknown>)['effort']).toBe('xhigh');
|
||||
expect((body['reasoning'] as Record<string, unknown>)['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<string, unknown>)['effort']).toBe('extreme');
|
||||
expect(provider.thinkingEffort).toBe('extreme');
|
||||
});
|
||||
|
||||
it('does not filter concrete efforts through a client-side allow-list', async () => {
|
||||
const provider = new OpenAIResponsesChatProvider({
|
||||
model: 'kimi-for-coding',
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
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<string, unknown>)['effort']).toBe('max');
|
||||
expect((xhighBody['reasoning'] as Record<string, unknown>)['effort']).toBe('xhigh');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -480,6 +480,7 @@ export type AgentPhase =
|
|||
export interface AgentStatusUpdatedEvent {
|
||||
readonly type: 'agent.status.updated';
|
||||
readonly model?: string;
|
||||
readonly thinkingEffort?: string;
|
||||
readonly contextTokens?: number;
|
||||
readonly maxContextTokens?: number;
|
||||
readonly contextUsage?: number;
|
||||
|
|
@ -1348,6 +1349,7 @@ export const agentPhaseSchema = z.discriminatedUnion('kind', [
|
|||
export const agentStatusUpdatedEventSchema = z.object({
|
||||
type: z.literal('agent.status.updated'),
|
||||
model: z.string().optional(),
|
||||
thinkingEffort: z.string().optional(),
|
||||
contextTokens: z.number().optional(),
|
||||
maxContextTokens: z.number().optional(),
|
||||
contextUsage: z.number().optional(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue