# -*- coding: utf-8 -*- # Kiro Gateway # https://github.com/jwadow/kiro-gateway # Copyright (C) 2025 Jwadow # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """ Thinking block parser for streaming responses. Implements a finite state machine (FSM) for reliable parsing of thinking blocks (, , , etc.) that may be split across multiple network chunks. Key features: - Tag detection ONLY at the start of response - "Cautious" sending - buffers potential tag fragments to avoid splitting tags - After closing tag - all content is treated as regular content - Support for multiple tag formats """ from enum import IntEnum from typing import Optional, List from dataclasses import dataclass, field from loguru import logger from kiro.config import ( FAKE_REASONING_HANDLING, FAKE_REASONING_OPEN_TAGS, FAKE_REASONING_INITIAL_BUFFER_SIZE, ) class ParserState(IntEnum): """ States of the thinking block parser FSM. PRE_CONTENT: Initial state, buffering to detect opening tag IN_THINKING: Inside thinking block, buffering until closing tag STREAMING: Regular streaming, no more thinking block detection """ PRE_CONTENT = 0 IN_THINKING = 1 STREAMING = 2 @dataclass class ThinkingParseResult: """ Result of processing a content chunk through the parser. Attributes: thinking_content: Content to be sent as reasoning_content (or processed per mode) regular_content: Regular content to be sent as delta.content is_first_thinking_chunk: True if this is the first chunk of thinking content is_last_thinking_chunk: True if thinking block just closed state_changed: True if parser state changed during this feed """ thinking_content: Optional[str] = None regular_content: Optional[str] = None is_first_thinking_chunk: bool = False is_last_thinking_chunk: bool = False state_changed: bool = False class ThinkingParser: """ Finite state machine parser for thinking blocks in streaming responses. The parser detects thinking tags ONLY at the start of the response. Once a thinking block is found and closed, all subsequent content is treated as regular content (even if it contains thinking tags). This implements "cautious" buffering to handle tags split across chunks: - In PRE_CONTENT: buffer until tag found or buffer exceeds limit - In IN_THINKING: buffer last MAX_TAG_LENGTH chars to avoid splitting closing tag Example: >>> parser = ThinkingParser() >>> result = parser.feed(">> result.thinking_content # None - still buffering >>> result = parser.feed("ing>Hello") >>> result.thinking_content # "Hello" (or None if buffering) >>> result = parser.feed("World") >>> result.thinking_content # remaining thinking content >>> result.regular_content # "World" """ def __init__( self, handling_mode: Optional[str] = None, open_tags: Optional[List[str]] = None, initial_buffer_size: int = FAKE_REASONING_INITIAL_BUFFER_SIZE, ): """ Initialize the thinking parser. Args: handling_mode: How to handle thinking blocks. One of: - "as_reasoning_content": Extract to reasoning_content field - "remove": Remove thinking block completely - "pass": Pass through with original tags - "strip_tags": Remove tags but keep content If None, uses FAKE_REASONING_HANDLING from config. open_tags: List of opening tags to detect. If None, uses config. initial_buffer_size: Max chars to buffer while looking for opening tag. """ self.handling_mode = handling_mode or FAKE_REASONING_HANDLING self.open_tags = open_tags or FAKE_REASONING_OPEN_TAGS self.initial_buffer_size = initial_buffer_size # Calculate max tag length for cautious buffering # We need to buffer enough to not split a closing tag self.max_tag_length = max(len(tag) for tag in self.open_tags) * 2 # State self.state = ParserState.PRE_CONTENT self.initial_buffer = "" self.thinking_buffer = "" self.open_tag: Optional[str] = None self.close_tag: Optional[str] = None self.is_first_thinking_chunk = True self._thinking_block_found = False def feed(self, content: str) -> ThinkingParseResult: """ Process a chunk of content through the parser. Args: content: New content from delta.content Returns: ThinkingParseResult with processed content """ result = ThinkingParseResult() if not content: return result # Handle based on current state if self.state == ParserState.PRE_CONTENT: result = self._handle_pre_content(content) # If state changed to IN_THINKING, process remaining content if self.state == ParserState.IN_THINKING and result.state_changed: # Content after tag is already in thinking_buffer from _handle_pre_content pass elif self.state == ParserState.IN_THINKING and not result.state_changed: result = self._handle_in_thinking(content) # If state changed to STREAMING, regular_content is already set if self.state == ParserState.STREAMING and not result.state_changed: result.regular_content = content return result def _handle_pre_content(self, content: str) -> ThinkingParseResult: """ Handle content in PRE_CONTENT state. Buffers content and looks for opening tag at the start. """ result = ThinkingParseResult() self.initial_buffer += content # Strip leading whitespace for tag detection stripped = self.initial_buffer.lstrip() # Check if buffer starts with any of the opening tags for tag in self.open_tags: if stripped.startswith(tag): # Tag found! Transition to IN_THINKING self.state = ParserState.IN_THINKING self.open_tag = tag self.close_tag = f" -> self._thinking_block_found = True result.state_changed = True logger.debug(f"Thinking tag '{tag}' detected. Transitioning to IN_THINKING.") # Content after the tag goes to thinking buffer content_after_tag = stripped[len(tag):] self.thinking_buffer = content_after_tag self.initial_buffer = "" # Now process the thinking buffer for potential closing tag thinking_result = self._process_thinking_buffer() if thinking_result.thinking_content: result.thinking_content = thinking_result.thinking_content result.is_first_thinking_chunk = thinking_result.is_first_thinking_chunk if thinking_result.is_last_thinking_chunk: result.is_last_thinking_chunk = True if thinking_result.regular_content: result.regular_content = thinking_result.regular_content return result # Check if we might still be receiving the tag # (buffer is shorter than longest tag and could be a prefix) for tag in self.open_tags: if tag.startswith(stripped) and len(stripped) < len(tag): # Could still be receiving the tag, keep buffering return result # No tag found and buffer is either: # 1. Too long (exceeds initial_buffer_size) # 2. Doesn't match any tag prefix if len(self.initial_buffer) > self.initial_buffer_size or not self._could_be_tag_prefix(stripped): # No thinking block, transition to STREAMING self.state = ParserState.STREAMING result.state_changed = True result.regular_content = self.initial_buffer self.initial_buffer = "" logger.debug("No thinking tag detected. Transitioning to STREAMING.") return result def _could_be_tag_prefix(self, text: str) -> bool: """Check if text could be the start of any opening tag.""" if not text: return True # Empty could be anything for tag in self.open_tags: if tag.startswith(text): return True return False def _handle_in_thinking(self, content: str) -> ThinkingParseResult: """ Handle content in IN_THINKING state. Buffers content and looks for closing tag. Uses "cautious" sending to avoid splitting the closing tag. """ self.thinking_buffer += content return self._process_thinking_buffer() def _process_thinking_buffer(self) -> ThinkingParseResult: """ Process the thinking buffer, looking for closing tag. Implements "cautious" sending - keeps last max_tag_length chars in buffer to avoid splitting the closing tag across chunks. """ result = ThinkingParseResult() if not self.close_tag: return result # Check for closing tag if self.close_tag in self.thinking_buffer: # Found closing tag! idx = self.thinking_buffer.find(self.close_tag) thinking_content = self.thinking_buffer[:idx] after_tag = self.thinking_buffer[idx + len(self.close_tag):] # Send all thinking content if thinking_content: result.thinking_content = thinking_content result.is_first_thinking_chunk = self.is_first_thinking_chunk self.is_first_thinking_chunk = False result.is_last_thinking_chunk = True # Transition to STREAMING self.state = ParserState.STREAMING result.state_changed = True self.thinking_buffer = "" logger.debug(f"Closing tag '{self.close_tag}' found. Transitioning to STREAMING.") # Content after closing tag is regular content # Strip leading whitespace/newlines that often follow the closing tag if after_tag: stripped_after = after_tag.lstrip() if stripped_after: result.regular_content = stripped_after return result # No closing tag yet - use "cautious" sending # Keep last max_tag_length chars in buffer to avoid splitting tag if len(self.thinking_buffer) > self.max_tag_length: send_part = self.thinking_buffer[:-self.max_tag_length] self.thinking_buffer = self.thinking_buffer[-self.max_tag_length:] result.thinking_content = send_part result.is_first_thinking_chunk = self.is_first_thinking_chunk self.is_first_thinking_chunk = False return result def finalize(self) -> ThinkingParseResult: """ Finalize parsing when stream ends. Flushes any remaining buffered content. Returns: ThinkingParseResult with any remaining content """ result = ThinkingParseResult() # Flush thinking buffer if we're still in thinking state if self.thinking_buffer: if self.state == ParserState.IN_THINKING: result.thinking_content = self.thinking_buffer result.is_first_thinking_chunk = self.is_first_thinking_chunk result.is_last_thinking_chunk = True logger.warning("Stream ended while still in thinking block. Flushing remaining content.") else: result.regular_content = self.thinking_buffer self.thinking_buffer = "" # Flush initial buffer if we never found a tag if self.initial_buffer: result.regular_content = (result.regular_content or "") + self.initial_buffer self.initial_buffer = "" return result def reset(self) -> None: """Reset parser to initial state.""" self.state = ParserState.PRE_CONTENT self.initial_buffer = "" self.thinking_buffer = "" self.open_tag = None self.close_tag = None self.is_first_thinking_chunk = True self._thinking_block_found = False @property def found_thinking_block(self) -> bool: """Returns True if a thinking block was detected in this response.""" return self._thinking_block_found def process_for_output( self, thinking_content: Optional[str], is_first: bool, is_last: bool, ) -> Optional[str]: """ Process thinking content according to handling mode. Args: thinking_content: Raw thinking content is_first: True if this is the first thinking chunk is_last: True if this is the last thinking chunk Returns: Processed content string or None (for "remove" mode) """ if not thinking_content: return None if self.handling_mode == "remove": return None if self.handling_mode == "pass": # Add tags back prefix = self.open_tag if is_first and self.open_tag else "" suffix = self.close_tag if is_last and self.close_tag else "" return f"{prefix}{thinking_content}{suffix}" if self.handling_mode == "strip_tags": # Return content without tags return thinking_content # "as_reasoning_content" - return as-is, caller will put in reasoning_content field return thinking_content