Streaming keyword guard for LLM output.
Project description
LLM Stream Guard
Provider-agnostic streaming keyword guard for LLM output.
llm-stream-guard does not call OpenAI, Anthropic, Agno, LangChain, or any other model framework. It only accepts an AsyncIterable[str], checks the streamed text before it reaches the client, and emits safe delta, blocked, and done events.
License: MIT
Why
LLM providers usually stream output in small chunks. A blocked phrase can be split across chunks:
chunk 1: "hello hydr"
chunk 2: "angea world"
If hydrangea is blocked, this package emits only:
hello
Then it emits BlockedEvent(word="hydrangea", ...). The blocked word is not flushed to the client.
Install
pip install llm-stream-guard
or:
uv add llm-stream-guard
Quick Start
Create a word list:
# block_words.txt
hydrangea
violet comet
forbidden nebula
Use it with any async text stream:
from llm_stream_guard import BlockedEvent, DeltaEvent, StreamGuard
guard = StreamGuard.from_file("block_words.txt")
async for event in guard.wrap(model_text_stream(), on_block=cancel_model):
if isinstance(event, DeltaEvent):
yield event.text
if isinstance(event, BlockedEvent):
yield {"type": "blocked", "word": event.word}
break
Core Idea
The package only needs this shape:
from collections.abc import AsyncIterable
async def model_text_stream() -> AsyncIterable[str]:
yield "hello "
yield "world"
Most LLM frameworks stream event objects, not plain strings. Adapt them by extracting text deltas:
async def text_stream_from_events(events):
async for event in events:
text = extract_text(event)
if text:
yield text
Then guard the text stream:
guard = StreamGuard.from_file("block_words.txt")
async for event in guard.wrap(text_stream_from_events(events), on_block=cancel_upstream):
...
Framework Adapters
This package intentionally does not depend on framework-specific event classes. Keep adapters in your application.
Generic Object Events
If your framework emits objects with a content field:
async def object_event_text_stream(events):
async for event in events:
text = getattr(event, "content", None)
if isinstance(text, str) and text:
yield text
Dict Events
If your framework emits dictionaries:
async def dict_event_text_stream(events):
async for event in events:
text = event.get("delta") or event.get("content")
if isinstance(text, str) and text:
yield text
Agno
Agno's agent.arun(..., stream=True) yields streaming events. Extract the content field:
async def agno_text_stream(agent, prompt):
async for event in agent.arun(prompt, stream=True):
text = getattr(event, "content", None)
if isinstance(text, str) and text:
yield text
Then:
async for event in guard.wrap(agno_text_stream(agent, prompt), on_block=cancel_agent_stream):
...
OpenAI-Compatible Chat Completions
For OpenAI-compatible SSE/chat-completion streams, extract the provider's text delta and yield strings:
async def openai_compatible_text_stream(response_stream):
async for chunk in response_stream:
text = chunk.choices[0].delta.content
if text:
yield text
The guard layer stays the same:
async for event in guard.wrap(openai_compatible_text_stream(response_stream)):
...
Cancelling Upstream
on_block is called when a blocked word is found.
async def cancel_model():
await response_stream.aclose()
async for event in guard.wrap(model_text_stream(), on_block=cancel_model):
...
If your framework exposes a task:
task = asyncio.create_task(run_model())
async def cancel_model():
task.cancel()
Events
DeltaEvent
Safe text that can be sent to the client.
event.type == "delta"
event.text
BlockedEvent
A blocked word was detected.
event.type == "blocked"
event.word
event.start
event.end
DoneEvent
The stream finished cleanly.
event.type == "done"
Word Lists
Load one file:
guard = StreamGuard.from_file("block_words.txt")
Load multiple files:
guard = StreamGuard.from_files([
"policy/base.txt",
"policy/custom.txt",
])
Load a directory of .txt files:
guard = StreamGuard.from_directory("/path/to/Vocabulary", min_word_length=2)
min_word_length is useful for large third-party lexicons that may contain one-character words.
Normalization
Use drop_separators=True to match text with inserted spaces, punctuation, or different casing:
guard = StreamGuard.from_file(
"block_words.txt",
drop_separators=True,
)
Example:
word list: violetcomet
streamed text: VIOLET-comet
This will be blocked.
Recommended Service Pattern
Create StreamGuard once at service startup:
guard = StreamGuard.from_file("block_words.txt", drop_separators=True)
Create a guarded stream per request:
async def guarded_llm_stream(model_text_stream):
async for event in guard.wrap(model_text_stream):
if isinstance(event, DeltaEvent):
yield {"type": "delta", "text": event.text}
elif isinstance(event, BlockedEvent):
yield {
"type": "blocked",
"word": event.word,
"start": event.start,
"end": event.end,
}
break
else:
yield {"type": "done"}
If you update the word list, create a new StreamGuard.
Local Development
uv run pytest python_tests
uv run python examples/basic_usage.py
uv run python scripts/test_wheel_package.py
Build:
uv build
The wheel install test creates a temporary environment, installs the built wheel, and runs usage tests against the installed package:
uv run python scripts/test_wheel_package.py
Notes
- Only model output is checked. User input should be checked separately if needed.
- The package does not include semantic moderation.
- The package does not include your production word list.
- The package does not manage HTTP, SSE, WebSocket, or model provider clients.
- Keep provider/framework adapters in your application code.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file llm_stream_guard-0.1.2.tar.gz.
File metadata
- Download URL: llm_stream_guard-0.1.2.tar.gz
- Upload date:
- Size: 7.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0cec04dd999104536ed84841d43bd7589319e65030177dc74da10d4716837d03
|
|
| MD5 |
efadab190c2a6a1f3f365fac4d7dfd61
|
|
| BLAKE2b-256 |
ff934be8f6cd0509e9b83e354ec75977732245a2450795c81de2f96e1997760f
|
File details
Details for the file llm_stream_guard-0.1.2-py3-none-any.whl.
File metadata
- Download URL: llm_stream_guard-0.1.2-py3-none-any.whl
- Upload date:
- Size: 7.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
253458d3b57ffc7f26d06c65883c431d4c93a93071130309db8b89efa0495738
|
|
| MD5 |
82fb27c90a5d84e31909fb046989c2ca
|
|
| BLAKE2b-256 |
e3364c6f8e5cdae6ecd82b75552274be58a7bc6c5057b587950433a19b16bbc1
|