Skip to content

Commit 489af22

Browse files
committed
fix: disable thinking mode in QwenCodeHandler.completePrompt for prompt enhancement
qwen3-coder models are thinking models that return reasoning in a separate reasoning_content field, which can leave message.content empty. The previous fix only stripped <think> tags but did not address the root cause. This change: - Passes enable_thinking: false to the API to disable thinking mode for simple completions (prompt enhancement does not need reasoning) - Falls back to reasoning_content if content is still empty - Strips inline <think> blocks as an additional safety net Closes #12102
1 parent 7adbfec commit 489af22

2 files changed

Lines changed: 237 additions & 3 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// npx vitest run api/providers/__tests__/qwen-code-complete-prompt.spec.ts
2+
3+
// Mock filesystem - must come before other imports
4+
vi.mock("node:fs", () => ({
5+
promises: {
6+
readFile: vi.fn(),
7+
writeFile: vi.fn(),
8+
},
9+
}))
10+
11+
const mockCreate = vi.fn()
12+
vi.mock("openai", () => {
13+
return {
14+
__esModule: true,
15+
default: vi.fn().mockImplementation(() => ({
16+
apiKey: "test-key",
17+
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
18+
chat: {
19+
completions: {
20+
create: mockCreate,
21+
},
22+
},
23+
})),
24+
}
25+
})
26+
27+
import { promises as fs } from "node:fs"
28+
import { QwenCodeHandler } from "../qwen-code"
29+
import type { ApiHandlerOptions } from "../../../shared/api"
30+
31+
describe("QwenCodeHandler completePrompt", () => {
32+
let handler: QwenCodeHandler
33+
let mockOptions: ApiHandlerOptions & { qwenCodeOauthPath?: string }
34+
35+
const validCredentials = {
36+
access_token: "test-access-token",
37+
refresh_token: "test-refresh-token",
38+
token_type: "Bearer",
39+
expiry_date: Date.now() + 3600000,
40+
resource_url: "https://dashscope.aliyuncs.com/compatible-mode/v1",
41+
}
42+
43+
beforeEach(() => {
44+
vi.clearAllMocks()
45+
46+
mockOptions = {
47+
apiModelId: "qwen3-coder-plus",
48+
qwenCodeOauthPath: "/tmp/test-creds.json",
49+
}
50+
51+
handler = new QwenCodeHandler(mockOptions)
52+
;(fs.readFile as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify(validCredentials))
53+
})
54+
55+
it("should return plain text content as-is", async () => {
56+
mockCreate.mockResolvedValueOnce({
57+
choices: [
58+
{
59+
message: {
60+
content: "Here is your enhanced prompt with more details.",
61+
},
62+
},
63+
],
64+
})
65+
66+
const result = await handler.completePrompt("Enhance this prompt")
67+
expect(result).toBe("Here is your enhanced prompt with more details.")
68+
})
69+
70+
it("should pass enable_thinking: false in the request", async () => {
71+
mockCreate.mockResolvedValueOnce({
72+
choices: [
73+
{
74+
message: {
75+
content: "Enhanced text",
76+
},
77+
},
78+
],
79+
})
80+
81+
await handler.completePrompt("Enhance this prompt")
82+
83+
expect(mockCreate).toHaveBeenCalledWith(
84+
expect.objectContaining({
85+
enable_thinking: false,
86+
}),
87+
)
88+
})
89+
90+
it("should strip <think> blocks from response content", async () => {
91+
mockCreate.mockResolvedValueOnce({
92+
choices: [
93+
{
94+
message: {
95+
content:
96+
"<think>Let me analyze this prompt and think about how to enhance it...</think>Here is your enhanced prompt with more details.",
97+
},
98+
},
99+
],
100+
})
101+
102+
const result = await handler.completePrompt("Enhance this prompt")
103+
expect(result).toBe("Here is your enhanced prompt with more details.")
104+
})
105+
106+
it("should strip multiple <think> blocks from response content", async () => {
107+
mockCreate.mockResolvedValueOnce({
108+
choices: [
109+
{
110+
message: {
111+
content: "<think>First thought...</think>Part one. <think>Second thought...</think>Part two.",
112+
},
113+
},
114+
],
115+
})
116+
117+
const result = await handler.completePrompt("Enhance this prompt")
118+
expect(result).toBe("Part one. Part two.")
119+
})
120+
121+
it("should fall back to reasoning_content when content is empty", async () => {
122+
mockCreate.mockResolvedValueOnce({
123+
choices: [
124+
{
125+
message: {
126+
content: "",
127+
reasoning_content: "The actual enhanced prompt text from reasoning.",
128+
},
129+
},
130+
],
131+
})
132+
133+
const result = await handler.completePrompt("Enhance this prompt")
134+
expect(result).toBe("The actual enhanced prompt text from reasoning.")
135+
})
136+
137+
it("should fall back to reasoning_content when content is null", async () => {
138+
mockCreate.mockResolvedValueOnce({
139+
choices: [
140+
{
141+
message: {
142+
content: null,
143+
reasoning_content: "Enhanced prompt from reasoning_content field.",
144+
},
145+
},
146+
],
147+
})
148+
149+
const result = await handler.completePrompt("Enhance this prompt")
150+
expect(result).toBe("Enhanced prompt from reasoning_content field.")
151+
})
152+
153+
it("should return empty string when both content and reasoning_content are empty", async () => {
154+
mockCreate.mockResolvedValueOnce({
155+
choices: [
156+
{
157+
message: {
158+
content: "",
159+
reasoning_content: "",
160+
},
161+
},
162+
],
163+
})
164+
165+
const result = await handler.completePrompt("Enhance this prompt")
166+
expect(result).toBe("")
167+
})
168+
169+
it("should handle response with only <think> blocks (content becomes empty after stripping)", async () => {
170+
mockCreate.mockResolvedValueOnce({
171+
choices: [
172+
{
173+
message: {
174+
content: "<think>Only thinking, no actual content</think>",
175+
},
176+
},
177+
],
178+
})
179+
180+
const result = await handler.completePrompt("Enhance this prompt")
181+
expect(result).toBe("")
182+
})
183+
184+
it("should handle multiline <think> blocks", async () => {
185+
mockCreate.mockResolvedValueOnce({
186+
choices: [
187+
{
188+
message: {
189+
content:
190+
"<think>\nStep 1: Analyze the prompt\nStep 2: Enhance it\nStep 3: Return result\n</think>\nHere is the enhanced prompt.",
191+
},
192+
},
193+
],
194+
})
195+
196+
const result = await handler.completePrompt("Enhance this prompt")
197+
expect(result).toBe("Here is the enhanced prompt.")
198+
})
199+
200+
it("should prefer content over reasoning_content when content is non-empty", async () => {
201+
mockCreate.mockResolvedValueOnce({
202+
choices: [
203+
{
204+
message: {
205+
content: "The actual content response.",
206+
reasoning_content: "Some reasoning that should be ignored.",
207+
},
208+
},
209+
],
210+
})
211+
212+
const result = await handler.completePrompt("Enhance this prompt")
213+
expect(result).toBe("The actual content response.")
214+
})
215+
})

src/api/providers/qwen-code.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,14 +332,33 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan
332332
const client = this.ensureClient()
333333
const model = this.getModel()
334334

335-
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
335+
// Disable thinking mode for simple completions (e.g. prompt enhancement).
336+
// qwen3-coder models are thinking models that put reasoning in a separate
337+
// `reasoning_content` field, which can leave `content` empty when thinking
338+
// is enabled. Explicitly disabling it ensures content is populated.
339+
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming & {
340+
enable_thinking?: boolean
341+
} = {
336342
model: model.id,
337343
messages: [{ role: "user", content: prompt }],
338344
max_completion_tokens: model.info.maxTokens,
345+
enable_thinking: false,
339346
}
340347

341-
const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
348+
const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions as any))
342349

343-
return response.choices[0]?.message.content || ""
350+
const message = response.choices[0]?.message
351+
let content = message?.content || ""
352+
353+
// Fallback: if content is empty, check for reasoning_content (in case
354+
// the API still returned thinking content despite enable_thinking: false).
355+
if (!content && message && "reasoning_content" in message) {
356+
content = (message as any).reasoning_content || ""
357+
}
358+
359+
// Strip any inline <think>...</think> blocks that may be present.
360+
content = content.replace(/<think>[\s\S]*?<\/think>/g, "").trim()
361+
362+
return content
344363
}
345364
}

0 commit comments

Comments
 (0)