diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index 02dfe4a81..7e6896e42 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -74,12 +74,14 @@ export async function generateAnswersWithOpenAICompatible({ ? session.conversationRecords : [] session.conversationRecords = conversationRecords + const contextRecords = conversationRecords.slice(-config.maxConversationContextLength) + const preserveKimiReasoning = endpointType === 'chat' && model === 'kimi-k3' const safeExtraBody = { ...extraBody } delete safeExtraBody.temperature if (endpointType === 'completion') { const prompt = (await getCompletionPromptBase()) + - getConversationPairs(conversationRecords.slice(-config.maxConversationContextLength), true) + + getConversationPairs(contextRecords, true) + `Human: ${question}\nAI: ` requestBody = { prompt, @@ -91,10 +93,14 @@ export async function generateAnswersWithOpenAICompatible({ ...safeExtraBody, } } else { - const messages = getConversationPairs( - conversationRecords.slice(-config.maxConversationContextLength), - false, - ) + const messages = getConversationPairs(contextRecords, false) + if (preserveKimiReasoning) { + contextRecords.forEach((record, index) => { + if (typeof record.reasoningContent === 'string') { + messages[index * 2 + 1].reasoning_content = record.reasoningContent + } + }) + } messages.push({ role: 'user', content: question }) const tokenParams = getChatCompletionsTokenParams( provider, @@ -115,11 +121,20 @@ export async function generateAnswersWithOpenAICompatible({ } let answer = '' + let reasoningContent let finished = false + const pushAnswerRecord = () => { + pushRecord(session, question, answer) + if (!preserveKimiReasoning) return + const record = session.conversationRecords.at(-1) + if (!record) return + if (typeof reasoningContent === 'string') record.reasoningContent = reasoningContent + else delete record.reasoningContent + } const finish = () => { if (finished) return finished = true - pushRecord(session, question, answer) + pushAnswerRecord() port.postMessage({ answer: null, done: true, session: session }) } @@ -142,6 +157,12 @@ export async function generateAnswersWithOpenAICompatible({ return } + if (preserveKimiReasoning) { + const delta = data?.choices?.[0]?.delta?.reasoning_content + const content = data?.choices?.[0]?.message?.reasoning_content + if (typeof delta === 'string') reasoningContent = (reasoningContent || '') + delta + else if (typeof content === 'string') reasoningContent = content + } answer = buildMessageAnswer(answer, data, allowLegacyResponseField) port.postMessage({ answer: answer, done: false, session: null }) @@ -156,9 +177,7 @@ export async function generateAnswersWithOpenAICompatible({ if (aborted) { const shouldPostSession = Boolean(answer) || session.isRetry if (shouldPostSession && isCurrentSessionRequest()) { - if (answer) { - pushRecord(session, question, answer) - } + if (answer) pushAnswerRecord() session.isRetry = false try { const stoppedGenerationId = getStopGenerationId() diff --git a/src/services/apis/temperature-params.mjs b/src/services/apis/temperature-params.mjs index f586ab0fa..4b2245544 100644 --- a/src/services/apis/temperature-params.mjs +++ b/src/services/apis/temperature-params.mjs @@ -42,6 +42,8 @@ function isGeminiWithoutCustomTemperature(model) { } export function canApplyTemperatureOverride(model) { + if (model === 'kimi-k3') return false + const normalizedModel = normalizeModelId(model) return ( !isKnownModelWithoutCustomTemperature(normalizedModel) && diff --git a/tests/unit/services/apis/kimi-k3-compat.test.mjs b/tests/unit/services/apis/kimi-k3-compat.test.mjs new file mode 100644 index 000000000..3be5653d6 --- /dev/null +++ b/tests/unit/services/apis/kimi-k3-compat.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { generateAnswersWithOpenAICompatible } from '../../../../src/services/apis/openai-compatible-core.mjs' +import { canApplyTemperatureOverride } from '../../../../src/services/apis/temperature-params.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +function createConfig() { + return { + maxConversationContextLength: 3, + maxResponseTokenLength: 16384, + temperatureOverrideEnabled: true, + temperature: 0.2, + } +} + +function sse(delta, finishReason = null) { + return `data: ${JSON.stringify({ choices: [{ delta, finish_reason: finishReason }] })}\n\n` +} + +test('direct K3 preserves reasoning history and omits temperature overrides', async (t) => { + t.mock.method(console, 'debug', () => {}) + const bodies = [] + let requestCount = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { + bodies.push(JSON.parse(init.body)) + requestCount += 1 + if (requestCount === 1) { + return createMockSseResponse([ + sse({ reasoning_content: 'first thought' }), + sse({ content: 'First answer' }, 'stop'), + ]) + } + return createMockSseResponse([sse({ content: 'Second answer' }, 'stop')]) + }) + + const session = { conversationRecords: [], isRetry: false } + await generateAnswersWithOpenAICompatible({ + port: createFakePort(), + question: 'First question', + session, + endpointType: 'chat', + requestUrl: 'https://api.moonshot.cn/v1/chat/completions', + model: 'kimi-k3', + apiKey: 'test-key', + config: createConfig(), + extraBody: { temperature: 0.8 }, + }) + + assert.equal(Object.hasOwn(bodies[0], 'temperature'), false) + assert.deepEqual(session.conversationRecords[0], { + question: 'First question', + answer: 'First answer', + reasoningContent: 'first thought', + }) + + await generateAnswersWithOpenAICompatible({ + port: createFakePort(), + question: 'Follow-up', + session, + endpointType: 'chat', + requestUrl: 'https://api.moonshot.cn/v1/chat/completions', + model: 'kimi-k3', + apiKey: 'test-key', + config: createConfig(), + }) + + assert.deepEqual(bodies[1].messages.slice(0, 2), [ + { role: 'user', content: 'First question' }, + { + role: 'assistant', + content: 'First answer', + reasoning_content: 'first thought', + }, + ]) +}) + +test('direct K3 preserves partial reasoning when generation is aborted', async (t) => { + t.mock.method(console, 'debug', () => {}) + const port = createFakePort() + port._sessionRequestGeneration = 1 + const session = { conversationRecords: [], isRetry: false } + + t.mock.method(globalThis, 'fetch', async () => { + const response = createMockSseResponse([ + sse({ reasoning_content: 'partial thought' }), + sse({ content: 'Partial answer' }), + ]) + const reader = response.body.getReader() + response.body.getReader = () => ({ + async read() { + const result = await reader.read() + if (!result.done) return result + port.emitMessage({ stop: true, stopGenerationId: 1 }) + throw new DOMException('Aborted', 'AbortError') + }, + }) + return response + }) + + await generateAnswersWithOpenAICompatible({ + port, + question: 'Question', + session, + endpointType: 'chat', + requestUrl: 'https://api.moonshot.cn/v1/chat/completions', + model: 'kimi-k3', + apiKey: 'test-key', + config: createConfig(), + }) + + assert.deepEqual(session.conversationRecords, [ + { + question: 'Question', + answer: 'Partial answer', + reasoningContent: 'partial thought', + }, + ]) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('K3 temperature restriction is limited to the direct model ID', () => { + assert.equal(canApplyTemperatureOverride('kimi-k3'), false) + assert.equal(canApplyTemperatureOverride('Kimi-K3'), true) + assert.equal(canApplyTemperatureOverride(' kimi-k3 '), true) + assert.equal(canApplyTemperatureOverride('moonshotai/kimi-k3'), true) + assert.equal(canApplyTemperatureOverride('moonshot/kimi-k3'), true) + assert.equal(canApplyTemperatureOverride('openai/kimi-k3'), true) + assert.equal(canApplyTemperatureOverride('kimi-k3-preview'), true) +})