UNPKG

ai

Version:

AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.

313 lines (272 loc) • 10.6 kB
import type { LanguageModelV4Content, LanguageModelV4StreamPart, } from '@ai-sdk/provider'; import type { LanguageModelMiddleware } from '../types/language-model-middleware'; import { InvalidArgumentError } from '../error/invalid-argument-error'; import { createIdMap } from '../util/create-id-map'; import { getPotentialStartIndex } from '../util/get-potential-start-index'; /** * Extracts a delimited reasoning section from the generated text and exposes it * as a `reasoning` property on the result. * * @param tagName - An XML tag name (without angle brackets), or literal opening * and closing delimiters for formats such as Gemma's thought channel. * @param separator - The separator to use between reasoning and text sections. * @param startWithReasoning - Whether to start with reasoning tokens. */ export function extractReasoningMiddleware({ tagName, separator = '\n', startWithReasoning = false, }: { tagName: string | { opening: string; closing: string }; separator?: string; startWithReasoning?: boolean; }): LanguageModelMiddleware { const openingTag = typeof tagName === 'string' ? `<${tagName}>` : tagName.opening; const closingTag = typeof tagName === 'string' ? `</${tagName}>` : tagName.closing; if (openingTag.length === 0 || closingTag.length === 0) { throw new InvalidArgumentError({ parameter: 'tagName', value: tagName, message: 'Reasoning delimiters must not be empty.', }); } // Delimiters are literal strings, including regex metacharacters such as `|`. const escapedOpeningTag = openingTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedClosingTag = closingTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); function extractReasoning(input: string) { const text = startWithReasoning ? openingTag + input : input; const regexp = new RegExp( `${escapedOpeningTag}(.*?)${escapedClosingTag}`, 'gs', ); const matches = Array.from(text.matchAll(regexp)); if (!matches.length) { return { text: input, reasoning: undefined }; } const reasoning = matches.map(match => match[1]).join(separator); let textWithoutReasoning = text; for (let i = matches.length - 1; i >= 0; i--) { const match = matches[i]; const beforeMatch = textWithoutReasoning.slice(0, match.index); const afterMatch = textWithoutReasoning.slice( match.index! + match[0].length, ); textWithoutReasoning = beforeMatch + (beforeMatch.length > 0 && afterMatch.length > 0 ? separator : '') + afterMatch; } return { text: textWithoutReasoning, reasoning }; } return { specificationVersion: 'v4', wrapGenerate: async ({ doGenerate }) => { const { content, structuredOutputText, ...rest } = await doGenerate(); const transformedContent: LanguageModelV4Content[] = []; for (const part of content) { if (part.type !== 'text') { transformedContent.push(part); continue; } const { text, reasoning } = extractReasoning(part.text); if (reasoning === undefined) { transformedContent.push(part); continue; } transformedContent.push({ type: 'reasoning', text: reasoning, }); transformedContent.push({ type: 'text', text, }); } return { content: transformedContent, ...rest, ...(structuredOutputText != null && { structuredOutputText: extractReasoning(structuredOutputText).text, }), }; }, wrapStream: async ({ doStream }) => { const { stream, ...rest } = await doStream(); const reasoningExtractions: Record< string, { isFirstReasoning: boolean; isFirstText: boolean; afterSwitch: boolean; isReasoning: boolean; buffer: string; reasoningId: string | undefined; textId: string; } > = createIdMap(); let reasoningIdCounter = 0; const delayedTextStarts: Record< string, Extract<LanguageModelV4StreamPart, { type: 'text-start' }> > = createIdMap(); return { stream: stream.pipeThrough( new TransformStream< LanguageModelV4StreamPart, LanguageModelV4StreamPart >({ transform: (chunk, controller) => { // do not send `text-start` before `reasoning-start` // https://github.com/vercel/ai/issues/7774 if (chunk.type === 'text-start') { delayedTextStarts[chunk.id] = chunk; return; } if (chunk.type !== 'text-delta' && chunk.type !== 'text-end') { controller.enqueue(chunk); return; } if ( chunk.type === 'text-delta' && reasoningExtractions[chunk.id] == null ) { reasoningExtractions[chunk.id] = { isFirstReasoning: true, isFirstText: true, afterSwitch: false, isReasoning: startWithReasoning, buffer: '', reasoningId: undefined, textId: chunk.id, }; } const activeExtraction = reasoningExtractions[chunk.id]; if (activeExtraction == null) { if (delayedTextStarts[chunk.id] != null) { controller.enqueue(delayedTextStarts[chunk.id]); delete delayedTextStarts[chunk.id]; } controller.enqueue(chunk); return; } function getReasoningId() { return (activeExtraction.reasoningId ??= `reasoning-${reasoningIdCounter++}`); } function publish(text: string) { if (text.length > 0) { const prefix = activeExtraction.afterSwitch && (activeExtraction.isReasoning ? !activeExtraction.isFirstReasoning : !activeExtraction.isFirstText) ? separator : ''; if ( activeExtraction.isReasoning && (activeExtraction.afterSwitch || activeExtraction.isFirstReasoning) ) { controller.enqueue({ type: 'reasoning-start', id: getReasoningId(), }); } if (activeExtraction.isReasoning) { controller.enqueue({ type: 'reasoning-delta', delta: prefix + text, id: getReasoningId(), }); } else { if (delayedTextStarts[activeExtraction.textId] != null) { controller.enqueue( delayedTextStarts[activeExtraction.textId], ); delete delayedTextStarts[activeExtraction.textId]; } controller.enqueue({ type: 'text-delta', delta: prefix + text, id: activeExtraction.textId, }); } activeExtraction.afterSwitch = false; if (activeExtraction.isReasoning) { activeExtraction.isFirstReasoning = false; } else { activeExtraction.isFirstText = false; } } } if (chunk.type === 'text-end') { publish(activeExtraction.buffer); activeExtraction.buffer = ''; if (delayedTextStarts[chunk.id] != null) { controller.enqueue(delayedTextStarts[chunk.id]); delete delayedTextStarts[chunk.id]; } controller.enqueue(chunk); return; } activeExtraction.buffer += chunk.delta; do { const nextTag = activeExtraction.isReasoning ? closingTag : openingTag; const startIndex = getPotentialStartIndex( activeExtraction.buffer, nextTag, ); // no opening or closing tag found, publish the buffer if (startIndex == null) { publish(activeExtraction.buffer); activeExtraction.buffer = ''; break; } // publish text before the tag publish(activeExtraction.buffer.slice(0, startIndex)); const foundFullMatch = startIndex + nextTag.length <= activeExtraction.buffer.length; if (foundFullMatch) { activeExtraction.buffer = activeExtraction.buffer.slice( startIndex + nextTag.length, ); if (activeExtraction.isReasoning) { // Emit reasoning-start for empty reasoning blocks (no delta was published). // This handles both cases: // - startWithReasoning=false: <think></think> (afterSwitch=true) // - startWithReasoning=true: immediate </think> (afterSwitch=false) if (activeExtraction.isFirstReasoning) { controller.enqueue({ type: 'reasoning-start', id: getReasoningId(), }); } // reasoning part finished: controller.enqueue({ type: 'reasoning-end', id: getReasoningId(), }); activeExtraction.reasoningId = undefined; } activeExtraction.isReasoning = !activeExtraction.isReasoning; activeExtraction.afterSwitch = true; } else { activeExtraction.buffer = activeExtraction.buffer.slice(startIndex); break; } } while (true); }, }), ), ...rest, }; }, }; }