mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
fix(ui): render reasoning in chat instead of raw think tags
The chat UI streams via the Responses API but only called onReasoningContent for response.reasoning.delta, an event only its own non-streaming helper synthesizes; the proxy streams response.reasoning_summary_text.delta, so streamed reasoning was dropped and the reasoning panel never appeared. It also never parsed inline <think> blocks, so deployments using merge_reasoning_content_in_choices rendered the literal tags in the answer. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
ffcb54b06d
commit
2badff4776
3 changed files with 167 additions and 11 deletions
|
|
@ -11,6 +11,7 @@ import {
|
|||
handleCodeInterpreterCall,
|
||||
handleCodeInterpreterOutput,
|
||||
} from "./code_interpreter_handler";
|
||||
import { flushThinkTags, initialThinkTagState, splitThinkTags, ThinkTagState } from "./thinkTags";
|
||||
|
||||
export type { CodeInterpreterResult } from "./code_interpreter_handler";
|
||||
|
||||
|
|
@ -29,6 +30,12 @@ interface NonStreamedResponse {
|
|||
output?: ResponseOutputItem[];
|
||||
}
|
||||
|
||||
const REASONING_DELTA_EVENT_TYPES = new Set([
|
||||
"response.reasoning.delta",
|
||||
"response.reasoning_text.delta",
|
||||
"response.reasoning_summary_text.delta",
|
||||
]);
|
||||
|
||||
type SynthesizedResponseEvent =
|
||||
| { type: "response.output_item.done"; item: ResponseOutputItem }
|
||||
| { type: "response.reasoning.delta"; delta: string }
|
||||
|
|
@ -206,6 +213,7 @@ export async function makeOpenAIResponsesRequest(
|
|||
|
||||
let mcpToolUsed = "";
|
||||
let codeInterpreterState: CodeInterpreterState = { code: "", containerId: "" };
|
||||
let thinkTagState: ThinkTagState = initialThinkTagState;
|
||||
|
||||
for await (const event of events) {
|
||||
// Use a type-safe approach to handle events
|
||||
|
|
@ -250,24 +258,30 @@ export async function makeOpenAIResponsesRequest(
|
|||
|
||||
// 2) only handle actual text deltas
|
||||
if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||
const delta = event.delta;
|
||||
if (delta.length > 0) {
|
||||
updateTextUI("assistant", delta, selectedModel);
|
||||
const split = splitThinkTags(thinkTagState, event.delta);
|
||||
thinkTagState = split.state;
|
||||
|
||||
// Calculate time to first token
|
||||
if (!firstTokenReceived) {
|
||||
firstTokenReceived = true;
|
||||
const timeToFirstToken = Date.now() - startTime;
|
||||
if (split.reasoning.length > 0 && onReasoningContent) {
|
||||
onReasoningContent(split.reasoning);
|
||||
}
|
||||
|
||||
if (onTimingData && streamingEnabled) {
|
||||
onTimingData(timeToFirstToken);
|
||||
}
|
||||
if (split.text.length > 0) {
|
||||
updateTextUI("assistant", split.text, selectedModel);
|
||||
}
|
||||
|
||||
// Calculate time to first token
|
||||
if ((split.text.length > 0 || split.reasoning.length > 0) && !firstTokenReceived) {
|
||||
firstTokenReceived = true;
|
||||
const timeToFirstToken = Date.now() - startTime;
|
||||
|
||||
if (onTimingData && streamingEnabled) {
|
||||
onTimingData(timeToFirstToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning content
|
||||
if (event.type === "response.reasoning.delta" && "delta" in event) {
|
||||
if (REASONING_DELTA_EVENT_TYPES.has(event.type) && "delta" in event) {
|
||||
const delta = event.delta;
|
||||
if (typeof delta === "string" && onReasoningContent) {
|
||||
onReasoningContent(delta);
|
||||
|
|
@ -303,6 +317,14 @@ export async function makeOpenAIResponsesRequest(
|
|||
}
|
||||
}
|
||||
|
||||
const trailing = flushThinkTags(thinkTagState);
|
||||
if (trailing.reasoning.length > 0 && onReasoningContent) {
|
||||
onReasoningContent(trailing.reasoning);
|
||||
}
|
||||
if (trailing.text.length > 0) {
|
||||
updateTextUI("assistant", trailing.text, selectedModel);
|
||||
}
|
||||
|
||||
if (onTotalLatency) {
|
||||
onTotalLatency(Date.now() - startTime);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { flushThinkTags, initialThinkTagState, splitThinkTags, ThinkTagState } from "./thinkTags";
|
||||
|
||||
function run(deltas: string[]): { text: string; reasoning: string } {
|
||||
const final = deltas.reduce<{ state: ThinkTagState; text: string; reasoning: string }>(
|
||||
(acc, delta) => {
|
||||
const split = splitThinkTags(acc.state, delta);
|
||||
return {
|
||||
state: split.state,
|
||||
text: acc.text + split.text,
|
||||
reasoning: acc.reasoning + split.reasoning,
|
||||
};
|
||||
},
|
||||
{ state: initialThinkTagState, text: "", reasoning: "" },
|
||||
);
|
||||
const trailing = flushThinkTags(final.state);
|
||||
return { text: final.text + trailing.text, reasoning: final.reasoning + trailing.reasoning };
|
||||
}
|
||||
|
||||
describe("splitThinkTags", () => {
|
||||
it("leaves plain text untouched", () => {
|
||||
expect(run(["Hello! ", "How can I help?"])).toEqual({ text: "Hello! How can I help?", reasoning: "" });
|
||||
});
|
||||
|
||||
it("routes a whole think block to reasoning and keeps the answer as text", () => {
|
||||
expect(run(["<think>the user said hi</think>Hello!"])).toEqual({
|
||||
text: "Hello!",
|
||||
reasoning: "the user said hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles a think block spread across many deltas", () => {
|
||||
expect(run(["<think>", "the user ", "said hi", "</think>", "Hello!"])).toEqual({
|
||||
text: "Hello!",
|
||||
reasoning: "the user said hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles tags split mid-token across delta boundaries", () => {
|
||||
expect(run(["<thi", "nk>reasoning</thi", "nk>answer"])).toEqual({
|
||||
text: "answer",
|
||||
reasoning: "reasoning",
|
||||
});
|
||||
});
|
||||
|
||||
it("never emits a partial tag as text before it is resolved", () => {
|
||||
const first = splitThinkTags(initialThinkTagState, "answer<thi");
|
||||
expect(first.text).toBe("answer");
|
||||
expect(first.reasoning).toBe("");
|
||||
|
||||
const second = splitThinkTags(first.state, "nk>hidden</think>done");
|
||||
expect(second.text).toBe("done");
|
||||
expect(second.reasoning).toBe("hidden");
|
||||
});
|
||||
|
||||
it("emits held-back text that turns out not to be a tag", () => {
|
||||
expect(run(["3 <", " 4"])).toEqual({ text: "3 < 4", reasoning: "" });
|
||||
});
|
||||
|
||||
it("supports multiple think blocks in one response", () => {
|
||||
expect(run(["<think>a</think>one<think>b</think>two"])).toEqual({ text: "onetwo", reasoning: "ab" });
|
||||
});
|
||||
|
||||
it("flushes an unterminated think block as reasoning", () => {
|
||||
expect(run(["<think>truncated reasoning"])).toEqual({ text: "", reasoning: "truncated reasoning" });
|
||||
});
|
||||
});
|
||||
67
ui/litellm-dashboard/src/components/llm_calls/thinkTags.ts
Normal file
67
ui/litellm-dashboard/src/components/llm_calls/thinkTags.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
const OPEN_TAG = "<think>";
|
||||
const CLOSE_TAG = "</think>";
|
||||
|
||||
export interface ThinkTagState {
|
||||
readonly insideThink: boolean;
|
||||
readonly pending: string;
|
||||
}
|
||||
|
||||
export interface ThinkTagSplit {
|
||||
readonly state: ThinkTagState;
|
||||
readonly text: string;
|
||||
readonly reasoning: string;
|
||||
}
|
||||
|
||||
export const initialThinkTagState: ThinkTagState = { insideThink: false, pending: "" };
|
||||
|
||||
function heldBackLength(buffer: string, tag: string): number {
|
||||
const max = Math.min(buffer.length, tag.length - 1);
|
||||
for (let len = max; len > 0; len--) {
|
||||
if (tag.startsWith(buffer.slice(buffer.length - len))) return len;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function splitThinkTags(state: ThinkTagState, delta: string): ThinkTagSplit {
|
||||
const consume = (
|
||||
buffer: string,
|
||||
insideThink: boolean,
|
||||
text: string,
|
||||
reasoning: string,
|
||||
): { pending: string; insideThink: boolean; text: string; reasoning: string } => {
|
||||
const tag = insideThink ? CLOSE_TAG : OPEN_TAG;
|
||||
const index = buffer.indexOf(tag);
|
||||
if (index !== -1) {
|
||||
const before = buffer.slice(0, index);
|
||||
return consume(
|
||||
buffer.slice(index + tag.length),
|
||||
!insideThink,
|
||||
insideThink ? text : text + before,
|
||||
insideThink ? reasoning + before : reasoning,
|
||||
);
|
||||
}
|
||||
const held = heldBackLength(buffer, tag);
|
||||
const emitted = buffer.slice(0, buffer.length - held);
|
||||
return {
|
||||
pending: buffer.slice(buffer.length - held),
|
||||
insideThink,
|
||||
text: insideThink ? text : text + emitted,
|
||||
reasoning: insideThink ? reasoning + emitted : reasoning,
|
||||
};
|
||||
};
|
||||
|
||||
const result = consume(state.pending + delta, state.insideThink, "", "");
|
||||
return {
|
||||
state: { insideThink: result.insideThink, pending: result.pending },
|
||||
text: result.text,
|
||||
reasoning: result.reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
export function flushThinkTags(state: ThinkTagState): ThinkTagSplit {
|
||||
return {
|
||||
state: initialThinkTagState,
|
||||
text: state.insideThink ? "" : state.pending,
|
||||
reasoning: state.insideThink ? state.pending : "",
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue