diff --git a/.changeset/model-alias-overrides.md b/.changeset/model-alias-overrides.md new file mode 100644 index 000000000..c62e52cd1 --- /dev/null +++ b/.changeset/model-alias-overrides.md @@ -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."".overrides]`. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 1b5c24fe1..bdbd27f25 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -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)) { diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 981ba31f4..d20595466 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -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; diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 3db1de3cf..ff3497450 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -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, diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 8820d46d7..32fe08b88 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -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, ): 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): 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})` }; }); } diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index a246d83ff..f53f1a2d0 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -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 { 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 { diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index ce5ddaf97..2fe6f3e52 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -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'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 76f073499..b827b7dbe 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -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'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 06f3843a0..ff4ff9356 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -4684,3 +4684,81 @@ command = "vim" expect(transcript).not.toContain(' { + 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.'); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 1f2187063..4bfbe6432 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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` | 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` | No | Thinking effort levels declared by the model catalog. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] support_efforts` instead | +| `default_effort` | `string` | No | Default thinking effort for the model. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] default_effort` instead | | `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset | | `reasoning_key` | `string` | No | `openai` provider only. Override the field name used for reasoning content when the gateway returns it under a non-standard name; by default `reasoning_content`, `reasoning_details`, and `reasoning` are auto-detected | | `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."".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."".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` diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index c214ce76b..3eefe21b0 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -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` | 否 | 显式追加的能力标签:`thinking`、`image_in`、`video_in`、`audio_in`、`tool_use`。与供应商自动识别的能力取并集,只能追加不能移除 | +| `support_efforts` | `array` | 否 | 模型目录声明的 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] support_efforts` | +| `default_effort` | `string` | 否 | 模型的默认 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] default_effort` | | `display_name` | `string` | 否 | UI 中显示的名称,未设时回退到 `model` | | `reasoning_key` | `string` | 否 | 仅 `openai` 供应商。当网关用非标准字段名返回推理内容时才需要设置;默认自动识别 `reasoning_content` / `reasoning_details` / `reasoning` | | `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."".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."".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` diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts index 63c3c30e5..5ce4256d9 100644 --- a/packages/acp-adapter/src/model-catalog.ts +++ b/packages/acp-adapter/src/model-catalog.ts @@ -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), diff --git a/packages/acp-adapter/test/model-catalog.test.ts b/packages/acp-adapter/test/model-catalog.test.ts index 59a340300..e637a95bd 100644 --- a/packages/acp-adapter/test/model-catalog.test.ts +++ b/packages/acp-adapter/test/model-catalog.test.ts @@ -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'); + }); +}); diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 4a4ddad70..3b4163281 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -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; diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts index ac97d1221..b4c9799d7 100644 --- a/packages/agent-core/src/config/index.ts +++ b/packages/agent-core/src/config/index.ts @@ -1,4 +1,5 @@ export * from './merge'; +export * from './model'; export * from './path'; export * from './resolve'; export * from './schema'; diff --git a/packages/agent-core/src/config/model.ts b/packages/agent-core/src/config/model.ts new file mode 100644 index 000000000..c31ea35c7 --- /dev/null +++ b/packages/agent-core/src/config/model.ts @@ -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, +): Record { + return Object.fromEntries( + Object.entries(models).map(([alias, model]) => [alias, effectiveModelAlias(model)]), + ); +} diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index c8cf2ef93..9b1ac9d64 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -37,7 +37,7 @@ export const ProviderConfigSchema = z.object({ export type ProviderConfig = z.infer; -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; + +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; export const ThinkingConfigSchema = z.object({ diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index a90138251..7e1c75c0e 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -359,7 +359,11 @@ function transformProviderData(data: Record): Record): Record { - return transformPlainObject(data); + const out = transformPlainObject(data); + if (isPlainObject(out['overrides'])) { + out['overrides'] = transformPlainObject(out['overrides']); + } + return out; } function transformPermissionData(data: Record): Record { @@ -552,6 +556,24 @@ function providerToToml(provider: ProviderConfig, rawProvider: unknown): Record< function modelToToml(model: ModelAlias, rawModel: unknown): Record { 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, + rawOverrides: unknown, +): Record { + const out = cloneRecord(rawOverrides); + for (const [key, value] of Object.entries(overrides)) { if (key === 'capabilities' && Array.isArray(value)) { out[camelToSnake(key)] = [...value]; } else { diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts index de1a1258a..46b83fb07 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts @@ -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, }; } diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 9dc0f85b3..245ec3275 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -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, }; diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 58ea539c8..0975c952d 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -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({ diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index f72ea8eda..37a4cd9a9 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -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'); + }); +}); diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index fd554343e..458a5cf8d 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -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>; + const overrides = models['kimi-code/kimi-k2']?.['overrides'] as Record; + + expect(overrides['support_efforts']).toEqual(['low', 'high']); + }); +}); diff --git a/packages/agent-core/test/config/model-overrides.test.ts b/packages/agent-core/test/config/model-overrides.test.ts new file mode 100644 index 000000000..d1caf9a52 --- /dev/null +++ b/packages/agent-core/test/config/model-overrides.test.ts @@ -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'); + }); +}); diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 6bcf2635d..da8206449 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -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'], + }); + }); +}); diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index 2b44b3f94..93bbc52e9 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -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' }; } diff --git a/packages/kosong/test/e2e/kimi-adapter-e2e.test.ts b/packages/kosong/test/e2e/kimi-adapter-e2e.test.ts index 5c8018a6e..20091e3c7 100644 --- a/packages/kosong/test/e2e/kimi-adapter-e2e.test.ts +++ b/packages/kosong/test/e2e/kimi-adapter-e2e.test.ts @@ -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.' }, diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 9f8cdfe7d..7a5eb54e6 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -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' }); }); }); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 22eb0eb98..caa4ece31 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -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. diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index 4301178de..b1cb18b76 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -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; diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index c839e6d46..8e07edc8e 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -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; diff --git a/packages/oauth/src/model-alias-merge.ts b/packages/oauth/src/model-alias-merge.ts new file mode 100644 index 000000000..67f7646b7 --- /dev/null +++ b/packages/oauth/src/model-alias-merge.ts @@ -0,0 +1,60 @@ +import { isRecord } from './utils'; +import type { ManagedKimiModelAlias, ManagedKimiModelAliasOverrides } from './managed-kimi-code'; + +export const MANAGED_KIMI_MODEL_FIELDS: ReadonlySet = new Set([ + 'provider', + 'model', + 'maxContextSize', + 'capabilities', + 'displayName', + 'protocol', + 'betaApi', + 'adaptiveThinking', + 'supportEfforts', + 'defaultEffort', +]); + +export const CUSTOM_REGISTRY_MODEL_FIELDS: ReadonlySet = 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, + remoteOwnedFields: ReadonlySet, +): Record { + const out: Record = {}; + 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, +): 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 } : {}), + }; +} diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index 62194f5a4..630d6fef3 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -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; diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts index b869dcf63..c84eb6703 100644 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -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, }, }; @@ -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', () => { diff --git a/packages/oauth/test/model-alias-merge.test.ts b/packages/oauth/test/model-alias-merge.test.ts new file mode 100644 index 000000000..faf5b9d96 --- /dev/null +++ b/packages/oauth/test/model-alias-merge.test.ts @@ -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']); + }); +}); diff --git a/packages/oauth/test/open-platform.test.ts b/packages/oauth/test/open-platform.test.ts index 40d462945..0fdc151c1 100644 --- a/packages/oauth/test/open-platform.test.ts +++ b/packages/oauth/test/open-platform.test.ts @@ -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, + }, + }; + 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', () => {