Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-kimi-provider-effort-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix Kimi-provider models routed through the Anthropic protocol incorrectly showing reasoning effort options. Effort choices now come only from the model's declared metadata, and the inferred fallback profile applies solely to non-Kimi Anthropic-compatible providers.
5 changes: 4 additions & 1 deletion apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig {

function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias {
const providerType = host.state.appState.availableProviders[model.provider]?.type;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve flat Anthropic efforts in the TUI

When the TUI is backed by a v2 flat model (baseUrl plus protocol: 'anthropic' and no provider), model.provider is undefined here, so this passes no provider context. After this commit effectiveModelAlias no longer infers the Anthropic fallback from protocol alone, while ModelResolverService still does for the same flat model, so /effort and the model picker lose the supportEfforts choices even though the runtime resolves them. Fall back to model.protocol when no provider type is available.

Useful? React with 👍 / 👎.

return effectiveModelAlias(model, (model.protocol ?? providerType) === 'anthropic');
// Flat models (no named provider, e.g. inline base_url served by a v2
// backend) have no provider entry to look up; their own protocol declaration
// plays the provider-identity role, mirroring the resolver.
return effectiveModelAlias(model, providerType ?? model.protocol);
}

export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
Expand Down
52 changes: 52 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5202,6 +5202,32 @@ describe('/effort support_efforts override', () => {
});

it('offers the latest Opus efforts for an unknown Anthropic-compatible model', async () => {
const { driver } = await makeDriver(makeSession(), {
getConfig: vi.fn(async () => ({
providers: {
compatible: { type: 'anthropic', apiKey: 'test-key' },
},
models: {
k2: {
provider: 'compatible',
model: 'compatible-model',
maxContextSize: 100,
},
},
defaultModel: 'k2',
})),
});

driver.handleUserInput('/effort');

await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent);
});
const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent;
expect(picker.render(80).join('\n')).toContain('Max');
});

it('offers no fallback efforts for an unknown model on a Kimi provider using the Anthropic protocol', async () => {
const { driver } = await makeDriver(makeSession(), {
getConfig: vi.fn(async () => ({
providers: {
Expand All @@ -5221,6 +5247,32 @@ describe('/effort support_efforts override', () => {

driver.handleUserInput('/effort');

await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent);
});
const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent;
expect(picker.render(80).join('\n')).not.toContain('Max');
});

it('offers the latest Opus efforts for a flat providerless Anthropic model', async () => {
const { driver } = await makeDriver(makeSession(), {
getConfig: vi.fn(async () => ({
providers: {},
models: {
// v2 flat model shape: no named provider, inline endpoint + protocol.
k2: {
model: 'compatible-model',
baseUrl: 'https://anthropic.example.test',
protocol: 'anthropic',
maxContextSize: 100,
},
},
defaultModel: 'k2',
})),
});

driver.handleUserInput('/effort');

await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent);
});
Expand Down
58 changes: 31 additions & 27 deletions packages/acp-adapter/src/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* `thinkingSupported` is true if any of:
* 1. the alias's declared `capabilities` array contains `'thinking'`
* (including the capability inferred from the Anthropic wire protocol —
* see the `anthropicCompatible` context below), or
* see the `providerType` context below), 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}
Expand All @@ -26,12 +26,12 @@
* The runtime resolves a model's wire protocol from
* `alias.protocol ?? provider.type` (see
* `ProviderManager.resolveProviderConfig`). The derive helpers below take the
* provider-derived `anthropicCompatible` flag as an optional second argument
* so the catalog agrees with the runtime about Anthropic profiles even when
* the alias itself does not declare `protocol`.
* provider's `type` as an optional second argument so the catalog agrees with
* the runtime about Anthropic profiles even when the alias itself does not
* declare `protocol`.
*/

import { effectiveModelAlias } from '@moonshot-ai/agent-core';
import { effectiveModelAlias, type ProviderType } from '@moonshot-ai/agent-core';
import type { KimiHarness, ModelAlias } from '@moonshot-ai/kimi-code-sdk';

/**
Expand Down Expand Up @@ -64,8 +64,8 @@ export interface AcpModelEntry {
*/
const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']);

export function deriveThinkingSupported(alias: ModelAlias, anthropicCompatible = false): boolean {
const effective = effectiveModelAlias(alias, anthropicCompatible);
export function deriveThinkingSupported(alias: ModelAlias, providerType?: ProviderType): boolean {
const effective = effectiveModelAlias(alias, providerType);
const declared = effective.capabilities ?? [];
if (declared.includes('thinking') || declared.includes('always_thinking')) return true;
const lower = effective.model.toLowerCase();
Expand All @@ -81,8 +81,8 @@ export function deriveThinkingSupported(alias: ModelAlias, anthropicCompatible =
* `thinkingSupported`, but only an explicit (server-derived) declaration
* may remove the off option from the client.
*/
export function deriveAlwaysThinking(alias: ModelAlias, anthropicCompatible = false): boolean {
return (effectiveModelAlias(alias, anthropicCompatible).capabilities ?? []).includes(
export function deriveAlwaysThinking(alias: ModelAlias, providerType?: ProviderType): boolean {
return (effectiveModelAlias(alias, providerType).capabilities ?? []).includes(
'always_thinking',
);
}
Expand All @@ -94,9 +94,9 @@ export function deriveAlwaysThinking(alias: ModelAlias, anthropicCompatible = fa
*/
export function deriveDefaultThinkingEffort(
alias: ModelAlias,
anthropicCompatible = false,
providerType?: ProviderType,
): string {
const effective = effectiveModelAlias(alias, anthropicCompatible);
const effective = effectiveModelAlias(alias, providerType);
const efforts = effective.supportEfforts;
if (efforts !== undefined && efforts.length > 0) {
return effective.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!;
Expand Down Expand Up @@ -126,35 +126,39 @@ export async function listModelsFromHarness(
if (models === undefined) return [];
const out: AcpModelEntry[] = [];
for (const [id, alias] of Object.entries(models)) {
const anthropicCompatible = usesAnthropicProvider(alias, config);
const effective = effectiveModelAlias(alias, anthropicCompatible);
const providerType = providerTypeOf(alias, config);
const effective = effectiveModelAlias(alias, providerType);
out.push({
id,
name: effective.displayName ?? effective.model ?? id,
thinkingSupported: deriveThinkingSupported(alias, anthropicCompatible),
alwaysThinking: deriveAlwaysThinking(alias, anthropicCompatible),
defaultThinkingEffort: deriveDefaultThinkingEffort(alias, anthropicCompatible),
thinkingSupported: deriveThinkingSupported(alias, providerType),
alwaysThinking: deriveAlwaysThinking(alias, providerType),
defaultThinkingEffort: deriveDefaultThinkingEffort(alias, providerType),
});
}
return out;
}

/**
* Provider-level Anthropic context for an alias, mirroring how
* `ProviderManager.resolveProviderConfig` resolves the wire protocol: the
* alias's provider (falling back to the configured default provider) decides
* when the alias itself does not declare `protocol`. Without this the catalog
* would mark a custom-named model on an `type = "anthropic"` provider as not
* thinking-capable while the runtime infers the latest Anthropic profile.
* The alias's provider type, resolved like
* `ProviderManager.resolveProviderConfig` does: the alias's provider (falling
* back to the configured default provider). The Anthropic fallback profile in
* `effectiveModelAlias` only applies to non-Kimi providers, so a custom-named
* model on a `type = "anthropic"` provider still gets an inferred effort list
* while managed Kimi models keep only their catalog-declared efforts.
*/
function usesAnthropicProvider(
function providerTypeOf(
alias: ModelAlias,
config: {
providers?: Record<string, { type?: string } | undefined>;
providers?: Record<string, { type?: ProviderType } | undefined>;
defaultProvider?: string | undefined;
},
): boolean {
): ProviderType | undefined {
const providerName = alias.provider ?? config.defaultProvider;
if (providerName === undefined) return false;
return config.providers?.[providerName]?.type === 'anthropic';
const providerType =
providerName === undefined ? undefined : config.providers?.[providerName]?.type;
// Flat models (inline base_url, no named provider) have no provider entry to
// look up; their own protocol declaration plays the provider-identity role,
// mirroring the v2 ModelResolverService.
return providerType ?? alias.protocol;
}
30 changes: 28 additions & 2 deletions packages/acp-adapter/test/config-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,37 @@ function makeHarnessWithModels(
displayName?: string;
capabilities?: readonly string[];
protocol?: 'anthropic';
providerType?: 'anthropic' | 'kimi' | 'openai';
}>,
): { harness: KimiHarness; getConfig: ReturnType<typeof vi.fn> } {
// Mirror the `listAvailableModels` derivation: `id` is the config map
// key, `model` defaults to id, `displayName` to model. The test fixtures
// below pick names that exercise the three thinkingSupported triggers
// (name regex, capabilities array, toggleable allow-list).
// (name regex, capabilities array, toggleable allow-list). Entries with a
// `providerType` also get a backing provider so provider-aware derivation
// (e.g. the Anthropic fallback profile) can resolve the provider's type.
const models: Record<string, {
provider?: string;
model: string;
displayName?: string;
capabilities?: readonly string[];
protocol?: 'anthropic';
}> = {};
const providers: Record<string, { type: string }> = {};
for (const entry of entries) {
const providerName = `provider-${entry.id}`;
models[entry.id] = {
...(entry.providerType !== undefined ? { provider: providerName } : {}),
model: entry.model ?? entry.id,
...(entry.displayName !== undefined ? { displayName: entry.displayName } : {}),
...(entry.capabilities !== undefined ? { capabilities: entry.capabilities } : {}),
protocol: entry.protocol,
};
if (entry.providerType !== undefined) {
providers[providerName] = { type: entry.providerType };
}
}
const getConfig = vi.fn(async () => ({ models }));
const getConfig = vi.fn(async () => ({ models, providers }));
return { harness: { getConfig } as unknown as KimiHarness, getConfig };
}

Expand Down Expand Up @@ -175,6 +185,7 @@ describe('buildSessionConfigOptions', () => {
id: 'custom',
model: 'custom-anthropic-model',
protocol: 'anthropic',
providerType: 'anthropic',
},
]);

Expand All @@ -183,6 +194,21 @@ describe('buildSessionConfigOptions', () => {
expect(result.map((option) => option.id)).toEqual(['model', 'thinking', 'mode']);
});

it('hides the thinking control for an unknown model on a Kimi provider using the Anthropic protocol', async () => {
const { harness } = makeHarnessWithModels([
{
id: 'custom',
model: 'custom-anthropic-model',
protocol: 'anthropic',
providerType: 'kimi',
},
]);

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

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

it('omits the thinking toggle when current model is non-thinking-supported', async () => {
const { harness } = makeHarnessWithModels([
{ id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' },
Expand Down
55 changes: 55 additions & 0 deletions packages/acp-adapter/test/model-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ describe('listModelsFromHarness', () => {
it('advertises thinking with a high default for an unknown model using the Anthropic protocol', async () => {
const harness = {
getConfig: async () => ({
providers: {
custom: { type: 'anthropic' },
},
models: {
custom: {
provider: 'custom',
Expand All @@ -80,6 +83,58 @@ describe('listModelsFromHarness', () => {
]);
});

it('advertises thinking for a flat providerless model using the Anthropic protocol', async () => {
const harness = {
getConfig: async () => ({
models: {
custom: {
model: 'custom-anthropic-model',
maxContextSize: 200000,
protocol: 'anthropic',
},
},
}),
} as unknown as KimiHarness;

await expect(listModelsFromHarness(harness)).resolves.toEqual([
{
id: 'custom',
name: 'custom-anthropic-model',
thinkingSupported: true,
alwaysThinking: false,
defaultThinkingEffort: 'high',
},
]);
});

it('does not advertise thinking for an unknown model on a Kimi provider using the Anthropic protocol', async () => {
const harness = {
getConfig: async () => ({
providers: {
'managed:kimi-code': { type: 'kimi' },
},
models: {
custom: {
provider: 'managed:kimi-code',
model: 'custom-anthropic-model',
maxContextSize: 200000,
protocol: 'anthropic',
},
},
}),
} as unknown as KimiHarness;

await expect(listModelsFromHarness(harness)).resolves.toEqual([
{
id: 'custom',
name: 'custom-anthropic-model',
thinkingSupported: false,
alwaysThinking: false,
defaultThinkingEffort: 'on',
},
]);
});

it('derives thinking support from the provider type when the alias omits protocol', async () => {
// Same shape the runtime sees for `[providers.compat] type = "anthropic"`
// + a custom-named model with no alias-level protocol: the provider
Expand Down
18 changes: 10 additions & 8 deletions packages/agent-core-v2/src/app/model/modelAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
* Resolves Model / Provider / Platform credential precedence for runtime
* model resolution and auth-readiness probes. Pure computation; callers
* supply the Platform lookup so this file stays outside the service graph.
* The inferred Anthropic effort profile is reserved for non-Kimi
* Anthropic-compatible providers; Kimi providers — including managed models
* routed through protocol = "anthropic" — keep only catalog-declared effort
* metadata.
*/

import { ErrorCodes, Error2 } from '#/errors';
Expand All @@ -13,7 +17,7 @@ import {
matchKnownAnthropicModelProfile,
} from '#/app/llmProtocol/providers/anthropic-profile';
import { type PlatformConfig, UNKNOWN_PLATFORM_KEY } from '#/app/platform/platform';
import type { OAuthRef, ProviderConfig } from '#/app/provider/provider';
import type { OAuthRef, ProviderConfig, ProviderType } from '#/app/provider/provider';
import type { Protocol } from '#/app/protocol/protocol';

import type { ModelConfig } from './model';
Expand Down Expand Up @@ -77,7 +81,7 @@ export function resolveModelAuthMaterial(args: {

export function effectiveModelConfig(
model: ModelConfig,
anthropicCompatible = false,
providerType?: ProviderType,
): ModelConfig {
const { overrides, ...base } = model;
const effective: ModelConfig = overrides === undefined ? model : { ...base, ...overrides };
Expand All @@ -89,18 +93,16 @@ export function effectiveModelConfig(
) {
delete effective.defaultEffort;
}
return withAnthropicProfile(
effective,
anthropicCompatible || effective.protocol === 'anthropic',
);
return withAnthropicProfile(effective, providerType);
}

function withAnthropicProfile(model: ModelConfig, anthropicCompatible: boolean): ModelConfig {
function withAnthropicProfile(model: ModelConfig, providerType?: ProviderType): ModelConfig {
const wireName = model.name ?? model.model;
const protocol = model.protocol ?? providerType;
const profile =
wireName === undefined
? undefined
: anthropicCompatible
: providerType !== undefined && providerType !== 'kimi' && protocol === 'anthropic'
? inferAnthropicModelProfile(wireName)
: matchKnownAnthropicModelProfile(wireName);
if (profile === undefined) return model;
Expand Down
Loading
Loading