Skip to content

Commit c4531d4

Browse files
daewoongohOh Daewoongclaude
authored
feat(litellm): handle reasoning_content and reasoning fields in streaming (#449)
* feat(litellm): handle reasoning_content and reasoning fields in streaming LiteLLMHandler was not processing reasoning/thinking fields from the stream delta, causing reasoning output from DeepSeek, QwQ, and other reasoning models routed through LiteLLM to be silently dropped. Mirrors the reasoning extraction already present in BaseOpenAiCompatibleProvider: checks for `reasoning_content` first, falls back to `reasoning`, skips empty/whitespace-only values. * test(litellm): add reasoning field handling coverage Add tests verifying that reasoning_content and reasoning delta fields are correctly yielded as reasoning chunks, that reasoning_content takes precedence when both fields are present, and that empty/whitespace-only values are silently ignored. * test(litellm): cover falsy reasoning field branch on line 241 Add a test case where reasoning_content is undefined and reasoning is an empty string. This exercises the `|| ""` fallback in the reasoning delta handler, which was previously uncovered. * refactor(reasoning): extract delta reasoning helper and fix fallback bug Replaces the for-of/break pattern in lite-llm.ts and base-openai-compatible-provider.ts with a shared extractReasoningFromDelta helper. The previous form short-circuited as soon as the reasoning_content key existed, so a delta carrying reasoning_content: null (or '') alongside a populated reasoning field would drop the model's thinking output entirely. The helper picks the first field that is both a string and non-blank, restoring the intended fallback chain across LiteLLM and every base-OpenAI-compatible provider. * fix(reasoning): preserve whitespace-only chunks in streamed deltas The previous trim-based guard in extractReasoningFromDelta dropped any delta whose reasoning_content / reasoning payload contained only whitespace. OpenAI-compatible streams routinely emit single-character chunks (a lone " " between words or "\n\n" between paragraphs), and discarding them collapsed word and paragraph boundaries once callers concatenated the chunks into the accumulated reasoning string. Replace the trim guard with explicit length-based fallthrough so: - " ", "\n\n", etc. flow through verbatim - null / non-string / empty-string still cascade to the next field - the original reasoning_content: null + reasoning: "real" fix is kept * Revert "fix(reasoning): preserve whitespace-only chunks in streamed deltas" This reverts commit 64c3fcf. * Reapply "fix(reasoning): preserve whitespace-only chunks in streamed deltas" This reverts commit 860d1f7. * test(reasoning): align base-openai-compatible tests with whitespace-preserving extractor The extractor now passes whitespace-only reasoning chunks through verbatim so streamed word/paragraph boundaries survive concatenation. Update the two expectations that still assumed the old trim-and-drop behavior. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Oh Daewoong <dw.oh@samsung.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d7bc9f6 commit c4531d4

6 files changed

Lines changed: 301 additions & 14 deletions

File tree

src/api/providers/__tests__/base-openai-compatible-provider.spec.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ describe("BaseOpenAiCompatibleProvider", () => {
229229
})
230230

231231
describe("reasoning_content field", () => {
232-
it("should filter out whitespace-only reasoning_content", async () => {
232+
it("should preserve whitespace-only reasoning_content so streamed boundaries survive concatenation", async () => {
233233
mockCreate.mockImplementationOnce(() => {
234234
return {
235235
[Symbol.asyncIterator]: () => ({
@@ -262,8 +262,12 @@ describe("BaseOpenAiCompatibleProvider", () => {
262262
chunks.push(chunk)
263263
}
264264

265-
// Should only have the regular content, not the whitespace-only reasoning
266-
expect(chunks).toEqual([{ type: "text", text: "Regular content" }])
265+
expect(chunks).toEqual([
266+
{ type: "reasoning", text: "\n" },
267+
{ type: "reasoning", text: " " },
268+
{ type: "reasoning", text: "\t\n " },
269+
{ type: "text", text: "Regular content" },
270+
])
267271
})
268272

269273
it("should yield non-empty reasoning_content", async () => {
@@ -295,9 +299,9 @@ describe("BaseOpenAiCompatibleProvider", () => {
295299
chunks.push(chunk)
296300
}
297301

298-
// Should only yield the non-empty reasoning content
299302
expect(chunks).toEqual([
300303
{ type: "reasoning", text: "Thinking step 1" },
304+
{ type: "reasoning", text: "\n" },
301305
{ type: "reasoning", text: "Thinking step 2" },
302306
])
303307
})

src/api/providers/__tests__/lite-llm.spec.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,201 @@ describe("LiteLLMHandler", () => {
719719
})
720720
})
721721

722+
describe("reasoning field handling", () => {
723+
it("should yield reasoning chunks from reasoning_content delta", async () => {
724+
const mockStream = {
725+
async *[Symbol.asyncIterator]() {
726+
yield {
727+
choices: [{ delta: { reasoning_content: "Let me think..." } }],
728+
usage: null,
729+
}
730+
yield {
731+
choices: [{ delta: { content: "The answer is 42." } }],
732+
usage: { prompt_tokens: 20, completion_tokens: 10 },
733+
}
734+
},
735+
}
736+
737+
mockCreate.mockReturnValue({
738+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
739+
})
740+
741+
const generator = handler.createMessage("system", [{ role: "user", content: "What is the answer?" }])
742+
const results = []
743+
for await (const chunk of generator) {
744+
results.push(chunk)
745+
}
746+
747+
const reasoningChunk = results.find((c) => c.type === "reasoning")
748+
expect(reasoningChunk).toBeDefined()
749+
expect(reasoningChunk).toMatchObject({ type: "reasoning", text: "Let me think..." })
750+
751+
const textChunk = results.find((c) => c.type === "text")
752+
expect(textChunk).toMatchObject({ type: "text", text: "The answer is 42." })
753+
})
754+
755+
it("should yield reasoning chunks from reasoning delta field", async () => {
756+
const mockStream = {
757+
async *[Symbol.asyncIterator]() {
758+
yield {
759+
choices: [{ delta: { reasoning: "Analyzing the problem..." } }],
760+
usage: null,
761+
}
762+
yield {
763+
choices: [{ delta: { content: "Done." } }],
764+
usage: { prompt_tokens: 10, completion_tokens: 5 },
765+
}
766+
},
767+
}
768+
769+
mockCreate.mockReturnValue({
770+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
771+
})
772+
773+
const generator = handler.createMessage("system", [{ role: "user", content: "Solve this." }])
774+
const results = []
775+
for await (const chunk of generator) {
776+
results.push(chunk)
777+
}
778+
779+
const reasoningChunk = results.find((c) => c.type === "reasoning")
780+
expect(reasoningChunk).toBeDefined()
781+
expect(reasoningChunk).toMatchObject({ type: "reasoning", text: "Analyzing the problem..." })
782+
})
783+
784+
it("should prefer reasoning_content over reasoning when both are present", async () => {
785+
const mockStream = {
786+
async *[Symbol.asyncIterator]() {
787+
yield {
788+
choices: [
789+
{ delta: { reasoning_content: "from_reasoning_content", reasoning: "from_reasoning" } },
790+
],
791+
usage: { prompt_tokens: 5, completion_tokens: 5 },
792+
}
793+
},
794+
}
795+
796+
mockCreate.mockReturnValue({
797+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
798+
})
799+
800+
const generator = handler.createMessage("system", [{ role: "user", content: "Test." }])
801+
const results = []
802+
for await (const chunk of generator) {
803+
results.push(chunk)
804+
}
805+
806+
const reasoningChunks = results.filter((c) => c.type === "reasoning")
807+
expect(reasoningChunks).toHaveLength(1)
808+
expect(reasoningChunks[0]).toMatchObject({ type: "reasoning", text: "from_reasoning_content" })
809+
})
810+
811+
it("should not yield reasoning chunk when reasoning field is present but falsy", async () => {
812+
const mockStream = {
813+
async *[Symbol.asyncIterator]() {
814+
yield {
815+
choices: [{ delta: { reasoning_content: undefined } }],
816+
usage: null,
817+
}
818+
yield {
819+
choices: [{ delta: { reasoning: "" } }],
820+
usage: null,
821+
}
822+
yield {
823+
choices: [{ delta: { content: "Hello" } }],
824+
usage: { prompt_tokens: 5, completion_tokens: 5 },
825+
}
826+
},
827+
}
828+
829+
mockCreate.mockReturnValue({
830+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
831+
})
832+
833+
const generator = handler.createMessage("system", [{ role: "user", content: "Hi" }])
834+
const results = []
835+
for await (const chunk of generator) {
836+
results.push(chunk)
837+
}
838+
839+
const reasoningChunks = results.filter((c) => c.type === "reasoning")
840+
expect(reasoningChunks).toHaveLength(0)
841+
})
842+
843+
it("should preserve whitespace-only reasoning chunks so streamed boundaries survive concatenation", async () => {
844+
const mockStream = {
845+
async *[Symbol.asyncIterator]() {
846+
yield {
847+
choices: [{ delta: { reasoning_content: "Let's" } }],
848+
usage: null,
849+
}
850+
yield {
851+
choices: [{ delta: { reasoning_content: " " } }],
852+
usage: null,
853+
}
854+
yield {
855+
choices: [{ delta: { reasoning_content: "think" } }],
856+
usage: null,
857+
}
858+
yield {
859+
choices: [{ delta: { reasoning_content: "\n\n" } }],
860+
usage: null,
861+
}
862+
yield {
863+
choices: [{ delta: { reasoning_content: "next" } }],
864+
usage: null,
865+
}
866+
yield {
867+
choices: [{ delta: { content: "Hello" } }],
868+
usage: { prompt_tokens: 5, completion_tokens: 5 },
869+
}
870+
},
871+
}
872+
873+
mockCreate.mockReturnValue({
874+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
875+
})
876+
877+
const generator = handler.createMessage("system", [{ role: "user", content: "Hi" }])
878+
const results = []
879+
for await (const chunk of generator) {
880+
results.push(chunk)
881+
}
882+
883+
const reasoningChunks = results.filter((c) => c.type === "reasoning")
884+
expect(reasoningChunks.map((c) => (c as { text: string }).text).join("")).toBe("Let's think\n\nnext")
885+
})
886+
887+
it("should fall back to reasoning when reasoning_content is null on the same delta", async () => {
888+
const mockStream = {
889+
async *[Symbol.asyncIterator]() {
890+
yield {
891+
choices: [{ delta: { reasoning_content: null, reasoning: "fallback thinking" } }],
892+
usage: null,
893+
}
894+
yield {
895+
choices: [{ delta: { content: "Answer." } }],
896+
usage: { prompt_tokens: 5, completion_tokens: 5 },
897+
}
898+
},
899+
}
900+
901+
mockCreate.mockReturnValue({
902+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
903+
})
904+
905+
const generator = handler.createMessage("system", [{ role: "user", content: "Test." }])
906+
const results = []
907+
for await (const chunk of generator) {
908+
results.push(chunk)
909+
}
910+
911+
const reasoningChunks = results.filter((c) => c.type === "reasoning")
912+
expect(reasoningChunks).toHaveLength(1)
913+
expect(reasoningChunks[0]).toMatchObject({ type: "reasoning", text: "fallback thinking" })
914+
})
915+
})
916+
722917
describe("tool ID normalization", () => {
723918
it("should truncate tool IDs longer than 64 characters", async () => {
724919
const optionsWithBedrock: ApiHandlerOptions = {

src/api/providers/base-openai-compatible-provider.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { BaseProvider } from "./base-provider"
1414
import { handleOpenAIError } from "./utils/openai-error-handler"
1515
import { calculateApiCostOpenAI } from "../../shared/cost"
1616
import { getApiRequestTimeout } from "./utils/timeout-config"
17+
import { extractReasoningFromDelta } from "./utils/extract-reasoning"
1718

1819
type BaseOpenAiCompatibleProviderOptions<ModelName extends string> = ApiHandlerOptions & {
1920
providerName: string
@@ -147,16 +148,9 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
147148
}
148149
}
149150

150-
if (delta) {
151-
for (const key of ["reasoning_content", "reasoning"] as const) {
152-
if (key in delta) {
153-
const reasoning_content = ((delta as any)[key] as string | undefined) || ""
154-
if (reasoning_content?.trim()) {
155-
yield { type: "reasoning", text: reasoning_content }
156-
}
157-
break
158-
}
159-
}
151+
const reasoningText = extractReasoningFromDelta(delta)
152+
if (reasoningText) {
153+
yield { type: "reasoning", text: reasoningText }
160154
}
161155

162156
// Emit raw tool call chunks - NativeToolCallParser handles state management

src/api/providers/lite-llm.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { sanitizeOpenAiCallId } from "../../utils/tool-id"
1313

1414
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1515
import { RouterProvider } from "./router-provider"
16+
import { extractReasoningFromDelta } from "./utils/extract-reasoning"
1617

1718
/**
1819
* LiteLLM provider handler
@@ -235,6 +236,11 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
235236
yield { type: "text", text: delta.content }
236237
}
237238

239+
const reasoningText = extractReasoningFromDelta(delta)
240+
if (reasoningText) {
241+
yield { type: "reasoning", text: reasoningText }
242+
}
243+
238244
// Handle tool calls in stream - emit partial chunks for NativeToolCallParser
239245
if (delta?.tool_calls) {
240246
for (const toolCall of delta.tool_calls) {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// npx vitest run api/providers/utils/__tests__/extract-reasoning.spec.ts
2+
3+
import { extractReasoningFromDelta } from "../extract-reasoning"
4+
5+
describe("extractReasoningFromDelta", () => {
6+
it("returns reasoning_content when present and non-blank", () => {
7+
expect(extractReasoningFromDelta({ reasoning_content: "thinking..." })).toBe("thinking...")
8+
})
9+
10+
it("returns reasoning when reasoning_content is missing", () => {
11+
expect(extractReasoningFromDelta({ reasoning: "analyzing" })).toBe("analyzing")
12+
})
13+
14+
it("prefers reasoning_content over reasoning when both are non-blank", () => {
15+
expect(
16+
extractReasoningFromDelta({
17+
reasoning_content: "from_content",
18+
reasoning: "from_reasoning",
19+
}),
20+
).toBe("from_content")
21+
})
22+
23+
it("falls back to reasoning when reasoning_content is null on the same delta", () => {
24+
expect(
25+
extractReasoningFromDelta({
26+
reasoning_content: null,
27+
reasoning: "fallback",
28+
}),
29+
).toBe("fallback")
30+
})
31+
32+
it("falls back to reasoning when reasoning_content is empty string", () => {
33+
expect(
34+
extractReasoningFromDelta({
35+
reasoning_content: "",
36+
reasoning: "fallback",
37+
}),
38+
).toBe("fallback")
39+
})
40+
41+
it("preserves whitespace-only payloads so streamed chunks keep word and paragraph boundaries", () => {
42+
expect(extractReasoningFromDelta({ reasoning_content: " " })).toBe(" ")
43+
expect(extractReasoningFromDelta({ reasoning: "\n\n" })).toBe("\n\n")
44+
})
45+
46+
it("falls back to reasoning when reasoning_content is an empty string but does not skip whitespace", () => {
47+
expect(
48+
extractReasoningFromDelta({
49+
reasoning_content: "",
50+
reasoning: "\n\n",
51+
}),
52+
).toBe("\n\n")
53+
})
54+
55+
it("returns undefined when neither field is present", () => {
56+
expect(extractReasoningFromDelta({ content: "hi" })).toBeUndefined()
57+
})
58+
59+
it("returns undefined for nullish input", () => {
60+
expect(extractReasoningFromDelta(null)).toBeUndefined()
61+
expect(extractReasoningFromDelta(undefined)).toBeUndefined()
62+
})
63+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Extracts reasoning text from a streaming delta object.
3+
*
4+
* Prefers `reasoning_content` (DeepSeek-R1 / QwQ style) and falls back to
5+
* `reasoning` (OpenRouter style). Whitespace-only payloads (e.g. a lone " "
6+
* or "\n\n" between paragraphs) are preserved so streamed reasoning keeps
7+
* word and paragraph boundaries once chunks are concatenated downstream.
8+
*
9+
* The fallback only fires when the current field is missing, non-string,
10+
* or an empty string — a delta with `reasoning_content: null` and a
11+
* populated `reasoning` still resolves to the populated field.
12+
*/
13+
export function extractReasoningFromDelta(delta: unknown): string | undefined {
14+
if (!delta) return undefined
15+
16+
const d = delta as { reasoning_content?: unknown; reasoning?: unknown }
17+
18+
if (typeof d.reasoning_content === "string" && d.reasoning_content.length > 0) {
19+
return d.reasoning_content
20+
}
21+
if (typeof d.reasoning === "string" && d.reasoning.length > 0) {
22+
return d.reasoning
23+
}
24+
return undefined
25+
}

0 commit comments

Comments
 (0)