mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat: add model alias overrides (#1262)
* feat: add model alias overrides Preserve user model overrides across provider catalog refreshes and resolve effective model metadata for runtime, TUI, protocol, and ACP consumers. * fix: apply model display name overrides Show overridden model display names in the footer, welcome panel, status output, and model switch confirmations. * fix: pass through kimi effort when undeclared Keep support_efforts authoritative when declared, but pass requested Kimi thinking effort through when the model does not declare support_efforts. * fix: honor model overrides in effort commands Use effective model metadata for /effort choices and for always_thinking clamping when resolving thinking effort.
This commit is contained in:
parent
0e279bfd7e
commit
c070fbedde
36 changed files with 750 additions and 91 deletions
5
.changeset/model-alias-overrides.md
Normal file
5
.changeset/model-alias-overrides.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add model alias overrides so manual thinking effort levels and model metadata survive provider catalog refreshes. Set them under `[models."<alias>".overrides]`.
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import type {
|
||||
ExperimentalFeatureState,
|
||||
FlagId,
|
||||
ModelAlias,
|
||||
PermissionMode,
|
||||
Session,
|
||||
ThinkingEffort,
|
||||
import {
|
||||
effectiveModelAlias,
|
||||
type ExperimentalFeatureState,
|
||||
type FlagId,
|
||||
type ModelAlias,
|
||||
type PermissionMode,
|
||||
type Session,
|
||||
type ThinkingEffort,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { EditorSelectorComponent } from '../components/dialogs/editor-selector';
|
||||
|
|
@ -224,10 +225,11 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string):
|
|||
host.showError('No model selected. Run /model to select one first.');
|
||||
return;
|
||||
}
|
||||
const segments = segmentsFor(model);
|
||||
const effective = effectiveModelAlias(model);
|
||||
const segments = segmentsFor(effective);
|
||||
const arg = args.trim().toLowerCase();
|
||||
if (arg.length === 0) {
|
||||
showEffortPicker(host, model, segments);
|
||||
showEffortPicker(host, effective, segments);
|
||||
return;
|
||||
}
|
||||
if (!segments.includes(arg)) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips';
|
||||
import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance';
|
||||
|
|
@ -132,7 +133,8 @@ function formatBadgeElapsed(ms: number): string {
|
|||
|
||||
function modelDisplayName(state: AppState): string {
|
||||
const model = state.availableModels[state.model];
|
||||
return model?.displayName ?? model?.model ?? state.model;
|
||||
const effective = model === undefined ? undefined : effectiveModelAlias(model);
|
||||
return effective?.displayName ?? effective?.model ?? state.model;
|
||||
}
|
||||
|
||||
function shortenCwd(path: string): string {
|
||||
|
|
@ -263,7 +265,8 @@ export class FooterComponent implements Component {
|
|||
const model = modelDisplayName(state);
|
||||
if (model) {
|
||||
const effort = state.thinkingEffort;
|
||||
const currentModel = state.availableModels[state.model];
|
||||
const rawCurrentModel = state.availableModels[state.model];
|
||||
const currentModel = rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel);
|
||||
// Only effort-capable models (those declaring support_efforts) show the
|
||||
// concrete effort; legacy boolean models keep the plain "thinking" suffix.
|
||||
const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import type { Component } from '@earendil-works/pi-tui';
|
|||
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance';
|
||||
import type { AppState } from '#/tui/types';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
|
|
@ -25,6 +27,7 @@ export class WelcomeComponent implements Component {
|
|||
const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s);
|
||||
const isLoggedOut = !this.state.model;
|
||||
const activeModel = this.state.availableModels[this.state.model];
|
||||
const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel);
|
||||
|
||||
if (safeWidth < 24) {
|
||||
const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!');
|
||||
|
|
@ -33,7 +36,7 @@ export class WelcomeComponent implements Component {
|
|||
: chalk.hex(currentTheme.palette.textDim)('Send /help for help information.');
|
||||
const model = isLoggedOut
|
||||
? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider')
|
||||
: (activeModel?.displayName ?? activeModel?.model ?? this.state.model);
|
||||
: (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model);
|
||||
return ['', title, prompt, `Model: ${model}`].map((line) =>
|
||||
truncateToWidth(line, safeWidth, '…'),
|
||||
);
|
||||
|
|
@ -71,7 +74,7 @@ export class WelcomeComponent implements Component {
|
|||
|
||||
const modelValue = isLoggedOut
|
||||
? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider')
|
||||
: (activeModel?.displayName ?? activeModel?.model ?? this.state.model);
|
||||
: (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model);
|
||||
|
||||
const infoLines = [
|
||||
labelStyle('Directory: ') + this.state.workDir,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk';
|
||||
import { effectiveModelAlias, type ModelAlias, type ThinkingEffort } from '@moonshot-ai/kimi-code-sdk';
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
|
|
@ -37,7 +37,8 @@ export interface ModelSelection {
|
|||
}
|
||||
|
||||
export function modelDisplayName(alias: string, model: ModelAlias | undefined): string {
|
||||
return model?.displayName ?? model?.model ?? alias;
|
||||
const effective = model === undefined ? undefined : effectiveModelAlias(model);
|
||||
return effective?.displayName ?? effective?.model ?? alias;
|
||||
}
|
||||
|
||||
export function providerDisplayName(provider: string): string {
|
||||
|
|
@ -49,10 +50,13 @@ export function providerDisplayName(provider: string): string {
|
|||
export function createModelChoiceOptions(
|
||||
models: Record<string, ModelAlias>,
|
||||
): readonly ChoiceOption[] {
|
||||
return Object.entries(models).map(([alias, cfg]) => ({
|
||||
value: alias,
|
||||
label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`,
|
||||
}));
|
||||
return Object.entries(models).map(([alias, cfg]) => {
|
||||
const effective = effectiveModelAlias(cfg);
|
||||
return {
|
||||
value: alias,
|
||||
label: `${modelDisplayName(alias, effective)} (${providerDisplayName(effective.provider)})`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface ModelSelectorOptions {
|
||||
|
|
@ -78,9 +82,10 @@ export interface ModelSelectorOptions {
|
|||
|
||||
function createModelChoices(models: Record<string, ModelAlias>): readonly ModelChoice[] {
|
||||
return Object.entries(models).map(([alias, cfg]) => {
|
||||
const name = modelDisplayName(alias, cfg);
|
||||
const provider = providerDisplayName(cfg.provider);
|
||||
return { alias, model: cfg, name, provider, label: `${name} (${provider})` };
|
||||
const effective = effectiveModelAlias(cfg);
|
||||
const name = modelDisplayName(alias, effective);
|
||||
const provider = providerDisplayName(effective.provider);
|
||||
return { alias, model: effective, name, provider, label: `${name} (${provider})` };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@
|
|||
* separate from the TUI orchestration layer.
|
||||
*/
|
||||
|
||||
import type { ModelAlias, PermissionMode, SessionStatus, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk';
|
||||
import {
|
||||
effectiveModelAlias,
|
||||
type ModelAlias,
|
||||
type PermissionMode,
|
||||
type SessionStatus,
|
||||
type ThinkingEffort,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { PRODUCT_NAME } from '#/constant/app';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
|
|
@ -47,7 +53,8 @@ type Colorize = (text: string) => string;
|
|||
|
||||
function displayModelName(alias: string, models: Record<string, ModelAlias>): string {
|
||||
const model = models[alias];
|
||||
return model?.displayName ?? model?.model ?? alias;
|
||||
const effective = model === undefined ? undefined : effectiveModelAlias(model);
|
||||
return effective?.displayName ?? effective?.model ?? alias;
|
||||
}
|
||||
|
||||
function formatModelStatus(options: StatusReportOptions): string {
|
||||
|
|
|
|||
|
|
@ -144,3 +144,46 @@ describe('FooterComponent', () => {
|
|||
expect(rendered).not.toContain('thinking:high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FooterComponent overrides', () => {
|
||||
it('shows the overridden effort list', () => {
|
||||
const effortModelWithOverride: ModelAlias = {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 262144,
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'max',
|
||||
overrides: { supportEfforts: ['low', 'high'], defaultEffort: 'high' },
|
||||
};
|
||||
const state: AppState = {
|
||||
...appState,
|
||||
thinkingEffort: 'high',
|
||||
availableModels: { 'kimi-k2': effortModelWithOverride },
|
||||
};
|
||||
const footer = new FooterComponent(state);
|
||||
|
||||
expect(footer.render(120).join('\n')).toContain('thinking: high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FooterComponent displayName override', () => {
|
||||
it('renders the overridden display name', () => {
|
||||
const state: AppState = {
|
||||
...appState,
|
||||
model: 'kimi-k2',
|
||||
availableModels: {
|
||||
'kimi-k2': {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 262144,
|
||||
displayName: 'Remote Name',
|
||||
overrides: { displayName: 'Custom Name' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const footer = new FooterComponent(state);
|
||||
|
||||
expect(footer.render(120).join('\n')).toContain('Custom Name');
|
||||
expect(footer.render(120).join('\n')).not.toContain('Remote Name');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -422,3 +422,25 @@ describe('ModelSelectorComponent', () => {
|
|||
expect(text(picker)).toContain('[ Medium ]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelSelectorComponent overrides', () => {
|
||||
it('uses overridden support_efforts for selectable efforts', () => {
|
||||
const picker = new ModelSelectorComponent({
|
||||
models: {
|
||||
kimi: {
|
||||
...effortModel('Kimi K2', ['low', 'high', 'max'], 'max'),
|
||||
overrides: { supportEfforts: ['low', 'high'] },
|
||||
},
|
||||
},
|
||||
currentValue: 'kimi',
|
||||
currentThinkingEffort: 'max',
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
});
|
||||
|
||||
const out = text(picker);
|
||||
expect(out).toContain('Low');
|
||||
expect(out).toContain('High');
|
||||
expect(out).not.toContain('Max');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4684,3 +4684,81 @@ command = "vim"
|
|||
expect(transcript).not.toContain('<hook_result');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/model status displayName override', () => {
|
||||
it('shows the overridden display name in the switch status', async () => {
|
||||
const session = makeSession();
|
||||
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,
|
||||
displayName: 'Kimi K2',
|
||||
capabilities: ['thinking'],
|
||||
},
|
||||
turbo: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-turbo',
|
||||
maxContextSize: 100,
|
||||
displayName: 'Remote Turbo',
|
||||
capabilities: ['thinking'],
|
||||
overrides: { displayName: 'Custom Turbo' },
|
||||
},
|
||||
},
|
||||
defaultModel: 'k2',
|
||||
thinking: { enabled: false },
|
||||
})),
|
||||
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 },
|
||||
});
|
||||
});
|
||||
|
||||
expect(renderTranscript(driver)).toContain('Switched to Custom Turbo with thinking on.');
|
||||
expect(renderTranscript(driver)).not.toContain('Remote Turbo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/effort support_efforts override', () => {
|
||||
it('rejects efforts hidden by support_efforts override', async () => {
|
||||
const session = makeSession();
|
||||
const { driver } = await makeDriver(session, {
|
||||
getConfig: vi.fn(async () => ({
|
||||
models: {
|
||||
k2: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 100,
|
||||
displayName: 'Kimi K2',
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
overrides: { supportEfforts: ['low', 'high'] },
|
||||
},
|
||||
},
|
||||
defaultModel: 'k2',
|
||||
thinking: { enabled: true, effort: 'low' },
|
||||
})),
|
||||
});
|
||||
|
||||
driver.handleUserInput('/effort max');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(renderTranscript(driver)).toContain('Unsupported thinking effort "max" for k2. Available: off, low, high');
|
||||
});
|
||||
expect(renderTranscript(driver)).not.toContain('Switched to Kimi K2 with thinking max.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ 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; recognized Claude models are automatically clamped to the server-side maximum |
|
||||
| `capabilities` | `array<string>` | No | Capability tags to add explicitly: `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 |
|
||||
| `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 |
|
||||
| `adaptive_thinking` | `boolean` | No | `anthropic` provider only. Force adaptive thinking on or off, overriding the version inference based on the model name. Omit to infer automatically (Claude ≥ 4.6 uses adaptive) |
|
||||
|
|
@ -140,6 +142,25 @@ model = "gpt-4.1"
|
|||
max_context_size = 1047576
|
||||
```
|
||||
|
||||
### Model overrides
|
||||
|
||||
Use `[models."<alias>".overrides]` for user overrides that must survive provider-model refreshes. Runtime consumers read the effective value: the override when present, otherwise the top-level field.
|
||||
|
||||
```toml
|
||||
[models."kimi-code/kimi-k2"]
|
||||
provider = "managed:kimi-code"
|
||||
model = "kimi-k2"
|
||||
max_context_size = 262144
|
||||
support_efforts = ["low", "high", "max"]
|
||||
default_effort = "max"
|
||||
|
||||
[models."kimi-code/kimi-k2".overrides]
|
||||
support_efforts = ["low", "high"]
|
||||
default_effort = "high"
|
||||
```
|
||||
|
||||
`[models."<alias>".overrides]` accepts ordinary model fields such as `max_context_size`, `max_output_size`, `capabilities`, `display_name`, `reasoning_key`, `adaptive_thinking`, `support_efforts`, and `default_effort`. It does not accept identity / routing fields: `provider`, `model`, `protocol`, and `beta_api`.
|
||||
|
||||
You can also switch models temporarily without touching the config file — by setting `KIMI_MODEL_*` environment variables, the CLI synthesizes a temporary provider in memory that does not persist after restart. See [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-kimi_model).
|
||||
|
||||
## `thinking`
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ 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`、`image_in`、`video_in`、`audio_in`、`tool_use`。与供应商自动识别的能力取并集,只能追加不能移除 |
|
||||
| `support_efforts` | `array<string>` | 否 | 模型目录声明的 Thinking 档位。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` |
|
||||
| `adaptive_thinking` | `boolean` | 否 | 仅 `anthropic` 供应商。强制开启或关闭 adaptive thinking,覆盖按模型名推断的逻辑。省略时自动推断(Claude ≥ 4.6 使用 adaptive) |
|
||||
|
|
@ -140,6 +142,25 @@ model = "gpt-4.1"
|
|||
max_context_size = 1047576
|
||||
```
|
||||
|
||||
### 模型覆盖项
|
||||
|
||||
如果某些用户覆盖需要在 provider-model 刷新后保留,请写到 `[models."<alias>".overrides]`。运行时读取的是 effective 值:有 override 时用 override,否则用顶层字段。
|
||||
|
||||
```toml
|
||||
[models."kimi-code/kimi-k2"]
|
||||
provider = "managed:kimi-code"
|
||||
model = "kimi-k2"
|
||||
max_context_size = 262144
|
||||
support_efforts = ["low", "high", "max"]
|
||||
default_effort = "max"
|
||||
|
||||
[models."kimi-code/kimi-k2".overrides]
|
||||
support_efforts = ["low", "high"]
|
||||
default_effort = "high"
|
||||
```
|
||||
|
||||
`[models."<alias>".overrides]` 接受普通模型字段,例如 `max_context_size`、`max_output_size`、`capabilities`、`display_name`、`reasoning_key`、`adaptive_thinking`、`support_efforts` 和 `default_effort`。不接受身份 / 路由字段:`provider`、`model`、`protocol` 和 `beta_api`。
|
||||
|
||||
无需修改配置文件也可以临时切换模型——通过 `KIMI_MODEL_*` 环境变量在内存里合成一个临时供应商,详见[用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。
|
||||
|
||||
## `thinking`
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
* allow-list (mirrors `kimi-cli/src/kimi_cli/llm.py:derive_model_capabilities`).
|
||||
*/
|
||||
|
||||
import { effectiveModelAlias } from '@moonshot-ai/agent-core';
|
||||
import type { KimiHarness, ModelAlias } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
/**
|
||||
|
|
@ -55,11 +56,12 @@ export interface AcpModelEntry {
|
|||
const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']);
|
||||
|
||||
export function deriveThinkingSupported(alias: ModelAlias): boolean {
|
||||
const declared = alias.capabilities ?? [];
|
||||
const effective = effectiveModelAlias(alias);
|
||||
const declared = effective.capabilities ?? [];
|
||||
if (declared.includes('thinking') || declared.includes('always_thinking')) return true;
|
||||
const lower = alias.model.toLowerCase();
|
||||
const lower = effective.model.toLowerCase();
|
||||
if (lower.includes('thinking') || lower.includes('reason')) return true;
|
||||
if (TOGGLEABLE_THINKING_MODELS.has(alias.model)) return true;
|
||||
if (TOGGLEABLE_THINKING_MODELS.has(effective.model)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +73,7 @@ export function deriveThinkingSupported(alias: ModelAlias): boolean {
|
|||
* may remove the off option from the client.
|
||||
*/
|
||||
export function deriveAlwaysThinking(alias: ModelAlias): boolean {
|
||||
return (alias.capabilities ?? []).includes('always_thinking');
|
||||
return (effectiveModelAlias(alias).capabilities ?? []).includes('always_thinking');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,9 +82,10 @@ export function deriveAlwaysThinking(alias: ModelAlias): boolean {
|
|||
* boolean models (no `support_efforts`).
|
||||
*/
|
||||
export function deriveDefaultThinkingEffort(alias: ModelAlias): string {
|
||||
const efforts = alias.supportEfforts;
|
||||
const effective = effectiveModelAlias(alias);
|
||||
const efforts = effective.supportEfforts;
|
||||
if (efforts !== undefined && efforts.length > 0) {
|
||||
return alias.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!;
|
||||
return effective.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!;
|
||||
}
|
||||
return 'on';
|
||||
}
|
||||
|
|
@ -109,9 +112,10 @@ export async function listModelsFromHarness(
|
|||
if (models === undefined) return [];
|
||||
const out: AcpModelEntry[] = [];
|
||||
for (const [id, alias] of Object.entries(models)) {
|
||||
const effective = effectiveModelAlias(alias);
|
||||
out.push({
|
||||
id,
|
||||
name: alias.displayName ?? alias.model ?? id,
|
||||
name: effective.displayName ?? effective.model ?? id,
|
||||
thinkingSupported: deriveThinkingSupported(alias),
|
||||
alwaysThinking: deriveAlwaysThinking(alias),
|
||||
defaultThinkingEffort: deriveDefaultThinkingEffort(alias),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { deriveAlwaysThinking, deriveThinkingSupported } from '../src/model-catalog';
|
||||
import {
|
||||
deriveAlwaysThinking,
|
||||
deriveDefaultThinkingEffort,
|
||||
deriveThinkingSupported,
|
||||
} from '../src/model-catalog';
|
||||
|
||||
function alias(model: string, capabilities?: readonly string[]): ModelAlias {
|
||||
return {
|
||||
|
|
@ -35,3 +39,16 @@ describe('deriveAlwaysThinking', () => {
|
|||
expect(deriveAlwaysThinking(alias('some-thinking-model'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveDefaultThinkingEffort', () => {
|
||||
it('uses overridden supportEfforts and defaultEffort', () => {
|
||||
expect(
|
||||
deriveDefaultThinkingEffort({
|
||||
...alias('custom-model', ['thinking']),
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'max',
|
||||
overrides: { supportEfforts: ['low', 'high'], defaultEffort: 'high' },
|
||||
}),
|
||||
).toBe('high');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ThinkingEffort } from '@moonshot-ai/kosong';
|
||||
|
||||
import { effectiveModelAlias } from '../../config';
|
||||
import type { ModelAlias, ThinkingConfig } from '../../config/schema';
|
||||
|
||||
export type { ThinkingEffort };
|
||||
|
|
@ -29,10 +30,11 @@ function middleOf(efforts: readonly string[]): string {
|
|||
* effort is always one the model can actually accept.
|
||||
*/
|
||||
export function defaultThinkingEffortFor(model: ModelAlias | undefined): ThinkingEffort {
|
||||
if (!supportsThinking(model)) return 'off';
|
||||
const efforts = model?.supportEfforts;
|
||||
const effective = model === undefined ? undefined : effectiveModelAlias(model);
|
||||
if (!supportsThinking(effective)) return 'off';
|
||||
const efforts = effective?.supportEfforts;
|
||||
if (efforts !== undefined && efforts.length > 0) {
|
||||
return model?.defaultEffort ?? middleOf(efforts);
|
||||
return effective?.defaultEffort ?? middleOf(efforts);
|
||||
}
|
||||
return 'on';
|
||||
}
|
||||
|
|
@ -54,21 +56,22 @@ export function resolveThinkingEffort(
|
|||
config: ThinkingConfig | undefined,
|
||||
model: ModelAlias | undefined,
|
||||
): ThinkingEffort {
|
||||
const effectiveModel = model === undefined ? undefined : effectiveModelAlias(model);
|
||||
let effort: ThinkingEffort;
|
||||
if (requested !== undefined) {
|
||||
effort = requested;
|
||||
} else if (config?.enabled === false) {
|
||||
effort = 'off';
|
||||
} else {
|
||||
effort = config?.effort ?? defaultThinkingEffortFor(model);
|
||||
effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel);
|
||||
}
|
||||
|
||||
if (effort === 'off' && model?.capabilities?.includes('always_thinking') === true) {
|
||||
if (effort === 'off' && effectiveModel?.capabilities?.includes('always_thinking') === true) {
|
||||
// always_thinking forces thinking on, but an explicitly configured effort
|
||||
// is still honored — `enabled = false` only expresses the intent to
|
||||
// disable, it should not also discard a chosen effort. Fall back to the
|
||||
// model default only when no effort is configured.
|
||||
effort = config?.effort ?? defaultThinkingEffortFor(model);
|
||||
effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel);
|
||||
}
|
||||
|
||||
return effort;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './merge';
|
||||
export * from './model';
|
||||
export * from './path';
|
||||
export * from './resolve';
|
||||
export * from './schema';
|
||||
|
|
|
|||
30
packages/agent-core/src/config/model.ts
Normal file
30
packages/agent-core/src/config/model.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { ModelAlias } from './schema';
|
||||
|
||||
export function effectiveModelAlias(alias: ModelAlias): ModelAlias {
|
||||
const { overrides, ...base } = alias;
|
||||
if (overrides === undefined) return alias;
|
||||
|
||||
const effective: ModelAlias = {
|
||||
...base,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
if (
|
||||
overrides.supportEfforts !== undefined &&
|
||||
overrides.defaultEffort === undefined &&
|
||||
effective.defaultEffort !== undefined &&
|
||||
!overrides.supportEfforts.includes(effective.defaultEffort)
|
||||
) {
|
||||
delete effective.defaultEffort;
|
||||
}
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
export function effectiveModelAliases(
|
||||
models: Record<string, ModelAlias>,
|
||||
): Record<string, ModelAlias> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(models).map(([alias, model]) => [alias, effectiveModelAlias(model)]),
|
||||
);
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export const ProviderConfigSchema = z.object({
|
|||
|
||||
export type ProviderConfig = z.infer<typeof ProviderConfigSchema>;
|
||||
|
||||
export const ModelAliasSchema = z.object({
|
||||
const ModelAliasBaseSchema = z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
maxContextSize: z.number().int().min(1),
|
||||
|
|
@ -62,6 +62,21 @@ export const ModelAliasSchema = z.object({
|
|||
betaApi: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ModelAliasOverrideSchema = ModelAliasBaseSchema.omit({
|
||||
provider: true,
|
||||
model: true,
|
||||
protocol: true,
|
||||
betaApi: true,
|
||||
}).partial();
|
||||
|
||||
export type ModelAliasOverrides = z.infer<typeof ModelAliasOverrideSchema>;
|
||||
|
||||
export const ModelAliasSchema = ModelAliasBaseSchema.extend({
|
||||
// User overrides for a model alias. These win over the top-level fields at
|
||||
// runtime and are preserved by provider-model refreshes.
|
||||
overrides: ModelAliasOverrideSchema.optional(),
|
||||
});
|
||||
|
||||
export type ModelAlias = z.infer<typeof ModelAliasSchema>;
|
||||
|
||||
export const ThinkingConfigSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -359,7 +359,11 @@ function transformProviderData(data: Record<string, unknown>): Record<string, un
|
|||
}
|
||||
|
||||
function transformModelData(data: Record<string, unknown>): Record<string, unknown> {
|
||||
return transformPlainObject(data);
|
||||
const out = transformPlainObject(data);
|
||||
if (isPlainObject(out['overrides'])) {
|
||||
out['overrides'] = transformPlainObject(out['overrides']);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function transformPermissionData(data: Record<string, unknown>): Record<string, unknown> {
|
||||
|
|
@ -552,6 +556,24 @@ function providerToToml(provider: ProviderConfig, rawProvider: unknown): Record<
|
|||
function modelToToml(model: ModelAlias, rawModel: unknown): Record<string, unknown> {
|
||||
const out = cloneRecord(rawModel);
|
||||
for (const [key, value] of Object.entries(model)) {
|
||||
if (key === 'capabilities' && Array.isArray(value)) {
|
||||
out[camelToSnake(key)] = [...value];
|
||||
} else if (key === 'overrides' && isPlainObject(value)) {
|
||||
const rawOverrides = isPlainObject(rawModel) ? rawModel['overrides'] : undefined;
|
||||
out['overrides'] = modelOverridesToToml(value, rawOverrides);
|
||||
} else {
|
||||
setDefined(out, camelToSnake(key), value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function modelOverridesToToml(
|
||||
overrides: Record<string, unknown>,
|
||||
rawOverrides: unknown,
|
||||
): Record<string, unknown> {
|
||||
const out = cloneRecord(rawOverrides);
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (key === 'capabilities' && Array.isArray(value)) {
|
||||
out[camelToSnake(key)] = [...value];
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createDecorator } from '../../di';
|
||||
import type { KimiConfig, ModelAlias, ProviderConfig } from '../../config';
|
||||
import { effectiveModelAlias, type KimiConfig, type ModelAlias, type ProviderConfig } from '../../config';
|
||||
import type {
|
||||
ModelCatalogItem,
|
||||
ProviderCatalogItem,
|
||||
|
|
@ -58,14 +58,15 @@ export function toProtocolModel(
|
|||
modelId: string,
|
||||
alias: ModelAlias,
|
||||
): ModelCatalogItem {
|
||||
const effective = effectiveModelAlias(alias);
|
||||
return {
|
||||
provider: alias.provider,
|
||||
provider: effective.provider,
|
||||
model: modelId,
|
||||
display_name: alias.displayName ?? alias.model,
|
||||
max_context_size: alias.maxContextSize,
|
||||
capabilities: alias.capabilities,
|
||||
support_efforts: alias.supportEfforts,
|
||||
default_effort: alias.defaultEffort,
|
||||
display_name: effective.displayName ?? effective.model,
|
||||
max_context_size: effective.maxContextSize,
|
||||
capabilities: effective.capabilities,
|
||||
support_efforts: effective.supportEfforts,
|
||||
default_effort: effective.defaultEffort,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import type { Logger } from '#/logging/types';
|
|||
import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong';
|
||||
import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong';
|
||||
import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth';
|
||||
import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig, ProviderType } from '../config';
|
||||
import {
|
||||
effectiveModelAlias,
|
||||
type KimiConfig,
|
||||
type ModelAlias,
|
||||
type OAuthRef,
|
||||
type ProviderConfig,
|
||||
type ProviderType,
|
||||
} from '../config';
|
||||
import { ErrorCodes, isKimiError, KimiError } from '../errors';
|
||||
|
||||
export interface BearerTokenProvider {
|
||||
|
|
@ -90,6 +97,7 @@ export class ProviderManager implements ModelProvider {
|
|||
);
|
||||
}
|
||||
|
||||
const effectiveAlias = effectiveModelAlias(alias);
|
||||
const providerName = alias.provider ?? this.config.defaultProvider;
|
||||
if (providerName === undefined) {
|
||||
throw new KimiError(
|
||||
|
|
@ -106,7 +114,7 @@ export class ProviderManager implements ModelProvider {
|
|||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(alias.maxContextSize) || alias.maxContextSize <= 0) {
|
||||
if (!Number.isInteger(effectiveAlias.maxContextSize) || effectiveAlias.maxContextSize <= 0) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Model "${model}" must define a positive max_context_size in config.toml.`,
|
||||
|
|
@ -115,28 +123,28 @@ export class ProviderManager implements ModelProvider {
|
|||
|
||||
// remove before commit
|
||||
const adaptiveThinkingOverride = this.options.adaptiveThinkingOverride?.();
|
||||
const effectiveAdaptiveThinking = adaptiveThinkingOverride ?? alias.adaptiveThinking;
|
||||
const effectiveAdaptiveThinking = adaptiveThinkingOverride ?? effectiveAlias.adaptiveThinking;
|
||||
const provider = toKosongProviderConfig(
|
||||
providerConfig,
|
||||
alias.model,
|
||||
alias.protocol,
|
||||
this.options.kimiRequestHeaders,
|
||||
alias.maxOutputSize,
|
||||
alias.reasoningKey,
|
||||
effectiveAlias.maxOutputSize,
|
||||
effectiveAlias.reasoningKey,
|
||||
this.options.promptCacheKey,
|
||||
effectiveAdaptiveThinking,
|
||||
alias.betaApi,
|
||||
alias.supportEfforts,
|
||||
effectiveAlias.supportEfforts,
|
||||
);
|
||||
|
||||
return {
|
||||
providerName,
|
||||
provider,
|
||||
modelCapabilities: resolveModelCapabilities(alias, provider),
|
||||
alwaysThinking: (alias.capabilities ?? []).some(
|
||||
modelCapabilities: resolveModelCapabilities(effectiveAlias, provider),
|
||||
alwaysThinking: (effectiveAlias.capabilities ?? []).some(
|
||||
(c) => c.trim().toLowerCase() === 'always_thinking',
|
||||
),
|
||||
maxOutputSize: alias.maxOutputSize,
|
||||
maxOutputSize: effectiveAlias.maxOutputSize,
|
||||
type: providerConfig.type,
|
||||
protocol: alias.protocol,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1874,11 +1874,10 @@ describe('FullCompaction', () => {
|
|||
|
||||
expect(callCount).toBe(3);
|
||||
// The catalogued model declares no supportEfforts, so the kimi provider
|
||||
// treats it as a boolean thinking model: any non-'off' level (incl. 'high')
|
||||
// is sent as thinking.enabled with no effort, which `thinkingEffort`
|
||||
// reports back as 'on'. The agent's stored thinkingEffort ('high') is still
|
||||
// carried across the compaction (see the record assertion below).
|
||||
expect(providerThinkingEfforts).toEqual(['on', 'on', 'on']);
|
||||
// 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']);
|
||||
expect(records).toContainEqual({
|
||||
event: 'compaction_finished',
|
||||
properties: expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -110,3 +110,46 @@ describe('resolveThinkingEffort', () => {
|
|||
expect(resolveThinkingEffort(undefined, { enabled: false }, booleanModel)).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultThinkingEffortFor overrides', () => {
|
||||
it('uses overridden supportEfforts for the default effort', () => {
|
||||
expect(
|
||||
defaultThinkingEffortFor(
|
||||
model({
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'max',
|
||||
overrides: { supportEfforts: ['low', 'high'] },
|
||||
}),
|
||||
),
|
||||
).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveThinkingEffort overrides', () => {
|
||||
it('honors overridden always_thinking when clamping off', () => {
|
||||
expect(
|
||||
resolveThinkingEffort(
|
||||
'off',
|
||||
{ enabled: false },
|
||||
model({
|
||||
capabilities: ['thinking'],
|
||||
overrides: { capabilities: ['thinking', 'always_thinking'] },
|
||||
}),
|
||||
),
|
||||
).toBe('on');
|
||||
});
|
||||
|
||||
it('honors overridden capabilities when always_thinking is removed', () => {
|
||||
expect(
|
||||
resolveThinkingEffort(
|
||||
'off',
|
||||
{ enabled: false },
|
||||
model({
|
||||
capabilities: ['thinking', 'always_thinking'],
|
||||
overrides: { capabilities: ['thinking'] },
|
||||
}),
|
||||
),
|
||||
).toBe('off');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||
import { ErrorCodes, KimiError } from '../../src/errors';
|
||||
import {
|
||||
KimiConfigSchema,
|
||||
configToTomlData,
|
||||
ensureConfigFile,
|
||||
loadRuntimeConfig,
|
||||
loadRuntimeConfigSafe,
|
||||
|
|
@ -869,3 +870,42 @@ ${VALID_TOML}`);
|
|||
expect(result.fileWarnings[0]).toContain('default_permission_mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('model overrides TOML', () => {
|
||||
it('parses nested model overrides from snake_case TOML', () => {
|
||||
const config = parseConfigString(`
|
||||
[models."kimi-code/kimi-k2"]
|
||||
provider = "managed:kimi-code"
|
||||
model = "kimi-k2"
|
||||
max_context_size = 262144
|
||||
support_efforts = ["low", "high", "max"]
|
||||
|
||||
[models."kimi-code/kimi-k2".overrides]
|
||||
support_efforts = ["low", "high"]
|
||||
default_effort = "high"
|
||||
`);
|
||||
|
||||
expect(config.models?.['kimi-code/kimi-k2']?.overrides).toEqual({
|
||||
supportEfforts: ['low', 'high'],
|
||||
defaultEffort: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nested model overrides back as snake_case TOML data', () => {
|
||||
const config = parseConfigString(`
|
||||
[models."kimi-code/kimi-k2"]
|
||||
provider = "managed:kimi-code"
|
||||
model = "kimi-k2"
|
||||
max_context_size = 262144
|
||||
|
||||
[models."kimi-code/kimi-k2".overrides]
|
||||
support_efforts = ["low", "high"]
|
||||
`);
|
||||
|
||||
const data = configToTomlData(config);
|
||||
const models = data['models'] as Record<string, Record<string, unknown>>;
|
||||
const overrides = models['kimi-code/kimi-k2']?.['overrides'] as Record<string, unknown>;
|
||||
|
||||
expect(overrides['support_efforts']).toEqual(['low', 'high']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
48
packages/agent-core/test/config/model-overrides.test.ts
Normal file
48
packages/agent-core/test/config/model-overrides.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { effectiveModelAlias } from '#/config/model';
|
||||
import type { ModelAlias } from '#/config/schema';
|
||||
|
||||
function alias(overrides?: ModelAlias['overrides']): ModelAlias {
|
||||
return {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 262144,
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'max',
|
||||
overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('effectiveModelAlias', () => {
|
||||
it('returns the alias unchanged when there are no overrides', () => {
|
||||
const model = alias();
|
||||
|
||||
expect(effectiveModelAlias(model)).toEqual(model);
|
||||
});
|
||||
|
||||
it('lets overrides win over top-level fields', () => {
|
||||
const model = alias({ supportEfforts: ['low', 'high'] });
|
||||
|
||||
expect(effectiveModelAlias(model).supportEfforts).toEqual(['low', 'high']);
|
||||
});
|
||||
|
||||
it('allows overriding non-identity model fields such as maxContextSize', () => {
|
||||
const model = alias({ maxContextSize: 128000 });
|
||||
|
||||
expect(effectiveModelAlias(model).maxContextSize).toBe(128000);
|
||||
});
|
||||
|
||||
it('drops an incompatible defaultEffort when supportEfforts is overridden', () => {
|
||||
const model = alias({ supportEfforts: ['low', 'high'] });
|
||||
|
||||
expect(effectiveModelAlias(model).defaultEffort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps an explicit defaultEffort override when it is valid', () => {
|
||||
const model = alias({ supportEfforts: ['low', 'high'], defaultEffort: 'high' });
|
||||
|
||||
expect(effectiveModelAlias(model).defaultEffort).toBe('high');
|
||||
});
|
||||
});
|
||||
|
|
@ -1038,3 +1038,25 @@ describe('per-model protocol routing', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeProvider model overrides', () => {
|
||||
it('passes overridden supportEfforts to the kimi provider config', () => {
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
models: {
|
||||
'kimi-code/kimi-for-coding': {
|
||||
...BASE_CONFIG.models!['kimi-code/kimi-for-coding']!,
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
overrides: { supportEfforts: ['low', 'high'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'kimi',
|
||||
supportEfforts: ['low', 'high'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -422,8 +422,7 @@ export class KimiChatProvider implements ChatProvider {
|
|||
const thinking = this._generationKwargs.extra_body?.thinking;
|
||||
if (thinking === undefined) return null;
|
||||
if (thinking.type === 'disabled') return 'off';
|
||||
// `support_efforts` is the single source of truth for efforts: a
|
||||
// model that sends thinking without an effort is a boolean ("on") model.
|
||||
// A model that enables thinking without an effort is treated as boolean ("on").
|
||||
return thinking.effort ?? 'on';
|
||||
}
|
||||
|
||||
|
|
@ -514,11 +513,15 @@ export class KimiChatProvider implements ChatProvider {
|
|||
if (effort === 'off') {
|
||||
thinking = { type: 'disabled' };
|
||||
} else {
|
||||
// `support_efforts` is the single source of truth for efforts: only
|
||||
// values the model declared are sent as effort. Everything else
|
||||
// ('on', 'xhigh', or any unrecognized string) is normalized to "no
|
||||
// effort" — thinking is enabled but the model picks its own effort.
|
||||
const declared = this._supportEfforts.includes(effort) ? effort : undefined;
|
||||
// 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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ describe('e2e: kimi adapter', () => {
|
|||
model: 'kimi-k2-turbo-preview',
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
thinking: { type: 'enabled' },
|
||||
thinking: { type: 'enabled', effort: 'high' },
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are helpful.' },
|
||||
{ role: 'user', content: 'Check the weather.' },
|
||||
|
|
|
|||
|
|
@ -679,7 +679,7 @@ describe('KimiChatProvider', () => {
|
|||
|
||||
expect(getGenerationState(provider)).toEqual({
|
||||
extra_body: {
|
||||
thinking: { type: 'enabled' },
|
||||
thinking: { type: 'enabled', effort: 'high' },
|
||||
},
|
||||
max_tokens: 512,
|
||||
});
|
||||
|
|
@ -716,7 +716,7 @@ describe('KimiChatProvider', () => {
|
|||
});
|
||||
|
||||
describe('with thinking', () => {
|
||||
it('non-effort model sends only thinking.type (no effort, no reasoning_effort)', async () => {
|
||||
it('model without support_efforts passes the requested effort through', 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' });
|
||||
expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' });
|
||||
expect(body['extra_body']).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -785,11 +785,9 @@ describe('KimiChatProvider', () => {
|
|||
expect(provider.withThinking('off').thinkingEffort).toBe('off');
|
||||
});
|
||||
|
||||
it('thinkingEffort returns on for an enabled boolean (non-effort) model', () => {
|
||||
it('thinkingEffort reflects the requested effort when support_efforts is absent', () => {
|
||||
const provider = createProvider();
|
||||
// A model without support_efforts carries no effort on the wire, so the
|
||||
// getter falls back to 'on' regardless of the requested effort.
|
||||
expect(provider.withThinking('high').thinkingEffort).toBe('on');
|
||||
expect(provider.withThinking('high').thinkingEffort).toBe('high');
|
||||
expect(provider.withThinking('on').thinkingEffort).toBe('on');
|
||||
});
|
||||
|
||||
|
|
@ -1340,7 +1338,7 @@ describe('KimiChatProvider', () => {
|
|||
.withThinking('high');
|
||||
|
||||
expect(getGenerationState(provider).extra_body).toEqual({
|
||||
thinking: { type: 'enabled', keep: 'all' },
|
||||
thinking: { type: 'enabled', effort: 'high', keep: 'all' },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1353,7 +1351,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 () => {
|
||||
|
|
@ -1363,7 +1361,7 @@ describe('KimiChatProvider', () => {
|
|||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(body['thinking']).toEqual({ type: 'enabled' });
|
||||
expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen
|
|||
// Host-side config helpers — safe config reader + config path resolution, used
|
||||
// by hosts (e.g. the CLI's server telemetry bootstrap) that need to inspect
|
||||
// config without spinning up a full KimiCore.
|
||||
export { loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-core';
|
||||
export { effectiveModelAlias, loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-core';
|
||||
|
||||
// Process-wide HTTP proxy bootstrap — installed once at CLI startup so all
|
||||
// outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { readApiErrorMessage } from './api-error';
|
||||
import { CUSTOM_REGISTRY_MODEL_FIELDS, mergeRefreshedModelAlias } from './model-alias-merge';
|
||||
import { isRecord } from './utils';
|
||||
import type { ManagedKimiConfigShape } from './managed-kimi-code';
|
||||
import type { ManagedKimiConfigShape, ManagedKimiModelAlias } from './managed-kimi-code';
|
||||
|
||||
export type { ManagedKimiConfigShape };
|
||||
|
||||
|
|
@ -316,14 +317,18 @@ export function applyCustomRegistryProvider(
|
|||
typeof model.name === 'string' && model.name.length > 0 ? model.name : model.id;
|
||||
const existing = isRecord(existingModels[aliasKey]) ? existingModels[aliasKey] : {};
|
||||
|
||||
existingModels[aliasKey] = {
|
||||
...existing,
|
||||
const remoteAlias: ManagedKimiModelAlias = {
|
||||
provider: providerKey,
|
||||
model: model.id,
|
||||
maxContextSize,
|
||||
capabilities,
|
||||
displayName,
|
||||
};
|
||||
existingModels[aliasKey] = mergeRefreshedModelAlias(
|
||||
existing,
|
||||
remoteAlias,
|
||||
CUSTOM_REGISTRY_MODEL_FIELDS,
|
||||
);
|
||||
}
|
||||
|
||||
config.models = existingModels;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DEFAULT_KIMI_CODE_OAUTH_HOST } from './constants';
|
|||
import { OAuthUnauthorizedError } from './errors';
|
||||
import { parseKimiCodeCustomHeaders } from './identity';
|
||||
import { DEFAULT_KIMI_CODE_BASE_URL, kimiCodeBaseUrl } from './managed-usage';
|
||||
import { MANAGED_KIMI_MODEL_FIELDS, mergeRefreshedModelAlias } from './model-alias-merge';
|
||||
import { isRecord } from './utils';
|
||||
|
||||
export const KIMI_CODE_PLATFORM_ID = 'kimi-code';
|
||||
|
|
@ -124,6 +125,18 @@ export interface ManagedKimiProviderConfig {
|
|||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ManagedKimiModelAliasOverrides {
|
||||
maxContextSize?: number | undefined;
|
||||
maxOutputSize?: number | undefined;
|
||||
capabilities?: string[] | undefined;
|
||||
displayName?: string | undefined;
|
||||
reasoningKey?: string | undefined;
|
||||
adaptiveThinking?: boolean | undefined;
|
||||
supportEfforts?: readonly string[] | undefined;
|
||||
defaultEffort?: string | undefined;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ManagedKimiModelAlias {
|
||||
provider: string;
|
||||
model: string;
|
||||
|
|
@ -134,6 +147,8 @@ export interface ManagedKimiModelAlias {
|
|||
displayName?: string | undefined;
|
||||
protocol?: ManagedKimiCodeProtocol;
|
||||
betaApi?: boolean;
|
||||
adaptiveThinking?: boolean | undefined;
|
||||
overrides?: ManagedKimiModelAliasOverrides | undefined;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -558,8 +573,7 @@ export function applyManagedKimiCodeConfig(
|
|||
model.protocol === 'anthropic' &&
|
||||
(capabilities?.includes('thinking') === true ||
|
||||
capabilities?.includes('always_thinking') === true);
|
||||
existingModels[key] = {
|
||||
...existing,
|
||||
const remoteAlias: ManagedKimiModelAlias = {
|
||||
provider: KIMI_CODE_PROVIDER_NAME,
|
||||
model: model.id,
|
||||
maxContextSize: model.contextLength,
|
||||
|
|
@ -575,6 +589,7 @@ export function applyManagedKimiCodeConfig(
|
|||
betaApi: model.protocol === 'anthropic' ? true : undefined,
|
||||
adaptiveThinking: supportsAdaptiveThinking ? true : undefined,
|
||||
};
|
||||
existingModels[key] = mergeRefreshedModelAlias(existing, remoteAlias, MANAGED_KIMI_MODEL_FIELDS);
|
||||
}
|
||||
|
||||
config.models = existingModels;
|
||||
|
|
|
|||
60
packages/oauth/src/model-alias-merge.ts
Normal file
60
packages/oauth/src/model-alias-merge.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { isRecord } from './utils';
|
||||
import type { ManagedKimiModelAlias, ManagedKimiModelAliasOverrides } from './managed-kimi-code';
|
||||
|
||||
export const MANAGED_KIMI_MODEL_FIELDS: ReadonlySet<string> = new Set([
|
||||
'provider',
|
||||
'model',
|
||||
'maxContextSize',
|
||||
'capabilities',
|
||||
'displayName',
|
||||
'protocol',
|
||||
'betaApi',
|
||||
'adaptiveThinking',
|
||||
'supportEfforts',
|
||||
'defaultEffort',
|
||||
]);
|
||||
|
||||
export const CUSTOM_REGISTRY_MODEL_FIELDS: ReadonlySet<string> = new Set([
|
||||
'provider',
|
||||
'model',
|
||||
'maxContextSize',
|
||||
'capabilities',
|
||||
'displayName',
|
||||
]);
|
||||
|
||||
function cloneOverrides(
|
||||
overrides: ManagedKimiModelAliasOverrides | undefined,
|
||||
): ManagedKimiModelAliasOverrides | undefined {
|
||||
if (overrides === undefined) return undefined;
|
||||
return structuredClone(overrides);
|
||||
}
|
||||
|
||||
function userExtras(
|
||||
existing: Record<string, unknown>,
|
||||
remoteOwnedFields: ReadonlySet<string>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(existing)) {
|
||||
if (key === 'overrides') continue;
|
||||
if (!remoteOwnedFields.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeRefreshedModelAlias(
|
||||
existing: unknown,
|
||||
remote: ManagedKimiModelAlias,
|
||||
remoteOwnedFields: ReadonlySet<string>,
|
||||
): ManagedKimiModelAlias {
|
||||
const current = isRecord(existing) ? existing : {};
|
||||
const overrides = cloneOverrides(
|
||||
isRecord(current['overrides'])
|
||||
? (current['overrides'] as ManagedKimiModelAliasOverrides)
|
||||
: undefined,
|
||||
);
|
||||
return {
|
||||
...userExtras(current, remoteOwnedFields),
|
||||
...remote,
|
||||
...(overrides !== undefined ? { overrides } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@ import { readApiErrorMessage } from './api-error';
|
|||
import { isRecord } from './utils';
|
||||
import { parseKimiCodeCustomHeaders } from './identity';
|
||||
import { parseSupportsThinkingType, parseThinkEfforts } from './managed-kimi-code';
|
||||
import { MANAGED_KIMI_MODEL_FIELDS, mergeRefreshedModelAlias } from './model-alias-merge';
|
||||
import type {
|
||||
ManagedKimiCodeModelInfo,
|
||||
ManagedKimiConfigShape,
|
||||
ManagedKimiModelAlias,
|
||||
} from './managed-kimi-code';
|
||||
|
||||
export type { ManagedKimiConfigShape };
|
||||
|
|
@ -188,8 +190,7 @@ export function applyOpenPlatformConfig(
|
|||
for (const model of options.models) {
|
||||
const aliasKey = `${providerKey}/${model.id}`;
|
||||
const existing = isRecord(existingModels[aliasKey]) ? existingModels[aliasKey] : {};
|
||||
existingModels[aliasKey] = {
|
||||
...existing,
|
||||
const remoteAlias: ManagedKimiModelAlias = {
|
||||
provider: providerKey,
|
||||
model: model.id,
|
||||
maxContextSize: model.contextLength,
|
||||
|
|
@ -198,6 +199,11 @@ export function applyOpenPlatformConfig(
|
|||
...(model.supportEfforts !== undefined ? { supportEfforts: model.supportEfforts } : {}),
|
||||
...(model.defaultEffort !== undefined ? { defaultEffort: model.defaultEffort } : {}),
|
||||
};
|
||||
existingModels[aliasKey] = mergeRefreshedModelAlias(
|
||||
existing,
|
||||
remoteAlias,
|
||||
MANAGED_KIMI_MODEL_FIELDS,
|
||||
);
|
||||
}
|
||||
|
||||
config.models = existingModels;
|
||||
|
|
|
|||
|
|
@ -1193,7 +1193,7 @@ describe('selective merge', () => {
|
|||
oauthKey: 'test-key',
|
||||
};
|
||||
|
||||
it('preserves hand-edited fields that upstream does not declare', () => {
|
||||
it('preserves non-managed user fields but drops stale managed fields', () => {
|
||||
const config: ManagedKimiConfigShape = {
|
||||
providers: {},
|
||||
models: {
|
||||
|
|
@ -1224,11 +1224,11 @@ describe('selective merge', () => {
|
|||
|
||||
const alias = config.models?.['kimi-code/kimi-k2'];
|
||||
expect(alias?.['maxOutputSize']).toBe(4096);
|
||||
expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']);
|
||||
expect(alias?.['supportEfforts']).toBeUndefined();
|
||||
expect(alias?.['maxContextSize']).toBe(262144);
|
||||
});
|
||||
|
||||
it('overwrites hand-edited fields when upstream declares them', () => {
|
||||
it('preserves overrides when upstream declares managed fields', () => {
|
||||
const config: ManagedKimiConfigShape = {
|
||||
providers: {},
|
||||
models: {
|
||||
|
|
@ -1236,7 +1236,7 @@ describe('selective merge', () => {
|
|||
provider: 'kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 262144,
|
||||
supportEfforts: ['low'],
|
||||
overrides: { supportEfforts: ['low'] },
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
|
|
@ -1259,6 +1259,7 @@ describe('selective merge', () => {
|
|||
const alias = config.models?.['kimi-code/kimi-k2'];
|
||||
expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']);
|
||||
expect(alias?.['defaultEffort']).toBe('high');
|
||||
expect(alias?.['overrides']).toEqual({ supportEfforts: ['low'] });
|
||||
});
|
||||
|
||||
it('removes managed models that upstream no longer lists', () => {
|
||||
|
|
|
|||
69
packages/oauth/test/model-alias-merge.test.ts
Normal file
69
packages/oauth/test/model-alias-merge.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CUSTOM_REGISTRY_MODEL_FIELDS,
|
||||
MANAGED_KIMI_MODEL_FIELDS,
|
||||
mergeRefreshedModelAlias,
|
||||
} from '../src/model-alias-merge';
|
||||
|
||||
describe('mergeRefreshedModelAlias', () => {
|
||||
it('preserves overrides while refreshing managed fields', () => {
|
||||
const merged = mergeRefreshedModelAlias(
|
||||
{
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 262144,
|
||||
supportEfforts: ['low'],
|
||||
overrides: { supportEfforts: ['low'] },
|
||||
},
|
||||
{
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 262144,
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
},
|
||||
MANAGED_KIMI_MODEL_FIELDS,
|
||||
);
|
||||
|
||||
expect(merged.supportEfforts).toEqual(['low', 'high', 'max']);
|
||||
expect(merged.overrides).toEqual({ supportEfforts: ['low'] });
|
||||
});
|
||||
|
||||
it('drops managed top-level fields when upstream stops declaring them', () => {
|
||||
const merged = mergeRefreshedModelAlias(
|
||||
{
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 262144,
|
||||
supportEfforts: ['low'],
|
||||
},
|
||||
{
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 262144,
|
||||
},
|
||||
MANAGED_KIMI_MODEL_FIELDS,
|
||||
);
|
||||
|
||||
expect(merged.supportEfforts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps custom-registry supportEfforts as user data', () => {
|
||||
const merged = mergeRefreshedModelAlias(
|
||||
{
|
||||
provider: 'registry',
|
||||
model: 'gpt-5.5',
|
||||
maxContextSize: 131072,
|
||||
supportEfforts: ['low', 'high'],
|
||||
},
|
||||
{
|
||||
provider: 'registry',
|
||||
model: 'gpt-5.5',
|
||||
maxContextSize: 131072,
|
||||
},
|
||||
CUSTOM_REGISTRY_MODEL_FIELDS,
|
||||
);
|
||||
|
||||
expect(merged.supportEfforts).toEqual(['low', 'high']);
|
||||
});
|
||||
});
|
||||
|
|
@ -356,7 +356,46 @@ describe('applyOpenPlatformConfig', () => {
|
|||
|
||||
const alias = config.models?.['moonshot-cn/kimi-k2-0712-preview'];
|
||||
expect(alias?.['maxOutputSize']).toBe(8192);
|
||||
expect(alias?.['supportEfforts']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves open-platform overrides during refresh', () => {
|
||||
const config: ManagedKimiConfigShape = {
|
||||
providers: {
|
||||
'moonshot-cn': { type: 'kimi', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-old' },
|
||||
},
|
||||
models: {
|
||||
'moonshot-cn/kimi-k2-0712-preview': {
|
||||
provider: 'moonshot-cn',
|
||||
model: 'kimi-k2-0712-preview',
|
||||
maxContextSize: 256000,
|
||||
overrides: { supportEfforts: ['low'] },
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
const platform = getOpenPlatformById('moonshot-cn')!;
|
||||
const models = [
|
||||
{
|
||||
id: 'kimi-k2-0712-preview',
|
||||
contextLength: 256000,
|
||||
supportsReasoning: true,
|
||||
supportsImageIn: false,
|
||||
supportsVideoIn: false,
|
||||
supportEfforts: ['low', 'high'],
|
||||
},
|
||||
];
|
||||
|
||||
applyOpenPlatformConfig(config, {
|
||||
platform,
|
||||
models,
|
||||
selectedModel: models[0]!,
|
||||
thinking: false,
|
||||
apiKey: 'sk-new',
|
||||
});
|
||||
|
||||
const alias = config.models?.['moonshot-cn/kimi-k2-0712-preview'];
|
||||
expect(alias?.['supportEfforts']).toEqual(['low', 'high']);
|
||||
expect(alias?.['overrides']).toEqual({ supportEfforts: ['low'] });
|
||||
});
|
||||
|
||||
it('writes a concrete effort into config.thinking when provided', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue