Skip to content
Draft
9 changes: 9 additions & 0 deletions .changeset/always-thinking-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@moonshot-ai/kosong": minor
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code-oauth": minor
"@moonshot-ai/kimi-code": minor
---

Surface models whose thinking cannot be turned off as always-on in the model selector, without requiring a manual capability declaration. Detection covers Claude Fable (including vendor-prefixed ids like `us.anthropic.claude-fable-5-v1:0`), OpenAI o-series, and Gemini 2.5 Pro, and both catalog routes (`always_reasoning` in models.dev-style catalogs and custom api.json registries) can declare it. Capabilities are resolved at read time through a single shared resolver — `resolveAliasCapabilities` — so config files stay pure declarations and write-back paths persist snapshots verbatim.
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
host.mountEditorReplacement(
new TabbedModelSelectorComponent({
models: host.state.appState.availableModels,
providers: host.state.appState.availableProviders,
currentValue: host.state.appState.model,
selectedValue,
currentThinking: host.state.appState.thinking,
Expand Down
8 changes: 6 additions & 2 deletions apps/kimi-code/src/tui/commands/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
catalogModelToAlias,
inferWireType,
resolveAliasCapabilities,
type Catalog,
type CatalogModel,
type ModelAlias,
Expand Down Expand Up @@ -162,8 +163,11 @@ export function runModelSelector(
): Promise<{ alias: string; thinking: boolean } | undefined> {
return new Promise((resolve) => {
const firstAlias = Object.keys(modelDict)[0] ?? '';
const caps = modelDict[firstAlias]?.capabilities ?? [];
const initialThinking = caps.includes('always_thinking') || caps.includes('thinking');
const first = modelDict[firstAlias];
// Pre-add flow: the provider isn't in config yet, so capability
// resolution runs on the catalog-declared strings alone.
const initialThinking =
first !== undefined && resolveAliasCapabilities(undefined, first).thinking;
const selector = new ModelSelectorComponent({
models: modelDict,
currentValue: firstAlias,
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/commands/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {

const selector = new TabbedModelSelectorComponent({
models: mergedModels,
providers: host.state.appState.availableProviders,
currentValue: host.state.appState.model,
selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)),
currentThinking: host.state.appState.thinking,
Expand Down Expand Up @@ -315,6 +316,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
: addedProviderIds[0];
const selector = new TabbedModelSelectorComponent({
models: stateModels,
providers: host.state.appState.availableProviders,
currentValue: host.state.appState.model,
selectedValue: firstNewAlias,
currentThinking: host.state.appState.thinking,
Expand Down
42 changes: 30 additions & 12 deletions apps/kimi-code/src/tui/components/dialogs/model-selector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
import { resolveAliasCapabilities } from '@moonshot-ai/kimi-code-sdk';
import type { ModelAlias, ProviderConfig } from '@moonshot-ai/kimi-code-sdk';
import {
Container,
Key,
Expand Down Expand Up @@ -54,6 +55,14 @@ export function createModelChoiceOptions(

export interface ModelSelectorOptions {
readonly models: Record<string, ModelAlias>;
/**
* Provider wire types keyed by provider id, used to resolve detected
* capabilities (e.g. claude-fable-5 → always-on thinking) for aliases that
* don't declare them. Only `type` is read — callers can pass their full
* `ProviderConfig` map as-is. Omitted in pre-add flows where aliases carry
* catalog-declared capability strings instead.
*/
readonly providers?: Record<string, Pick<ProviderConfig, 'type'>>;
readonly currentValue: string;
readonly selectedValue?: string;
readonly currentThinking: boolean;
Expand All @@ -76,15 +85,20 @@ function createModelChoices(models: Record<string, ModelAlias>): readonly ModelC
});
}

function thinkingAvailability(model: ModelAlias): ThinkingAvailability {
const caps = model.capabilities ?? [];
if (caps.includes('always_thinking')) return 'always-on';
if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle';
function thinkingAvailability(
model: ModelAlias,
providers: Record<string, Pick<ProviderConfig, 'type'>> | undefined,
): ThinkingAvailability {
const resolved = resolveAliasCapabilities(providers?.[model.provider]?.type, model);
if (resolved.always_thinking) return 'always-on';
if (resolved.thinking || model.adaptiveThinking === true) return 'toggle';
return 'unsupported';
}

function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean {
const availability = thinkingAvailability(model);
function effectiveThinking(
availability: ThinkingAvailability,
thinkingDraft: boolean,
): boolean {
if (availability === 'always-on') return true;
if (availability === 'unsupported') return false;
return thinkingDraft;
Expand Down Expand Up @@ -120,6 +134,10 @@ export class ModelSelectorComponent extends Container implements Focusable {
});
}

private availabilityFor(model: ModelAlias): ThinkingAvailability {
return thinkingAvailability(model, this.opts.providers);
}

/**
* Thinking draft for a model: an explicit ←/→ override when set, otherwise
* the live thinking state for the active model, otherwise On for any other
Expand All @@ -129,7 +147,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
const override = this.thinkingOverrides.get(choice.alias);
if (override !== undefined) return override;
if (choice.alias === this.opts.currentValue) return this.opts.currentThinking;
return thinkingAvailability(choice.model) !== 'unsupported';
return this.availabilityFor(choice.model) !== 'unsupported';
}

handleInput(data: string): void {
Expand All @@ -147,7 +165,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
// Left/Right toggle the thinking draft for models that support it.
if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
const selected = this.selectedChoice();
if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') {
if (selected !== undefined && this.availabilityFor(selected.model) === 'toggle') {
this.thinkingOverrides.set(selected.alias, !this.draftFor(selected));
}
return;
Expand All @@ -158,7 +176,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
if (selected === undefined) return;
this.opts.onSelect({
alias: selected.alias,
thinking: effectiveThinking(selected.model, this.draftFor(selected)),
thinking: effectiveThinking(this.availabilityFor(selected.model), this.draftFor(selected)),
});
}
}
Expand Down Expand Up @@ -240,7 +258,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
lines.push('');
const selected = this.selectedChoice();
if (selected !== undefined) {
const availability = thinkingAvailability(selected.model);
const availability = this.availabilityFor(selected.model);
const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking';
lines.push(currentTheme.fg('textMuted', thinkingHeader));
lines.push(this.renderThinkingControl(selected));
Expand All @@ -260,7 +278,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
? currentTheme.boldFg('primary', `[ ${label} ]`)
: currentTheme.fg('text', ` ${label} `);

const availability = thinkingAvailability(choice.model);
const availability = this.availabilityFor(choice.model);
if (availability === 'always-on') {
return ` ${segment('Always on', true)}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* AskUserQuestion dialog's tab strip) — see .agents/skills/write-tui/DESIGN.md.
*/

import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
import type { ModelAlias, ProviderConfig } from '@moonshot-ai/kimi-code-sdk';
import {
Container,
Key,
Expand All @@ -37,6 +37,8 @@ const ALL_TAB_LABEL = 'All';

export interface TabbedModelSelectorOptions {
readonly models: Record<string, ModelAlias>;
/** Passed through to the inner selectors — see {@link ModelSelectorOptions}. */
readonly providers?: Record<string, Pick<ProviderConfig, 'type'>>;
readonly currentValue: string;
readonly selectedValue?: string;
readonly currentThinking: boolean;
Expand Down Expand Up @@ -244,6 +246,7 @@ function makeSelector(
const selectedValue = subset[candidate] !== undefined ? candidate : undefined;
const inner: ModelSelectorOptions = {
models: subset,
providers: opts.providers,
currentValue: opts.currentValue,
...(selectedValue !== undefined ? { selectedValue } : {}),
currentThinking: opts.currentThinking,
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,10 @@ export class KimiTUI {
return true;
}

// Deliberately NOT resolveAliasCapabilities: this gate is permissive when
// nothing is declared, while the resolver reports `false` for modalities of
// models kosong has no built-in knowledge of — which would wrongly reject
// media on undeclared (e.g. kimi) models.
private supportsCurrentModelCapability(capability: string): boolean {
const capabilities =
this.state.appState.availableModels[this.state.appState.model]?.capabilities;
Expand Down
28 changes: 28 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,34 @@ describe('ModelSelectorComponent', () => {
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false });
});

it('renders detected always-thinking models as always-on without a declaration', () => {
const onSelect = vi.fn();
const picker = new ModelSelectorComponent({
models: {
fable: {
provider: 'anthropic',
model: 'claude-fable-5',
maxContextSize: 1_000_000,
displayName: 'Claude Fable 5',
},
},
providers: {
// The component only needs the wire type — not credentials.
anthropic: { type: 'anthropic' },
},
currentValue: 'fable',
currentThinking: false,
onSelect,
onCancel: vi.fn(),
});

expect(text(picker)).toContain('[ Always on ]');
// ←/→ cannot toggle always-on thinking off.
picker.handleInput(RIGHT);
picker.handleInput('\r');
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'fable', thinking: true });
});

it('keeps the thinking draft when moving across models', () => {
const onSelect = vi.fn();
const picker = new ModelSelectorComponent({
Expand Down
13 changes: 9 additions & 4 deletions packages/acp-adapter/src/config-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ export function buildModelOption(
* only the wire encoding is `'on'` / `'off'` strings.
*
* The caller decides whether to include this option at all — when the
* currently-selected model has `thinkingSupported === false`, the
* snapshot omits it entirely (dynamic visibility), so the client never
* shows a toggle that wouldn't do anything.
* currently-selected model has `thinkingSupported === false`, or is
* `alwaysThinking` (the off entry would silently run thinking anyway),
* the snapshot omits it entirely (dynamic visibility), so the client
* never shows a toggle that wouldn't do anything.
*/
export function buildThinkingOption(enabled: boolean): SessionConfigOption {
return {
Expand Down Expand Up @@ -170,7 +171,11 @@ export async function buildSessionConfigOptions(
): Promise<SessionConfigOption[]> {
const models = await listModelsFromHarness(harness);
const currentModelEntry = models.find((m) => m.id === currentBaseModelId);
const showThinking = currentModelEntry?.thinkingSupported === true;
// Always-thinking models get no toggle: ACP's select arm has no read-only
// entry, and an "off" choice would misreport a model that reasons (and
// bills thinking tokens) regardless.
const showThinking =
currentModelEntry?.thinkingSupported === true && !currentModelEntry.alwaysThinking;
const out: SessionConfigOption[] = [buildModelOption(models, currentBaseModelId)];
if (showThinking) {
out.push(buildThinkingOption(currentThinkingEnabled));
Expand Down
48 changes: 39 additions & 9 deletions packages/acp-adapter/src/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,28 @@
* `for model_key, model in models.items()`.
*
* `thinkingSupported` is true if any of:
* 1. the alias's declared `capabilities` array contains `'thinking'`, or
* 1. the alias resolves to thinking via `resolveAliasCapabilities` —
* declared `capabilities` (`'thinking'` or `'always_thinking'`,
* case-insensitive) or kosong's built-in detection for the provider
* wire type (e.g. `claude-fable-5`), or
* 2. the underlying model name matches `/thinking|reason/i`
* (always-thinking variants), or
* 3. the underlying model name is on the {@link TOGGLEABLE_THINKING_MODELS}
* allow-list (mirrors `kimi-cli/src/kimi_cli/llm.py:derive_model_capabilities`).
*
* `alwaysThinking` is set only by route 1 (capability resolution): the
* name-regex route cannot tell an always-on variant from a toggleable one,
* so it stays a plain `thinkingSupported`. Consumers use it to suppress
* thinking-off controls — offering "off" on such a model would silently
* run (and bill) thinking anyway.
*/

import type { KimiHarness, ModelAlias } from '@moonshot-ai/kimi-code-sdk';
import {
resolveAliasCapabilities,
type KimiHarness,
type ModelAlias,
type ProviderConfig,
} from '@moonshot-ai/kimi-code-sdk';

/**
* One catalog row per configured model alias, suitable for an ACP
Expand All @@ -35,6 +49,13 @@ export interface AcpModelEntry {
readonly name: string;
readonly description?: string | undefined;
readonly thinkingSupported: boolean;
/**
* The model always reasons and cannot run with thinking turned off
* (kosong's `always_thinking`, e.g. `claude-fable-5`). Implies
* `thinkingSupported`. Consumers must not offer a thinking-off control
* when this is set.
*/
readonly alwaysThinking?: true;
}

/**
Expand All @@ -45,13 +66,17 @@ export interface AcpModelEntry {
*/
const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']);

export function deriveThinkingSupported(alias: ModelAlias): boolean {
const declared = alias.capabilities ?? [];
if (declared.includes('thinking')) return true;
export function deriveThinking(
alias: ModelAlias,
providerType?: ProviderConfig['type'],
): Pick<AcpModelEntry, 'thinkingSupported' | 'alwaysThinking'> {
const resolved = resolveAliasCapabilities(providerType, alias);
if (resolved.always_thinking) return { thinkingSupported: true, alwaysThinking: true };
if (resolved.thinking) return { thinkingSupported: true };
const lower = alias.model.toLowerCase();
if (lower.includes('thinking') || lower.includes('reason')) return true;
if (TOGGLEABLE_THINKING_MODELS.has(alias.model)) return true;
return false;
if (lower.includes('thinking') || lower.includes('reason')) return { thinkingSupported: true };
if (TOGGLEABLE_THINKING_MODELS.has(alias.model)) return { thinkingSupported: true };
return { thinkingSupported: false };
}

/**
Expand All @@ -67,9 +92,14 @@ export async function listModelsFromHarness(
): Promise<readonly AcpModelEntry[]> {
if (typeof harness.getConfig !== 'function') return [];
let models: Record<string, ModelAlias> | undefined;
let providers: Record<string, ProviderConfig>;
try {
const config = await harness.getConfig();
models = config.models;
// `KimiConfig` types `providers` as required (zod default), but getConfig
// crosses an RPC/stub boundary — a partial harness can omit it, and this
// dereference sits outside the try.
providers = config.providers ?? {};
} catch {
return [];
}
Expand All @@ -79,7 +109,7 @@ export async function listModelsFromHarness(
out.push({
id,
name: alias.displayName ?? alias.model ?? id,
thinkingSupported: deriveThinkingSupported(alias),
...deriveThinking(alias, providers[alias.provider]?.type),
});
}
return out;
Expand Down
15 changes: 15 additions & 0 deletions packages/acp-adapter/test/config-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ function makeHarnessWithModels(
...(entry.capabilities !== undefined ? { capabilities: entry.capabilities } : {}),
};
}
// Deliberately omits `providers`: a partial stub exercises the RPC-boundary
// fallback in listModelsFromHarness.
const getConfig = vi.fn(async () => ({ models }));
return { harness: { getConfig } as unknown as KimiHarness, getConfig };
}
Expand Down Expand Up @@ -160,6 +162,19 @@ describe('buildSessionConfigOptions', () => {
expect(result.map((o) => o.id)).toEqual(['model', 'mode']);
});

it('omits the thinking toggle for always-thinking models — off would be a no-op', async () => {
// Same dynamic-visibility rule as non-thinking models: ACP's select arm
// has no read-only entry, and showing Off/On for a model whose thinking
// cannot be turned off would misreport what actually runs.
const { harness } = makeHarnessWithModels([
{ id: 'fable', model: 'claude-fable-5', capabilities: ['always_thinking'] },
]);

const result = await buildSessionConfigOptions(harness, 'fable', false, 'default');

expect(result.map((o) => o.id)).toEqual(['model', 'mode']);
});

it('reflects the thinking toggle currentValue from the explicit argument', async () => {
const { harness } = makeHarnessWithModels([
{ id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' },
Expand Down
Loading
Loading