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:
liruifengv 2026-07-01 19:57:13 +08:00 committed by GitHub
parent 0e279bfd7e
commit c070fbedde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 750 additions and 91 deletions

View 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]`.

View file

@ -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)) {

View file

@ -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;

View file

@ -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,

View file

@ -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})` };
});
}

View file

@ -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 {

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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.');
});
});

View file

@ -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`

View file

@ -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`

View file

@ -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),

View file

@ -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');
});
});

View file

@ -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;

View file

@ -1,4 +1,5 @@
export * from './merge';
export * from './model';
export * from './path';
export * from './resolve';
export * from './schema';

View 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)]),
);
}

View file

@ -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({

View file

@ -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 {

View file

@ -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,
};
}

View file

@ -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,
};

View file

@ -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({

View file

@ -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');
});
});

View file

@ -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']);
});
});

View 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');
});
});

View file

@ -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'],
});
});
});

View file

@ -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' };
}

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' },
thinking: { type: 'enabled', effort: 'high' },
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' },
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' });
});
});

View file

@ -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.

View file

@ -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;

View file

@ -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;

View 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 } : {}),
};
}

View file

@ -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;

View file

@ -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', () => {

View 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']);
});
});

View file

@ -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', () => {