From 792f0e9d019d407f315e409cc9f486b2ce8cd9d0 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Tue, 18 Aug 2026 23:19:19 +0800 Subject: [PATCH 1/4] feat(livekit): make reasoning middleware configurable per model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reasoning middleware was only enabled by a hardcoded well-known model list, so users could not turn it on for their own -emitting models. Expose it as a `useReasoningMiddleware` model option (like `useToolCallMiddleware`), keeping the well-known list as fallback. 🤖 Generated with [Pochi](https://getpochi.com) | [Task](https://app.getpochi.com/share/p-c1444bad2f084203a178cac40505c024) Co-Authored-By: Pochi --- packages/cli/src/cli.ts | 4 +++ packages/common/src/configuration/model.ts | 6 ++++ packages/common/src/vendor/types.ts | 1 + .../src/chat/flexible-chat-transport.ts | 15 +++++++++- packages/livekit/src/types.ts | 28 +++++++++++++++++++ .../features/chat/lib/display-model-to-llm.ts | 4 +++ 6 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 06a325bb6f..7c583fda28 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -704,6 +704,7 @@ async function createLLMConfigWithVendors( contextWindow: options.contextWindow, useToolCallMiddleware: options.useToolCallMiddleware, + useReasoningMiddleware: options.useReasoningMiddleware, getModel: () => createModel(vendorId, { modelId, @@ -728,6 +729,7 @@ async function createLLMConfigWithPochi( contextWindow: pochiModelOptions.contextWindow, useToolCallMiddleware: pochiModelOptions.useToolCallMiddleware, + useReasoningMiddleware: pochiModelOptions.useReasoningMiddleware, getModel: () => createModel(vendorId, { modelId: model, @@ -778,6 +780,7 @@ async function createLLMConfigWithProviders( maxOutputTokens: modelSetting.maxTokens ?? constants.DefaultMaxOutputTokens, useToolCallMiddleware: modelSetting.useToolCallMiddleware, + useReasoningMiddleware: modelSetting.useReasoningMiddleware, contentType: modelSetting.contentType, }; } @@ -801,6 +804,7 @@ async function createLLMConfigWithProviders( maxOutputTokens: modelSetting.maxTokens ?? constants.DefaultMaxOutputTokens, useToolCallMiddleware: modelSetting.useToolCallMiddleware, + useReasoningMiddleware: modelSetting.useReasoningMiddleware, contentType: modelSetting.contentType, }; } diff --git a/packages/common/src/configuration/model.ts b/packages/common/src/configuration/model.ts index 724873a091..d7d432365f 100644 --- a/packages/common/src/configuration/model.ts +++ b/packages/common/src/configuration/model.ts @@ -26,6 +26,12 @@ const BaseModelSettings = z.object({ .boolean() .optional() .describe("Whether to use tool call middleware"), + useReasoningMiddleware: z + .boolean() + .optional() + .describe( + "Whether to use reasoning middleware, which extracts reasoning wrapped in tags from the model output", + ), contentType: z .array(z.string()) .optional() diff --git a/packages/common/src/vendor/types.ts b/packages/common/src/vendor/types.ts index 26b18939bc..3c05652113 100644 --- a/packages/common/src/vendor/types.ts +++ b/packages/common/src/vendor/types.ts @@ -4,6 +4,7 @@ export const ModelOptions = z.object({ label: z.string().optional(), contextWindow: z.number().optional(), useToolCallMiddleware: z.boolean().optional(), + useReasoningMiddleware: z.boolean().optional(), contentType: z.array(z.string()).optional(), }); diff --git a/packages/livekit/src/chat/flexible-chat-transport.ts b/packages/livekit/src/chat/flexible-chat-transport.ts index 8a26ce8823..5de28670d8 100644 --- a/packages/livekit/src/chat/flexible-chat-transport.ts +++ b/packages/livekit/src/chat/flexible-chat-transport.ts @@ -267,7 +267,7 @@ export class FlexibleChatTransport implements ChatTransport { ); } - if ("modelId" in llm && isWellKnownReasoningModel(llm.modelId)) { + if (useReasoningMiddleware(llm)) { middlewares.push(createReasoningMiddleware()); } @@ -455,6 +455,19 @@ function prepareMessages(inputMessages: Message[]): Message[] { return convertDataReviewsToText(inputMessages); } +/** + * The reasoning middleware is opt-in via model configuration + * (`useReasoningMiddleware`), falling back to a well-known model list when the + * setting is not provided. + */ +function useReasoningMiddleware(llm: RequestData["llm"]): boolean { + if (llm.useReasoningMiddleware !== undefined) { + return llm.useReasoningMiddleware; + } + + return "modelId" in llm && isWellKnownReasoningModel(llm.modelId); +} + function isWellKnownReasoningModel(model?: string): boolean { if (!model) return false; diff --git a/packages/livekit/src/types.ts b/packages/livekit/src/types.ts index 3ab9cb8ff7..285fa51221 100644 --- a/packages/livekit/src/types.ts +++ b/packages/livekit/src/types.ts @@ -88,6 +88,10 @@ const RequestData = z.object({ .boolean() .optional() .describe("Whether to use tool call middleware"), + useReasoningMiddleware: z + .boolean() + .optional() + .describe("Whether to use reasoning middleware"), contentType: z .array(z.string()) .optional() @@ -106,6 +110,10 @@ const RequestData = z.object({ .boolean() .optional() .describe("Whether to use tool call middleware"), + useReasoningMiddleware: z + .boolean() + .optional() + .describe("Whether to use reasoning middleware"), contentType: z .array(z.string()) .optional() @@ -124,6 +132,10 @@ const RequestData = z.object({ .boolean() .optional() .describe("Whether to use tool call middleware"), + useReasoningMiddleware: z + .boolean() + .optional() + .describe("Whether to use reasoning middleware"), contentType: z .array(z.string()) .optional() @@ -141,6 +153,10 @@ const RequestData = z.object({ .boolean() .optional() .describe("Whether to use tool call middleware"), + useReasoningMiddleware: z + .boolean() + .optional() + .describe("Whether to use reasoning middleware"), contentType: z .array(z.string()) .optional() @@ -158,6 +174,10 @@ const RequestData = z.object({ .boolean() .optional() .describe("Whether to use tool call middleware"), + useReasoningMiddleware: z + .boolean() + .optional() + .describe("Whether to use reasoning middleware"), contentType: z .array(z.string()) .optional() @@ -175,6 +195,10 @@ const RequestData = z.object({ .boolean() .optional() .describe("Whether to use tool call middleware"), + useReasoningMiddleware: z + .boolean() + .optional() + .describe("Whether to use reasoning middleware"), contentType: z .array(z.string()) .optional() @@ -192,6 +216,10 @@ const RequestData = z.object({ .boolean() .optional() .describe("Whether to use tool call middleware"), + useReasoningMiddleware: z + .boolean() + .optional() + .describe("Whether to use reasoning middleware"), getModel: z.custom<() => LanguageModelV3>(), contentType: z .array(z.string()) diff --git a/packages/vscode-webui/src/features/chat/lib/display-model-to-llm.ts b/packages/vscode-webui/src/features/chat/lib/display-model-to-llm.ts index 5aeb37c2b5..9d694c9438 100644 --- a/packages/vscode-webui/src/features/chat/lib/display-model-to-llm.ts +++ b/packages/vscode-webui/src/features/chat/lib/display-model-to-llm.ts @@ -18,6 +18,7 @@ export function displayModelToLLM(model: DisplayModel): LLMRequestData { type: "vendor", contextWindow: model.options.contextWindow, useToolCallMiddleware: model.options.useToolCallMiddleware, + useReasoningMiddleware: model.options.useReasoningMiddleware, getModel: () => createModel(model.vendorId, { modelId: model.modelId, @@ -39,6 +40,7 @@ export function displayModelToLLM(model: DisplayModel): LLMRequestData { contextWindow: model.options.contextWindow ?? constants.DefaultContextWindow, useToolCallMiddleware: model.options.useToolCallMiddleware, + useReasoningMiddleware: model.options.useReasoningMiddleware, contentType: model.contentType, }; } @@ -54,6 +56,7 @@ export function displayModelToLLM(model: DisplayModel): LLMRequestData { contextWindow: model.options.contextWindow ?? constants.DefaultContextWindow, useToolCallMiddleware: model.options.useToolCallMiddleware, + useReasoningMiddleware: model.options.useReasoningMiddleware, contentType: model.contentType, }; } @@ -76,6 +79,7 @@ export function displayModelToLLM(model: DisplayModel): LLMRequestData { contextWindow: model.options.contextWindow ?? constants.DefaultContextWindow, useToolCallMiddleware: model.options.useToolCallMiddleware, + useReasoningMiddleware: model.options.useReasoningMiddleware, contentType: model.contentType, }; } From e7765d1a4358da4e15cdf87ab358ff1d01a4a716 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Tue, 18 Aug 2026 23:23:38 +0800 Subject: [PATCH 2/4] feat(livekit): support reasoning tags with attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models such as GLM emit signed thinking blocks like , which the reasoning middleware previously failed to detect because it only matched the bare tag. Parse attributes on the opening tag and keep them in the reasoning part provider metadata so they are not lost. 🤖 Generated with [Pochi](https://getpochi.com) | [Task](https://app.getpochi.com/share/p-c1444bad2f084203a178cac40505c024) Co-Authored-By: Pochi --- .../__tests__/reasoning-middleware.test.ts | 231 ++++++++++++++++++ .../chat/middlewares/reasoning-middleware.ts | 164 +++++++++---- 2 files changed, 348 insertions(+), 47 deletions(-) create mode 100644 packages/livekit/src/chat/__tests__/reasoning-middleware.test.ts diff --git a/packages/livekit/src/chat/__tests__/reasoning-middleware.test.ts b/packages/livekit/src/chat/__tests__/reasoning-middleware.test.ts new file mode 100644 index 0000000000..1d2577ed30 --- /dev/null +++ b/packages/livekit/src/chat/__tests__/reasoning-middleware.test.ts @@ -0,0 +1,231 @@ +import type { + LanguageModelV3StreamPart, + LanguageModelV3Usage, +} from '@ai-sdk/provider'; +import { wrapLanguageModel } from 'ai'; +import { describe, expect, it } from 'vitest'; +import { + ReasoningTagMetadataKey, + createReasoningMiddleware, +} from '../middlewares/reasoning-middleware'; + +const emptyUsage: LanguageModelV3Usage = { + inputTokens: { + total: undefined, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { + total: undefined, + text: undefined, + reasoning: undefined, + }, +}; + +function createTextStream(deltas: string[]) { + const chunks: LanguageModelV3StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-0' }, + ...deltas.map( + (delta): LanguageModelV3StreamPart => ({ + type: 'text-delta', + id: 'text-0', + delta, + }), + ), + { type: 'text-end', id: 'text-0' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage, + }, + ]; + + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); +} + +async function streamParts( + deltas: string[], + tag?: string, +): Promise { + const model = wrapLanguageModel({ + model: { + specificationVersion: 'v3', + provider: 'test', + modelId: 'test-model', + supportedUrls: {}, + doGenerate: async () => { + throw new Error('not implemented'); + }, + doStream: async () => ({ stream: createTextStream(deltas) }), + }, + middleware: createReasoningMiddleware(tag), + }); + + const { stream } = await model.doStream({ prompt: [] }); + const parts: LanguageModelV3StreamPart[] = []; + for await (const part of stream) { + parts.push(part); + } + return parts; +} + +function reasoningText(parts: LanguageModelV3StreamPart[]): string { + return parts + .filter( + (part): part is Extract => + part.type === 'reasoning-delta', + ) + .map((part) => part.delta) + .join(''); +} + +function text(parts: LanguageModelV3StreamPart[]): string { + return parts + .filter( + (part): part is Extract => + part.type === 'text-delta', + ) + .map((part) => part.delta) + .join(''); +} + +function reasoningStart(parts: LanguageModelV3StreamPart[]) { + return parts.find((part) => part.type === 'reasoning-start'); +} + +describe('createReasoningMiddleware', () => { + it('extracts reasoning from a plain tag', async () => { + const parts = await streamParts(['secretanswer']); + + expect(reasoningText(parts)).toBe('secret'); + expect(text(parts)).toBe('answer'); + expect(reasoningStart(parts)).toEqual({ + type: 'reasoning-start', + id: 'reasoning-1', + }); + }); + + it('keeps tag attributes in provider metadata', async () => { + const parts = await streamParts([ + 'secretanswer', + ]); + + expect(reasoningText(parts)).toBe('secret'); + expect(text(parts)).toBe('answer'); + expect(reasoningStart(parts)).toEqual({ + type: 'reasoning-start', + id: 'reasoning-1', + providerMetadata: { + [ReasoningTagMetadataKey]: { + tag: 'think', + attributes: ' signature="abc123"', + }, + }, + }); + }); + + it('handles tags with attributes split across deltas', async () => { + const parts = await streamParts([ + 'before sec', + 'retafter', + ]); + + expect(reasoningText(parts)).toBe('secret'); + expect(text(parts)).toBe('before after'); + expect(reasoningStart(parts)).toMatchObject({ + providerMetadata: { + [ReasoningTagMetadataKey]: { + tag: 'think', + attributes: ' signature="abc123"', + }, + }, + }); + }); + + it('supports multiple reasoning sections with different attributes', async () => { + const parts = await streamParts([ + 'firsta', + 'secondb', + ]); + + expect( + parts.filter((part) => part.type === 'reasoning-start'), + ).toEqual([ + { + type: 'reasoning-start', + id: 'reasoning-1', + providerMetadata: { + [ReasoningTagMetadataKey]: { + tag: 'think', + attributes: ' signature="one"', + }, + }, + }, + { + type: 'reasoning-start', + id: 'reasoning-2', + providerMetadata: { + [ReasoningTagMetadataKey]: { + tag: 'think', + attributes: ' signature="two"', + }, + }, + }, + ]); + expect(text(parts)).toBe('ab'); + }); + + it('does not treat tags sharing the prefix as reasoning', async () => { + const parts = await streamParts(['not reasoning']); + + expect(reasoningText(parts)).toBe(''); + expect(text(parts)).toBe('not reasoning'); + }); + + it('flushes an incomplete tag as text when the text section ends', async () => { + const parts = await streamParts(['answer { + const parts = await streamParts(['secret']); + + expect(reasoningText(parts)).toBe('secret'); + expect(parts.at(-2)).toEqual({ + type: 'reasoning-end', + id: 'reasoning-1', + }); + }); + + it('supports a custom tag name', async () => { + const parts = await streamParts( + ['secretanswer'], + 'reasoning', + ); + + expect(reasoningText(parts)).toBe('secret'); + expect(text(parts)).toBe('answer'); + expect(reasoningStart(parts)).toMatchObject({ + providerMetadata: { + [ReasoningTagMetadataKey]: { + tag: 'reasoning', + attributes: ' depth="2"', + }, + }, + }); + }); +}); diff --git a/packages/livekit/src/chat/middlewares/reasoning-middleware.ts b/packages/livekit/src/chat/middlewares/reasoning-middleware.ts index 40f6c1ac84..6b6ec862e8 100644 --- a/packages/livekit/src/chat/middlewares/reasoning-middleware.ts +++ b/packages/livekit/src/chat/middlewares/reasoning-middleware.ts @@ -4,10 +4,26 @@ import type { } from "@ai-sdk/provider"; import { getPotentialStartIndex } from "./utils"; +/** + * Provider metadata namespace used to remember how the reasoning tag was + * originally written by the model, so it can be echoed back verbatim in + * follow-up requests. + */ +export const ReasoningTagMetadataKey = "pochiReasoningTag"; + +export type ReasoningTagMetadata = { + /** The tag name, e.g. `think`. */ + tag: string; + /** The raw attributes of the opening tag, e.g. ` signature="abc"`. */ + attributes: string; +}; + export function createReasoningMiddleware( tag = "think", ): LanguageModelV3Middleware { - const tagStart = `<${tag}>`; + // The opening tag may carry attributes, e.g. ``. + const tagStartPrefix = `<${tag}`; + const tagStartRegex = new RegExp(`^<${escapeRegExp(tag)}(\\s[^>]*)?>$`); const tagEnd = ``; let countReasoning = 0; let textId = ""; @@ -39,6 +55,19 @@ export function createReasoningMiddleware( } if (chunk.type === "text-end") { + // Flush whatever is left in the buffer: it can hold an + // incomplete tag that will never be completed. + if (buffer.length > 0) { + publish(controller, buffer); + buffer = ""; + } + if (isReasoning) { + isReasoning = false; + controller.enqueue({ + type: "reasoning-end", + id: getReasoningId(), + }); + } textId = ""; // Skip entire text section if it's empty. if (pendingTextStart) { @@ -54,46 +83,16 @@ export function createReasoningMiddleware( buffer += chunk.delta; - function publish(text: string) { - const isEmptyText = text.trim().length === 0; - if (isReasoning) { - if (isFirstReasoning && isEmptyText) { - // Skip - } else { - controller.enqueue({ - id: getReasoningId(), - type: "reasoning-delta", - delta: text, - }); - } - isFirstReasoning = false; - } else { - if (pendingTextStart && isEmptyText) { - // Skip - } else { - if (pendingTextStart) { - controller.enqueue(pendingTextStart); - pendingTextStart = undefined; - } - controller.enqueue({ - id: textId, - type: "text-delta", - delta: text, - }); - } - } - } - do { if (isReasoning) { const endIndex = getPotentialStartIndex(buffer, tagEnd); if (endIndex === null) { - publish(buffer); + publish(controller, buffer); buffer = ""; break; } - publish(buffer.slice(0, endIndex)); + publish(controller, buffer.slice(0, endIndex)); const foundFullEndMatch = endIndex + tagEnd.length <= buffer.length; @@ -110,27 +109,60 @@ export function createReasoningMiddleware( break; } } else { - const startIndex = getPotentialStartIndex(buffer, tagStart); + const startIndex = getPotentialStartIndex( + buffer, + tagStartPrefix, + ); if (startIndex === null) { - publish(buffer); + publish(controller, buffer); buffer = ""; break; } - publish(buffer.slice(0, startIndex)); - const foundFullStartMatch = - startIndex + tagStart.length <= buffer.length; - if (foundFullStartMatch) { - buffer = buffer.slice(startIndex + tagStart.length); - isReasoning = true; - countReasoning++; - controller.enqueue({ - type: "reasoning-start", - id: getReasoningId(), - }); - } else { + + const foundFullStartPrefix = + startIndex + tagStartPrefix.length <= buffer.length; + if (!foundFullStartPrefix) { + publish(controller, buffer.slice(0, startIndex)); buffer = buffer.slice(startIndex); break; } + + const tagEndIndex = buffer.indexOf(">", startIndex); + if (tagEndIndex < 0) { + // The opening tag is not complete yet, wait for more deltas. + publish(controller, buffer.slice(0, startIndex)); + buffer = buffer.slice(startIndex); + break; + } + + const tagStart = buffer.slice(startIndex, tagEndIndex + 1); + const match = tagStart.match(tagStartRegex); + if (!match) { + // A different tag sharing the same prefix, e.g. ``. + publish(controller, buffer.slice(0, tagEndIndex + 1)); + buffer = buffer.slice(tagEndIndex + 1); + continue; + } + + publish(controller, buffer.slice(0, startIndex)); + buffer = buffer.slice(startIndex + tagStart.length); + isReasoning = true; + countReasoning++; + const attributes = match[1] ?? ""; + controller.enqueue({ + type: "reasoning-start", + id: getReasoningId(), + ...(attributes + ? { + providerMetadata: { + [ReasoningTagMetadataKey]: { + tag, + attributes, + } satisfies ReasoningTagMetadata, + }, + } + : {}), + }); } // biome-ignore lint/correctness/noConstantCondition: This loop intentionally runs indefinitely, processing the buffer in chunks until no more complete tags can be found. The loop breaks internally based on buffer content and parsing progress. @@ -144,4 +176,42 @@ export function createReasoningMiddleware( }; }, }; + + function publish( + controller: TransformStreamDefaultController, + text: string, + ) { + if (text.length === 0) return; + const isEmptyText = text.trim().length === 0; + if (isReasoning) { + if (isFirstReasoning && isEmptyText) { + // Skip + } else { + controller.enqueue({ + id: getReasoningId(), + type: "reasoning-delta", + delta: text, + }); + } + isFirstReasoning = false; + } else { + if (pendingTextStart && isEmptyText) { + // Skip + } else { + if (pendingTextStart) { + controller.enqueue(pendingTextStart); + pendingTextStart = undefined; + } + controller.enqueue({ + id: textId, + type: "text-delta", + delta: text, + }); + } + } + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } From 091210f49970f9a1f294ce53ffaaba0d1862bd51 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Tue, 18 Aug 2026 23:27:46 +0800 Subject: [PATCH 3/4] fix(livekit): echo reasoning back inside its original think tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-turn round-trip test showed the extracted reasoning was replayed as an SDK reasoning part, so providers either dropped it or mapped it to a field the model never wrote. Re-serialize assistant reasoning into the original tag (attributes included) before the request leaves the client. 🤖 Generated with [Pochi](https://getpochi.com) | [Task](https://app.getpochi.com/share/p-c1444bad2f084203a178cac40505c024) Co-Authored-By: Pochi --- .../__tests__/reasoning-middleware.test.ts | 154 ++++++++++++++++-- .../chat/middlewares/reasoning-middleware.ts | 44 +++++ 2 files changed, 187 insertions(+), 11 deletions(-) diff --git a/packages/livekit/src/chat/__tests__/reasoning-middleware.test.ts b/packages/livekit/src/chat/__tests__/reasoning-middleware.test.ts index 1d2577ed30..26875be181 100644 --- a/packages/livekit/src/chat/__tests__/reasoning-middleware.test.ts +++ b/packages/livekit/src/chat/__tests__/reasoning-middleware.test.ts @@ -1,8 +1,16 @@ import type { + LanguageModelV3, + LanguageModelV3CallOptions, LanguageModelV3StreamPart, LanguageModelV3Usage, } from '@ai-sdk/provider'; -import { wrapLanguageModel } from 'ai'; +import type { UIMessage } from 'ai'; +import { + convertToModelMessages, + readUIMessageStream, + streamText, + wrapLanguageModel, +} from 'ai'; import { describe, expect, it } from 'vitest'; import { ReasoningTagMetadataKey, @@ -52,21 +60,31 @@ function createTextStream(deltas: string[]) { }); } +function createModel( + deltas: string[], + calls: LanguageModelV3CallOptions[] = [], +): LanguageModelV3 { + return { + specificationVersion: 'v3', + provider: 'test', + modelId: 'test-model', + supportedUrls: {}, + doGenerate: async () => { + throw new Error('not implemented'); + }, + doStream: async (options) => { + calls.push(options); + return { stream: createTextStream(deltas) }; + }, + }; +} + async function streamParts( deltas: string[], tag?: string, ): Promise { const model = wrapLanguageModel({ - model: { - specificationVersion: 'v3', - provider: 'test', - modelId: 'test-model', - supportedUrls: {}, - doGenerate: async () => { - throw new Error('not implemented'); - }, - doStream: async () => ({ stream: createTextStream(deltas) }), - }, + model: createModel(deltas), middleware: createReasoningMiddleware(tag), }); @@ -229,3 +247,117 @@ describe('createReasoningMiddleware', () => { }); }); }); + +/** + * Simulates a full turn: the model streams `deltas`, the UI message is rebuilt + * from the resulting UI stream, and the assistant message is returned. + */ +async function runTurn( + model: LanguageModelV3, + messages: UIMessage[], +): Promise { + const result = streamText({ + model, + messages: await convertToModelMessages(messages), + onError: ({ error }) => { + throw error; + }, + }); + + let assistantMessage: UIMessage | undefined; + for await (const message of readUIMessageStream({ + stream: result.toUIMessageStream(), + })) { + assistantMessage = message; + } + + if (!assistantMessage) { + throw new Error('No assistant message was produced'); + } + + return assistantMessage; +} + +describe('createReasoningMiddleware multi turn', () => { + it('echoes reasoning back in its original tag format', async () => { + const calls: LanguageModelV3CallOptions[] = []; + const model = wrapLanguageModel({ + model: createModel( + ['secretanswer'], + calls, + ), + middleware: createReasoningMiddleware(), + }); + + const userMessage: UIMessage = { + id: 'user-1', + role: 'user', + parts: [{ type: 'text', text: 'hi' }], + }; + const assistantMessage = await runTurn(model, [userMessage]); + + expect( + assistantMessage.parts.filter((part) => part.type !== 'step-start'), + ).toMatchObject([ + { + type: 'reasoning', + text: 'secret', + providerMetadata: { + [ReasoningTagMetadataKey]: { + tag: 'think', + attributes: ' signature="abc123"', + }, + }, + }, + { type: 'text', text: 'answer' }, + ]); + + // Second turn: the reasoning is sent back to the model verbatim. + await runTurn(model, [ + userMessage, + assistantMessage, + { + id: 'user-2', + role: 'user', + parts: [{ type: 'text', text: 'thanks' }], + }, + ]); + + expect(calls).toHaveLength(2); + expect(calls[1].prompt[1]).toEqual({ + role: 'assistant', + content: [ + { + type: 'text', + text: 'secret', + }, + { type: 'text', text: 'answer' }, + ], + }); + }); + + it('echoes reasoning without attributes back in a plain tag', async () => { + const calls: LanguageModelV3CallOptions[] = []; + const model = wrapLanguageModel({ + model: createModel(['secretanswer'], calls), + middleware: createReasoningMiddleware(), + }); + + const userMessage: UIMessage = { + id: 'user-1', + role: 'user', + parts: [{ type: 'text', text: 'hi' }], + }; + const assistantMessage = await runTurn(model, [userMessage]); + + await runTurn(model, [userMessage, assistantMessage]); + + expect(calls[1].prompt[1]).toMatchObject({ + role: 'assistant', + content: [ + { type: 'text', text: 'secret' }, + { type: 'text', text: 'answer' }, + ], + }); + }); +}); diff --git a/packages/livekit/src/chat/middlewares/reasoning-middleware.ts b/packages/livekit/src/chat/middlewares/reasoning-middleware.ts index 6b6ec862e8..b4f3503f07 100644 --- a/packages/livekit/src/chat/middlewares/reasoning-middleware.ts +++ b/packages/livekit/src/chat/middlewares/reasoning-middleware.ts @@ -1,5 +1,6 @@ import type { LanguageModelV3Middleware, + LanguageModelV3Prompt, LanguageModelV3StreamPart, } from "@ai-sdk/provider"; import { getPotentialStartIndex } from "./utils"; @@ -40,6 +41,20 @@ export function createReasoningMiddleware( return { specificationVersion: "v3", + /** + * Models parsed by this middleware emit their reasoning as part of the text + * content, so the reasoning has to be sent back in the very same format: + * providers either drop reasoning parts or map them to a dedicated field + * the model never wrote itself. + */ + transformParams: async ({ params }) => ({ + ...params, + prompt: params.prompt.map((message) => + message.role === "assistant" + ? { ...message, content: serializeReasoning(message.content) } + : message, + ) as LanguageModelV3Prompt, + }), wrapStream: async ({ doStream }) => { const { stream, ...rest } = await doStream(); const transformedStream = stream.pipeThrough( @@ -177,6 +192,35 @@ export function createReasoningMiddleware( }, }; + /** + * Renders assistant reasoning content back into the tag the model used, e.g. + * `...`. + */ + function serializeReasoning( + content: Extract< + LanguageModelV3Prompt[number], + { role: "assistant" } + >["content"], + ) { + return content.map((part) => { + if (part.type !== "reasoning") return part; + + const { [ReasoningTagMetadataKey]: metadata, ...providerOptions } = + part.providerOptions ?? {}; + const tagName = + typeof metadata?.tag === "string" && metadata.tag ? metadata.tag : tag; + const attributes = + typeof metadata?.attributes === "string" ? metadata.attributes : ""; + const separator = !attributes || attributes.startsWith(" ") ? "" : " "; + + return { + type: "text" as const, + text: `<${tagName}${separator}${attributes}>${part.text}`, + ...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), + }; + }); + } + function publish( controller: TransformStreamDefaultController, text: string, From 7e3ec2aff6986be0a17b3236e08aced535b6dd75 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Wed, 19 Aug 2026 13:32:29 +0800 Subject: [PATCH 4/4] chore(livekit): regenerate config schema for useReasoningMiddleware --- assets/config.schema.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/assets/config.schema.json b/assets/config.schema.json index d52a8efd2e..02a9c80660 100644 --- a/assets/config.schema.json +++ b/assets/config.schema.json @@ -85,6 +85,10 @@ "description": "Whether to use tool call middleware", "type": "boolean" }, + "useReasoningMiddleware": { + "description": "Whether to use reasoning middleware, which extracts reasoning wrapped in tags from the model output", + "type": "boolean" + }, "contentType": { "description": "The supported mime types model can handle", "type": "array", @@ -145,6 +149,10 @@ "description": "Whether to use tool call middleware", "type": "boolean" }, + "useReasoningMiddleware": { + "description": "Whether to use reasoning middleware, which extracts reasoning wrapped in tags from the model output", + "type": "boolean" + }, "contentType": { "description": "The supported mime types model can handle", "type": "array", @@ -206,6 +214,10 @@ "description": "Whether to use tool call middleware", "type": "boolean" }, + "useReasoningMiddleware": { + "description": "Whether to use reasoning middleware, which extracts reasoning wrapped in tags from the model output", + "type": "boolean" + }, "contentType": { "description": "The supported mime types model can handle", "type": "array", @@ -267,6 +279,10 @@ "description": "Whether to use tool call middleware", "type": "boolean" }, + "useReasoningMiddleware": { + "description": "Whether to use reasoning middleware, which extracts reasoning wrapped in tags from the model output", + "type": "boolean" + }, "contentType": { "description": "The supported mime types model can handle", "type": "array", @@ -397,6 +413,10 @@ "description": "Whether to use tool call middleware", "type": "boolean" }, + "useReasoningMiddleware": { + "description": "Whether to use reasoning middleware, which extracts reasoning wrapped in tags from the model output", + "type": "boolean" + }, "contentType": { "description": "The supported mime types model can handle", "type": "array", @@ -454,6 +474,10 @@ "description": "Whether to use tool call middleware", "type": "boolean" }, + "useReasoningMiddleware": { + "description": "Whether to use reasoning middleware, which extracts reasoning wrapped in tags from the model output", + "type": "boolean" + }, "contentType": { "description": "The supported mime types model can handle", "type": "array",