Skip to content

Commit 0327e8e

Browse files
authored
feat: support model migration metadata (#4171)
* feat: support model migration metadata * chore: revert web bundle sync * feat: persist model upgrade metadata * fix: resolve model upgrade targets * fix: keep model upgrade metadata consistent * fix: complete model upgrade metadata handling * fix: handle aliased upgrade targets and RFC3339 variants * fix: resolve bare model upgrade targets * feat: prompt for model migration in the TUI * fix(tui): defer model migration until the editor is idle * fix(tui): preserve model migration prompts and preferences * fix(tui): hold the model migration prompt while /new replaces the session * fix(tui): keep the resume cache hint from replacing an open dialog * fix(tui): keep the thinking effort across a model migration when supported * fix(tui): strip terminal control sequences from migration notices * fix(tui): warn about prompt cache loss before a mid-conversation migration * docs: drop the effort persistence claim from the migration wording * test: trim redundant migration test cases * fix: switch to the declared replacement when a registry drops the default model A registry retires a model by dropping it after declaring `upgrade`, but the refresh deleted the alias together with its upgrade metadata and cleared the default, leaving users without a model and with a misleading login hint. Refresh and re-import now move a dropped default to the upgrade target persisted by an earlier refresh, clearing it as before when that target is gone too. The TUI warns about the switch and moves the live session off the dropped model once no dialog is open. * docs: fix the /reload-tui command spelling in config-files * fix: resolve migration placeholders at import and keep acknowledgements out of tui.toml Migration text placeholders were only filled in by the TUI, so the model catalog served raw templates to every other client. The registry import now stores the final text and the TUI renders it as is. Migration acknowledgements are display state rather than preferences, so they now live in model-migrations.json under the data directory instead of tui.toml; preference saves and acknowledgements no longer rewrite each other, and /reload has nothing to sync. Also carry the dropped model's record in the refresh result instead of snapshotting it in the TUI, give performModelSwitch explicit persistence modes instead of a boolean-or-mode flag, fold the Markdown notice into the picker's notice option, and rename waitForLazyCreation to waitForSessionAssembly now that it also waits for /new. * refactor(tui): move the shared model switch out of the /model command performModelSwitch and its helpers became shared once the migration prompt and the retired-default switch started calling them, yet they stayed in the /model command module, leaving a controller depending on command internals. Move them unchanged to tui/utils/model-switch.ts, which the command module and the migration controller now both import. * feat: surface retired default model redirects in refresh results and catalog events A registry-side retirement only existed in the OAuth orchestrator's in-process RefreshResult, so kap-server's scheduled refreshes redirected the default model without any client-visible signal. The refresh response now carries an optional retired_default { from, to, from_model }, mapped through to the REST refresh endpoints and the global event.model_catalog.changed broadcast, and mirrored in the klient contract. * refactor(tui): consume the engine's provider refresh instead of orchestrating its own The TUI ran the OAuth refresh orchestrator in-process through its own persistence host (including a dead v1 fallback), duplicating what the engine's discovery service already does. KimiHarness now exposes refreshProviderModels (via the klient facade over IProviderDiscoveryService), so the TUI deletes its orchestration glue and reads retired_default from the engine response. Also fix the klient facade deriving RefreshProviderModelsResponse from the OAuth login refresh, which hid retired_default from clients. * feat: move model retirement follow-through into the engine The discovery service now records retirements, emits onDidRetireModel, and answers retiredModelReplacement; agent profiles follow a retirement live and on restore, so the TUI only shows the notice and steers the session-less selection. The wire retired_default drops from_model in favor of an engine-resolved from_display_name. Removes the now-dead supportsAtomicSectionReplace / replaceConfigSections / removeProviderFromConfig SDK exports (no remaining consumers). * Update model migration guidance in documentation Prompt for migration when the TUI editor is idle and switch to the declared replacement when a registry drops the default model. Signed-off-by: 7Sageer <sag77r@hotmail.com> * test(oauth): drop the duplicate dropped-default redirect case --------- Signed-off-by: 7Sageer <sag77r@hotmail.com>
1 parent 7e58f2d commit 0327e8e

70 files changed

Lines changed: 2770 additions & 712 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Support custom-registry model upgrade metadata in model configuration.

‎apps/kimi-code/src/cli/sub/provider.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
* a provider, or inspect what is configured without launching the TUI.
77
*
88
* `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the
9-
* TUI does; the next launch's `refreshAllProviderModels`
10-
* (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries
11-
* available API-key candidates, and re-fetches the model list, so periodic
12-
* refresh is automatic.
9+
* TUI does; the next launch's provider-model refresh
10+
* (`KimiHarness.refreshProviderModels`, backed by the engine's discovery
11+
* service) groups by URL, retries available API-key candidates, and
12+
* re-fetches the model list, so periodic refresh is automatic.
1313
*/
1414

1515
import {

‎apps/kimi-code/src/constant/app.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const KIMI_CODE_BANNER_DIR_NAME = 'banner';
7070
export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json';
7171
export const KIMI_CODE_SURVEY_STATE_FILE_NAME = 'feedback-survey-state.json';
7272
export const KIMI_CODE_RECOMMENDED_EFFORT_STATE_FILE_NAME = 'recommended-effort-state.json';
73+
export const KIMI_CODE_MODEL_MIGRATION_STATE_FILE_NAME = 'model-migrations.json';
7374

7475
// Managed Kimi auth provider key shared with OAuth/SDK config.
7576
export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code';

‎apps/kimi-code/src/tui/commands/config.ts‎

Lines changed: 11 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import {
2-
effectiveModelAlias,
32
PRIMARY_SUBAGENT_MODEL_CHOICE,
43
SECONDARY_DERIVED_MODEL_ALIAS,
54
type ExperimentalFeatureState,
65
type ModelAlias,
76
type PermissionMode,
87
type Session,
9-
type ThinkingEffort,
108
} from '@moonshot-ai/kimi-code-sdk';
119

1210
import { EditorSelectorComponent } from '../components/dialogs/editor-selector';
@@ -30,8 +28,13 @@ import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from
3028
import { NO_ACTIVE_SESSION_MESSAGE, TUI_MODE_RESTART_NOTICE } from '../constant/kimi-tui';
3129
import { formatErrorMessage } from '../utils/event-payload';
3230
import { setMarkdownMermaidMode, type MermaidRenderMode } from '../utils/markdown-options';
31+
import {
32+
effectiveModelForHost,
33+
hasConversationHistory,
34+
MODEL_SWITCH_CACHE_WARNING,
35+
performModelSwitch,
36+
} from '../utils/model-switch';
3337
import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode';
34-
import { thinkingEffortToConfig } from '../utils/thinking-config';
3538
import { showUsage } from './info';
3639
import { setExperimentalFeatures } from './experimental-flags';
3740
import type { SlashCommandHost } from './dispatch';
@@ -42,21 +45,9 @@ import type { SlashCommandHost } from './dispatch';
4245

4346
const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000;
4447

45-
const MODEL_SWITCH_CACHE_WARNING =
46-
'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.';
4748
const EFFORT_SWITCH_CACHE_WARNING =
4849
'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.';
4950

50-
/** True once the conversation has at least one user message: a switch from
51-
* then on resends the accumulated context, losing the cache. Shell-command
52-
* echoes are also 'user' transcript entries but carry an empty `bullet`, so
53-
* they're excluded. */
54-
function hasConversationHistory(host: SlashCommandHost): boolean {
55-
return host.state.transcriptEntries.some(
56-
(entry) => entry.kind === 'user' && entry.bullet !== '',
57-
);
58-
}
59-
6051
export function currentTuiConfig(host: Pick<SlashCommandHost, 'state'>): TuiConfig {
6152
return {
6253
theme: host.state.appState.theme,
@@ -74,14 +65,6 @@ export function currentTuiConfig(host: Pick<SlashCommandHost, 'state'>): TuiConf
7465
};
7566
}
7667

77-
export function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias {
78-
const providerType = host.state.appState.availableProviders[model.provider]?.type;
79-
// Flat models (no named provider, e.g. inline base_url served by a v2
80-
// backend) have no provider entry to look up; their own protocol declaration
81-
// plays the provider-identity role, mirroring the resolver.
82-
return effectiveModelAlias(model, providerType ?? model.protocol);
83-
}
84-
8568
export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
8669
const session = host.session;
8770
if (session === undefined) {
@@ -243,7 +226,7 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string):
243226
'warning',
244227
);
245228
}
246-
await performModelSwitch(host, alias, arg, true);
229+
await performModelSwitch(host, alias, arg, 'model');
247230
}
248231

249232
function showEffortPicker(
@@ -261,11 +244,11 @@ function showEffortPicker(
261244
warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined,
262245
onSelect: (effort) => {
263246
host.restoreEditor();
264-
void performModelSwitch(host, alias, effort, true);
247+
void performModelSwitch(host, alias, effort, 'model');
265248
},
266249
onSessionOnlySelect: (effort) => {
267250
host.restoreEditor();
268-
void performModelSwitch(host, alias, effort, false);
251+
void performModelSwitch(host, alias, effort, 'session');
269252
},
270253
onCancel: () => {
271254
host.restoreEditor();
@@ -387,11 +370,11 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
387370
warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined,
388371
onSelect: ({ alias, thinking }) => {
389372
host.restoreEditor();
390-
void performModelSwitch(host, alias, thinking, true);
373+
void performModelSwitch(host, alias, thinking, 'model');
391374
},
392375
onSessionOnlySelect: ({ alias, thinking }) => {
393376
host.restoreEditor();
394-
void performModelSwitch(host, alias, thinking, false);
377+
void performModelSwitch(host, alias, thinking, 'session');
395378
},
396379
onCancel: () => {
397380
host.restoreEditor();
@@ -400,139 +383,6 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
400383
);
401384
}
402385

403-
async function performModelSwitch(
404-
host: SlashCommandHost,
405-
alias: string,
406-
effort: ThinkingEffort,
407-
persist: boolean,
408-
): Promise<void> {
409-
let session = host.session;
410-
if (session === undefined) {
411-
// A first prompt may still be inside lazy creation: wait it out so the
412-
// switch lands on the new session instead of being overwritten by its
413-
// assembly.
414-
await host.waitForLazyCreation();
415-
session = host.session;
416-
}
417-
if (host.state.appState.streamingPhase !== 'idle') {
418-
host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');
419-
return;
420-
}
421-
422-
const prevModel = host.state.appState.model;
423-
const prevEffort = host.state.appState.thinkingEffort;
424-
const modelChanged = alias !== prevModel;
425-
const effortChanged = effort !== prevEffort;
426-
const runtimeChanged = modelChanged || effortChanged;
427-
let effectiveAlias = alias;
428-
let effectiveEffort = effort;
429-
430-
try {
431-
if (session === undefined && runtimeChanged) {
432-
await host.authFlow.activateModelAfterLogin(alias, effort);
433-
} else if (session !== undefined) {
434-
if (alias !== prevModel) {
435-
await session.setModel(alias);
436-
}
437-
if (effort !== prevEffort) {
438-
await session.setThinking(effort);
439-
}
440-
const status = await session.getStatus();
441-
effectiveAlias = status.model ?? alias;
442-
effectiveEffort = status.thinkingEffort;
443-
}
444-
} catch (error) {
445-
const msg = formatErrorMessage(error);
446-
host.showError(`Failed to switch model: ${msg}`);
447-
return;
448-
}
449-
450-
if (session === undefined) {
451-
effectiveAlias = host.state.appState.model;
452-
effectiveEffort = host.state.appState.thinkingEffort;
453-
}
454-
const effectiveModelChanged = effectiveAlias !== prevModel;
455-
const effectiveEffortChanged = effectiveEffort !== prevEffort;
456-
const displayName = modelDisplayName(
457-
effectiveAlias,
458-
host.state.appState.availableModels[effectiveAlias],
459-
);
460-
host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort });
461-
if (session === undefined && runtimeChanged) {
462-
if (effectiveModelChanged) {
463-
host.track('model_switch', { model: effectiveAlias });
464-
}
465-
if (effectiveEffortChanged) {
466-
host.track('thinking_toggle', {
467-
enabled: effectiveEffort !== 'off',
468-
effort: effectiveEffort,
469-
from: prevEffort,
470-
});
471-
}
472-
}
473-
474-
let persisted = false;
475-
if (persist) {
476-
try {
477-
persisted = await persistModelSelection(
478-
host,
479-
effectiveAlias,
480-
effectiveEffort,
481-
effectiveEffortChanged,
482-
);
483-
} catch (error) {
484-
const msg = formatErrorMessage(error);
485-
host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`);
486-
return;
487-
}
488-
}
489-
490-
let status: string;
491-
if (effectiveModelChanged) {
492-
status = persist
493-
? `Switched to ${displayName} with thinking ${effectiveEffort}.`
494-
: `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`;
495-
} else if (effectiveEffortChanged) {
496-
status = persist
497-
? `Thinking set to ${effectiveEffort}.`
498-
: `Thinking set to ${effectiveEffort} for this session only.`;
499-
} else if (persist && persisted) {
500-
status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`;
501-
} else {
502-
status = `Already using ${displayName} with thinking ${effectiveEffort}.`;
503-
}
504-
host.showStatus(status, 'success');
505-
}
506-
507-
async function persistModelSelection(
508-
host: SlashCommandHost,
509-
alias: string,
510-
effort: ThinkingEffort,
511-
effortChanged: boolean,
512-
): Promise<boolean> {
513-
const config = await host.harness.getConfig({ reload: true });
514-
const model = host.state.appState.availableModels[alias];
515-
const full = thinkingEffortToConfig(
516-
effort,
517-
model === undefined ? undefined : effectiveModelForHost(host, model),
518-
);
519-
// Re-confirming the effort shown when the picker opened is not an explicit
520-
// choice — persist the model but leave the stored effort preference alone.
521-
const patch = effortChanged ? full : { enabled: full.enabled };
522-
if (
523-
config.defaultModel === alias &&
524-
config.thinking?.enabled === patch.enabled &&
525-
(!effortChanged || config.thinking?.effort === patch.effort)
526-
) {
527-
return false;
528-
}
529-
await host.harness.setConfig({
530-
defaultModel: alias,
531-
thinking: patch,
532-
});
533-
return true;
534-
}
535-
536386
// ---------------------------------------------------------------------------
537387
// Secondary model (`/secondary-model`) — persists `[secondary_model] default_model`
538388
// ---------------------------------------------------------------------------

‎apps/kimi-code/src/tui/commands/dispatch.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export interface SlashCommandHost {
125125
showNotice(title: string, detail?: string): void;
126126
appendTranscriptEntry(entry: TranscriptEntry): void;
127127
track(event: string, props?: Record<string, unknown>): void;
128-
mountEditorReplacement(panel: Component & Focusable): void;
128+
mountEditorReplacement(panel: Component & Focusable, onDisplaced?: () => void): void;
129129
restoreEditor(): void;
130130
restoreInputText(text: string): void;
131131
refreshSlashCommandAutocomplete(): void;
@@ -156,8 +156,10 @@ export interface SlashCommandHost {
156156
* fails.
157157
*/
158158
ensureSession(): Promise<Session | undefined>;
159-
/** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */
160-
waitForLazyCreation(): Promise<void>;
159+
/** Await in-flight session assembly — lazy creation or a `/new` replacement — if any; no-op otherwise. */
160+
waitForSessionAssembly(): Promise<void>;
161+
/** True while a `/new` replacement is assembling; idle checks must treat it as busy. */
162+
readonly sessionReplacementInFlight: boolean;
161163
switchToSession(session: Session, message: string): Promise<void>;
162164
reloadCurrentSessionView(session: Session, message: string): Promise<void>;
163165
beginSessionRequest(): void;
@@ -381,7 +383,7 @@ async function handleBuiltInSlashCommand(
381383
case 'new': {
382384
// A first-use lazy creation may still be in flight: wait it out so /new
383385
// never races a second createSession against the pending prompt.
384-
await host.waitForLazyCreation();
386+
await host.waitForSessionAssembly();
385387
// The waited-out prompt may have started a turn meanwhile; /new is
386388
// idle-only, so re-run the busy gate resolved before the await.
387389
const busyReason = slashCommandBusyReason({

‎apps/kimi-code/src/tui/commands/provider.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model
2828
import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui';
2929
import { formatErrorMessage } from '../utils/event-payload';
3030
import { thinkingEffortToConfig } from '../utils/thinking-config';
31-
import { effectiveModelForHost } from './config';
31+
import { effectiveModelForHost } from '../utils/model-switch';
3232
import {
3333
promptApiKey,
3434
promptBaseUrl,
@@ -307,7 +307,7 @@ export async function setDefaultModel(
307307
// A first prompt may still be inside lazy creation: wait it out so the
308308
// pick lands on the new session instead of racing its assembly (same
309309
// coordination as the /model path).
310-
await host.waitForLazyCreation();
310+
await host.waitForSessionAssembly();
311311
}
312312
await host.harness.setConfig({
313313
defaultModel: alias,

‎apps/kimi-code/src/tui/commands/web.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function handleWebCommand(host: SlashCommandHost): Promise<void> {
4444
}
4545

4646
export async function handleRemoteControlCommand(host: SlashCommandHost): Promise<void> {
47-
await host.waitForLazyCreation();
47+
await host.waitForSessionAssembly();
4848
const session = host.session;
4949

5050
const holder = await inspectRemoteControlLock(getDataDir());

‎apps/kimi-code/src/tui/components/dialogs/choice-picker.ts‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import {
1818
} from '@moonshot-ai/pi-tui';
1919
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';
2020
import { currentTheme, type ColorToken } from '#/tui/theme';
21+
import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme';
22+
import { createMarkdownOptions } from '#/tui/utils/markdown-options';
23+
import { Markdown } from '#/tui/components/markdown/markdown';
2124
import { printableChar } from '#/tui/utils/printable-key';
2225
import { SearchableList } from '#/tui/utils/searchable-list';
2326

@@ -39,14 +42,16 @@ export interface ChoicePickerOptions {
3942
readonly title: string;
4043
readonly hint?: string;
4144
readonly formatHint?: (text: string) => string;
42-
readonly notice?: string;
43-
/** Color tone for the notice line. Defaults to 'success'. */
45+
/** Plain text shown in `noticeTone`, or Markdown rendered with the Markdown theme. */
46+
readonly notice?: string | { readonly markdown: string };
47+
/** Color tone for a plain-text notice. Defaults to 'success'. */
4448
readonly noticeTone?: 'success' | 'warning';
4549
readonly options: readonly ChoiceOption[];
4650
readonly currentValue?: string;
4751
readonly initialValue?: string;
4852
/** When true, typed characters filter the list (fuzzy) and a search line is shown. */
4953
readonly searchable?: boolean;
54+
readonly spaceSelect?: boolean;
5055
/** Items per page. Lists longer than this paginate. */
5156
readonly pageSize?: number;
5257
readonly onSelect: (value: string) => void;
@@ -119,10 +124,12 @@ export class ChoicePickerComponent extends Container implements Focusable {
119124
this.list.pageDown();
120125
return;
121126
}
122-
// Enter always selects. Space selects too — but only when the list is not
123-
// searchable; in a searchable list a space must reach the query instead.
127+
// Space confirmation is opt-out; searchable lists always route it to the query.
124128
const isSpace = matchesKey(data, Key.space) || printableChar(data) === ' ';
125-
if (matchesKey(data, Key.enter) || (isSpace && this.opts.searchable !== true)) {
129+
if (
130+
matchesKey(data, Key.enter) ||
131+
(isSpace && this.opts.searchable !== true && this.opts.spaceSelect !== false)
132+
) {
126133
const chosen = this.list.selected();
127134
if (chosen !== undefined) this.opts.onSelect(chosen.value);
128135
return;
@@ -157,10 +164,22 @@ export class ChoicePickerComponent extends Container implements Focusable {
157164
: this.opts.formatHint(` ${hintLine}`),
158165
);
159166
}
160-
if (this.opts.notice !== undefined) {
167+
const notice = this.opts.notice;
168+
if (typeof notice === 'object') {
169+
const markdown = new Markdown(
170+
notice.markdown,
171+
0,
172+
0,
173+
createMarkdownTheme(),
174+
undefined,
175+
createMarkdownOptions(),
176+
);
177+
lines.push('');
178+
lines.push(...markdown.render(Math.max(1, width - 1)).map((line) => ` ${line}`));
179+
} else if (notice !== undefined) {
161180
const tone = this.opts.noticeTone ?? 'success';
162181
const noticeWidth = Math.max(1, width - 1);
163-
for (const noticeLine of this.opts.notice.split(/\r?\n/)) {
182+
for (const noticeLine of notice.split(/\r?\n/)) {
164183
for (const wrapped of wrapDescription(noticeLine, noticeWidth)) {
165184
lines.push(currentTheme.fg(tone, ` ${wrapped}`));
166185
}

0 commit comments

Comments
 (0)