Skip to main content

๐Ÿช„ Filter Function: Modify Inputs and Outputs

โš ๏ธ Critical Security Warning

Filter Functions execute arbitrary Python code on your server. Function creation is restricted to administrators only. Only install from trusted sources and review code before importing. A malicious Function could access your file system, exfiltrate data, or compromise your entire system. For full details, see the Plugin Security Warning.

Welcome to the comprehensive guide on Filter Functions in Open WebUI! Filters are a flexible and powerful plugin system for modifying data before it's sent to the Large Language Model (LLM) (input) or after itโ€™s returned from the LLM (output). Whether youโ€™re transforming inputs for better context or cleaning up outputs for improved readability, Filter Functions let you do it all.

This guide will break down what Filters are, how they work, their structure, and everything you need to know to build powerful and user-friendly filters of your own. Letโ€™s dig in, and donโ€™t worryโ€”Iโ€™ll use metaphors, examples, and tips to make everything crystal clear! ๐ŸŒŸ


๐ŸŒŠ What Are Filters in Open WebUI?โ€‹

Imagine Open WebUI as a stream of water flowing through pipes:

  • User inputs and LLM outputs are the water.
  • Filters are the water treatment stages that clean, modify, and adapt the water before it reaches the final destination.

Filters sit in the middle of the flowโ€”like checkpointsโ€”where you decide what needs to be adjusted.

Hereโ€™s a quick summary of what Filters do:

  1. Modify User Inputs (Inlet Function): Tweak the input data before it reaches the AI model. This is where you enhance clarity, add context, sanitize text, or reformat messages to match specific requirements.
  2. Intercept Model Outputs (Stream Function): Capture and adjust the AIโ€™s responses as theyโ€™re generated by the model. This is useful for real-time modifications, like filtering out sensitive information or formatting the output for better readability.
  3. Modify Model Outputs (Outlet Function): Adjust the AI's response after itโ€™s processed, before showing it to the user. This can help refine, log, or adapt the data for a cleaner user experience.

Key Concept: Filters are not standalone models but tools that enhance or transform the data traveling to and from models.

Filters are like translators or editors in the AI workflow: you can intercept and change the conversation without interrupting the flow.


๐Ÿ—บ๏ธ Structure of a Filter Function: The Skeletonโ€‹

Let's start with the simplest representation of a Filter Function. Don't worry if some parts feel technical at firstโ€”weโ€™ll break it all down step by step!

๐Ÿฆด Basic Skeleton of a Filterโ€‹

from pydantic import BaseModel
from typing import Optional

class Filter:
    # Valves: Configuration options for the filter
    class Valves(BaseModel):
        pass

    def __init__(self):
        # Initialize valves (optional configuration for the Filter)
        self.valves = self.Valves()

    async def inlet(self, body: dict) -> dict:
        # This is where you manipulate user inputs.
        print(f"inlet called: {body}")
        return body

    async def stream(self, event: dict) -> dict:
        # This is where you modify streamed chunks of model output.
        print(f"stream event: {event}")
        return event

    async def outlet(self, body: dict) -> dict:
        # This is where you manipulate model outputs.
        print(f"outlet called: {body}")
        return body

๐Ÿงฒ Toggleable Filters: Making Filters User-Controllable (self.toggle)โ€‹

By default a filter that's active and in scope (global, or attached to the model) runs on every request โ€” the user has no say in it. That's often what you want (PII scrubbing, logging, mandatory guardrails). Sometimes you want the opposite: let the user decide whether the filter runs for a given conversation.

Set self.toggle = True to make the filter user-controllable. The filter then shows up in the chat UI with a clickable chip + an entry in the Integrations menu, and only runs on requests where the user has it selected.

from pydantic import BaseModel, Field
from typing import Optional

class Filter:
    class Valves(BaseModel):
        pass

    def __init__(self):
        self.valves = self.Valves()
        self.toggle = True   # Make this filter user-controllable (see notes below)
        # TIP: Use a hosted URL for your icon instead of base64 to avoid API payload bloat.
        # See the Action Function docs for details on why base64 icons are not recommended.
        self.icon = "https://example.com/icons/lightbulb.svg"

    async def inlet(
        self, body: dict, __event_emitter__, __user__: Optional[dict] = None
    ) -> dict:
        # This method ONLY runs when the filter is currently selected by the user.
        # You do NOT need to branch on self.toggle inside here โ€” see the note below.
        await __event_emitter__(
            {
                "type": "status",
                "data": {"description": "Running!", "done": True, "hidden": False},
            }
        )
        return body

What self.toggle = True actually doesโ€‹

It is a visibility / gating flag, read once at request-dispatch time โ€” not a runtime state the UI flips on your Python object. Specifically:

  • Visibility: the filter only appears in the chat UI (inline chip + Integrations menu entry) when self.toggle = True and it is either a global filter or attached to the selected model. Without self.toggle, the filter still runs (if active and in scope) but has no UI surface โ€” users can't turn it off.
  • Gating: at request time the backend checks the user's current filter_ids selection. If the filter is in that list, inlet() / stream() / outlet() run. If not, the filter is not invoked at all.
  • self.toggle is never mutated by the UI. Inside inlet() it is always whatever you set in __init__ โ€” which will be True for every call that actually runs, because if the user had disabled the filter, inlet() wouldn't be running. Don't build logic that reads self.toggle at runtime; it's not a live on/off signal.
Upgrading from pre-0.9.0 filters

Some older filters used a pattern like if self.toggle: enable_feature() else: disable_feature() inside inlet(), hoping to read the UI state back on every request. That pattern was never reliable and is effectively dead on 0.9.0+. inlet() simply isn't called when the filter is disabled in the UI, so there is no "else" branch to hit. The correct migration is to stop branching on self.toggle entirely and just do the work unconditionally โ€” the user controls whether inlet() runs by selecting/deselecting the chip. If you need user-driven config (a numeric threshold, a target language, etc.), expose it through UserValves instead; clicking the chip opens the user's valves modal automatically.

How users interact with a toggleable filterโ€‹

When a toggleable filter is in scope for the current chat, two UI surfaces show up:

  • Inline chip in the chat input bar. Shows the filter's self.icon + name. Clicking it:
    • opens the user-valves modal if the filter defines a UserValves class (so the user can tune per-chat settings), otherwise
    • removes the filter from the current selection for this chat session (the chip disappears from the inline row).
  • Integrations menu (โš™๏ธ icon). Lists every toggleable filter in scope, each with a proper on/off Switch. This is where users re-enable a filter they removed from the chip row, or switch one off that was selected by default.

The chip being present = the filter is enabled for the next request. The chip being absent (but the filter is in the Integrations menu) = the user has turned it off.

Where the selection livesโ€‹

  • Stored in the browser as sessionStorage draft state, keyed per chat.
  • Survives page reloads in the same browser session but is not persisted to the chat record on the server.
  • Initial state comes from the model's defaultFilterIds (Admin Panel โ†’ Model Settings โ†’ Default Filters) โ€” admins decide which toggleable filters start on vs off per model.
  • Resets when the user switches to a different model.

Toggle Filter

self.icon continues to work as before: pass a URL (strongly preferred) or a base64 data URI, and it renders in both the inline chip and the Integrations menu entry. See the Action Function icon_url warning for why hosted URLs are recommended over base64.


โš™๏ธ Filter Administration & Configurationโ€‹

๐ŸŒ Global Filters vs. Model-Specific Filtersโ€‹

Open WebUI provides a flexible multi-level filter system that allows you to control which filters are active, how they're enabled, and who can toggle them. Understanding this system is crucial for effective filter management.

Filter Activation Statesโ€‹

Filters can exist in one of four states, controlled by two boolean flags in the database:

Stateis_activeis_globalEffect
Globally Enabledโœ… Trueโœ… TrueApplied to ALL models automatically, cannot be disabled per-model
Globally DisabledโŒ FalseTrueNot applied anywhere - even though the filter is globally enabled, the filter itself is disabled
Model-Specificโœ… TrueโŒ FalseOnly applied to models where the admin explicitly enables it
InactiveโŒ FalseFalseNot applied anywhere, even if filter is enabled for a model by the admin - the filter itself is turned off
Global Filter Behavior

When a filter is set as Global (is_global=True) and Active (is_active=True), it becomes force-enabled for all models:

  • It appears in every model's filter list as checked and greyed out
  • Admins cannot uncheck it in model settings
  • It runs on every chat completion request, regardless of model

Admin Panel: Making a Filter Globalโ€‹

Location: Admin Panel โ†’ Functions โ†’ Filter Management

To make a filter global:

  1. Navigate to the Admin Panel
  2. Click on Functions in the sidebar
  3. Find your filter in the list
  4. Click the three-dot menu (โ‹ฎ) next to the filter
  5. Click the ๐ŸŒ Globe icon to toggle is_global
  6. Ensure the filter is also Active (green toggle switch)

API Endpoint:

POST /functions/id/{filter_id}/toggle/global

Visual Indicators:

  • ๐ŸŸข Green toggle = is_active=True (filter is active)
  • ๐ŸŒ Highlighted globe icon = is_global=True (applies to all models)

๐ŸŽ›๏ธ The Two-Tier Filter Systemโ€‹

Open WebUI uses a sophisticated two-tier system for managing filters on a per-model basis. This can be confusing at first, but it's designed to support both always-on filters and user-toggleable filters.

Tier 1: FiltersSelector (Which filters are available?)โ€‹

Location: Model Settings โ†’ Filters โ†’ "Filters" Section

This controls which filters are available for a specific model.

Behavior:

  • Shows all filters (both global and model-specific)
  • Global filters appear as checked and disabled (can't be unchecked)
  • Regular filters can be toggled on/off
  • Saves to: model.meta.filterIds in the database

Example:

{
  "meta": {
    "filterIds": ["filter-uuid-1", "filter-uuid-2"]
  }
}

Tier 2: DefaultFiltersSelector (Which toggleable filters start enabled?)โ€‹

Location: Model Settings โ†’ Filters โ†’ "Default Filters" Section

This section only appears when at least one toggleable filter is selected (or is global).

Purpose: Controls which toggleable filters are enabled by default for new chats.

What is a "Toggleable" Filter?

A filter becomes toggleable when its Python code includes:

class Filter:
    def __init__(self):
        self.toggle = True  # This makes it toggleable!

Behavior:

  • Only shows filters with toggle=True
  • Only shows filters that are either:
    • In filterIds (selected for this model), OR
    • Have is_global=true (globally enabled)
  • Controls whether the filter is ON or OFF by default in the chat UI
  • Saves to: model.meta.defaultFilterIds

Example:

{
  "meta": {
    "filterIds": ["filter-uuid-1", "filter-uuid-2", "filter-uuid-3"],
    "defaultFilterIds": ["filter-uuid-2"]
  }
}

Interpretation:

  • All three filters are available for this model
  • Only filter-uuid-2 starts enabled by default
  • If filter-uuid-1 and filter-uuid-3 have toggle=True, users can enable them manually in the chat UI

๐Ÿ”„ Toggleable Filters vs. Always-On Filtersโ€‹

Understanding the difference between these two types is key to using the filter system effectively.

Always-On Filters (No toggle property)โ€‹

Characteristics:

  • Run automatically whenever the filter is active for a model
  • No user control in the chat interface
  • Do not appear in the "Default Filters" section
  • Do not show up in the chat integrations menu (โš™๏ธ icon)

Use Cases:

  • Content moderation - Filter profanity, hate speech, or inappropriate content
  • PII scrubbing - Help redact emails, phone numbers, SSNs, credit card numbers
  • Prompt injection detection - Block attempts to manipulate the system prompt
  • Input/output logging - Track all conversations for audit or analytics
  • Cost tracking - Estimate and log token usage for billing
  • Rate limiting - Enforce request limits per user or globally
  • Language enforcement - Ensure responses are in a specific language
  • Company policy enforcement - Inject legal disclaimers or compliance notices
  • Model routing - Redirect requests to different models based on content

Example:

class ContentModerationFilter:
    def __init__(self):
        # No toggle property - this is an always-on filter
        pass

    async def inlet(self, body: dict) -> dict:
        # Always scrub PII before sending to model
        last_message = body["messages"][-1]["content"]
        body["messages"][-1]["content"] = self.scrub_pii(last_message)
        return body

Toggleable Filters (toggle=True)โ€‹

Characteristics:

  • Appear in the chat input bar as a clickable chip and in the Integrations menu (โš™๏ธ icon) as a Switch.
  • Users can add or remove them from the active selection on a per-chat, per-session basis. Selection is stored in browser sessionStorage โ€” not persisted to the chat record on the server.
  • Do appear in the model's "Default Filters" configuration.
  • defaultFilterIds on the model controls the initial selection (which toggleable filters start on when a new chat begins with that model).
  • self.toggle itself is never mutated at runtime โ€” it's a visibility/gating flag read once at request dispatch. inlet() only runs when the filter is currently selected; there is no "else" branch to write inside the filter. See the detailed note above.

Use Cases:

  • Web search integration - User decides when to search the web for context
  • Citation mode - User controls when to require sources in responses
  • Verbose/detailed mode - User toggles between concise and detailed responses
  • Translation filters - User enables translation to/from specific languages
  • Code formatting - User chooses when to apply syntax highlighting or linting
  • Thinking/reasoning toggle - User switches the underlying model's thinking mode on/off by enabling the filter (do the work unconditionally in inlet(); the user disables it by removing the chip)
  • Markdown rendering - Toggle between raw text and formatted output
  • Anonymization mode - User enables when discussing sensitive topics
  • Expert mode - Inject domain-specific context (legal, medical, technical)
  • Creative writing mode - Adjust temperature and style for creative tasks

Example:

class WebSearchFilter:
    def __init__(self):
        self.toggle = True  # Make user-controllable
        self.icon = "https://example.com/icons/web-search.svg"

    async def inlet(self, body: dict, __event_emitter__) -> dict:
        # This only runs when the user has this filter selected.
        # Do NOT branch on self.toggle here โ€” it's always True when this runs.
        await __event_emitter__({
            "type": "status",
            "data": {"description": "Searching the web...", "done": False}
        })
        # ... perform web search ...
        return body

Where Toggleable Filters Appear:

  1. Model Settings โ†’ Default Filters
    • Admin picks which toggleable filters start in the selection on new chats with that model.
  2. Chat input bar โ†’ Inline chip
    • Shown for every toggleable filter currently in the user's selection for this chat.
    • Clicking the chip opens the user-valves modal if the filter defines UserValves, otherwise removes the filter from the selection (it moves back to the Integrations menu where the user can re-enable it).
    • self.icon renders as the chip's image.
  3. Chat UI โ†’ Integrations Menu (โš™๏ธ icon)
    • Lists every toggleable filter in scope for the current model, each with a proper on/off Switch.
    • Used to re-enable a filter removed from the chip row, or to turn off a filter selected by default.

๐Ÿ“Š Filter Execution Flowโ€‹

Here's the complete flow from admin configuration to filter execution:

1. ADMIN PANEL (Filter Creation & Global Settings)

  • Admin Panel โ†’ Functions โ†’ Create New Function
  • Set type="filter"
  • Toggle is_active (enable/disable filter globally)
  • Toggle is_global (apply to all models)

2. MODEL CONFIGURATION (Per-Model Filter Selection)

  • Model Settings โ†’ Filters Section
  • FiltersSelector: Select which filters for this model
  • DefaultFiltersSelector: Set default enabled state (only for toggleable filters)

3. CHAT UI (User Interaction - Toggleable Filters Only)

  • Chat input bar โ†’ Inline chip (add/remove via click; click opens UserValves modal if defined)
  • Chat โ†’ Integrations Menu (โš™๏ธ) โ†’ Switch per toggleable filter
  • Frontend tracks selectedFilterIds in sessionStorage (per chat, per session)
  • Initial selection seeded from the model's defaultFilterIds
  • Always-on filters (no self.toggle) run automatically with no UI control

4. REQUEST PROCESSING (Filter Compilation)

  • Frontend ships the current selectedFilterIds with the request
  • Backend: get_sorted_filter_ids(request, model, filter_ids)
  • Fetch global filters (is_global=True, is_active=True) + model-specific filters from model.meta.filterIds
  • Filter by is_active status
  • For toggleable filters: keep only the ones whose ID is in the request's filter_ids โ€” others are dropped entirely (never invoked, self.toggle never read)
  • Sort by priority (from valves)

5. FILTER EXECUTION

  • Execute inlet() filters (pre-request)
  • Send modified request to LLM
  • Execute stream() filters (during streaming)
  • Execute outlet() filters (post-response)

๐Ÿ“ก Filter Behavior with API Requestsโ€‹

When using Open WebUI's API endpoints directly (e.g., via curl or external applications), inlet() and stream() follow the same execution model as WebUI requests. outlet() is the one that behaves very differently for direct API callers and is covered in detail below.

Key Behavioral Differencesโ€‹

FunctionWebUI RequestDirect API โ€” stable (main)Direct API โ€” pre-release (dev)
inlet()โœ… Always calledโœ… Always calledโœ… Always called
stream()โœ… Called during streamingโœ… Called during streamingโœ… Called during streaming
outlet()โœ… Called after responseโŒ Not called by /api/chat/completions โ€” only by /api/chat/completedโš ๏ธ Runs inline only under narrow conditions (see below)
__event_emitter__โœ… Shows UI feedbackโš ๏ธ Inert for pure API callersโš ๏ธ Inert for pure API callers
outlet() on Direct API Calls โ€” Accurate Picture

Earlier versions of this page claimed outlet() runs inline during /api/chat/completions for non-streaming direct API requests. That was only partially accurate and only on dev. Verified against the backend source, the real behavior is:

On tagged releases / main: outlet() is never invoked by /api/chat/completions. It runs only when the caller performs the second POST to /api/chat/completed. API integrations that need outlet() must make both calls.

On dev / pre-release builds: outlet() can fire inline after /api/chat/completions, but only when all of the following hold:

  1. The request body includes both chat_id and id (the assistant message id). If either is missing, the backend sees event_emitter = None and silently skips the outlet block.
  2. The chat_id is a chat the authenticated user already owns; otherwise the request 404s before the outlet path runs. (Alternatively, send parent_id: null without a chat_id to have the server create a new chat.)
  3. The request is non-streaming. On the streaming path the server consumes the stream itself and routes content to the user's WebSocket โ€” an HTTP streaming API caller receives effectively nothing. outlet() still runs DB-side, but the streaming client does not see it.

Even on the non-streaming path, outlet() does not rewrite the HTTP response body. The handler updates the persisted chat message row and emits a chat:outlet WebSocket event; the JSON returned to your HTTP client is the pre-outlet content. To observe outlet()'s output you must either read the chat record back, subscribe to the WebSocket, or use /api/chat/completed.

Bottom line for filter authors: if your filter's outlet() needs to be visible to pure API consumers (Continue.dev, Claude Code, Langfuse traces, custom scripts), assume /api/chat/completed is the supported surface today. Relying on inline outlet() during /api/chat/completions currently works only for clients shaped like the WebUI.

Inlet โ†” Outlet Correlation via __metadata__โ€‹

__metadata__ is a live dict passed through the request lifecycle, so anything your filter stashes in inlet() is visible in outlet() on the same request โ€” this is useful for things like start-time tracking, correlation IDs, or per-call valves, when outlet() actually runs. Given the constraints above, this pattern is primarily useful in WebUI requests and in the /api/chat/completed handler.

import time
from pydantic import BaseModel, Field

class Filter:
    class Valves(BaseModel):
        priority: int = Field(default=0)

    def __init__(self):
        self.valves = self.Valves()

    async def inlet(self, body: dict, __metadata__: dict = None) -> dict:
        if __metadata__ is not None:
            __metadata__["_my_started_at"] = time.monotonic()
        return body

    async def outlet(self, body: dict, __metadata__: dict = None) -> dict:
        if __metadata__ is not None and "_my_started_at" in __metadata__:
            duration = time.monotonic() - __metadata__["_my_started_at"]
            print(f"request took {duration:.3f}s")
        return body
Synthesizing chat_id / message_id in inlet() Is Not a Reliable Workaround

Older versions of this page suggested synthesizing a local:<uuid> chat_id and a random message_id inside inlet() to force outlet() to run for pure API callers. Do not rely on that pattern โ€” in current backend code the chat ownership check runs before the filter pipeline, and the inline outlet handler does not rewrite the HTTP response body even when it does run. If you need outlet() output over HTTP for an API caller, use /api/chat/completed instead.

Running outlet() for API Callers: /api/chat/completedโ€‹

The reliable, supported way to run outlet() for a direct API integration is to follow up /api/chat/completions with POST /api/chat/completed, passing the full conversation (including the assistant response) in messages. The endpoint runs pipeline outlet filters and Function outlet() handlers unconditionally and returns the filtered payload.

# Step 2: run outlet() after /api/chat/completions returned a response.
curl -X POST http://localhost:3000/api/chat/completed \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.1",
    "messages": [
      {"role": "user", "content": "Hello"},
      {"role": "assistant", "content": "Hi there! How can I help you?"}
    ],
    "chat_id": "optional-chat-id",
    "session_id": "optional-session-id"
  }'
info

On dev this endpoint is labeled deprecated in favor of inline execution, but because inline execution does not return the filtered payload over HTTP to pure API callers, /api/chat/completed is still the correct choice for most API integrations today.

Detecting API vs WebUI Requestsโ€‹

You can detect whether a request originates from the WebUI or a direct API call by checking the __metadata__ argument:

async def inlet(self, body: dict, __metadata__: dict = None) -> dict:
    # Check if request is from WebUI
    interface = __metadata__.get("interface") if __metadata__ else None
    
    if interface == "open-webui":
        print("Request from WebUI")
    else:
        print("Direct API request")
    
    # You can also check for presence of chat context
    chat_id = __metadata__.get("chat_id") if __metadata__ else None
    if not chat_id:
        print("No chat context - likely a direct API call")
    
    return body

Example: Rate Limiting for All Requestsโ€‹

Since inlet() is always called, use it for rate limiting that applies to both WebUI and API requests:

from pydantic import BaseModel, Field
from typing import Optional
import time

class Filter:
    class Valves(BaseModel):
        requests_per_minute: int = Field(default=60, description="Max requests per minute per user")
    
    def __init__(self):
        self.valves = self.Valves()
        self.user_requests = {}  # Track requests per user
    
    async def inlet(self, body: dict, __user__: dict = None) -> dict:
        if not __user__:
            return body
        
        user_id = __user__.get("id")
        current_time = time.time()
        
        # Clean old entries and count recent requests
        if user_id not in self.user_requests:
            self.user_requests[user_id] = []
        
        # Keep only requests from the last minute
        self.user_requests[user_id] = [
            t for t in self.user_requests[user_id] 
            if current_time - t < 60
        ]
        
        if len(self.user_requests[user_id]) >= self.valves.requests_per_minute:
            raise Exception(f"Rate limit exceeded: {self.valves.requests_per_minute} requests/minute")
        
        self.user_requests[user_id].append(current_time)
        return body

Example: Logging All API Usageโ€‹

Track token usage and requests for both WebUI and direct API calls:

from pydantic import BaseModel, Field
from typing import Optional
import logging

class Filter:
    class Valves(BaseModel):
        log_level: str = Field(default="INFO", description="Logging level")
    
    def __init__(self):
        self.valves = self.Valves()
        self.logger = logging.getLogger("api_usage")
    
    async def inlet(self, body: dict, __user__: dict = None, __metadata__: dict = None) -> dict:
        user_email = __user__.get("email", "unknown") if __user__ else "anonymous"
        model = body.get("model", "unknown")
        interface = __metadata__.get("interface", "api") if __metadata__ else "api"
        chat_id = __metadata__.get("chat_id") if __metadata__ else None
        
        self.logger.info(
            f"Request: user={user_email}, model={model}, "
            f"interface={interface}, chat_id={chat_id or 'none'}"
        )
        
        return body
Event Emitter Behavior

Filters that use __event_emitter__ will still execute for API requests, but since there's no WebUI to display the events, the status messages won't be visible. The filter logic still runsโ€”only the visual feedback is missing.


โšก Filter Priority & Execution Orderโ€‹

When multiple filters are active, they execute in a specific order determined by their priority value. Understanding this is crucial when building filter chains where one filter depends on another's changes.

Setting Filter Priorityโ€‹

Priority is configured via the Valves class using a priority field:

class Filter:
    class Valves(BaseModel):
        priority: int = Field(
            default=0,
            description="Filter execution order. Lower values run first."
        )
    
    def __init__(self):
        self.valves = self.Valves()
    
    async def inlet(self, body: dict) -> dict:
        # This filter's execution order depends on its priority value
        return body

Priority Ordering Rulesโ€‹

Priority ValueExecution Order
0 (default)Runs first
1Runs after priority 0
2Runs after priority 1
Lower Priority = Earlier Execution

Filters are sorted in ascending order by priority. A filter with priority=0 runs before a filter with priority=1, which runs before priority=2, and so forth. When multiple filters share the same priority value, they are sorted alphabetically by function ID for deterministic ordering.


๐Ÿ”— Data Passing Between Filtersโ€‹

When multiple filters are active, each filter in the chain receives the modified data from the previous filter. The returned value from one filter becomes the input to the next filter in the priority order.

User Input
โ†“
Model Router Filter (priority=0) โ†’ changes parts of the body
โ†“
Context Manager Filter (priority=1) โ†’ receives modified body โœ“
โ†“
Logging Filter (priority=2) โ†’ receives body with all previous changes โœ“
โ†“
LLM Request (sends final modified body to OpenAI/Ollama API)
Important: Always Return the Body

If your filter modifies the body, you must return it. The returned value is passed to the next filter. If you return None, subsequent filters will fail.

async def inlet(self, body: dict, __event_emitter__) -> dict:
    body["messages"].append({"role": "system", "content": "Hello"})
    return body  # Don't forget this!

๐Ÿ”Œ Injecting Extra API Body Parametersโ€‹

Inlet filters can inject extra fields into the request body that get forwarded to the external LLM API. This is useful for API-specific parameters that Open WebUI doesn't expose in the UI.

The request body flows from your inlet filter to the LLM API without stripping unknown fields โ€” only internal keys like metadata, features, tool_ids, files, and skill_ids are removed. Any other field you add will be serialized to JSON and sent to the API provider.

Example: OpenAI Safety Identifierโ€‹

OpenAI recommends sending a safety_identifier with each request for abuse detection. You can inject this automatically via a filter:

import hashlib

class Filter:
    async def inlet(self, body: dict, __user__: dict = None) -> dict:
        if __user__ and __user__.get("id"):
            body["safety_identifier"] = hashlib.sha256(
                __user__["id"].encode()
            ).hexdigest()
        return body

The hashed user UUID is added as a top-level body parameter and forwarded directly to OpenAI's API โ€” no PII is sent, just an opaque hash.

Cannot Inject HTTP Headers

Filters can only modify the request body (form_data). Outbound HTTP headers are constructed separately and cannot be influenced from a filter. To add custom headers to API requests, use the Admin Panel โ†’ Settings โ†’ Connections โ†’ OpenAI API headers configuration.


๐Ÿ” Resolving the Base Model (__model__)โ€‹

When a user selects a workspace or custom model, body["model"] contains the custom model ID (e.g. "my-custom-gpt5"), not the underlying base model. To discover the actual base model, use the __model__ dunder parameter:

class Filter:
    async def inlet(self, body: dict, __model__: dict = None) -> dict:
        custom_model_id = body["model"]  # e.g. "my-custom-gpt5"

        base_model_id = None
        if __model__ and "info" in __model__:
            base_model_id = __model__["info"].get("base_model_id")
            # e.g. "gpt-5.2"

        if base_model_id:
            print(f"Workspace model '{custom_model_id}' โ†’ base model '{base_model_id}'")
        else:
            print(f"Direct base model: '{custom_model_id}'")

        return body

If no base_model_id is present, the user selected a base model directly (no workspace wrapper).

Available Dunder Parametersโ€‹

Filters can declare any of these parameters in their function signature to receive them automatically:

ParameterWhat it provides
__model__Full model dict (including info.base_model_id for workspace models)
__user__User data (id, email, name, role)
__metadata__Request metadata (chat_id, session_id, interface, etc.)
__event_emitter__Function to send status updates, embeds, etc. to the client
__chat_id__Chat session ID
__request__The raw FastAPI Request object

Only parameters you declare in your function signature are injected โ€” Open WebUI inspects the signature at runtime to determine what to pass.


๐ŸŽจ UI Indicators & Visual Feedbackโ€‹

In the Admin Functions Panelโ€‹

IndicatorMeaning
๐ŸŸข Green toggleFilter is active (is_active=True)
โšช Grey toggleFilter is inactive (is_active=False)
๐ŸŒ Highlighted globeFilter is global (is_global=True)
๐ŸŒ Unhighlighted globeFilter is not global (is_global=False)

In Model Settings (FiltersSelector)โ€‹

StateCheckboxDescription
Global Filterโœ… Checked & Disabled (greyed)"This filter is globally enabled"
Selected Filterโœ… Checked & Enabled"This filter is selected for this model"
Unselected Filterโ˜ Unchecked & Enabled"Click to include this filter"

In Chat UI (Integrations Menu)โ€‹

ElementDescription
Filter nameShows the filter's display name
Custom iconSVG icon from self.icon (if provided)
Toggle switchEnable/disable the filter for this chat
Status badgeShows if filter is currently active

๐Ÿ’ก Best Practices for Filter Configurationโ€‹

1. When to Use Global Filtersโ€‹

โœ… Use global filters for:

  • Security and compliance (PII scrubbing, content moderation)
  • System-wide formatting (standardize all outputs)
  • Logging and analytics (track all requests)
  • Organization-wide policies (enforce company guidelines)

โŒ Don't use global filters for:

  • Optional features (use toggleable filters instead)
  • Model-specific behavior (use model-specific filters)
  • User-preference features (let users control via toggles)

2. When to Use Toggleable Filtersโ€‹

โœ… Make a filter toggleable (toggle=True) when:

  • Users should control when it's active (web search, translation)
  • It's an optional enhancement (citation mode, verbose output)
  • It adds functionality users may not always want (code formatting)
  • It has a performance cost that should be optional

โŒ Don't make a filter toggleable when:

  • It's required for security/compliance (always-on is better)
  • Users shouldn't be able to disable it (use always-on)
  • It's a system-level transformation (global is better)

3. Organizing Filters for Your Organizationโ€‹

Recommended Structure:

Global Always-On Filters:
โ”œโ”€ PII Scrubber (security)
โ”œโ”€ Content Moderator (compliance)
โ””โ”€ Request Logger (analytics)

Model-Specific Always-On Filters:
โ”œโ”€ Code Formatter (for coding models only)
โ”œโ”€ Medical Terminology Corrector (for medical models)
โ””โ”€ Legal Citation Validator (for legal models)

Toggleable Filters (User Choice):
โ”œโ”€ Web Search Integration
โ”œโ”€ Citation Mode
โ”œโ”€ Translation Filter
โ”œโ”€ Verbose Output Mode
โ””โ”€ Image Description Generator

๐ŸŽฏ Key Components Explainedโ€‹

1๏ธโƒฃ Valves Class (Optional Settings)โ€‹

Think of Valves as the knobs and sliders for your filter. If you want to give users configurable options to adjust your Filterโ€™s behavior, you define those here.

class Valves(BaseModel):
    OPTION_NAME: str = "Default Value"

For example: If you're creating a filter that converts responses into uppercase, you might allow users to configure whether every output gets totally capitalized via a valve like TRANSFORM_UPPERCASE: bool = True/False.

Configuring Valves with Dropdown Menus (Enums)โ€‹

You can enhance the user experience for your filter's settings by providing dropdown menus instead of free-form text inputs for certain Valves. This is achieved using json_schema_extra with the enum keyword in your Pydantic Field definitions.

The enum keyword allows you to specify a list of predefined values that the UI should present as options in a dropdown.

Example: Creating a dropdown for color themes in a filter.

from pydantic import BaseModel, Field
from typing import Optional

# Define your available options (e.g., color themes)
COLOR_THEMES = {
    "Plain (No Color)": [],
    "Monochromatic Blue": ["blue", "RoyalBlue", "SteelBlue", "LightSteelBlue"],
    "Warm & Energetic": ["orange", "red", "magenta", "DarkOrange"],
    "Cool & Calm": ["cyan", "blue", "green", "Teal", "CadetBlue"],
    "Forest & Earth": ["green", "DarkGreen", "LimeGreen", "OliveGreen"],
    "Mystical Purple": ["purple", "DarkOrchid", "MediumPurple", "Lavender"],
    "Grayscale": ["gray", "DarkGray", "LightGray"],
    "Rainbow Fun": [
        "red",
        "orange",
        "yellow",
        "green",
        "blue",
        "indigo",
        "violet",
    ],
    "Ocean Breeze": ["blue", "cyan", "LightCyan", "DarkTurquoise"],
    "Sunset Glow": ["DarkRed", "DarkOrange", "Orange", "gold"],
    "Custom Sequence (See Code)": [],
}

class Filter:
    class Valves(BaseModel):
        selected_theme: str = Field(
            "Monochromatic Blue",
            description="Choose a predefined color theme for LLM responses. 'Plain (No Color)' disables coloring.",
            json_schema_extra={"enum": list(COLOR_THEMES.keys())}, # KEY: This creates the dropdown
        )
        custom_colors_csv: str = Field(
            "",
            description="CSV of colors for 'Custom Sequence' theme (e.g., 'red,blue,green'). Uses xcolor names.",
        )
        strip_existing_latex: bool = Field(
            True,
            description="If true, attempts to remove existing LaTeX color commands. Recommended to avoid nested rendering issues.",
        )
        colorize_type: str = Field(
            "sequential_word",
            description="How to apply colors: 'sequential_word' (word by word), 'sequential_line' (line by line), 'per_letter' (letter by letter), 'full_message' (entire message).",
            json_schema_extra={
                "enum": [
                    "sequential_word",
                    "sequential_line",
                    "per_letter",
                    "full_message",
                ]
            }, # Another example of an enum dropdown
        )
        color_cycle_reset_per_message: bool = Field(
            True,
            description="If true, the color sequence restarts for each new LLM response message. If false, it continues across messages.",
        )
        debug_logging: bool = Field(
            False,
            description="Enable verbose logging to the console for debugging filter operations.",
        )

    def __init__(self):
        self.valves = self.Valves()
        # ... rest of your __init__ logic ...

What's happening?

  • json_schema_extra: This argument in Field allows you to inject arbitrary JSON Schema properties that Pydantic doesn't explicitly support but can be used by downstream tools (like Open WebUI's UI renderer).
  • "enum": list(COLOR_THEMES.keys()): This tells Open WebUI that the selected_theme field should present a selection of values, specifically the keys from our COLOR_THEMES dictionary. The UI will then render a dropdown menu with "Plain (No Color)", "Monochromatic Blue", "Warm & Energetic", etc., as selectable options.
  • The colorize_type field also demonstrates another enum dropdown for different coloring methods.

Using enum for your Valves options makes your filters more user-friendly and prevents invalid inputs, leading to a smoother configuration experience.


2๏ธโƒฃ inlet Function (Input Pre-Processing)โ€‹

The inlet function is like prepping food before cooking. Imagine youโ€™re a chef: before the ingredients go into the recipe (the LLM in this case), you might wash vegetables, chop onions, or season the meat. Without this step, your final dish could lack flavor, have unwashed produce, or simply be inconsistent.

In the world of Open WebUI, the inlet function does this important prep work on the user input before itโ€™s sent to the model. It ensures the input is as clean, contextual, and helpful as possible for the AI to handle.

๐Ÿ“ฅ Input:

  • body: The raw input from Open WebUI to the model. It is in the format of a chat-completion request (usually a dictionary that includes fields like the conversation's messages, model settings, and other metadata). Think of this as your recipe ingredients.

๐Ÿš€ Your Task: Modify and return the body. The modified version of the body is what the LLM works with, so this is your chance to bring clarity, structure, and context to the input.

๐Ÿณ Why Would You Use the inlet?โ€‹
  1. Adding Context: Automatically append crucial information to the userโ€™s input, especially if their text is vague or incomplete. For example, you might add "You are a friendly assistant" or "Help this user troubleshoot a software bug."

  2. Formatting Data: If the input requires a specific format, like JSON or Markdown, you can transform it before sending it to the model.

  3. Sanitizing Input: Remove unwanted characters, strip potentially harmful or confusing symbols (like excessive whitespace or emojis), or replace sensitive information.

  4. Streamlining User Input: If your modelโ€™s output improves with additional guidance, you can use the inlet to inject clarifying instructions automatically!

  5. Rate Limiting: Track requests per user and reject requests that exceed your quota (works for both WebUI and API requests).

  6. Request Logging: Log all incoming requests for analytics, debugging, or billing purposes.

  7. Language Detection: Detect the user's language and inject translation instructions or route to a language-specific model.

  8. Prompt Injection Detection: Scan user input for attempts to manipulate the system prompt and block malicious requests.

  9. Cost Estimation: Estimate input tokens before sending to the model for budget tracking.

  10. A/B Testing: Route users to different model configurations based on user ID or random selection.

๐Ÿ’ก Example Use Cases: Build on Food Prepโ€‹
๐Ÿฅ— Example 1: Adding System Contextโ€‹

Letโ€™s say the LLM is a chef preparing a dish for Italian cuisine, but the user hasnโ€™t mentioned "This is for Italian cooking." You can ensure the message is clear by appending this context before sending the data to the model.

async def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
    # Add system message for Italian context in the conversation
    context_message = {
        "role": "system",
        "content": "You are helping the user prepare an Italian meal."
    }
    # Insert the context at the beginning of the chat history
    body.setdefault("messages", []).insert(0, context_message)
    return body

๐Ÿ“– What Happens?

  • Any user input like "What are some good dinner ideas?" now carries the Italian theme because weโ€™ve set the system context! Cheesecake might not show up as an answer, but pasta sure will.
๐Ÿ”ช Example 2: Cleaning Input (Remove Odd Characters)โ€‹

Suppose the input from the user looks messy or includes unwanted symbols like !!!, making the conversation inefficient or harder for the model to parse. You can clean it up while preserving the core content.

async def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
    # Clean the last user input (from the end of the 'messages' list)
    last_message = body["messages"][-1]["content"]
    body["messages"][-1]["content"] = last_message.replace("!!!", "").strip()
    return body

๐Ÿ“– What Happens?

  • Before: "How can I debug this issue!!!" โžก๏ธ Sent to the model as "How can I debug this issue"
note

Note: The user feels the same, but the model processes a cleaner and easier-to-understand query.

๐Ÿ“Š How inlet Helps Optimize Input for the LLM:โ€‹
  • Improves accuracy by clarifying ambiguous queries.
  • Makes the AI more efficient by removing unnecessary noise like emojis, HTML tags, or extra punctuation.
  • Ensures consistency by formatting user input to match the modelโ€™s expected patterns or schemas (like, say, JSON for a specific use case).

๐Ÿ’ญ Think of inlet as the sous-chef in your kitchenโ€”ensuring everything that goes into the model (your AI "recipe") has been prepped, cleaned, and seasoned to perfection. The better the input, the better the output!


๐Ÿ†• 3๏ธโƒฃ stream Hook (New in Open WebUI 0.5.17)โ€‹

๐Ÿ”„ What is the stream Hook?โ€‹

The stream function is a new feature introduced in Open WebUI 0.5.17 that allows you to intercept and modify streamed model responses in real time.

Unlike outlet, which processes an entire completed response, stream operates on individual chunks as they are received from the model.

๐Ÿ› ๏ธ When to Use the Stream Hook?โ€‹
  • Real-time content filtering - Censor profanity or sensitive content as it streams
  • Live word replacement - Replace brand names, competitor mentions, or outdated terms
  • Streaming analytics - Count tokens and track response length in real-time
  • Progress indicators - Detect specific patterns to show loading states
  • Debugging - Log each chunk for troubleshooting streaming issues
  • Format correction - Fix common formatting issues as they appear
๐Ÿ“œ Example: Logging Streaming Chunksโ€‹

Hereโ€™s how you can inspect and modify streamed LLM responses:

async def stream(self, event: dict) -> dict:
    print(event)  # Print each incoming chunk for inspection
    return event

Example Streamed Events:

{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": "Hi"}}]}
{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": "!"}}]}
{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": " ๐Ÿ˜Š"}}]}

๐Ÿ“– What Happens?

  • Each line represents a small fragment of the model's streamed response.
  • The delta.content field contains the progressively generated text.
๐Ÿ”„ Example: Filtering Out Emojis from Streamed Dataโ€‹
async 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("๐Ÿ˜Š", "")  # Strip emojis
    return event

๐Ÿ“– Before: "Hi ๐Ÿ˜Š" ๐Ÿ“– After: "Hi"


4๏ธโƒฃ outlet Function (Output Post-Processing)โ€‹

The outlet function is like a proofreader: tidy up the AI's response (or make final changes) after itโ€™s processed by the LLM.

๐Ÿ“ค Input:

  • body: This contains all current messages in the chat (user history + LLM replies).

๐Ÿš€ Your Task: Modify this body. You can clean, append, or log changes, but be mindful of how each adjustment impacts the user experience.

๐Ÿ’ก Best Practices:

  • Prefer logging over direct edits in the outlet (e.g., for debugging or analytics).
  • If heavy modifications are needed (like formatting outputs), consider using the pipe function instead.
๐Ÿ› ๏ธ Use Cases for outlet:โ€‹
  • Response logging - Track all model outputs for analytics or compliance
  • Token usage tracking - Count output tokens after completion for billing
  • Langfuse/observability integration - Send traces to monitoring platforms
  • Citation formatting - Reformat reference links in the final output
  • Disclaimer injection - Append legal notices or AI disclosure statements
  • Response caching - Store responses for future retrieval
  • Quality scoring - Run automated quality checks on model outputs
Outlet and API Requests

outlet() does not run reliably for direct /api/chat/completions calls. On tagged releases it is never invoked by that endpoint. On dev it can run inline, but only when the caller supplies chat_id + id, owns the chat, and uses a non-streaming request โ€” and even then the filtered content is not returned in the HTTP response. For direct API integrations that need outlet(), follow /api/chat/completions with POST /api/chat/completed. See Filter Behavior with API Requests for the full picture.

๐Ÿ’ก Example Use Case: Strip out sensitive API responses you don't want the user to see:

async def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
    for message in body["messages"]:
        message["content"] = message["content"].replace("<API_KEY>", "[REDACTED]")
    return body

๐ŸŒŸ Filters in Action: Building Practical Examplesโ€‹

Letโ€™s build some real-world examples to see how youโ€™d use Filters!

๐Ÿ“š Example #1: Add Context to Every User Inputโ€‹

Want the LLM to always know it's assisting a customer in troubleshooting software bugs? You can add instructions like "You're a software troubleshooting assistant" to every user query.

class Filter:
    async def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
        context_message = {
            "role": "system",
            "content": "You're a software troubleshooting assistant."
        }
        body.setdefault("messages", []).insert(0, context_message)
        return body

๐Ÿ“š Example #2: Highlight Outputs for Easy Readingโ€‹

Returning output in Markdown or another formatted style? Use the outlet function!

class Filter:
    async def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
        # Add "highlight" markdown for every response
        for message in body["messages"]:
            if message["role"] == "assistant":  # Target model response
                message["content"] = f"**{message['content']}**"  # Highlight with Markdown
        return body

๐Ÿšง Potential Confusion: Clear FAQ ๐Ÿ›‘โ€‹

Q: How Are Filters Different From Pipe Functions?โ€‹

Filters modify data going to and coming from models but do not significantly interact with logic outside of these phases. Pipes, on the other hand:

  • Can integrate external APIs or significantly transform how the backend handles operations.
  • Expose custom logic as entirely new "models."

Q: Can I Do Heavy Post-Processing Inside outlet?โ€‹

You can, but itโ€™s not the best practice.:

  • Filters are designed to make lightweight changes or apply logging.
  • If heavy modifications are required, consider a Pipe Function instead.

๐ŸŽ‰ Recap: Why Build Filter Functions?โ€‹

By now, youโ€™ve learned:

  1. Inlet manipulates user inputs (pre-processing).
  2. Stream intercepts and modifies streamed model outputs (real-time).
  3. Outlet tweaks AI outputs (post-processing).
  4. Filters are best for lightweight, real-time alterations to the data flow.
  5. With Valves, you empower users to configure Filters dynamically for tailored behavior.

๐Ÿš€ Your Turn: Start experimenting! What small tweak or context addition could elevate your Open WebUI experience? Filters are fun to build, flexible to use, and can take your models to the next level!

Happy coding! โœจ

This content is for informational purposes only and does not constitute a warranty, guarantee, or contractual commitment. Open WebUI is provided "as is." See your license for applicable terms.