Filter Function: Modify Inputs and Outputs
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:
- 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.
- 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.
- 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 bodyToggleable 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 bodyWhat 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 = Trueand it is either a global filter or attached to the selected model. Withoutself.toggle, the filter still runs (if active and in scope) but has no UI surface, so users can't turn it off. - Gating: at request time the backend checks the user's current
filter_idsselection. If the filter is in that list,inlet()/stream()/outlet()run. If not, the filter is not invoked at all. self.toggleis never mutated by the UI. Insideinlet()it is always whatever you set in__init__, which will beTruefor every call that actually runs, because if the user had disabled the filter,inlet()wouldn't be running. Don't build logic that readsself.toggleat runtime; it's not a live on/off signal.
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
UserValvesclass (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).
- opens the user-valves modal if the filter defines a
- 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(Settings > Admin > AI > Models → Edit the model → Default Filters). Admins decide which toggleable filters start on vs off per model. - Resets when the user switches to a different model.

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.
Owning Retrieval With file_handler
By default, when a user attaches a knowledge collection or uploads a file to a chat, Open WebUI runs the built-in RAG pipeline after every inlet filter has returned. The chat-completion handler queries the vector DB for chunks relevant to the user's last message, wraps them in <source> tags, appends them to the last user message (or to a system message, depending on RAG_SYSTEM_CONTEXT), and only then calls the LLM.
This is important to understand for filter authors: at inlet() time, body["metadata"]["files"] and body["files"] contain only the file/collection references (IDs, names, types). The chunk text doesn't exist yet: retrieval hasn't happened. So if you want to inspect or transform the chunks themselves (PII / PHI redaction, reranking, custom hybrid scoring, translation, chunk-level access control, anonymization), the standard inlet contract is not enough. The data you want isn't there yet.
file_handler = True is the opt-in escape hatch for exactly this case. Declared as a module-level attribute at the top of your filter file, it tells Open WebUI "I am handling retrieval and chunk injection myself, skip the built-in RAG step." When set, the backend strips body["metadata"]["files"] and body["files"] after your inlet() returns, so the chat-completion handler finds no files to retrieve over and goes straight to the LLM with whatever you injected.
from pydantic import BaseModel
from typing import Optional
# Module-level attribute, sits OUTSIDE the Filter class, alongside imports.
file_handler = True
class Filter:
class Valves(BaseModel):
pass
def __init__(self):
self.valves = self.Valves()
async def inlet(
self,
body: dict,
__request__=None,
__user__: Optional[dict] = None,
__model__: Optional[dict] = None,
) -> dict:
# body["metadata"]["files"] still contains the file/collection REFERENCES here.
# After this method returns, Open WebUI strips them and does NOT run its own RAG.
# Therefore: it is YOUR job to retrieve, transform, and inject chunks below.
return bodyself.file_handlerOpen WebUI reads file_handler from the module object (the file your filter lives in), not from the Filter instance. Setting self.file_handler = True inside __init__ is silently ignored. Put the assignment at the top of the file, alongside your imports, exactly as shown above.
When to use it
- Per-model redaction. Apply PII / PHI scrubbing only when the request targets a remote model, while letting a self-hosted model see raw chunks. Branch on
__model__["owned_by"](or another signal) inside the inlet and transform chunks accordingly. - Custom retrieval logic. Hybrid BM25 + dense scoring, query rewriting, multi-collection routing, reranking with a different model than the one Open WebUI uses, result caching keyed on the rewritten query.
- Pre-injection transformation. Translation, summarization, deduplication, or any transform that needs the actual chunk text rather than just the references.
- Chunk-level access control. Filter out chunks the current user shouldn't see based on metadata attached to the source documents.
The recipe
-
Set
file_handler = Trueat the top of your filter module. -
In
inlet(), read the file references frombody["metadata"]["files"](andbody["files"]for ad-hoc attachments). -
Retrieve chunks yourself. Two options:
- HTTP: call
POST /api/v1/retrieval/query/doc(single collection) orPOST /api/v1/retrieval/query/collection(multiple), passing the user's last message as the query string and the inbound request's bearer token so permissions stay scoped to the user. - In-process:
from open_webui.retrieval.utils import get_sources_from_itemsand call it directly with the same arguments the core code uses. This avoids the network hop and returns a cleaner shape (list of dicts each containing adocumentarray of chunks and a parallelmetadataarray).
- HTTP: call
-
Transform the chunks however you need. Branch on
__model__/__user__if the transform is conditional (e.g. "redact only when the model is remote"). -
Inject the transformed chunks back into
body["messages"]. To preserve clickable citations in the UI, mirror the format Open WebUI uses internally:<source id="1" name="filename.pdf" resource-id="<collection_id>" resource-type="collection"> ...chunk text... </source>Plain Markdown also works if you don't care about citations being clickable in the UI; only the structured
<source>form wires up the citation popovers. -
Return
body. The built-in RAG step is skipped (becausefile_handlercaused the file references to be stripped), and the LLM call goes out with your sanitized chunks already in the prompt.
Caveat: it's static, all-or-nothing per filter
file_handler is read once per filter, at the module level. It is not a per-request signal and cannot be flipped based on the model, user, or chat from inside inlet(). When set, the built-in RAG is always skipped for any request where this filter is invoked, regardless of whether your inlet() actually called any retrieval logic on that particular request.
In practice this means: if you use file_handler = True, your filter must handle retrieval for every scenario where files would normally be retrieved by the built-in path, including the cases where you'd have been happy with the default behavior. The retrieval call itself is identical in both cases; only any conditional transformation (e.g. "only redact for remote models") branches on context.
If you genuinely need per-request switching between built-in and custom retrieval (e.g. "use built-in RAG for some users, custom for others on the same model"), the cleanest approach is to gate the custom-RAG filter on self.toggle = True so it only runs when the user has it selected. When the filter isn't selected, it doesn't run, its file_handler doesn't apply, and the built-in RAG handles the request normally. Don't try to dynamically mutate file_handler from inside inlet(); the flag is read off the module object before your method is called.
Why this matters compared to mutating body["files"] in inlet
A naive alternative is to clear body["metadata"]["files"] = [] and body["files"] = [] inside inlet() to suppress the built-in RAG dynamically. This works in practice but is brittle: future Open WebUI versions can add new file/collection plumbing under additional keys, and the official "I'm handling this myself" contract is file_handler. Prefer the documented opt-in.
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:
| State | is_active | is_global | Effect |
|---|---|---|---|
| Globally Enabled | ✅ True | ✅ True | Applied to ALL models automatically, cannot be disabled per-model |
| Globally Disabled | ❌ False | True | Not applied anywhere; even though the filter is globally enabled, the filter itself is disabled |
| Model-Specific | ✅ True | ❌ False | Only applied to models where the admin explicitly enables it |
| Inactive | ❌ False | False | Not applied anywhere, even if filter is enabled for a model by the admin; the filter itself is turned off |
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:
- Navigate to the Admin Panel
- Click on Functions in the sidebar
- Find your filter in the list
- Click the three-dot menu (⋮) next to the filter
- Click the 🌐 Globe icon to toggle
is_global - Ensure the filter is also Active (green toggle switch)
API Endpoint:
POST /api/v1/functions/id/{filter_id}/toggle/globalVisual 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.filterIdsin 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)
- In
- 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-2starts enabled by default - If
filter-uuid-1andfilter-uuid-3havetoggle=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 bodyToggleable 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.
defaultFilterIdson the model controls the initial selection (which toggleable filters start on when a new chat begins with that model).self.toggleitself 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 bodyWhere Toggleable Filters Appear:
- Model Settings → Default Filters
- Admin picks which toggleable filters start in the selection on new chats with that model.
- 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.iconrenders as the chip's image.
- 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
selectedFilterIdsin 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
selectedFilterIdswith the request - Backend:
get_sorted_filter_ids(request, model, filter_ids) - Fetch global filters (
is_global=True,is_active=True) + model-specific filters frommodel.meta.filterIds - Filter by
is_activestatus - For toggleable filters: keep only the ones whose ID is in the request's
filter_ids. Others are dropped entirely (never invoked,self.togglenever 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
| Function | WebUI Request | Direct 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 by default for API callers, gated by ENABLE_API_OUTLET_FILTERS (see below) |
__event_emitter__ | ✅ Shows UI feedback | ⚠️ Inert for pure API callers | ⚠️ Inert for pure API callers |
This behavior changed on dev (shipping in an upcoming release). The two cases differ:
On dev / upcoming release: outlet() runs for direct /api/chat/completions callers by default, on both the non-streaming and streaming paths, controlled by ENABLE_API_OUTLET_FILTERS (default True). On the streaming path the server accumulates the full response and runs outlet() once the stream completes. Set ENABLE_API_OUTLET_FILTERS=False to skip outlet filters on direct API traffic.
On tagged releases / main: outlet() is not invoked by /api/chat/completions; it runs only when the caller performs the second POST to /api/chat/completed. API integrations that need outlet() on these releases must make both calls.
Response visibility (both cases): outlet()'s side effects run (DB update, __event_emitter__, external calls like logging/tracing), but it does not rewrite the HTTP response body, and a streaming client has already received the stream before outlet() fires. To observe outlet()'s output, read the chat record back, subscribe to the chat:outlet WebSocket event, or use /api/chat/completed. __event_emitter__ remains inert for pure API callers (no UI to receive it).
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. That covers WebUI requests, the /api/chat/completed handler, and, on dev/upcoming releases with ENABLE_API_OUTLET_FILTERS enabled, direct /api/chat/completions API calls.
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 bodychat_id / message_id in inlet() Is Not a Reliable WorkaroundOlder versions of this page suggested synthesizing a temporary-chat chat_id (temporary:<uuid>, or local:<uuid> in older releases) 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": "qwen3.6:27b",
"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"
}'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
There is no field that names the caller, so use the chat context instead. A request from the WebUI always carries a chat_id and a session_id; a plain API call gets an empty chat_id and a null session_id. Both keys always exist, so test their values, not their presence:
async def inlet(self, body: dict, __metadata__: dict = None) -> dict:
metadata = __metadata__ or {}
if metadata.get("chat_id") and metadata.get("session_id"):
print("Request from a chat session")
else:
print("No chat context, likely a direct API call")
return bodyExample: 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 bodyExample: 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")
chat_id = (__metadata__ or {}).get("chat_id")
self.logger.info(
f"Request: user={user_email}, model={model}, "
f"chat_id={chat_id or 'none'}"
)
return bodyFilters 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 bodyPriority Ordering Rules
| Priority Value | Execution Order |
|---|---|
0 (default) | Runs first |
1 | Runs after priority 0 |
2 | Runs after priority 1 |
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)
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 bodyThe 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.
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 Settings → Admin → AI → Connections → OpenAI API headers configuration.
Injecting OpenAI-Style tools From a Filter
A filter inlet can also append OpenAI-style function-calling tools to body["tools"]. These are merged with the tools Open WebUI resolves server-side from tool_ids, MCP servers, and the model's built-in tools; they don't replace them.
class Filter:
async def inlet(self, body: dict) -> dict:
body.setdefault("tools", []).append({
"type": "function",
"function": {
"name": "lookup_user_tier",
"description": "Return the caller's billing tier.",
"parameters": {
"type": "object",
"properties": {"user_id": {"type": "string"}},
"required": ["user_id"],
},
},
})
return bodyBehavior:
- Native function calling (the default). Filter-injected tools are appended to the server-resolved tool list before the chat completion is sent. Both sets of tools are visible to the model on the same request.
- Tools provided by the original API caller. If the request that entered the filter pipeline already had
body["tools"](e.g. an external client called/api/chat/completionswith its owntoolsarray), those caller-provided tools take precedence and Open WebUI skips server-side tool resolution entirely. Filter inlets should additively append in that case too; the caller's tools and yours both go to the LLM. - Non-native function calling. Server-side tool resolution still runs through Open WebUI's prompt-driven tool handler; filter-injected tools are forwarded to the LLM but the runtime executor for them is up to whatever you wired into the upstream call.
Use this for tools that don't have a corresponding registered Tool in the workspace, for example transient per-request capabilities you want the model to know about on a specific user's chats only.
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.6-sol"
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 bodyIf 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:
| Parameter | What 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, user_id, files, features, params, 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
| Indicator | Meaning |
|---|---|
| 🟢 Green toggle | Filter is active (is_active=True) |
| ⚪ Grey toggle | Filter is inactive (is_active=False) |
| 🌐 Highlighted globe | Filter is global (is_global=True) |
| 🌐 Unhighlighted globe | Filter is not global (is_global=False) |
In Model Settings (FiltersSelector)
| State | Checkbox | Description |
|---|---|---|
| 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)
| Element | Description |
|---|---|
| Filter name | Shows the filter's display name |
| Custom icon | SVG icon from self.icon (if provided) |
| Toggle switch | Enable/disable the filter for this chat |
| Status badge | Shows 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)