Skip to content

Commit c265455

Browse files
author
Paweł Kędzia
committed
Merge branch 'features/refactoring'
2 parents d1775a0 + 3dd6baa commit c265455

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+3388
-0
lines changed

.version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0.0.1

README.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
## Overview
2+
3+
The **LLM‑Router** project ships with a modular plugin system that lets you plug‑in **anonymizers**
4+
(also called *maskers*) and **guardrails** into request‑processing pipelines.
5+
Each plugin implements a tiny, well‑defined interface (`apply`) and can be composed
6+
in an ordered list to form a **pipeline**. The pipelines are instantiated by the
7+
`MaskerPipeline` and `GuardrailPipeline` classes and are driven automatically by the
8+
endpoint logic in `endpoint_i.py`.
9+
10+
---
11+
12+
## 1. Anonymizers (Maskers)
13+
14+
### 1.1 What they do
15+
16+
* **Goal** – Remove or replace personally‑identifiable information (PII) from a payload before it reaches the LLM or
17+
external service.
18+
* **Typical strategy** – Run a pipeline of maskers, to locate spans that correspond to IDs, etc., and replace each span
19+
with a placeholder such as `{{MASKED_ITEM}}`.
20+
21+
### 1.2 Built‑in anonymizer plugins
22+
23+
Full list of `FastMaskerPlugin` masking strategies is located in [README.md](llm_router_plugins/maskers/fast_masker/README.md) file.
24+
25+
| Plugin | Description | Technical notes |
26+
|------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
27+
| **FastMaskerPlugin** (`fast_masker_plugin.py`) | A thin wrapper around the `FastMasker` utility class. It receives a JSON‑compatible payload and returns the same payload with all detected PII masked. | Implements `PluginInterface`. The heavy lifting is delegated to `FastMasker.mask_payload(payload)`. No extra I/O; the `FastMasker` instance is created once in `__init__`. |
28+
29+
### 1.3 How a masker is used
30+
31+
1. The endpoint (e.g. `EndpointI._do_masking_if_needed`) checks the global flag `FORCE_MASKING`.
32+
2. If enabled, it creates a `MaskerPipeline` with the list of masker plugin identifiers (e.g. `["fast_masker"]`).
33+
3. The pipeline calls each plugin’s `apply` method sequentially, feeding the output of one as the input to the next.
34+
4. The final payload – now stripped of PII – proceeds to the rest of the request flow (guardrails, model dispatch,
35+
etc.).
36+
37+
---
38+
39+
## 2. Guardrails
40+
41+
### 2.1 What they do
42+
43+
* **Goal** – Verify that a request (or its response) complies with policy rules (e.g. no hateful, illegal, or unsafe
44+
content).
45+
* **Typical strategy** – Split the payload into manageable text chunks, run a pipeline of guardrails,
46+
aggregate per‑chunk scores, and decide whether the overall request is safe.
47+
48+
### 2.2 Built‑in guardrail plugins
49+
50+
| Plugin | Description | Technical notes |
51+
|----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
52+
| **NASKGuardPlugin** (`nask_guard_plugin.py`) | An HTTP‑based guardrail that forwards the payload to the external NASK guardrail service (`/nask_guard` endpoint) and returns a boolean *safe* flag together with the raw response. | Inherits from `HttpPluginInterface`. The `apply` method calls `_request(payload)` (provided by the base class) and extracts `results["safe"]`. Errors are caught and logged; on failure the plugin returns `(False, {})`. |
53+
| **(Implicit) GuardrailProcessor** (`processor.py`) | Not a plugin per‑se, but the core logic used by the internal NASK guardrail Flask route (`nask_guardrail`). It tokenises the payload, creates overlapping chunks, runs a Hugging‑Face `text‑classification` pipeline, and produces a detailed safety report. | Handles model loading (`AutoTokenizer`, `pipeline("text‑classification")`), chunking (`_chunk_text`), and scoring thresholds (`MIN_SCORE_FOR_SAFE`, `MIN_SCORE_FOR_NOT_SAFE`). Returns a dict: `{"safe": <bool>, "detailed": [...]}`. |
54+
55+
### 2.3 How a guardrail is used
56+
57+
1. The endpoint calls `_is_request_guardrail_safe(payload)` (or the analogous response guardrail).
58+
2. If `FORCE_GUARDRAIL_REQUEST` is true, a `GuardrailPipeline` is built from the configured plugin IDs (e.g.
59+
`["nask_guard"]`).
60+
3. The pipeline iterates over each guardrail plugin; each `apply` returns `(is_safe, message)`.
61+
4. The first plugin that reports `is_safe=False` short‑circuits the pipeline and the request is rejected with a 400/500
62+
error payload.
63+
64+
---
65+
66+
## 3. Pipelines
67+
68+
Both masker and guardrail pipelines share the same design pattern:
69+
70+
| Class | Purpose |
71+
|-----------------------------------------------------------|------------------------------------------------------------------------------------|
72+
| **MaskerPipeline** (`pipeline.py` – masker version) | Executes a list of masker plugins in order, transforming the payload step‑by‑step. |
73+
| **GuardrailPipeline** (`pipeline.py` – guardrail version) | Executes guardrail plugins sequentially, stopping on the first failure. |
74+
75+
### 3.1 Registration
76+
77+
* Plugins are registered lazily via `MaskerRegistry.register(name, logger)` or
78+
`GuardrailRegistry.register(name, logger)`.
79+
* The registry maps a string identifier (e.g. `"fast_masker"`) to a concrete plugin class, allowing pipelines to resolve
80+
the classes at runtime.
81+
82+
### 3.2 Configuration
83+
84+
All plugin identifiers are stored in environment variables or constants such as:
85+
86+
```python
87+
MASKING_STRATEGY_PIPELINE = ["fast_masker"]
88+
GUARDRAIL_STRATEGY_PIPELINE_REQUEST = ["nask_guard"]
89+
```
90+
91+
These lists are consumed by the endpoint initialization (`EndpointI._prepare_masker_pipeline`,
92+
`EndpointI._prepare_guardrails_pipeline`).
93+
94+
---
95+
96+
## 4. Adding a New Plugin
97+
98+
1. **Create a subclass** of either `PluginInterface` (for maskers) or `HttpPluginInterface` / a custom guardrail base.
99+
2. **Define a `name` class attribute** – this is the identifier used in pipeline configuration.
100+
3. **Implement `apply(self, payload: Dict) -> Dict`** (masker) **or `apply(self, payload: Dict) -> Tuple[bool, Dict]`
101+
** (guardrail).
102+
4. **Register the plugin** – either automatically via the registry’s `register` call in the pipeline constructor, or
103+
manually by calling `MaskerRegistry.register(name=MyPlugin.name, logger=logger)`.
104+
105+
*Example stub for a new masker:*
106+
107+
```python
108+
# my_custom_masker.py
109+
from llm_router_plugins.maskers.plugin_interface import PluginInterface
110+
import logging
111+
from typing import Dict, Optional
112+
113+
114+
class MyCustomMasker(PluginInterface):
115+
name = "my_custom_masker"
116+
117+
def __init__(self, logger: Optional[logging.Logger] = None):
118+
super().__init__(logger=logger)
119+
# Load any heavy resources here (e.g., a spaCy model)
120+
121+
def apply(self, payload: Dict) -> Dict:
122+
# Perform your masking logic and return the modified payload
123+
return payload
124+
```
125+
126+
After placing the file in `llm_router_plugins/maskers/plugins/`, you can enable it by adding `"my_custom_masker"` to
127+
`MASKING_STRATEGY_PIPELINE`.
128+
129+
---
130+
131+
## 5. Summary
132+
133+
* **Anonymizers** (`FastMaskerPlugin`, `BANonymizer`) scrub PII from requests.
134+
* **Guardrails** (`NASKGuardPlugin`, internal `GuardrailProcessor`) enforce safety policies.
135+
* **Pipelines** (`MaskerPipeline`, `GuardrailPipeline`) orchestrate the sequential execution of these plugins,
136+
short‑circuiting on failure for guardrails.
137+
* The system is **extensible**: new plugins are just classes that obey the tiny interface contract and can be referenced
138+
by name in the configuration.
139+
140+
These components together give the LLM‑Router a flexible, policy‑driven request‑processing stack that can be tailored to
141+
any deployment scenario.

llm_router_plugins/__init__.py

Whitespace-only changes.

llm_router_plugins/guardrails/__init__.py

Whitespace-only changes.

llm_router_plugins/guardrails/nask/__init__.py

Whitespace-only changes.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
NASK Guardrail Plugin
3+
4+
This plugin sends the incoming ``payload`` to the NASK guardrail service and
5+
parses the JSON response. The service URL can be configured through the
6+
environment variable ``LLM_ROUTER_GUARDRAIL_NASK_GUARD_HOST_EP``;
7+
8+
The expected response format is:
9+
10+
{
11+
"results": {
12+
"detailed": [
13+
{
14+
"chunk_index": 0,
15+
"chunk_text": "...",
16+
"label": "safe",
17+
"safe": true,
18+
"score": 0.9834
19+
}
20+
],
21+
"safe": true
22+
}
23+
}
24+
25+
If the request succeeds, ``apply`` returns a dictionary containing the
26+
extracted fields. If any error occurs (network error, unexpected payload,
27+
missing keys, etc.) the method returns ``{'success': False}``.
28+
29+
---
30+
31+
**Model License:** The model used by this plugin is licensed under **CC BY‑NC‑SA 4.0**.
32+
**Router License:** The LLM router component is licensed under **Apache 2.0**.
33+
Before using the plugin, ensure that your intended use complies with these licenses.
34+
35+
**Authors:** Aleksandra Krasnodębska, Karolina Seweryn, Szymon Łukasik, Wojciech Kusa
36+
(see *PL‑Guard: Benchmarking Language Model Safety for Polish*, 2025).
37+
38+
"""
39+
40+
import json
41+
import logging
42+
from typing import Dict, Optional, Tuple
43+
44+
from llm_router_api.base.constants import GUARDRAIL_NASK_GUARD_HOST_EP
45+
from llm_router_plugins.plugin_interface import HttpPluginInterface
46+
47+
48+
class NASKGuardPlugin(HttpPluginInterface):
49+
"""
50+
Concrete implementation of :class:`HttpPluginInterface` that
51+
talks to the NASK guardrail HTTP endpoint.
52+
"""
53+
54+
name = "nask_guard"
55+
56+
def __init__(self, logger: Optional[logging.Logger] = None):
57+
if not len(GUARDRAIL_NASK_GUARD_HOST_EP):
58+
raise RuntimeError(
59+
f"When you are using `nask_guard` plugin, you must provide a "
60+
f"host with model, GUARDRAIL_NASK_GUARD_HOST_EP must be set "
61+
f"to valid host."
62+
)
63+
64+
super().__init__(logger=logger)
65+
66+
@property
67+
def base_url(self) -> str:
68+
"""
69+
Resolve the endpoint URL from the environment variable or fall back to
70+
the default value.
71+
"""
72+
return GUARDRAIL_NASK_GUARD_HOST_EP
73+
74+
def apply(self, payload: Dict) -> Tuple[bool, Dict]:
75+
"""
76+
Send ``payload`` to the guardrail service, parse the JSON response and
77+
expose the most relevant fields.
78+
79+
Parameters
80+
----------
81+
payload: Dict
82+
The data that should be evaluated by the guardrail.
83+
84+
Returns
85+
-------
86+
Dict
87+
``{'success': True, 'safe': <bool>, 'chunk_index': <int>,
88+
'chunk_text': <str>, 'label': <str>, 'score': <float>}``
89+
on success, or ``{'success': False}`` on any error.
90+
"""
91+
try:
92+
response = self._request(payload)
93+
results = response.get("results", {})
94+
safe_overall: bool = bool(results.get("safe", False))
95+
96+
# detailed = results.get("detailed", [])
97+
# if not detailed:
98+
# # No detailed information – treat as failure
99+
# raise ValueError("Missing 'detailed' entries in response")
100+
# first_chunk = detailed[0]
101+
# chunk_index: int = first_chunk.get("chunk_index", -1)
102+
# chunk_text: str = first_chunk.get("chunk_text", "")
103+
# label: str = first_chunk.get("label", "")
104+
# safe_chunk: bool = first_chunk.get("safe", False)
105+
# score: float = first_chunk.get("score", 0.0)
106+
# Build a concise result dictionary
107+
return safe_overall, response
108+
except Exception as exc:
109+
if self._logger:
110+
self._logger.error(
111+
"NASKGuardPlugin failed to process payload: %s", exc
112+
)
113+
return False, {}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Executable pipeline for guardrail plugins.
3+
4+
It works the same way as the masker pipeline: an ordered list of plugin
5+
identifiers is supplied, each plugin is registered (if not already), and then
6+
their ``apply`` methods are called sequentially on the payload.
7+
"""
8+
9+
import logging
10+
from typing import Tuple, Dict
11+
12+
from llm_router_plugins.guardrails.plugin_registrator import GuardrailRegistry
13+
14+
15+
class GuardrailPipeline:
16+
"""
17+
Represents an executable pipeline of guardrail plugins.
18+
19+
The pipeline is built from an ordered list of plugin identifiers.
20+
Calling ``apply(payload, *args, **kwargs)`` will invoke each plugin's
21+
``apply`` method sequentially, passing the result of one as the input
22+
to the next.
23+
"""
24+
25+
def __init__(self, plugin_names: list[str], logger: logging.Logger):
26+
self._logger = logger
27+
28+
# Ensure every requested plugin is instantiated and cached.
29+
for p_name in plugin_names:
30+
GuardrailRegistry.register(name=p_name, logger=logger)
31+
32+
# Resolve the concrete plugin instances.
33+
self._plugin_instances = [
34+
GuardrailRegistry.get(name) for name in plugin_names
35+
]
36+
37+
def apply(self, payload: Dict) -> Tuple[bool, Dict]:
38+
"""
39+
Execute the pipeline.
40+
41+
Args:
42+
payload: Initial data passed to the first guardrail plugin.
43+
*args, **kwargs: Additional arguments forwarded to each plugin's
44+
``apply`` method.
45+
46+
Returns:
47+
True when payload is satisfied, False otherwise.
48+
"""
49+
for plugin in self._plugin_instances:
50+
is_safe, message = plugin.apply(payload)
51+
if not is_safe:
52+
return False, message
53+
return True, {}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Helper that registers guardrail plugins on demand, similar to the masker
3+
registrator.
4+
5+
Usage:
6+
GuardrailRegistry.register(name="name", logger=my_logger)
7+
plugin = GuardrailRegistry.get("name")
8+
"""
9+
10+
import logging
11+
from typing import Optional
12+
13+
from llm_router_plugins.guardrails.registry import (
14+
MAIN_GUARDRAILS_REGISTRY,
15+
GUARDRAILS_REGISTRY_SESSION,
16+
)
17+
18+
19+
class GuardrailRegistry:
20+
"""Central registry for guardrail plugins."""
21+
22+
@staticmethod
23+
def register(name: str, logger: Optional[logging.Logger] = None) -> None:
24+
"""
25+
Register a guardrail plugin for the current session.
26+
27+
Args:
28+
name: Identifier of the guardrail plugin (must exist in
29+
``MAIN_GUARDRAILS_REGISTRY``).
30+
logger: Optional logger that will be passed to the plugin's
31+
constructor.
32+
"""
33+
if name not in MAIN_GUARDRAILS_REGISTRY:
34+
raise KeyError(
35+
f"Guardrail '{name}' not found in registry: {MAIN_GUARDRAILS_REGISTRY}"
36+
)
37+
38+
# Already registered – nothing to do.
39+
if name in GUARDRAILS_REGISTRY_SESSION:
40+
return
41+
42+
# Instantiate the plugin and store it in the session cache.
43+
_cls = MAIN_GUARDRAILS_REGISTRY[name](logger=logger)
44+
GUARDRAILS_REGISTRY_SESSION[name] = _cls
45+
46+
if logger:
47+
logger.info(
48+
f"[guardrail] Registering guardrail '{name}' for plugin '{_cls}'"
49+
)
50+
51+
@staticmethod
52+
def get(name: str):
53+
"""
54+
Retrieve a registered guardrail plugin instance by name.
55+
56+
Raises:
57+
KeyError: If the plugin has not been registered yet.
58+
"""
59+
try:
60+
return GUARDRAILS_REGISTRY_SESSION[name]
61+
except KeyError as exc:
62+
raise KeyError(
63+
f"Guardrail '{name}' not found in registry. "
64+
f"Available plugins: {list(GUARDRAILS_REGISTRY_SESSION.keys())}"
65+
) from exc
66+
67+
@staticmethod
68+
def list_plugins() -> list[str]:
69+
"""Return the list of guardrail names currently registered in the session."""
70+
return list(GUARDRAILS_REGISTRY_SESSION.keys())

0 commit comments

Comments
 (0)