feat: add KIMI_MODEL_THINKING_EFFORT to force a thinking effort (#1275)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* feat: add KIMI_MODEL_THINKING_EFFORT to force a thinking effort

Send thinking effort only when the model declares it in support_efforts, and add the KIMI_MODEL_THINKING_EFFORT environment variable as an escape hatch to force a specific effort regardless of declared support.

* test: align thinking effort expectations with support_efforts gating

Update the kimi adapter e2e and compaction tests that asserted the previous pass-through behavior on models without support_efforts.
This commit is contained in:
liruifengv 2026-07-01 21:20:27 +08:00 committed by GitHub
parent 54703d9457
commit a5db546d77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 133 additions and 28 deletions

View file

@ -130,6 +130,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `kimi` provider only | Positive integer; `0` or negative disables clamping |
| `KIMI_MODEL_TEMPERATURE` | Sampling temperature for every request; applies to the `kimi` provider only (global — independent of `KIMI_MODEL_NAME`) | Number, e.g. `0.3` |
| `KIMI_MODEL_TOP_P` | Nucleus-sampling `top_p` for every request; applies to the `kimi` provider only (global) | Number, e.g. `0.95` |
| `KIMI_MODEL_THINKING_EFFORT` | Force a specific thinking effort on the wire (`thinking.effort`), bypassing the model's declared `support_efforts`; applies to the `kimi` provider only, and only while Thinking is on | An effort value, e.g. `max` |
| `KIMI_MODEL_THINKING_KEEP` | Moonshot preserved-thinking passthrough (`thinking.keep`); applies to the `kimi` provider only, and only while Thinking is on | A value the API accepts, e.g. `all` |
| `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` |
| `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable |

View file

@ -130,6 +130,7 @@ kimi
| `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求的 `max_completion_tokens` 硬上限,仅对 `kimi` 供应商生效 | 正整数;`0` 或负数禁用 clamp |
| `KIMI_MODEL_TEMPERATURE` | 每次请求的采样温度,仅对 `kimi` 供应商生效(全局生效,不依赖 `KIMI_MODEL_NAME` | 数字,如 `0.3` |
| `KIMI_MODEL_TOP_P` | 每次请求的核采样 `top_p`,仅对 `kimi` 供应商生效(全局生效) | 数字,如 `0.95` |
| `KIMI_MODEL_THINKING_EFFORT` | 在线上强制使用指定的思考强度(`thinking.effort`),绕过模型声明的 `support_efforts`;仅对 `kimi` 供应商生效,且仅在 Thinking 开启时注入 | 思考强度值,如 `max` |
| `KIMI_MODEL_THINKING_KEEP` | Moonshot 保留思考透传(`thinking.keep`),仅对 `kimi` 供应商生效,且仅在 Thinking 开启时注入 | API 接受的值,如 `all` |
| `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` |
| `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 |

View file

@ -6,7 +6,11 @@ import {
type ProviderConfig,
} from '@moonshot-ai/kosong';
import { applyKimiEnvSamplingParams, applyKimiEnvThinkingKeep } from '#/config/kimi-env-params';
import {
applyKimiEnvSamplingParams,
applyKimiEnvThinkingEffort,
applyKimiEnvThinkingKeep,
} from '#/config/kimi-env-params';
import type { Agent } from '..';
import { ErrorCodes, KimiError } from '../../errors';
@ -117,9 +121,12 @@ 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.keep: KIMI_MODEL_THINKING_KEEP (only while thinking is on)
const provider = createProvider(this.providerConfig).withThinking(this.thinkingEffort);
return applyKimiEnvThinkingKeep(applyKimiEnvSamplingParams(provider), this.thinkingEffort);
const withSampling = applyKimiEnvSamplingParams(provider);
const withEffort = applyKimiEnvThinkingEffort(withSampling, this.thinkingEffort);
return applyKimiEnvThinkingKeep(withEffort, this.thinkingEffort);
}
get model(): string {

View file

@ -36,6 +36,27 @@ export function applyKimiEnvSamplingParams(
return Object.keys(kwargs).length > 0 ? provider.withGenerationKwargs(kwargs) : provider;
}
/**
* 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`.
*
* Non-Kimi providers and an unset/blank value are returned unchanged.
*/
export function applyKimiEnvThinkingEffort(
provider: ChatProvider,
thinkingEffort: ThinkingEffort,
env: Env = process.env,
): ChatProvider {
if (!(provider instanceof KimiChatProvider)) return provider;
const effort = env['KIMI_MODEL_THINKING_EFFORT']?.trim();
if (effort === undefined || effort.length === 0 || thinkingEffort === 'off') return provider;
return provider.withExtraBody({ thinking: { effort } });
}
/**
* Apply the Moonshot preserved-thinking passthrough (`KIMI_MODEL_THINKING_KEEP`
* -> `thinking.keep`) to a chat provider. Applied in `ConfigState.provider` after

View file

@ -1874,10 +1874,10 @@ describe('FullCompaction', () => {
expect(callCount).toBe(3);
// The catalogued model declares no supportEfforts, so the kimi provider
// passes the requested effort through verbatim. The agent's stored
// thinkingEffort ('high') is also carried across the compaction (see the
// record assertion below).
expect(providerThinkingEfforts).toEqual(['high', 'high', 'high']);
// 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).
expect(providerThinkingEfforts).toEqual(['on', 'on', 'on']);
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({

View file

@ -289,4 +289,36 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () =
vi.unstubAllEnvs();
}
});
it('injects KIMI_MODEL_THINKING_EFFORT into config.provider when thinking is on', () => {
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
try {
const ctx = kimiAgent();
ctx.agent.config.update({ modelAlias: 'kimi-code', thinkingEffort: 'high' });
const provider = ctx.agent.config.provider;
const gen = Reflect.get(provider as object, '_generationKwargs') as {
extra_body?: { thinking?: { type?: string; effort?: string } };
};
expect(gen.extra_body?.thinking).toEqual({ type: 'enabled', effort: 'max' });
} 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 {
const ctx = kimiAgent();
ctx.agent.config.update({ modelAlias: 'kimi-code', thinkingEffort: 'off' });
const provider = ctx.agent.config.provider;
const gen = Reflect.get(provider as object, '_generationKwargs') as {
extra_body?: { thinking?: { effort?: string } };
};
expect(gen.extra_body?.thinking?.effort).toBeUndefined();
} finally {
vi.unstubAllEnvs();
}
});
});

View file

@ -1,7 +1,11 @@
import { type ChatProvider, KimiChatProvider } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import { applyKimiEnvSamplingParams, applyKimiEnvThinkingKeep } from '../../src/config/kimi-env-params';
import {
applyKimiEnvSamplingParams,
applyKimiEnvThinkingEffort,
applyKimiEnvThinkingKeep,
} from '../../src/config/kimi-env-params';
import { KimiError } from '../../src/errors';
function kimi(): KimiChatProvider {
@ -11,7 +15,7 @@ function kimi(): KimiChatProvider {
interface KimiGenerationState {
temperature?: number;
top_p?: number;
extra_body?: { thinking?: { keep?: unknown } };
extra_body?: { thinking?: { type?: string; effort?: string; keep?: unknown } };
}
function genState(provider: ChatProvider): KimiGenerationState {
@ -78,3 +82,44 @@ describe('applyKimiEnvThinkingKeep', () => {
expect(applyKimiEnvThinkingKeep(stub, 'high', { KIMI_MODEL_THINKING_KEEP: 'all' })).toBe(stub);
});
});
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);
expect(
applyKimiEnvThinkingEffort(provider, 'high', { KIMI_MODEL_THINKING_EFFORT: ' ' }),
).toBe(provider);
});
it('leaves non-kimi providers untouched', () => {
const stub = { name: 'stub' } as unknown as ChatProvider;
expect(
applyKimiEnvThinkingEffort(stub, 'high', { KIMI_MODEL_THINKING_EFFORT: 'max' }),
).toBe(stub);
});
});

View file

@ -513,17 +513,12 @@ export class KimiChatProvider implements ChatProvider {
if (effort === 'off') {
thinking = { type: 'disabled' };
} else {
// When `support_efforts` is present, it is the source of truth: only
// declared values are sent as effort. When it is absent, pass the
// requested effort through verbatim.
const hasDeclaredEfforts = this._supportEfforts.length > 0;
const declared = hasDeclaredEfforts
? this._supportEfforts.includes(effort)
? effort
: undefined
: effort;
thinking =
declared !== undefined ? { type: 'enabled', effort: declared } : { type: 'enabled' };
// 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' };
}
// Replace extra_body.thinking wholesale so a stale `effort` from a previous
// withThinking call can never linger on a disabled or non-effort thinking

View file

@ -149,7 +149,7 @@ describe('e2e: kimi adapter', () => {
model: 'kimi-k2-turbo-preview',
stream: true,
stream_options: { include_usage: true },
thinking: { type: 'enabled', effort: 'high' },
thinking: { type: 'enabled' },
messages: [
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Check the weather.' },

View file

@ -679,7 +679,7 @@ describe('KimiChatProvider', () => {
expect(getGenerationState(provider)).toEqual({
extra_body: {
thinking: { type: 'enabled', effort: 'high' },
thinking: { type: 'enabled' },
},
max_tokens: 512,
});
@ -716,7 +716,7 @@ describe('KimiChatProvider', () => {
});
describe('with thinking', () => {
it('model without support_efforts passes the requested effort through', async () => {
it('model without support_efforts omits effort', async () => {
const provider = createProvider().withThinking('high');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
@ -724,7 +724,7 @@ describe('KimiChatProvider', () => {
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning_effort']).toBeUndefined();
expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' });
expect(body['thinking']).toEqual({ type: 'enabled' });
expect(body['extra_body']).toBeUndefined();
});
@ -785,10 +785,13 @@ describe('KimiChatProvider', () => {
expect(provider.withThinking('off').thinkingEffort).toBe('off');
});
it('thinkingEffort reflects the requested effort when support_efforts is absent', () => {
it("thinkingEffort falls back to 'on' when support_efforts is absent", () => {
const provider = createProvider();
expect(provider.withThinking('high').thinkingEffort).toBe('high');
// 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('on').thinkingEffort).toBe('on');
expect(provider.withThinking('off').thinkingEffort).toBe('off');
});
it('replaces the previous thinking effort when called again', () => {
@ -1338,7 +1341,7 @@ describe('KimiChatProvider', () => {
.withThinking('high');
expect(getGenerationState(provider).extra_body).toEqual({
thinking: { type: 'enabled', effort: 'high', keep: 'all' },
thinking: { type: 'enabled', keep: 'all' },
});
});
@ -1351,7 +1354,7 @@ describe('KimiChatProvider', () => {
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' });
expect(body['thinking']).toEqual({ type: 'enabled' });
});
it('treats empty thinking patch as noop, preserving prior withThinking', async () => {
@ -1361,7 +1364,7 @@ describe('KimiChatProvider', () => {
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' });
expect(body['thinking']).toEqual({ type: 'enabled' });
});
});