""" title: Reasoning Stripper id: reasoning_stripper author: p1s4 description: Strips LLM reasoning/thinking blocks from older messages to save context tokens. Runs before Async Context Compression (prio 15) to clean stale reasoning before summarization. version: 1.0.0 license: MIT ═══════════════════════════════════════════════════════════════════════ 📌 Overview ═══════════════════════════════════════════════════════════════════════ This filter removes LLM "thinking" / "reasoning" blocks from conversation messages BEFORE they are sent to the LLM, dramatically reducing token usage in long conversations. WHY: Modern reasoning models (DeepSeek R1, Qwen3, Gemini, o1, etc.) emit large thinking/reasoning blocks before their actual answers. These blocks: • Consume 500-5000+ tokens EACH • Are useful for the immediate response but useless after a few turns • Accumulate rapidly, filling the context window with stale reasoning • Get summarized by Async Context Compression, wasting even MORE tokens PLACEMENT: Priority 5 — before Async Context Compression (prio 15). This means: 1. (optional) earlier inlet filters inject context 2. ⭐ Reasoning Stripper cleans old thoughts (prio 5) ← THIS FILTER 3. Async Context Compression summarizes already-clean context (prio 15) ═══════════════════════════════════════════════════════════════════════ 🛡️ Safety Rules ═══════════════════════════════════════════════════════════════════════ 1. NEVER strips from the last N messages (configurable, default 4) → Some models need their own recent thoughts to maintain coherence 2. NEVER strips from user messages — reasoning only appears in assistant msgs 3. NEVER strips from system messages — instructions must be preserved 4. NEVER strips from messages with tool_calls — reasoning before tool use may be part of the decision chain 5. NEVER modifies the original chat history in the database — only the in-flight messages sent to the LLM 6. Only strips KNOWN reasoning patterns — no false positives ═══════════════════════════════════════════════════════════════════════ 🔍 Supported Reasoning Patterns ═══════════════════════════════════════════════════════════════════════ 1.
...
— OpenWebUI + Gemini format 2.
...
— Alternative OWUI format 3.
...
— Another variant 4.
...
(generic) — Any OWUI details block (only when strip_all_details is enabled) 5. ... — DeepSeek R1, Qwen3, etc. 6. ... — Some Anthropic-style models 7. <|channel|>analysis<|message|>...<|channel|> — Interleaved thinking ═══════════════════════════════════════════════════════════════════════ ⚙️ Configuration ═══════════════════════════════════════════════════════════════════════ priority: Filter priority (default: 5, before ACC prio 15) enabled: Master switch (default: true) protect_recent: Number of recent messages to never strip (default: 4) strip_reasoning_details: Strip
(default: true) strip_think_tags: Strip ... and ... (default: true) strip_all_details: Strip ALL
blocks, not just reasoning ones (default: false) ⚠️ WARNING: This removes tool_calls details too! Only enable if you don't use native function calling or if ACC already handles tool output trimming. strip_channel_analysis: Strip <|channel|>analysis blocks (default: true) min_messages_before_strip: Don't strip anything until chat has this many messages (default: 6, i.e. ~3 turns) show_status: Show status notification about stripping results (default: true) debug_mode: Enable detailed logging (default: false) ═══════════════════════════════════════════════════════════════════════ 📝 Design Notes ═══════════════════════════════════════════════════════════════════════ This filter is intentionally MINIMAL and STATELESS: • No database — nothing to persist or break • No LLM calls — zero latency, zero cost • No async background tasks — runs synchronously in inlet • Pure regex-based — deterministic, no surprises • Only modifies in-flight messages — DB history is untouched The "protect_recent" mechanism is the key safety valve: - Recent thoughts help the model maintain chain-of-thought coherence - After 3-4 messages, the model no longer needs its own old thoughts - The token savings are enormous: each stripped reasoning block saves 500-5000+ tokens from being sent, and later from being summarized """ import re import logging from typing import Optional, Callable, Awaitable, Any from pydantic import BaseModel, Field logger = logging.getLogger(__name__) # ═══════════════════════════════════════════════════════════════════════════ # Regex Patterns for Reasoning Block Detection # ═══════════════════════════════════════════════════════════════════════════ # Pattern 1:
...
(OpenWebUI + Gemini) # Also matches type="thinking" and type="thought" REASONING_DETAILS_PATTERN = re.compile( r']*\btype\s*=\s*["\'](?:reasoning|thinking|thought)["\'][^>]*>.*?
', re.DOTALL | re.IGNORECASE, ) # Pattern 2: ANY
...
block (aggressive — only when strip_all_details is on) ALL_DETAILS_PATTERN = re.compile( r']*>.*?
', re.DOTALL | re.IGNORECASE, ) # Pattern 3: ... (DeepSeek R1, Qwen3, etc.) THINK_TAG_PATTERN = re.compile( r'.*?', re.DOTALL | re.IGNORECASE, ) # Pattern 4: ... (some Anthropic-style models) THINKING_TAG_PATTERN = re.compile( r'.*?', re.DOTALL | re.IGNORECASE, ) # Pattern 5: <|channel|>analysis<|message|>...<|channel|> (interleaved) CHANNEL_ANALYSIS_PATTERN = re.compile( r'<\|channel\|>analysis<\|message\|>.*?(?=<\|channel\|>|$)', re.DOTALL, ) class Filter: """ Open WebUI Filter that strips reasoning/thinking blocks from older messages in the conversation to reduce context token usage. """ class Valves(BaseModel): priority: int = Field( default=5, description=( "Filter priority. Default 5 runs before Async Context Compression (prio 15) " "so stale reasoning is removed before summarization." ), ) enabled: bool = Field( default=True, description="Master switch. Disable to skip all reasoning stripping.", ) protect_recent: int = Field( default=4, ge=0, description=( "Number of recent messages to NEVER strip reasoning from. " "Some models need their own recent thoughts for coherence. " "Default 4 protects the last ~2 turns of reasoning. " "Set to 0 to strip from all messages (aggressive)." ), ) strip_reasoning_details: bool = Field( default=True, description=( 'Strip
,
, ' 'and
blocks. ' 'These are the OpenWebUI-wrapped reasoning tokens from Gemini and similar models.' ), ) strip_think_tags: bool = Field( default=True, description=( "Strip ... and ... blocks. " "These are the raw reasoning tokens from DeepSeek R1, Qwen3, and similar models." ), ) strip_all_details: bool = Field( default=False, description=( "Strip ALL
...
blocks, not just reasoning ones. " "⚠️ WARNING: This also removes
blocks! " "Only enable if you don't use native function calling or if " "Async Context Compression already handles tool output trimming." ), ) strip_channel_analysis: bool = Field( default=True, description=( "Strip <|channel|>analysis<|message|>... blocks " "(interleaved thinking format used by some models)." ), ) min_messages_before_strip: int = Field( default=6, ge=0, description=( "Minimum number of messages in the conversation before stripping begins. " "Prevents stripping in very short chats where it provides no benefit. " "Default 6 means stripping starts after ~3 turns. Set to 0 to always strip." ), ) show_status: bool = Field( default=True, description="Show a status notification about how many reasoning blocks were stripped.", ) debug_mode: bool = Field( default=False, description="Enable detailed logging for debugging.", ) def __init__(self): self.valves = self.Valves() def _strip_reasoning_from_content(self, content: str) -> tuple[str, int]: """ Remove reasoning/thinking blocks from a single content string. Returns: (cleaned_content, blocks_removed_count) """ if not isinstance(content, str) or not content: return content, 0 original = content blocks_removed = 0 # 1. Strip
if self.valves.strip_reasoning_details and not self.valves.strip_all_details: new_content, count = REASONING_DETAILS_PATTERN.subn('', content) if count > 0: blocks_removed += count content = new_content # 2. Strip ALL
blocks (aggressive mode) if self.valves.strip_all_details: new_content, count = ALL_DETAILS_PATTERN.subn('', content) if count > 0: blocks_removed += count content = new_content # 3. Strip ... if self.valves.strip_think_tags: new_content, count = THINK_TAG_PATTERN.subn('', content) if count > 0: blocks_removed += count content = new_content # 4. Strip ... if self.valves.strip_think_tags: new_content, count = THINKING_TAG_PATTERN.subn('', content) if count > 0: blocks_removed += count content = new_content # 5. Strip <|channel|>analysis blocks if self.valves.strip_channel_analysis: new_content, count = CHANNEL_ANALYSIS_PATTERN.subn('', content) if count > 0: blocks_removed += count content = new_content # Clean up excessive whitespace left behind by removal if blocks_removed > 0: # Remove multiple consecutive blank lines (max 2 newlines) content = re.sub(r'\n{3,}', '\n\n', content) # Remove leading/trailing whitespace from the whole content content = content.strip() # If we stripped everything and content is now empty, leave a minimal marker # so the message doesn't become an empty assistant message if not content: content = "[reasoning stripped]" return content, blocks_removed def _should_protect_message(self, message: dict) -> bool: """ Return True if this message should NOT have its reasoning stripped. Protection rules: 1. Non-assistant messages (user, system, tool) are never modified 2. Messages with tool_calls are protected (reasoning before tool use matters) 3. Messages that are already summary markers are protected """ role = message.get("role", "") # Never strip from non-assistant messages if role != "assistant": return True # Never strip from messages with tool_calls # (the reasoning before a tool call may be part of the decision chain) tool_calls = message.get("tool_calls") if isinstance(tool_calls, list) and tool_calls: return True # Never strip from summary markers (from Async Context Compression) metadata = message.get("metadata", {}) if isinstance(metadata, dict) and metadata.get("is_summary"): return True return False def _extract_text_content(self, content: Any) -> str: """Extract text from string or multimodal list content.""" if isinstance(content, str): return content if isinstance(content, list): text_parts = [] for part in content: if isinstance(part, dict): text_value = part.get("text") if isinstance(text_value, str) and text_value: text_parts.append(text_value) return " ".join(text_parts) if isinstance(content, dict): text_value = content.get("text") or content.get("content", "") return str(text_value) if text_value else "" return str(content) if content is not None else "" def _strip_reasoning_from_multimodal(self, content: list) -> tuple[list, int]: """ Strip reasoning from multimodal content (list of parts). Only modifies text parts; leaves image/audio/etc. parts untouched. Returns: (cleaned_content_list, blocks_removed_count) """ total_blocks = 0 cleaned = [] for part in content: if isinstance(part, dict) and part.get("type") == "text": text = part.get("text", "") if isinstance(text, str): new_text, count = self._strip_reasoning_from_content(text) total_blocks += count cleaned.append({**part, "text": new_text}) else: cleaned.append(part) else: cleaned.append(part) return cleaned, total_blocks async def inlet( self, body: dict, __user__: Optional[dict] = None, __metadata__: Optional[dict] = None, __event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None, __event_call__: Optional[Callable[[Any], Awaitable[Any]]] = None, ) -> dict: """ Process messages before they are sent to the LLM. Strip reasoning blocks from older assistant messages. """ # Master switch check if not self.valves.enabled: return body messages = body.get("messages", []) if not messages: return body # Check minimum message count if len(messages) < self.valves.min_messages_before_strip: if self.valves.debug_mode: logger.info( f"[Reasoning Stripper] Skipping: only {len(messages)} messages " f"(min={self.valves.min_messages_before_strip})" ) return body # Calculate the cutoff index: messages before this index are eligible for stripping # The last `protect_recent` messages are always protected protect_recent = self.valves.protect_recent cutoff_index = max(0, len(messages) - protect_recent) if self.valves.debug_mode: logger.info( f"[Reasoning Stripper] Processing {len(messages)} messages, " f"cutoff_index={cutoff_index}, protect_recent={protect_recent}" ) total_blocks_stripped = 0 messages_modified = 0 chars_removed = 0 for i, message in enumerate(messages): # Skip messages in the protected zone (last N messages) if i >= cutoff_index: if self.valves.debug_mode: logger.info( f"[Reasoning Stripper] Protecting message {i} " f"(in recent zone, cutoff={cutoff_index})" ) continue # Check if this message type should be protected if self._should_protect_message(message): continue content = message.get("content") # Handle string content if isinstance(content, str): original_len = len(content) new_content, blocks = self._strip_reasoning_from_content(content) if blocks > 0: message["content"] = new_content total_blocks_stripped += blocks messages_modified += 1 chars_removed += original_len - len(new_content) if self.valves.debug_mode: logger.info( f"[Reasoning Stripper] Message {i}: stripped {blocks} " f"reasoning blocks ({original_len - len(new_content)} chars)" ) # Handle multimodal (list) content elif isinstance(content, list): original_text = self._extract_text_content(content) original_len = len(original_text) new_content, blocks = self._strip_reasoning_from_multimodal(content) if blocks > 0: message["content"] = new_content total_blocks_stripped += blocks messages_modified += 1 new_text = self._extract_text_content(new_content) chars_removed += original_len - len(new_text) if self.valves.debug_mode: logger.info( f"[Reasoning Stripper] Message {i} (multimodal): stripped " f"{blocks} reasoning blocks ({original_len - len(new_text)} chars)" ) # Emit status notification if total_blocks_stripped > 0 and self.valves.show_status and __event_emitter__: status_msg = ( f"🧹 Stripped {total_blocks_stripped} reasoning block(s) " f"from {messages_modified} message(s) " f"({chars_removed:,} chars saved)" ) await __event_emitter__( { "type": "status", "data": { "description": status_msg, "done": True, }, } ) if self.valves.debug_mode: logger.info( f"[Reasoning Stripper] Complete: {total_blocks_stripped} blocks stripped, " f"{messages_modified} messages modified, {chars_removed} chars removed" ) return body