Skip to content

Commit 3830e21

Browse files
committed
fix(moonshot): preserve Kimi K3 behavior on the OpenAI SDK handler path
- Honor requiredReasoningEffort in getOpenAiReasoning so Kimi K3 always sends reasoning_effort=max, matching the Roo provider precedent - Send resolved reasoning params in completePrompt and the non-streaming createMessage branch - Skip explicit cache_control breakpoints for Moonshot (automatic caching) via a useExplicitCacheBreakpoints hook - Rewrite the K3 moonshot specs for the OpenAI SDK handler path and add coverage for required-effort resolution and the non-streaming branch
1 parent 8d86d53 commit 3830e21

5 files changed

Lines changed: 161 additions & 39 deletions

File tree

src/api/providers/__tests__/moonshot.spec.ts

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Anthropic } from "@anthropic-ai/sdk"
2-
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
32

43
import { moonshotDefaultModelId } from "@roo-code/types"
54

@@ -168,14 +167,20 @@ describe("MoonshotHandler", () => {
168167
})
169168

170169
it("omits temperature and sends required max reasoning for Kimi K3", async () => {
171-
async function* mockFullStream() {
172-
yield { type: "text-delta", text: "K3 response" }
170+
async function* mockStream() {
171+
yield {
172+
choices: [{ delta: { content: "K3 response" }, finish_reason: null }],
173+
usage: null,
174+
}
173175
}
174176

175-
mockStreamText.mockReturnValue({
176-
fullStream: mockFullStream(),
177-
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1, details: {}, raw: {} }),
178-
})
177+
const mockClient = {
178+
chat: {
179+
completions: {
180+
create: vi.fn().mockResolvedValue(mockStream()),
181+
},
182+
},
183+
}
179184

180185
const k3Handler = new MoonshotHandler({
181186
...mockOptions,
@@ -184,26 +189,57 @@ describe("MoonshotHandler", () => {
184189
reasoningEffort: "disable",
185190
enableReasoningEffort: false,
186191
})
192+
;(k3Handler as any).client = mockClient
193+
187194
for await (const _chunk of k3Handler.createMessage(systemPrompt, messages)) {
188195
void _chunk
189196
}
190197

191-
expect(mockStreamText).toHaveBeenCalledWith(
192-
expect.objectContaining({
193-
temperature: undefined,
194-
maxOutputTokens: 131_072,
195-
providerOptions: { openaiCompatible: { reasoningEffort: "max" } },
196-
}),
197-
)
198+
const [requestOptions] = mockClient.chat.completions.create.mock.calls[0]
199+
expect(requestOptions).toMatchObject({
200+
model: "kimi-k3",
201+
reasoning_effort: "max",
202+
max_tokens: 131_072,
203+
})
204+
expect(requestOptions).not.toHaveProperty("temperature")
198205
})
199206

200-
it("serializes retained Kimi K3 reasoning through the installed AI SDK", async () => {
201-
const actualAi = await vi.importActual<typeof import("ai")>("ai")
202-
const actualOpenAICompatible =
203-
await vi.importActual<typeof import("@ai-sdk/openai-compatible")>("@ai-sdk/openai-compatible")
204-
vi.mocked(createOpenAICompatible).mockImplementationOnce(actualOpenAICompatible.createOpenAICompatible)
205-
mockStreamText.mockImplementationOnce(actualAi.streamText)
207+
it("sends required max reasoning for Kimi K3 when streaming is disabled", async () => {
208+
const mockClient = {
209+
chat: {
210+
completions: {
211+
create: vi.fn().mockResolvedValue({
212+
choices: [{ message: { content: "K3 response" } }],
213+
usage: { prompt_tokens: 1, completion_tokens: 1 },
214+
}),
215+
},
216+
},
217+
}
206218

219+
const k3Handler = new MoonshotHandler({
220+
...mockOptions,
221+
apiModelId: "kimi-k3",
222+
modelTemperature: 0.9,
223+
reasoningEffort: "disable",
224+
enableReasoningEffort: false,
225+
openAiStreamingEnabled: false,
226+
})
227+
;(k3Handler as any).client = mockClient
228+
229+
for await (const _chunk of k3Handler.createMessage(systemPrompt, messages)) {
230+
void _chunk
231+
}
232+
233+
const [requestOptions] = mockClient.chat.completions.create.mock.calls[0]
234+
expect(requestOptions).toMatchObject({
235+
model: "kimi-k3",
236+
reasoning_effort: "max",
237+
max_tokens: 131_072,
238+
})
239+
expect(requestOptions).not.toHaveProperty("temperature")
240+
})
241+
242+
it("serializes retained Kimi K3 reasoning through the installed OpenAI SDK", async () => {
207243
let requestBody: Record<string, any> | undefined
208244
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
209245
requestBody = JSON.parse(String(init?.body))
@@ -236,24 +272,20 @@ describe("MoonshotHandler", () => {
236272
},
237273
] as Anthropic.Messages.MessageParam[]
238274

239-
try {
240-
for await (const _chunk of k3Handler.createMessage("system", retainedMessages)) {
241-
// Drain the stream so the installed SDK serializes the HTTP request.
242-
}
243-
} catch (error) {
244-
// The synthetic SSE only needs to support request serialization.
245-
expect((error as Error).name).toBe("AI_NoOutputGeneratedError")
275+
for await (const _chunk of k3Handler.createMessage("system", retainedMessages)) {
276+
void _chunk
246277
}
247278

248279
expect(requestBody).toMatchObject({
249280
model: "kimi-k3",
250281
reasoning_effort: "max",
282+
max_tokens: 131_072,
251283
messages: [
252284
{ role: "system", content: "system" },
253285
{ role: "user", content: "Inspect the file" },
254286
{
255287
role: "assistant",
256-
content: null,
288+
content: "",
257289
reasoning_content: "I need the file contents first.",
258290
tool_calls: [
259291
{
@@ -365,24 +397,35 @@ describe("MoonshotHandler", () => {
365397
})
366398

367399
it("omits temperature and sends required max reasoning for Kimi K3", async () => {
368-
mockGenerateText.mockResolvedValue({ text: "K3 completion" })
400+
const mockClient = {
401+
chat: {
402+
completions: {
403+
create: vi.fn().mockResolvedValue({
404+
choices: [{ message: { content: "K3 completion" } }],
405+
}),
406+
},
407+
},
408+
}
409+
369410
const k3Handler = new MoonshotHandler({
370411
...mockOptions,
371412
apiModelId: "kimi-k3",
372413
modelTemperature: 0.9,
373414
reasoningEffort: "disable",
374415
enableReasoningEffort: false,
375416
})
417+
;(k3Handler as any).client = mockClient
376418

377-
await k3Handler.completePrompt("Test prompt")
419+
const result = await k3Handler.completePrompt("Test prompt")
378420

379-
expect(mockGenerateText).toHaveBeenCalledWith(
380-
expect.objectContaining({
381-
temperature: undefined,
382-
maxOutputTokens: 131_072,
383-
providerOptions: { openaiCompatible: { reasoningEffort: "max" } },
384-
}),
385-
)
421+
expect(result).toBe("K3 completion")
422+
const [requestOptions] = mockClient.chat.completions.create.mock.calls[0]
423+
expect(requestOptions).toMatchObject({
424+
model: "kimi-k3",
425+
reasoning_effort: "max",
426+
max_tokens: 131_072,
427+
})
428+
expect(requestOptions).not.toHaveProperty("temperature")
386429
})
387430
})
388431

src/api/providers/moonshot.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ export class MoonshotHandler extends OpenAiHandler {
7272
}
7373
}
7474

75+
/**
76+
* Moonshot caches prompts automatically; explicit Anthropic-style
77+
* cache_control breakpoints are not part of its OpenAI-compatible API.
78+
*/
79+
protected override useExplicitCacheBreakpoints(): boolean {
80+
return false
81+
}
82+
7583
/**
7684
* Override to always include max_tokens for Moonshot (not max_completion_tokens).
7785
* Moonshot requires max_tokens parameter to be sent.

src/api/providers/openai.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
105105
if (deepseekReasoner) {
106106
convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
107107
} else {
108-
if (modelInfo.supportsPromptCache) {
108+
if (modelInfo.supportsPromptCache && this.useExplicitCacheBreakpoints()) {
109109
systemMessage = {
110110
role: "system",
111111
content: [
@@ -121,7 +121,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
121121

122122
convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
123123

124-
if (modelInfo.supportsPromptCache) {
124+
if (modelInfo.supportsPromptCache && this.useExplicitCacheBreakpoints()) {
125125
// Note: the following logic is copied from openrouter:
126126
// Add cache_control to the last two user messages
127127
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
@@ -230,6 +230,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
230230
messages: deepseekReasoner
231231
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
232232
: [systemMessage, ...convertToOpenAiMessages(messages)],
233+
// Send reasoning params when the model resolves them (e.g. models that
234+
// require reasoning effort), matching the streaming path.
235+
...(reasoning && reasoning),
233236
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
234237
tools: this.convertToolsForOpenAI(metadata?.tools),
235238
tool_choice: metadata?.tool_choice,
@@ -273,6 +276,16 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
273276
}
274277
}
275278

279+
/**
280+
* Whether to attach explicit Anthropic-style cache_control breakpoints to
281+
* requests. Providers whose prompt caching is automatic (e.g. Moonshot)
282+
* override this to skip breakpoints their OpenAI-compatible endpoints do
283+
* not support.
284+
*/
285+
protected useExplicitCacheBreakpoints(): boolean {
286+
return true
287+
}
288+
276289
protected processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk {
277290
return {
278291
type: "usage",
@@ -305,6 +318,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
305318
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
306319
model: model.id,
307320
messages: [{ role: "user", content: prompt }],
321+
// Send reasoning params when the model resolves them (e.g. models that
322+
// require reasoning effort), matching the streaming path.
323+
...(model.reasoning && model.reasoning),
308324
}
309325

310326
// Add max_tokens if needed

src/api/transform/__tests__/reasoning.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,52 @@ describe("reasoning.ts", () => {
547547
expect(result).toEqual({ reasoning_effort: "medium" })
548548
})
549549

550+
it("should send the model's required effort even when the user disables reasoning", () => {
551+
const requiredModel: ModelInfo = {
552+
...baseModel,
553+
supportsReasoningEffort: ["max"],
554+
requiredReasoningEffort: true,
555+
reasoningEffort: "max",
556+
}
557+
558+
const options = {
559+
...baseOptions,
560+
model: requiredModel,
561+
reasoningEffort: "disable" as const,
562+
settings: { reasoningEffort: "disable", enableReasoningEffort: false } as ProviderSettings,
563+
}
564+
565+
const result = getOpenAiReasoning(options)
566+
567+
expect(result).toEqual({ reasoning_effort: "max" })
568+
})
569+
570+
it("should send the model's required effort with default settings", () => {
571+
const requiredModel: ModelInfo = {
572+
...baseModel,
573+
supportsReasoningEffort: ["max"],
574+
requiredReasoningEffort: true,
575+
reasoningEffort: "max",
576+
}
577+
578+
const options = { ...baseOptions, model: requiredModel }
579+
580+
const result = getOpenAiReasoning(options)
581+
582+
expect(result).toEqual({ reasoning_effort: "max" })
583+
})
584+
585+
it("should fall back to normal handling when requiredReasoningEffort has no model effort", () => {
586+
const requiredWithoutEffort: ModelInfo = {
587+
...baseModel,
588+
requiredReasoningEffort: true,
589+
}
590+
591+
const result = getOpenAiReasoning({ ...baseOptions, model: requiredWithoutEffort, settings: {} })
592+
593+
expect(result).toBeUndefined()
594+
})
595+
550596
it("should return undefined when model has no reasoning effort capability", () => {
551597
const result = getOpenAiReasoning(baseOptions)
552598
expect(result).toBeUndefined()

src/api/transform/reasoning.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ export const getOpenAiReasoning = ({
129129
reasoningEffort,
130130
settings,
131131
}: GetModelReasoningOptions): OpenAiReasoningParams | undefined => {
132+
// Models that require reasoning effort always send the model's effort,
133+
// regardless of user toggles (mirrors the required-effort handling in
134+
// getRooReasoning and the gateway providers).
135+
if (model.requiredReasoningEffort && model.reasoningEffort) {
136+
return {
137+
reasoning_effort: model.reasoningEffort as OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"],
138+
}
139+
}
140+
132141
if (!shouldUseReasoningEffort({ model, settings })) return undefined
133142
if (reasoningEffort === "disable" || !reasoningEffort) return undefined
134143

0 commit comments

Comments
 (0)