Skip to content
Open
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
37 changes: 28 additions & 9 deletions src/services/apis/openai-compatible-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
PeterDaveHello marked this conversation as resolved.
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,
Expand All @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand All @@ -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 })
}

Expand All @@ -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 })

Expand All @@ -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()
Comment thread
PeterDaveHello marked this conversation as resolved.
session.isRetry = false
try {
const stoppedGenerationId = getStopGenerationId()
Expand Down
2 changes: 2 additions & 0 deletions src/services/apis/temperature-params.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ function isGeminiWithoutCustomTemperature(model) {
}

export function canApplyTemperatureOverride(model) {
if (model === 'kimi-k3') return false

const normalizedModel = normalizeModelId(model)
return (
!isKnownModelWithoutCustomTemperature(normalizedModel) &&
Expand Down
130 changes: 130 additions & 0 deletions tests/unit/services/apis/kimi-k3-compat.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
})