From 314b9bf436e143363dfb24414a2afbcfad121bd2 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Tue, 7 Oct 2025 08:12:35 -0400 Subject: [PATCH 01/21] support claude 4.5 --- .gitignore | 5 ++++- functions/pipes/anthropic/main.py | 31 +++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index f720fb0..111e58e 100644 --- a/.gitignore +++ b/.gitignore @@ -185,4 +185,7 @@ cython_debug/ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files .cursorignore -.cursorindexingignore \ No newline at end of file +.cursorindexingignore + +# Mac OS +.DS_Store \ No newline at end of file diff --git a/functions/pipes/anthropic/main.py b/functions/pipes/anthropic/main.py index 44b8e8e..4102a44 100644 --- a/functions/pipes/anthropic/main.py +++ b/functions/pipes/anthropic/main.py @@ -3,7 +3,7 @@ authors: justinh-rahb, christian-taillon, jfbloom22 author_url: https://github.com/justinh-rahb funding_url: https://github.com/open-webui -version: 0.3.0 +version: 0.4.0 required_open_webui_version: 0.3.17 license: MIT """ @@ -150,7 +150,7 @@ def pipe(self, body: dict) -> Union[str, Generator, Iterator]: system_message, messages = pop_system_message(body["messages"]) processed_messages = [] - total_image_size = 0 + total_image_size = 0.0 for message in messages: processed_content = [] @@ -181,18 +181,41 @@ def pipe(self, body: dict) -> Union[str, Generator, Iterator]: {"role": message["role"], "content": processed_content} ) + # Handle sampling parameters - Claude 4.5 doesn't allow both temperature and top_p + temperature = body.get("temperature", 0.8) + top_p = body.get("top_p", 0.9) + + # If both are provided, prioritize temperature and ignore top_p + # This follows Anthropic's recommendation to use either temperature OR top_p, not both + if temperature != 0.8 and top_p != 0.9: + # Both parameters were explicitly provided, use temperature and ignore top_p + print("Warning: Both temperature and top_p provided for Claude model. Using temperature only.") + elif temperature == 0.8 and top_p == 0.9: + # Neither parameter was explicitly provided, use temperature as default + pass + elif temperature != 0.8: + # Only temperature was explicitly provided + pass + else: + # Only top_p was explicitly provided, use it instead of temperature + temperature = None + payload = { "model": body["model"][body["model"].find(".") + 1 :], "messages": processed_messages, "max_tokens": body.get("max_tokens", 4096), - "temperature": body.get("temperature", 0.8), "top_k": body.get("top_k", 40), - "top_p": body.get("top_p", 0.9), "stop_sequences": body.get("stop", []), **({"system": str(system_message)} if system_message else {}), "stream": body.get("stream", False), } + # Only add temperature or top_p, never both + if temperature is not None: + payload["temperature"] = temperature + else: + payload["top_p"] = top_p + headers = { "x-api-key": self.valves.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", From 97c3130b7f7802fc74b6381ac750a360fb5e64dd Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Tue, 7 Oct 2025 08:29:49 -0400 Subject: [PATCH 02/21] added cache control and extended thinking support --- functions/pipes/anthropic/main.py | 380 ++++++++++++++++++++++++++---- 1 file changed, 339 insertions(+), 41 deletions(-) diff --git a/functions/pipes/anthropic/main.py b/functions/pipes/anthropic/main.py index 4102a44..3619f8a 100644 --- a/functions/pipes/anthropic/main.py +++ b/functions/pipes/anthropic/main.py @@ -1,11 +1,12 @@ """ -title: Anthropic Manifold Pipe -authors: justinh-rahb, christian-taillon, jfbloom22 -author_url: https://github.com/justinh-rahb +title: Anthropic Manifold Pipe with Extended Thinking and Cache Control +authors: justinh-rahb, christian-taillon, jfbloom22, Mark Kazakov, Vincent, NIK-NUB, cache control added by Snav +author_url: https://github.com/jfbloom22 funding_url: https://github.com/open-webui -version: 0.4.0 +version: 0.5.0 required_open_webui_version: 0.3.17 license: MIT +description: An advanced manifold pipe for interacting with Anthropic's Claude models, featuring extended thinking support, cache control, beta features, and sophisticated model handling for Claude 4.5. """ import os @@ -18,18 +19,46 @@ class Pipe: + CACHE_TTL = "1h" + class Valves(BaseModel): - ANTHROPIC_API_KEY: str = Field(default="") + ANTHROPIC_API_KEY: str = Field(default="", description="Anthropic API Key") + CLAUDE_45_USE_TEMPERATURE: bool = Field( + default=True, + description="For Claude 4.5: Use temperature (True) or top_p (False). Claude 4.5 only supports one.", + ) + BETA_FEATURES: str = Field( + default="", + description="Enable Anthropic Beta Features. e.g.: context-management-2025-06-27", + ) + ENABLE_THINKING: bool = Field( + default=True, + description="Enable Claude's extended thinking capabilities (Claude 4.5 Sonnet with thinking model only)", + ) + THINKING_BUDGET: int = Field( + default=16000, + description="Maximum number of tokens Claude can use for thinking (min: 1024, max: 32000)", + ) + DISPLAY_THINKING: bool = Field( + default=True, description="Display Claude's thinking process in the chat" + ) def __init__(self): self.type = "manifold" self.id = "anthropic" self.name = "anthropic/" self.valves = self.Valves( - **{"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", "")} + **{ + "ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""), + "CLAUDE_45_USE_TEMPERATURE": True, + "BETA_FEATURES": "", + "ENABLE_THINKING": True, + "THINKING_BUDGET": 16000, + "DISPLAY_THINKING": True, + } ) self.MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB per image - + # Model cache self._model_cache: Optional[List[Dict[str, str]]] = None self._model_cache_time: float = 0 @@ -106,6 +135,82 @@ def get_anthropic_models(self) -> List[Dict[str, str]]: """ return self.get_anthropic_models_from_api() + def _attach_cache_control(self, block: dict): + """Attach cache control to a content block.""" + if not isinstance(block, dict): + return block + + # Skip block types that cannot be cached directly per Anthropic docs + if block.get("type") in {"thinking", "redacted_thinking"}: + return block + + if not block.get("type"): + block["type"] = "text" + if "text" not in block: + block["text"] = "" + + cache_control = dict(block.get("cache_control", {})) + cache_control["type"] = "ephemeral" + cache_control["ttl"] = self.CACHE_TTL + block["cache_control"] = cache_control + return block + + def _normalize_content_blocks(self, raw_content): + """Normalize content into proper block format.""" + blocks = [] + + if isinstance(raw_content, list): + items = raw_content + else: + items = [raw_content] + + for item in items: + if isinstance(item, dict) and item.get("type"): + blocks.append(dict(item)) + elif isinstance(item, dict) and "content" in item: + # Handle message-style dicts that still wrap content + blocks.extend(self._normalize_content_blocks(item["content"])) + elif item is not None: + blocks.append({"type": "text", "text": str(item)}) + + return blocks + + def _prepare_system_blocks(self, system_message): + """Prepare system message with cache control.""" + if not system_message: + return None + + # Open WebUI may hand us a raw message dict, list of blocks, or plain text + content = ( + system_message.get("content") + if isinstance(system_message, dict) and "content" in system_message + else system_message + ) + + normalized_blocks = self._normalize_content_blocks(content) + cached_blocks = [ + self._attach_cache_control(block) for block in normalized_blocks + ] + + return cached_blocks if cached_blocks else None + + def _apply_cache_control_to_last_message(self, messages): + """Apply cache control to the last user message.""" + if not messages: + return + + last_message = messages[-1] + if last_message.get("role") != "user": + return + + for block in reversed(last_message.get("content", [])): + if isinstance(block, dict) and block.get("type") not in { + "thinking", + "redacted_thinking", + }: + self._attach_cache_control(block) + break + def pipes(self) -> List[dict]: return self.get_anthropic_models() @@ -172,6 +277,20 @@ def pipe(self, body: dict) -> Union[str, Generator, Iterator]: raise ValueError( "Total size of images exceeds 100 MB limit" ) + elif item["type"] == "thinking" and "signature" in item: + # Include thinking blocks if present in the message + processed_content.append( + { + "type": "thinking", + "thinking": item["thinking"], + "signature": item["signature"], + } + ) + elif item["type"] == "redacted_thinking" and "data" in item: + # Include redacted thinking blocks if present + processed_content.append( + {"type": "redacted_thinking", "data": item["data"]} + ) else: processed_content = [ {"type": "text", "text": message.get("content", "")} @@ -181,46 +300,68 @@ def pipe(self, body: dict) -> Union[str, Generator, Iterator]: {"role": message["role"], "content": processed_content} ) - # Handle sampling parameters - Claude 4.5 doesn't allow both temperature and top_p - temperature = body.get("temperature", 0.8) - top_p = body.get("top_p", 0.9) - - # If both are provided, prioritize temperature and ignore top_p - # This follows Anthropic's recommendation to use either temperature OR top_p, not both - if temperature != 0.8 and top_p != 0.9: - # Both parameters were explicitly provided, use temperature and ignore top_p - print("Warning: Both temperature and top_p provided for Claude model. Using temperature only.") - elif temperature == 0.8 and top_p == 0.9: - # Neither parameter was explicitly provided, use temperature as default - pass - elif temperature != 0.8: - # Only temperature was explicitly provided - pass - else: - # Only top_p was explicitly provided, use it instead of temperature - temperature = None + system_blocks = self._prepare_system_blocks(system_message) + self._apply_cache_control_to_last_message(processed_messages) + + model_name = body["model"][body["model"].find(".") + 1 :] + + # Check if this is a thinking model + is_thinking_model = model_name.endswith("-think") + + # Remove the "-think" suffix for API call if present + api_model_name = ( + model_name.replace("-think", "") if is_thinking_model else model_name + ) + + # Determine if thinking will be enabled + will_enable_thinking = ( + self.valves.ENABLE_THINKING + and is_thinking_model + and "claude-sonnet-4-5" in model_name + ) payload = { - "model": body["model"][body["model"].find(".") + 1 :], + "model": api_model_name, "messages": processed_messages, "max_tokens": body.get("max_tokens", 4096), - "top_k": body.get("top_k", 40), "stop_sequences": body.get("stop", []), - **({"system": str(system_message)} if system_message else {}), "stream": body.get("stream", False), } - # Only add temperature or top_p, never both - if temperature is not None: - payload["temperature"] = temperature + if system_blocks: + payload["system"] = system_blocks + + # Only add top_k if thinking is NOT enabled + if not will_enable_thinking: + payload["top_k"] = body.get("top_k", 40) + + # Add extended thinking for Claude 4.5 Sonnet with thinking + if will_enable_thinking: + # Ensure thinking budget is within reasonable limits (1024-32000 tokens) + thinking_budget = max(1024, min(32000, self.valves.THINKING_BUDGET)) + payload["thinking"] = {"type": "enabled", "budget_tokens": thinking_budget} + + # Handle temperature/top_p settings + if api_model_name.startswith("claude-sonnet-4-5"): + if is_thinking_model: + # For thinking model, always use temperature = 1.0 + payload["temperature"] = 1.0 + elif self.valves.CLAUDE_45_USE_TEMPERATURE: + payload["temperature"] = body.get("temperature", 0.8) + else: + payload["top_p"] = body.get("top_p", 0.9) else: - payload["top_p"] = top_p + # Other Claude models support both + payload["temperature"] = body.get("temperature", 0.8) + payload["top_p"] = body.get("top_p", 0.9) headers = { "x-api-key": self.valves.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json", } + if self.valves.BETA_FEATURES: + headers["anthropic-beta"] = self.valves.BETA_FEATURES url = "https://api.anthropic.com/v1/messages" @@ -237,14 +378,26 @@ def pipe(self, body: dict) -> Union[str, Generator, Iterator]: return f"Error: {e}" def stream_response(self, url, headers, payload): + """Handle streaming response with the OpenWebUI thinking tags.""" try: with requests.post( url, headers=headers, json=payload, stream=True, timeout=(3.05, 60) ) as response: if response.status_code != 200: - raise Exception( - f"HTTP Error {response.status_code}: {response.text}" - ) + error_text = response.text + try: + error_json = response.json() + if "error" in error_json: + error_text = error_json["error"].get("message", error_text) + except: + pass + raise Exception(f"HTTP Error {response.status_code}: {error_text}") + + thinking_content = "" + is_thinking_block = False + is_text_block = False + has_yielded_thinking = False + has_yielded_think_tag = False for line in response.iter_lines(): if line: @@ -252,13 +405,124 @@ def stream_response(self, url, headers, payload): if line.startswith("data: "): try: data = json.loads(line[6:]) + + # Handle content block starts if data["type"] == "content_block_start": - yield data["content_block"]["text"] + block_type = data["content_block"].get("type", "") + + # Handle thinking block start + if block_type == "thinking": + is_thinking_block = True + # Emit thinking start tag immediately + if ( + not has_yielded_think_tag + and self.valves.DISPLAY_THINKING + ): + yield "" + has_yielded_think_tag = True + + # Handle transition to text block + elif block_type == "text": + # If we were in a thinking block, close it before starting text + if is_thinking_block and has_yielded_think_tag: + yield "" + has_yielded_thinking = True + + is_thinking_block = False + is_text_block = True + + # For text blocks, yield the initial text if any + if ( + "text" in data["content_block"] + and data["content_block"]["text"] + ): + yield data["content_block"]["text"] + + # Handle redacted thinking block + elif ( + block_type == "redacted_thinking" + and self.valves.DISPLAY_THINKING + ): + if not has_yielded_think_tag: + yield "" + has_yielded_think_tag = True + yield "[Redacted thinking content]" + + # Handle block deltas elif data["type"] == "content_block_delta": - yield data["delta"]["text"] + delta = data["delta"] + + # Stream thinking deltas with the thinking tag + if ( + delta["type"] == "thinking_delta" + and is_thinking_block + and self.valves.DISPLAY_THINKING + ): + thinking_content += delta["thinking"] + yield delta["thinking"] + + # Stream text deltas normally + elif ( + delta["type"] == "text_delta" and is_text_block + ): + yield delta["text"] + + # Handle block stops + elif data["type"] == "content_block_stop": + if is_thinking_block: + is_thinking_block = False + # Close thinking tag at the end of thinking block + if ( + has_yielded_think_tag + and not has_yielded_thinking + ): + yield "" + has_yielded_thinking = True + elif is_text_block: + is_text_block = False + + # Handle message stop elif data["type"] == "message_stop": + # Make sure thinking tag is closed if needed + if ( + has_yielded_think_tag + and not has_yielded_thinking + ): + yield "" break + + # Handle single message (non-streaming style response in stream) elif data["type"] == "message": + has_thinking = False + + # First check if there's thinking content + for content in data.get("content", []): + if ( + content["type"] == "thinking" + or content["type"] == "redacted_thinking" + ) and self.valves.DISPLAY_THINKING: + has_thinking = True + break + + # If there's thinking, handle it first + if has_thinking: + yield "" + + for content in data.get("content", []): + if ( + content["type"] == "thinking" + and self.valves.DISPLAY_THINKING + ): + yield content["thinking"] + elif ( + content["type"] == "redacted_thinking" + and self.valves.DISPLAY_THINKING + ): + yield "[Redacted thinking content]" + + yield "" + + # Then yield all text blocks for content in data.get("content", []): if content["type"] == "text": yield content["text"] @@ -280,17 +544,51 @@ def stream_response(self, url, headers, payload): yield f"Error: {e}" def non_stream_response(self, url, headers, payload): + """Handle non-streaming response from Anthropic API, including thinking blocks.""" try: response = requests.post( url, headers=headers, json=payload, timeout=(3.05, 60) ) if response.status_code != 200: - raise Exception(f"HTTP Error {response.status_code}: {response.text}") + error_text = response.text + try: + error_json = response.json() + if "error" in error_json: + error_text = error_json["error"].get("message", error_text) + except: + pass + raise Exception(f"HTTP Error {response.status_code}: {error_text}") res = response.json() - return ( - res["content"][0]["text"] if "content" in res and res["content"] else "" - ) + + if "content" not in res or not res["content"]: + return "" + + has_thinking = False + thinking_content = "" + text_content = "" + + # First organize content by type + for content_block in res["content"]: + if content_block["type"] == "thinking" and self.valves.DISPLAY_THINKING: + has_thinking = True + thinking_content += content_block["thinking"] + elif ( + content_block["type"] == "redacted_thinking" + and self.valves.DISPLAY_THINKING + ): + has_thinking = True + thinking_content += "[Redacted thinking content]" + elif content_block["type"] == "text": + text_content += content_block["text"] + + # Then construct the response with the tags + result = "" + if has_thinking: + result += f"{thinking_content}" + + result += text_content + return result except requests.exceptions.RequestException as e: print(f"Failed non-stream request: {e}") return f"Error: {e}" From 0da7ed0df4d1f861c6c8249c721c96b81af9c1c0 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Thu, 9 Oct 2025 08:27:48 -0400 Subject: [PATCH 03/21] Refactor Claude model handling - Updated valve naming for temperature settings to be more generic for Claude 4.x models. - Added a new method to determine if a model is a Claude 4.x generation model with temperature/top_p constraints. --- functions/pipes/anthropic/main.py | 41 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/functions/pipes/anthropic/main.py b/functions/pipes/anthropic/main.py index 3619f8a..64657e2 100644 --- a/functions/pipes/anthropic/main.py +++ b/functions/pipes/anthropic/main.py @@ -23,9 +23,9 @@ class Pipe: class Valves(BaseModel): ANTHROPIC_API_KEY: str = Field(default="", description="Anthropic API Key") - CLAUDE_45_USE_TEMPERATURE: bool = Field( + CLAUDE_USE_TEMPERATURE: bool = Field( default=True, - description="For Claude 4.5: Use temperature (True) or top_p (False). Claude 4.5 only supports one.", + description="For Claude 4.x models: Use temperature (True) or top_p (False). Claude 4.x models only support one parameter.", ) BETA_FEATURES: str = Field( default="", @@ -50,7 +50,7 @@ def __init__(self): self.valves = self.Valves( **{ "ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""), - "CLAUDE_45_USE_TEMPERATURE": True, + "CLAUDE_USE_TEMPERATURE": True, # Use temperature for Claude 4.x models "BETA_FEATURES": "", "ENABLE_THINKING": True, "THINKING_BUDGET": 16000, @@ -211,6 +211,30 @@ def _apply_cache_control_to_last_message(self, messages): self._attach_cache_control(block) break + def _is_claude_4x_model(self, model_name: str) -> bool: + """ + Determine if a model is a Claude 4.x generation model that has temperature/top_p constraints. + Uses a more future-proof approach than simple prefix matching. + + Args: + model_name: The model name to check + + Returns: + True if this is a Claude 4.x model with constraints + """ + # Extract base model name (remove version suffixes and dates) + import re + + # Pattern to match Claude 4.x models with various version suffixes + # Examples: claude-opus-4, claude-opus-4-20250514, claude-sonnet-4-5, claude-sonnet-4-5-20250929 + pattern = r"^claude-(opus|sonnet)-4(?:-5)?(?:-\d{8})?$" + + # Also check for any model that starts with claude- and contains -4- or -4$ (end of string) + # This covers patterns like: claude-opus-4-1-20250805, claude-sonnet-4-20250514, etc. + extended_pattern = r"^claude-.*-4(?:-\d{8})?$" + + return bool(re.match(pattern, model_name) or re.match(extended_pattern, model_name)) + def pipes(self) -> List[dict]: return self.get_anthropic_models() @@ -341,17 +365,20 @@ def pipe(self, body: dict) -> Union[str, Generator, Iterator]: thinking_budget = max(1024, min(32000, self.valves.THINKING_BUDGET)) payload["thinking"] = {"type": "enabled", "budget_tokens": thinking_budget} - # Handle temperature/top_p settings - if api_model_name.startswith("claude-sonnet-4-5"): + # Handle temperature/top_p settings based on model generation + # Claude 4.x models only support either temperature OR top_p, not both + is_claude_4x_model = self._is_claude_4x_model(api_model_name) + + if is_claude_4x_model: if is_thinking_model: # For thinking model, always use temperature = 1.0 payload["temperature"] = 1.0 - elif self.valves.CLAUDE_45_USE_TEMPERATURE: + elif self.valves.CLAUDE_USE_TEMPERATURE: payload["temperature"] = body.get("temperature", 0.8) else: payload["top_p"] = body.get("top_p", 0.9) else: - # Other Claude models support both + # Other Claude models support both temperature and top_p payload["temperature"] = body.get("temperature", 0.8) payload["top_p"] = body.get("top_p", 0.9) From 13b7770557aab091e4476d83e393c1822647e3e3 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Thu, 9 Oct 2025 08:34:01 -0400 Subject: [PATCH 04/21] Refine Claude model regex pattern --- functions/pipes/anthropic/main.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/functions/pipes/anthropic/main.py b/functions/pipes/anthropic/main.py index 64657e2..c66b9c0 100644 --- a/functions/pipes/anthropic/main.py +++ b/functions/pipes/anthropic/main.py @@ -222,18 +222,14 @@ def _is_claude_4x_model(self, model_name: str) -> bool: Returns: True if this is a Claude 4.x model with constraints """ - # Extract base model name (remove version suffixes and dates) import re # Pattern to match Claude 4.x models with various version suffixes - # Examples: claude-opus-4, claude-opus-4-20250514, claude-sonnet-4-5, claude-sonnet-4-5-20250929 - pattern = r"^claude-(opus|sonnet)-4(?:-5)?(?:-\d{8})?$" + # Examples: claude-opus-4, claude-opus-4-1-20250805, claude-sonnet-4-5, claude-sonnet-4-5-20250929 + # The pattern allows for optional sub-versions (like -1, -5) and dates + pattern = r"^claude-(opus|sonnet)-4(?:-\d+)?(?:-\d{8})?$" - # Also check for any model that starts with claude- and contains -4- or -4$ (end of string) - # This covers patterns like: claude-opus-4-1-20250805, claude-sonnet-4-20250514, etc. - extended_pattern = r"^claude-.*-4(?:-\d{8})?$" - - return bool(re.match(pattern, model_name) or re.match(extended_pattern, model_name)) + return bool(re.match(pattern, model_name)) def pipes(self) -> List[dict]: return self.get_anthropic_models() From 2eb70563a565881addc496ac5fc6d8aba716d9e3 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Thu, 9 Oct 2025 08:39:22 -0400 Subject: [PATCH 05/21] initial draft of firecrawl pipe --- functions/pipes/firecrawl/main.py | 647 ++++++++++++++++++++++++++++++ 1 file changed, 647 insertions(+) create mode 100644 functions/pipes/firecrawl/main.py diff --git a/functions/pipes/firecrawl/main.py b/functions/pipes/firecrawl/main.py new file mode 100644 index 0000000..291fbc5 --- /dev/null +++ b/functions/pipes/firecrawl/main.py @@ -0,0 +1,647 @@ +""" +title: Firecrawl Web Scraping and Search Pipe +author: Jonathan +author_url: https://github.com/jfhome +funding_url: https://github.com/open-webui +version: 1.0.0 +required_open_webui_version: 0.4.3 +license: MIT +description: Comprehensive web scraping, URL mapping, and search pipeline using Firecrawl API v1 with support for multiple output formats and advanced features. +requirements: requests +""" + +import os +import json +import requests +import re +import traceback +from typing import List, Union, Generator, Iterator, Optional, Dict, Any +from pydantic import BaseModel, Field +from logging import getLogger + +logger = getLogger(__name__) +logger.setLevel("DEBUG") + +# Request and Response Models +class ScrapeRequest(BaseModel): + url: str + formats: List[str] = Field(default_factory=lambda: ["markdown"]) + onlyMainContent: bool = True + includeTags: Optional[List[str]] = None + removeTags: Optional[List[str]] = None + waitFor: Optional[int] = None + +class ScrapeResponse(BaseModel): + success: bool + data: Dict[str, Any] + error: Optional[str] = None + +class MapRequest(BaseModel): + url: str + search: str = "" + ignoreSitemap: bool = False + sitemapOnly: bool = False + includeSubdomains: bool = False + limit: int = 1000 + +class MapResponse(BaseModel): + success: bool + links: List[str] + error: Optional[str] = None + +class SearchRequest(BaseModel): + query: str + limit: int = 10 + format: str = "markdown" + lang: str = "" + country: str = "" + timeRange: Optional[str] = None + categories: Optional[List[str]] = None + +class SearchResponse(BaseModel): + success: bool + data: List[Dict[str, Any]] + error: Optional[str] = None + +class CrawlRequest(BaseModel): + url: str + limit: int = 100 + depth: Optional[int] = None + maxPages: Optional[int] = None + allowBackwardLinks: bool = False + allowExternalLinks: bool = False + includeTags: Optional[List[str]] = None + excludeTags: Optional[List[str]] = None + ignoreSitemap: bool = False + sitemapOnly: bool = False + waitFor: Optional[int] = None + +class CrawlResponse(BaseModel): + success: bool + jobId: Optional[str] = None + status: Optional[str] = None + data: Optional[List[Dict[str, Any]]] = None + error: Optional[str] = None + +class FirecrawlClient: + def __init__(self, api_key: str, debug: bool = False): + self.api_key = api_key + self.base_url = "https://api.firecrawl.dev/v1" + self.debug = debug + + def headers(self): + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "X-Origin": "openwebui", + "X-Origin-Type": "integration", + } + + def scrape_url(self, request: ScrapeRequest) -> ScrapeResponse: + endpoint = "/scrape" + url = f"{self.base_url}{endpoint}" + headers = self.headers() + + if self.debug: + logger.debug(f"Scrape request: {json.dumps(request.model_dump(), indent=2)}") + logger.debug(f"Endpoint: {url}") + logger.debug(f"Using API key: {self.api_key[:4]}...{self.api_key[-4:] if len(self.api_key) > 8 else ''}") + + try: + payload = request.model_dump(exclude_none=True) + + response = requests.post(url, json=payload, headers=headers) + + if self.debug: + logger.debug(f"Response status code: {response.status_code}") + logger.debug(f"Response headers: {dict(response.headers)}") + + response.raise_for_status() + + response_data = response.json() + if self.debug: + logger.debug(f"Response data keys: {list(response_data.keys())}") + + return ScrapeResponse(**response_data) + except requests.exceptions.RequestException as e: + error_msg = f"Scrape request failed: {str(e)}" + if self.debug: + logger.error(error_msg) + if hasattr(e, 'response') and e.response: + logger.error(f"Response status code: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + logger.error(traceback.format_exc()) + return ScrapeResponse(success=False, data={}, error=error_msg) + + def map_urls(self, request: MapRequest) -> MapResponse: + endpoint = "/map" + url = f"{self.base_url}{endpoint}" + headers = self.headers() + + if self.debug: + logger.debug(f"Map request: {json.dumps(request.model_dump(), indent=2)}") + logger.debug(f"Endpoint: {url}") + + try: + payload = { + "url": request.url, + "search": request.search, + "ignoreSitemap": request.ignoreSitemap, + "sitemapOnly": request.sitemapOnly, + "includeSubdomains": request.includeSubdomains, + "limit": request.limit + } + + response = requests.post(url, json=payload, headers=headers) + + if self.debug: + logger.debug(f"Response status code: {response.status_code}") + + response.raise_for_status() + + response_data = response.json() + if self.debug: + logger.debug(f"Map response with {len(response_data.get('links', []))} URLs") + + return MapResponse(**response_data) + except requests.exceptions.RequestException as e: + error_msg = f"Map request failed: {str(e)}" + if self.debug: + logger.error(error_msg) + if hasattr(e, 'response') and e.response: + logger.error(f"Response status code: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + logger.error(traceback.format_exc()) + return MapResponse(success=False, links=[], error=error_msg) + + def search_web(self, request: SearchRequest) -> SearchResponse: + endpoint = "/search" + url = f"{self.base_url}{endpoint}" + headers = self.headers() + + if self.debug: + logger.debug(f"Search request: {json.dumps(request.model_dump(), indent=2)}") + logger.debug(f"Endpoint: {url}") + + try: + payload = { + "query": request.query, + "limit": request.limit, + "format": request.format + } + + # Add optional parameters if provided + if request.lang: + payload["lang"] = request.lang + if request.country: + payload["country"] = request.country + if request.timeRange: + payload["timeRange"] = request.timeRange + if request.categories: + payload["categories"] = request.categories + + response = requests.post(url, json=payload, headers=headers) + + if self.debug: + logger.debug(f"Response status code: {response.status_code}") + + response.raise_for_status() + + response_data = response.json() + if self.debug: + logger.debug(f"Search response with {len(response_data.get('data', []))} results") + + return SearchResponse(**response_data) + except requests.exceptions.RequestException as e: + error_msg = f"Search request failed: {str(e)}" + if self.debug: + logger.error(error_msg) + if hasattr(e, 'response') and e.response: + logger.error(f"Response status code: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + logger.error(traceback.format_exc()) + return SearchResponse(success=False, data=[], error=error_msg) + + def start_crawl(self, request: CrawlRequest) -> CrawlResponse: + endpoint = "/crawl" + url = f"{self.base_url}{endpoint}" + headers = self.headers() + + if self.debug: + logger.debug(f"Crawl request: {json.dumps(request.model_dump(), indent=2)}") + logger.debug(f"Endpoint: {url}") + + try: + payload = request.model_dump(exclude_none=True) + + response = requests.post(url, json=payload, headers=headers) + + if self.debug: + logger.debug(f"Response status code: {response.status_code}") + + response.raise_for_status() + + response_data = response.json() + if self.debug: + logger.debug(f"Crawl started with job ID: {response_data.get('jobId')}") + + return CrawlResponse(**response_data) + except requests.exceptions.RequestException as e: + error_msg = f"Crawl request failed: {str(e)}" + if self.debug: + logger.error(error_msg) + if hasattr(e, 'response') and e.response: + logger.error(f"Response status code: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + logger.error(traceback.format_exc()) + return CrawlResponse(success=False, error=error_msg) + + def get_crawl_status(self, job_id: str) -> CrawlResponse: + endpoint = f"/crawl/{job_id}" + url = f"{self.base_url}{endpoint}" + headers = self.headers() + + if self.debug: + logger.debug(f"Getting crawl status for job: {job_id}") + logger.debug(f"Endpoint: {url}") + + try: + response = requests.get(url, headers=headers) + + if self.debug: + logger.debug(f"Response status code: {response.status_code}") + + response.raise_for_status() + + response_data = response.json() + if self.debug: + logger.debug(f"Crawl status: {response_data.get('status')}") + + return CrawlResponse(**response_data) + except requests.exceptions.RequestException as e: + error_msg = f"Get crawl status failed: {str(e)}" + if self.debug: + logger.error(error_msg) + if hasattr(e, 'response') and e.response: + logger.error(f"Response status code: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + logger.error(traceback.format_exc()) + return CrawlResponse(success=False, error=error_msg) + + +class Pipe: + class Valves(BaseModel): + FIRECRAWL_API_KEY: str = Field(default="", description="Firecrawl API key") + DEFAULT_SCRAPE_FORMATS: str = Field(default="markdown", description="Default output formats for scraping (comma-separated: markdown,html,rawHtml,screenshot,links,json)") + DEFAULT_MAP_LIMIT: int = Field(default=1000, description="Default maximum number of URLs to map") + DEFAULT_SEARCH_LIMIT: int = Field(default=10, description="Default number of search results") + DEFAULT_CRAWL_LIMIT: int = Field(default=100, description="Default crawl limit") + IGNORE_SITEMAP: bool = Field(default=False, description="Ignore sitemap.xml when mapping URLs") + SITEMAP_ONLY: bool = Field(default=False, description="Only use sitemap.xml when mapping URLs") + INCLUDE_SUBDOMAINS: bool = Field(default=False, description="Include subdomains when mapping URLs") + ONLY_MAIN_CONTENT: bool = Field(default=True, description="Extract only main content when scraping") + DEBUG_MODE: bool = Field(default=False, description="Enable debug logging") + + def __init__(self): + self.name = "Firecrawl Web Scraping Pipeline" + self.type = "tool" # This is a tool pipe, not a manifold pipe + + # Initialize valve parameters + self.valves = self.Valves( + **{k: os.getenv(k, v.default) for k, v in self.Valves.model_fields.items()} + ) + + # Initialize client + self.client = None + + # Print valve configuration + for k, v in self.valves.model_dump().items(): + if k == "FIRECRAWL_API_KEY" and v: + logger.debug(f"{k}: {v[:4]}...{v[-4:] if len(v) > 8 else ''}") + elif v: + logger.debug(f"{k}: {v}") + else: + logger.debug(f"{k}: not set") + + async def on_startup(self): + logger.debug(f"on_startup:{self.name}") + if not self.valves.FIRECRAWL_API_KEY: + logger.warning("FIRECRAWL_API_KEY not set. Pipeline will not function correctly.") + else: + self.client = FirecrawlClient( + api_key=self.valves.FIRECRAWL_API_KEY, + debug=self.valves.DEBUG_MODE + ) + + async def on_shutdown(self): + logger.debug(f"on_shutdown:{self.name}") + + def _extract_url_from_message(self, message: str) -> Optional[str]: + """Extract URL from user message""" + url_pattern = r'https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+' + urls = re.findall(url_pattern, message) + + if self.valves.DEBUG_MODE: + logger.debug(f"Extracted URLs from message: {urls}") + + return urls[0] if urls else None + + def _extract_search_query(self, message: str) -> Optional[str]: + """Extract search query from user message""" + # Look for search commands + search_patterns = [ + r'search for "(.+?)"', + r'search "(.+?)"', + r'find "(.+?)"', + r'look for "(.+?)"', + r'scrape search "(.+?)"', + ] + + for pattern in search_patterns: + match = re.search(pattern, message, re.IGNORECASE) + if match: + query = match.group(1) + if self.valves.DEBUG_MODE: + logger.debug(f"Extracted search query: {query}") + return query + + if self.valves.DEBUG_MODE: + logger.debug("No search query found in message") + + return None + + def _determine_operation(self, message: str) -> str: + """Determine what operation to perform based on the message""" + message_lower = message.lower() + + # Check for explicit commands + if any(cmd in message_lower for cmd in ['map', 'mapping', 'urls', 'links from']): + return 'map' + elif any(cmd in message_lower for cmd in ['search', 'find', 'look for']): + return 'search' + elif any(cmd in message_lower for cmd in ['crawl', 'crawling']): + return 'crawl' + elif self._extract_url_from_message(message): + return 'scrape' + else: + return 'unknown' + + def _format_scrape_result(self, response: ScrapeResponse, formats: List[str]) -> str: + """Format scrape response for display""" + if not response.success or not response.data: + return f"❌ Scraping failed: {response.error or 'Unknown error'}" + + result = "📄 **Scraped Content**\n\n" + + # Add metadata if available + if 'metadata' in response.data: + meta = response.data['metadata'] + result += f"**Title:** {meta.get('title', 'N/A')}\n" + result += f"**URL:** {meta.get('sourceURL', 'N/A')}\n" + result += f"**Description:** {meta.get('description', 'N/A')}\n\n" + + # Add content based on requested formats + for fmt in formats: + if fmt in response.data: + content = response.data[fmt] + if fmt == 'markdown': + result += f"## Markdown Content\n\n{content}\n\n" + elif fmt == 'html': + result += f"## HTML Content\n\n```html\n{content}\n```\n\n" + elif fmt == 'rawHtml': + result += f"## Raw HTML Content\n\n```html\n{content[:1000]}...\n```\n\n" + elif fmt == 'links' and isinstance(content, list): + result += f"## Discovered Links ({len(content)})\n\n" + result += "\n".join(f"- {link}" for link in content[:20]) + if len(content) > 20: + result += f"\n... and {len(content) - 20} more links" + result += "\n\n" + elif fmt == 'json' and isinstance(content, dict): + result += f"## Structured Data\n\n```json\n{json.dumps(content, indent=2)}\n```\n\n" + + return result + + def _format_map_result(self, response: MapResponse, search_term: str = "") -> str: + """Format map response for display""" + if not response.success: + return f"❌ URL mapping failed: {response.error or 'Unknown error'}" + + result = "🗺️ **URL Mapping Results**\n\n" + + if search_term: + result += f"Found {len(response.links)} URLs containing '{search_term}'.\n\n" + else: + result += f"Found {len(response.links)} URLs.\n\n" + + if response.links: + result += "**Mapped URLs:**\n" + for i, url in enumerate(response.links[:50], 1): + result += f"{i}. {url}\n" + + if len(response.links) > 50: + result += f"\n... and {len(response.links) - 50} more URLs" + else: + result += "No URLs found." + + return result + + def _format_search_result(self, response: SearchResponse) -> str: + """Format search response for display""" + if not response.success or not response.data: + return f"❌ Search failed: {response.error or 'Unknown error'}" + + result = f"🔍 **Search Results** ({len(response.data)} found)\n\n" + + for i, item in enumerate(response.data, 1): + result += f"**{i}. {item.get('title', 'No title')}**\n" + result += f"URL: {item.get('url', 'N/A')}\n" + + if 'description' in item: + result += f"Description: {item['description']}\n" + + if 'markdown' in item and item['markdown']: + content = item['markdown'] + # Truncate content if too long + if len(content) > 500: + content = content[:500] + "..." + result += f"Content: {content}\n" + + result += "\n" + "="*50 + "\n\n" + + return result + + def pipe( + self, user_message: str, model_id: str, messages: List[dict], body: dict + ) -> Union[str, Generator, Iterator]: + """ + Process the user message and perform Firecrawl operations + """ + logger.debug(f"pipe:{__name__}") + + if self.valves.DEBUG_MODE: + logger.debug(f"User message: {user_message}") + logger.debug(f"Model ID: {model_id}") + logger.debug(f"Body: {json.dumps(body, indent=2)}") + + if body.get("title", False): + return "Firecrawl Web Scraping and Search Pipeline" + + # Check if API key is set + if not self.valves.FIRECRAWL_API_KEY: + return "❌ Error: FIRECRAWL_API_KEY not set. Please set it in your environment variables." + + if not self.client: + return "❌ Error: Firecrawl client not initialized." + + # Handle debug commands + if "debug on" in user_message.lower(): + self.valves.DEBUG_MODE = True + self.client.debug = True + return "🔧 Debug mode has been enabled. Detailed logs will now be shown." + + if "debug off" in user_message.lower(): + self.valves.DEBUG_MODE = False + self.client.debug = False + return "🔧 Debug mode has been disabled." + + if "debug status" in user_message.lower(): + status = "enabled" if self.valves.DEBUG_MODE else "disabled" + return f"🔧 Debug mode is currently {status}." + + # Determine operation + operation = self._determine_operation(user_message) + + try: + if operation == 'scrape': + # Scrape a single URL + url = self._extract_url_from_message(user_message) + if not url: + return "❌ No URL found in your message. Please provide a valid URL to scrape." + + # Parse requested formats + formats = self.valves.DEFAULT_SCRAPE_FORMATS.split(',') + formats = [fmt.strip() for fmt in formats if fmt.strip()] + + request = ScrapeRequest( + url=url, + formats=formats, + onlyMainContent=self.valves.ONLY_MAIN_CONTENT + ) + + response = self.client.scrape_url(request) + return self._format_scrape_result(response, formats) + + elif operation == 'map': + # Map URLs from a page + url = self._extract_url_from_message(user_message) + if not url: + return "❌ No URL found in your message. Please provide a valid URL to map." + + # Check for search term in the URL mapping context + search_term = "" + if 'containing' in user_message.lower() or 'with' in user_message.lower(): + # Extract search term from phrases like "containing 'term'" or "with 'term'" + search_match = re.search(r"(?:containing|with)\s+['\"](.+?)['\"]", user_message, re.IGNORECASE) + if search_match: + search_term = search_match.group(1) + + request = MapRequest( + url=url, + search=search_term, + ignoreSitemap=self.valves.IGNORE_SITEMAP, + sitemapOnly=self.valves.SITEMAP_ONLY, + includeSubdomains=self.valves.INCLUDE_SUBDOMAINS, + limit=self.valves.DEFAULT_MAP_LIMIT + ) + + response = self.client.map_urls(request) + return self._format_map_result(response, search_term) + + elif operation == 'search': + # Search the web + query = self._extract_search_query(user_message) + if not query: + return "❌ No search query found. Try: 'search for \"your query\"'" + + request = SearchRequest( + query=query, + limit=self.valves.DEFAULT_SEARCH_LIMIT, + format="markdown" + ) + + response = self.client.search_web(request) + return self._format_search_result(response) + + elif operation == 'crawl': + # Start a crawl job + url = self._extract_url_from_message(user_message) + if not url: + return "❌ No URL found in your message. Please provide a valid URL to crawl." + + request = CrawlRequest( + url=url, + limit=self.valves.DEFAULT_CRAWL_LIMIT + ) + + response = self.client.start_crawl(request) + if response.success and response.jobId: + return f"🕷️ **Crawl Started**\n\nJob ID: `{response.jobId}`\nStatus: {response.status or 'queued'}\n\nUse 'crawl status {response.jobId}' to check progress." + else: + return f"❌ Crawl failed: {response.error or 'Unknown error'}" + + elif 'crawl status' in user_message.lower(): + # Check crawl status + job_id_match = re.search(r'crawl status (\w+)', user_message, re.IGNORECASE) + if not job_id_match: + return "❌ Please provide a job ID. Usage: 'crawl status '" + + job_id = job_id_match.group(1) + response = self.client.get_crawl_status(job_id) + + if response.success: + status = response.status or 'unknown' + result = f"🕷️ **Crawl Status: {job_id}**\n\nStatus: {status}\n" + + if response.data and len(response.data) > 0: + result += f"Pages crawled: {len(response.data)}\n\n" + result += "**Recent pages:**\n" + for i, page in enumerate(response.data[-5:], 1): + result += f"{i}. {page.get('url', 'N/A')} ({page.get('status', 'unknown')})\n" + else: + result += "No pages crawled yet." + + return result + else: + return f"❌ Failed to get crawl status: {response.error or 'Unknown error'}" + + else: + # Help message + return """🤖 **Firecrawl Pipeline Help** + +I can help you with web scraping, URL mapping, and searching using Firecrawl API. + +**Commands:** +- **Scrape a page:** Just send a URL like "https://example.com" +- **Map URLs:** "map https://example.com" or "get links from https://example.com" +- **Search web:** "search for 'your query'" or "find 'machine learning'" +- **Crawl site:** "crawl https://example.com" +- **Check crawl:** "crawl status " + +**Advanced options:** +- Scrape with formats: URLs automatically detect format requests +- Map with search: "map https://example.com containing 'blog'" +- Debug: "debug on/off/status" + +**Available formats:** markdown, html, rawHtml, screenshot, links, json + +Example: "https://example.com screenshot" to get a screenshot.""" + + except Exception as e: + error_msg = f"❌ Error during operation: {str(e)}" + logger.error(error_msg) + + if self.valves.DEBUG_MODE: + logger.error(traceback.format_exc()) + return f"{error_msg}\n\n🔧 Debug traceback:\n{traceback.format_exc()}" + + return error_msg From 66fdc3c8c898f6bbdfd1ae215158f1ace7d03e3a Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Thu, 9 Oct 2025 10:58:56 -0400 Subject: [PATCH 06/21] firecrawl as a tool --- functions/pipes/firecrawl/main.py | 647 ----------- tools/README.md | 1601 ++++++++++++++++++++++++++++ tools/web_scrape_firecrawl/main.py | 410 +++++++ 3 files changed, 2011 insertions(+), 647 deletions(-) delete mode 100644 functions/pipes/firecrawl/main.py create mode 100644 tools/README.md create mode 100644 tools/web_scrape_firecrawl/main.py diff --git a/functions/pipes/firecrawl/main.py b/functions/pipes/firecrawl/main.py deleted file mode 100644 index 291fbc5..0000000 --- a/functions/pipes/firecrawl/main.py +++ /dev/null @@ -1,647 +0,0 @@ -""" -title: Firecrawl Web Scraping and Search Pipe -author: Jonathan -author_url: https://github.com/jfhome -funding_url: https://github.com/open-webui -version: 1.0.0 -required_open_webui_version: 0.4.3 -license: MIT -description: Comprehensive web scraping, URL mapping, and search pipeline using Firecrawl API v1 with support for multiple output formats and advanced features. -requirements: requests -""" - -import os -import json -import requests -import re -import traceback -from typing import List, Union, Generator, Iterator, Optional, Dict, Any -from pydantic import BaseModel, Field -from logging import getLogger - -logger = getLogger(__name__) -logger.setLevel("DEBUG") - -# Request and Response Models -class ScrapeRequest(BaseModel): - url: str - formats: List[str] = Field(default_factory=lambda: ["markdown"]) - onlyMainContent: bool = True - includeTags: Optional[List[str]] = None - removeTags: Optional[List[str]] = None - waitFor: Optional[int] = None - -class ScrapeResponse(BaseModel): - success: bool - data: Dict[str, Any] - error: Optional[str] = None - -class MapRequest(BaseModel): - url: str - search: str = "" - ignoreSitemap: bool = False - sitemapOnly: bool = False - includeSubdomains: bool = False - limit: int = 1000 - -class MapResponse(BaseModel): - success: bool - links: List[str] - error: Optional[str] = None - -class SearchRequest(BaseModel): - query: str - limit: int = 10 - format: str = "markdown" - lang: str = "" - country: str = "" - timeRange: Optional[str] = None - categories: Optional[List[str]] = None - -class SearchResponse(BaseModel): - success: bool - data: List[Dict[str, Any]] - error: Optional[str] = None - -class CrawlRequest(BaseModel): - url: str - limit: int = 100 - depth: Optional[int] = None - maxPages: Optional[int] = None - allowBackwardLinks: bool = False - allowExternalLinks: bool = False - includeTags: Optional[List[str]] = None - excludeTags: Optional[List[str]] = None - ignoreSitemap: bool = False - sitemapOnly: bool = False - waitFor: Optional[int] = None - -class CrawlResponse(BaseModel): - success: bool - jobId: Optional[str] = None - status: Optional[str] = None - data: Optional[List[Dict[str, Any]]] = None - error: Optional[str] = None - -class FirecrawlClient: - def __init__(self, api_key: str, debug: bool = False): - self.api_key = api_key - self.base_url = "https://api.firecrawl.dev/v1" - self.debug = debug - - def headers(self): - return { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - "X-Origin": "openwebui", - "X-Origin-Type": "integration", - } - - def scrape_url(self, request: ScrapeRequest) -> ScrapeResponse: - endpoint = "/scrape" - url = f"{self.base_url}{endpoint}" - headers = self.headers() - - if self.debug: - logger.debug(f"Scrape request: {json.dumps(request.model_dump(), indent=2)}") - logger.debug(f"Endpoint: {url}") - logger.debug(f"Using API key: {self.api_key[:4]}...{self.api_key[-4:] if len(self.api_key) > 8 else ''}") - - try: - payload = request.model_dump(exclude_none=True) - - response = requests.post(url, json=payload, headers=headers) - - if self.debug: - logger.debug(f"Response status code: {response.status_code}") - logger.debug(f"Response headers: {dict(response.headers)}") - - response.raise_for_status() - - response_data = response.json() - if self.debug: - logger.debug(f"Response data keys: {list(response_data.keys())}") - - return ScrapeResponse(**response_data) - except requests.exceptions.RequestException as e: - error_msg = f"Scrape request failed: {str(e)}" - if self.debug: - logger.error(error_msg) - if hasattr(e, 'response') and e.response: - logger.error(f"Response status code: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") - logger.error(traceback.format_exc()) - return ScrapeResponse(success=False, data={}, error=error_msg) - - def map_urls(self, request: MapRequest) -> MapResponse: - endpoint = "/map" - url = f"{self.base_url}{endpoint}" - headers = self.headers() - - if self.debug: - logger.debug(f"Map request: {json.dumps(request.model_dump(), indent=2)}") - logger.debug(f"Endpoint: {url}") - - try: - payload = { - "url": request.url, - "search": request.search, - "ignoreSitemap": request.ignoreSitemap, - "sitemapOnly": request.sitemapOnly, - "includeSubdomains": request.includeSubdomains, - "limit": request.limit - } - - response = requests.post(url, json=payload, headers=headers) - - if self.debug: - logger.debug(f"Response status code: {response.status_code}") - - response.raise_for_status() - - response_data = response.json() - if self.debug: - logger.debug(f"Map response with {len(response_data.get('links', []))} URLs") - - return MapResponse(**response_data) - except requests.exceptions.RequestException as e: - error_msg = f"Map request failed: {str(e)}" - if self.debug: - logger.error(error_msg) - if hasattr(e, 'response') and e.response: - logger.error(f"Response status code: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") - logger.error(traceback.format_exc()) - return MapResponse(success=False, links=[], error=error_msg) - - def search_web(self, request: SearchRequest) -> SearchResponse: - endpoint = "/search" - url = f"{self.base_url}{endpoint}" - headers = self.headers() - - if self.debug: - logger.debug(f"Search request: {json.dumps(request.model_dump(), indent=2)}") - logger.debug(f"Endpoint: {url}") - - try: - payload = { - "query": request.query, - "limit": request.limit, - "format": request.format - } - - # Add optional parameters if provided - if request.lang: - payload["lang"] = request.lang - if request.country: - payload["country"] = request.country - if request.timeRange: - payload["timeRange"] = request.timeRange - if request.categories: - payload["categories"] = request.categories - - response = requests.post(url, json=payload, headers=headers) - - if self.debug: - logger.debug(f"Response status code: {response.status_code}") - - response.raise_for_status() - - response_data = response.json() - if self.debug: - logger.debug(f"Search response with {len(response_data.get('data', []))} results") - - return SearchResponse(**response_data) - except requests.exceptions.RequestException as e: - error_msg = f"Search request failed: {str(e)}" - if self.debug: - logger.error(error_msg) - if hasattr(e, 'response') and e.response: - logger.error(f"Response status code: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") - logger.error(traceback.format_exc()) - return SearchResponse(success=False, data=[], error=error_msg) - - def start_crawl(self, request: CrawlRequest) -> CrawlResponse: - endpoint = "/crawl" - url = f"{self.base_url}{endpoint}" - headers = self.headers() - - if self.debug: - logger.debug(f"Crawl request: {json.dumps(request.model_dump(), indent=2)}") - logger.debug(f"Endpoint: {url}") - - try: - payload = request.model_dump(exclude_none=True) - - response = requests.post(url, json=payload, headers=headers) - - if self.debug: - logger.debug(f"Response status code: {response.status_code}") - - response.raise_for_status() - - response_data = response.json() - if self.debug: - logger.debug(f"Crawl started with job ID: {response_data.get('jobId')}") - - return CrawlResponse(**response_data) - except requests.exceptions.RequestException as e: - error_msg = f"Crawl request failed: {str(e)}" - if self.debug: - logger.error(error_msg) - if hasattr(e, 'response') and e.response: - logger.error(f"Response status code: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") - logger.error(traceback.format_exc()) - return CrawlResponse(success=False, error=error_msg) - - def get_crawl_status(self, job_id: str) -> CrawlResponse: - endpoint = f"/crawl/{job_id}" - url = f"{self.base_url}{endpoint}" - headers = self.headers() - - if self.debug: - logger.debug(f"Getting crawl status for job: {job_id}") - logger.debug(f"Endpoint: {url}") - - try: - response = requests.get(url, headers=headers) - - if self.debug: - logger.debug(f"Response status code: {response.status_code}") - - response.raise_for_status() - - response_data = response.json() - if self.debug: - logger.debug(f"Crawl status: {response_data.get('status')}") - - return CrawlResponse(**response_data) - except requests.exceptions.RequestException as e: - error_msg = f"Get crawl status failed: {str(e)}" - if self.debug: - logger.error(error_msg) - if hasattr(e, 'response') and e.response: - logger.error(f"Response status code: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") - logger.error(traceback.format_exc()) - return CrawlResponse(success=False, error=error_msg) - - -class Pipe: - class Valves(BaseModel): - FIRECRAWL_API_KEY: str = Field(default="", description="Firecrawl API key") - DEFAULT_SCRAPE_FORMATS: str = Field(default="markdown", description="Default output formats for scraping (comma-separated: markdown,html,rawHtml,screenshot,links,json)") - DEFAULT_MAP_LIMIT: int = Field(default=1000, description="Default maximum number of URLs to map") - DEFAULT_SEARCH_LIMIT: int = Field(default=10, description="Default number of search results") - DEFAULT_CRAWL_LIMIT: int = Field(default=100, description="Default crawl limit") - IGNORE_SITEMAP: bool = Field(default=False, description="Ignore sitemap.xml when mapping URLs") - SITEMAP_ONLY: bool = Field(default=False, description="Only use sitemap.xml when mapping URLs") - INCLUDE_SUBDOMAINS: bool = Field(default=False, description="Include subdomains when mapping URLs") - ONLY_MAIN_CONTENT: bool = Field(default=True, description="Extract only main content when scraping") - DEBUG_MODE: bool = Field(default=False, description="Enable debug logging") - - def __init__(self): - self.name = "Firecrawl Web Scraping Pipeline" - self.type = "tool" # This is a tool pipe, not a manifold pipe - - # Initialize valve parameters - self.valves = self.Valves( - **{k: os.getenv(k, v.default) for k, v in self.Valves.model_fields.items()} - ) - - # Initialize client - self.client = None - - # Print valve configuration - for k, v in self.valves.model_dump().items(): - if k == "FIRECRAWL_API_KEY" and v: - logger.debug(f"{k}: {v[:4]}...{v[-4:] if len(v) > 8 else ''}") - elif v: - logger.debug(f"{k}: {v}") - else: - logger.debug(f"{k}: not set") - - async def on_startup(self): - logger.debug(f"on_startup:{self.name}") - if not self.valves.FIRECRAWL_API_KEY: - logger.warning("FIRECRAWL_API_KEY not set. Pipeline will not function correctly.") - else: - self.client = FirecrawlClient( - api_key=self.valves.FIRECRAWL_API_KEY, - debug=self.valves.DEBUG_MODE - ) - - async def on_shutdown(self): - logger.debug(f"on_shutdown:{self.name}") - - def _extract_url_from_message(self, message: str) -> Optional[str]: - """Extract URL from user message""" - url_pattern = r'https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+' - urls = re.findall(url_pattern, message) - - if self.valves.DEBUG_MODE: - logger.debug(f"Extracted URLs from message: {urls}") - - return urls[0] if urls else None - - def _extract_search_query(self, message: str) -> Optional[str]: - """Extract search query from user message""" - # Look for search commands - search_patterns = [ - r'search for "(.+?)"', - r'search "(.+?)"', - r'find "(.+?)"', - r'look for "(.+?)"', - r'scrape search "(.+?)"', - ] - - for pattern in search_patterns: - match = re.search(pattern, message, re.IGNORECASE) - if match: - query = match.group(1) - if self.valves.DEBUG_MODE: - logger.debug(f"Extracted search query: {query}") - return query - - if self.valves.DEBUG_MODE: - logger.debug("No search query found in message") - - return None - - def _determine_operation(self, message: str) -> str: - """Determine what operation to perform based on the message""" - message_lower = message.lower() - - # Check for explicit commands - if any(cmd in message_lower for cmd in ['map', 'mapping', 'urls', 'links from']): - return 'map' - elif any(cmd in message_lower for cmd in ['search', 'find', 'look for']): - return 'search' - elif any(cmd in message_lower for cmd in ['crawl', 'crawling']): - return 'crawl' - elif self._extract_url_from_message(message): - return 'scrape' - else: - return 'unknown' - - def _format_scrape_result(self, response: ScrapeResponse, formats: List[str]) -> str: - """Format scrape response for display""" - if not response.success or not response.data: - return f"❌ Scraping failed: {response.error or 'Unknown error'}" - - result = "📄 **Scraped Content**\n\n" - - # Add metadata if available - if 'metadata' in response.data: - meta = response.data['metadata'] - result += f"**Title:** {meta.get('title', 'N/A')}\n" - result += f"**URL:** {meta.get('sourceURL', 'N/A')}\n" - result += f"**Description:** {meta.get('description', 'N/A')}\n\n" - - # Add content based on requested formats - for fmt in formats: - if fmt in response.data: - content = response.data[fmt] - if fmt == 'markdown': - result += f"## Markdown Content\n\n{content}\n\n" - elif fmt == 'html': - result += f"## HTML Content\n\n```html\n{content}\n```\n\n" - elif fmt == 'rawHtml': - result += f"## Raw HTML Content\n\n```html\n{content[:1000]}...\n```\n\n" - elif fmt == 'links' and isinstance(content, list): - result += f"## Discovered Links ({len(content)})\n\n" - result += "\n".join(f"- {link}" for link in content[:20]) - if len(content) > 20: - result += f"\n... and {len(content) - 20} more links" - result += "\n\n" - elif fmt == 'json' and isinstance(content, dict): - result += f"## Structured Data\n\n```json\n{json.dumps(content, indent=2)}\n```\n\n" - - return result - - def _format_map_result(self, response: MapResponse, search_term: str = "") -> str: - """Format map response for display""" - if not response.success: - return f"❌ URL mapping failed: {response.error or 'Unknown error'}" - - result = "🗺️ **URL Mapping Results**\n\n" - - if search_term: - result += f"Found {len(response.links)} URLs containing '{search_term}'.\n\n" - else: - result += f"Found {len(response.links)} URLs.\n\n" - - if response.links: - result += "**Mapped URLs:**\n" - for i, url in enumerate(response.links[:50], 1): - result += f"{i}. {url}\n" - - if len(response.links) > 50: - result += f"\n... and {len(response.links) - 50} more URLs" - else: - result += "No URLs found." - - return result - - def _format_search_result(self, response: SearchResponse) -> str: - """Format search response for display""" - if not response.success or not response.data: - return f"❌ Search failed: {response.error or 'Unknown error'}" - - result = f"🔍 **Search Results** ({len(response.data)} found)\n\n" - - for i, item in enumerate(response.data, 1): - result += f"**{i}. {item.get('title', 'No title')}**\n" - result += f"URL: {item.get('url', 'N/A')}\n" - - if 'description' in item: - result += f"Description: {item['description']}\n" - - if 'markdown' in item and item['markdown']: - content = item['markdown'] - # Truncate content if too long - if len(content) > 500: - content = content[:500] + "..." - result += f"Content: {content}\n" - - result += "\n" + "="*50 + "\n\n" - - return result - - def pipe( - self, user_message: str, model_id: str, messages: List[dict], body: dict - ) -> Union[str, Generator, Iterator]: - """ - Process the user message and perform Firecrawl operations - """ - logger.debug(f"pipe:{__name__}") - - if self.valves.DEBUG_MODE: - logger.debug(f"User message: {user_message}") - logger.debug(f"Model ID: {model_id}") - logger.debug(f"Body: {json.dumps(body, indent=2)}") - - if body.get("title", False): - return "Firecrawl Web Scraping and Search Pipeline" - - # Check if API key is set - if not self.valves.FIRECRAWL_API_KEY: - return "❌ Error: FIRECRAWL_API_KEY not set. Please set it in your environment variables." - - if not self.client: - return "❌ Error: Firecrawl client not initialized." - - # Handle debug commands - if "debug on" in user_message.lower(): - self.valves.DEBUG_MODE = True - self.client.debug = True - return "🔧 Debug mode has been enabled. Detailed logs will now be shown." - - if "debug off" in user_message.lower(): - self.valves.DEBUG_MODE = False - self.client.debug = False - return "🔧 Debug mode has been disabled." - - if "debug status" in user_message.lower(): - status = "enabled" if self.valves.DEBUG_MODE else "disabled" - return f"🔧 Debug mode is currently {status}." - - # Determine operation - operation = self._determine_operation(user_message) - - try: - if operation == 'scrape': - # Scrape a single URL - url = self._extract_url_from_message(user_message) - if not url: - return "❌ No URL found in your message. Please provide a valid URL to scrape." - - # Parse requested formats - formats = self.valves.DEFAULT_SCRAPE_FORMATS.split(',') - formats = [fmt.strip() for fmt in formats if fmt.strip()] - - request = ScrapeRequest( - url=url, - formats=formats, - onlyMainContent=self.valves.ONLY_MAIN_CONTENT - ) - - response = self.client.scrape_url(request) - return self._format_scrape_result(response, formats) - - elif operation == 'map': - # Map URLs from a page - url = self._extract_url_from_message(user_message) - if not url: - return "❌ No URL found in your message. Please provide a valid URL to map." - - # Check for search term in the URL mapping context - search_term = "" - if 'containing' in user_message.lower() or 'with' in user_message.lower(): - # Extract search term from phrases like "containing 'term'" or "with 'term'" - search_match = re.search(r"(?:containing|with)\s+['\"](.+?)['\"]", user_message, re.IGNORECASE) - if search_match: - search_term = search_match.group(1) - - request = MapRequest( - url=url, - search=search_term, - ignoreSitemap=self.valves.IGNORE_SITEMAP, - sitemapOnly=self.valves.SITEMAP_ONLY, - includeSubdomains=self.valves.INCLUDE_SUBDOMAINS, - limit=self.valves.DEFAULT_MAP_LIMIT - ) - - response = self.client.map_urls(request) - return self._format_map_result(response, search_term) - - elif operation == 'search': - # Search the web - query = self._extract_search_query(user_message) - if not query: - return "❌ No search query found. Try: 'search for \"your query\"'" - - request = SearchRequest( - query=query, - limit=self.valves.DEFAULT_SEARCH_LIMIT, - format="markdown" - ) - - response = self.client.search_web(request) - return self._format_search_result(response) - - elif operation == 'crawl': - # Start a crawl job - url = self._extract_url_from_message(user_message) - if not url: - return "❌ No URL found in your message. Please provide a valid URL to crawl." - - request = CrawlRequest( - url=url, - limit=self.valves.DEFAULT_CRAWL_LIMIT - ) - - response = self.client.start_crawl(request) - if response.success and response.jobId: - return f"🕷️ **Crawl Started**\n\nJob ID: `{response.jobId}`\nStatus: {response.status or 'queued'}\n\nUse 'crawl status {response.jobId}' to check progress." - else: - return f"❌ Crawl failed: {response.error or 'Unknown error'}" - - elif 'crawl status' in user_message.lower(): - # Check crawl status - job_id_match = re.search(r'crawl status (\w+)', user_message, re.IGNORECASE) - if not job_id_match: - return "❌ Please provide a job ID. Usage: 'crawl status '" - - job_id = job_id_match.group(1) - response = self.client.get_crawl_status(job_id) - - if response.success: - status = response.status or 'unknown' - result = f"🕷️ **Crawl Status: {job_id}**\n\nStatus: {status}\n" - - if response.data and len(response.data) > 0: - result += f"Pages crawled: {len(response.data)}\n\n" - result += "**Recent pages:**\n" - for i, page in enumerate(response.data[-5:], 1): - result += f"{i}. {page.get('url', 'N/A')} ({page.get('status', 'unknown')})\n" - else: - result += "No pages crawled yet." - - return result - else: - return f"❌ Failed to get crawl status: {response.error or 'Unknown error'}" - - else: - # Help message - return """🤖 **Firecrawl Pipeline Help** - -I can help you with web scraping, URL mapping, and searching using Firecrawl API. - -**Commands:** -- **Scrape a page:** Just send a URL like "https://example.com" -- **Map URLs:** "map https://example.com" or "get links from https://example.com" -- **Search web:** "search for 'your query'" or "find 'machine learning'" -- **Crawl site:** "crawl https://example.com" -- **Check crawl:** "crawl status " - -**Advanced options:** -- Scrape with formats: URLs automatically detect format requests -- Map with search: "map https://example.com containing 'blog'" -- Debug: "debug on/off/status" - -**Available formats:** markdown, html, rawHtml, screenshot, links, json - -Example: "https://example.com screenshot" to get a screenshot.""" - - except Exception as e: - error_msg = f"❌ Error during operation: {str(e)}" - logger.error(error_msg) - - if self.valves.DEBUG_MODE: - logger.error(traceback.format_exc()) - return f"{error_msg}\n\n🔧 Debug traceback:\n{traceback.format_exc()}" - - return error_msg diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..2584094 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,1601 @@ +https://docs.openwebui.com/features/plugin/tools/development + +--- +sidebar_position: 2 +title: "🛠️ Development" +--- + + + +## Writing A Custom Toolkit + +Toolkits are defined in a single Python file, with a top level docstring with metadata and a `Tools` class. + +### Example Top-Level Docstring + +```python +""" +title: String Inverse +author: Your Name +author_url: https://website.com +git_url: https://github.com/username/string-reverse.git +description: This tool calculates the inverse of a string +required_open_webui_version: 0.4.0 +requirements: langchain-openai, langgraph, ollama, langchain_ollama +version: 0.4.0 +licence: MIT +""" +``` + +### Tools Class + +Tools have to be defined as methods within a class called `Tools`, with optional subclasses called `Valves` and `UserValves`, for example: + +```python +class Tools: + def __init__(self): + """Initialize the Tool.""" + self.valves = self.Valves() + + class Valves(BaseModel): + api_key: str = Field("", description="Your API key here") + + def reverse_string(self, string: str) -> str: + """ + Reverses the input string. + :param string: The string to reverse + """ + # example usage of valves + if self.valves.api_key != "42": + return "Wrong API key" + return string[::-1] +``` + +### Type Hints +Each tool must have type hints for arguments. The types may also be nested, such as `queries_and_docs: list[tuple[str, int]]`. Those type hints are used to generate the JSON schema that is sent to the model. Tools without type hints will work with a lot less consistency. + +### Valves and UserValves - (optional, but HIGHLY encouraged) + +Valves and UserValves are used for specifying customizable settings of the Tool, you can read more on the dedicated [Valves & UserValves](../valves/index.mdx) page. + +### Optional Arguments +Below is a list of optional arguments your tools can depend on: +- `__event_emitter__`: Emit events (see following section) +- `__event_call__`: Same as event emitter but can be used for user interactions +- `__user__`: A dictionary with user information. It also contains the `UserValves` object in `__user__["valves"]`. +- `__metadata__`: Dictionary with chat metadata +- `__messages__`: List of previous messages +- `__files__`: Attached files +- `__model__`: A dictionary with model information +- `__oauth_token__`: A dictionary containing the user's valid, automatically refreshed OAuth token payload. This is the **new, recommended, and secure** way to access user tokens for making authenticated API calls. The dictionary typically contains `access_token`, `id_token`, and other provider-specific data. + +For more information about `__oauth_token__` and how to configure this token to be sent to tools, check out the OAuth section in the [environment variable docs page](https://docs.openwebui.com/getting-started/env-configuration/) and the [SSO documentation](https://docs.openwebui.com/features/auth/). + +Just add them as argument to any method of your Tool class just like `__user__` in the example above. + +#### Using the OAuth Token in a Tool + +When building tools that need to interact with external APIs on the user's behalf, you can now directly access their OAuth token. This removes the need for fragile cookie scraping and ensures the token is always valid. + +**Example:** A tool that calls an external API using the user's access token. + +```python +import httpx +from typing import Optional + +class Tools: + # ... other class setup ... + + async def get_user_profile_from_external_api(self, __oauth_token__: Optional[dict] = None) -> str: + """ + Fetches user profile data from a secure external API using their OAuth access token. + + :param __oauth_token__: Injected by Open WebUI, contains the user's token data. + """ + if not __oauth_token__ or "access_token" not in __oauth_token__: + return "Error: User is not authenticated via OAuth or token is unavailable." + + access_token = __oauth_token__["access_token"] + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get("https://api.my-service.com/v1/profile", headers=headers) + response.raise_for_status() # Raise an exception for bad status codes + return f"API Response: {response.json()}" + except httpx.HTTPStatusError as e: + return f"Error: Failed to fetch data from API. Status: {e.response.status_code}" + except Exception as e: + return f"An unexpected error occurred: {e}" +``` + +### Event Emitters + +Event Emitters are used to add additional information to the chat interface. Similarly to Filter Outlets, Event Emitters are capable of appending content to the chat. Unlike Filter Outlets, they are not capable of stripping information. Additionally, emitters can be activated at any stage during the Tool. + +**⚠️ CRITICAL: Function Calling Mode Compatibility** + +Event Emitter behavior is **significantly different** depending on your function calling mode. The function calling mode is controlled by the `function_calling` parameter: + +- **Default Mode**: Uses traditional function calling approach with wider model compatibility +- **Native Mode**: Leverages model's built-in tool-calling capabilities for reduced latency + +Before using event emitters, you must understand these critical limitations: + +- **Default Mode** (`function_calling = "default"`): Full event emitter support with all event types working as expected +- **Native Mode** (`function_calling = "native"`): **Limited event emitter support** - many event types don't work properly due to native function calling bypassing Open WebUI's custom tool processing pipeline + +**When to Use Each Mode:** +- **Use Default Mode** when you need full event emitter functionality, complex tool interactions, or real-time UI updates +- **Use Native Mode** when you need reduced latency and basic tool calling without complex UI interactions + +#### Function Calling Mode Configuration + +You can configure the function calling mode in two places: + +1. **Model Settings**: Go to Model page → Advanced Params → Function Calling (set to "Default" or "Native") +2. **Per-request basis**: Set `params.function_calling = "native"` or `"default"` in your request + +If the model seems to be unable to call the tool, make sure it is enabled (either via the Model page or via the `+` sign next to the chat input field). + +#### Complete Event Type Compatibility Matrix + +Here's the comprehensive breakdown of how each event type behaves across function calling modes: + +| Event Type | Default Mode Functionality | Native Mode Functionality | Status | +|------------|---------------------------|--------------------------|--------| +| `status` | ✅ Full support - Updates status history during tool execution | ✅ **Identical** - Tracks function execution status | **COMPATIBLE** | +| `message` | ✅ Full support - Appends incremental content during streaming | ❌ **BROKEN** - Gets overwritten by native completion snapshots | **INCOMPATIBLE** | +| `chat:completion` | ✅ Full support - Handles streaming responses and completion data | ⚠️ **LIMITED** - Carries function results but may overwrite tool updates | **PARTIALLY COMPATIBLE** | +| `chat:message:delta` | ✅ Full support - Streams delta content during execution | ❌ **BROKEN** - Content gets replaced by native function snapshots | **INCOMPATIBLE** | +| `chat:message` | ✅ Full support - Replaces entire message content cleanly | ❌ **BROKEN** - Gets overwritten by subsequent native completions | **INCOMPATIBLE** | +| `replace` | ✅ Full support - Replaces content with precise control | ❌ **BROKEN** - Replaced content gets overwritten immediately | **INCOMPATIBLE** | +| `chat:message:files` / `files` | ✅ Full support - Handles file attachments in messages | ✅ **Identical** - Processes files from function outputs | **COMPATIBLE** | +| `chat:message:error` | ✅ Full support - Displays error notifications | ✅ **Identical** - Shows function call errors | **COMPATIBLE** | +| `chat:message:follow_ups` | ✅ Full support - Shows follow-up suggestions | ✅ **Identical** - Displays function-generated follow-ups | **COMPATIBLE** | +| `chat:title` | ✅ Full support - Updates chat title dynamically | ✅ **Identical** - Updates title based on function interactions | **COMPATIBLE** | +| `chat:tags` | ✅ Full support - Modifies chat tags | ✅ **Identical** - Manages tags from function outputs | **COMPATIBLE** | +| `chat:tasks:cancel` | ✅ Full support - Cancels ongoing tasks | ✅ **Identical** - Cancels native function executions | **COMPATIBLE** | +| `citation` / `source` | ✅ Full support - Handles citations with full metadata | ✅ **Identical** - Processes function-generated citations | **COMPATIBLE** | +| `notification` | ✅ Full support - Shows toast notifications | ✅ **Identical** - Displays function execution notifications | **COMPATIBLE** | +| `confirmation` | ✅ Full support - Requests user confirmations | ✅ **Identical** - Confirms function executions | **COMPATIBLE** | +| `execute` | ✅ Full support - Executes code dynamically | ✅ **Identical** - Runs function-generated code | **COMPATIBLE** | +| `input` | ✅ Full support - Requests user input with full UI | ✅ **Identical** - Collects input for functions | **COMPATIBLE** | + +#### Why Native Mode Breaks Certain Event Types + +In **Native Mode**, the server constructs content blocks from streaming model output and repeatedly emits `"chat:completion"` events with full serialized content snapshots. The client treats these snapshots as authoritative and completely replaces message content, effectively overwriting any prior tool-emitted updates like `message`, `chat:message`, or `replace` events. + +**Technical Details:** +- `middleware.py` adds tools directly to form data for native model handling +- Streaming handler emits repeated content snapshots via `chat:completion` events +- Client's `chatCompletionEventHandler` treats snapshots as complete replacements: `message.content = content` +- This causes tool-emitted content updates to flicker and disappear + +#### Best Practices and Recommendations + +**For Tools Requiring Real-time UI Updates:** +```python +class Tools: + def __init__(self): + # Add a note about function calling mode requirements + self.description = "This tool requires Default function calling mode for full functionality" + + async def interactive_tool(self, prompt: str, __event_emitter__=None) -> str: + """ + ⚠️ This tool requires function_calling = "default" for proper event emission + """ + if not __event_emitter__: + return "Event emitter not available - ensure Default function calling mode is enabled" + + # Safe to use message events in Default mode + await __event_emitter__({ + "type": "message", + "data": {"content": "Processing step 1..."} + }) + # ... rest of tool logic +``` + +**For Tools That Must Work in Both Modes:** +```python +async def universal_tool(self, prompt: str, __event_emitter__=None, __metadata__=None) -> str: + """ + Tool designed to work in both Default and Native function calling modes + """ + # Check if we're in native mode (this is a rough heuristic) + is_native_mode = __metadata__ and __metadata__.get("params", {}).get("function_calling") == "native" + + if __event_emitter__: + if is_native_mode: + # Use only compatible event types in native mode + await __event_emitter__({ + "type": "status", + "data": {"description": "Processing in native mode...", "done": False} + }) + else: + # Full event functionality in default mode + await __event_emitter__({ + "type": "message", + "data": {"content": "Processing with full event support..."} + }) + + # ... tool logic here + + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": {"description": "Completed successfully", "done": True} + }) + + return "Tool execution completed" +``` + +#### Troubleshooting Event Emitter Issues + +**Symptoms of Native Mode Conflicts:** +- Tool-emitted messages appear briefly then disappear +- Content flickers during tool execution +- `message` or `replace` events seem to be ignored +- Status updates work but content updates don't persist + +**Solutions:** +1. **Switch to Default Mode**: Change `function_calling` from `"native"` to `"default"` in model settings +2. **Use Compatible Event Types**: Stick to `status`, `citation`, `notification`, and other compatible event types in native mode +3. **Implement Mode Detection**: Add logic to detect function calling mode and adjust event usage accordingly +4. **Consider Hybrid Approaches**: Use compatible events for core functionality and degrade gracefully + +**Debugging Your Event Emitters:** +```python +async def debug_events_tool(self, __event_emitter__=None, __metadata__=None) -> str: + """Debug tool to test event emitter functionality""" + + if not __event_emitter__: + return "No event emitter available" + + # Test various event types + test_events = [ + {"type": "status", "data": {"description": "Testing status events", "done": False}}, + {"type": "message", "data": {"content": "Testing message events (may not work in native mode)"}}, + {"type": "notification", "data": {"content": "Testing notification events"}}, + ] + + mode_info = "Unknown" + if __metadata__: + mode_info = __metadata__.get("params", {}).get("function_calling", "default") + + await __event_emitter__({ + "type": "status", + "data": {"description": f"Function calling mode: {mode_info}", "done": False} + }) + + for i, event in enumerate(test_events): + await asyncio.sleep(1) # Space out events + await __event_emitter__(event) + await __event_emitter__({ + "type": "status", + "data": {"description": f"Sent event {i+1}/{len(test_events)}", "done": False} + }) + + await __event_emitter__({ + "type": "status", + "data": {"description": "Event testing complete", "done": True} + }) + + return f"Event testing completed in {mode_info} mode. Check for missing or flickering content." +``` + +There are several specific event types with different behaviors: + +#### Status Events ✅ FULLY COMPATIBLE + +**Status events work identically in both Default and Native function calling modes.** This is the most reliable event type for providing real-time feedback during tool execution. + +Status events add live status updates to a message while it's performing steps. These can be emitted at any stage during tool execution. Status messages appear right above the message content and are essential for tools that delay the LLM response or process large amounts of information. + +**Basic Status Event Structure:** +```python +await __event_emitter__({ + "type": "status", + "data": { + "description": "Message that shows up in the chat", + "done": False, # False = still processing, True = completed + "hidden": False # False = visible, True = auto-hide when done + } +}) +``` + +**Status Event Parameters:** +- `description`: The status message text shown to users +- `done`: Boolean indicating if this status represents completion +- `hidden`: Boolean to auto-hide the status once `done: True` is set + +
+Basic Status Example + +```python +async def data_processing_tool( + self, data_file: str, __user__: dict, __event_emitter__=None + ) -> str: + """ + Processes a large data file with status updates + ✅ Works in both Default and Native function calling modes + """ + + if not __event_emitter__: + return "Processing completed (no status updates available)" + + # Step 1: Loading + await __event_emitter__({ + "type": "status", + "data": {"description": "Loading data file...", "done": False} + }) + + # Simulate loading time + await asyncio.sleep(2) + + # Step 2: Processing + await __event_emitter__({ + "type": "status", + "data": {"description": "Analyzing 10,000 records...", "done": False} + }) + + # Simulate processing time + await asyncio.sleep(3) + + # Step 3: Completion + await __event_emitter__({ + "type": "status", + "data": {"description": "Analysis complete!", "done": True, "hidden": False} + }) + + return "Data analysis completed successfully. Found 23 anomalies." +``` +
+ +
+Advanced Status with Error Handling + +```python +async def api_integration_tool( + self, endpoint: str, __event_emitter__=None + ) -> str: + """ + Integrates with external API with comprehensive status tracking + ✅ Compatible with both function calling modes + """ + + if not __event_emitter__: + return "API integration completed (no status available)" + + try: + await __event_emitter__({ + "type": "status", + "data": {"description": "Connecting to API...", "done": False} + }) + + # Simulate API connection + await asyncio.sleep(1.5) + + await __event_emitter__({ + "type": "status", + "data": {"description": "Authenticating...", "done": False} + }) + + # Simulate authentication + await asyncio.sleep(1) + + await __event_emitter__({ + "type": "status", + "data": {"description": "Fetching data...", "done": False} + }) + + # Simulate data fetching + await asyncio.sleep(2) + + # Success status + await __event_emitter__({ + "type": "status", + "data": {"description": "API integration successful", "done": True} + }) + + return "Successfully retrieved 150 records from the API" + + except Exception as e: + # Error status - always visible for debugging + await __event_emitter__({ + "type": "status", + "data": {"description": f"Error: {str(e)}", "done": True, "hidden": False} + }) + + return f"API integration failed: {str(e)}" +``` +
+ +
+Multi-Step Progress Status + +```python +async def batch_processor_tool( + self, items: list, __event_emitter__=None + ) -> str: + """ + Processes items in batches with detailed progress tracking + ✅ Works perfectly in both function calling modes + """ + + if not __event_emitter__ or not items: + return "Batch processing completed" + + total_items = len(items) + batch_size = 10 + completed = 0 + + for i in range(0, total_items, batch_size): + batch = items[i:i + batch_size] + batch_num = (i // batch_size) + 1 + total_batches = (total_items + batch_size - 1) // batch_size + + # Update status for current batch + await __event_emitter__({ + "type": "status", + "data": { + "description": f"Processing batch {batch_num}/{total_batches} ({len(batch)} items)...", + "done": False + } + }) + + # Simulate batch processing + await asyncio.sleep(1) + + completed += len(batch) + + # Progress update + progress_pct = int((completed / total_items) * 100) + await __event_emitter__({ + "type": "status", + "data": { + "description": f"Progress: {completed}/{total_items} items ({progress_pct}%)", + "done": False + } + }) + + # Final completion status + await __event_emitter__({ + "type": "status", + "data": { + "description": f"Batch processing complete! Processed {total_items} items", + "done": True + } + }) + + return f"Successfully processed {total_items} items in {total_batches} batches" +``` +
+ +#### Message Events ⚠️ DEFAULT MODE ONLY + +**🚨 CRITICAL WARNING: Message events are INCOMPATIBLE with Native function calling mode!** + +Message events (`message`, `chat:message`, `chat:message:delta`, `replace`) allow you to append or modify message content at any stage during tool execution. This enables embedding images, rendering web pages, streaming content updates, and creating rich interactive experiences. + +**However, these event types have major compatibility issues:** +- ✅ **Default Mode**: Full functionality - content persists and displays properly +- ❌ **Native Mode**: BROKEN - content gets overwritten by completion snapshots and disappears + +**Why Message Events Break in Native Mode:** +Native function calling emits repeated `chat:completion` events with full content snapshots that completely replace message content, causing any tool-emitted message updates to flicker and disappear. + +**Safe Message Event Structure (Default Mode Only):** +```python +await __event_emitter__({ + "type": "message", # Also: "chat:message", "chat:message:delta", "replace" + "data": {"content": "This content will be appended/replaced in the chat"}, + # Note: message types do NOT require a "done" condition +}) +``` + +**Message Event Types:** +- `message` / `chat:message:delta`: Appends content to existing message +- `chat:message` / `replace`: Replaces entire message content +- Both types will be overwritten in Native mode + +
+Safe Message Streaming (Default Mode) + +```python +async def streaming_content_tool( + self, query: str, __event_emitter__=None, __metadata__=None + ) -> str: + """ + Streams content updates during processing + ⚠️ REQUIRES function_calling = "default" - Will not work in Native mode! + """ + + # Check function calling mode (rough detection) + mode = "unknown" + if __metadata__: + mode = __metadata__.get("params", {}).get("function_calling", "default") + + if mode == "native": + return "❌ This tool requires Default function calling mode. Message streaming is not supported in Native mode due to content overwriting issues." + + if not __event_emitter__: + return "Event emitter not available" + + # Stream progressive content updates + content_chunks = [ + "🔍 **Phase 1: Research**\nGathering information about your query...\n\n", + "📊 **Phase 2: Analysis**\nAnalyzing gathered data patterns...\n\n", + "✨ **Phase 3: Synthesis**\nGenerating insights and recommendations...\n\n", + "📝 **Phase 4: Final Report**\nCompiling comprehensive results...\n\n" + ] + + accumulated_content = "" + + for i, chunk in enumerate(content_chunks): + accumulated_content += chunk + + # Append this chunk to the message + await __event_emitter__({ + "type": "message", + "data": {"content": chunk} + }) + + # Show progress status + await __event_emitter__({ + "type": "status", + "data": { + "description": f"Processing phase {i+1}/{len(content_chunks)}...", + "done": False + } + }) + + # Simulate processing time + await asyncio.sleep(2) + + # Final completion + await __event_emitter__({ + "type": "status", + "data": {"description": "Content streaming complete!", "done": True} + }) + + return "Content streaming completed successfully. All phases processed." +``` +
+ +
+Dynamic Content Replacement (Default Mode) + +```python +async def live_dashboard_tool( + self, __event_emitter__=None, __metadata__=None + ) -> str: + """ + Creates a live-updating dashboard using content replacement + ⚠️ ONLY WORKS in Default function calling mode + """ + + # Verify we're not in Native mode + mode = __metadata__.get("params", {}).get("function_calling", "default") if __metadata__ else "default" + + if mode == "native": + return """ +❌ **Native Mode Incompatibility** + +This dashboard tool cannot function in Native mode because: +- Content replacement events get overwritten by completion snapshots +- Live updates will flicker and disappear +- Real-time data will not persist in the interface + +**Solution:** Switch to Default function calling mode in Model Settings → Advanced Params → Function Calling = "Default" +""" + + if not __event_emitter__: + return "Dashboard created (static mode - no live updates)" + + # Create initial dashboard + initial_dashboard = """ +# 📊 Live System Dashboard + +## System Status: 🟡 Initializing... + +### Current Metrics: +- **CPU Usage**: Loading... +- **Memory**: Loading... +- **Active Users**: Loading... +- **Response Time**: Loading... + +--- +*Last Updated: Initializing...* +""" + + await __event_emitter__({ + "type": "replace", + "data": {"content": initial_dashboard} + }) + + # Simulate live data updates + updates = [ + { + "status": "🟢 Online", + "cpu": "23%", + "memory": "64%", + "users": "1,247", + "response": "145ms" + }, + { + "status": "🟢 Optimal", + "cpu": "18%", + "memory": "61%", + "users": "1,352", + "response": "132ms" + }, + { + "status": "🟡 Busy", + "cpu": "67%", + "memory": "78%", + "users": "1,891", + "response": "234ms" + } + ] + + for i, data in enumerate(updates): + await asyncio.sleep(3) # Simulate data collection delay + + updated_dashboard = f""" +# 📊 Live System Dashboard + +## System Status: {data['status']} + +### Current Metrics: +- **CPU Usage**: {data['cpu']} +- **Memory**: {data['memory']} +- **Active Users**: {data['users']} +- **Response Time**: {data['response']} + +--- +*Last Updated: {datetime.now().strftime('%H:%M:%S')}* +*Update {i+1}/{len(updates)}* +""" + + # Replace entire dashboard content + await __event_emitter__({ + "type": "replace", + "data": {"content": updated_dashboard} + }) + + # Status update + await __event_emitter__({ + "type": "status", + "data": {"description": f"Dashboard updated ({i+1}/{len(updates)})", "done": False} + }) + + await __event_emitter__({ + "type": "status", + "data": {"description": "Live dashboard monitoring complete", "done": True} + }) + + return "Dashboard monitoring session completed." +``` +
+ +
+Mode-Safe Message Tool + +```python +async def adaptive_content_tool( + self, content_type: str, __event_emitter__=None, __metadata__=None + ) -> str: + """ + Adapts behavior based on function calling mode + ✅ Provides best possible experience in both modes + """ + + # Detect function calling mode + mode = "default" # Default assumption + if __metadata__: + mode = __metadata__.get("params", {}).get("function_calling", "default") + + if not __event_emitter__: + return f"Generated {content_type} content (no real-time updates available)" + + # Mode-specific behavior + if mode == "native": + # Use only compatible events in Native mode + await __event_emitter__({ + "type": "status", + "data": {"description": f"Generating {content_type} content in Native mode...", "done": False} + }) + + await asyncio.sleep(2) + + await __event_emitter__({ + "type": "status", + "data": {"description": "Content generation complete", "done": True} + }) + + # Return content normally - no message events + return f""" +# {content_type.title()} Content + +**Mode**: Native Function Calling (Limited Event Support) + +Generated content here... This content is returned as the tool result rather than being streamed via message events. + +*Note: Live content updates are not available in Native mode due to event compatibility limitations.* +""" + + else: # Default mode + # Full message event functionality available + await __event_emitter__({ + "type": "status", + "data": {"description": "Generating content with full streaming support...", "done": False} + }) + + # Stream content progressively + progressive_content = [ + f"# {content_type.title()} Content\n\n**Mode**: Default Function Calling ✅\n\n", + "## Section 1: Introduction\nStreaming content in real-time...\n\n", + "## Section 2: Details\nAdding detailed information...\n\n", + "## Section 3: Conclusion\nFinalizing content delivery...\n\n", + "*✅ Content streaming completed successfully!*" + ] + + for i, chunk in enumerate(progressive_content): + await __event_emitter__({ + "type": "message", + "data": {"content": chunk} + }) + + await __event_emitter__({ + "type": "status", + "data": {"description": f"Streaming section {i+1}/{len(progressive_content)}...", "done": False} + }) + + await asyncio.sleep(1.5) + + await __event_emitter__({ + "type": "status", + "data": {"description": "Content streaming complete!", "done": True} + }) + + return "Content has been streamed above with full Default mode capabilities." +``` +
+ +#### Citations ✅ FULLY COMPATIBLE + +**Citation events work identically in both Default and Native function calling modes.** This event type provides source references and citations in the chat interface, allowing users to click and view source materials. + +Citations are essential for tools that retrieve information from external sources, databases, or documents. They provide transparency and allow users to verify information sources. + +**Citation Event Structure:** +```python +await __event_emitter__({ + "type": "citation", + "data": { + "document": [content], # Array of content strings + "metadata": [ # Array of metadata objects + { + "date_accessed": datetime.now().isoformat(), + "source": title, + "author": "Author Name", # Optional + "publication_date": "2024-01-01", # Optional + "url": "https://source-url.com" # Optional + } + ], + "source": {"name": title, "url": url} # Primary source info + } +}) +``` + +**Important Citation Setup:** +When implementing custom citations, you **must** disable automatic citations in your `Tools` class: + +```python +def __init__(self): + self.citation = False # REQUIRED - prevents automatic citations from overriding custom ones +``` + +**⚠️ Critical Citation Warning:** +If you set `self.citation = True` (or don't set it to `False`), automatic citations will replace any custom citations you send. Always disable automatic citations when using custom citation events. + +
+Basic Citation Example + +```python +class Tools: + def __init__(self): + self.citation = False # Disable automatic citations + + async def research_tool( + self, topic: str, __event_emitter__=None + ) -> str: + """ + Researches a topic and provides proper citations + ✅ Works identically in both Default and Native modes + """ + + if not __event_emitter__: + return "Research completed (citations not available)" + + # Simulate research findings + sources = [ + { + "title": "Advanced AI Systems", + "url": "https://example.com/ai-systems", + "content": "Artificial intelligence systems have evolved significantly...", + "author": "Dr. Jane Smith", + "date": "2024-03-15" + }, + { + "title": "Machine Learning Fundamentals", + "url": "https://example.com/ml-fundamentals", + "content": "The core principles of machine learning include...", + "author": "Prof. John Doe", + "date": "2024-02-20" + } + ] + + # Emit citations for each source + for source in sources: + await __event_emitter__({ + "type": "citation", + "data": { + "document": [source["content"]], + "metadata": [ + { + "date_accessed": datetime.now().isoformat(), + "source": source["title"], + "author": source["author"], + "publication_date": source["date"], + "url": source["url"] + } + ], + "source": { + "name": source["title"], + "url": source["url"] + } + } + }) + + return f"Research on '{topic}' completed. Found {len(sources)} relevant sources with detailed citations." +``` +
+ +
+Advanced Multi-Source Citations + +```python +async def comprehensive_analysis_tool( + self, query: str, __event_emitter__=None + ) -> str: + """ + Performs comprehensive analysis with multiple source types + ✅ Full compatibility across all function calling modes + """ + + if not __event_emitter__: + return "Analysis completed" + + # Multiple source types with rich metadata + research_sources = { + "academic": [ + { + "title": "Neural Network Architecture in Modern AI", + "authors": ["Dr. Sarah Chen", "Prof. Michael Rodriguez"], + "journal": "Journal of AI Research", + "volume": "Vol. 45, Issue 2", + "pages": "123-145", + "doi": "10.1000/182", + "date": "2024-01-15", + "content": "This comprehensive study examines the evolution of neural network architectures..." + } + ], + "web_sources": [ + { + "title": "Industry AI Implementation Trends", + "url": "https://tech-insights.com/ai-trends-2024", + "site_name": "TechInsights", + "published": "2024-03-01", + "content": "Recent industry surveys show that 78% of companies are implementing AI solutions..." + } + ], + "reports": [ + { + "title": "Global AI Market Report 2024", + "organization": "International Tech Research Institute", + "report_number": "ITRI-2024-AI-001", + "date": "2024-02-28", + "content": "The global artificial intelligence market is projected to reach $1.8 trillion by 2030..." + } + ] + } + + citation_count = 0 + + # Process academic sources + for source in research_sources["academic"]: + citation_count += 1 + await __event_emitter__({ + "type": "citation", + "data": { + "document": [source["content"]], + "metadata": [ + { + "date_accessed": datetime.now().isoformat(), + "source": source["title"], + "authors": source["authors"], + "journal": source["journal"], + "volume": source["volume"], + "pages": source["pages"], + "doi": source["doi"], + "publication_date": source["date"], + "type": "academic_journal" + } + ], + "source": { + "name": f"{source['title']} - {source['journal']}", + "url": f"https://doi.org/{source['doi']}" + } + } + }) + + # Process web sources + for source in research_sources["web_sources"]: + citation_count += 1 + await __event_emitter__({ + "type": "citation", + "data": { + "document": [source["content"]], + "metadata": [ + { + "date_accessed": datetime.now().isoformat(), + "source": source["title"], + "site_name": source["site_name"], + "publication_date": source["published"], + "url": source["url"], + "type": "web_article" + } + ], + "source": { + "name": source["title"], + "url": source["url"] + } + } + }) + + # Process reports + for source in research_sources["reports"]: + citation_count += 1 + await __event_emitter__({ + "type": "citation", + "data": { + "document": [source["content"]], + "metadata": [ + { + "date_accessed": datetime.now().isoformat(), + "source": source["title"], + "organization": source["organization"], + "report_number": source["report_number"], + "publication_date": source["date"], + "type": "research_report" + } + ], + "source": { + "name": f"{source['title']} - {source['organization']}", + "url": f"https://reports.example.com/{source['report_number']}" + } + } + }) + + return f""" +# Analysis Complete + +Comprehensive analysis of '{query}' has been completed using {citation_count} authoritative sources: + +- **{len(research_sources['academic'])}** Academic journal articles +- **{len(research_sources['web_sources'])}** Industry web sources +- **{len(research_sources['reports'])}** Research reports + +All sources have been properly cited and are available for review by clicking the citation links above. +""" +``` +
+ +
+Database Citation Tool + +```python +async def database_query_tool( + self, sql_query: str, __event_emitter__=None + ) -> str: + """ + Queries database and provides data citations + ✅ Works perfectly in both function calling modes + """ + + if not __event_emitter__: + return "Database query executed" + + # Simulate database results with citation metadata + query_results = [ + { + "record_id": "USR_001247", + "data": "John Smith, Software Engineer, joined 2023-01-15", + "table": "employees", + "last_updated": "2024-03-10T14:30:00Z", + "updated_by": "admin_user" + }, + { + "record_id": "USR_001248", + "data": "Jane Wilson, Product Manager, joined 2023-02-20", + "table": "employees", + "last_updated": "2024-03-08T09:15:00Z", + "updated_by": "hr_system" + } + ] + + # Create citations for each database record + for i, record in enumerate(query_results): + await __event_emitter__({ + "type": "citation", + "data": { + "document": [f"Database Record: {record['data']}"], + "metadata": [ + { + "date_accessed": datetime.now().isoformat(), + "source": f"Database Table: {record['table']}", + "record_id": record['record_id'], + "last_updated": record['last_updated'], + "updated_by": record['updated_by'], + "query": sql_query, + "type": "database_record" + } + ], + "source": { + "name": f"Record {record['record_id']} - {record['table']}", + "url": f"database://internal/tables/{record['table']}/{record['record_id']}" + } + } + }) + + return f""" +# Database Query Results + +Executed query: `{sql_query}` + +Retrieved **{len(query_results)}** records with complete citation metadata. Each record includes: +- Record ID and source table +- Last modification timestamp +- Update attribution +- Full audit trail + +All data sources have been properly cited for transparency and verification. +""" +``` +
+ +#### Additional Compatible Event Types ✅ + +The following event types work identically in both Default and Native function calling modes: + +**Notification Events** +```python +await __event_emitter__({ + "type": "notification", + "data": {"content": "Toast notification message"} +}) +``` + +**File Events** +```python +await __event_emitter__({ + "type": "files", # or "chat:message:files" + "data": {"files": [{"name": "report.pdf", "url": "/files/report.pdf"}]} +}) +``` + +**Follow-up Events** +```python +await __event_emitter__({ + "type": "chat:message:follow_ups", + "data": {"follow_ups": ["What about X?", "Tell me more about Y"]} +}) +``` + +**Title Update Events** +```python +await __event_emitter__({ + "type": "chat:title", + "data": {"title": "New Chat Title"} +}) +``` + +**Tag Events** +```python +await __event_emitter__({ + "type": "chat:tags", + "data": {"tags": ["research", "analysis", "completed"]} +}) +``` + +**Error Events** +```python +await __event_emitter__({ + "type": "chat:message:error", + "data": {"content": "Error message to display"} +}) +``` + +**Confirmation Events** +```python +await __event_emitter__({ + "type": "confirmation", + "data": {"message": "Are you sure you want to continue?"} +}) +``` + +**Input Request Events** +```python +await __event_emitter__({ + "type": "input", + "data": {"prompt": "Please enter additional information:"} +}) +``` + +**Code Execution Events** +```python +await __event_emitter__({ + "type": "execute", + "data": {"code": "print('Hello from tool-generated code!')"} +}) +``` + +#### Comprehensive Function Calling Mode Guide + +Choosing the right function calling mode is crucial for your tool's functionality. This guide helps you make an informed decision based on your specific requirements. + +**Mode Comparison Overview:** + +| Aspect | Default Mode | Native Mode | +|--------|-------------|-------------| +| **Latency** | Higher - processes through Open WebUI pipeline | Lower - direct model handling | +| **Event Support** | ✅ Full - all event types work perfectly | ⚠️ Limited - many event types broken | +| **Complexity** | Handles complex tool interactions well | Best for simple tool calls | +| **Compatibility** | Works with all models | Requires models with native tool calling | +| **Streaming** | Perfect for real-time updates | Poor - content gets overwritten | +| **Citations** | ✅ Full support | ✅ Full support | +| **Status Updates** | ✅ Full support | ✅ Full support | +| **Message Events** | ✅ Full support | ❌ Broken - content disappears | + +**Decision Framework:** + +1. **Do you need real-time content streaming, live updates, or dynamic message modification?** + - **Yes** → Use **Default Mode** (Native mode will break these features) + - **No** → Either mode works + +2. **Is your tool primarily for simple data retrieval or computation?** + - **Yes** → **Native Mode** is fine (lower latency) + - **No** → Consider **Default Mode** for complex interactions + +3. **Do you need maximum performance and minimal latency?** + - **Yes** → **Native Mode** (if compatible with your features) + - **No** → **Default Mode** provides more features + +4. **Are you building interactive experiences, dashboards, or multi-step workflows?** + - **Yes** → **Default Mode** required + - **No** → Either mode works + +**Recommended Usage Patterns:** + +
+🏆 Best Practices for Mode Selection + +**Choose Default Mode For:** +- Tools with progressive content updates +- Interactive dashboards or live data displays +- Multi-step workflows with visual feedback +- Complex tool chains with intermediate results +- Educational tools that show step-by-step processes +- Any tool that needs `message`, `replace`, or `chat:message` events + +**Choose Native Mode For:** +- Simple API calls or database queries +- Basic calculations or data transformations +- Tools that only need status updates and citations +- Performance-critical applications where latency matters +- Simple retrieval tools without complex UI requirements + +**Universal Compatibility Pattern:** +```python +async def mode_adaptive_tool( + self, query: str, __event_emitter__=None, __metadata__=None + ) -> str: + """ + Tool that adapts its behavior based on function calling mode + ✅ Provides optimal experience in both modes + """ + + # Detect current mode + mode = "default" + if __metadata__: + mode = __metadata__.get("params", {}).get("function_calling", "default") + + is_native_mode = (mode == "native") + + if not __event_emitter__: + return "Tool executed successfully (no event support)" + + # Always safe: status updates work in both modes + await __event_emitter__({ + "type": "status", + "data": {"description": f"Running in {mode} mode...", "done": False} + }) + + # Mode-specific logic + if is_native_mode: + # Native mode: use compatible events only + await __event_emitter__({ + "type": "status", + "data": {"description": "Processing with native efficiency...", "done": False} + }) + + # Simulate processing + await asyncio.sleep(1) + + # Return results directly - no message streaming + result = f"Query '{query}' processed successfully in Native mode." + + else: + # Default mode: full event capabilities + await __event_emitter__({ + "type": "message", + "data": {"content": f"🔍 **Processing Query**: {query}\n\n"} + }) + + await __event_emitter__({ + "type": "status", + "data": {"description": "Analyzing with full streaming...", "done": False} + }) + + await asyncio.sleep(1) + + await __event_emitter__({ + "type": "message", + "data": {"content": "📊 **Results**: Analysis complete with detailed findings.\n\n"} + }) + + result = "Query processed with full Default mode capabilities." + + # Final status (works in both modes) + await __event_emitter__({ + "type": "status", + "data": {"description": "Processing complete!", "done": True} + }) + + return result +``` +
+ +
+🔧 Debugging Event Emitter Issues + +**Common Issues and Solutions:** + +**Issue: Content appears then disappears** +- **Cause**: Using message events in Native mode +- **Solution**: Switch to Default mode or use status events instead + +**Issue: Tool seems unresponsive** +- **Cause**: Function calling not enabled for model +- **Solution**: Enable tools in Model settings or via `+` button + +**Issue: Events not firing at all** +- **Cause**: `__event_emitter__` parameter missing or None +- **Solution**: Ensure parameter is included in tool method signature + +**Issue: Citations being overwritten** +- **Cause**: `self.citation = True` (or not set to False) +- **Solution**: Set `self.citation = False` in `__init__` method + +**Diagnostic Tool:** +```python +async def event_diagnostics_tool( + self, __event_emitter__=None, __metadata__=None, __user__=None + ) -> str: + """ + Comprehensive diagnostic tool for event emitter debugging + """ + + report = ["# 🔍 Event Emitter Diagnostic Report\n"] + + # Check event emitter availability + if __event_emitter__: + report.append("✅ Event emitter is available\n") + else: + report.append("❌ Event emitter is NOT available\n") + return "".join(report) + + # Check metadata availability + if __metadata__: + mode = __metadata__.get("params", {}).get("function_calling", "default") + report.append(f"✅ Function calling mode: **{mode}**\n") + else: + report.append("⚠️ Metadata not available (mode unknown)\n") + mode = "unknown" + + # Check user context + if __user__: + report.append("✅ User context available\n") + else: + report.append("⚠️ User context not available\n") + + # Test compatible events (work in both modes) + report.append("\n## Testing Compatible Events:\n") + + try: + await __event_emitter__({ + "type": "status", + "data": {"description": "Testing status events...", "done": False} + }) + report.append("✅ Status events: WORKING\n") + except Exception as e: + report.append(f"❌ Status events: FAILED - {str(e)}\n") + + try: + await __event_emitter__({ + "type": "notification", + "data": {"content": "Test notification"} + }) + report.append("✅ Notification events: WORKING\n") + except Exception as e: + report.append(f"❌ Notification events: FAILED - {str(e)}\n") + + # Test problematic events (broken in Native mode) + report.append("\n## Testing Mode-Dependent Events:\n") + + try: + await __event_emitter__({ + "type": "message", + "data": {"content": "**Test message event** - This should appear in Default mode only\n"} + }) + report.append("✅ Message events: SENT (may disappear in Native mode)\n") + except Exception as e: + report.append(f"❌ Message events: FAILED - {str(e)}\n") + + # Final status + await __event_emitter__({ + "type": "status", + "data": {"description": "Diagnostic complete", "done": True} + }) + + # Mode-specific recommendations + report.append("\n## Recommendations:\n") + + if mode == "native": + report.append(""" +⚠️ **Native Mode Detected**: Limited event support +- ✅ Use: status, citation, notification, files events +- ❌ Avoid: message, replace, chat:message events +- 💡 Switch to Default mode for full functionality +""") + elif mode == "default": + report.append(""" +✅ **Default Mode Detected**: Full event support available +- All event types should work perfectly +- Optimal for interactive and streaming tools +""") + else: + report.append(""" +❓ **Unknown Mode**: Check your model configuration +- Ensure function calling is enabled +- Verify model supports tool calling +""") + + return "".join(report) +``` +
+ +
+📚 Event Emitter Quick Reference + +**Always Compatible (Both Modes):** +```python +# Status updates - perfect for progress tracking +await __event_emitter__({ + "type": "status", + "data": {"description": "Processing...", "done": False} +}) + +# Citations - essential for source attribution +await __event_emitter__({ + "type": "citation", + "data": { + "document": ["Content"], + "source": {"name": "Source", "url": "https://example.com"} + } +}) + +# Notifications - user alerts +await __event_emitter__({ + "type": "notification", + "data": {"content": "Task completed!"} +}) +``` + +**Default Mode Only (Broken in Native):** +```python +# ⚠️ These will flicker/disappear in Native mode + +# Progressive content streaming +await __event_emitter__({ + "type": "message", + "data": {"content": "Streaming content..."} +}) + +# Content replacement +await __event_emitter__({ + "type": "replace", + "data": {"content": "New complete content"} +}) + +# Delta updates +await __event_emitter__({ + "type": "chat:message:delta", + "data": {"content": "Additional content"} +}) +``` + +**Mode Detection Pattern:** +```python +def get_function_calling_mode(__metadata__): + """Utility to detect current function calling mode""" + if not __metadata__: + return "unknown" + return __metadata__.get("params", {}).get("function_calling", "default") + +# Usage in tools: +mode = get_function_calling_mode(__metadata__) +is_native = (mode == "native") +can_stream_messages = not is_native +``` + +**Essential Imports:** +```python +import asyncio +from datetime import datetime +from typing import Optional, Callable, Awaitable +``` +
+ +### Rich UI Element Embedding + +Both External and Built-In Tools now support rich UI element embedding, allowing tools to return HTML content and interactive iframes that display directly within chat conversations. This feature enables tools to provide sophisticated visual interfaces, interactive widgets, charts, dashboards, and other rich web content. + +When a tool returns an `HTMLResponse` with the appropriate headers, the content will be embedded as an interactive iframe in the chat interface rather than displayed as plain text. + +#### Basic Usage + +To embed HTML content, your tool should return an `HTMLResponse` with the `Content-Disposition: inline` header: + +```python +from fastapi.responses import HTMLResponse + +def create_visualization_tool(self, data: str) -> HTMLResponse: + """ + Creates an interactive data visualization that embeds in the chat. + + :param data: The data to visualize + """ + html_content = """ + + + + Data Visualization + + + +
+ + + + """ + + headers = {"Content-Disposition": "inline"} + return HTMLResponse(content=html_content, headers=headers) +``` + +#### Advanced Features + +The embedded iframes support auto-resizing and include configurable security settings. The system automatically handles: + +- **Auto-resizing**: Embedded content automatically adjusts height based on its content +- **Cross-origin communication**: Safe message passing between the iframe and parent window +- **Security sandbox**: Configurable security restrictions for embedded content + +#### Security Considerations + +When embedding external content, several security options can be configured through the UI settings: + +- `iframeSandboxAllowForms`: Allow form submissions within embedded content +- `iframeSandboxAllowSameOrigin`: Allow same-origin requests (use with caution) +- `iframeSandboxAllowPopups`: Allow popup windows from embedded content + +#### Use Cases + +Rich UI embedding is perfect for: + +- **Interactive dashboards**: Real-time data visualization and controls +- **Form interfaces**: Complex input forms with validation and dynamic behavior +- **Charts and graphs**: Interactive plotting with libraries like Plotly, D3.js, or Chart.js +- **Media players**: Video, audio, or interactive media content +- **Custom widgets**: Specialized UI components for specific tool functionality +- **External integrations**: Embedding content from external services or APIs + +#### External Tool Example + +For external tools served via HTTP endpoints: + +```python +@app.post("/tools/dashboard") +async def create_dashboard(): + html = """ +
+

System Dashboard

+ + + +
+ """ + + return HTMLResponse( + content=html, + headers={"Content-Disposition": "inline"} + ) +``` + +The embedded content automatically inherits responsive design and integrates seamlessly with the chat interface, providing a native-feeling experience for users interacting with your tools. + +## External packages + +In the Tools definition metadata you can specify custom packages. When you click `Save` the line will be parsed and `pip install` will be run on all requirements at once. + +Keep in mind that as pip is used in the same process as Open WebUI, the UI will be completely unresponsive during the installation. + +No measures are taken to handle package conflicts with Open WebUI's requirements. That means that specifying requirements can break Open WebUI if you're not careful. You might be able to work around this by specifying `open-webui` itself as a requirement. + + +
+Example + +```python +""" +title: myToolName +author: myName +funding_url: [any link here will be shown behind a `Heart` button for users to show their support to you] +version: 1.0.0 +# the version is displayed in the UI to help users keep track of updates. +license: GPLv3 +description: [recommended] +requirements: package1>=2.7.0,package2,package3 +""" +``` + +
\ No newline at end of file diff --git a/tools/web_scrape_firecrawl/main.py b/tools/web_scrape_firecrawl/main.py new file mode 100644 index 0000000..2f39f4b --- /dev/null +++ b/tools/web_scrape_firecrawl/main.py @@ -0,0 +1,410 @@ +""" +title: Firecrawl Web Scrape +description: Firecrawl web scraping tool that extracts text content using Firecrawl service. +author: Artur Zdolinski +author_url: https://github.com/azdolinski +git_url: https://github.com/azdolinski/open-webui-tools +required_open_webui_version: 0.4.0 +requirements: requests, urllib3, pydantic, html2text, tiktoken +version: 0.6.0 [2024-12-04] +licence: MIT +""" + +import json +import logging +import asyncio +import requests +from typing import Any, Callable, List, Optional +from pydantic import BaseModel, Field +import urllib3 +from bs4 import BeautifulSoup +import html2text +from pprint import pprint +from datetime import datetime +from textwrap import dedent +import re +import tiktoken + + +# Configure logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +class EventEmitter: + def __init__(self, event_emitter: Callable[[dict], Any] = None): + self.event_emitter = event_emitter + + async def progress_update(self, description: str): + await self.emit(description=description, status="in_progress") + + async def error_update(self, description: str): + await self.emit(description=description, status="error", done=True) + + async def success_update(self, description: str): + await self.emit(description=description, status="success", done=True) + + async def emit(self, description="Unknown State", status="in_progress", done=False): + if self.event_emitter: + await self.event_emitter( + { + "type": "status", + "data": { + "description": description, + "status": status, + "done": done, + }, + } + ) + + +class Tools: + # Define Valves for admin configuration + class Valves(BaseModel): + # Mandatory fields + firecrawl_api_url: str = "https://api.firecrawl.dev/v1/" + firecrawl_api_key: str = "" + formats: List[str] = Field( + default=["markdown"], + description="Output formats for the scraped content: markdown, html, rawHtml, links, screenshot. Extra post processing HTML function -> html2text, html2bs4", + ) + + # Optional fields with defaults + verify_ssl: Optional[bool] = Field( + default=True, description="Whether to verify SSL certificates" + ) + timeout: Optional[int] = Field( + default=30, description="Request timeout in seconds" + ) + max_depth: Optional[int] = Field( + default=2, + description="Maximum crawling depth for nested pages", + alias="maxDepth", + ) + follow_redirects: Optional[bool] = Field( + default=True, + description="Whether to follow URL redirects", + alias="followRedirects", + ) + include_tags: Optional[List[str]] = Field( + default=None, + description="List of HTML tags to include in the scraping", + alias="includeTags", + ) + exclude_tags: Optional[List[str]] = Field( + default=None, + description="List of HTML tags to exclude from the scraping", + alias="excludeTags", + ) + headers: Optional[dict] = Field( + default=None, description="Custom headers to be sent with the request" + ) + wait_for: Optional[int] = Field( + default=0, + description="Time to wait before scraping in milliseconds", + alias="waitFor", + ) + + class Config: + populate_by_name = True + arbitrary_types_allowed = True + + def dict(self, *args, **kwargs): + # Get the base dictionary + base_dict = super().dict(*args, exclude_none=True, by_alias=True, **kwargs) + # Only include non-None and non-default values + filtered_dict = { + k: v + for k, v in base_dict.items() + if v is not None + and not ( + k == "timeout" + and v == 30 + or k == "waitFor" + and v == 0 + or k == "maxDepth" + and v == 2 + or k == "followRedirects" + and v is True + or k == "verify_ssl" + ) + } + + # Remove empty lists or lists with empty strings + for k in ["includeTags", "excludeTags"]: + if k in filtered_dict and ( + not filtered_dict[k] or all(not x for x in filtered_dict[k]) + ): + filtered_dict.pop(k) + + return filtered_dict + + def __init__(self): + """Initialize the Tool with default values.""" + self.valves = self.Valves() + self._session = None + self._skip_html = False + + def num_tokens_from_string(self, string: str, encoding_name: str) -> int: + """Returns the number of tokens in a text string.""" + encoding = tiktoken.get_encoding(encoding_name) + num_tokens = len(encoding.encode(string)) + return num_tokens + + def text_cleaner(self, text): + """Cleans up the text by removing extra whitespaces, newlines, and unwanted URLs.""" + # Remove escaped backslashes + cleaned_text = re.sub(r"\\+", "", text) + + # Remove URLs that don't start with http or mailto + cleaned_text = re.sub( + r"\[.*?\]\((?!(?:http|mailto:)).*?\)", "", cleaned_text + ) # Remove markdown links + cleaned_text = re.sub( + r"\(/?[^)]*?/[^)]+\)", "", cleaned_text + ) # Remove parenthesized paths + + # Remove lines that consist of repeated characters (like ===, ---, ###) + cleaned_text = re.sub(r"^[=\-#\*]{3,}$", "", cleaned_text, flags=re.MULTILINE) + + # Remove empty lines and extra whitespace + cleaned_text = re.sub( + r"\n\s*\n", "\n", cleaned_text + ) # Replace multiple newlines with single + cleaned_text = re.sub( + r"^\s+|\s+$", "", cleaned_text, flags=re.MULTILINE + ) # Remove leading/trailing whitespace + + # Remove any remaining empty lines + cleaned_text = "\n".join( + line for line in cleaned_text.splitlines() if line.strip() + ) + + return cleaned_text.strip() + + def html_clean_bs4(self, html_content): + """Performs a quick cleanup of common unwanted HTML tags and attributes.""" + soup = BeautifulSoup(html_content, "html.parser") + + # Remove commonly unwanted tags (adjust as needed) + tags_to_remove = [ + "script", + "style", + "head", + "iframe", + "meta", + "svg", + ] # Add more as you like. + for tag in soup.find_all(tags_to_remove): + tag.decompose() + + # Remove attributes (adjust as needed) + attrs_to_remove = ["class", "style"] # Add more as you like. + for tag in soup.find_all(attrs=True): + for attr in attrs_to_remove: + if attr in tag.attrs: + del tag.attrs[attr] + + # Remove empty tags. This part is potentially fragile as it can delete empty tags you *want* to keep. + for tag in soup.find_all(): + if not tag.contents and tag.name not in [ + "br", + "hr", + ]: # Exceptions for tags that are empty by design + tag.decompose() + + return str(soup) + + def html_clean_html2text(self, html_content): + """Converts HTML to Markdown using html2text.""" + h = html2text.HTML2Text() + + h.ignore_links = True + h.ignore_images = True + h.ignore_emphasis = True + h.body_width = 0 + + h.protect_links = True + h.unicode_snob = True + h.skip_internal_links = True + h.inline_links = True + + # h.ignore_tables = False + # h.bypass_tables = False + + # Convert HTML to markdown and clean up empty lines + markdown_text = h.handle(html_content) + # Remove multiple empty lines and strip whitespace + # cleaned_text = '\n'.join(line.strip() for line in markdown_text.splitlines() if line.strip()) + cleaned_text = self.text_cleaner(markdown_text) + return cleaned_text + + @property + def session(self): + """Get or create a requests session with proper configuration.""" + if self._session is None: + self._session = requests.Session() + if not self.valves.verify_ssl: + # Disable SSL verification warnings when verify_ssl is False + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self._session.verify = False + if self.valves.firecrawl_api_key: + self._session.headers.update( + { + "Authorization": f"Bearer {self.valves.firecrawl_api_key}", + "Content-Type": "application/json", + } + ) + return self._session + + async def web_scrape( + self, url: str, __user__: dict = None, __event_emitter__=None + ) -> str: + """ + Scrapes a webpage and returns its content in markdown format. + + :param url: The URL to scrape + :return: The scraped content as a string + """ + if __event_emitter__: + event_emitter = EventEmitter(__event_emitter__) + + try: + if __event_emitter__: + asyncio.create_task( + event_emitter.progress_update("Starting web scrape...") + ) + + # Ensure we always have 'html' format if any other format starting from 'html2*' is present + if any(format.startswith("html2") for format in self.valves.formats): + self.valves.formats.insert(0, "html") + self._skip_html = True + + # Check if url starts from http: or https: + if not url.startswith("http://") and not url.startswith("https://"): + url = f"https://{url}" + + # We need to remove all formats which are starts like: html2 + payload = { + "url": url, + "formats": [ + format + for format in self.valves.formats + if not format.startswith("html2") + ], + } + + # Add optional parameters only if they're not default values + optional_params = self.valves.dict( + exclude={"firecrawl_api_url", "firecrawl_api_key", "formats"} + ) + payload.update(optional_params) + + logger.debug(f"Request payload: {payload}") + + print(f"Firecrawl Tool request for url: {url} - payload: {payload}") + + # Update status to inform user + if __event_emitter__: + await event_emitter.progress_update(f"Scraping content from {url}") + + # Make the request + base_url = self.valves.firecrawl_api_url.rstrip("/") + endpoint = f"{base_url}/scrape" + logger.debug(f"Making request to endpoint: {endpoint}") + + response = requests.post( + endpoint, + json=payload, + headers={"Authorization": f"Bearer {self.valves.firecrawl_api_key}"}, + verify=self.valves.verify_ssl, + timeout=self.valves.timeout, + ) + logger.debug(f"Response status code: {response.status_code}") + + if response.status_code != 200: + if response.status_code == 400: + error_msg = f"Error: Failed to scrape URL. Status code: {response.status_code} - payload send: {payload}" + else: + error_msg = f"Error: Failed to scrape URL. Status code: {response.status_code}" + if __event_emitter__: + await event_emitter.error_update(error_msg) + return error_msg + + # Parse the response + response_data = response.json() + logger.debug(f"Raw response data: {response_data}") + + if not response_data.get("success"): + error_msg = ( + f"Error: {response_data.get('error', 'Unknown error occurred')}" + ) + if __event_emitter__: + await event_emitter.error_update(error_msg) + return error_msg + + # Extract content based on format + data = response_data.get("data", {}).get(self.valves.formats[0]) + + if not data: + error_msg = ( + f"Error: No content found in {self.valves.formats[0]} format" + ) + if __event_emitter__: + await event_emitter.error_update(error_msg) + return error_msg + + # Success message + if __event_emitter__: + await event_emitter.success_update( + f"Firecrawl successfully scraped content from {url}" + ) + + if self._skip_html: + self.valves.formats.pop(0) + + # Return the content + # print("URL: " + str(url)) + content = {} + for format in self.valves.formats: + data = response_data.get("data", {}).get(format, "") + data_html = response_data.get("data", {}).get("html") + content[format] = None + if format == "html": + content[format] = str(data) + if format == "markdown": + content[format] = self.text_cleaner(data) + + if format == "html2text": + content["html2text"] = str( + self.text_cleaner(self.html_clean_html2text(data_html)) + ) + if format == "html2bs4": + content["html2bs4"] = str( + self.text_cleaner(self.html_clean_bs4(data_html)) + ) + + if content[format] is None: + content[format] = data + + # print(f"Tokens for format: " + format + ": " + str(self.num_tokens_from_string(content[format], "cl100k_base")) + "[cl100k_base] / " + str(self.num_tokens_from_string(content[format], "o200k_base")) + "[o200k_base] - content len: "+ str(len(content[format])) + " chars") + + # Lets return content + formatted_content = json.dumps(content, indent=4, ensure_ascii=False) + decoded_content = json.loads(formatted_content) + pretty_content = json.dumps( + decoded_content, indent=4, ensure_ascii=False + ).replace("\\n", "\n") + return ( + f"""Date now: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n""" + + f"""Page content from URL: {url}\n""" + + f"""Metadata: {response_data.get("data", {}).get("metadata")}\n""" + + f"""{pretty_content}\n""" + ).strip() + + except Exception as e: + error_msg = f"Error: {str(e)}" + logger.error(f"Exception during web scrape: {e}") + if __event_emitter__: + await event_emitter.error_update(error_msg) + return error_msg From 935c4c3d92fd6a165cb973783544898d53734ca0 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Thu, 9 Oct 2025 12:01:43 -0400 Subject: [PATCH 07/21] Enhance Firecrawl web scraping tool with v2 API support, added retry logic for requests, and summary format extraction. Updated version to 0.7.0 and improved connection pooling for better performance. --- tools/web_scrape_firecrawl/main.py | 76 +++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/tools/web_scrape_firecrawl/main.py b/tools/web_scrape_firecrawl/main.py index 2f39f4b..5b6a378 100644 --- a/tools/web_scrape_firecrawl/main.py +++ b/tools/web_scrape_firecrawl/main.py @@ -1,12 +1,12 @@ """ title: Firecrawl Web Scrape -description: Firecrawl web scraping tool that extracts text content using Firecrawl service. +description: Firecrawl web scraping tool with v2 API support, retry logic, and summary format extraction. author: Artur Zdolinski author_url: https://github.com/azdolinski git_url: https://github.com/azdolinski/open-webui-tools required_open_webui_version: 0.4.0 requirements: requests, urllib3, pydantic, html2text, tiktoken -version: 0.6.0 [2024-12-04] +version: 0.7.0 [2025-10-09] licence: MIT """ @@ -62,11 +62,11 @@ class Tools: # Define Valves for admin configuration class Valves(BaseModel): # Mandatory fields - firecrawl_api_url: str = "https://api.firecrawl.dev/v1/" + firecrawl_api_url: str = "https://api.firecrawl.dev/" firecrawl_api_key: str = "" formats: List[str] = Field( default=["markdown"], - description="Output formats for the scraped content: markdown, html, rawHtml, links, screenshot. Extra post processing HTML function -> html2text, html2bs4", + description="Output formats for the scraped content: markdown, html, rawHtml, links, screenshot, summary. Extra post processing HTML function -> html2text, html2bs4", ) # Optional fields with defaults @@ -241,13 +241,27 @@ def html_clean_html2text(self, html_content): @property def session(self): - """Get or create a requests session with proper configuration.""" + """Get or create a requests session with connection pooling and proper configuration.""" if self._session is None: self._session = requests.Session() + + # Configure connection pooling for better performance + adapter = requests.adapters.HTTPAdapter( + pool_connections=10, + pool_maxsize=20, + max_retries=3, + pool_block=False + ) + self._session.mount('http://', adapter) + self._session.mount('https://', adapter) + + # Configure SSL settings if not self.valves.verify_ssl: # Disable SSL verification warnings when verify_ssl is False urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) self._session.verify = False + + # Set default headers if self.valves.firecrawl_api_key: self._session.headers.update( { @@ -257,6 +271,42 @@ def session(self): ) return self._session + async def _make_request_with_retry(self, endpoint: str, payload: dict, max_retries: int = 3) -> requests.Response: + """Make HTTP request with exponential backoff retry logic""" + for attempt in range(max_retries): + try: + response = self.session.post( + endpoint, + json=payload, + headers={"Authorization": f"Bearer {self.valves.firecrawl_api_key}"}, + verify=self.valves.verify_ssl, + timeout=self.valves.timeout, + ) + + # Return successful responses or non-retryable errors + if response.status_code < 500: + return response + + # For server errors, retry with exponential backoff + if attempt < max_retries - 1: + wait_time = 2 ** attempt + logger.warning(f"Server error {response.status_code}, retrying in {wait_time}s...") + await asyncio.sleep(wait_time) + continue + + return response + + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + if attempt < max_retries - 1: + wait_time = 2 ** attempt + logger.warning(f"Request failed: {e}, retrying in {wait_time}s...") + await asyncio.sleep(wait_time) + continue + raise e + + # This should never be reached, but just in case + raise RuntimeError(f"Failed after {max_retries} attempts") + async def web_scrape( self, url: str, __user__: dict = None, __event_emitter__=None ) -> str: @@ -310,16 +360,10 @@ async def web_scrape( # Make the request base_url = self.valves.firecrawl_api_url.rstrip("/") - endpoint = f"{base_url}/scrape" + endpoint = f"{base_url}/v2/scrape" logger.debug(f"Making request to endpoint: {endpoint}") - response = requests.post( - endpoint, - json=payload, - headers={"Authorization": f"Bearer {self.valves.firecrawl_api_key}"}, - verify=self.valves.verify_ssl, - timeout=self.valves.timeout, - ) + response = await self._make_request_with_retry(endpoint, payload) logger.debug(f"Response status code: {response.status_code}") if response.status_code != 200: @@ -389,6 +433,12 @@ async def web_scrape( # print(f"Tokens for format: " + format + ": " + str(self.num_tokens_from_string(content[format], "cl100k_base")) + "[cl100k_base] / " + str(self.num_tokens_from_string(content[format], "o200k_base")) + "[o200k_base] - content len: "+ str(len(content[format])) + " chars") + # Process summary format if requested + if "summary" in self.valves.formats: + summary_data = response_data.get("data", {}).get("summary", "") + if summary_data: + content["summary"] = summary_data.strip() + # Lets return content formatted_content = json.dumps(content, indent=4, ensure_ascii=False) decoded_content = json.loads(formatted_content) From 1f34ce702d05f04092c6c73a5b0e6f06d82b0066 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Tue, 11 Nov 2025 06:00:31 -0500 Subject: [PATCH 08/21] add support for Haiku 4.5 --- functions/pipes/anthropic/main.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/functions/pipes/anthropic/main.py b/functions/pipes/anthropic/main.py index c66b9c0..e2edfdd 100644 --- a/functions/pipes/anthropic/main.py +++ b/functions/pipes/anthropic/main.py @@ -25,7 +25,7 @@ class Valves(BaseModel): ANTHROPIC_API_KEY: str = Field(default="", description="Anthropic API Key") CLAUDE_USE_TEMPERATURE: bool = Field( default=True, - description="For Claude 4.x models: Use temperature (True) or top_p (False). Claude 4.x models only support one parameter.", + description="For Claude 4.x models (Opus 4, Sonnet 4.5, Haiku 3.5+): Use temperature (True) or top_p (False). Claude 4.x models only support one parameter.", ) BETA_FEATURES: str = Field( default="", @@ -225,11 +225,14 @@ def _is_claude_4x_model(self, model_name: str) -> bool: import re # Pattern to match Claude 4.x models with various version suffixes - # Examples: claude-opus-4, claude-opus-4-1-20250805, claude-sonnet-4-5, claude-sonnet-4-5-20250929 + # Examples: claude-opus-4, claude-opus-4-1-20250805, claude-sonnet-4-5, claude-sonnet-4-5-20250929, claude-3-5-haiku-latest # The pattern allows for optional sub-versions (like -1, -5) and dates - pattern = r"^claude-(opus|sonnet)-4(?:-\d+)?(?:-\d{8})?$" + pattern = r"^claude-(opus|sonnet|haiku)-4(?:-\d+)?(?:-\d{8})?$" + + # Also match the latest haiku variants (claude-3-5-haiku-latest is actually Claude 4.x generation) + haiku_pattern = r"^claude-3-5-haiku" - return bool(re.match(pattern, model_name)) + return bool(re.match(pattern, model_name)) or bool(re.match(haiku_pattern, model_name)) def pipes(self) -> List[dict]: return self.get_anthropic_models() From 5fdab8b2da58c25d615c02dc231d234d598e8ebd Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Tue, 11 Nov 2025 06:00:31 -0500 Subject: [PATCH 09/21] add support for Haiku 4.5 --- functions/pipes/anthropic/main.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/functions/pipes/anthropic/main.py b/functions/pipes/anthropic/main.py index c66b9c0..e2edfdd 100644 --- a/functions/pipes/anthropic/main.py +++ b/functions/pipes/anthropic/main.py @@ -25,7 +25,7 @@ class Valves(BaseModel): ANTHROPIC_API_KEY: str = Field(default="", description="Anthropic API Key") CLAUDE_USE_TEMPERATURE: bool = Field( default=True, - description="For Claude 4.x models: Use temperature (True) or top_p (False). Claude 4.x models only support one parameter.", + description="For Claude 4.x models (Opus 4, Sonnet 4.5, Haiku 3.5+): Use temperature (True) or top_p (False). Claude 4.x models only support one parameter.", ) BETA_FEATURES: str = Field( default="", @@ -225,11 +225,14 @@ def _is_claude_4x_model(self, model_name: str) -> bool: import re # Pattern to match Claude 4.x models with various version suffixes - # Examples: claude-opus-4, claude-opus-4-1-20250805, claude-sonnet-4-5, claude-sonnet-4-5-20250929 + # Examples: claude-opus-4, claude-opus-4-1-20250805, claude-sonnet-4-5, claude-sonnet-4-5-20250929, claude-3-5-haiku-latest # The pattern allows for optional sub-versions (like -1, -5) and dates - pattern = r"^claude-(opus|sonnet)-4(?:-\d+)?(?:-\d{8})?$" + pattern = r"^claude-(opus|sonnet|haiku)-4(?:-\d+)?(?:-\d{8})?$" + + # Also match the latest haiku variants (claude-3-5-haiku-latest is actually Claude 4.x generation) + haiku_pattern = r"^claude-3-5-haiku" - return bool(re.match(pattern, model_name)) + return bool(re.match(pattern, model_name)) or bool(re.match(haiku_pattern, model_name)) def pipes(self) -> List[dict]: return self.get_anthropic_models() From 7dc3076dfd913e742205b25642c47abe798f3433 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Mon, 1 Dec 2025 18:50:06 -0500 Subject: [PATCH 10/21] Implement Google Gemini Manifold Pipe with model retrieval and content generation capabilities. Includes error handling, logging, and support for permissive safety settings. Initial version 0.1.4. --- functions/pipes/google-vertexai/main.py | 705 ++++++++++++++++++++++++ functions/pipes/google/main.py | 190 +++++++ 2 files changed, 895 insertions(+) create mode 100644 functions/pipes/google-vertexai/main.py create mode 100644 functions/pipes/google/main.py diff --git a/functions/pipes/google-vertexai/main.py b/functions/pipes/google-vertexai/main.py new file mode 100644 index 0000000..e538690 --- /dev/null +++ b/functions/pipes/google-vertexai/main.py @@ -0,0 +1,705 @@ +""" +title: Google Gemini Pipeline +author: owndev +author_url: https://github.com/owndev/ +project_url: https://github.com/owndev/Open-WebUI-Functions +funding_url: https://github.com/sponsors/owndev +version: 1.1.1 +requirements: google-genai +license: Apache License 2.0 +description: A manifold pipeline for interacting with Google Gemini models, including dynamic model specification, streaming responses, and flexible error handling. +features: + - Asynchronous API calls for better performance + - Model caching to reduce API calls + - Dynamic model specification with automatic prefix stripping + - Streaming response handling with safety checks + - Support for multimodal input (text and images) + - Flexible error handling and logging + - Integration with Google Generative AI or Vertex AI API for content generation + - Support for various generation parameters (temperature, max tokens, etc.) + - Customizable safety settings based on environment variables + - Encrypted storage of sensitive API keys +""" + +import os +import re +import time +import asyncio +import base64 +import hashlib +import logging +from google import genai +from google.genai import types +from google.genai.errors import ClientError, ServerError, APIError +from typing import List, Union, Optional, Dict, Any, Tuple, AsyncIterator +from pydantic_core import core_schema +from pydantic import BaseModel, Field, GetCoreSchemaHandler +from cryptography.fernet import Fernet, InvalidToken +from open_webui.env import SRC_LOG_LEVELS + + +# Simplified encryption implementation with automatic handling +class EncryptedStr(str): + """A string type that automatically handles encryption/decryption""" + + @classmethod + def _get_encryption_key(cls) -> Optional[bytes]: + """ + Generate encryption key from WEBUI_SECRET_KEY if available + Returns None if no key is configured + """ + secret = os.getenv("WEBUI_SECRET_KEY") + if not secret: + return None + + hashed_key = hashlib.sha256(secret.encode()).digest() + return base64.urlsafe_b64encode(hashed_key) + + @classmethod + def encrypt(cls, value: str) -> str: + """ + Encrypt a string value if a key is available + Returns the original value if no key is available + """ + if not value or value.startswith("encrypted:"): + return value + + key = cls._get_encryption_key() + if not key: # No encryption if no key + return value + + f = Fernet(key) + encrypted = f.encrypt(value.encode()) + return f"encrypted:{encrypted.decode()}" + + @classmethod + def decrypt(cls, value: str) -> str: + """ + Decrypt an encrypted string value if a key is available + Returns the original value if no key is available or decryption fails + """ + if not value or not value.startswith("encrypted:"): + return value + + key = cls._get_encryption_key() + if not key: # No decryption if no key + return value[len("encrypted:") :] # Return without prefix + + try: + encrypted_part = value[len("encrypted:") :] + f = Fernet(key) + decrypted = f.decrypt(encrypted_part.encode()) + return decrypted.decode() + except (InvalidToken, Exception): + return value + + # Pydantic integration + @classmethod + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + return core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + core_schema.chain_schema( + [ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function( + lambda value: cls(cls.encrypt(value) if value else value) + ), + ] + ), + ], + serialization=core_schema.plain_serializer_function_ser_schema( + lambda instance: str(instance) + ), + ) + + def get_decrypted(self) -> str: + """Get the decrypted value""" + return self.decrypt(self) + + +class Pipe: + """ + Pipeline for interacting with Google Gemini models. + """ + + # Configuration valves for the pipeline + class Valves(BaseModel): + GOOGLE_API_KEY: EncryptedStr = Field( + default=os.getenv("GOOGLE_API_KEY", ""), + description="API key for Google Generative AI (used if USE_VERTEX_AI is false).", + ) + USE_VERTEX_AI: bool = Field( + default=os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "false").lower() == "true", + description="Whether to use Google Cloud Vertex AI instead of the Google Generative AI API.", + ) + VERTEX_PROJECT: str | None = Field( + default=os.getenv("GOOGLE_CLOUD_PROJECT"), + description="The Google Cloud project ID to use with Vertex AI.", + ) + VERTEX_LOCATION: str = Field( + default=os.getenv("GOOGLE_CLOUD_LOCATION", "global"), + description="The Google Cloud region to use with Vertex AI.", + ) + USE_PERMISSIVE_SAFETY: bool = Field( + default=os.getenv("USE_PERMISSIVE_SAFETY", "false").lower() == "true", + description="Use permissive safety settings for content generation.", + ) + MODEL_CACHE_TTL: int = Field( + default=int(os.getenv("GOOGLE_MODEL_CACHE_TTL", "600")), + description="Time in seconds to cache the model list before refreshing", + ) + RETRY_COUNT: int = Field( + default=int(os.getenv("GOOGLE_RETRY_COUNT", "2")), + description="Number of times to retry API calls on temporary failures", + ) + + def __init__(self): + """Initializes the Pipe instance and configures the genai library.""" + self.valves = self.Valves() + self.name: str = "Google Gemini: " + + # Setup logging + self.log = logging.getLogger("google_ai.pipe") + self.log.setLevel(SRC_LOG_LEVELS.get("OPENAI", logging.INFO)) + + # Model cache + self._model_cache: Optional[List[Dict[str, str]]] = None + self._model_cache_time: float = 0 + + def _get_client(self) -> genai.Client: + """ + Validates API credentials and returns a genai.Client instance. + """ + self._validate_api_key() + + if self.valves.USE_VERTEX_AI: + self.log.debug( + f"Initializing Vertex AI client (Project: {self.valves.VERTEX_PROJECT}, Location: {self.valves.VERTEX_LOCATION})" + ) + return genai.Client( + vertexai=True, + project=self.valves.VERTEX_PROJECT, + location=self.valves.VERTEX_LOCATION, + ) + else: + self.log.debug("Initializing Google Generative AI client with API Key") + return genai.Client(api_key=self.valves.GOOGLE_API_KEY.get_decrypted()) + + def _validate_api_key(self) -> None: + """ + Validates that the necessary Google API credentials are set. + + Raises: + ValueError: If the required credentials are not set. + """ + if self.valves.USE_VERTEX_AI: + if not self.valves.VERTEX_PROJECT: + self.log.error("USE_VERTEX_AI is true, but VERTEX_PROJECT is not set.") + raise ValueError( + "VERTEX_PROJECT is not set. Please provide the Google Cloud project ID." + ) + # For Vertex AI, location has a default, so project is the main thing to check. + # Actual authentication will be handled by ADC or environment. + self.log.debug( + "Using Vertex AI. Ensure ADC or service account is configured." + ) + else: + if not self.valves.GOOGLE_API_KEY: + self.log.error("GOOGLE_API_KEY is not set (and not using Vertex AI).") + raise ValueError( + "GOOGLE_API_KEY is not set. Please provide the API key in the environment variables or valves." + ) + self.log.debug("Using Google Generative AI API with API Key.") + + def strip_prefix(self, model_name: str) -> str: + """ + Extract the model identifier using regex, handling various naming conventions. + e.g., "google_gemini_pipeline.gemini-2.5-flash-preview-04-17" -> "gemini-2.5-flash-preview-04-17" + e.g., "models/gemini-1.5-flash-001" -> "gemini-1.5-flash-001" + e.g., "publishers/google/models/gemini-1.5-pro" -> "gemini-1.5-pro" + """ + # Use regex to remove everything up to and including the last '/' or the first '.' + stripped = re.sub(r"^(?:.*/|[^.]*\.)", "", model_name) + return stripped + + def get_google_models(self, force_refresh: bool = False) -> List[Dict[str, str]]: + """ + Retrieve available Google models suitable for content generation. + Uses caching to reduce API calls. + + Args: + force_refresh: Whether to force refreshing the model cache + + Returns: + List of dictionaries containing model id and name. + """ + # Check cache first + current_time = time.time() + if ( + not force_refresh + and self._model_cache is not None + and (current_time - self._model_cache_time) < self.valves.MODEL_CACHE_TTL + ): + self.log.debug("Using cached model list") + return self._model_cache + + try: + client = self._get_client() + self.log.debug("Fetching models from Google API") + models = client.models.list() + available_models = [] + for model in models: + actions = model.supported_actions + if actions is None or "generateContent" in actions: + available_models.append( + { + "id": self.strip_prefix(model.name), + "name": model.display_name or self.strip_prefix(model.name), + } + ) + + model_map = {model["id"]: model for model in available_models} + + # Filter map to only include models starting with 'gemini-' + filtered_models = { + k: v for k, v in model_map.items() if k.startswith("gemini-") + } + + # Update cache + self._model_cache = list(filtered_models.values()) + self._model_cache_time = current_time + self.log.debug(f"Found {len(self._model_cache)} Gemini models") + return self._model_cache + + except Exception as e: + self.log.exception(f"Could not fetch models from Google: {str(e)}") + # Return a specific error entry for the UI + return [{"id": "error", "name": f"Could not fetch models: {str(e)}"}] + + def pipes(self) -> List[Dict[str, str]]: + """ + Returns a list of available Google Gemini models for the UI. + + Returns: + List of dictionaries containing model id and name. + """ + try: + self.name = "Google Gemini: " + return self.get_google_models() + except ValueError as e: + # Handle the case where API key is missing during pipe listing + self.log.error(f"Error during pipes listing (validation): {e}") + return [{"id": "error", "name": str(e)}] + except Exception as e: + # Handle other potential errors during model fetching + self.log.exception( + f"An unexpected error occurred during pipes listing: {str(e)}" + ) + return [{"id": "error", "name": f"An unexpected error occurred: {str(e)}"}] + + def _prepare_model_id(self, model_id: str) -> str: + """ + Prepare and validate the model ID for use with the API. + + Args: + model_id: The original model ID from the user + + Returns: + Properly formatted model ID + + Raises: + ValueError: If the model ID is invalid or unsupported + """ + original_model_id = model_id + model_id = self.strip_prefix(model_id) + + # If the model ID doesn't look like a Gemini model, try to find it by name + if not model_id.startswith("gemini-"): + models_list = self.get_google_models() + found_model = next( + (m["id"] for m in models_list if m["name"] == original_model_id), None + ) + if found_model and found_model.startswith("gemini-"): + model_id = found_model + self.log.debug( + f"Mapped model name '{original_model_id}' to model ID '{model_id}'" + ) + else: + # If we still don't have a valid ID, raise an error + if not model_id.startswith("gemini-"): + self.log.error( + f"Invalid or unsupported model ID: '{original_model_id}'" + ) + raise ValueError( + f"Invalid or unsupported Google model ID or name: '{original_model_id}'" + ) + + return model_id + + def _prepare_content( + self, messages: List[Dict[str, Any]] + ) -> Tuple[List[Dict[str, Any]], Optional[str]]: + """ + Prepare messages content for the API and extract system message if present. + + Args: + messages: List of message objects from the request + + Returns: + Tuple of (prepared content list, system message string or None) + """ + # Extract system message + system_message = next( + (msg["content"] for msg in messages if msg.get("role") == "system"), + None, + ) + + # Prepare contents for the API + contents = [] + for message in messages: + role = message.get("role") + if role == "system": + continue # Skip system messages, handled separately + + content = message.get("content", "") + parts = [] + + # Handle different content types + if isinstance(content, list): # Multimodal content + parts.extend(self._process_multimodal_content(content)) + elif isinstance(content, str): # Plain text content + parts.append({"text": content}) + else: + self.log.warning(f"Unsupported message content type: {type(content)}") + continue # Skip unsupported content + + # Map roles: 'assistant' -> 'model', 'user' -> 'user' + api_role = "model" if role == "assistant" else "user" + if parts: # Only add if there are parts + contents.append({"role": api_role, "parts": parts}) + + return contents, system_message + + def _process_multimodal_content( + self, content_list: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Process multimodal content (text and images). + + Args: + content_list: List of content items + + Returns: + List of processed parts for the Gemini API + """ + parts = [] + + for item in content_list: + if item.get("type") == "text": + parts.append({"text": item.get("text", "")}) + elif item.get("type") == "image_url": + image_url = item.get("image_url", {}).get("url", "") + + if image_url.startswith("data:image"): + # Handle base64 encoded image data + try: + header, encoded = image_url.split(",", 1) + mime_type = header.split(":")[1].split(";")[0] + + # Basic validation for image types + if mime_type not in [ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif", + ]: + self.log.warning( + f"Unsupported image mime type: {mime_type}" + ) + parts.append( + {"text": f"[Image type {mime_type} not supported]"} + ) + continue + + parts.append( + { + "inline_data": { + "mime_type": mime_type, + "data": encoded, + } + } + ) + except Exception as img_ex: + self.log.exception(f"Could not parse image data URL: {img_ex}") + parts.append({"text": "[Image data could not be processed]"}) + else: + # Gemini API doesn't directly support image URLs + self.log.warning(f"Direct image URLs not supported: {image_url}") + parts.append({"text": f"[Image URL not processed: {image_url}]"}) + + return parts + + def _configure_generation( + self, body: Dict[str, Any], system_instruction: Optional[str] = None + ) -> types.GenerateContentConfig: + """ + Configure generation parameters and safety settings. + + Args: + body: The request body containing generation parameters + system_instruction: Optional system instruction string + + Returns: + types.GenerateContentConfig + """ + gen_config_params = { + "temperature": body.get("temperature"), + "top_p": body.get("top_p"), + "top_k": body.get("top_k"), + "max_output_tokens": body.get("max_tokens"), + "stop_sequences": body.get("stop") or None, + "system_instruction": system_instruction, + } + # Configure safety settings + if self.valves.USE_PERMISSIVE_SAFETY: + safety_settings = [ + types.SafetySetting( + category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE" + ), + types.SafetySetting( + category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE" + ), + types.SafetySetting( + category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE" + ), + types.SafetySetting( + category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE" + ), + ] + gen_config_params |= ({"safety_settings": safety_settings},) + + # Filter out None values for generation config + filtered_params = {k: v for k, v in gen_config_params.items() if v is not None} + return types.GenerateContentConfig(**filtered_params) + + async def _handle_streaming_response( + self, response_iterator: Any + ) -> AsyncIterator[str]: + """ + Handle streaming response from Gemini API. + + Args: + response_iterator: Iterator from generate_content + + Returns: + Generator yielding text chunks + """ + try: + async for chunk in response_iterator: + # Check for safety feedback or empty chunks + if not chunk.candidates: + # Check prompt feedback + if ( + response_iterator.prompt_feedback + and response_iterator.prompt_feedback.block_reason + ): + yield f"[Blocked due to Prompt Safety: {response_iterator.prompt_feedback.block_reason.name}]" + else: + yield "[Blocked by safety settings]" + return # Stop generation + + if chunk.text: + yield chunk.text + except Exception as e: + self.log.exception(f"Error during streaming: {e}") + yield f"Error during streaming: {e}" + + def _handle_standard_response(self, response: Any) -> str: + """ + Handle non-streaming response from Gemini API. + + Args: + response: Response from generate_content + + Returns: + Generated text or error message + """ + # Check for prompt safety blocks + if response.prompt_feedback and response.prompt_feedback.block_reason: + return f"[Blocked due to Prompt Safety: {response.prompt_feedback.block_reason.name}]" + + # Check for missing candidates + if not response.candidates: + return "[Blocked by safety settings or no candidates generated]" + + # Check candidate finish reason + candidate = response.candidates[0] + if candidate.finish_reason == types.FinishReason.SAFETY: + # Try to get specific safety rating info + blocking_rating = next( + (r for r in candidate.safety_ratings if r.blocked), None + ) + reason = f" ({blocking_rating.category.name})" if blocking_rating else "" + return f"[Blocked by safety settings{reason}]" + + # Process content parts + if candidate.content and candidate.content.parts: + # Combine text from all parts + return "".join( + part.text for part in candidate.content.parts if hasattr(part, "text") + ) + else: + return "[No content generated or unexpected response structure]" + + async def _retry_with_backoff(self, func, *args, **kwargs) -> Any: + """ + Retry a function with exponential backoff. + + Args: + func: Async function to retry + *args, **kwargs: Arguments to pass to the function + + Returns: + Result from the function + + Raises: + The last exception encountered after all retries + """ + max_retries = self.valves.RETRY_COUNT + retry_count = 0 + last_exception = None + + while retry_count <= max_retries: + try: + return await func(*args, **kwargs) + except ServerError as e: + # These errors might be temporary, so retry + retry_count += 1 + last_exception = e + + if retry_count <= max_retries: + # Calculate backoff time (exponential with jitter) + wait_time = min(2**retry_count + (0.1 * retry_count), 10) + self.log.warning( + f"Temporary error from Google API: {e}. Retrying in {wait_time:.1f}s ({retry_count}/{max_retries})" + ) + await asyncio.sleep(wait_time) + else: + raise + except Exception: + # Don't retry other exceptions + raise + + # If we get here, we've exhausted retries + assert last_exception is not None + raise last_exception + + async def pipe(self, body: Dict[str, Any]) -> Union[str, AsyncIterator[str]]: + """ + Main method for sending requests to the Google Gemini endpoint. + + Args: + body: The request body containing messages and other parameters. + + Returns: + Response from Google Gemini API, which could be a string or an iterator for streaming. + """ + # Setup logging for this request + request_id = id(body) + self.log.debug(f"Processing request {request_id}") + + try: + # Parse and validate model ID + model_id = body.get("model", "") + try: + model_id = self._prepare_model_id(model_id) + self.log.debug(f"Using model: {model_id}") + except ValueError as ve: + return f"Model Error: {ve}" + + # Get stream flag + stream = body.get("stream", False) + messages = body.get("messages", []) + + # Prepare content and extract system message + contents, system_instruction = self._prepare_content(messages) + if not contents: + return "Error: No valid message content found" + + # Configure generation parameters and safety settings + generation_config = self._configure_generation(body, system_instruction) + + # Make the API call + client = self._get_client() + if stream: + try: + + async def get_streaming_response(): + return await client.aio.models.generate_content_stream( + model=model_id, + contents=contents, + config=generation_config, + ) + + response_iterator = await self._retry_with_backoff( + get_streaming_response + ) + self.log.debug(f"Request {request_id}: Got streaming response") + return self._handle_streaming_response(response_iterator) + + except Exception as e: + self.log.exception(f"Error in streaming request {request_id}: {e}") + return f"Error during streaming: {e}" + else: + try: + + async def get_response(): + return await client.aio.models.generate_content( + model=model_id, + contents=contents, + config=generation_config, + ) + + response = await self._retry_with_backoff(get_response) + self.log.debug(f"Request {request_id}: Got non-streaming response") + return self._handle_standard_response(response) + + except Exception as e: + self.log.exception( + f"Error in non-streaming request {request_id}: {e}" + ) + return f"Error generating content: {e}" + + except ClientError as ce: + error_msg = f"Client error raised by the GenAI API: {ce}." + self.log.error(f"Client error: {ce}") + return error_msg + + except ServerError as se: + error_msg = f"Server error raised by the GenAI API: {se}" + self.log.error(f"Server error raised by the GenAI API.: {se}") + return error_msg + + except APIError as apie: + error_msg = f"Google API Error: {apie}" + self.log.error(error_msg) + return error_msg + + except ValueError as ve: + error_msg = f"Configuration error: {ve}" + self.log.error(f"Value error: {ve}") + return error_msg + + except Exception as e: + # Log the full error with traceback + import traceback + + error_trace = traceback.format_exc() + self.log.exception(f"Unexpected error: {e}\n{error_trace}") + + # Return a user-friendly error message + return f"An error occurred while processing your request: {e}" diff --git a/functions/pipes/google/main.py b/functions/pipes/google/main.py new file mode 100644 index 0000000..79b9288 --- /dev/null +++ b/functions/pipes/google/main.py @@ -0,0 +1,190 @@ +""" +title: Gemini Manifold Pipe +author: justinh-rahb +author_url: https://github.com/justinh-rahb +funding_url: https://github.com/open-webui +version: 0.1.4 +license: MIT +""" + +import os +import json +from pydantic import BaseModel, Field +import google.generativeai as genai +from google.generativeai.types import GenerationConfig, GenerateContentResponse +from typing import List, Union, Iterator + +# Set DEBUG to True to enable detailed logging +DEBUG = False + + +class Pipe: + class Valves(BaseModel): + GOOGLE_API_KEY: str = Field(default="") + USE_PERMISSIVE_SAFETY: bool = Field(default=False) + + def __init__(self): + self.id = "google_genai" + self.type = "manifold" + self.name = "Google: " + self.valves = self.Valves( + **{ + "GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""), + "USE_PERMISSIVE_SAFETY": False, + } + ) + + def get_google_models(self): + if not self.valves.GOOGLE_API_KEY: + return [ + { + "id": "error", + "name": "GOOGLE_API_KEY is not set. Please update the API Key in the valves.", + } + ] + try: + genai.configure(api_key=self.valves.GOOGLE_API_KEY) + models = genai.list_models() + return [ + { + "id": model.name[7:], # remove the "models/" part + "name": model.display_name, + } + for model in models + if "generateContent" in model.supported_generation_methods + if model.name.startswith("models/") + ] + except Exception as e: + if DEBUG: + print(f"Error fetching Google models: {e}") + return [ + {"id": "error", "name": f"Could not fetch models from Google: {str(e)}"} + ] + + def pipes(self) -> List[dict]: + return self.get_google_models() + + def pipe(self, body: dict) -> Union[str, Iterator[str]]: + if not self.valves.GOOGLE_API_KEY: + return "Error: GOOGLE_API_KEY is not set" + try: + genai.configure(api_key=self.valves.GOOGLE_API_KEY) + model_id = body["model"] + + if model_id.startswith("google_genai."): + model_id = model_id[12:] + + model_id = model_id.lstrip(".") + + if not model_id.startswith("gemini-"): + return f"Error: Invalid model name format: {model_id}" + + messages = body["messages"] + stream = body.get("stream", False) + + if DEBUG: + print("Incoming body:", str(body)) + + system_message = next( + (msg["content"] for msg in messages if msg["role"] == "system"), None + ) + + contents = [] + for message in messages: + if message["role"] != "system": + if isinstance(message.get("content"), list): + parts = [] + for content in message["content"]: + if content["type"] == "text": + parts.append({"text": content["text"]}) + elif content["type"] == "image_url": + image_url = content["image_url"]["url"] + if image_url.startswith("data:image"): + image_data = image_url.split(",")[1] + parts.append( + { + "inline_data": { + "mime_type": "image/jpeg", + "data": image_data, + } + } + ) + else: + parts.append({"image_url": image_url}) + contents.append({"role": message["role"], "parts": parts}) + else: + contents.append( + { + "role": ( + "user" if message["role"] == "user" else "model" + ), + "parts": [{"text": message["content"]}], + } + ) + + if system_message: + contents.insert( + 0, + {"role": "user", "parts": [{"text": f"System: {system_message}"}]}, + ) + + if "gemini-1.5" in model_id: + model = genai.GenerativeModel( + model_name=model_id, system_instruction=system_message + ) + else: + model = genai.GenerativeModel(model_name=model_id) + + generation_config = GenerationConfig( + temperature=body.get("temperature", 0.7), + top_p=body.get("top_p", 0.9), + top_k=body.get("top_k", 40), + max_output_tokens=body.get("max_tokens", 8192), + stop_sequences=body.get("stop", []), + ) + + # Safety settings omitted for brevity... + if self.valves.USE_PERMISSIVE_SAFETY: + safety_settings = { + genai.types.HarmCategory.HARM_CATEGORY_HARASSMENT: genai.types.HarmBlockThreshold.BLOCK_NONE, + genai.types.HarmCategory.HARM_CATEGORY_HATE_SPEECH: genai.types.HarmBlockThreshold.BLOCK_NONE, + genai.types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: genai.types.HarmBlockThreshold.BLOCK_NONE, + genai.types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: genai.types.HarmBlockThreshold.BLOCK_NONE, + } + else: + safety_settings = body.get("safety_settings") + + if DEBUG: + print("Google API request:") + print(" Model:", model_id) + print(" Contents:", str(contents)) + print(" Generation Config:", generation_config) + print(" Safety Settings:", safety_settings) + print(" Stream:", stream) + + if stream: + + def stream_generator(): + response = model.generate_content( + contents, + generation_config=generation_config, + safety_settings=safety_settings, + stream=True, + ) + for chunk in response: + if chunk.text: + yield chunk.text + + return stream_generator() + else: + response = model.generate_content( + contents, + generation_config=generation_config, + safety_settings=safety_settings, + stream=False, + ) + return response.text + except Exception as e: + if DEBUG: + print(f"Error in pipe method: {e}") + return f"Error: {e}" From 1949dbb407a071c31a98f18464994cda69055bdb Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Mon, 1 Dec 2025 20:32:16 -0500 Subject: [PATCH 11/21] Update Google Vertex AI Pipe to version 2.0.0 with enhanced SDK compliance, automatic resource management, and improved error handling. Refactor client creation methods and streamline model retrieval process. Add exponential backoff for transient errors and update documentation for clarity. --- functions/pipes/google-vertexai/main.py | 165 +++++++++++++----------- 1 file changed, 89 insertions(+), 76 deletions(-) diff --git a/functions/pipes/google-vertexai/main.py b/functions/pipes/google-vertexai/main.py index e538690..dc0171c 100644 --- a/functions/pipes/google-vertexai/main.py +++ b/functions/pipes/google-vertexai/main.py @@ -4,21 +4,23 @@ author_url: https://github.com/owndev/ project_url: https://github.com/owndev/Open-WebUI-Functions funding_url: https://github.com/sponsors/owndev -version: 1.1.1 +version: 2.0.0 requirements: google-genai license: Apache License 2.0 -description: A manifold pipeline for interacting with Google Gemini models, including dynamic model specification, streaming responses, and flexible error handling. +description: A manifold pipeline for interacting with Google Gemini models following SDK best practices with context managers, streaming responses, and comprehensive error handling. features: - - Asynchronous API calls for better performance + - Follows Google GenAI SDK best practices with context managers + - Automatic resource cleanup for sync and async operations - Model caching to reduce API calls - Dynamic model specification with automatic prefix stripping - Streaming response handling with safety checks - Support for multimodal input (text and images) - - Flexible error handling and logging - - Integration with Google Generative AI or Vertex AI API for content generation + - Comprehensive error handling and logging + - Integration with Google Generative AI or Vertex AI API - Support for various generation parameters (temperature, max tokens, etc.) - Customizable safety settings based on environment variables - Encrypted storage of sensitive API keys + - Exponential backoff retry mechanism for transient errors """ import os @@ -169,15 +171,22 @@ def __init__(self): self._model_cache: Optional[List[Dict[str, str]]] = None self._model_cache_time: float = 0 - def _get_client(self) -> genai.Client: + def _create_client(self) -> genai.Client: """ - Validates API credentials and returns a genai.Client instance. + Creates and returns a new genai.Client instance based on configuration. + Validates credentials before creating the client. + + Returns: + genai.Client: A new client instance + + Raises: + ValueError: If credentials are not properly configured """ self._validate_api_key() if self.valves.USE_VERTEX_AI: self.log.debug( - f"Initializing Vertex AI client (Project: {self.valves.VERTEX_PROJECT}, Location: {self.valves.VERTEX_LOCATION})" + f"Creating Vertex AI client (Project: {self.valves.VERTEX_PROJECT}, Location: {self.valves.VERTEX_LOCATION})" ) return genai.Client( vertexai=True, @@ -185,7 +194,7 @@ def _get_client(self) -> genai.Client: location=self.valves.VERTEX_LOCATION, ) else: - self.log.debug("Initializing Google Generative AI client with API Key") + self.log.debug("Creating Google Generative AI client with API Key") return genai.Client(api_key=self.valves.GOOGLE_API_KEY.get_decrypted()) def _validate_api_key(self) -> None: @@ -214,7 +223,8 @@ def _validate_api_key(self) -> None: ) self.log.debug("Using Google Generative AI API with API Key.") - def strip_prefix(self, model_name: str) -> str: + @staticmethod + def strip_prefix(model_name: str) -> str: """ Extract the model identifier using regex, handling various naming conventions. e.g., "google_gemini_pipeline.gemini-2.5-flash-preview-04-17" -> "gemini-2.5-flash-preview-04-17" @@ -247,36 +257,41 @@ def get_google_models(self, force_refresh: bool = False) -> List[Dict[str, str]] return self._model_cache try: - client = self._get_client() - self.log.debug("Fetching models from Google API") - models = client.models.list() - available_models = [] - for model in models: - actions = model.supported_actions - if actions is None or "generateContent" in actions: - available_models.append( - { - "id": self.strip_prefix(model.name), - "name": model.display_name or self.strip_prefix(model.name), - } - ) - - model_map = {model["id"]: model for model in available_models} + # Use context manager for automatic resource cleanup + # Following SDK best practices: https://github.com/googleapis/python-genai + with self._create_client() as client: + self.log.debug("Fetching models from Google API") + + # Fetch and process models + models = list(client.models.list()) + self.log.debug(f"Retrieved {len(models)} total models from Google API") + + available_models = [] + for model in models: + actions = model.supported_actions + if actions is None or "generateContent" in actions: + available_models.append( + { + "id": self.strip_prefix(model.name), + "name": model.display_name or self.strip_prefix(model.name), + } + ) - # Filter map to only include models starting with 'gemini-' - filtered_models = { - k: v for k, v in model_map.items() if k.startswith("gemini-") - } + # Filter to only include Gemini models + model_map = {model["id"]: model for model in available_models} + filtered_models = { + k: v for k, v in model_map.items() if k.startswith("gemini-") + } - # Update cache - self._model_cache = list(filtered_models.values()) - self._model_cache_time = current_time - self.log.debug(f"Found {len(self._model_cache)} Gemini models") - return self._model_cache + # Update cache + self._model_cache = list(filtered_models.values()) + self._model_cache_time = current_time + self.log.debug(f"Found {len(self._model_cache)} Gemini models") + return self._model_cache + # Client is automatically closed here except Exception as e: self.log.exception(f"Could not fetch models from Google: {str(e)}") - # Return a specific error entry for the UI return [{"id": "error", "name": f"Could not fetch models: {str(e)}"}] def pipes(self) -> List[Dict[str, str]]: @@ -480,7 +495,7 @@ def _configure_generation( category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE" ), ] - gen_config_params |= ({"safety_settings": safety_settings},) + gen_config_params["safety_settings"] = safety_settings # Filter out None values for generation config filtered_params = {k: v for k, v in gen_config_params.items() if v is not None} @@ -633,46 +648,48 @@ async def pipe(self, body: Dict[str, Any]) -> Union[str, AsyncIterator[str]]: # Configure generation parameters and safety settings generation_config = self._configure_generation(body, system_instruction) - # Make the API call - client = self._get_client() - if stream: - try: + # Use async context manager for automatic resource cleanup + # Following SDK best practices: https://github.com/googleapis/python-genai + async with self._create_client().aio as aclient: + if stream: + try: - async def get_streaming_response(): - return await client.aio.models.generate_content_stream( - model=model_id, - contents=contents, - config=generation_config, - ) + async def get_streaming_response(): + return await aclient.models.generate_content_stream( + model=model_id, + contents=contents, + config=generation_config, + ) - response_iterator = await self._retry_with_backoff( - get_streaming_response - ) - self.log.debug(f"Request {request_id}: Got streaming response") - return self._handle_streaming_response(response_iterator) + response_iterator = await self._retry_with_backoff( + get_streaming_response + ) + self.log.debug(f"Request {request_id}: Got streaming response") + return self._handle_streaming_response(response_iterator) - except Exception as e: - self.log.exception(f"Error in streaming request {request_id}: {e}") - return f"Error during streaming: {e}" - else: - try: + except Exception as e: + self.log.exception(f"Error in streaming request {request_id}: {e}") + return f"Error during streaming: {e}" + else: + try: - async def get_response(): - return await client.aio.models.generate_content( - model=model_id, - contents=contents, - config=generation_config, - ) + async def get_response(): + return await aclient.models.generate_content( + model=model_id, + contents=contents, + config=generation_config, + ) - response = await self._retry_with_backoff(get_response) - self.log.debug(f"Request {request_id}: Got non-streaming response") - return self._handle_standard_response(response) + response = await self._retry_with_backoff(get_response) + self.log.debug(f"Request {request_id}: Got non-streaming response") + return self._handle_standard_response(response) - except Exception as e: - self.log.exception( - f"Error in non-streaming request {request_id}: {e}" - ) - return f"Error generating content: {e}" + except Exception as e: + self.log.exception( + f"Error in non-streaming request {request_id}: {e}" + ) + return f"Error generating content: {e}" + # Client is automatically closed here except ClientError as ce: error_msg = f"Client error raised by the GenAI API: {ce}." @@ -695,11 +712,7 @@ async def get_response(): return error_msg except Exception as e: - # Log the full error with traceback - import traceback - - error_trace = traceback.format_exc() - self.log.exception(f"Unexpected error: {e}\n{error_trace}") - + # Log the full error + self.log.exception(f"Unexpected error: {e}") # Return a user-friendly error message return f"An error occurred while processing your request: {e}" From 8262129605fc9e5855cda6b3c7a5c42498bc9a76 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Mon, 1 Dec 2025 21:58:00 -0500 Subject: [PATCH 12/21] Refactor Google Vertex AI Pipe to enhance async client management for streaming operations. Update description and features for clarity, emphasizing persistent async client usage and improved error handling. Streamline response handling for both streaming and non-streaming requests. --- functions/pipes/google-vertexai/main.py | 121 +++++++++++++++--------- 1 file changed, 74 insertions(+), 47 deletions(-) diff --git a/functions/pipes/google-vertexai/main.py b/functions/pipes/google-vertexai/main.py index dc0171c..afba8ad 100644 --- a/functions/pipes/google-vertexai/main.py +++ b/functions/pipes/google-vertexai/main.py @@ -7,19 +7,19 @@ version: 2.0.0 requirements: google-genai license: Apache License 2.0 -description: A manifold pipeline for interacting with Google Gemini models following SDK best practices with context managers, streaming responses, and comprehensive error handling. +description: A manifold pipeline for Google Gemini models with optimized client management - context managers for sync operations, persistent client for async operations. features: - - Follows Google GenAI SDK best practices with context managers - - Automatic resource cleanup for sync and async operations - - Model caching to reduce API calls + - Optimized client management (context managers for sync, persistent for async) + - Prevents streaming connector errors with persistent async client + - Model caching to reduce API calls (configurable TTL) - Dynamic model specification with automatic prefix stripping - - Streaming response handling with safety checks - - Support for multimodal input (text and images) + - Streaming and non-streaming response support + - Multimodal input support (text and images) - Comprehensive error handling and logging - - Integration with Google Generative AI or Vertex AI API - - Support for various generation parameters (temperature, max tokens, etc.) - - Customizable safety settings based on environment variables - - Encrypted storage of sensitive API keys + - Dual API support (Generative AI API and Vertex AI) + - Configurable generation parameters (temperature, tokens, etc.) + - Customizable safety settings via environment variables + - Encrypted API key storage with automatic encryption/decryption - Exponential backoff retry mechanism for transient errors """ @@ -171,6 +171,32 @@ def __init__(self): self._model_cache: Optional[List[Dict[str, str]]] = None self._model_cache_time: float = 0 + # Persistent async client for streaming operations + # Streaming requires the client to outlive the request context + # See: https://github.com/googleapis/python-genai/issues/[streaming-context] + self._async_client: Optional[Any] = None + self._async_client_lock = asyncio.Lock() + + async def _get_async_client(self): + """ + Get or create a persistent async client for streaming operations. + + Streaming requires a persistent client because the generator is consumed + outside the request context. Using a context manager would close the client + before the stream is fully consumed, causing connector errors. + + Returns: + Async client instance (.aio) + """ + async with self._async_client_lock: + if self._async_client is not None: + return self._async_client + + self.log.debug("Creating persistent async client for streaming") + base_client = self._create_client() + self._async_client = base_client.aio + return self._async_client + def _create_client(self) -> genai.Client: """ Creates and returns a new genai.Client instance based on configuration. @@ -648,48 +674,49 @@ async def pipe(self, body: Dict[str, Any]) -> Union[str, AsyncIterator[str]]: # Configure generation parameters and safety settings generation_config = self._configure_generation(body, system_instruction) - # Use async context manager for automatic resource cleanup - # Following SDK best practices: https://github.com/googleapis/python-genai - async with self._create_client().aio as aclient: - if stream: - try: - - async def get_streaming_response(): - return await aclient.models.generate_content_stream( - model=model_id, - contents=contents, - config=generation_config, - ) - - response_iterator = await self._retry_with_backoff( - get_streaming_response + # Get persistent async client for all async operations + # We use a persistent client for async because: + # 1. Streaming: generator is consumed outside request context + # 2. Non-streaming: works fine with persistent client too + # This avoids the need to branch on streaming vs non-streaming + aclient = await self._get_async_client() + + if stream: + try: + async def get_streaming_response(): + return await aclient.models.generate_content_stream( + model=model_id, + contents=contents, + config=generation_config, ) - self.log.debug(f"Request {request_id}: Got streaming response") - return self._handle_streaming_response(response_iterator) - except Exception as e: - self.log.exception(f"Error in streaming request {request_id}: {e}") - return f"Error during streaming: {e}" - else: - try: + response_iterator = await self._retry_with_backoff( + get_streaming_response + ) + self.log.debug(f"Request {request_id}: Got streaming response") + return self._handle_streaming_response(response_iterator) - async def get_response(): - return await aclient.models.generate_content( - model=model_id, - contents=contents, - config=generation_config, - ) + except Exception as e: + self.log.exception(f"Error in streaming request {request_id}: {e}") + return f"Error during streaming: {e}" + else: + try: + async def get_response(): + return await aclient.models.generate_content( + model=model_id, + contents=contents, + config=generation_config, + ) - response = await self._retry_with_backoff(get_response) - self.log.debug(f"Request {request_id}: Got non-streaming response") - return self._handle_standard_response(response) + response = await self._retry_with_backoff(get_response) + self.log.debug(f"Request {request_id}: Got non-streaming response") + return self._handle_standard_response(response) - except Exception as e: - self.log.exception( - f"Error in non-streaming request {request_id}: {e}" - ) - return f"Error generating content: {e}" - # Client is automatically closed here + except Exception as e: + self.log.exception( + f"Error in non-streaming request {request_id}: {e}" + ) + return f"Error generating content: {e}" except ClientError as ce: error_msg = f"Client error raised by the GenAI API: {ce}." From f7f4f0521f3c57ff1a2db295ffacf9d566004ab3 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Fri, 23 Jan 2026 15:23:22 -0500 Subject: [PATCH 13/21] added docs and ai agent via agents.md --- AGENTS.md | 268 +++++++ README.md | 18 +- docs/README.md | 369 +++++++++ docs/actions-guide.md | 694 ++++++++++++++++ docs/best-practices.md | 691 ++++++++++++++++ docs/filters-guide.md | 572 ++++++++++++++ docs/pipes-guide.md | 520 ++++++++++++ docs/python-standards.md | 681 ++++++++++++++++ docs/testing-guide.md | 748 ++++++++++++++++++ docs/tools-guide.md | 732 +++++++++++++++++ .../actions/elevenlabs-tts/elevenlabs-tts | 281 +++++++ tools/elevenlabs-tts/elevenlabs-tts.py | 327 ++++++++ 12 files changed, 5900 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 docs/README.md create mode 100644 docs/actions-guide.md create mode 100644 docs/best-practices.md create mode 100644 docs/filters-guide.md create mode 100644 docs/pipes-guide.md create mode 100644 docs/python-standards.md create mode 100644 docs/testing-guide.md create mode 100644 docs/tools-guide.md create mode 100644 functions/actions/elevenlabs-tts/elevenlabs-tts create mode 100644 tools/elevenlabs-tts/elevenlabs-tts.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..413eebb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,268 @@ +# Open WebUI Extension Development Agent Configuration + +This file defines specialized agent personas and context optimized for developing Open WebUI functions, pipes, filters, actions, and tools. + +## Primary Persona: Open WebUI Extension Architect + +You are an expert Open WebUI extension developer with deep knowledge of Python, async programming, Pydantic, and the Open WebUI plugin architecture. You specialize in building high-quality, maintainable extensions that follow current best practices. + +### Core Expertise + +- **Open WebUI Architecture**: Deep understanding of Pipes, Filters, Actions, and Tools +- **Python Best Practices**: Modern Python 3.10+ with strict typing, async/await patterns +- **Pydantic 2**: Type validation, BaseModel usage, Field definitions with proper constraints +- **API Integration**: RESTful APIs, streaming responses, error handling, retry logic +- **Security**: API key management, input validation, sanitization, secure coding practices +- **User Experience**: Event emitters, status updates, confirmation dialogs, progress feedback + +### Development Principles + +1. **Always use async functions** - Open WebUI is moving to fully async execution +2. **Comprehensive type hints** - Every function parameter and return value must have types +3. **Valves for configuration** - Use Pydantic BaseModel for all user-configurable settings +4. **Event-driven feedback** - Use `__event_emitter__` for status updates and user notifications +5. **Graceful error handling** - Always implement try-except blocks with meaningful error messages +6. **Documentation first** - Every function needs a docstring with metadata (title, author, version, requirements) +7. **Security by design** - Never hardcode secrets, validate all inputs, sanitize outputs +8. **Test thoroughly** - Validate with different inputs, edge cases, and error conditions + +### Code Standards + +- Use modern Python syntax: `str | None` instead of `Optional[str]`, `list[str]` instead of `List[str]` +- Prefer `Annotated[type, constraints]` for reusable custom types with validation +- Use descriptive variable names that reflect purpose +- Keep functions focused and single-purpose +- Maximum line length: 100 characters +- Use docstrings for all classes and functions +- Add inline comments for complex logic + +### Open WebUI Specific Knowledge + +#### Function Types + +**Pipes (Custom Models/Agents)** +- Create custom models that appear in the model selector +- Use `pipes()` function to return multiple models (manifold pattern) +- Always extract and use correct model ID from body +- Handle both streaming and non-streaming responses +- Can proxy to external APIs (OpenAI, Anthropic, etc.) + +**Filters (Input/Output Modification)** +- `inlet()`: Pre-process user inputs before sending to model +- `stream()`: Intercept and modify streaming model responses in real-time +- `outlet()`: Post-process model outputs before displaying to user +- Can be global (all models) or model-specific +- Support toggle switches via `self.toggle = True` +- Use priority field in Valves to control execution order + +**Actions (Custom Buttons)** +- Add interactive buttons to message toolbars +- Use `__event_call__` for user confirmations and input +- Can be single action or multi-action (actions array) +- Access message content, files, and user context +- Return modified content or new files + +**Tools (Function Calling)** +- Simple function calls from the model +- Must have comprehensive type hints for JSON schema generation +- Support async execution patterns +- Handle file uploads and processing + +#### Event System + +**Event Emitter Patterns** +```python +await __event_emitter__({ + "type": "status", + "data": {"description": "Processing...", "done": False} +}) + +await __event_emitter__({ + "type": "notification", + "data": {"type": "info", "content": "Success message"} +}) +``` + +**Event Call Patterns** +```python +# Confirmation dialog +response = await __event_call__({ + "type": "confirmation", + "data": { + "title": "Confirm Action", + "message": "Are you sure?" + } +}) + +# Input dialog +user_input = await __event_call__({ + "type": "input", + "data": { + "title": "Enter Value", + "message": "Provide details:", + "placeholder": "Type here..." + } +}) +``` + +#### Metadata Template + +```python +""" +title: Function Name +author: Your Name +author_url: https://github.com/username +funding_url: https://github.com/open-webui +version: 1.0.0 +required_open_webui_version: 0.4.0 +requirements: requests, pydantic +license: MIT +description: Brief description of functionality +""" +``` + +### Common Patterns + +#### Valves Configuration +```python +class Valves(BaseModel): + API_KEY: str = Field( + default="", + description="API key for authentication" + ) + TIMEOUT: int = Field( + default=30, + description="Request timeout in seconds" + ) + OPTION: str = Field( + default="default", + description="Configuration option", + json_schema_extra={"enum": ["option1", "option2", "option3"]} + ) +``` + +#### Error Handling +```python +try: + result = await perform_operation() + await __event_emitter__({ + "type": "status", + "data": {"description": "Success", "done": True} + }) + return result +except Exception as e: + await __event_emitter__({ + "type": "notification", + "data": {"type": "error", "content": f"Failed: {str(e)}"} + }) + return {"content": f"Error: {str(e)}"} +``` + +#### Streaming Response Handling +```python +if body.get("stream", False): + return response.iter_lines() +else: + return response.json() +``` + +### Development Workflow + +1. **Research**: Review similar existing functions in the repository +2. **Design**: Plan Valves (configuration), event flow, error handling +3. **Implement**: Write clean, typed, async code +4. **Document**: Add comprehensive docstrings and metadata +5. **Test**: Validate with various inputs and edge cases +6. **Security review**: Check for vulnerabilities, secrets exposure +7. **Optimize**: Review for performance, caching opportunities +8. **Polish**: Clean up code, add helpful comments + +### File Organization + +- `functions/pipes/` - Custom model integrations +- `functions/filters/` - Input/output processors +- `functions/actions/` - Message toolbar buttons +- `tools/` - Function calling tools +- Each function in its own subdirectory with: + - `main.py` - Main implementation + - `README.md` - Usage documentation + - `LICENSE` - License file (if applicable) + +### Testing Checklist + +- [ ] Function loads without errors +- [ ] Valves configuration saves and loads correctly +- [ ] All async operations work as expected +- [ ] Event emitters show proper status updates +- [ ] Error cases display helpful messages +- [ ] Type hints generate correct JSON schema +- [ ] Streaming responses work correctly +- [ ] No hardcoded secrets or credentials +- [ ] Documentation is clear and complete +- [ ] Code follows repository conventions + +### Common Pitfalls to Avoid + +1. **Forgetting to return body in filters** - Always return the modified body +2. **Synchronous functions** - Use async/await for all I/O operations +3. **Missing type hints** - Required for JSON schema generation +4. **Hardcoded API keys** - Always use Valves for sensitive config +5. **Poor error messages** - Users need actionable feedback +6. **Blocking operations** - Use async libraries, implement timeouts +7. **No status updates** - Use event emitters for long operations +8. **Incorrect model ID extraction** - Parse body["model"] correctly +9. **Missing required_open_webui_version** - Specify minimum version +10. **Over-engineering** - Keep solutions simple and focused + +### References + +- [Open WebUI Documentation](https://docs.openwebui.com/) +- [Functions Overview](https://docs.openwebui.com/features/plugin/functions/) +- [Community Functions](https://openwebui.com/) +- [Repository](https://github.com/open-webui/functions) + +--- + +## When to Use Each Function Type + +**Use a Pipe when:** +- Creating a proxy to an external AI service +- Building a custom agent with specific behavior +- Combining multiple models or services +- Creating non-AI integrations (search, home automation, etc.) + +**Use a Filter when:** +- Modifying user inputs before sending to model (inlet) +- Processing streaming responses in real-time (stream) +- Cleaning up or formatting model outputs (outlet) +- Adding automatic context or instructions +- Implementing content moderation + +**Use an Action when:** +- Adding interactive buttons to messages +- Creating optional user-triggered functionality +- Requiring user confirmation before operations +- Generating visualizations or downloads +- Processing existing message content + +**Use a Tool when:** +- Enabling model function calling capabilities +- Creating utility functions the model can invoke +- Building integrations the model can use automatically +- Providing structured data retrieval + +--- + +## Current Context + +This is the official Open WebUI functions repository containing curated, high-quality extensions approved by the core team. All contributions should: + +- Follow established patterns in existing code +- Include comprehensive testing +- Provide clear documentation +- Maintain backwards compatibility when possible +- Consider security implications +- Optimize for performance +- Enhance user experience + +When working on extensions, always prioritize code quality, maintainability, and user experience over feature complexity. diff --git a/README.md b/README.md index cef39c9..eb9d67a 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,21 @@ Curated custom functions approved by the Open WebUI core team. - ✅ High-quality, reliable, and ready to use - ⚡ Easy integration with your Open WebUI projects +## 📚 Development Resources + +**For Contributors and Developers:** + +- 🤖 **[AGENTS.md](AGENTS.md)** - AI agent personas and context optimized for Open WebUI development +- 📖 **[Documentation](docs/)** - Comprehensive development guides: + - [Best Practices](docs/best-practices.md) - Essential patterns for all extensions + - [Pipes Guide](docs/pipes-guide.md) - Create custom models and integrations + - [Filters Guide](docs/filters-guide.md) - Modify inputs and outputs + - [Actions Guide](docs/actions-guide.md) - Add interactive buttons + - [Tools Guide](docs/tools-guide.md) - Enable function calling + - [Python Standards](docs/python-standards.md) - Coding conventions + - [Testing Guide](docs/testing-guide.md) - Testing strategies + +## 🔗 Official Documentation Check out these links for more information and help with Functions: @@ -14,5 +29,6 @@ Check out these links for more information and help with Functions: - 🪄 [Filter Function](https://docs.openwebui.com/features/plugin/functions/filter) - 🎬 [Action Function](https://docs.openwebui.com/features/plugin/functions/action) +## 🌐 Community -Looking for more? Discover community-contributed functions at [openwebui.com](http://openwebui.com/) 🌐 +Looking for more? Discover community-contributed functions at [openwebui.com](http://openwebui.com/) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..46252c5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,369 @@ +# Open WebUI Extension Development Documentation + +Comprehensive guides for developing high-quality Open WebUI extensions (Pipes, Filters, Actions, and Tools). + +## Quick Start + +1. Read the [Best Practices](best-practices.md) guide +2. Choose your function type and read the specific guide: + - [Pipes Guide](pipes-guide.md) - Custom models and integrations + - [Filters Guide](filters-guide.md) - Input/output modification + - [Actions Guide](actions-guide.md) - Interactive message buttons + - [Tools Guide](tools-guide.md) - Function calling capabilities +3. Review [Python Standards](python-standards.md) for coding conventions +4. Follow the [Testing Guide](testing-guide.md) to validate your work + +## Documentation Overview + +### Core Guides + +**[Best Practices](best-practices.md)** +Essential patterns and practices for all Open WebUI extensions: +- Code quality and modern Python syntax +- Type safety with comprehensive type hints +- Async programming patterns +- Error handling strategies +- Configuration management with Valves +- Security best practices +- Performance optimization +- User experience guidelines +- Documentation standards +- Testing approaches + +**[Python Standards](python-standards.md)** +Python coding conventions specific to Open WebUI development: +- Modern type hints (Python 3.10+) +- Pydantic 2 usage patterns +- Async/await best practices +- Code formatting and style +- Import organization +- Naming conventions + +**[Testing Guide](testing-guide.md)** +Comprehensive testing strategies for extensions: +- Manual testing procedures +- Unit testing examples +- Integration testing approaches +- Performance testing +- Security testing +- Edge case coverage + +### Function-Specific Guides + +**[Pipes Guide](pipes-guide.md)** +Build custom models and integrations: +- When to use Pipes +- Basic structure and patterns +- Creating multiple models (manifold pattern) +- Handling streaming responses +- Model ID extraction +- OpenAI/Anthropic/Google proxy examples +- Using internal Open WebUI functions +- Error handling and retry logic +- Performance optimization +- Complete working examples + +**[Filters Guide](filters-guide.md)** +Modify inputs, outputs, and streaming responses: +- When to use Filters +- Always-on vs toggleable filters +- inlet() - Pre-process user inputs +- stream() - Modify streaming responses +- outlet() - Post-process model outputs +- Filter priority and execution order +- Global vs model-specific configuration +- Content moderation examples +- Context injection patterns +- Translation and logging examples + +**[Actions Guide](actions-guide.md)** +Create interactive message toolbar buttons: +- When to use Actions +- Basic structure and patterns +- Event system integration +- User confirmations and input dialogs +- Single vs multi-action functions +- Working with uploaded files +- User permission checks +- Background task execution +- Complete working examples (summarizer, code formatter) + +**[Tools Guide](tools-guide.md)** +Enable model function calling: +- When to use Tools +- Critical requirements (type hints, docstrings) +- Structured return types with TypedDict +- Multiple tools in one class +- Complex parameter types +- Error handling patterns +- Caching and rate limiting +- Complete web search tool example + +## Function Type Decision Guide + +### Choose a Pipe when: +- Creating a proxy to an external AI service (OpenAI, Anthropic, Google) +- Building a custom agent with specific behavior +- Combining multiple models or services +- Creating non-AI integrations (search, home automation, APIs) +- You want it to appear as a selectable "model" + +### Choose a Filter when: +- Modifying user inputs before sending to model (add context, sanitize) +- Processing streaming responses in real-time +- Cleaning up or formatting model outputs +- Implementing content moderation or PII scrubbing +- Adding automatic instructions or context +- Logging conversations + +### Choose an Action when: +- Adding interactive buttons to the message toolbar +- Creating optional user-triggered functionality +- Requiring user confirmation before operations +- Generating visualizations or downloads from messages +- Processing or transforming existing message content +- Providing quick access to common operations + +### Choose a Tool when: +- Enabling model function calling capabilities +- Creating utility functions the model can invoke automatically +- Building integrations the model uses based on conversation +- Providing structured data retrieval on demand +- Performing calculations or data processing when needed + +## Quick Reference Templates + +### Pipe Template +```python +""" +title: My Pipe +author: Your Name +version: 1.0.0 +required_open_webui_version: 0.4.0 +requirements: aiohttp +""" + +from pydantic import BaseModel, Field +from typing import Any, Callable + +class Pipe: + class Valves(BaseModel): + API_KEY: str = Field(default="") + + def __init__(self): + self.valves = self.Valves() + + async def pipe( + self, + body: dict[str, Any], + __user__: dict | None = None, + __event_emitter__: Callable | None = None, + ) -> dict | str: + # Implementation + pass +``` + +### Filter Template +```python +""" +title: My Filter +author: Your Name +version: 1.0.0 +required_open_webui_version: 0.4.0 +""" + +from pydantic import BaseModel, Field + +class Filter: + class Valves(BaseModel): + priority: int = Field(default=0) + + def __init__(self): + self.valves = self.Valves() + self.toggle = True # Optional: make toggleable + + async def inlet(self, body: dict) -> dict: + return body + + def stream(self, event: dict) -> dict: + return event + + async def outlet(self, body: dict) -> dict: + return body +``` + +### Action Template +```python +""" +title: My Action +author: Your Name +version: 1.0.0 +required_open_webui_version: 0.4.0 +icon_url: data:image/svg+xml;base64,... +""" + +from pydantic import BaseModel +from typing import Any, Callable + +class Action: + class Valves(BaseModel): + pass + + def __init__(self): + self.valves = self.Valves() + + async def action( + self, + body: dict[str, Any], + __event_emitter__: Callable | None = None, + __event_call__: Callable | None = None, + ) -> dict[str, Any]: + return {"content": "Action result"} +``` + +### Tool Template +```python +""" +title: My Tool +author: Your Name +version: 1.0.0 +required_open_webui_version: 0.4.0 +""" + +from pydantic import BaseModel +from typing import Any, Callable + +class Tools: + class Valves(BaseModel): + pass + + def __init__(self): + self.valves = self.Valves() + + async def my_function( + self, + parameter: str, + __event_emitter__: Callable | None = None, + ) -> str: + """ + Function description for the model. + + :param parameter: Parameter description + :return: Return value description + """ + return f"Result: {parameter}" +``` + +## Development Workflow + +1. **Plan**: Determine which function type fits your use case +2. **Research**: Review similar existing functions in the repository +3. **Design**: Plan Valves configuration, event flow, error handling +4. **Implement**: Write clean, typed, async code following standards +5. **Document**: Add comprehensive docstrings and metadata +6. **Test**: Validate with various inputs and edge cases +7. **Security Review**: Check for vulnerabilities, secrets exposure +8. **Optimize**: Review for performance, caching opportunities +9. **Polish**: Clean up code, add helpful comments +10. **Submit**: Create PR with clear description and examples + +## Key Principles + +### 1. Always Use Async +Open WebUI is moving to fully async execution. All I/O operations must be async. + +### 2. Complete Type Hints +Every parameter and return value needs type hints. They generate the JSON schema. + +### 3. Comprehensive Error Handling +Always use try-except blocks with meaningful error messages and notifications. + +### 4. Event-Driven Feedback +Use `__event_emitter__` for status updates and notifications on long operations. + +### 5. Security First +Never hardcode secrets, validate all inputs, sanitize outputs. + +### 6. Test Thoroughly +Validate with different inputs, edge cases, errors, and concurrent requests. + +### 7. Document Everything +Clear docstrings, metadata, README files, and inline comments. + +## Common Patterns + +### Status Updates +```python +if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": {"description": "Processing...", "done": False} + }) +``` + +### User Confirmation +```python +if __event_call__: + confirmed = await __event_call__({ + "type": "confirmation", + "data": {"title": "Confirm", "message": "Proceed?"} + }) +``` + +### Error Notification +```python +if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": {"type": "error", "content": "Operation failed"} + }) +``` + +## External Resources + +### Official Documentation +- [Open WebUI Docs](https://docs.openwebui.com/) +- [Functions Overview](https://docs.openwebui.com/features/plugin/functions/) +- [Event System](https://docs.openwebui.com/features/plugin/events/) + +### Community +- [Open WebUI GitHub](https://github.com/open-webui/open-webui) +- [Functions Repository](https://github.com/open-webui/functions) +- [Community Functions](https://openwebui.com/) + +### Python Resources +- [Type Hints Best Practices](https://typing.python.org/en/latest/reference/best_practices.html) +- [Pydantic Documentation](https://docs.pydantic.dev/) +- [Async Python Guide](https://docs.python.org/3/library/asyncio.html) +- [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices) + +## Getting Help + +1. Check this documentation first +2. Review similar functions in the repository +3. Search existing GitHub issues +4. Ask in Open WebUI Discord/community +5. Create a GitHub issue with: + - Clear description of problem + - Code example (minimal reproducible example) + - Expected vs actual behavior + - Environment details (Open WebUI version, Python version) + +## Contributing + +When contributing to the Open WebUI functions repository: + +1. Follow all guidelines in this documentation +2. Ensure your code passes all tests +3. Include comprehensive documentation +4. Add usage examples +5. Update README if adding new patterns +6. Follow existing code conventions +7. Make atomic, focused commits +8. Write clear PR descriptions + +--- + +For the most up-to-date information, always refer to the [official Open WebUI documentation](https://docs.openwebui.com/). + +Happy coding! 🚀 diff --git a/docs/actions-guide.md b/docs/actions-guide.md new file mode 100644 index 0000000..4f2b95f --- /dev/null +++ b/docs/actions-guide.md @@ -0,0 +1,694 @@ +# Actions Development Guide + +Actions are custom buttons that appear in the message toolbar, allowing users to interact with messages through clickable interfaces. Use Actions for optional, user-triggered functionality. + +## When to Use Actions + +Use an Action when you want to: + +- Add interactive buttons to messages +- Create optional user-triggered functionality +- Require user confirmation before operations +- Generate visualizations or downloads from message content +- Process or transform existing messages +- Provide quick access to common operations + +## Basic Structure + +```python +""" +title: My Custom Action +author: Your Name +version: 1.0.0 +required_open_webui_version: 0.4.0 +requirements: requests +icon_url: data:image/svg+xml;base64,... +""" + +from pydantic import BaseModel, Field +from typing import Any, Callable + +class Action: + class Valves(BaseModel): + API_KEY: str = Field( + default="", + description="API key for service" + ) + + def __init__(self): + self.valves = self.Valves() + + async def action( + self, + body: dict[str, Any], + __user__: dict[str, Any] | None = None, + __event_emitter__: Callable[[dict], Any] | None = None, + __event_call__: Callable[[dict], Any] | None = None, + ) -> dict[str, Any]: + """ + Process action request. + + Args: + body: Message data and context + __user__: User information + __event_emitter__: Send status updates/notifications + __event_call__: Request user input/confirmation + + Returns: + Modified message content + """ + # Your implementation + return {"content": "Action completed"} +``` + +## Action Method Parameters + +### body: dict + +Contains message data and context: + +```python +{ + "messages": [ + {"role": "user", "content": "..."}, + {"role": "assistant", "content": "..."} + ], + "content": "Current message content", + "files": [ + {"type": "image", "url": "...", "name": "..."} + ] +} +``` + +### __user__: dict + +User information: + +```python +{ + "id": "user-123", + "name": "John Doe", + "email": "john@example.com", + "role": "user" # or "admin" +} +``` + +### __event_emitter__: Callable + +Send real-time updates: + +```python +# Status update +await __event_emitter__({ + "type": "status", + "data": { + "description": "Processing...", + "done": False + } +}) + +# Notification +await __event_emitter__({ + "type": "notification", + "data": { + "type": "info", # or "success", "warning", "error" + "content": "Operation completed" + } +}) +``` + +### __event_call__: Callable + +Request user input: + +```python +# Confirmation dialog +response = await __event_call__({ + "type": "confirmation", + "data": { + "title": "Confirm Action", + "message": "Are you sure you want to proceed?" + } +}) +# Returns: True or False + +# Input dialog +user_input = await __event_call__({ + "type": "input", + "data": { + "title": "Enter Value", + "message": "Please provide details:", + "placeholder": "Type here..." + } +}) +# Returns: str (user input) +``` + +### __model__: dict (optional) + +Model information: + +```python +{ + "id": "gpt-4", + "name": "GPT-4" +} +``` + +### __request__: Request (optional) + +FastAPI request object for accessing headers, etc. + +## Single Action Example + +```python +""" +title: Message Summarizer +author: Open WebUI Team +version: 1.0.0 +required_open_webui_version: 0.4.0 +description: Summarize message content with configurable length +icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiPjxwYXRoIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiBkPSJNNCA2aDE2TTQgMTJoMTZNNCAxOGg3Ii8+PC9zdmc+ +""" + +from pydantic import BaseModel, Field +from typing import Any, Callable +import aiohttp + +class Action: + class Valves(BaseModel): + API_KEY: str = Field( + default="", + description="OpenAI API key" + ) + MAX_LENGTH: int = Field( + default=100, + ge=50, + le=500, + description="Maximum summary length in words" + ) + + def __init__(self): + self.valves = self.Valves() + + async def action( + self, + body: dict[str, Any], + __user__: dict[str, Any] | None = None, + __event_emitter__: Callable[[dict], Any] | None = None, + __event_call__: Callable[[dict], Any] | None = None, + ) -> dict[str, Any]: + """Summarize the message content.""" + + # Check API key + if not self.valves.API_KEY: + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": { + "type": "error", + "content": "API key not configured" + } + }) + return {"content": "Error: API key required"} + + # Get message content + content = body.get("content", "") + + if not content: + return {"content": "Error: No content to summarize"} + + # Request confirmation + if __event_call__: + confirmed = await __event_call__({ + "type": "confirmation", + "data": { + "title": "Summarize Message", + "message": f"Summarize this message ({len(content)} chars)?" + } + }) + + if not confirmed: + return {"content": "Summarization cancelled"} + + # Show progress + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": "Generating summary...", + "done": False + } + }) + + try: + # Call summarization API + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4", + "messages": [ + { + "role": "system", + "content": f"Summarize the following text in {self.valves.MAX_LENGTH} words or less." + }, + { + "role": "user", + "content": content + } + ] + }, + headers={ + "Authorization": f"Bearer {self.valves.API_KEY}", + "Content-Type": "application/json" + }, + timeout=30 + ) as response: + response.raise_for_status() + result = await response.json() + + summary = result["choices"][0]["message"]["content"] + + # Success notification + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": "Summary generated", + "done": True + } + }) + + await __event_emitter__({ + "type": "notification", + "data": { + "type": "success", + "content": "Message summarized successfully" + } + }) + + # Return modified content + return { + "content": f"**Summary:**\n\n{summary}\n\n---\n\n**Original:**\n\n{content}" + } + + except Exception as e: + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": { + "type": "error", + "content": f"Summarization failed: {str(e)}" + } + }) + + return {"content": f"Error: {str(e)}"} +``` + +## Multi-Action Example + +Define multiple actions with an `actions` array: + +```python +""" +title: Text Utilities +author: Open WebUI Team +version: 1.0.0 +required_open_webui_version: 0.4.0 +description: Multiple text processing actions +""" + +from pydantic import BaseModel +from typing import Any, Callable + +class Action: + class Valves(BaseModel): + pass + + def __init__(self): + self.valves = self.Valves() + + # Define available actions + actions = [ + { + "id": "uppercase", + "name": "Convert to Uppercase", + "icon_url": "data:image/svg+xml;base64,..." + }, + { + "id": "lowercase", + "name": "Convert to Lowercase", + "icon_url": "data:image/svg+xml;base64,..." + }, + { + "id": "word_count", + "name": "Count Words", + "icon_url": "data:image/svg+xml;base64,..." + } + ] + + async def action( + self, + body: dict[str, Any], + __id__: str | None = None, + __event_emitter__: Callable | None = None, + ) -> dict[str, Any]: + """Process action based on __id__.""" + + content = body.get("content", "") + + if __id__ == "uppercase": + return {"content": content.upper()} + + elif __id__ == "lowercase": + return {"content": content.lower()} + + elif __id__ == "word_count": + word_count = len(content.split()) + + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": { + "type": "info", + "content": f"Word count: {word_count}" + } + }) + + return { + "content": f"{content}\n\n---\n**Word count:** {word_count}" + } + + return {"content": "Unknown action"} +``` + +## Working with Files + +Actions can process uploaded files: + +```python +async def action(self, body: dict, __event_emitter__=None) -> dict: + """Process uploaded images.""" + + files = body.get("files", []) + + if not files: + return {"content": "No files to process"} + + processed_files = [] + + for file in files: + if file.get("type") == "image": + # Process image + processed = await self.process_image(file["url"]) + + processed_files.append({ + "type": "image", + "url": processed["url"], + "name": f"processed_{file['name']}" + }) + + return { + "content": "Images processed successfully", + "files": processed_files + } +``` + +## User Permission Checks + +Restrict actions based on user role: + +```python +async def action(self, body: dict, __user__=None, __event_emitter__=None): + """Admin-only action.""" + + # Check user role + if not __user__ or __user__.get("role") != "admin": + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": { + "type": "error", + "content": "This action requires admin privileges" + } + }) + return {"content": "Access denied"} + + # Proceed with admin action + return {"content": "Admin action completed"} +``` + +## Background Task Example + +For long-running operations: + +```python +import asyncio + +async def action(self, body: dict, __event_emitter__=None): + """Long-running operation with progress updates.""" + + steps = ["Initializing", "Processing", "Finalizing"] + + for i, step in enumerate(steps): + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": f"{step}... ({i+1}/{len(steps)})", + "done": False + } + }) + + # Simulate work + await asyncio.sleep(2) + + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": "Complete", + "done": True + } + }) + + return {"content": "Operation completed successfully"} +``` + +## Global vs Model-Specific Actions + +### Global Actions + +Apply to all models: + +1. Admin Panel → Functions → Actions +2. Click three-dot menu (⋮) +3. Toggle Globe icon (🌐) +4. Ensure action is Active + +### Model-Specific Actions + +Apply to specific models: + +1. Model Settings → Actions +2. Select actions to enable for this model + +## Complete Example: Code Formatter + +```python +""" +title: Code Formatter +author: Open WebUI Team +version: 1.0.0 +required_open_webui_version: 0.4.0 +requirements: black, isort +description: Format Python code in messages +icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiPjxwYXRoIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiBkPSJNNyA4aDEwTTcgMTJoNE0xMCAxNmg3TTYgMjBoMTJhMiAyIDAgMCAwIDItMlY2YTIgMiAwIDAgMC0yLTJINmEyIDIgMCAwIDAtMiAydjEyYTIgMiAwIDAgMCAyIDJ6Ii8+PC9zdmc+ +""" + +from pydantic import BaseModel, Field +from typing import Any, Callable +import re +import black +import isort + +class Action: + class Valves(BaseModel): + line_length: int = Field( + default=88, + ge=50, + le=120, + description="Maximum line length for formatting" + ) + sort_imports: bool = Field( + default=True, + description="Sort imports using isort" + ) + + def __init__(self): + self.valves = self.Valves() + + def extract_code_blocks(self, content: str) -> list[tuple[str, str]]: + """Extract Python code blocks from markdown.""" + pattern = r'```python\n(.*?)\n```' + matches = re.findall(pattern, content, re.DOTALL) + return matches + + def format_code(self, code: str) -> str: + """Format Python code using black and isort.""" + try: + # Sort imports + if self.valves.sort_imports: + code = isort.code(code) + + # Format with black + code = black.format_str( + code, + mode=black.Mode(line_length=self.valves.line_length) + ) + + return code.strip() + + except Exception as e: + raise ValueError(f"Formatting failed: {str(e)}") + + async def action( + self, + body: dict[str, Any], + __event_emitter__: Callable | None = None, + __event_call__: Callable | None = None, + ) -> dict[str, Any]: + """Format Python code blocks in the message.""" + + content = body.get("content", "") + + if not content: + return {"content": "No content to format"} + + # Extract code blocks + code_blocks = self.extract_code_blocks(content) + + if not code_blocks: + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": { + "type": "warning", + "content": "No Python code blocks found" + } + }) + return {"content": content} + + # Request confirmation + if __event_call__: + confirmed = await __event_call__({ + "type": "confirmation", + "data": { + "title": "Format Code", + "message": f"Format {len(code_blocks)} Python code block(s)?" + } + }) + + if not confirmed: + return {"content": content} + + # Show progress + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": "Formatting code...", + "done": False + } + }) + + try: + # Format each code block + formatted_content = content + + for i, code_block in enumerate(code_blocks): + formatted_code = self.format_code(code_block) + + # Replace in content + original_block = f"```python\n{code_block}\n```" + formatted_block = f"```python\n{formatted_code}\n```" + formatted_content = formatted_content.replace( + original_block, + formatted_block, + 1 # Replace only first occurrence + ) + + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": f"Formatted {i+1}/{len(code_blocks)} blocks", + "done": False + } + }) + + # Success + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": "Formatting complete", + "done": True + } + }) + + await __event_emitter__({ + "type": "notification", + "data": { + "type": "success", + "content": f"Formatted {len(code_blocks)} code block(s)" + } + }) + + return {"content": formatted_content} + + except Exception as e: + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": { + "type": "error", + "content": f"Formatting failed: {str(e)}" + } + }) + + return {"content": content} # Return original on error +``` + +## Testing Checklist + +- [ ] Action appears in message toolbar +- [ ] Button icon displays correctly +- [ ] Button name is clear +- [ ] Action executes without errors +- [ ] Confirmation dialogs work +- [ ] Input dialogs work +- [ ] Status updates display +- [ ] Notifications appear +- [ ] Error handling works +- [ ] User permissions respected +- [ ] Files processed correctly (if applicable) +- [ ] Performance is acceptable + +## Common Pitfalls + +1. **Not requesting confirmation** - Ask before destructive operations +2. **No status updates** - Show progress for long operations +3. **Poor error messages** - Provide actionable feedback +4. **Ignoring user permissions** - Check roles when needed +5. **Blocking operations** - Use async for I/O +6. **Missing icon** - Actions look better with custom icons +7. **Not handling cancellation** - Respect when user cancels +8. **Modifying wrong content** - Validate body structure first + +## Additional Resources + +- [Action Function Documentation](https://docs.openwebui.com/features/plugin/functions/action) +- [Example Actions](https://openwebui.com/search?type=action) +- [Event System Guide](https://docs.openwebui.com/features/plugin/events/) diff --git a/docs/best-practices.md b/docs/best-practices.md new file mode 100644 index 0000000..8bc83b2 --- /dev/null +++ b/docs/best-practices.md @@ -0,0 +1,691 @@ +# Open WebUI Extension Development Best Practices + +This guide outlines best practices for developing high-quality Open WebUI extensions (Pipes, Filters, Actions, and Tools). + +## Table of Contents + +- [Code Quality](#code-quality) +- [Type Safety](#type-safety) +- [Async Programming](#async-programming) +- [Error Handling](#error-handling) +- [Configuration Management](#configuration-management) +- [Security](#security) +- [Performance](#performance) +- [User Experience](#user-experience) +- [Documentation](#documentation) +- [Testing](#testing) + +## Code Quality + +### Use Modern Python Syntax + +```python +# Good: Modern union syntax (Python 3.10+) +def process(value: str | None) -> list[dict[str, str]]: + pass + +# Avoid: Old-style typing imports +from typing import Optional, List, Dict +def process(value: Optional[str]) -> List[Dict[str, str]]: + pass +``` + +### Follow PEP 8 with Modifications + +- Maximum line length: 100 characters (not 79) +- Use double quotes for strings +- Use trailing commas in multi-line collections +- Group imports: standard library, third-party, local + +```python +# Good +from typing import Any, Callable +import asyncio + +from pydantic import BaseModel, Field +import requests + +from open_webui.utils.chat import generate_chat_completion +``` + +### Keep Functions Focused + +Each function should do one thing well: + +```python +# Good: Single responsibility +async def fetch_data(url: str) -> dict: + """Fetch data from URL.""" + pass + +async def process_data(data: dict) -> str: + """Process fetched data.""" + pass + +# Avoid: Multiple responsibilities +async def fetch_and_process(url: str) -> str: + """Fetch and process data.""" + # Too much in one function + pass +``` + +## Type Safety + +### Comprehensive Type Hints + +Type hints are **required** for Open WebUI functions. They generate the JSON schema used by models. + +```python +from typing import Any, Callable +from pydantic import BaseModel, Field + +# Good: Complete type hints +async def action( + self, + body: dict[str, Any], + __user__: dict[str, Any] | None = None, + __event_emitter__: Callable[[dict], Any] | None = None, +) -> dict[str, Any]: + pass + +# Avoid: Missing type hints +async def action(self, body, __user__=None, __event_emitter__=None): + pass +``` + +### Use Pydantic for Validation + +```python +from pydantic import BaseModel, Field, validator + +class Valves(BaseModel): + api_key: str = Field(default="", min_length=1) + timeout: int = Field(default=30, ge=1, le=300) + retries: int = Field(default=3, ge=0, le=10) + + @validator("api_key") + def validate_api_key(cls, v): + if v and not v.startswith("sk-"): + raise ValueError("API key must start with 'sk-'") + return v +``` + +### Leverage Annotated Types + +```python +from typing import Annotated +from pydantic import Field, AfterValidator + +def validate_url(v: str) -> str: + if not v.startswith(("http://", "https://")): + raise ValueError("URL must start with http:// or https://") + return v + +Url = Annotated[str, AfterValidator(validate_url)] + +class Valves(BaseModel): + api_url: Url = Field(default="https://api.example.com") +``` + +## Async Programming + +### Always Use Async for I/O Operations + +Open WebUI is moving to fully async execution. All I/O operations must be async. + +```python +# Good: Async I/O +async def fetch_data(self, url: str) -> dict: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + return await response.json() + +# Avoid: Synchronous I/O (blocks event loop) +def fetch_data(self, url: str) -> dict: + response = requests.get(url) + return response.json() +``` + +### Use Async Libraries + +- `aiohttp` instead of `requests` +- `asyncio.sleep()` instead of `time.sleep()` +- `aiofiles` for file I/O +- Native async support for databases + +```python +import asyncio +import aiohttp + +async def fetch_with_retry(url: str, max_retries: int = 3) -> dict: + for attempt in range(max_retries): + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=30) as response: + response.raise_for_status() + return await response.json() + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + if attempt == max_retries - 1: + raise + await asyncio.sleep(2 ** attempt) # Exponential backoff +``` + +### Handle Concurrent Operations + +```python +import asyncio + +async def process_multiple(urls: list[str]) -> list[dict]: + """Process multiple URLs concurrently.""" + tasks = [fetch_data(url) for url in urls] + return await asyncio.gather(*tasks, return_exceptions=True) +``` + +## Error Handling + +### Always Implement Try-Except Blocks + +```python +async def pipe(self, body: dict, __event_emitter__=None) -> dict | str: + try: + # Operation + result = await perform_operation() + + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": {"description": "Success", "done": True} + }) + + return result + + except ValueError as e: + # Specific error handling + error_msg = f"Invalid input: {str(e)}" + + except requests.exceptions.Timeout: + error_msg = "Request timed out. Please try again." + + except Exception as e: + # Catch-all for unexpected errors + error_msg = f"Unexpected error: {str(e)}" + + # Send error notification + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": {"type": "error", "content": error_msg} + }) + + return {"content": error_msg} +``` + +### Provide Meaningful Error Messages + +```python +# Good: Actionable error message +"API key is invalid. Please check your Valves configuration and ensure the key starts with 'sk-'." + +# Avoid: Vague error message +"Error occurred" +``` + +### Log Errors for Debugging + +```python +import logging + +logger = logging.getLogger(__name__) + +try: + result = await operation() +except Exception as e: + logger.error(f"Operation failed: {e}", exc_info=True) + raise +``` + +## Configuration Management + +### Use Valves for All Configuration + +```python +class Valves(BaseModel): + # Required settings + api_key: str = Field( + default="", + description="API key for authentication. Required." + ) + + # Optional settings with sensible defaults + api_url: str = Field( + default="https://api.example.com", + description="Base URL for API requests" + ) + + timeout: int = Field( + default=30, + ge=1, + le=300, + description="Request timeout in seconds (1-300)" + ) + + # Dropdown options using enum + mode: str = Field( + default="standard", + description="Processing mode", + json_schema_extra={"enum": ["standard", "advanced", "custom"]} + ) +``` + +### Validate Configuration on Init + +```python +def __init__(self): + self.valves = self.Valves() + self._validate_configuration() + +def _validate_configuration(self): + """Validate configuration on initialization.""" + if not self.valves.api_key: + logger.warning("API key not configured. Please set in Valves.") +``` + +## Security + +### Never Hardcode Secrets + +```python +# Good: Use Valves +class Valves(BaseModel): + api_key: str = Field(default="", description="API key") + +# Avoid: Hardcoded secrets +API_KEY = "sk-1234567890abcdef" # NEVER DO THIS +``` + +### Validate and Sanitize Inputs + +```python +import re + +def sanitize_input(text: str) -> str: + """Remove potentially harmful content from input.""" + # Remove script tags + text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) + # Remove other potentially harmful patterns + return text.strip() + +async def inlet(self, body: dict) -> dict: + """Pre-process user input with sanitization.""" + if body.get("messages"): + last_message = body["messages"][-1] + if "content" in last_message: + last_message["content"] = sanitize_input(last_message["content"]) + return body +``` + +### Implement Rate Limiting + +```python +import time +from collections import deque + +class RateLimiter: + def __init__(self, max_calls: int, period: float): + self.max_calls = max_calls + self.period = period + self.calls = deque() + + async def acquire(self): + """Wait if rate limit is exceeded.""" + now = time.time() + + # Remove old calls outside the period + while self.calls and self.calls[0] < now - self.period: + self.calls.popleft() + + # Wait if limit reached + if len(self.calls) >= self.max_calls: + sleep_time = self.period - (now - self.calls[0]) + if sleep_time > 0: + await asyncio.sleep(sleep_time) + + self.calls.append(time.time()) +``` + +### Secure External API Calls + +```python +import ssl +import certifi + +# Use verified SSL connections +ssl_context = ssl.create_default_context(cafile=certifi.where()) + +async with aiohttp.ClientSession() as session: + async with session.get( + url, + ssl=ssl_context, + headers={"Authorization": f"Bearer {self.valves.api_key}"} + ) as response: + return await response.json() +``` + +## Performance + +### Implement Caching + +```python +from functools import lru_cache +import time + +class SmartCache: + def __init__(self, ttl: int = 3600): + self._cache = {} + self._timestamps = {} + self.ttl = ttl + + def get(self, key: str) -> Any | None: + if key in self._cache: + if time.time() - self._timestamps[key] < self.ttl: + return self._cache[key] + else: + del self._cache[key] + del self._timestamps[key] + return None + + def set(self, key: str, value: Any): + self._cache[key] = value + self._timestamps[key] = time.time() +``` + +### Use Connection Pooling + +```python +import aiohttp + +class Pipe: + def __init__(self): + self.valves = self.Valves() + self._session = None + + async def get_session(self) -> aiohttp.ClientSession: + """Get or create persistent session with connection pooling.""" + if self._session is None or self._session.closed: + connector = aiohttp.TCPConnector( + limit=100, + limit_per_host=30, + ttl_dns_cache=300 + ) + self._session = aiohttp.ClientSession(connector=connector) + return self._session +``` + +### Implement Timeouts + +```python +import asyncio + +# Set timeout for operations +try: + result = await asyncio.wait_for( + slow_operation(), + timeout=self.valves.timeout + ) +except asyncio.TimeoutError: + return {"content": "Operation timed out"} +``` + +## User Experience + +### Provide Status Updates + +```python +async def pipe(self, body: dict, __event_emitter__=None): + if __event_emitter__: + # Starting + await __event_emitter__({ + "type": "status", + "data": {"description": "Starting process...", "done": False} + }) + + # Progress update + await __event_emitter__({ + "type": "status", + "data": {"description": "Processing data...", "done": False} + }) + + # Completion + await __event_emitter__({ + "type": "status", + "data": {"description": "Complete", "done": True} + }) +``` + +### Request Confirmation for Destructive Actions + +```python +async def action(self, body: dict, __event_call__=None): + if __event_call__: + response = await __event_call__({ + "type": "confirmation", + "data": { + "title": "Confirm Deletion", + "message": "This will permanently delete the data. Continue?" + } + }) + + if not response: + return {"content": "Action cancelled"} +``` + +### Use Appropriate Notification Types + +```python +# Info notification +await __event_emitter__({ + "type": "notification", + "data": {"type": "info", "content": "Processing started"} +}) + +# Success notification +await __event_emitter__({ + "type": "notification", + "data": {"type": "success", "content": "Operation completed successfully"} +}) + +# Warning notification +await __event_emitter__({ + "type": "notification", + "data": {"type": "warning", "content": "API rate limit approaching"} +}) + +# Error notification +await __event_emitter__({ + "type": "notification", + "data": {"type": "error", "content": "Operation failed"} +}) +``` + +## Documentation + +### Complete Metadata in Docstring + +```python +""" +title: Advanced Data Processor +author: Your Name +author_url: https://github.com/username +funding_url: https://github.com/open-webui +version: 1.2.0 +required_open_webui_version: 0.4.0 +requirements: aiohttp, pydantic, tiktoken +license: MIT +description: Advanced data processing with caching, retry logic, and rate limiting. Supports multiple output formats and streaming responses. +""" +``` + +### Document Valves Configuration + +```python +class Valves(BaseModel): + api_key: str = Field( + default="", + description="Your API key from https://platform.example.com/api-keys" + ) + + max_retries: int = Field( + default=3, + ge=0, + le=10, + description="Number of retry attempts for failed requests (0-10)" + ) +``` + +### Add Inline Comments for Complex Logic + +```python +# Extract model ID from the full model name +# Format: "provider.model_id" -> "model_id" +model_id = body["model"].split(".", 1)[1] if "." in body["model"] else body["model"] + +# Prepare payload with extracted model ID +payload = {**body, "model": model_id} +``` + +### Create README Files + +Each function should have a README.md with: + +- Overview and purpose +- Installation requirements +- Configuration instructions +- Usage examples +- Troubleshooting tips +- Known limitations + +## Testing + +### Test Coverage Checklist + +- [ ] Function loads without errors +- [ ] Valid inputs produce expected outputs +- [ ] Invalid inputs show helpful error messages +- [ ] Edge cases are handled (empty strings, null values, etc.) +- [ ] Async operations complete correctly +- [ ] Event emitters send proper notifications +- [ ] Streaming responses work +- [ ] Configuration validation works +- [ ] Error recovery functions properly +- [ ] Performance is acceptable under load + +### Manual Testing Scenarios + +1. **Happy path**: Valid configuration, normal inputs +2. **Missing configuration**: Empty API keys, missing URLs +3. **Invalid inputs**: Malformed data, unexpected types +4. **Network errors**: Timeouts, connection failures +5. **API errors**: Rate limits, authentication failures +6. **Edge cases**: Very long inputs, special characters +7. **Concurrent requests**: Multiple simultaneous operations + +### Example Test Cases + +```python +# Test 1: Valid operation +body = { + "messages": [{"role": "user", "content": "test"}], + "model": "test-model" +} +result = await pipe.pipe(body) +assert result is not None + +# Test 2: Missing API key +pipe.valves.api_key = "" +result = await pipe.pipe(body) +assert "error" in str(result).lower() + +# Test 3: Timeout handling +pipe.valves.timeout = 0.001 # Very short timeout +result = await pipe.pipe(body) +# Should handle timeout gracefully +``` + +--- + +## Quick Reference + +### Function Template + +```python +""" +title: Function Name +author: Author Name +version: 1.0.0 +required_open_webui_version: 0.4.0 +requirements: aiohttp, pydantic +license: MIT +""" + +from pydantic import BaseModel, Field +from typing import Any, Callable +import asyncio + +class Pipe: # or Filter, Action, Tools + class Valves(BaseModel): + api_key: str = Field(default="", description="API key") + timeout: int = Field(default=30, ge=1, description="Timeout in seconds") + + def __init__(self): + self.valves = self.Valves() + + async def pipe( + self, + body: dict[str, Any], + __user__: dict[str, Any] | None = None, + __event_emitter__: Callable[[dict], Any] | None = None, + ) -> dict[str, Any] | str: + try: + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": {"description": "Processing...", "done": False} + }) + + # Your logic here + result = await self.process(body) + + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": {"description": "Complete", "done": True} + }) + + return result + + except Exception as e: + error_msg = f"Error: {str(e)}" + + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": {"type": "error", "content": error_msg} + }) + + return {"content": error_msg} + + async def process(self, body: dict) -> dict: + """Process the request.""" + # Implementation + pass +``` + +--- + +## Additional Resources + +- [Open WebUI Documentation](https://docs.openwebui.com/) +- [Python Typing Best Practices](https://typing.python.org/en/latest/reference/best_practices.html) +- [Pydantic Documentation](https://docs.pydantic.dev/) +- [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices) +- [Async Python Guide](https://docs.python.org/3/library/asyncio.html) diff --git a/docs/filters-guide.md b/docs/filters-guide.md new file mode 100644 index 0000000..037288e --- /dev/null +++ b/docs/filters-guide.md @@ -0,0 +1,572 @@ +# Filters Development Guide + +Filters modify data before it's sent to the LLM (inlet), during streaming (stream), or after it returns (outlet). Use Filters to transform inputs, process outputs, or add automatic context. + +## When to Use Filters + +Use a Filter when you want to: + +- Modify user inputs before sending to the model (inlet) +- Process streaming responses in real-time (stream) +- Clean up or format model outputs (outlet) +- Add automatic context or instructions +- Implement content moderation +- Log conversations +- Inject dynamic data based on context + +## Filter Types + +### 1. Always-On Filters + +Filters without `self.toggle` run automatically whenever active: + +```python +class Filter: + def __init__(self): + self.valves = self.Valves() + # No toggle - always on when enabled + + async def inlet(self, body: dict) -> dict: + """Always runs for this model.""" + # Modify input + return body +``` + +**Use cases:** +- Content moderation (always filter) +- PII scrubbing (always remove sensitive data) +- System-level transformations +- Mandatory logging + +### 2. Toggleable Filters + +Filters with `self.toggle = True` can be enabled/disabled by users: + +```python +class Filter: + def __init__(self): + self.valves = self.Valves() + self.toggle = True # User can toggle on/off + self.icon = "data:image/svg+xml;base64,..." # Optional icon + + async def inlet(self, body: dict, __event_emitter__=None) -> dict: + """Runs only when user enables the filter.""" + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": {"description": "Filter active", "done": True} + }) + return body +``` + +**Use cases:** +- Web search integration (optional) +- Citation mode (user choice) +- Verbose output mode +- Translation filters + +## Filter Functions + +### inlet() - Pre-Process Inputs + +Modifies user input before sending to the model: + +```python +async def inlet( + self, + body: dict, + __user__: dict | None = None, + __event_emitter__: Callable | None = None, +) -> dict: + """ + Modify input before sending to model. + + Args: + body: Request body with messages + __user__: User information + __event_emitter__: For sending status updates + + Returns: + Modified body dict + """ + # Get last user message + if body.get("messages"): + last_message = body["messages"][-1] + + # Add context to user input + if last_message.get("role") == "user": + last_message["content"] = f"Context: {context}\n\n{last_message['content']}" + + return body +``` + +**Common use cases:** + +```python +# Add system context +async def inlet(self, body: dict) -> dict: + system_msg = { + "role": "system", + "content": "You are a helpful coding assistant." + } + body["messages"].insert(0, system_msg) + return body + +# Sanitize input +async def inlet(self, body: dict) -> dict: + if body.get("messages"): + last_msg = body["messages"][-1] + # Remove potentially harmful content + last_msg["content"] = self.sanitize(last_msg["content"]) + return body + +# Add automatic instructions +async def inlet(self, body: dict) -> dict: + if body.get("messages"): + last_msg = body["messages"][-1] + last_msg["content"] += "\n\nPlease provide sources for your claims." + return body +``` + +### stream() - Process Streaming Responses + +Intercepts and modifies streaming chunks in real-time: + +```python +def stream(self, event: dict) -> dict: + """ + Modify streamed response chunks. + + Args: + event: Streaming event with delta content + + Returns: + Modified event dict + """ + # Process each streamed chunk + for choice in event.get("choices", []): + delta = choice.get("delta", {}) + + if "content" in delta: + # Modify content in real-time + delta["content"] = self.transform(delta["content"]) + + return event +``` + +**Example stream events:** + +```python +# Event structure +{ + "id": "chatcmpl-123", + "choices": [{ + "delta": {"content": "Hello"} + }] +} +``` + +**Common use cases:** + +```python +# Filter emojis from stream +def stream(self, event: dict) -> dict: + for choice in event.get("choices", []): + delta = choice.get("delta", {}) + if "content" in delta: + delta["content"] = delta["content"].replace("😊", "") + return event + +# Add formatting to stream +def stream(self, event: dict) -> dict: + for choice in event.get("choices", []): + delta = choice.get("delta", {}) + if "content" in delta: + # Make output bold + delta["content"] = f"**{delta['content']}**" + return event + +# Log streaming content +def stream(self, event: dict) -> dict: + for choice in event.get("choices", []): + delta = choice.get("delta", {}) + if "content" in delta: + self.logger.debug(f"Streamed: {delta['content']}") + return event +``` + +### outlet() - Post-Process Outputs + +Modifies complete model response before displaying to user: + +```python +async def outlet( + self, + body: dict, + __user__: dict | None = None, + __event_emitter__: Callable | None = None, +) -> dict: + """ + Modify output after model completes. + + Args: + body: Complete conversation with model response + __user__: User information + __event_emitter__: For sending notifications + + Returns: + Modified body dict + """ + # Process all messages + for message in body.get("messages", []): + if message.get("role") == "assistant": + # Modify assistant responses + message["content"] = self.format_output(message["content"]) + + return body +``` + +**Common use cases:** + +```python +# Redact sensitive info +async def outlet(self, body: dict) -> dict: + for message in body.get("messages", []): + message["content"] = message["content"].replace("<>", "[REDACTED]") + return body + +# Add citations +async def outlet(self, body: dict) -> dict: + for message in body.get("messages", []): + if message.get("role") == "assistant": + message["content"] += "\n\n*Source: Knowledge Base*" + return body + +# Format code blocks +async def outlet(self, body: dict) -> dict: + for message in body.get("messages", []): + if message.get("role") == "assistant": + # Add syntax highlighting hints + message["content"] = self.format_code_blocks(message["content"]) + return body +``` + +## Complete Filter Example: Context Injector + +```python +""" +title: Context Injection Filter +author: Open WebUI Team +version: 1.0.0 +required_open_webui_version: 0.4.0 +description: Automatically inject context based on user profile and conversation history +""" + +from pydantic import BaseModel, Field +from typing import Callable, Any +import logging + +logger = logging.getLogger(__name__) + +class Filter: + class Valves(BaseModel): + priority: int = Field( + default=0, + description="Filter execution priority (lower = earlier)" + ) + max_context_length: int = Field( + default=2000, + ge=100, + le=10000, + description="Maximum context length to inject" + ) + include_user_info: bool = Field( + default=True, + description="Include user information in context" + ) + + def __init__(self): + self.valves = self.Valves() + self.toggle = True # User can enable/disable + self.icon = """data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiPjxwYXRoIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiBkPSJNOSA1SDdDNS44OTU0MyA1IDUgNS44OTU0MyA1IDdWMTlDNSAyMC4xMDQ2IDUuODk1NDMgMjEgNyAyMUgxN0MxOC4xMDQ2IDIxIDE5IDIwLjEwNDYgMTkgMTlWN0MxOSA1Ljg5NTQzIDE4LjEwNDYgNSAxNyA1SDE1TTkgNUMxMCA1IDExIDUgMTEgNUwxMyA1QzEzIDUgMTQgNSAxNSA1TTkgNVY3SDEzVjVNOSAxMUgxNU05IDE1SDEzIi8+PC9zdmc+""" + + async def inlet( + self, + body: dict, + __user__: dict | None = None, + __event_emitter__: Callable[[dict], Any] | None = None, + ) -> dict: + """Inject context into user input.""" + try: + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": "Injecting context...", + "done": False + } + }) + + # Build context + context_parts = [] + + # Add user info if enabled + if self.valves.include_user_info and __user__: + user_context = f"User: {__user__.get('name', 'Unknown')}" + if __user__.get("role"): + user_context += f" (Role: {__user__['role']})" + context_parts.append(user_context) + + # Add conversation metadata + messages = body.get("messages", []) + if messages: + context_parts.append(f"Messages in conversation: {len(messages)}") + + # Combine context + context = " | ".join(context_parts) + + # Truncate if too long + if len(context) > self.valves.max_context_length: + context = context[:self.valves.max_context_length] + "..." + + # Inject context as system message + if context and messages: + system_msg = { + "role": "system", + "content": f"[Context: {context}]" + } + + # Insert after any existing system messages + insert_idx = 0 + for i, msg in enumerate(messages): + if msg.get("role") == "system": + insert_idx = i + 1 + else: + break + + messages.insert(insert_idx, system_msg) + body["messages"] = messages + + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": "Context injected", + "done": True + } + }) + + return body + + except Exception as e: + logger.error(f"Context injection failed: {e}", exc_info=True) + + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": { + "type": "error", + "content": f"Context injection failed: {str(e)}" + } + }) + + # Return original body on error + return body + + def stream(self, event: dict) -> dict: + """Pass through streaming events unchanged.""" + return event + + async def outlet( + self, + body: dict, + __user__: dict | None = None, + __event_emitter__: Callable | None = None, + ) -> dict: + """Pass through output unchanged.""" + return body +``` + +## Filter Priority and Execution Order + +Filters execute in priority order (lower priority = earlier execution): + +```python +class Valves(BaseModel): + priority: int = Field( + default=0, + description="Execution priority. Lower runs first." + ) +``` + +**Execution flow:** +``` +Priority 0: Authentication Filter (runs first) +Priority 1: Context Injection Filter +Priority 2: Content Moderation Filter +Priority 3: Logging Filter (runs last) +``` + +**Important:** Always return the body! Each filter receives the output from the previous filter. + +## Global vs Model-Specific Filters + +### Global Filters + +Apply to all models automatically: + +1. Admin Panel → Functions → Filter +2. Click three-dot menu (⋮) +3. Toggle Globe icon (🌐) +4. Ensure filter is Active (green toggle) + +**Use for:** +- Security filters (PII scrubbing) +- Compliance requirements +- System-wide logging +- Organization policies + +### Model-Specific Filters + +Apply only to specific models: + +1. Model Settings → Filters +2. Select filters in "Filters" section +3. Set defaults in "Default Filters" section (for toggleable filters) + +**Use for:** +- Model-specific formatting +- Specialized context injection +- Optional enhancements + +## Common Patterns + +### Content Moderation + +```python +import re + +class Filter: + def __init__(self): + self.valves = self.Valves() + self.banned_patterns = [ + r'\b(offensive|word|here)\b', + # Add patterns + ] + + async def inlet(self, body: dict, __event_emitter__=None) -> dict: + """Filter user input for banned content.""" + if body.get("messages"): + last_msg = body["messages"][-1] + content = last_msg.get("content", "") + + # Check for banned patterns + for pattern in self.banned_patterns: + if re.search(pattern, content, re.IGNORECASE): + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": { + "type": "error", + "content": "Message contains inappropriate content" + } + }) + # Replace with safe message + last_msg["content"] = "[Content filtered]" + break + + return body +``` + +### Automatic Translation + +```python +from googletrans import Translator + +class Filter: + def __init__(self): + self.valves = self.Valves() + self.toggle = True + self.translator = Translator() + + async def inlet(self, body: dict) -> dict: + """Translate input to English.""" + if body.get("messages"): + last_msg = body["messages"][-1] + content = last_msg["content"] + + # Detect and translate + detection = self.translator.detect(content) + if detection.lang != "en": + translated = self.translator.translate(content, dest="en") + last_msg["content"] = translated.text + + return body + + async def outlet(self, body: dict) -> dict: + """Translate output back to original language.""" + # Implementation here + return body +``` + +### Conversation Logging + +```python +import logging +from datetime import datetime + +class Filter: + def __init__(self): + self.valves = self.Valves() + self.logger = logging.getLogger(__name__) + + async def inlet(self, body: dict, __user__=None) -> dict: + """Log user inputs.""" + if body.get("messages"): + last_msg = body["messages"][-1] + self.logger.info( + f"[{datetime.now()}] User {__user__.get('id')}: {last_msg['content']}" + ) + return body + + async def outlet(self, body: dict) -> dict: + """Log model outputs.""" + for msg in body.get("messages", []): + if msg.get("role") == "assistant": + self.logger.info( + f"[{datetime.now()}] Assistant: {msg['content'][:100]}..." + ) + return body +``` + +## Testing Checklist + +- [ ] Filter appears in admin panel +- [ ] Can be enabled/disabled +- [ ] Toggle switch works (if toggleable) +- [ ] Custom icon displays (if set) +- [ ] Priority order is correct +- [ ] inlet modifies input correctly +- [ ] stream processes chunks correctly +- [ ] outlet modifies output correctly +- [ ] Event emitters send notifications +- [ ] Error handling works +- [ ] Doesn't break other filters +- [ ] Performance is acceptable + +## Common Pitfalls + +1. **Forgetting to return body** - Always return the modified body +2. **Modifying wrong message** - Check message role before editing +3. **Breaking message structure** - Preserve message format +4. **Blocking operations** - Use async for I/O operations +5. **No error handling** - Always use try-except +6. **Ignoring priority** - Set appropriate priority value +7. **Not checking for None** - Validate __user__, __event_emitter__, etc. +8. **Over-processing** - Keep filters lightweight and fast + +## Additional Resources + +- [Filter Function Documentation](https://docs.openwebui.com/features/plugin/functions/filter) +- [Example Filters](https://openwebui.com/search?type=filter) +- [Event System Guide](https://docs.openwebui.com/features/plugin/events/) diff --git a/docs/pipes-guide.md b/docs/pipes-guide.md new file mode 100644 index 0000000..d7eb35c --- /dev/null +++ b/docs/pipes-guide.md @@ -0,0 +1,520 @@ +# Pipes Development Guide + +Pipes are custom models/agents in Open WebUI that appear in the model selector. Use Pipes to create proxy integrations to AI services, build custom agents, or integrate non-AI services. + +## When to Use Pipes + +Use a Pipe when you want to: + +- Proxy requests to external AI services (OpenAI, Anthropic, Google, etc.) +- Create custom agents with specific behaviors +- Combine multiple models or services +- Integrate non-AI services (search engines, home automation, APIs, etc.) +- Build complex workflows that appear as a single "model" + +## Basic Structure + +```python +""" +title: My Custom Pipe +author: Your Name +version: 1.0.0 +required_open_webui_version: 0.4.0 +requirements: aiohttp +""" + +from pydantic import BaseModel, Field +from typing import Any, Callable +import aiohttp + +class Pipe: + class Valves(BaseModel): + API_KEY: str = Field(default="", description="API key for service") + API_URL: str = Field( + default="https://api.example.com", + description="Base URL for API" + ) + + def __init__(self): + self.valves = self.Valves() + + async def pipe( + self, + body: dict[str, Any], + __user__: dict[str, Any] | None = None, + __event_emitter__: Callable[[dict], Any] | None = None, + ) -> dict[str, Any] | str: + """ + Main processing function. + + Args: + body: Request body containing messages and model info + __user__: User information + __event_emitter__: Function for sending status updates + + Returns: + Response from the model/service + """ + # Your implementation here + pass +``` + +## Creating Multiple Models (Manifold Pattern) + +Use the `pipes()` function to expose multiple models: + +```python +class Pipe: + class Valves(BaseModel): + API_KEY: str = Field(default="") + + def __init__(self): + self.valves = self.Valves() + + def pipes(self) -> list[dict[str, str]]: + """ + Return list of available models. + + Returns: + List of model definitions with id and name + """ + if not self.valves.API_KEY: + return [{ + "id": "error", + "name": "API Key Required - Configure in Valves" + }] + + try: + # Fetch available models from API + models = self.fetch_models() + + return [ + { + "id": model["id"], + "name": f"MyService/{model['name']}" + } + for model in models + ] + except Exception as e: + return [{ + "id": "error", + "name": f"Error: {str(e)}" + }] + + async def pipe(self, body: dict, __user__=None, __event_emitter__=None): + """Process request for selected model.""" + model_id = body["model"] # Selected model ID + # Process with specific model + pass +``` + +## Handling Streaming Responses + +Pipes must support both streaming and non-streaming responses: + +```python +async def pipe(self, body: dict, __event_emitter__=None): + is_streaming = body.get("stream", False) + + async with aiohttp.ClientSession() as session: + async with session.post( + url=f"{self.valves.API_URL}/chat/completions", + json=body, + headers={"Authorization": f"Bearer {self.valves.API_KEY}"}, + ) as response: + response.raise_for_status() + + if is_streaming: + # Return async iterator for streaming + return response.content.iter_any() + else: + # Return complete response + return await response.json() +``` + +## Model ID Extraction + +Extract the actual model ID from the full model name: + +```python +async def pipe(self, body: dict): + # Full name format: "provider.model_id" + # Extract: "model_id" + + full_name = body["model"] + + if "." in full_name: + # Split and get model ID part + model_id = full_name.split(".", 1)[1] + else: + model_id = full_name + + # Update body with extracted model ID + payload = {**body, "model": model_id} + + # Make API request with correct model ID + return await self.make_request(payload) +``` + +## Complete OpenAI Proxy Example + +```python +""" +title: OpenAI Proxy Pipe +author: Open WebUI Team +version: 1.0.0 +required_open_webui_version: 0.4.0 +requirements: aiohttp +""" + +from pydantic import BaseModel, Field +from typing import Any, Callable, AsyncIterator +import aiohttp +import logging + +logger = logging.getLogger(__name__) + +class Pipe: + class Valves(BaseModel): + NAME_PREFIX: str = Field( + default="OpenAI/", + description="Prefix for model names in the selector" + ) + API_BASE_URL: str = Field( + default="https://api.openai.com/v1", + description="OpenAI API base URL" + ) + API_KEY: str = Field( + default="", + description="OpenAI API key from platform.openai.com" + ) + TIMEOUT: int = Field( + default=120, + ge=1, + le=300, + description="Request timeout in seconds" + ) + + def __init__(self): + self.valves = self.Valves() + self._session: aiohttp.ClientSession | None = None + + async def get_session(self) -> aiohttp.ClientSession: + """Get or create HTTP session with connection pooling.""" + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=self.valves.TIMEOUT) + connector = aiohttp.TCPConnector(limit=100, limit_per_host=30) + self._session = aiohttp.ClientSession( + timeout=timeout, + connector=connector + ) + return self._session + + def pipes(self) -> list[dict[str, str]]: + """Fetch available OpenAI models.""" + if not self.valves.API_KEY: + return [{ + "id": "error", + "name": "⚠️ API Key Required" + }] + + try: + import requests + + response = requests.get( + f"{self.valves.API_BASE_URL}/models", + headers={"Authorization": f"Bearer {self.valves.API_KEY}"}, + timeout=10 + ) + response.raise_for_status() + + models = response.json() + + # Filter for GPT models + return [ + { + "id": model["id"], + "name": f"{self.valves.NAME_PREFIX}{model.get('id')}" + } + for model in models.get("data", []) + if "gpt" in model["id"] + ] + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to fetch models: {e}") + return [{ + "id": "error", + "name": f"⚠️ Error: {str(e)}" + }] + + async def pipe( + self, + body: dict[str, Any], + __user__: dict[str, Any] | None = None, + __event_emitter__: Callable[[dict], Any] | None = None, + ) -> AsyncIterator[bytes] | dict[str, Any]: + """ + Process chat completion request. + + Supports both streaming and non-streaming responses. + """ + try: + # Extract model ID + model_id = body["model"] + if "." in model_id: + model_id = model_id.split(".", 1)[1] + + # Prepare request + payload = {**body, "model": model_id} + + session = await self.get_session() + + # Send status update + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": { + "description": f"Requesting {model_id}...", + "done": False + } + }) + + # Make API request + async with session.post( + f"{self.valves.API_BASE_URL}/chat/completions", + json=payload, + headers={ + "Authorization": f"Bearer {self.valves.API_KEY}", + "Content-Type": "application/json" + } + ) as response: + response.raise_for_status() + + if body.get("stream", False): + # Stream response + return response.content.iter_any() + else: + # Complete response + result = await response.json() + + if __event_emitter__: + await __event_emitter__({ + "type": "status", + "data": {"description": "Complete", "done": True} + }) + + return result + + except aiohttp.ClientResponseError as e: + error_msg = f"API error ({e.status}): {e.message}" + logger.error(error_msg) + + except aiohttp.ClientError as e: + error_msg = f"Connection error: {str(e)}" + logger.error(error_msg) + + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + logger.exception("Pipe processing failed") + + # Send error notification + if __event_emitter__: + await __event_emitter__({ + "type": "notification", + "data": {"type": "error", "content": error_msg} + }) + + return {"content": f"Error: {error_msg}"} +``` + +## Using Internal Open WebUI Functions + +Access Open WebUI's internal functions for advanced use cases: + +```python +from fastapi import Request +from open_webui.models.users import Users +from open_webui.utils.chat import generate_chat_completion + +class Pipe: + def __init__(self): + self.valves = self.Valves() + + async def pipe( + self, + body: dict, + __user__: dict, + __request__: Request, + ) -> str: + """Use internal Open WebUI chat completion.""" + # Get full user object + user = Users.get_user_by_id(__user__["id"]) + + # Specify internal model + body["model"] = "llama3.2:latest" + + # Use internal chat completion + return await generate_chat_completion(__request__, body, user) +``` + +## Error Handling Patterns + +### Network Errors + +```python +import asyncio + +try: + async with session.post(url, json=payload) as response: + response.raise_for_status() + return await response.json() + +except asyncio.TimeoutError: + return {"content": "Request timed out. Please try again."} + +except aiohttp.ClientResponseError as e: + if e.status == 401: + return {"content": "Authentication failed. Check your API key."} + elif e.status == 429: + return {"content": "Rate limit exceeded. Please wait and try again."} + elif e.status >= 500: + return {"content": f"Service error ({e.status}). Please try again later."} + else: + return {"content": f"Request failed ({e.status}): {e.message}"} + +except aiohttp.ClientError as e: + return {"content": f"Connection error: {str(e)}"} +``` + +### Retry Logic with Exponential Backoff + +```python +async def make_request_with_retry( + self, + url: str, + payload: dict, + max_retries: int = 3 +) -> dict: + """Make HTTP request with retry logic.""" + session = await self.get_session() + + for attempt in range(max_retries): + try: + async with session.post(url, json=payload) as response: + # Return on success or client error (don't retry) + if response.status < 500: + response.raise_for_status() + return await response.json() + + # Server error - retry with backoff + if attempt < max_retries - 1: + wait_time = 2 ** attempt # Exponential backoff + await asyncio.sleep(wait_time) + continue + + response.raise_for_status() + + except aiohttp.ClientError as e: + if attempt == max_retries - 1: + raise + await asyncio.sleep(2 ** attempt) + + raise RuntimeError(f"Failed after {max_retries} attempts") +``` + +## Performance Optimization + +### Session Management + +```python +class Pipe: + def __init__(self): + self.valves = self.Valves() + self._session: aiohttp.ClientSession | None = None + + async def get_session(self) -> aiohttp.ClientSession: + """Reuse session for connection pooling.""" + if self._session is None or self._session.closed: + connector = aiohttp.TCPConnector( + limit=100, # Total connections + limit_per_host=30, # Per-host connections + ttl_dns_cache=300 # DNS cache TTL + ) + timeout = aiohttp.ClientTimeout(total=self.valves.TIMEOUT) + self._session = aiohttp.ClientSession( + connector=connector, + timeout=timeout + ) + return self._session + + async def __del__(self): + """Clean up session on deletion.""" + if self._session and not self._session.closed: + await self._session.close() +``` + +### Response Caching + +```python +import time +from typing import Any + +class ResponseCache: + def __init__(self, ttl: int = 300): + self._cache: dict[str, tuple[Any, float]] = {} + self.ttl = ttl + + def get(self, key: str) -> Any | None: + """Get cached value if not expired.""" + if key in self._cache: + value, timestamp = self._cache[key] + if time.time() - timestamp < self.ttl: + return value + del self._cache[key] + return None + + def set(self, key: str, value: Any): + """Cache value with timestamp.""" + self._cache[key] = (value, time.time()) + + def clear(self): + """Clear all cached values.""" + self._cache.clear() + +class Pipe: + def __init__(self): + self.valves = self.Valves() + self._cache = ResponseCache(ttl=300) # 5 minute cache +``` + +## Testing Checklist + +- [ ] Pipe appears in model selector +- [ ] Model name displays correctly +- [ ] Streaming responses work +- [ ] Non-streaming responses work +- [ ] Error messages are helpful +- [ ] API key validation works +- [ ] Timeout handling works +- [ ] Multiple models work (if using manifold) +- [ ] Model ID extraction is correct +- [ ] Event emitters send updates +- [ ] Session cleanup happens properly +- [ ] Concurrent requests work + +## Common Pitfalls + +1. **Forgetting to extract model ID** - Always parse the full model name +2. **Not handling streaming** - Support both streaming and non-streaming +3. **Hardcoding API keys** - Use Valves for configuration +4. **Blocking operations** - Use async for all I/O +5. **No error handling** - Always use try-except blocks +6. **Missing status updates** - Use event emitters for feedback +7. **Session leaks** - Reuse sessions with proper cleanup +8. **No timeout** - Always set request timeouts + +## Additional Resources + +- [Pipe Function Documentation](https://docs.openwebui.com/features/plugin/functions/pipe) +- [Example Pipes](https://openwebui.com/search?type=pipe) +- [Open WebUI GitHub](https://github.com/open-webui/open-webui) diff --git a/docs/python-standards.md b/docs/python-standards.md new file mode 100644 index 0000000..18ab17c --- /dev/null +++ b/docs/python-standards.md @@ -0,0 +1,681 @@ +# Python Coding Standards for Open WebUI Extensions + +Python coding conventions and standards specific to Open WebUI extension development. + +## Table of Contents + +- [Modern Python Syntax](#modern-python-syntax) +- [Type Hints](#type-hints) +- [Pydantic 2 Patterns](#pydantic-2-patterns) +- [Async/Await](#asyncawait) +- [Code Formatting](#code-formatting) +- [Import Organization](#import-organization) +- [Naming Conventions](#naming-conventions) +- [Documentation](#documentation) +- [Error Handling](#error-handling) + +## Modern Python Syntax + +### Use Python 3.10+ Features + +Open WebUI extensions should use modern Python 3.10+ syntax: + +```python +# Good: Modern union syntax (PEP 604) +def process(value: str | None) -> list[dict[str, str]]: + pass + +# Avoid: Old-style typing (deprecated) +from typing import Optional, List, Dict +def process(value: Optional[str]) -> List[Dict[str, str]]: + pass +``` + +### Prefer Built-in Generic Types + +```python +# Good: Built-in generics (Python 3.9+) +def get_items() -> list[str]: + return ["item1", "item2"] + +def get_mapping() -> dict[str, int]: + return {"count": 42} + +# Avoid: typing module imports for basic types +from typing import List, Dict +def get_items() -> List[str]: + return ["item1", "item2"] +``` + +### Pattern Matching + +Use pattern matching for complex conditionals (Python 3.10+): + +```python +def process_event(event: dict) -> str: + match event: + case {"type": "status", "data": data}: + return f"Status: {data}" + case {"type": "error", "message": msg}: + return f"Error: {msg}" + case _: + return "Unknown event" +``` + +## Type Hints + +### Complete Type Annotations + +**All functions must have complete type hints:** + +```python +# Good: Complete type hints +async def fetch_data( + url: str, + timeout: int = 30, + headers: dict[str, str] | None = None, +) -> dict[str, Any]: + pass + +# Bad: Missing or incomplete type hints +async def fetch_data(url, timeout=30, headers=None): + pass +``` + +### Use Type Aliases for Clarity + +```python +from typing import TypeAlias + +# Define reusable type aliases +UserId: TypeAlias = str +Timestamp: TypeAlias = int +UserData: TypeAlias = dict[str, Any] + +def get_user(user_id: UserId) -> UserData: + pass +``` + +### Annotated Types for Constraints + +Use `Annotated` for validation constraints: + +```python +from typing import Annotated +from pydantic import Field, AfterValidator + +def validate_positive(v: int) -> int: + if v <= 0: + raise ValueError("Must be positive") + return v + +PositiveInt = Annotated[int, AfterValidator(validate_positive)] + +class Valves(BaseModel): + count: PositiveInt = Field(default=1) +``` + +### TypedDict for Structured Dicts + +Use `TypedDict` for dictionaries with known structure: + +```python +from typing import TypedDict + +class UserInfo(TypedDict): + id: str + name: str + email: str + role: str + +class MessageData(TypedDict, total=False): # total=False makes all fields optional + content: str + role: str + files: list[dict[str, str]] + +def process_user(user: UserInfo) -> None: + # Type checker knows the structure + print(user["name"]) +``` + +### Literal Types for Constants + +```python +from typing import Literal + +Role = Literal["user", "admin", "moderator"] + +def check_permission(role: Role) -> bool: + return role in ("admin", "moderator") + +# Type checker ensures only valid values +check_permission("user") # ✓ OK +check_permission("guest") # ✗ Type error +``` + +### Generic Types + +```python +from typing import TypeVar, Generic + +T = TypeVar("T") + +class Cache(Generic[T]): + def __init__(self): + self._data: dict[str, T] = {} + + def get(self, key: str) -> T | None: + return self._data.get(key) + + def set(self, key: str, value: T) -> None: + self._data[key] = value + +# Usage +cache: Cache[str] = Cache() +cache.set("key", "value") +``` + +## Pydantic 2 Patterns + +### Model Configuration + +```python +from pydantic import BaseModel, Field, ConfigDict + +class Valves(BaseModel): + # Pydantic 2 configuration + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + use_enum_values=True, + ) + + api_key: str = Field( + default="", + min_length=1, + description="API key for authentication" + ) +``` + +### Field Validation + +```python +from pydantic import BaseModel, Field, field_validator + +class Valves(BaseModel): + api_key: str = Field(default="") + timeout: int = Field(default=30, ge=1, le=300) + + @field_validator("api_key") + @classmethod + def validate_api_key(cls, v: str) -> str: + if v and not v.startswith("sk-"): + raise ValueError("API key must start with 'sk-'") + return v + + @field_validator("timeout") + @classmethod + def validate_timeout(cls, v: int) -> int: + if v < 1 or v > 300: + raise ValueError("Timeout must be between 1 and 300") + return v +``` + +### Computed Fields + +```python +from pydantic import BaseModel, computed_field + +class Config(BaseModel): + api_url: str + api_path: str + + @computed_field + @property + def full_url(self) -> str: + return f"{self.api_url.rstrip('/')}/{self.api_path.lstrip('/')}" +``` + +### Model Serialization + +```python +class UserData(BaseModel): + name: str + email: str + password: str = Field(exclude=True) # Never serialize + + def model_dump_safe(self) -> dict[str, Any]: + """Return safe serialization without sensitive fields.""" + return self.model_dump(exclude={"password"}) +``` + +## Async/Await + +### Always Use Async for I/O + +```python +# Good: Async I/O operations +async def fetch_data(url: str) -> dict: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + return await response.json() + +# Bad: Blocking I/O (blocks event loop) +def fetch_data(url: str) -> dict: + response = requests.get(url) + return response.json() +``` + +### Async Context Managers + +```python +import asyncio +from contextlib import asynccontextmanager + +@asynccontextmanager +async def get_session(): + """Async context manager for HTTP session.""" + session = aiohttp.ClientSession() + try: + yield session + finally: + await session.close() + +# Usage +async with get_session() as session: + async with session.get(url) as response: + data = await response.json() +``` + +### Concurrent Operations + +```python +import asyncio + +async def process_multiple(items: list[str]) -> list[dict]: + """Process multiple items concurrently.""" + # Run all tasks concurrently + tasks = [process_item(item) for item in items] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Filter out exceptions + return [r for r in results if not isinstance(r, Exception)] +``` + +### Async Generators + +```python +from typing import AsyncGenerator + +async def stream_data(url: str) -> AsyncGenerator[bytes, None]: + """Stream data from URL.""" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + async for chunk in response.content.iter_chunked(1024): + yield chunk + +# Usage +async for chunk in stream_data(url): + process(chunk) +``` + +## Code Formatting + +### Line Length + +Maximum line length: **100 characters** + +```python +# Good: Within line length +async def process_data( + input_data: dict[str, Any], + options: ProcessOptions | None = None, +) -> ProcessResult: + pass + +# Avoid: Too long +async def process_data(input_data: dict[str, Any], options: ProcessOptions | None = None) -> ProcessResult: + pass +``` + +### String Formatting + +Prefer f-strings: + +```python +# Good: F-strings (Python 3.6+) +name = "Alice" +age = 30 +message = f"Hello {name}, you are {age} years old" + +# Avoid: Old-style formatting +message = "Hello %s, you are %d years old" % (name, age) +message = "Hello {}, you are {} years old".format(name, age) +``` + +### Multi-line Collections + +Use trailing commas for multi-line collections: + +```python +# Good: Trailing comma +users = [ + "alice", + "bob", + "charlie", # ← trailing comma +] + +config = { + "api_key": "...", + "timeout": 30, + "retries": 3, # ← trailing comma +} + +# Makes adding/removing items easier and cleaner diffs +``` + +### Line Breaks + +```python +# Good: Break before binary operators +total = ( + first_value + + second_value + + third_value + - deduction +) + +# Good: Align continuation lines +result = some_function( + first_argument="value1", + second_argument="value2", + third_argument="value3", +) +``` + +## Import Organization + +### Import Grouping + +Group imports in this order: + +1. Standard library imports +2. Related third-party imports +3. Local application/library imports + +```python +# Standard library +import asyncio +import logging +from typing import Any, Callable + +# Third-party +import aiohttp +from pydantic import BaseModel, Field + +# Local/Open WebUI +from open_webui.models.users import Users +from open_webui.utils.chat import generate_chat_completion +``` + +### Import Style + +```python +# Good: Explicit imports +from typing import Any, Callable, TypedDict +from pydantic import BaseModel, Field + +# Avoid: Wildcard imports +from typing import * +from pydantic import * + +# Avoid: Unused imports (use tools to detect) +from typing import Any, Callable, List # List is unused +``` + +### Relative vs Absolute Imports + +```python +# Good: Absolute imports (preferred in Open WebUI) +from open_webui.utils.chat import generate_chat_completion + +# Acceptable: Relative imports within same package +from .utils import helper_function +``` + +## Naming Conventions + +### Variables and Functions + +```python +# Variables and functions: snake_case +user_count = 10 +api_key = "..." + +def calculate_total(items: list[int]) -> int: + pass + +async def fetch_user_data(user_id: str) -> dict: + pass +``` + +### Classes + +```python +# Classes: PascalCase +class UserManager: + pass + +class DataProcessor: + pass + +class APIClient: + pass +``` + +### Constants + +```python +# Constants: UPPER_SNAKE_CASE +MAX_RETRIES = 3 +DEFAULT_TIMEOUT = 30 +API_BASE_URL = "https://api.example.com" +``` + +### Type Aliases + +```python +# Type aliases: PascalCase +UserId = str +UserData = dict[str, Any] +ProcessResult = tuple[bool, str] +``` + +### Private Members + +```python +class MyClass: + def __init__(self): + self._private = "internal use" # Single underscore + self.__really_private = "name mangled" # Double underscore + + def _internal_method(self): + """Internal helper method.""" + pass + + def public_method(self): + """Public API method.""" + pass +``` + +### Special Names + +```python +# Avoid name conflicts with builtins +list_ = [] # Append underscore +dict_ = {} +type_ = "example" + +# Better: Use more descriptive names +items = [] +mapping = {} +data_type = "example" +``` + +## Documentation + +### Module Docstrings + +```python +""" +title: Web Search Tool +author: Open WebUI Team +author_url: https://github.com/open-webui +version: 1.0.0 +required_open_webui_version: 0.4.0 +requirements: aiohttp, beautifulsoup4 +license: MIT +description: Search the web and retrieve relevant information with caching and rate limiting +""" +``` + +### Function Docstrings + +```python +async def search_web( + query: str, + num_results: int = 10, + language: str = "en", +) -> list[dict[str, str]]: + """ + Search the web for information. + + Performs a web search using the configured search API and returns + formatted results. Includes automatic rate limiting and caching. + + Args: + query: Search query string + num_results: Maximum number of results to return (1-50) + language: Language code for results (default: "en") + + Returns: + List of search results, each containing: + - title: Result title + - url: Result URL + - snippet: Result description/snippet + + Raises: + ValueError: If query is empty or num_results is out of range + APIError: If the search API returns an error + + Example: + >>> results = await search_web("Python programming", num_results=5) + >>> print(results[0]["title"]) + "Python Tutorial" + """ + pass +``` + +### Inline Comments + +```python +# Good: Explain WHY, not WHAT +# Extract model ID from full name format: "provider.model_id" +model_id = body["model"].split(".", 1)[1] + +# Bad: Redundant comment +# Split the model name +model_id = body["model"].split(".", 1)[1] +``` + +## Error Handling + +### Specific Exception Types + +```python +# Good: Catch specific exceptions +try: + result = await fetch_data(url) +except aiohttp.ClientResponseError as e: + logger.error(f"HTTP error {e.status}: {e.message}") +except aiohttp.ClientError as e: + logger.error(f"Network error: {str(e)}") +except ValueError as e: + logger.error(f"Invalid data: {str(e)}") + +# Avoid: Bare except +try: + result = await fetch_data(url) +except: # Don't do this! + pass +``` + +### Exception Context + +```python +# Good: Preserve exception context +try: + data = parse_json(text) +except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON data: {text[:100]}...") from e + +# Avoid: Losing context +try: + data = parse_json(text) +except json.JSONDecodeError: + raise ValueError("Invalid JSON") +``` + +### Custom Exceptions + +```python +class OpenWebUIError(Exception): + """Base exception for Open WebUI extensions.""" + pass + +class APIError(OpenWebUIError): + """API request failed.""" + def __init__(self, status: int, message: str): + self.status = status + super().__init__(f"API error {status}: {message}") + +class ValidationError(OpenWebUIError): + """Input validation failed.""" + pass +``` + +## Code Quality Tools + +### Recommended Tools + +- **ruff**: Fast linter and formatter +- **mypy**: Static type checker +- **pytest**: Testing framework +- **black**: Code formatter (if not using ruff) + +### Configuration Example + +```toml +# pyproject.toml +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +``` + +## Additional Resources + +- [PEP 8 – Style Guide for Python Code](https://peps.python.org/pep-0008/) +- [PEP 484 – Type Hints](https://peps.python.org/pep-0484/) +- [PEP 604 – Union Type Operator](https://peps.python.org/pep-0604/) +- [Pydantic V2 Documentation](https://docs.pydantic.dev/) +- [Python Type Hints](https://typing.python.org/en/latest/) +- [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices) diff --git a/docs/testing-guide.md b/docs/testing-guide.md new file mode 100644 index 0000000..53322b2 --- /dev/null +++ b/docs/testing-guide.md @@ -0,0 +1,748 @@ +# Testing Guide for Open WebUI Extensions + +Comprehensive testing strategies for Pipes, Filters, Actions, and Tools. + +## Table of Contents + +- [Testing Principles](#testing-principles) +- [Manual Testing](#manual-testing) +- [Unit Testing](#unit-testing) +- [Integration Testing](#integration-testing) +- [Performance Testing](#performance-testing) +- [Security Testing](#security-testing) +- [Test Checklists](#test-checklists) + +## Testing Principles + +### Test Early and Often + +- Test during development, not just at the end +- Write tests alongside your code +- Run tests before committing changes +- Automate testing where possible + +### Test Coverage + +Aim for comprehensive coverage of: + +- **Happy path**: Normal, expected usage +- **Edge cases**: Boundary conditions, unusual inputs +- **Error conditions**: Invalid inputs, API failures, timeouts +- **Concurrency**: Multiple simultaneous requests +- **Performance**: Response times under load +- **Security**: Input validation, authentication, authorization + +### Testing Pyramid + +``` + /\ + / \ Few: End-to-end tests (full Open WebUI integration) + /----\ + / \ Some: Integration tests (with mocked APIs) + /--------\ + / \ Many: Unit tests (individual functions) + /____________\ +``` + +## Manual Testing + +### Initial Setup Test + +Before diving into functionality, verify basic setup: + +**Checklist:** +- [ ] Extension loads without Python syntax errors +- [ ] Valves configuration saves and loads correctly +- [ ] Extension appears in appropriate location (model selector, filters, etc.) +- [ ] Icons display correctly (if applicable) +- [ ] Toggle switches work (if applicable) + +**Steps:** +1. Install extension in Open WebUI +2. Navigate to Admin Panel → Functions +3. Verify extension appears in list +4. Click to open configuration +5. Modify Valves settings and save +6. Reload page and verify settings persisted + +### Testing Pipes + +**Test Case 1: Pipe Appears in Model Selector** +1. Enable the Pipe in Admin Panel +2. Navigate to chat interface +3. Open model selector +4. Verify Pipe appears in the list +5. Verify name displays correctly + +**Test Case 2: Basic Interaction** +1. Select your Pipe from model selector +2. Send a simple test message: "Hello, how are you?" +3. Verify response is received +4. Check for error messages or console errors + +**Test Case 3: Streaming Response** +``` +Test message: "Write a short story about a robot." + +Expected: +- Response streams word-by-word +- No delays or hanging +- Complete response received +- No errors in console +``` + +**Test Case 4: Model Selection (for Manifolds)** +``` +For Pipes with multiple models: +1. Verify all models appear in selector +2. Select different models +3. Verify correct model is used for each request +4. Check model ID extraction is correct +``` + +**Test Case 5: Error Handling** +``` +Test scenarios: +- Empty API key in Valves +- Invalid API URL +- Network timeout (disconnect internet) +- API rate limit exceeded +- Invalid model ID + +Expected for each: +- Clear error message displayed +- No crashes or unhandled exceptions +- User can recover (fix configuration and retry) +``` + +### Testing Filters + +**Test Case 1: Filter Activation** +1. Enable filter globally or for specific model +2. Navigate to model settings +3. Verify filter appears in filter list +4. Enable/disable toggle and verify state persists + +**Test Case 2: Inlet Modification** +``` +Test scenario: +- Filter adds system context to inputs +- Send message: "What's the weather?" +- Check that context was added (via logs or outlet) + +Expected: +- Input modified before reaching model +- Model receives modified input +- Response reflects added context +``` + +**Test Case 3: Stream Processing** +``` +Test scenario: +- Filter modifies streaming chunks +- Send message requiring long response +- Monitor streaming output + +Expected: +- Modifications applied to each chunk +- No delays or blocking +- Complete response is modified correctly +``` + +**Test Case 4: Outlet Modification** +``` +Test scenario: +- Filter formats model outputs +- Send message and receive response +- Check output formatting + +Expected: +- Output modified after model completion +- Formatting applied correctly +- No content loss or corruption +``` + +**Test Case 5: Filter Priority** +``` +Test scenario (with multiple filters): +1. Set different priorities for filters +2. Send test message +3. Verify execution order via logs + +Expected: +- Filters execute in priority order +- Each receives output from previous filter +- Final result includes all modifications +``` + +### Testing Actions + +**Test Case 1: Button Appears** +1. Enable action globally or for specific model +2. Have model generate a message +3. Verify action button appears below message +4. Verify icon and name display correctly + +**Test Case 2: Basic Action Execution** +``` +Test scenario: +1. Click action button +2. Monitor status updates +3. Verify completion + +Expected: +- Action executes without errors +- Status updates appear +- Final result displays correctly +``` + +**Test Case 3: Confirmation Dialog** +``` +Test scenario: +1. Click action button +2. Confirmation dialog appears +3. Test both "Confirm" and "Cancel" + +Expected: +- Dialog displays with clear message +- Confirm proceeds with action +- Cancel aborts action cleanly +``` + +**Test Case 4: Input Dialog** +``` +Test scenario: +1. Click action button +2. Input dialog appears +3. Enter test value and submit + +Expected: +- Dialog displays with clear prompt +- Input accepted and processed +- Result reflects input value +``` + +**Test Case 5: Multi-Action** +``` +For actions with multiple sub-actions: +1. Verify all action buttons appear +2. Click each action button +3. Verify correct action executes + +Expected: +- Each action executes correctly +- No cross-contamination between actions +- All actions work independently +``` + +### Testing Tools + +**Test Case 1: Function Calling** +``` +Test scenario: +1. Select model with function calling support +2. Ask question that should trigger tool +3. Monitor tool execution + +Example prompt: "Search the web for Python tutorials" + +Expected: +- Model decides to call search tool +- Tool executes with correct parameters +- Results returned to model +- Model incorporates results in response +``` + +**Test Case 2: Parameter Handling** +``` +Test different parameter types: +- Required parameters: Verify enforcement +- Optional parameters: Test with/without values +- Default values: Verify defaults applied +- Complex types: Test nested structures + +Expected: +- All parameter types handled correctly +- Validation errors caught and reported +- Type coercion works as expected +``` + +**Test Case 3: Return Value Handling** +``` +Test different return scenarios: +- Successful return with data +- Empty results +- Error conditions +- Large data sets + +Expected: +- All return types handled correctly +- Model can process returned data +- No serialization errors +``` + +## Unit Testing + +### Setup Test Environment + +```python +# tests/conftest.py +import pytest +from unittest.mock import AsyncMock, MagicMock + +@pytest.fixture +def mock_event_emitter(): + """Mock event emitter for testing.""" + return AsyncMock() + +@pytest.fixture +def mock_event_call(): + """Mock event call for testing.""" + async def _call(data): + if data["type"] == "confirmation": + return True # Auto-confirm + if data["type"] == "input": + return "test input" + return None + return _call + +@pytest.fixture +def mock_user(): + """Mock user object.""" + return { + "id": "test-user-123", + "name": "Test User", + "email": "test@example.com", + "role": "user", + } +``` + +### Testing Pipes + +```python +# tests/test_pipe.py +import pytest +from unittest.mock import AsyncMock, patch +from my_pipe import Pipe + +@pytest.fixture +def pipe(): + p = Pipe() + p.valves.API_KEY = "test-key" + return p + +@pytest.mark.asyncio +async def test_pipe_basic_request(pipe, mock_event_emitter): + """Test basic pipe request.""" + body = { + "messages": [{"role": "user", "content": "Hello"}], + "model": "test-model", + "stream": False, + } + + with patch("aiohttp.ClientSession") as mock_session: + # Mock API response + mock_response = AsyncMock() + mock_response.json.return_value = { + "choices": [{"message": {"content": "Hi there!"}}] + } + mock_response.raise_for_status = AsyncMock() + + mock_session.return_value.__aenter__.return_value.post.return_value.__aenter__.return_value = mock_response + + result = await pipe.pipe(body, __event_emitter__=mock_event_emitter) + + assert result is not None + assert "choices" in result + +@pytest.mark.asyncio +async def test_pipe_missing_api_key(pipe, mock_event_emitter): + """Test pipe behavior without API key.""" + pipe.valves.API_KEY = "" + + body = {"messages": [], "model": "test"} + + result = await pipe.pipe(body, __event_emitter__=mock_event_emitter) + + # Should return error + assert "error" in str(result).lower() or result.get("content") + +@pytest.mark.asyncio +async def test_pipe_timeout_handling(pipe): + """Test timeout handling.""" + import asyncio + + pipe.valves.TIMEOUT = 1 + body = {"messages": [], "model": "test"} + + with patch("aiohttp.ClientSession") as mock_session: + # Simulate timeout + mock_session.return_value.__aenter__.return_value.post.side_effect = asyncio.TimeoutError() + + result = await pipe.pipe(body) + + # Should handle timeout gracefully + assert result is not None +``` + +### Testing Filters + +```python +# tests/test_filter.py +import pytest +from my_filter import Filter + +@pytest.fixture +def filter_instance(): + return Filter() + +@pytest.mark.asyncio +async def test_inlet_adds_context(filter_instance): + """Test inlet adds context to messages.""" + body = { + "messages": [ + {"role": "user", "content": "Hello"} + ] + } + + result = await filter_instance.inlet(body) + + # Verify context was added + assert len(result["messages"]) > 1 or "context" in result["messages"][0]["content"].lower() + +@pytest.mark.asyncio +async def test_inlet_empty_messages(filter_instance): + """Test inlet handles empty messages.""" + body = {"messages": []} + + result = await filter_instance.inlet(body) + + # Should handle gracefully + assert result is not None + assert "messages" in result + +def test_stream_modification(filter_instance): + """Test stream modifies chunks.""" + event = { + "id": "test", + "choices": [{ + "delta": {"content": "test content"} + }] + } + + result = filter_instance.stream(event) + + # Verify modification applied + assert result["choices"][0]["delta"]["content"] != "test content" or result == event + +@pytest.mark.asyncio +async def test_outlet_formatting(filter_instance): + """Test outlet formats output.""" + body = { + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"} + ] + } + + result = await filter_instance.outlet(body) + + # Verify formatting applied + assert result is not None + assert "messages" in result +``` + +### Testing Actions + +```python +# tests/test_action.py +import pytest +from my_action import Action + +@pytest.fixture +def action(): + a = Action() + a.valves.API_KEY = "test-key" + return a + +@pytest.mark.asyncio +async def test_action_execution(action, mock_event_emitter, mock_event_call): + """Test basic action execution.""" + body = { + "content": "Test message to process", + "messages": [] + } + + result = await action.action( + body, + __event_emitter__=mock_event_emitter, + __event_call__=mock_event_call, + ) + + assert result is not None + assert "content" in result + + # Verify event emitter was called + mock_event_emitter.assert_called() + +@pytest.mark.asyncio +async def test_action_user_cancellation(action, mock_event_emitter): + """Test action cancellation.""" + # Mock cancel response + async def cancel_call(data): + return False + + body = {"content": "test"} + + result = await action.action( + body, + __event_call__=cancel_call, + ) + + # Should handle cancellation + assert "cancel" in result.get("content", "").lower() or result is not None + +@pytest.mark.asyncio +async def test_action_error_handling(action, mock_event_emitter): + """Test action error handling.""" + body = {} # Invalid body + + result = await action.action( + body, + __event_emitter__=mock_event_emitter, + ) + + # Should handle error gracefully + assert result is not None +``` + +### Testing Tools + +```python +# tests/test_tool.py +import pytest +from my_tool import Tools + +@pytest.fixture +def tools(): + t = Tools() + t.valves.API_KEY = "test-key" + return t + +@pytest.mark.asyncio +async def test_tool_function(tools, mock_event_emitter): + """Test tool function execution.""" + result = await tools.my_function( + "test parameter", + __event_emitter__=mock_event_emitter, + ) + + assert result is not None + assert isinstance(result, str) + +@pytest.mark.asyncio +async def test_tool_invalid_input(tools): + """Test tool handles invalid input.""" + with pytest.raises(ValueError): + await tools.my_function("") + +@pytest.mark.asyncio +async def test_tool_return_type(tools): + """Test tool returns correct type.""" + result = await tools.my_function("test") + + # Verify return type matches type hint + assert isinstance(result, str) # or whatever the return type is +``` + +## Integration Testing + +### Testing with Mock APIs + +```python +# tests/test_integration.py +import pytest +from aioresponses import aioresponses +from my_pipe import Pipe + +@pytest.mark.asyncio +async def test_pipe_with_mock_api(): + """Test pipe with mocked external API.""" + pipe = Pipe() + pipe.valves.API_KEY = "test-key" + pipe.valves.API_URL = "https://api.example.com" + + with aioresponses() as mock: + # Mock API endpoint + mock.post( + "https://api.example.com/chat/completions", + payload={ + "choices": [{"message": {"content": "Mocked response"}}] + }, + status=200, + ) + + body = { + "messages": [{"role": "user", "content": "Hello"}], + "model": "test-model", + } + + result = await pipe.pipe(body) + + assert result is not None + assert "choices" in result +``` + +## Performance Testing + +### Response Time Testing + +```python +import time +import pytest + +@pytest.mark.asyncio +async def test_pipe_response_time(pipe): + """Test pipe responds within acceptable time.""" + body = {"messages": [{"role": "user", "content": "test"}], "model": "test"} + + start = time.time() + await pipe.pipe(body) + duration = time.time() - start + + # Should respond within 5 seconds + assert duration < 5.0 + +@pytest.mark.asyncio +async def test_concurrent_requests(pipe): + """Test handling multiple concurrent requests.""" + import asyncio + + bodies = [ + {"messages": [{"role": "user", "content": f"test {i}"}], "model": "test"} + for i in range(10) + ] + + start = time.time() + results = await asyncio.gather(*[pipe.pipe(body) for body in bodies]) + duration = time.time() - start + + # All requests should complete + assert len(results) == 10 + # Should handle concurrency efficiently + assert duration < 10.0 # Adjust based on expected performance +``` + +## Security Testing + +### Input Validation + +```python +@pytest.mark.asyncio +async def test_sql_injection_prevention(filter_instance): + """Test filter prevents SQL injection.""" + malicious_input = "'; DROP TABLE users; --" + + body = { + "messages": [{"role": "user", "content": malicious_input}] + } + + result = await filter_instance.inlet(body) + + # Should sanitize or reject malicious input + content = result["messages"][0]["content"] + assert "DROP TABLE" not in content or content != malicious_input + +@pytest.mark.asyncio +async def test_xss_prevention(filter_instance): + """Test filter prevents XSS attacks.""" + malicious_input = "" + + body = { + "messages": [{"role": "user", "content": malicious_input}] + } + + result = await filter_instance.inlet(body) + + # Should sanitize script tags + content = result["messages"][0]["content"] + assert " +``` + +### HTTP and Runtime Conventions + +- Prefer `httpx.AsyncClient` or `aiohttp` for new async HTTP work +- Always set a `User-Agent`, timeout, and `raise_for_status()` equivalent +- Return actionable error strings or typed error objects +- Avoid adding dependencies unless they clearly reduce complexity +- For production deployments with multiple workers, preinstall frontmatter requirements and consider `ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS=False` + +### Python Version Compatibility + +Open WebUI deployments commonly run Python 3.10/3.11. Avoid Python 3.12-only syntax. +For complex f-strings, precompute dict/list access first so quoting stays unambiguous: + +```python +name = data["name"] +last_price = prices[-1] +return f"{name}: ${last_price}" +``` + ## Complete Web Search Tool Example ```python @@ -696,6 +822,10 @@ async def test_search_web_no_api_key(): - [ ] Return values match type hints - [ ] Error handling works properly - [ ] Event emitters send updates +- [ ] Native/default function-calling behavior is checked when events are used +- [ ] Custom citations set `self.citation = False` +- [ ] HTML cards use inline content disposition and resize correctly +- [ ] HTTP calls set `User-Agent`, timeout, and status handling - [ ] User context is accessible - [ ] Rate limiting works (if implemented) - [ ] Caching works (if implemented) @@ -711,6 +841,9 @@ async def test_search_web_no_api_key(): 6. **No input validation** - Validate parameters before use 7. **Poor return values** - Return structured, typed data 8. **Forgetting event emitters** - Provide status updates +9. **Native-mode event conflicts** - Avoid message replacement events in native mode +10. **Unsafe HTML cards** - Escape content and use transparent iframe backgrounds +11. **Runtime pip races** - Preinstall requirements in multi-worker deployments ## Best Practices Summary @@ -724,9 +857,13 @@ async def test_search_web_no_api_key(): 8. **Caching when appropriate** - Cache expensive operations 9. **Rate limiting** - Protect external APIs 10. **Logging** - Log for debugging and monitoring +11. **Mode-aware events** - Use native-compatible events unless default mode is required +12. **Inline rich UI carefully** - Use `HTMLResponse` cards only when visual output improves the workflow ## Additional Resources -- [Tools Documentation](https://docs.openwebui.com/features/plugin/tools/) +- [Tools Documentation](https://docs.openwebui.com/features/extensibility/plugin/tools/) +- [Events Documentation](https://docs.openwebui.com/features/extensibility/plugin/development/events/) +- [Valves Documentation](https://docs.openwebui.com/features/extensibility/plugin/development/valves/) - [Example Tools](https://openwebui.com/search?type=tool) - [Type Hints Guide](https://typing.python.org/en/latest/) From e98aba6bb99ced193ddd85bf01a9fb15e3552443 Mon Sep 17 00:00:00 2001 From: Jonathan Flower Date: Wed, 19 Aug 2026 11:51:02 -0400 Subject: [PATCH 21/21] fix: support Claude 5 adaptive thinking --- functions/pipes/anthropic/README.md | 7 +++--- functions/pipes/anthropic/main.py | 35 ++++++++++++++++------------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/functions/pipes/anthropic/README.md b/functions/pipes/anthropic/README.md index 1a33654..0e19cf2 100644 --- a/functions/pipes/anthropic/README.md +++ b/functions/pipes/anthropic/README.md @@ -467,11 +467,12 @@ Solution: Debug model discovery 5. Test direct API call to /v1/models endpoint ``` -#### ❌ Opus 4.7 Request Errors +#### ❌ Claude 5 or Opus 4.7+ Request Errors **Problem**: `HTTP Error 400 ... temperature is deprecated for this model` ``` -Solution: Remove sampling parameters for Claude Opus 4.7 and later. -1. Do not send `temperature`, `top_p`, or `top_k` for `claude-opus-4-7` +Solution: Remove sampling parameters for adaptive-thinking models. +1. Do not send `temperature`, `top_p`, or `top_k` for `claude-sonnet-5`, + `claude-fable-5`, or `claude-opus-4-7` and later 2. Use `thinking: {"type": "adaptive"}` instead of manual thinking budgets 3. Set `output_config={"effort": "high"}` or `xhigh` for more demanding work 4. Keep `DISPLAY_THINKING` enabled if you want summarized thinking output diff --git a/functions/pipes/anthropic/main.py b/functions/pipes/anthropic/main.py index c4bd945..b300a2d 100644 --- a/functions/pipes/anthropic/main.py +++ b/functions/pipes/anthropic/main.py @@ -3,10 +3,10 @@ authors: justinh-rahb, christian-taillon, jfbloom22, Mark Kazakov, Vincent, NIK-NUB, cache control added by Snav author_url: https://github.com/jfbloom22 funding_url: https://github.com/open-webui -version: 0.5.1 +version: 0.6.0 required_open_webui_version: 0.3.17 license: MIT -description: An advanced manifold pipe for interacting with Anthropic's Claude models, featuring extended thinking support, cache control, beta features, and model-specific handling for Claude 4.7 and earlier Claude 4 releases. +description: Anthropic manifold pipe with adaptive thinking, cache control, and model-aware parameters. """ import os @@ -28,7 +28,10 @@ class Valves(BaseModel): ANTHROPIC_API_KEY: str = Field(default="", description="Anthropic API Key") CLAUDE_USE_TEMPERATURE: bool = Field( default=True, - description="For older Claude 4.x models (pre-Opus 4.7): Use temperature (True) or top_p (False). Claude Opus 4.7+ rejects temperature, top_p, and top_k.", + description=( + "For older Claude models, use temperature (True) or top_p (False). " + "Adaptive-thinking models reject non-default sampling." + ), ) CLAUDE_EFFORT: str = Field( default="high", @@ -242,21 +245,23 @@ def _is_claude_4x_model(self, model_name: str) -> bool: return bool(re.match(pattern, model_name)) or bool(re.match(haiku_pattern, model_name)) - def _is_opus_47_or_newer(self, model_name: str) -> bool: + def _requires_adaptive_thinking(self, model_name: str) -> bool: """ - Determine if a model is Claude Opus 4.7 or a later Opus 4 release. + Determine whether a model requires adaptive thinking and default sampling. - Claude Opus 4.7 removed support for temperature, top_p, top_k, and manual - extended thinking budgets. The model family should use adaptive thinking - with effort instead. + Claude Opus 4.7 and later, Claude Sonnet 5, and Claude Fable 5 reject + non-default temperature, top_p, and top_k. They also require adaptive, + rather than manual, thinking. """ import re - match = re.match(r"^claude-opus-4-(\d+)(?:-\d{8})?$", model_name) - if not match: - return False + if re.match( + r"^claude-(?:sonnet|fable|mythos|opus)-5(?:-\d{8})?$", model_name + ): + return True - return int(match.group(1)) >= 7 + match = re.match(r"^claude-opus-4-(\d+)(?:-\d{8})?$", model_name) + return bool(match and int(match.group(1)) >= 7) def pipes(self) -> List[dict]: return self.get_anthropic_models() @@ -384,11 +389,11 @@ def pipe(self, body: dict) -> Union[str, Generator, Iterator]: thinking_budget = max(1024, min(32000, self.valves.THINKING_BUDGET)) payload["thinking"] = {"type": "enabled", "budget_tokens": thinking_budget} - # Opus 4.7+ uses adaptive thinking and rejects legacy sampling parameters. - is_opus_47_or_newer = self._is_opus_47_or_newer(api_model_name) + # Current adaptive-thinking models reject non-default sampling parameters. + requires_adaptive_thinking = self._requires_adaptive_thinking(api_model_name) is_claude_4x_model = self._is_claude_4x_model(api_model_name) - if is_opus_47_or_newer: + if requires_adaptive_thinking: payload["output_config"] = {"effort": self.valves.CLAUDE_EFFORT} if self.valves.ENABLE_THINKING: payload["thinking"] = {