Handle NVIDIA LLM reasoning content in stream wrapper
This commit is contained in:
@@ -14,11 +14,11 @@ Refer to the NVIDIA NIM LLM API documentation for available models and usage:
|
|||||||
https://docs.api.nvidia.com/nim/reference/llm-apis
|
https://docs.api.nvidia.com/nim/reference/llm-apis
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import AsyncIterator, Optional
|
from enum import StrEnum
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from openai import AsyncStream
|
|
||||||
from openai.types.chat import ChatCompletionChunk
|
from openai.types.chat import ChatCompletionChunk
|
||||||
|
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
@@ -35,6 +35,12 @@ _THINK_OPEN = "<think>"
|
|||||||
_THINK_CLOSE = "</think>"
|
_THINK_CLOSE = "</think>"
|
||||||
|
|
||||||
|
|
||||||
|
class _ThinkTagState(StrEnum):
|
||||||
|
DETECTING = "detecting"
|
||||||
|
IN_THOUGHT = "in_thought"
|
||||||
|
CONTENT = "content"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NvidiaLLMSettings(BaseOpenAILLMService.Settings):
|
class NvidiaLLMSettings(BaseOpenAILLMService.Settings):
|
||||||
"""Settings for NvidiaLLMService."""
|
"""Settings for NvidiaLLMService."""
|
||||||
@@ -50,8 +56,9 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
|
|
||||||
- Incremental token usage reporting (NIM sends per-chunk counts instead
|
- Incremental token usage reporting (NIM sends per-chunk counts instead
|
||||||
of a final summary)
|
of a final summary)
|
||||||
- Automatic detection and filtering of reasoning tokens from models that
|
- Detection and filtering of leading ``<think>``/``</think>`` content for
|
||||||
emit ``<think>``/``</think>`` tags in content (e.g. DeepSeek-R1, some nemotron models)
|
models that emit reasoning inline before visible output (e.g.
|
||||||
|
DeepSeek-R1, some nemotron models)
|
||||||
- Extraction of ``reasoning_content`` from the streaming delta for models
|
- Extraction of ``reasoning_content`` from the streaming delta for models
|
||||||
with API-level reasoning separation (e.g. Nemotron Nano models)
|
with API-level reasoning separation (e.g. Nemotron Nano models)
|
||||||
|
|
||||||
@@ -65,7 +72,7 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
api_key: Optional[str] = None,
|
api_key: str | None = None,
|
||||||
base_url: str = "https://integrate.api.nvidia.com/v1",
|
base_url: str = "https://integrate.api.nvidia.com/v1",
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
@@ -121,7 +128,7 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
def _reset_response_state(self):
|
def _reset_response_state(self):
|
||||||
"""Reset per-response state at the start of each LLM call.
|
"""Reset per-response state at the start of each LLM call.
|
||||||
|
|
||||||
Resets token accumulation counters, thinking-tag detection state,
|
Resets token accumulation counters, leading-think-tag detection state,
|
||||||
and reasoning-content field tracking.
|
and reasoning-content field tracking.
|
||||||
"""
|
"""
|
||||||
self._prompt_tokens = 0
|
self._prompt_tokens = 0
|
||||||
@@ -130,55 +137,64 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
self._has_reported_prompt_tokens = False
|
self._has_reported_prompt_tokens = False
|
||||||
self._is_processing = True
|
self._is_processing = True
|
||||||
|
|
||||||
# <think> tag detection: "detecting" → "in_thought" | "content"
|
self._think_tag_state = _ThinkTagState.DETECTING
|
||||||
self._think_tag_state = "detecting"
|
|
||||||
self._think_tag_buffer = ""
|
self._think_tag_buffer = ""
|
||||||
|
|
||||||
# reasoning_content field tracking
|
# reasoning_content field tracking
|
||||||
self._has_reasoning_field = False
|
self._has_reasoning_field = False
|
||||||
|
|
||||||
async def _push_llm_text(self, text: str):
|
async def _filter_thinking_content(self, text: str) -> str | None:
|
||||||
"""Push LLM text, auto-detecting and filtering ``<think>`` tags.
|
"""Filter leading ``<think>`` tags from content and emit thought frames.
|
||||||
|
|
||||||
Uses a three-state machine to handle reasoning tokens in content:
|
Uses a three-state machine optimized for the common provider pattern
|
||||||
|
where a response either begins with a ``<think>`` block or contains no
|
||||||
|
think tags at all. It returns only visible content to the base OpenAI
|
||||||
|
processing loop while emitting hidden reasoning as ``LLMThought*Frame``
|
||||||
|
side effects.
|
||||||
|
|
||||||
- ``detecting``: Buffers the first few chars to check for ``<think>``.
|
- ``detecting``: Buffers the start of the stream to check for
|
||||||
- ``in_thought``: Inside a think block; emits ``LLMThoughtTextFrame``
|
``<think>``.
|
||||||
until ``</think>`` is found.
|
- ``in_thought``: Inside a leading think block; emits
|
||||||
- ``content``: Normal content; direct passthrough to base class.
|
``LLMThoughtTextFrame`` until ``</think>`` is found.
|
||||||
|
- ``content``: Normal content; passthrough.
|
||||||
|
|
||||||
Non-reasoning models transition from ``detecting`` to ``content``
|
Non-reasoning models transition from ``detecting`` to ``content``
|
||||||
on the first chunk with zero buffering overhead after that.
|
on the first chunk with zero buffering overhead after that.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text content from the LLM to push.
|
text: The text content from the LLM to filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The non-reasoning content that should continue through the base
|
||||||
|
OpenAI content path, or ``None`` if this chunk should not emit
|
||||||
|
normal content.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if self._think_tag_state == "content":
|
if self._think_tag_state == _ThinkTagState.CONTENT:
|
||||||
await super()._push_llm_text(text)
|
return text
|
||||||
return
|
|
||||||
|
|
||||||
self._think_tag_buffer += text
|
self._think_tag_buffer += text
|
||||||
|
|
||||||
if self._think_tag_state == "detecting":
|
if self._think_tag_state == _ThinkTagState.DETECTING:
|
||||||
if len(self._think_tag_buffer) < len(_THINK_OPEN):
|
if len(self._think_tag_buffer) < len(_THINK_OPEN):
|
||||||
if _THINK_OPEN.startswith(self._think_tag_buffer):
|
if _THINK_OPEN.startswith(self._think_tag_buffer):
|
||||||
return
|
return None
|
||||||
self._think_tag_state = "content"
|
self._think_tag_state = _ThinkTagState.CONTENT
|
||||||
await super()._push_llm_text(self._think_tag_buffer)
|
passthrough = self._think_tag_buffer
|
||||||
self._think_tag_buffer = ""
|
self._think_tag_buffer = ""
|
||||||
return
|
return passthrough
|
||||||
|
|
||||||
if self._think_tag_buffer.startswith(_THINK_OPEN):
|
if self._think_tag_buffer.startswith(_THINK_OPEN):
|
||||||
self._think_tag_state = "in_thought"
|
self._think_tag_state = _ThinkTagState.IN_THOUGHT
|
||||||
await self.push_frame(LLMThoughtStartFrame())
|
await self.push_frame(LLMThoughtStartFrame())
|
||||||
self._think_tag_buffer = self._think_tag_buffer[len(_THINK_OPEN) :]
|
self._think_tag_buffer = self._think_tag_buffer[len(_THINK_OPEN) :]
|
||||||
else:
|
else:
|
||||||
self._think_tag_state = "content"
|
self._think_tag_state = _ThinkTagState.CONTENT
|
||||||
await super()._push_llm_text(self._think_tag_buffer)
|
passthrough = self._think_tag_buffer
|
||||||
self._think_tag_buffer = ""
|
self._think_tag_buffer = ""
|
||||||
return
|
return passthrough
|
||||||
|
|
||||||
if self._think_tag_state == "in_thought":
|
if self._think_tag_state == _ThinkTagState.IN_THOUGHT:
|
||||||
idx = self._think_tag_buffer.find(_THINK_CLOSE)
|
idx = self._think_tag_buffer.find(_THINK_CLOSE)
|
||||||
if idx != -1:
|
if idx != -1:
|
||||||
thought = self._think_tag_buffer[:idx]
|
thought = self._think_tag_buffer[:idx]
|
||||||
@@ -187,9 +203,8 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
await self.push_frame(LLMThoughtEndFrame())
|
await self.push_frame(LLMThoughtEndFrame())
|
||||||
remainder = self._think_tag_buffer[idx + len(_THINK_CLOSE) :]
|
remainder = self._think_tag_buffer[idx + len(_THINK_CLOSE) :]
|
||||||
self._think_tag_buffer = ""
|
self._think_tag_buffer = ""
|
||||||
self._think_tag_state = "content"
|
self._think_tag_state = _ThinkTagState.CONTENT
|
||||||
if remainder:
|
return remainder or None
|
||||||
await super()._push_llm_text(remainder)
|
|
||||||
else:
|
else:
|
||||||
safe_end = len(self._think_tag_buffer) - len(_THINK_CLOSE) + 1
|
safe_end = len(self._think_tag_buffer) - len(_THINK_CLOSE) + 1
|
||||||
if safe_end > 0:
|
if safe_end > 0:
|
||||||
@@ -197,6 +212,28 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
LLMThoughtTextFrame(text=self._think_tag_buffer[:safe_end])
|
LLMThoughtTextFrame(text=self._think_tag_buffer[:safe_end])
|
||||||
)
|
)
|
||||||
self._think_tag_buffer = self._think_tag_buffer[safe_end:]
|
self._think_tag_buffer = self._think_tag_buffer[safe_end:]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _flush_reasoning_state(self):
|
||||||
|
"""Flush buffered reasoning state at normal stream completion.
|
||||||
|
|
||||||
|
Emits any buffered trailing thought text, closes open thought frames,
|
||||||
|
and forwards any buffered pre-content text that was held while deciding
|
||||||
|
whether the stream began with ``<think>``.
|
||||||
|
"""
|
||||||
|
if self._think_tag_state == _ThinkTagState.IN_THOUGHT:
|
||||||
|
if self._think_tag_buffer:
|
||||||
|
await self.push_frame(LLMThoughtTextFrame(text=self._think_tag_buffer))
|
||||||
|
await self.push_frame(LLMThoughtEndFrame())
|
||||||
|
elif self._think_tag_state == _ThinkTagState.DETECTING and self._think_tag_buffer:
|
||||||
|
await super()._push_llm_text(self._think_tag_buffer)
|
||||||
|
|
||||||
|
self._think_tag_buffer = ""
|
||||||
|
self._think_tag_state = _ThinkTagState.CONTENT
|
||||||
|
|
||||||
|
if self._has_reasoning_field:
|
||||||
|
await self.push_frame(LLMThoughtEndFrame())
|
||||||
|
self._has_reasoning_field = False
|
||||||
|
|
||||||
async def get_chat_completions(self, context: LLMContext) -> AsyncIterator[ChatCompletionChunk]:
|
async def get_chat_completions(self, context: LLMContext) -> AsyncIterator[ChatCompletionChunk]:
|
||||||
"""Wrap the chat completion stream to handle ``reasoning_content``.
|
"""Wrap the chat completion stream to handle ``reasoning_content``.
|
||||||
@@ -204,7 +241,9 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
Models with API-level reasoning separation (e.g. Nemotron Nano)
|
Models with API-level reasoning separation (e.g. Nemotron Nano)
|
||||||
include a ``reasoning_content`` field on the streaming delta. This
|
include a ``reasoning_content`` field on the streaming delta. This
|
||||||
wrapper extracts those chunks and emits them as ``LLMThought*Frame``
|
wrapper extracts those chunks and emits them as ``LLMThought*Frame``
|
||||||
objects, keeping them out of the normal content path.
|
objects. It also rewrites streamed ``delta.content`` so leading
|
||||||
|
``<think>`` sections are removed before the base OpenAI loop processes
|
||||||
|
visible content.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
context: The LLM context for the completion request.
|
context: The LLM context for the completion request.
|
||||||
@@ -218,15 +257,17 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
return self._handle_reasoning_content(stream)
|
return self._handle_reasoning_content(stream)
|
||||||
|
|
||||||
async def _handle_reasoning_content(
|
async def _handle_reasoning_content(
|
||||||
self, stream: AsyncStream[ChatCompletionChunk]
|
self, stream: AsyncIterator[ChatCompletionChunk]
|
||||||
) -> AsyncIterator[ChatCompletionChunk]:
|
) -> AsyncIterator[ChatCompletionChunk]:
|
||||||
"""Handle ``reasoning_content`` from a chat completion chunk stream.
|
"""Handle ``reasoning_content`` and leading ``<think>`` tags in a chunk stream.
|
||||||
|
|
||||||
Inspects each chunk for a ``reasoning_content`` field on the delta and
|
Inspects each chunk for a ``reasoning_content`` field on the delta and
|
||||||
emits ``LLMThoughtStartFrame`` / ``LLMThoughtTextFrame`` /
|
emits ``LLMThoughtStartFrame`` / ``LLMThoughtTextFrame`` /
|
||||||
``LLMThoughtEndFrame`` as side effects. Every chunk (including
|
``LLMThoughtEndFrame`` as side effects. It also strips ``<think>``
|
||||||
reasoning-only ones) is still yielded so the base streaming loop
|
blocks from ``delta.content`` before yielding the chunk so the base
|
||||||
can process metadata such as token usage and model name.
|
OpenAI loop only sees user-facing content. Every chunk is still yielded
|
||||||
|
so the base streaming loop can process metadata such as token usage,
|
||||||
|
model name, tool calls, and audio transcripts.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
Stream cleanup is owned by the base OpenAI processing loop
|
Stream cleanup is owned by the base OpenAI processing loop
|
||||||
@@ -237,32 +278,38 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
stream: The original chat completion stream.
|
stream: The original chat completion stream.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
All chat completion chunks, unchanged.
|
Chat completion chunks with any leading ``<think>`` content removed
|
||||||
|
from ``delta.content`` before they reach the base OpenAI loop.
|
||||||
"""
|
"""
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
if chunk.choices and len(chunk.choices) > 0 and chunk.choices[0].delta:
|
if chunk.choices and len(chunk.choices) > 0 and chunk.choices[0].delta:
|
||||||
rc = getattr(chunk.choices[0].delta, "reasoning_content", None)
|
delta = chunk.choices[0].delta
|
||||||
|
rc = getattr(delta, "reasoning_content", None)
|
||||||
if rc:
|
if rc:
|
||||||
if not self._has_reasoning_field:
|
if not self._has_reasoning_field:
|
||||||
self._has_reasoning_field = True
|
self._has_reasoning_field = True
|
||||||
await self.push_frame(LLMThoughtStartFrame())
|
await self.push_frame(LLMThoughtStartFrame())
|
||||||
await self.push_frame(LLMThoughtTextFrame(text=rc))
|
await self.push_frame(LLMThoughtTextFrame(text=rc))
|
||||||
elif self._has_reasoning_field and chunk.choices[0].delta.content:
|
elif self._has_reasoning_field and delta.content:
|
||||||
await self.push_frame(LLMThoughtEndFrame())
|
await self.push_frame(LLMThoughtEndFrame())
|
||||||
self._has_reasoning_field = False
|
self._has_reasoning_field = False
|
||||||
|
|
||||||
|
if delta.content:
|
||||||
|
delta.content = await self._filter_thinking_content(delta.content)
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
|
await self._flush_reasoning_state()
|
||||||
|
|
||||||
async def _process_context(self, context: LLMContext):
|
async def _process_context(self, context: LLMContext):
|
||||||
"""Process a context through the LLM and accumulate token usage metrics.
|
"""Process a context through the LLM and accumulate token usage metrics.
|
||||||
|
|
||||||
Delegates to the base OpenAI streaming loop while adding
|
Delegates to the base OpenAI streaming loop while adding
|
||||||
NVIDIA-specific behavior:
|
NVIDIA-specific behavior:
|
||||||
|
|
||||||
- ``reasoning_content`` is intercepted via the
|
- ``reasoning_content`` and leading ``<think>`` content are
|
||||||
``get_chat_completions`` stream wrapper and emitted as
|
intercepted via the ``get_chat_completions`` stream wrapper and
|
||||||
|
emitted as
|
||||||
``LLMThought*Frame`` objects.
|
``LLMThought*Frame`` objects.
|
||||||
- ``<think>`` tag detection is handled by the ``_push_llm_text``
|
|
||||||
override for models that embed reasoning in content.
|
|
||||||
- Incremental token counts are accumulated and reported as final
|
- Incremental token counts are accumulated and reported as final
|
||||||
totals.
|
totals.
|
||||||
|
|
||||||
@@ -276,18 +323,6 @@ class NvidiaLLMService(OpenAILLMService):
|
|||||||
# reported and _is_processing is cleared even on cancellation.
|
# reported and _is_processing is cleared even on cancellation.
|
||||||
try:
|
try:
|
||||||
await super()._process_context(context)
|
await super()._process_context(context)
|
||||||
|
|
||||||
# Flush any pending think-tag state (normal completion only;
|
|
||||||
# CancelledError skips this block).
|
|
||||||
if self._think_tag_state == "in_thought":
|
|
||||||
if self._think_tag_buffer:
|
|
||||||
await self.push_frame(LLMThoughtTextFrame(text=self._think_tag_buffer))
|
|
||||||
await self.push_frame(LLMThoughtEndFrame())
|
|
||||||
elif self._think_tag_buffer:
|
|
||||||
await super()._push_llm_text(self._think_tag_buffer)
|
|
||||||
|
|
||||||
if self._has_reasoning_field:
|
|
||||||
await self.push_frame(LLMThoughtEndFrame())
|
|
||||||
finally:
|
finally:
|
||||||
self._is_processing = False
|
self._is_processing = False
|
||||||
# Report final accumulated token usage at the end of processing
|
# Report final accumulated token usage at the end of processing
|
||||||
|
|||||||
Reference in New Issue
Block a user