Skip to content

Commit 918c135

Browse files
authored
fix: align Anthropic-compatible model capabilities (#1746)
* fix: align Anthropic-compatible model capabilities * fix: warn on Anthropic effort mismatches * fix: harden Anthropic model resolution * fix: align Anthropic replay and ACP thinking state * fix: normalize Anthropic thinking stream payloads * fix: backfill non-empty preserved thinking * fix: resolve session thinking effort with provider context * fix: honor adaptive thinking opt-out in effort resolution
1 parent f0c8a10 commit 918c135

55 files changed

Lines changed: 3535 additions & 573 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+
Honor adaptive_thinking = false on Anthropic-compatible models by limiting thinking efforts to the legacy budget set and omitting the effort parameter from requests.
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+
Apply official Anthropic effort profiles and a 128k output fallback for unknown models. Preserve compatible-provider thinking history across session resumes and model switches, normalize incomplete stream events, and warn on unlisted efforts.
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+
Fix custom-named models on Anthropic-compatible providers starting new sessions with thinking effort off instead of the model default, and not showing the thinking control in ACP clients.

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig {
4545
};
4646
}
4747

48+
function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias {
49+
const providerType = host.state.appState.availableProviders[model.provider]?.type;
50+
return effectiveModelAlias(model, (model.protocol ?? providerType) === 'anthropic');
51+
}
52+
4853
export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
4954
const session = host.session;
5055
if (session === undefined) {
@@ -234,18 +239,27 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string):
234239
host.showError('No model selected. Run /model to select one first.');
235240
return;
236241
}
237-
const effective = effectiveModelAlias(model);
242+
const effective = effectiveModelForHost(host, model);
238243
const segments = segmentsFor(effective);
239244
const arg = args.trim().toLowerCase();
240245
if (arg.length === 0) {
241246
showEffortPicker(host, effective, segments);
242247
return;
243248
}
244249
if (!segments.includes(arg)) {
245-
host.showError(
246-
`Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`,
250+
const providerType = host.state.appState.availableProviders[effective.provider]?.type;
251+
const protocol = effective.protocol ?? providerType;
252+
if (protocol !== 'anthropic') {
253+
host.showError(
254+
`Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`,
255+
);
256+
return;
257+
}
258+
const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared';
259+
host.showStatus(
260+
`Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`,
261+
'warning',
247262
);
248-
return;
249263
}
250264
await performModelSwitch(host, alias, arg, true);
251265
}
@@ -358,7 +372,13 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise
358372
}
359373

360374
export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void {
361-
const entries = Object.entries(host.state.appState.availableModels);
375+
const models = Object.fromEntries(
376+
Object.entries(host.state.appState.availableModels).map(([alias, model]) => [
377+
alias,
378+
effectiveModelForHost(host, model),
379+
]),
380+
);
381+
const entries = Object.entries(models);
362382
if (entries.length === 0) {
363383
host.showNotice(
364384
'No models configured',
@@ -368,7 +388,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
368388
}
369389
host.mountEditorReplacement(
370390
new TabbedModelSelectorComponent({
371-
models: host.state.appState.availableModels,
391+
models,
372392
currentValue: host.state.appState.model,
373393
selectedValue,
374394
currentThinkingEffort: host.state.appState.thinkingEffort,

apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,55 @@ describe('ModelSelectorComponent', () => {
338338
expect(out).toContain('Thinking (←→ to switch)');
339339
});
340340

341+
it('derives official Anthropic effort segments from the model name', () => {
342+
const onSelect = vi.fn();
343+
const picker = new ModelSelectorComponent({
344+
models: {
345+
opus: {
346+
provider: 'anthropic',
347+
model: 'claude-opus-4-6',
348+
maxContextSize: 200000,
349+
},
350+
},
351+
currentValue: 'opus',
352+
currentThinkingEffort: 'high',
353+
onSelect,
354+
onCancel: vi.fn(),
355+
});
356+
357+
const out = text(picker);
358+
expect(out).toContain('Low');
359+
expect(out).toContain('[ High ]');
360+
expect(out).toContain('Max');
361+
expect(out).toContain('Off');
362+
expect(out).not.toContain('Xhigh');
363+
364+
picker.handleInput(RIGHT);
365+
picker.handleInput('\r');
366+
expect(onSelect).toHaveBeenCalledWith({ alias: 'opus', thinking: 'max' });
367+
});
368+
369+
it('derives official always-on Anthropic models without an Off segment', () => {
370+
const picker = new ModelSelectorComponent({
371+
models: {
372+
fable: {
373+
provider: 'anthropic',
374+
model: 'claude-fable-5',
375+
maxContextSize: 200000,
376+
},
377+
},
378+
currentValue: 'fable',
379+
currentThinkingEffort: 'high',
380+
onSelect: vi.fn(),
381+
onCancel: vi.fn(),
382+
});
383+
384+
const out = text(picker);
385+
expect(out).toContain('Xhigh');
386+
expect(out).toContain('Max');
387+
expect(out).not.toContain('Off');
388+
});
389+
341390
it('cycles efforts with Left/Right and clamps at the ends', () => {
342391
const onSelect = vi.fn();
343392
const picker = new ModelSelectorComponent({

apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi
1212
import { afterEach, describe, expect, it, vi } from 'vitest';
1313

1414
import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel';
15+
import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector';
1516
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app';
1617
import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering';
1718
import {
@@ -5161,16 +5162,20 @@ describe('/model status displayName override', () => {
51615162
});
51625163

51635164
describe('/effort support_efforts override', () => {
5164-
it('rejects efforts hidden by support_efforts override', async () => {
5165+
it('warns and applies efforts hidden by an Anthropic support_efforts override', async () => {
51655166
const session = makeSession();
51665167
const { driver } = await makeDriver(session, {
51675168
getConfig: vi.fn(async () => ({
5169+
providers: {
5170+
compatible: { type: 'kimi', apiKey: 'test-key' },
5171+
},
51685172
models: {
51695173
k2: {
5170-
provider: 'managed:kimi-code',
5171-
model: 'kimi-k2',
5174+
provider: 'compatible',
5175+
model: 'compatible-model',
5176+
protocol: 'anthropic',
51725177
maxContextSize: 100,
5173-
displayName: 'Kimi K2',
5178+
displayName: 'Compatible Model',
51745179
capabilities: ['thinking'],
51755180
supportEfforts: ['low', 'high', 'max'],
51765181
overrides: { supportEfforts: ['low', 'high'] },
@@ -5184,8 +5189,73 @@ describe('/effort support_efforts override', () => {
51845189
driver.handleUserInput('/effort max');
51855190

51865191
await vi.waitFor(() => {
5187-
expect(renderTranscript(driver)).toContain('Unsupported thinking effort "max" for k2. Available: off, low, high');
5192+
expect(session.setThinking).toHaveBeenCalledWith('max');
5193+
});
5194+
await vi.waitFor(() => {
5195+
expect(renderTranscript(driver)).toContain('Thinking set to max.');
5196+
});
5197+
const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' ');
5198+
expect(transcript).toContain(
5199+
'Thinking effort "max" is not listed for k2 (known: low, high). Sending "max" unchanged; the configured provider will validate it.',
5200+
);
5201+
expect(transcript).toContain('Thinking set to max.');
5202+
});
5203+
5204+
it('offers the latest Opus efforts for an unknown Anthropic-compatible model', async () => {
5205+
const { driver } = await makeDriver(makeSession(), {
5206+
getConfig: vi.fn(async () => ({
5207+
providers: {
5208+
compatible: { type: 'kimi', apiKey: 'test-key' },
5209+
},
5210+
models: {
5211+
k2: {
5212+
provider: 'compatible',
5213+
model: 'compatible-model',
5214+
protocol: 'anthropic',
5215+
maxContextSize: 100,
5216+
},
5217+
},
5218+
defaultModel: 'k2',
5219+
})),
5220+
});
5221+
5222+
driver.handleUserInput('/effort');
5223+
5224+
await vi.waitFor(() => {
5225+
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent);
5226+
});
5227+
const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent;
5228+
expect(picker.render(80).join('\n')).toContain('Max');
5229+
});
5230+
5231+
it('keeps rejecting efforts hidden by a Kimi support_efforts override', async () => {
5232+
const session = makeSession();
5233+
const { driver } = await makeDriver(session, {
5234+
getConfig: vi.fn(async () => ({
5235+
providers: {
5236+
kimi: { type: 'kimi', apiKey: 'test-key' },
5237+
},
5238+
models: {
5239+
k2: {
5240+
provider: 'kimi',
5241+
model: 'kimi-model',
5242+
maxContextSize: 100,
5243+
capabilities: ['thinking'],
5244+
supportEfforts: ['low', 'high'],
5245+
},
5246+
},
5247+
defaultModel: 'k2',
5248+
thinking: { enabled: true, effort: 'low' },
5249+
})),
5250+
});
5251+
5252+
driver.handleUserInput('/effort max');
5253+
5254+
await vi.waitFor(() => {
5255+
expect(renderTranscript(driver)).toContain(
5256+
'Unsupported thinking effort "max" for k2. Available: off, low, high',
5257+
);
51885258
});
5189-
expect(renderTranscript(driver)).not.toContain('Switched to Kimi K2 with thinking max.');
5259+
expect(session.setThinking).not.toHaveBeenCalled();
51905260
});
51915261
});

packages/acp-adapter/src/model-catalog.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,20 @@
1515
* `for model_key, model in models.items()`.
1616
*
1717
* `thinkingSupported` is true if any of:
18-
* 1. the alias's declared `capabilities` array contains `'thinking'`, or
18+
* 1. the alias's declared `capabilities` array contains `'thinking'`
19+
* (including the capability inferred from the Anthropic wire protocol —
20+
* see the `anthropicCompatible` context below), or
1921
* 2. the underlying model name matches `/thinking|reason/i`
2022
* (always-thinking variants), or
2123
* 3. the underlying model name is on the {@link TOGGLEABLE_THINKING_MODELS}
2224
* allow-list (mirrors `kimi-cli/src/kimi_cli/llm.py:derive_model_capabilities`).
25+
*
26+
* The runtime resolves a model's wire protocol from
27+
* `alias.protocol ?? provider.type` (see
28+
* `ProviderManager.resolveProviderConfig`). The derive helpers below take the
29+
* provider-derived `anthropicCompatible` flag as an optional second argument
30+
* so the catalog agrees with the runtime about Anthropic profiles even when
31+
* the alias itself does not declare `protocol`.
2332
*/
2433

2534
import { effectiveModelAlias } from '@moonshot-ai/agent-core';
@@ -55,8 +64,8 @@ export interface AcpModelEntry {
5564
*/
5665
const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']);
5766

58-
export function deriveThinkingSupported(alias: ModelAlias): boolean {
59-
const effective = effectiveModelAlias(alias);
67+
export function deriveThinkingSupported(alias: ModelAlias, anthropicCompatible = false): boolean {
68+
const effective = effectiveModelAlias(alias, anthropicCompatible);
6069
const declared = effective.capabilities ?? [];
6170
if (declared.includes('thinking') || declared.includes('always_thinking')) return true;
6271
const lower = effective.model.toLowerCase();
@@ -72,17 +81,22 @@ export function deriveThinkingSupported(alias: ModelAlias): boolean {
7281
* `thinkingSupported`, but only an explicit (server-derived) declaration
7382
* may remove the off option from the client.
7483
*/
75-
export function deriveAlwaysThinking(alias: ModelAlias): boolean {
76-
return (effectiveModelAlias(alias).capabilities ?? []).includes('always_thinking');
84+
export function deriveAlwaysThinking(alias: ModelAlias, anthropicCompatible = false): boolean {
85+
return (effectiveModelAlias(alias, anthropicCompatible).capabilities ?? []).includes(
86+
'always_thinking',
87+
);
7788
}
7889

7990
/**
8091
* The effort a boolean "thinking on" toggle maps to for this model: declared
8192
* `default_effort`, else the middle `support_efforts` entry, else `'on'` for
8293
* boolean models (no `support_efforts`).
8394
*/
84-
export function deriveDefaultThinkingEffort(alias: ModelAlias): string {
85-
const effective = effectiveModelAlias(alias);
95+
export function deriveDefaultThinkingEffort(
96+
alias: ModelAlias,
97+
anthropicCompatible = false,
98+
): string {
99+
const effective = effectiveModelAlias(alias, anthropicCompatible);
86100
const efforts = effective.supportEfforts;
87101
if (efforts !== undefined && efforts.length > 0) {
88102
return effective.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!;
@@ -102,24 +116,45 @@ export async function listModelsFromHarness(
102116
harness: KimiHarness,
103117
): Promise<readonly AcpModelEntry[]> {
104118
if (typeof harness.getConfig !== 'function') return [];
105-
let models: Record<string, ModelAlias> | undefined;
119+
let config: Awaited<ReturnType<KimiHarness['getConfig']>>;
106120
try {
107-
const config = await harness.getConfig();
108-
models = config.models;
121+
config = await harness.getConfig();
109122
} catch {
110123
return [];
111124
}
125+
const models = config.models;
112126
if (models === undefined) return [];
113127
const out: AcpModelEntry[] = [];
114128
for (const [id, alias] of Object.entries(models)) {
115-
const effective = effectiveModelAlias(alias);
129+
const anthropicCompatible = usesAnthropicProvider(alias, config);
130+
const effective = effectiveModelAlias(alias, anthropicCompatible);
116131
out.push({
117132
id,
118133
name: effective.displayName ?? effective.model ?? id,
119-
thinkingSupported: deriveThinkingSupported(alias),
120-
alwaysThinking: deriveAlwaysThinking(alias),
121-
defaultThinkingEffort: deriveDefaultThinkingEffort(alias),
134+
thinkingSupported: deriveThinkingSupported(alias, anthropicCompatible),
135+
alwaysThinking: deriveAlwaysThinking(alias, anthropicCompatible),
136+
defaultThinkingEffort: deriveDefaultThinkingEffort(alias, anthropicCompatible),
122137
});
123138
}
124139
return out;
125140
}
141+
142+
/**
143+
* Provider-level Anthropic context for an alias, mirroring how
144+
* `ProviderManager.resolveProviderConfig` resolves the wire protocol: the
145+
* alias's provider (falling back to the configured default provider) decides
146+
* when the alias itself does not declare `protocol`. Without this the catalog
147+
* would mark a custom-named model on an `type = "anthropic"` provider as not
148+
* thinking-capable while the runtime infers the latest Anthropic profile.
149+
*/
150+
function usesAnthropicProvider(
151+
alias: ModelAlias,
152+
config: {
153+
providers?: Record<string, { type?: string } | undefined>;
154+
defaultProvider?: string | undefined;
155+
},
156+
): boolean {
157+
const providerName = alias.provider ?? config.defaultProvider;
158+
if (providerName === undefined) return false;
159+
return config.providers?.[providerName]?.type === 'anthropic';
160+
}

0 commit comments

Comments
 (0)