diff --git a/packages/model-runtime/src/providers/moonshot/index.test.ts b/packages/model-runtime/src/providers/moonshot/index.test.ts index 5e64b279f3..24afc7ab8e 100644 --- a/packages/model-runtime/src/providers/moonshot/index.test.ts +++ b/packages/model-runtime/src/providers/moonshot/index.test.ts @@ -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: [ diff --git a/packages/model-runtime/src/providers/moonshot/index.ts b/packages/model-runtime/src/providers/moonshot/index.ts index 8439227b8c..cda2dfd310 100644 --- a/packages/model-runtime/src/providers/moonshot/index.ts +++ b/packages/model-runtime/src/providers/moonshot/index.ts @@ -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; diff --git a/packages/model-runtime/src/utils/kimiModelId.test.ts b/packages/model-runtime/src/utils/kimiModelId.test.ts new file mode 100644 index 0000000000..430a799ec6 --- /dev/null +++ b/packages/model-runtime/src/utils/kimiModelId.test.ts @@ -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); + }); +}); diff --git a/packages/model-runtime/src/utils/kimiModelId.ts b/packages/model-runtime/src/utils/kimiModelId.ts new file mode 100644 index 0000000000..2287c69160 --- /dev/null +++ b/packages/model-runtime/src/utils/kimiModelId.ts @@ -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 => { + 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) + ); +};