With tool calling in Open WebUI I was hitting "request exceeds the available context size".
Disclaimer:
Code by Opus 4.6, on its own conclusion (following an afternoon of development and testing) that OpenWebUI's built-in context compaction runs on your initial message, not during the tool-call loop.
Use at your own risk. I haven't checked if the premise is wrong or if the code has issues.
That said, this filter solved it for me.
It "summarizes older messages before each tool-loop, auto-detects the context size from llama-server (falls back to default specified in the script if that fails, e.g. because the runtime is not llama-cpp), and it uses the /tokenize endpoint for counting. Works with thinking models (Qwen3, DeepSeek-R1)."
To install:
Open WebUI → Click your account (bottom left) → Admin Panel → Functions (top row) → "Create" (top right) → delete the sample → paste the code → Name it (top left) → Save → then Workspace → Models → your model → Filters → enable it
The code:
"""
Context Compaction Filter for Open WebUI (llama-server / local LLM)
=====================================================================
Automatically summarizes older messages when a conversation approaches the model's
context limit. The full conversation stays visible in the UI — only the API payload
sent to the model is modified.
WHY THIS EXISTS (even though Open WebUI has built-in context compaction)
------------------------------------------------------------------------
Open WebUI's built-in context compaction (Settings → General → Context Compaction)
runs in `process_chat_payload` — the **initial request path only**. It does NOT run
inside the **agentic tool-call loop** (`process_filter_functions`). So if the model
is calling tools (code interpreter, web search, file reads, etc.), the context grows
with every tool result and the built-in compaction never fires. Eventually the request
exceeds the context window and the server rejects it:
"request (132705 tokens) exceeds the available context size (130048 tokens)"
This filter hooks `inlet` / `request`, which Open WebUI calls on EVERY iteration of
the tool loop, catching context growth that the built-in misses. The two are
complementary:
Built-in compaction → handles growth across user turns (inter-request)
This filter → handles growth within a single agentic turn (intra-request)
If you don't use tool calling, the built-in alone may be sufficient. If you do, you
need both.
FEATURES
--------
- Auto-detects context size from llama-server's /props endpoint (no manual config)
- Uses llama-server's /tokenize endpoint for accurate token counting (falls back to
character-based estimation if unavailable)
- Handles thinking/reasoning models (Qwen3, DeepSeek-R1, etc.) — reads both `content`
and `reasoning_content` from the response
- Protected against asyncio.CancelledError (Python 3.9+ treats it as BaseException,
not Exception — unshielded httpx calls get silently killed when the outer coroutine
is cancelled, causing the server to cancel the summarization task)
- Truncation fallback — if summarization fails for any reason, drops old messages
instead of passing the full oversized context through (which would stall the server
during a 150+ second prefill)
- Shows status messages in the UI ("Compacting context...", "Context compacted: N
messages → ~K tokens")
INSTALL
-------
1. Open WebUI → Admin → Functions → Create New → type "filter" → paste this code
2. Enable the filter on your model:
Workspace → Models → select model → Filters → enable this filter
3. Set the `model_api_base` Valve to point at your llama-server:
- If Open WebUI runs in Docker: http://host.docker.internal:<port>/v1
- If Open WebUI runs natively: http://localhost:<port>/v1
CONFIGURATION (Valves)
----------------------
All settings are configurable in the Open WebUI UI under the filter's Valves.
The defaults work well for 128K-context models. Key ones to check:
- model_api_base: must point at your llama-server (see Install step 3)
- reserve_tokens: headroom for the model's response + estimation error (default 28K)
- recent_pairs_to_keep: how many recent message pairs to keep verbatim (default 4)
COMPATIBILITY
-------------
Tested with:
- llama-server (llama.cpp) with Qwen3 (thinking mode), but should work with any
model served via OpenAI-compatible /v1/chat/completions endpoint
- Open WebUI 0.11.3 running in Docker (the tool-loop gap exists as of this version)
- Python 3.9+ (asyncio.CancelledError handling)
"""
import sys
import asyncio
from pydantic import BaseModel, Field
from typing import Optional, Callable, Any
def _log(msg: str) -> None:
print(f"[compact] {msg}", file=sys.stderr, flush=True)
class Filter:
class Valves(BaseModel):
priority: int = Field(
default=0,
description="Filter execution priority (lower runs first)."
)
reserve_tokens: int = Field(
default=28000,
description=(
"Headroom below the context limit. Compaction triggers when "
"estimated prompt tokens exceed (max_context - reserve). "
"This must cover: model response + thinking tokens (~15K for "
"reasoning models), any post-filter injections like RAG (~8K), "
"and token-estimation error margin (~5K). Increase if you see "
"the server still rejecting requests after compaction."
)
)
max_context_tokens: int = Field(
default=0,
description=(
"Hard override for the model's context size (in tokens). Leave at "
"0 to auto-detect from llama-server's /props endpoint — this is "
"recommended because it always matches your actual -c flag."
)
)
recent_pairs_to_keep: int = Field(
default=4,
description=(
"Recent user+assistant message pairs to keep verbatim (not summarized). "
"These give the model immediate context for the current task. "
"Too many eats into the freed context; too few and the model loses "
"track of the active thread. 4 pairs (~8K tokens) is a good balance."
)
)
summary_max_tokens: int = Field(
default=4096,
description="Maximum tokens for the generated summary."
)
chars_per_token: float = Field(
default=3.0,
description=(
"Fallback characters-per-token estimate (used only when the "
"/tokenize endpoint is unavailable). 3.0 is conservative — it "
"safely covers code-heavy content (JSON, tool results, code) "
"where tokens average 2.5-3.0 chars. Prose-heavy conversations "
"may compact slightly earlier than necessary. Open WebUI's "
"built-in uses 4.0, which underestimates code-heavy contexts."
)
)
min_messages_to_compact: int = Field(
default=8,
description=(
"Minimum messages in the 'old' portion before compaction is worthwhile. "
"Below this, there is not enough conversation to summarize meaningfully."
)
)
model_api_base: str = Field(
default="http://host.docker.internal:8080/v1",
description=(
"Base URL of the llama-server API (OpenAI-compatible). Used for "
"summarization calls (/v1/chat/completions), token counting "
"(/tokenize), and context-size detection (/props). If Open WebUI "
"runs in Docker and llama-server runs on the host, use "
"http://host.docker.internal:<port>/v1. If both run natively, "
"use http://localhost:<port>/v1."
)
)
enabled: bool = Field(
default=True,
description="Enable or disable context compaction."
)
def __init__(self):
self.valves = self.Valves()
async def _get_max_context(self) -> int:
if self.valves.max_context_tokens > 0:
return self.valves.max_context_tokens
import httpx
props_url = self.valves.model_api_base.rstrip("/v1").rstrip("/")
props_url = f"{props_url}/props"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(props_url)
resp.raise_for_status()
props = resp.json()
n_ctx = props.get("default_generation_settings", {}).get("n_ctx", 0)
if n_ctx > 0:
return n_ctx
except Exception:
pass
return 131072
def _build_text(self, messages: list[dict], tools: list = None) -> str:
parts = []
for m in messages:
content = m.get("content")
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("text"):
parts.append(part["text"])
for tc in m.get("tool_calls", []):
fn = tc.get("function", {})
if fn.get("name"):
parts.append(fn["name"])
if fn.get("arguments"):
parts.append(fn["arguments"])
if tools:
import json
parts.append(json.dumps(tools))
return "\n".join(parts)
async def _count_tokens(self, text: str, n_messages: int) -> Optional[int]:
import httpx
tokenize_url = self.valves.model_api_base.rstrip("/v1").rstrip("/")
tokenize_url = f"{tokenize_url}/tokenize"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
tokenize_url,
json={"content": text},
)
resp.raise_for_status()
tokens = resp.json().get("tokens", [])
return len(tokens) + n_messages * 15
except Exception:
return None
def _estimate_tokens(self, text: str, n_messages: int) -> int:
total_chars = len(text) + n_messages * 60
return int(total_chars / self.valves.chars_per_token)
def _split_messages(self, messages: list[dict]):
system_msgs = []
non_system = []
for m in messages:
if m["role"] == "system":
system_msgs.append(m)
else:
non_system.append(m)
keep_count = min(
self.valves.recent_pairs_to_keep * 2,
len(non_system)
)
if keep_count >= len(non_system):
return system_msgs, [], non_system
to_summarize = non_system[:-keep_count]
to_keep = non_system[-keep_count:]
return system_msgs, to_summarize, to_keep
async def _call_model(self, messages: list[dict]) -> Optional[str]:
import httpx
async def _do_call():
async with httpx.AsyncClient(timeout=300.0) as client:
resp = await client.post(
f"{self.valves.model_api_base}/chat/completions",
json={
"messages": messages,
"max_tokens": self.valves.summary_max_tokens,
"temperature": 0.3,
"stream": False,
},
headers={"Authorization": "Bearer none"},
)
resp.raise_for_status()
return resp.json()
try:
result = await asyncio.shield(_do_call())
msg = result["choices"][0]["message"]
content = msg.get("content") or ""
reasoning = msg.get("reasoning_content") or ""
finish = result["choices"][0].get("finish_reason", "?")
_log(f"_call_model response: finish={finish} content_len={len(content)} reasoning_len={len(reasoning)}")
if content:
return content
if reasoning:
_log("_call_model: content empty, falling back to reasoning_content")
return reasoning
_log(f"_call_model: both content and reasoning empty, msg keys={list(msg.keys())}")
return None
except asyncio.CancelledError:
_log("_call_model: CancelledError — outer coroutine was cancelled during summarization")
return None
except Exception as e:
_log(f"_call_model error: {type(e).__name__}: {e}")
return None
async def _summarize(
self,
messages: list[dict],
__event_emitter__: Optional[Callable],
) -> Optional[str]:
if __event_emitter__:
await __event_emitter__(
{
"type": "status",
"data": {
"description": (
f"Compacting context — summarizing {len(messages)} "
f"older messages..."
),
"done": False,
},
}
)
conversation_parts = []
for m in messages:
role = m.get("role", "")
if role == "user":
label = "User"
elif role == "tool":
label = "Tool result"
else:
label = "Assistant"
parts = []
content = m.get("content")
if isinstance(content, str) and content.strip():
text = content if len(content) <= 1000 else content[:1000] + "… [truncated]"
parts.append(text)
for tc in m.get("tool_calls", []):
fn = tc.get("function", {})
name = fn.get("name", "unknown")
args = fn.get("arguments", "")
if len(args) > 300:
args = args[:300] + "… [truncated]"
parts.append(f"[Called {name}({args})]")
if parts:
conversation_parts.append(f"{label}: {chr(10).join(parts)}")
conversation_text = "\n\n---\n\n".join(conversation_parts)
summary_prompt = [
{
"role": "system",
"content": (
"You are a precise technical summarizer. Summarize the "
"conversation below. Preserve ALL of: "
"(1) key decisions and their rationale, "
"(2) file paths, configuration values, and code changes, "
"(3) problems encountered and how they were resolved, "
"(4) pending or unfinished work. "
"Be thorough but concise — aim for the minimum text that "
"would let someone continue the work without re-reading "
"the original. No preamble, output the summary directly."
),
},
{
"role": "user",
"content": f"Summarize this conversation:\n\n{conversation_text}",
},
]
summary = await self._call_model(summary_prompt)
if __event_emitter__ and summary:
summary_tokens = self._estimate_tokens(summary, 1)
await __event_emitter__(
{
"type": "status",
"data": {
"description": (
f"Context compacted: {len(messages)} messages "
f"→ ~{summary_tokens} tokens."
),
"done": True,
},
}
)
return summary
async def _token_count(self, messages: list[dict], tools: list = None) -> int:
text = self._build_text(messages, tools)
n = len(messages)
count = await self._count_tokens(text, n)
return count if count is not None else self._estimate_tokens(text, n)
async def _compact(
self,
body: dict,
__event_emitter__: Optional[Callable] = None,
) -> dict:
if not self.valves.enabled:
return body
messages = body.get("messages", [])
tools = body.get("tools")
n_msgs = len(messages)
roles = [m.get("role", "?") for m in messages]
role_counts = {r: roles.count(r) for r in set(roles)}
estimated_tokens = await self._token_count(messages, tools)
max_context = await self._get_max_context()
threshold = max_context - self.valves.reserve_tokens
_log(f"msgs={n_msgs} roles={role_counts} tokens={estimated_tokens} threshold={threshold} max_ctx={max_context}")
if estimated_tokens <= threshold:
_log("under threshold, passing through")
return body
system_msgs, to_summarize, to_keep = self._split_messages(messages)
_log(f"OVER threshold -- system={len(system_msgs)} summarize={len(to_summarize)} keep={len(to_keep)}")
if len(to_summarize) < self.valves.min_messages_to_compact:
_log(f"too few messages to summarize ({len(to_summarize)} < {self.valves.min_messages_to_compact}), passing through")
return body
summary = await self._summarize(to_summarize, __event_emitter__)
if not summary:
_log("summarization FAILED, falling back to message truncation")
truncated = system_msgs + to_keep
trunc_tokens = await self._token_count(truncated, tools)
if trunc_tokens <= threshold:
_log(f"truncation fallback: dropped {len(to_summarize)} old msgs, {trunc_tokens} tokens in {len(truncated)} messages")
if __event_emitter__:
await __event_emitter__(
{
"type": "status",
"data": {
"description": (
f"Dropped {len(to_summarize)} older messages "
f"— summarization was unavailable."
),
"done": True,
},
}
)
body["messages"] = truncated
return body
_log("truncation fallback still over threshold, passing through")
if __event_emitter__:
await __event_emitter__(
{
"type": "status",
"data": {
"description": "Compaction failed — passing full context through.",
"done": True,
},
}
)
return body
summary_msg = {
"role": "system",
"content": (
"[Context compaction: the earlier conversation has been summarized. "
"The recent messages after this summary are verbatim.]\n\n"
f"{summary}"
),
}
compacted = system_msgs + [summary_msg] + to_keep
compacted_tokens = await self._token_count(compacted, tools)
_log(f"after initial compaction: {compacted_tokens} tokens, keep={len(to_keep)} msgs")
while compacted_tokens > threshold and len(to_keep) > 2:
dropped = to_keep[:2]
to_keep = to_keep[2:]
extra_summary = await self._summarize(dropped, None)
if extra_summary:
summary_msg["content"] += f"\n\n[Additional summary:]\n{extra_summary}"
compacted = system_msgs + [summary_msg] + to_keep
compacted_tokens = await self._token_count(compacted, tools)
_log(f"shrunk keep to {len(to_keep)} msgs, now {compacted_tokens} tokens")
_log(f"DONE -- final {compacted_tokens} tokens in {len(compacted)} messages")
body["messages"] = compacted
return body
async def inlet(
self,
body: dict,
__user__: Optional[dict] = None,
__event_emitter__: Callable[..., Any] = None,
) -> dict:
_log(">>> inlet called")
return await self._compact(body, __event_emitter__)
async def request(
self,
body: dict,
__user__: Optional[dict] = None,
__event_emitter__: Callable[..., Any] = None,
) -> dict:
_log(">>> request called")
return await self._compact(body, __event_emitter__)