Skip to content

Commit 6daa153

Browse files
daewoongohOh Daewoong
andauthored
refactor(providers): use extractReasoningFromDelta helper for reasoni… (#588)
* refactor(providers): use extractReasoningFromDelta helper for reasoning streams Replace duplicated reasoning_content extraction logic across 7 providers (deepseek, mimo, openai, opencode-go, qwen-code, requesty, unbound) with the shared extractReasoningFromDelta helper. This also adds the OpenRouter- style 'reasoning' field fallback to each provider for free. * test(providers): cover reasoning_content + reasoning fallback streaming Add streaming tests for openai, requesty, and unbound covering: - delta.reasoning_content yields a reasoning chunk - delta.reasoning fallback yields a reasoning chunk when reasoning_content is absent (OpenRouter-style) This raises line coverage on the changed reasoning paths from 50%. * test(providers): add reasoning_content streaming tests to deepseek, mimo, opencode-go, qwen-code --------- Co-authored-by: Oh Daewoong <dw.oh@samsung.com>
1 parent d3ba52d commit 6daa153

14 files changed

Lines changed: 545 additions & 39 deletions

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,75 @@ describe("DeepSeekHandler", () => {
386386
expect(usageChunks[0].cacheWriteTokens).toBe(8)
387387
expect(usageChunks[0].cacheReadTokens).toBe(2)
388388
})
389+
390+
it("streams reasoning chunks from delta.reasoning_content", async () => {
391+
mockCreate.mockImplementationOnce(async () => ({
392+
[Symbol.asyncIterator]: async function* () {
393+
yield { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }
394+
yield { choices: [{ delta: { content: "answer" }, index: 0 }] }
395+
yield {
396+
choices: [{ delta: {}, index: 0 }],
397+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
398+
}
399+
},
400+
}))
401+
402+
const chunks: any[] = []
403+
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
404+
chunks.push(chunk)
405+
}
406+
407+
expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." })
408+
})
409+
410+
it("falls back to delta.reasoning when reasoning_content is absent", async () => {
411+
mockCreate.mockImplementationOnce(async () => ({
412+
[Symbol.asyncIterator]: async function* () {
413+
yield { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }
414+
yield {
415+
choices: [{ delta: {}, index: 0 }],
416+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
417+
}
418+
},
419+
}))
420+
421+
const chunks: any[] = []
422+
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
423+
chunks.push(chunk)
424+
}
425+
426+
expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" })
427+
})
428+
429+
it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => {
430+
mockCreate.mockImplementationOnce(async () => ({
431+
[Symbol.asyncIterator]: async function* () {
432+
yield {
433+
choices: [
434+
{
435+
delta: {
436+
reasoning_content: "primary thought",
437+
reasoning: "fallback thought",
438+
},
439+
index: 0,
440+
},
441+
],
442+
}
443+
yield {
444+
choices: [{ delta: {}, index: 0 }],
445+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
446+
}
447+
},
448+
}))
449+
450+
const chunks: any[] = []
451+
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
452+
chunks.push(chunk)
453+
}
454+
455+
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
456+
expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }])
457+
})
389458
})
390459

391460
describe("processUsageMetrics", () => {

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

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -470,21 +470,70 @@ describe("MimoHandler", () => {
470470
expect(usageChunks[0].outputTokens).toBe(5)
471471
})
472472

473-
it("should handle reasoning_content in stream", async () => {
474-
// Override mock to return reasoning_content
473+
it("streams reasoning chunks from delta.reasoning_content", async () => {
475474
mockCreate.mockImplementationOnce(async () => ({
476475
[Symbol.asyncIterator]: async function* () {
476+
yield { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }
477+
yield { choices: [{ delta: { content: "answer" }, index: 0 }] }
477478
yield {
478-
choices: [{ delta: { reasoning_content: "Thinking..." }, index: 0 }],
479-
usage: null,
479+
choices: [{ delta: {}, index: 0 }],
480+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
480481
}
482+
},
483+
}))
484+
485+
const messages: Anthropic.Messages.MessageParam[] = [
486+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
487+
]
488+
489+
const chunks: any[] = []
490+
for await (const chunk of handler.createMessage("System prompt", messages)) {
491+
chunks.push(chunk)
492+
}
493+
494+
expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." })
495+
})
496+
497+
it("falls back to delta.reasoning when reasoning_content is absent", async () => {
498+
mockCreate.mockImplementationOnce(async () => ({
499+
[Symbol.asyncIterator]: async function* () {
500+
yield { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }
481501
yield {
482-
choices: [{ delta: { content: "Done" }, index: 0 }],
483-
usage: null,
502+
choices: [{ delta: {}, index: 0 }],
503+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
484504
}
505+
},
506+
}))
507+
508+
const messages: Anthropic.Messages.MessageParam[] = [
509+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
510+
]
511+
512+
const chunks: any[] = []
513+
for await (const chunk of handler.createMessage("System prompt", messages)) {
514+
chunks.push(chunk)
515+
}
516+
517+
expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" })
518+
})
519+
520+
it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => {
521+
mockCreate.mockImplementationOnce(async () => ({
522+
[Symbol.asyncIterator]: async function* () {
485523
yield {
486-
choices: [{ delta: {}, index: 0, finish_reason: "stop" }],
487-
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
524+
choices: [
525+
{
526+
delta: {
527+
reasoning_content: "primary thought",
528+
reasoning: "fallback thought",
529+
},
530+
index: 0,
531+
},
532+
],
533+
}
534+
yield {
535+
choices: [{ delta: {}, index: 0 }],
536+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
488537
}
489538
},
490539
}))
@@ -494,14 +543,12 @@ describe("MimoHandler", () => {
494543
]
495544

496545
const chunks: any[] = []
497-
const stream = handler.createMessage("System prompt", messages)
498-
for await (const chunk of stream) {
546+
for await (const chunk of handler.createMessage("System prompt", messages)) {
499547
chunks.push(chunk)
500548
}
501549

502-
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
503-
expect(reasoningChunks).toHaveLength(1)
504-
expect(reasoningChunks[0].text).toBe("Thinking...")
550+
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
551+
expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }])
505552
})
506553

507554
it("should yield tool_call_partial chunks from stream", async () => {

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,77 @@ describe("OpenAiHandler", () => {
221221
expect(textChunks[0].text).toBe("Test response")
222222
})
223223

224+
it("streams reasoning chunks from delta.reasoning_content", async () => {
225+
mockCreate.mockImplementationOnce(async () => ({
226+
[Symbol.asyncIterator]: async function* () {
227+
yield { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }
228+
yield { choices: [{ delta: { content: "answer" }, index: 0 }] }
229+
yield {
230+
choices: [{ delta: {}, index: 0 }],
231+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
232+
}
233+
},
234+
}))
235+
236+
const chunks: any[] = []
237+
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
238+
chunks.push(chunk)
239+
}
240+
241+
expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." })
242+
})
243+
244+
it("falls back to delta.reasoning when reasoning_content is absent", async () => {
245+
mockCreate.mockImplementationOnce(async () => ({
246+
[Symbol.asyncIterator]: async function* () {
247+
yield { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }
248+
yield {
249+
choices: [{ delta: {}, index: 0 }],
250+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
251+
}
252+
},
253+
}))
254+
255+
const chunks: any[] = []
256+
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
257+
chunks.push(chunk)
258+
}
259+
260+
expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" })
261+
})
262+
263+
it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => {
264+
mockCreate.mockImplementationOnce(async () => ({
265+
[Symbol.asyncIterator]: async function* () {
266+
yield {
267+
choices: [
268+
{
269+
delta: {
270+
reasoning_content: "primary thought",
271+
reasoning: "fallback thought",
272+
},
273+
index: 0,
274+
},
275+
],
276+
}
277+
yield {
278+
choices: [{ delta: {}, index: 0 }],
279+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
280+
}
281+
},
282+
}))
283+
284+
const chunks: any[] = []
285+
286+
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
287+
chunks.push(chunk)
288+
}
289+
290+
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
291+
292+
expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }])
293+
})
294+
224295
it("should handle tool calls in streaming responses", async () => {
225296
mockCreate.mockImplementation(async (options) => {
226297
return {

src/api/providers/__tests__/opencode-go.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,84 @@ describe("OpencodeGoHandler", () => {
158158
}),
159159
)
160160
})
161+
162+
it("streams reasoning chunks from delta.reasoning_content", async () => {
163+
mockCreate.mockImplementationOnce(async () => ({
164+
[Symbol.asyncIterator]: async function* () {
165+
yield { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }
166+
yield { choices: [{ delta: { content: "answer" }, index: 0 }] }
167+
yield {
168+
choices: [{ delta: {}, index: 0 }],
169+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
170+
}
171+
},
172+
}))
173+
174+
const handler = new OpencodeGoHandler(mockOptions)
175+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
176+
177+
const chunks: any[] = []
178+
for await (const chunk of handler.createMessage("sys", messages)) {
179+
chunks.push(chunk)
180+
}
181+
182+
expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." })
183+
})
184+
185+
it("falls back to delta.reasoning when reasoning_content is absent", async () => {
186+
mockCreate.mockImplementationOnce(async () => ({
187+
[Symbol.asyncIterator]: async function* () {
188+
yield { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }
189+
yield {
190+
choices: [{ delta: {}, index: 0 }],
191+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
192+
}
193+
},
194+
}))
195+
196+
const handler = new OpencodeGoHandler(mockOptions)
197+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
198+
199+
const chunks: any[] = []
200+
for await (const chunk of handler.createMessage("sys", messages)) {
201+
chunks.push(chunk)
202+
}
203+
204+
expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" })
205+
})
206+
207+
it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => {
208+
mockCreate.mockImplementationOnce(async () => ({
209+
[Symbol.asyncIterator]: async function* () {
210+
yield {
211+
choices: [
212+
{
213+
delta: {
214+
reasoning_content: "primary thought",
215+
reasoning: "fallback thought",
216+
},
217+
index: 0,
218+
},
219+
],
220+
}
221+
yield {
222+
choices: [{ delta: {}, index: 0 }],
223+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
224+
}
225+
},
226+
}))
227+
228+
const handler = new OpencodeGoHandler(mockOptions)
229+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
230+
231+
const chunks: any[] = []
232+
for await (const chunk of handler.createMessage("sys", messages)) {
233+
chunks.push(chunk)
234+
}
235+
236+
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
237+
expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }])
238+
})
161239
})
162240

163241
describe("completePrompt", () => {

0 commit comments

Comments
 (0)