🐛 fix: handle Kimi code thinking mode (#15725)

This commit is contained in:
YuTengjing
2026-06-13 11:21:25 +08:00
committed by GitHub
parent c7e0c83174
commit e5a27dc97c
4 changed files with 205 additions and 8 deletions
@@ -11,6 +11,14 @@ import {
params,
} from './index';
const { loadModelsMock } = vi.hoisted(() => ({
loadModelsMock: vi.fn(),
}));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
const defaultOpenAIBaseURL = 'https://api.moonshot.cn/v1';
const anthropicBaseURL = 'https://api.moonshot.cn/anthropic';
@@ -18,6 +26,10 @@ const anthropicBaseURL = 'https://api.moonshot.cn/anthropic';
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
beforeEach(() => {
loadModelsMock.mockResolvedValue([]);
});
describe('LobeMoonshotAI', () => {
const createRuntime = ({
baseURL,
@@ -390,6 +402,18 @@ describe('LobeMoonshotOpenAI', () => {
expect(payload.temperature).toBe(1);
});
it('should always enable thinking for kimi-k2.7-code', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'kimi-k2.7-code',
thinking: { budget_tokens: 0, type: 'disabled' },
});
const payload = getLastRequestPayload();
expect(payload.thinking).toEqual({ type: 'enabled' });
expect(payload.temperature).toBe(1);
});
it('should force reasoning_content on assistant messages', async () => {
await instance.chat({
messages: [
@@ -662,6 +686,21 @@ describe('LobeMoonshotAnthropicAI', () => {
expect(payload.temperature).toBe(1);
});
it('should always enable thinking for kimi-k2.7-code', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'kimi-k2.7-code',
thinking: { budget_tokens: 0, type: 'disabled' },
});
const payload = getLastRequestPayload();
expect(payload.thinking).toEqual({
budget_tokens: 1024,
type: 'enabled',
});
expect(payload.temperature).toBe(1);
});
it('should force thinking block on assistant messages', async () => {
await instance.chat({
messages: [
@@ -13,6 +13,7 @@ import type { CreateRouterRuntimeOptions } from '../../core/RouterRuntime';
import { createRouterRuntime } from '../../core/RouterRuntime';
import type { ChatStreamPayload } from '../../types';
import { getModelPropertyWithFallback } from '../../utils/getFallbackModelProperty';
import { isKimiNativeThinkingModel, isKimiThinkingToggleModel } from '../../utils/kimiModelId';
import { MODEL_LIST_CONFIGS, processModelList } from '../../utils/modelParse';
export interface MoonshotModelCard {
@@ -30,14 +31,6 @@ type MoonshotSDKType = 'anthropic' | 'openai';
// Shared constants and helpers
const MOONSHOT_SEARCH_TOOL = { function: { name: '$web_search' }, type: 'builtin_function' } as any;
/**
* Matches kimi-k2.N models (K2.5, K2.6, ...) that expose a thinking toggle via
* `payload.thinking.type`. Assumes every future kimi-k2.N release keeps the same
* toggle contract and param constraints; if Moonshot diverges, introduce an
* explicit allowlist instead of widening this prefix.
*/
const isKimiThinkingToggleModel = (model: string) => model.startsWith('kimi-k2.');
const isKimiNativeThinkingModel = (model: string) => model.startsWith('kimi-k2-thinking');
const isEmptyContent = (content: any) =>
content === '' || content === null || content === undefined;
const hasValidReasoning = (reasoning: any) => reasoning?.content && !reasoning?.signature;
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
isKimiNativeThinkingModel,
isKimiThinkingToggleModel,
parseKimiModelId,
} from './kimiModelId';
describe('parseKimiModelId', () => {
it('should parse Kimi K2 minor-version ids', () => {
expect(parseKimiModelId('kimi-k2.6')).toEqual({
family: 'k',
majorVersion: 2,
minorVersion: 6,
normalizedModelId: 'kimi-k2.6',
source: 'moonshot',
});
});
it('should parse Kimi K2 variant ids', () => {
expect(parseKimiModelId('kimi-k2.7-code')).toEqual({
family: 'k',
majorVersion: 2,
minorVersion: 7,
normalizedModelId: 'kimi-k2.7-code',
source: 'moonshot',
variant: 'code',
});
expect(parseKimiModelId('kimi-k2-thinking-turbo')).toEqual({
family: 'k',
majorVersion: 2,
normalizedModelId: 'kimi-k2-thinking-turbo',
source: 'moonshot',
variant: 'thinking-turbo',
});
});
it('should parse OpenRouter Kimi ids', () => {
expect(parseKimiModelId('moonshotai/kimi-k2.7-code')).toEqual({
family: 'k',
majorVersion: 2,
minorVersion: 7,
normalizedModelId: 'kimi-k2.7-code',
source: 'openRouter',
variant: 'code',
});
});
it('should return undefined for non-Kimi ids', () => {
expect(parseKimiModelId('claude-sonnet-4-5')).toBeUndefined();
});
});
describe('isKimiThinkingToggleModel', () => {
it('should return true for Kimi K2 models with switchable thinking', () => {
expect(isKimiThinkingToggleModel('kimi-k2.5')).toBe(true);
expect(isKimiThinkingToggleModel('kimi-k2.6')).toBe(true);
});
it('should return false for always-thinking and legacy Kimi K2 ids', () => {
expect(isKimiThinkingToggleModel('kimi-k2.7-code')).toBe(false);
expect(isKimiThinkingToggleModel('kimi-k2-thinking')).toBe(false);
expect(isKimiThinkingToggleModel('kimi-k2-turbo-preview')).toBe(false);
});
});
describe('isKimiNativeThinkingModel', () => {
it('should return true for native thinking Kimi models', () => {
expect(isKimiNativeThinkingModel('kimi-k2-thinking')).toBe(true);
expect(isKimiNativeThinkingModel('kimi-k2-thinking-turbo')).toBe(true);
expect(isKimiNativeThinkingModel('kimi-k2.7-code')).toBe(true);
expect(isKimiNativeThinkingModel('kimi-k2.8-code')).toBe(true);
expect(isKimiNativeThinkingModel('kimi-k2.8-code-preview')).toBe(true);
});
it('should return false for switchable Kimi K2 models', () => {
expect(isKimiNativeThinkingModel('kimi-k2.5')).toBe(false);
expect(isKimiNativeThinkingModel('kimi-k2.6')).toBe(false);
});
});
@@ -0,0 +1,84 @@
export type KimiModelIdSource = 'moonshot' | 'openRouter';
export interface ParsedKimiModelId {
family: 'k';
majorVersion: number;
minorVersion?: number;
normalizedModelId: string;
source: KimiModelIdSource;
variant?: string;
}
interface ExtractedKimiModelId {
normalizedModelId: string;
source: KimiModelIdSource;
}
const KIMI_MODEL_PATTERN =
/^kimi-k(\d+)(?:\.(\d+))?(?:-([a-z][a-z0-9]*(?:-[a-z0-9]+)*))?(?:\b|[-.:])/;
const extractKimiModelId = (model: string): ExtractedKimiModelId | undefined => {
const normalized = model.trim().toLowerCase();
if (!normalized) return;
if (normalized.startsWith('moonshotai/')) {
return { normalizedModelId: normalized.slice('moonshotai/'.length), source: 'openRouter' };
}
if (normalized.startsWith('kimi-')) {
return { normalizedModelId: normalized, source: 'moonshot' };
}
};
const parseMinorVersion = (value: string | undefined): Pick<ParsedKimiModelId, 'minorVersion'> => {
if (!value || !/^\d{1,2}$/.test(value)) return {};
return {
minorVersion: Number(value),
};
};
export const parseKimiModelId = (model: string): ParsedKimiModelId | undefined => {
const extracted = extractKimiModelId(model);
if (!extracted) return;
const match = KIMI_MODEL_PATTERN.exec(extracted.normalizedModelId);
if (!match) return;
const [, majorVersion, minorVersion, variant] = match;
return {
family: 'k',
majorVersion: Number(majorVersion),
normalizedModelId: extracted.normalizedModelId,
source: extracted.source,
...(variant ? { variant } : {}),
...parseMinorVersion(minorVersion),
};
};
const hasVariant = (parsed: ParsedKimiModelId, variant: string): boolean =>
parsed.variant === variant || !!parsed.variant?.startsWith(`${variant}-`);
export const isKimiNativeThinkingModel = (model: string): boolean => {
const parsed = parseKimiModelId(model);
if (!parsed) return false;
if (parsed.majorVersion !== 2) return false;
if (hasVariant(parsed, 'thinking')) return true;
return (
hasVariant(parsed, 'code') && parsed.minorVersion !== undefined && parsed.minorVersion >= 7
);
};
export const isKimiThinkingToggleModel = (model: string): boolean => {
const parsed = parseKimiModelId(model);
if (!parsed) return false;
return (
parsed.majorVersion === 2 &&
parsed.minorVersion !== undefined &&
!isKimiNativeThinkingModel(model)
);
};