llm-stream-guards 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_stream_guards-0.1.0/.gitignore +14 -0
- llm_stream_guards-0.1.0/CHANGELOG.md +30 -0
- llm_stream_guards-0.1.0/LICENSE +21 -0
- llm_stream_guards-0.1.0/PKG-INFO +204 -0
- llm_stream_guards-0.1.0/README.md +180 -0
- llm_stream_guards-0.1.0/pyproject.toml +78 -0
- llm_stream_guards-0.1.0/spec/README.md +37 -0
- llm_stream_guards-0.1.0/spec/cases/amounts.json +1785 -0
- llm_stream_guards-0.1.0/spec/cases/chain.json +248 -0
- llm_stream_guards-0.1.0/spec/cases/code.json +725 -0
- llm_stream_guards-0.1.0/spec/cases/contacts.json +607 -0
- llm_stream_guards-0.1.0/spec/cases/leak.json +253 -0
- llm_stream_guards-0.1.0/spec/cases/links.json +1567 -0
- llm_stream_guards-0.1.0/spec/cases/long.json +10035 -0
- llm_stream_guards-0.1.0/spec/fill.py +41 -0
- llm_stream_guards-0.1.0/spec/make_long.py +71 -0
- llm_stream_guards-0.1.0/src/llm_stream_guards/__init__.py +38 -0
- llm_stream_guards-0.1.0/src/llm_stream_guards/_amounts.py +344 -0
- llm_stream_guards-0.1.0/src/llm_stream_guards/_code.py +114 -0
- llm_stream_guards-0.1.0/src/llm_stream_guards/_leak.py +92 -0
- llm_stream_guards-0.1.0/src/llm_stream_guards/_links.py +358 -0
- llm_stream_guards-0.1.0/src/llm_stream_guards/_stream.py +76 -0
- llm_stream_guards-0.1.0/src/llm_stream_guards/py.typed +0 -0
- llm_stream_guards-0.1.0/tests/test_amounts.py +267 -0
- llm_stream_guards-0.1.0/tests/test_chain.py +83 -0
- llm_stream_guards-0.1.0/tests/test_code.py +171 -0
- llm_stream_guards-0.1.0/tests/test_contacts.py +108 -0
- llm_stream_guards-0.1.0/tests/test_leak.py +85 -0
- llm_stream_guards-0.1.0/tests/test_links.py +134 -0
- llm_stream_guards-0.1.0/tests/test_spec.py +97 -0
- llm_stream_guards-0.1.0/tests/test_window.py +174 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
|
|
4
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
First release, for Python (PyPI) and for JavaScript and TypeScript (npm), with the same guards, the same behaviour and the
|
|
11
|
+
same version number.
|
|
12
|
+
|
|
13
|
+
- Streaming guards share one shape: `feed(delta)` returns what may be shown now, `finish()` returns what was held back,
|
|
14
|
+
`text` is the reply as it should read, and `Blocked` (with `reason` and `found`) stops a reply that must be replaced.
|
|
15
|
+
`Chain` runs several in order. The npm package adds `guardTransform` to run a guard as a `TransformStream`.
|
|
16
|
+
- `LinkStream` and `strip_untrusted_links` / `stripUntrustedLinks`: a link to a host not in the allowed set becomes its
|
|
17
|
+
text, or its host in inline code. A link still being written is held until it can be checked.
|
|
18
|
+
- Ungrounded email addresses and telephone numbers: `LinkStream(emails=..., phones=...)` while streaming, and
|
|
19
|
+
`without_ungrounded_emails` / `without_ungrounded_phones` on finished text.
|
|
20
|
+
- `AmountStream` and `unsupported_amounts`: a money or person-time figure no source states blocks the reply before it is
|
|
21
|
+
shown. A figure that may still grow is held. `Locale` holds the currencies, magnitudes and cost words.
|
|
22
|
+
- `LeakGuard` and `LeakStream`: a reply that quotes a protected text 8 words in a row (configurable) is blocked. A
|
|
23
|
+
protected text with fewer words than its run length is refused.
|
|
24
|
+
- `CodeStream` and `contains_code_deliverable`: a code fence blocks the reply at once, and a finished reply that hands
|
|
25
|
+
over code without one is blocked at `finish`.
|
|
26
|
+
- Each streaming guard works on the last 320 to 640 characters of the reply and judges the whole reply once, at `finish`.
|
|
27
|
+
- Python 3.11+ and Node 22+, no dependencies.
|
|
28
|
+
|
|
29
|
+
[Unreleased]: https://github.com/sansanth/llm-stream-guards/compare/v0.1.0...HEAD
|
|
30
|
+
[0.1.0]: https://github.com/sansanth/llm-stream-guards/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sandhu Santhakumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: llm-stream-guards
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic output guards for a streamed LLM reply: untrusted links, made-up emails, phone numbers and prices, quoted system prompts and code, stopped before they reach the screen.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sansanth/llm-stream-guards
|
|
6
|
+
Project-URL: Issues, https://github.com/sansanth/llm-stream-guards/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/sansanth/llm-stream-guards/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Sandhu Santhakumar <code@sandhu.sh>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: chatbot,guardrails,llm,output-filter,prompt-injection,security,streaming
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Text Processing :: Filters
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# llm-stream-guards
|
|
26
|
+
|
|
27
|
+
[](https://github.com/sansanth/llm-stream-guards/actions/workflows/python.yml)
|
|
28
|
+
[](https://pypi.org/project/llm-stream-guards/)
|
|
29
|
+
[](https://pypi.org/project/llm-stream-guards/)
|
|
30
|
+
[](https://github.com/sansanth/llm-stream-guards/blob/main/LICENSE)
|
|
31
|
+
|
|
32
|
+
Output guards for an LLM reply that streams to a user. Prompt injection can make a model print a phishing link, a made-up price or phone number, or quote its system prompt, and by the time you check the finished reply the user has read it. These guards work on the reply as it streams: each piece is either shown now, held back until it can be judged (a half-written link, a number that may still grow), or the reply is stopped before the piece that breaks a rule reaches the screen.
|
|
33
|
+
|
|
34
|
+
Plain string logic: no model call, no ML, no dependencies. Every guard can only make a reply more restrictive. The
|
|
35
|
+
same guards are on npm as [`llm-stream-guards`](https://www.npmjs.com/package/llm-stream-guards), and the two packages
|
|
36
|
+
pass the same test cases.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install llm-stream-guards
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What it guards
|
|
43
|
+
|
|
44
|
+
| Guard | Stops | While streaming |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `LinkStream`, `strip_untrusted_links` | A link to a host you did not supply. `[text](url)` becomes its text; a bare URL becomes its host in inline code (`` `evil.example` ``), so the sentence still reads. | Holds a link from its first character (`h`, `www`, an unclosed `[...](`) until it can be checked. |
|
|
47
|
+
| `LinkStream(emails=...)`, `without_ungrounded_emails` | An email address that none of your sources holds. | Holds the word being written until it ends. |
|
|
48
|
+
| `LinkStream(phones=...)`, `without_ungrounded_phones` | A telephone number that none of your sources holds. `020 7946 0001` and `+44 20 7946 0001` are the same number. | Holds a number from its `+`, `(` or first digit. |
|
|
49
|
+
| `AmountStream`, `unsupported_amounts` | A price or an effort estimate none of your sources states: `$120`, `INR 50,000`, `40 euros`, `300 engineering hours`, `$40 to $65`, and `60k` or `120 per hour` near a cost word. `$2M` and `$2,000,000` compare equal, and so do `$1,200` and `$1,200.00`. A range is judged as a range, whether it is priced by a symbol (`$40 to $65`), a code (`USD 40 to 65`) or a name (`40 to 65 euros`). | Blocks before the piece that completes the figure. Holds a figure that may still grow (`$5` before `00`), and an unsourced `60k` or `120 per hour` until the 120 characters after it show no cost word. |
|
|
50
|
+
| `LeakStream`, `LeakGuard` | A reply that quotes a protected text (your system prompt) 8 words in a row, ignoring case, punctuation and Markdown. | Blocks as soon as the run completes. |
|
|
51
|
+
| `CodeStream`, `contains_code_deliverable` | A reply that hands over code: a fence, an indented block, SQL, a shell line, markup, a definition or an import. Prose that uses the words ("Import the data first") is not code. | Blocks at the first fence, so you stop paying for the rest. |
|
|
52
|
+
|
|
53
|
+
Links, emails and phone numbers are rewritten and the reply goes on. A code fence, a quotation or an unsourced figure means the reply as a whole is wrong: the guard raises `Blocked`, and you replace what was shown.
|
|
54
|
+
|
|
55
|
+
## Use it
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from llm_stream_guards import (
|
|
59
|
+
AmountStream,
|
|
60
|
+
Blocked,
|
|
61
|
+
Chain,
|
|
62
|
+
CodeStream,
|
|
63
|
+
LeakGuard,
|
|
64
|
+
LeakStream,
|
|
65
|
+
LinkStream,
|
|
66
|
+
emails_in,
|
|
67
|
+
hosts_of,
|
|
68
|
+
numbers_in,
|
|
69
|
+
phones_in,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Built once: it holds nothing about a reply.
|
|
73
|
+
leaks = LeakGuard({"system prompt": SYSTEM_PROMPT})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def guarded(deltas, sources, user_messages):
|
|
77
|
+
texts = [source.text for source in sources]
|
|
78
|
+
guards = Chain(
|
|
79
|
+
CodeStream(),
|
|
80
|
+
LeakStream(leaks),
|
|
81
|
+
AmountStream(numbers_in(*texts)),
|
|
82
|
+
LinkStream(
|
|
83
|
+
hosts_of(source.url for source in sources),
|
|
84
|
+
emails=emails_in(*texts, *user_messages),
|
|
85
|
+
phones=phones_in(*texts, *user_messages),
|
|
86
|
+
replacement="our team",
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
for delta in deltas:
|
|
90
|
+
if shown := guards.feed(delta):
|
|
91
|
+
yield shown
|
|
92
|
+
if tail := guards.finish():
|
|
93
|
+
yield tail
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
stream = client.chat.completions.create(..., stream=True)
|
|
97
|
+
deltas = (chunk.choices[0].delta.content or "" for chunk in stream)
|
|
98
|
+
try:
|
|
99
|
+
for piece in guarded(deltas, sources, user_messages):
|
|
100
|
+
send(piece)
|
|
101
|
+
except Blocked as blocked:
|
|
102
|
+
stream.close() # stop paying for the rest
|
|
103
|
+
log.warning("reply blocked: %s", blocked) # e.g. "amount: $80,000"
|
|
104
|
+
replace_shown_reply("Our team will follow up with a quote.")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A grounded set is what the reply may repeat. For links, emails and phone numbers that is the sources you retrieved for this reply, the user's own messages and your own contact details. Never the model's earlier replies: that would ground what it made up. For amounts, leave the user's messages out, or "you charge 5k, right?" lets the reply confirm it.
|
|
108
|
+
|
|
109
|
+
Order the chain as above: the guards that block judge the model's own text, and `LinkStream` rewrites what they let through.
|
|
110
|
+
|
|
111
|
+
Not streaming? Call the functions on the finished text:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
reply = strip_untrusted_links(reply, hosts_of(urls))
|
|
115
|
+
reply = without_ungrounded_emails(reply, emails_in(*texts), replacement="our team")
|
|
116
|
+
if unsupported_amounts(reply, numbers_in(*texts)) or leaks.leaked(reply) or contains_code_deliverable(reply):
|
|
117
|
+
reply = fallback
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## API
|
|
121
|
+
|
|
122
|
+
### Streaming guards
|
|
123
|
+
|
|
124
|
+
Every streaming guard has the same three members, and a `Chain` of them is one too (`StreamGuard` is the protocol):
|
|
125
|
+
|
|
126
|
+
- `feed(delta) -> str`: takes the next piece of the reply and returns what may be shown now, possibly `""`.
|
|
127
|
+
- `finish() -> str`: returns what was held back, once the model has stopped.
|
|
128
|
+
- `text`: everything let through so far, as it should finally read. After `finish`, the reply to store.
|
|
129
|
+
|
|
130
|
+
`feed` and `finish` raise `Blocked` when the reply must not go on. A guard that has raised raises the same exception on every later call.
|
|
131
|
+
|
|
132
|
+
| Name | Signature |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `Chain` | `Chain(*guards)`: each guard is fed what the one before it let through. |
|
|
135
|
+
| `CodeStream` | `CodeStream(*, deliverable=True)`: blocks at the first fence (three backticks, or three tildes opening a line). With `deliverable`, `finish` also blocks a finished reply that `contains_code_deliverable` flags. |
|
|
136
|
+
| `LeakStream` | `LeakStream(guard: LeakGuard)`: blocks once the text so far quotes a protected text. Holds nothing back. |
|
|
137
|
+
| `AmountStream` | `AmountStream(grounded, *, locale=ENGLISH)`: blocks an unsourced figure before it is shown; holds what may still grow. `finish` judges the finished text. |
|
|
138
|
+
| `LinkStream` | `LinkStream(allowed_hosts, *, emails=None, phones=None, replacement="[removed]")`: removes untrusted links, and with `emails` or `phones` (a grounded set, empty for none) ungrounded addresses and numbers. |
|
|
139
|
+
| `Blocked` | The exception. `reason` is `"code"`, `"leak"` or `"amount"`; `found` is what was caught: the fence, the protected text's name, or the figures. |
|
|
140
|
+
|
|
141
|
+
### Finished text
|
|
142
|
+
|
|
143
|
+
| Name | Signature |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `strip_untrusted_links` | `(text, allowed_hosts) -> str` |
|
|
146
|
+
| `hosts_of` | `(urls) -> set[str]`: the hosts `strip_untrusted_links` compares against: lowercase, without `www.`. Matched exactly: `example.com` does not allow `docs.example.com`. |
|
|
147
|
+
| `without_ungrounded_emails` | `(text, grounded, *, replacement="[removed]") -> str`: a `mailto:` link to an ungrounded address becomes its text. |
|
|
148
|
+
| `emails_in` | `(*texts) -> set[str]`: the addresses in `texts`, lowercased. |
|
|
149
|
+
| `without_ungrounded_phones` | `(text, grounded, *, replacement="[removed]") -> str` |
|
|
150
|
+
| `phones_in` | `(*texts) -> set[str]`: the telephone numbers in `texts`, as digits. |
|
|
151
|
+
| `unsupported_amounts` | `(text, grounded, *, complete=True, locale=ENGLISH) -> list[str]`: the unsourced figures, as written. With `complete=False` a figure that ends the text is not judged yet. |
|
|
152
|
+
| `numbers_in` | `(*texts, locale=ENGLISH) -> set[str]`: every number and range in `texts`, in the form `unsupported_amounts` compares against. |
|
|
153
|
+
| `LeakGuard` | `LeakGuard(protected, *, words=8)`: `protected` maps a name to a text, or to `(text, n)` for its own run length. A text with fewer than `n` words is refused with a `ValueError`: it has no run to quote. `match(text)` returns the name of the first text quoted, or `None`; `leaked(text)` is `match(text) is not None`. |
|
|
154
|
+
| `contains_code_deliverable` | `(text) -> bool` |
|
|
155
|
+
|
|
156
|
+
### `Locale`
|
|
157
|
+
|
|
158
|
+
The amount guard's words and symbols: currency symbols, codes, prefixes (`Rs.`) and names, magnitudes (`k`, `million`, `lakh`, `crore`), cost words, and the regex fragments that make a number effort (`person-days`) or a rate (`per hour`). `ENGLISH` is the default. Change part of it with `dataclasses.replace`, or build your own:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from dataclasses import replace
|
|
162
|
+
from llm_stream_guards import ENGLISH, Locale
|
|
163
|
+
|
|
164
|
+
nordic = replace(ENGLISH, currency_codes=(*ENGLISH.currency_codes, "nok", "sek", "dkk"))
|
|
165
|
+
german = Locale(
|
|
166
|
+
currency_names=("euro",),
|
|
167
|
+
magnitudes={"tsd": 1_000, "mio": 1_000_000, "mrd": 1_000_000_000},
|
|
168
|
+
cost_words=("kosten", "preis", "budget", "angebot"),
|
|
169
|
+
effort=r"personentage\b",
|
|
170
|
+
rate=r"\s*(?:pro|je)\s*stunde\b",
|
|
171
|
+
range_words=("bis",),
|
|
172
|
+
)
|
|
173
|
+
AmountStream(grounded, locale=german)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
An empty list matches nothing. The fields are documented on the class.
|
|
177
|
+
|
|
178
|
+
## Limits
|
|
179
|
+
|
|
180
|
+
- **Quotation, not paraphrase.** The leak guard catches a protected text repeated word for word, not one retold. Up to 7 words of a quotation are shown before the eighth completes the run. A protected text shorter than the run length is refused, not ignored: give it its own, `(text, 3)`. Leave out of the protected text any phrase the model is told to say, or saying it counts as a leak.
|
|
181
|
+
- **Text without word boundaries is not covered.** Chinese, Japanese or Thai reduces to very few words, so the leak guard sees almost nothing to compare.
|
|
182
|
+
- **The default patterns read English.** A price written in another language passes `ENGLISH`; give it a `Locale`. A figure in words ("forty thousand dollars") is not caught in any locale. Numbers are read with `.` as the decimal point.
|
|
183
|
+
- **Telephone numbers are counted by their digits**: 9 or more, or 8 after a `+`, on one line. A shorter number, or one written in words, is not checked. A date such as `2026-10-07` is never a number, but a run of other numbers that adds up to that many digits (`2019 2020 2021`) is, and so is an identifier of that length (`Order 12345678901`). A dotted address such as `192.168.100.100` is not a number.
|
|
184
|
+
- **Only web links are checked.** A link is one to `http`, `https`, `www.` or `//host`, and one with a `javascript:`, `data:`, `vbscript:` or `file:` scheme goes too. An `ftp:` link, or one to an app scheme (`intent:`, `slack:`), passes.
|
|
185
|
+
- **Holding back costs latency.** An unsourced `60k` or `120 per hour` waits for the next 120 characters; an unclosed `[` waits up to 80 characters and an unclosed link up to 300. The rest of the reply waits behind it.
|
|
186
|
+
- **Time is spent on the end of the reply.** Each piece is checked together with the last 320 to 640 characters, not the whole reply, so the time a reply takes grows with its length, about 0.3 milliseconds a piece for the chain above (a few times that if your pieces are single characters). The whole reply is checked once more when it ends. A link whose text runs past 300 characters, or a figure with more than 300 characters of white space inside it, is therefore judged only then.
|
|
187
|
+
- **What was shown can differ from `text`.** In a rare case (a `[` let through as prose that then opens a link to an untrusted host) cleaning the longer text changes text already shown. The stream goes on, and after `finish` you show `text` in its place.
|
|
188
|
+
- **The code detector prefers false positives.** A reply with a SQL statement or a block opener with an indented body counts as code. Pass `deliverable=False` to `CodeStream` to block on fences only.
|
|
189
|
+
- **A backstop, not a judge.** These checks see shapes, not meaning: they are what still holds when the prompt, and any model-based check, did not.
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
See [CONTRIBUTING.md](https://github.com/sansanth/llm-stream-guards/blob/main/CONTRIBUTING.md). In short:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
cd python
|
|
197
|
+
uv sync
|
|
198
|
+
uv run pytest --cov
|
|
199
|
+
uv run ruff check . && uv run mypy
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# llm-stream-guards
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sansanth/llm-stream-guards/actions/workflows/python.yml)
|
|
4
|
+
[](https://pypi.org/project/llm-stream-guards/)
|
|
5
|
+
[](https://pypi.org/project/llm-stream-guards/)
|
|
6
|
+
[](https://github.com/sansanth/llm-stream-guards/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
Output guards for an LLM reply that streams to a user. Prompt injection can make a model print a phishing link, a made-up price or phone number, or quote its system prompt, and by the time you check the finished reply the user has read it. These guards work on the reply as it streams: each piece is either shown now, held back until it can be judged (a half-written link, a number that may still grow), or the reply is stopped before the piece that breaks a rule reaches the screen.
|
|
9
|
+
|
|
10
|
+
Plain string logic: no model call, no ML, no dependencies. Every guard can only make a reply more restrictive. The
|
|
11
|
+
same guards are on npm as [`llm-stream-guards`](https://www.npmjs.com/package/llm-stream-guards), and the two packages
|
|
12
|
+
pass the same test cases.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install llm-stream-guards
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## What it guards
|
|
19
|
+
|
|
20
|
+
| Guard | Stops | While streaming |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `LinkStream`, `strip_untrusted_links` | A link to a host you did not supply. `[text](url)` becomes its text; a bare URL becomes its host in inline code (`` `evil.example` ``), so the sentence still reads. | Holds a link from its first character (`h`, `www`, an unclosed `[...](`) until it can be checked. |
|
|
23
|
+
| `LinkStream(emails=...)`, `without_ungrounded_emails` | An email address that none of your sources holds. | Holds the word being written until it ends. |
|
|
24
|
+
| `LinkStream(phones=...)`, `without_ungrounded_phones` | A telephone number that none of your sources holds. `020 7946 0001` and `+44 20 7946 0001` are the same number. | Holds a number from its `+`, `(` or first digit. |
|
|
25
|
+
| `AmountStream`, `unsupported_amounts` | A price or an effort estimate none of your sources states: `$120`, `INR 50,000`, `40 euros`, `300 engineering hours`, `$40 to $65`, and `60k` or `120 per hour` near a cost word. `$2M` and `$2,000,000` compare equal, and so do `$1,200` and `$1,200.00`. A range is judged as a range, whether it is priced by a symbol (`$40 to $65`), a code (`USD 40 to 65`) or a name (`40 to 65 euros`). | Blocks before the piece that completes the figure. Holds a figure that may still grow (`$5` before `00`), and an unsourced `60k` or `120 per hour` until the 120 characters after it show no cost word. |
|
|
26
|
+
| `LeakStream`, `LeakGuard` | A reply that quotes a protected text (your system prompt) 8 words in a row, ignoring case, punctuation and Markdown. | Blocks as soon as the run completes. |
|
|
27
|
+
| `CodeStream`, `contains_code_deliverable` | A reply that hands over code: a fence, an indented block, SQL, a shell line, markup, a definition or an import. Prose that uses the words ("Import the data first") is not code. | Blocks at the first fence, so you stop paying for the rest. |
|
|
28
|
+
|
|
29
|
+
Links, emails and phone numbers are rewritten and the reply goes on. A code fence, a quotation or an unsourced figure means the reply as a whole is wrong: the guard raises `Blocked`, and you replace what was shown.
|
|
30
|
+
|
|
31
|
+
## Use it
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from llm_stream_guards import (
|
|
35
|
+
AmountStream,
|
|
36
|
+
Blocked,
|
|
37
|
+
Chain,
|
|
38
|
+
CodeStream,
|
|
39
|
+
LeakGuard,
|
|
40
|
+
LeakStream,
|
|
41
|
+
LinkStream,
|
|
42
|
+
emails_in,
|
|
43
|
+
hosts_of,
|
|
44
|
+
numbers_in,
|
|
45
|
+
phones_in,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Built once: it holds nothing about a reply.
|
|
49
|
+
leaks = LeakGuard({"system prompt": SYSTEM_PROMPT})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def guarded(deltas, sources, user_messages):
|
|
53
|
+
texts = [source.text for source in sources]
|
|
54
|
+
guards = Chain(
|
|
55
|
+
CodeStream(),
|
|
56
|
+
LeakStream(leaks),
|
|
57
|
+
AmountStream(numbers_in(*texts)),
|
|
58
|
+
LinkStream(
|
|
59
|
+
hosts_of(source.url for source in sources),
|
|
60
|
+
emails=emails_in(*texts, *user_messages),
|
|
61
|
+
phones=phones_in(*texts, *user_messages),
|
|
62
|
+
replacement="our team",
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
for delta in deltas:
|
|
66
|
+
if shown := guards.feed(delta):
|
|
67
|
+
yield shown
|
|
68
|
+
if tail := guards.finish():
|
|
69
|
+
yield tail
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
stream = client.chat.completions.create(..., stream=True)
|
|
73
|
+
deltas = (chunk.choices[0].delta.content or "" for chunk in stream)
|
|
74
|
+
try:
|
|
75
|
+
for piece in guarded(deltas, sources, user_messages):
|
|
76
|
+
send(piece)
|
|
77
|
+
except Blocked as blocked:
|
|
78
|
+
stream.close() # stop paying for the rest
|
|
79
|
+
log.warning("reply blocked: %s", blocked) # e.g. "amount: $80,000"
|
|
80
|
+
replace_shown_reply("Our team will follow up with a quote.")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A grounded set is what the reply may repeat. For links, emails and phone numbers that is the sources you retrieved for this reply, the user's own messages and your own contact details. Never the model's earlier replies: that would ground what it made up. For amounts, leave the user's messages out, or "you charge 5k, right?" lets the reply confirm it.
|
|
84
|
+
|
|
85
|
+
Order the chain as above: the guards that block judge the model's own text, and `LinkStream` rewrites what they let through.
|
|
86
|
+
|
|
87
|
+
Not streaming? Call the functions on the finished text:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
reply = strip_untrusted_links(reply, hosts_of(urls))
|
|
91
|
+
reply = without_ungrounded_emails(reply, emails_in(*texts), replacement="our team")
|
|
92
|
+
if unsupported_amounts(reply, numbers_in(*texts)) or leaks.leaked(reply) or contains_code_deliverable(reply):
|
|
93
|
+
reply = fallback
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## API
|
|
97
|
+
|
|
98
|
+
### Streaming guards
|
|
99
|
+
|
|
100
|
+
Every streaming guard has the same three members, and a `Chain` of them is one too (`StreamGuard` is the protocol):
|
|
101
|
+
|
|
102
|
+
- `feed(delta) -> str`: takes the next piece of the reply and returns what may be shown now, possibly `""`.
|
|
103
|
+
- `finish() -> str`: returns what was held back, once the model has stopped.
|
|
104
|
+
- `text`: everything let through so far, as it should finally read. After `finish`, the reply to store.
|
|
105
|
+
|
|
106
|
+
`feed` and `finish` raise `Blocked` when the reply must not go on. A guard that has raised raises the same exception on every later call.
|
|
107
|
+
|
|
108
|
+
| Name | Signature |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `Chain` | `Chain(*guards)`: each guard is fed what the one before it let through. |
|
|
111
|
+
| `CodeStream` | `CodeStream(*, deliverable=True)`: blocks at the first fence (three backticks, or three tildes opening a line). With `deliverable`, `finish` also blocks a finished reply that `contains_code_deliverable` flags. |
|
|
112
|
+
| `LeakStream` | `LeakStream(guard: LeakGuard)`: blocks once the text so far quotes a protected text. Holds nothing back. |
|
|
113
|
+
| `AmountStream` | `AmountStream(grounded, *, locale=ENGLISH)`: blocks an unsourced figure before it is shown; holds what may still grow. `finish` judges the finished text. |
|
|
114
|
+
| `LinkStream` | `LinkStream(allowed_hosts, *, emails=None, phones=None, replacement="[removed]")`: removes untrusted links, and with `emails` or `phones` (a grounded set, empty for none) ungrounded addresses and numbers. |
|
|
115
|
+
| `Blocked` | The exception. `reason` is `"code"`, `"leak"` or `"amount"`; `found` is what was caught: the fence, the protected text's name, or the figures. |
|
|
116
|
+
|
|
117
|
+
### Finished text
|
|
118
|
+
|
|
119
|
+
| Name | Signature |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `strip_untrusted_links` | `(text, allowed_hosts) -> str` |
|
|
122
|
+
| `hosts_of` | `(urls) -> set[str]`: the hosts `strip_untrusted_links` compares against: lowercase, without `www.`. Matched exactly: `example.com` does not allow `docs.example.com`. |
|
|
123
|
+
| `without_ungrounded_emails` | `(text, grounded, *, replacement="[removed]") -> str`: a `mailto:` link to an ungrounded address becomes its text. |
|
|
124
|
+
| `emails_in` | `(*texts) -> set[str]`: the addresses in `texts`, lowercased. |
|
|
125
|
+
| `without_ungrounded_phones` | `(text, grounded, *, replacement="[removed]") -> str` |
|
|
126
|
+
| `phones_in` | `(*texts) -> set[str]`: the telephone numbers in `texts`, as digits. |
|
|
127
|
+
| `unsupported_amounts` | `(text, grounded, *, complete=True, locale=ENGLISH) -> list[str]`: the unsourced figures, as written. With `complete=False` a figure that ends the text is not judged yet. |
|
|
128
|
+
| `numbers_in` | `(*texts, locale=ENGLISH) -> set[str]`: every number and range in `texts`, in the form `unsupported_amounts` compares against. |
|
|
129
|
+
| `LeakGuard` | `LeakGuard(protected, *, words=8)`: `protected` maps a name to a text, or to `(text, n)` for its own run length. A text with fewer than `n` words is refused with a `ValueError`: it has no run to quote. `match(text)` returns the name of the first text quoted, or `None`; `leaked(text)` is `match(text) is not None`. |
|
|
130
|
+
| `contains_code_deliverable` | `(text) -> bool` |
|
|
131
|
+
|
|
132
|
+
### `Locale`
|
|
133
|
+
|
|
134
|
+
The amount guard's words and symbols: currency symbols, codes, prefixes (`Rs.`) and names, magnitudes (`k`, `million`, `lakh`, `crore`), cost words, and the regex fragments that make a number effort (`person-days`) or a rate (`per hour`). `ENGLISH` is the default. Change part of it with `dataclasses.replace`, or build your own:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from dataclasses import replace
|
|
138
|
+
from llm_stream_guards import ENGLISH, Locale
|
|
139
|
+
|
|
140
|
+
nordic = replace(ENGLISH, currency_codes=(*ENGLISH.currency_codes, "nok", "sek", "dkk"))
|
|
141
|
+
german = Locale(
|
|
142
|
+
currency_names=("euro",),
|
|
143
|
+
magnitudes={"tsd": 1_000, "mio": 1_000_000, "mrd": 1_000_000_000},
|
|
144
|
+
cost_words=("kosten", "preis", "budget", "angebot"),
|
|
145
|
+
effort=r"personentage\b",
|
|
146
|
+
rate=r"\s*(?:pro|je)\s*stunde\b",
|
|
147
|
+
range_words=("bis",),
|
|
148
|
+
)
|
|
149
|
+
AmountStream(grounded, locale=german)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
An empty list matches nothing. The fields are documented on the class.
|
|
153
|
+
|
|
154
|
+
## Limits
|
|
155
|
+
|
|
156
|
+
- **Quotation, not paraphrase.** The leak guard catches a protected text repeated word for word, not one retold. Up to 7 words of a quotation are shown before the eighth completes the run. A protected text shorter than the run length is refused, not ignored: give it its own, `(text, 3)`. Leave out of the protected text any phrase the model is told to say, or saying it counts as a leak.
|
|
157
|
+
- **Text without word boundaries is not covered.** Chinese, Japanese or Thai reduces to very few words, so the leak guard sees almost nothing to compare.
|
|
158
|
+
- **The default patterns read English.** A price written in another language passes `ENGLISH`; give it a `Locale`. A figure in words ("forty thousand dollars") is not caught in any locale. Numbers are read with `.` as the decimal point.
|
|
159
|
+
- **Telephone numbers are counted by their digits**: 9 or more, or 8 after a `+`, on one line. A shorter number, or one written in words, is not checked. A date such as `2026-10-07` is never a number, but a run of other numbers that adds up to that many digits (`2019 2020 2021`) is, and so is an identifier of that length (`Order 12345678901`). A dotted address such as `192.168.100.100` is not a number.
|
|
160
|
+
- **Only web links are checked.** A link is one to `http`, `https`, `www.` or `//host`, and one with a `javascript:`, `data:`, `vbscript:` or `file:` scheme goes too. An `ftp:` link, or one to an app scheme (`intent:`, `slack:`), passes.
|
|
161
|
+
- **Holding back costs latency.** An unsourced `60k` or `120 per hour` waits for the next 120 characters; an unclosed `[` waits up to 80 characters and an unclosed link up to 300. The rest of the reply waits behind it.
|
|
162
|
+
- **Time is spent on the end of the reply.** Each piece is checked together with the last 320 to 640 characters, not the whole reply, so the time a reply takes grows with its length, about 0.3 milliseconds a piece for the chain above (a few times that if your pieces are single characters). The whole reply is checked once more when it ends. A link whose text runs past 300 characters, or a figure with more than 300 characters of white space inside it, is therefore judged only then.
|
|
163
|
+
- **What was shown can differ from `text`.** In a rare case (a `[` let through as prose that then opens a link to an untrusted host) cleaning the longer text changes text already shown. The stream goes on, and after `finish` you show `text` in its place.
|
|
164
|
+
- **The code detector prefers false positives.** A reply with a SQL statement or a block opener with an indented body counts as code. Pass `deliverable=False` to `CodeStream` to block on fences only.
|
|
165
|
+
- **A backstop, not a judge.** These checks see shapes, not meaning: they are what still holds when the prompt, and any model-based check, did not.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
See [CONTRIBUTING.md](https://github.com/sansanth/llm-stream-guards/blob/main/CONTRIBUTING.md). In short:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
cd python
|
|
173
|
+
uv sync
|
|
174
|
+
uv run pytest --cov
|
|
175
|
+
uv run ruff check . && uv run mypy
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "llm-stream-guards"
|
|
3
|
+
# One place: `__version__` in src/llm_stream_guards/__init__.py; hatchling reads it at build time.
|
|
4
|
+
dynamic = ["version"]
|
|
5
|
+
description = "Deterministic output guards for a streamed LLM reply: untrusted links, made-up emails, phone numbers and prices, quoted system prompts and code, stopped before they reach the screen."
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Sandhu Santhakumar", email = "code@sandhu.sh" }]
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = []
|
|
12
|
+
keywords = ["llm", "streaming", "guardrails", "prompt-injection", "output-filter", "chatbot", "security"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Security",
|
|
22
|
+
"Topic :: Text Processing :: Filters",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/sansanth/llm-stream-guards"
|
|
28
|
+
Issues = "https://github.com/sansanth/llm-stream-guards/issues"
|
|
29
|
+
Changelog = "https://github.com/sansanth/llm-stream-guards/blob/main/CHANGELOG.md"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"mypy>=1.10",
|
|
34
|
+
"pytest>=8",
|
|
35
|
+
"pytest-cov>=5",
|
|
36
|
+
"ruff>=0.6",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling>=1.26"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "src/llm_stream_guards/__init__.py"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
48
|
+
|
|
49
|
+
# The changelog and the test cases shared with the npm package live at the repository root.
|
|
50
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
51
|
+
"../CHANGELOG.md" = "CHANGELOG.md"
|
|
52
|
+
"../spec" = "spec"
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
addopts = "--strict-markers"
|
|
57
|
+
|
|
58
|
+
[tool.coverage.run]
|
|
59
|
+
source = ["llm_stream_guards"]
|
|
60
|
+
branch = true
|
|
61
|
+
|
|
62
|
+
[tool.coverage.report]
|
|
63
|
+
fail_under = 95
|
|
64
|
+
show_missing = true
|
|
65
|
+
|
|
66
|
+
[tool.mypy]
|
|
67
|
+
strict = true
|
|
68
|
+
files = ["src"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
line-length = 120
|
|
72
|
+
target-version = "py311"
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint]
|
|
75
|
+
select = ["E", "F", "W", "I", "B", "UP", "S", "ASYNC", "RUF", "SIM"]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.per-file-ignores]
|
|
78
|
+
"tests/*" = ["S101"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Shared test cases
|
|
2
|
+
|
|
3
|
+
The Python and npm packages run every case in `cases/*.json`, so they cannot drift apart. Each package has a runner
|
|
4
|
+
(`python/tests/test_spec.py`, `js/test/spec.test.ts`) that reads these files and compares.
|
|
5
|
+
|
|
6
|
+
A case either calls a function:
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{ "call": "strip_untrusted_links", "args": ["See [x](https://evil.example)", ["acme.example"]], "returns": "See x" }
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
or feeds a guard the pieces of a reply:
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"stream": { "amount": { "grounded": ["500"] } },
|
|
17
|
+
"deltas": ["They saved $5", "0 a month."],
|
|
18
|
+
"shown": ["They saved ", ""],
|
|
19
|
+
"text": "They saved ",
|
|
20
|
+
"blocked": { "reason": "amount", "found": ["$50"] }
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `call` is a Python function name (`LeakGuard.match` for the method), `args` its positional arguments and `options` its
|
|
25
|
+
keyword ones, with a `locale` as the changes to `ENGLISH`. A set is compared as a sorted list.
|
|
26
|
+
- `stream` is one guard, `{"link": {...}}`, `{"amount": {...}}`, `{"leak": {...}}`, `{"code": {...}}`, or
|
|
27
|
+
`{"chain": [...]}` of them. `shown` is what each `feed` call and the last `finish` returned, `text` the guard's `text`,
|
|
28
|
+
and `blocked` is `null` or the `reason` and `found` of the `Blocked` it raised. After a block, the runner checks that
|
|
29
|
+
the guard keeps refusing.
|
|
30
|
+
|
|
31
|
+
To add a case, write its input, leave the result out, and run `cd python && uv run python ../spec/fill.py`. The result is
|
|
32
|
+
what Python does, so read it before you trust it, and run both test suites. Sorting is by code point, as Python's
|
|
33
|
+
`sorted` does.
|
|
34
|
+
|
|
35
|
+
After a change to what a guard does, `cd python && uv run python ../spec/fill.py --refresh` recomputes every result: read the
|
|
36
|
+
diff of the JSON, and only what you meant to change should have. `make_long.py` writes `cases/long.json`, the replies long
|
|
37
|
+
enough that the streaming guards move their window.
|