From 3535ea348f4b62361d1f2ad49465ba4fa06e3299 Mon Sep 17 00:00:00 2001 From: basil-k-aji-dev <70605804+basil-k-aji-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:49:02 +0530 Subject: [PATCH 1/5] feat(presidio): opt-in stable PII tokens that survive across requests Presidio's anonymizer numbers placeholders per request, so in one turn and in the next need not be the same person. A conversation that masks on the way out and restores on the way back in therefore cannot match them up across turns. Add opt-in stable tokens derived from a salt, so the same value maps to the same placeholder for as long as the salt holds. The salt is refused as a literal and must name an environment variable with os.environ/. A salt is the only thing standing between the tokens and a dictionary attack over the masked values, so it must not sit in the config file that gets committed. --- .../guardrails/guardrail_hooks/presidio.py | 60 +++++- .../guardrails/guardrail_initializers.py | 2 + .../guardrail_hooks/test_presidio.py | 195 +++++++++++++++++- 3 files changed, 249 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index d0006f1a091..086468f16bb 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -9,6 +9,8 @@ import asyncio +import hashlib +import hmac import json import re import threading @@ -29,6 +31,7 @@ from litellm.constants import ( PRESIDIO_ANALYZE_CHUNK_CONCURRENCY, PRESIDIO_ANALYZE_CHUNK_OVERLAP_CHARS, ) +from litellm.secret_managers.main import get_secret_str from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: @@ -169,6 +172,29 @@ async def _coalesce_first_sse_frame(stream: AsyncIterator[object]) -> AsyncGener yield chunk +def _resolved_token_salt(value: str | None) -> str: + """Resolve the salt from an ``os.environ/`` reference, the way guardrail + api_key and api_base are already resolved. + + A literal is refused rather than used. The salt is the HMAC key, so it has to + survive as a secret for the tokens to mean anything, and a literal does not: + guardrail_registry logs the whole params mapping at debug before + initialization, so a literal reaches the proxy log in full and anyone with + the log can test candidate values against the tokens they can see.""" + if value is None: + return "" + if not value.startswith("os.environ/"): + raise ValueError( + "presidio_token_salt must be an os.environ/ reference, not a literal. " + "The salt is the HMAC key behind every stable token, and guardrail params " + "are logged in full at debug level." + ) + resolved: Final = get_secret_str(value) + if resolved is None or not resolved.strip(): + raise ValueError(f"presidio_token_salt: {value!r} resolves to an unset or blank environment variable") + return resolved + + class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): user_api_key_cache = None ad_hoc_recognizers: list[str] | None = None @@ -201,6 +227,8 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): presidio_entities_deny_list: list[PiiEntityType | str] | None = None, presidio_analyze_chunk_size_bytes: int | None = None, _callback_role: Literal["scan", "restore"] | None = None, + presidio_stable_tokens: bool | None = None, + presidio_token_salt: str | None = None, **kwargs, ): if logging_only is True: @@ -231,6 +259,13 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): self.presidio_entities_deny_list: list[PiiEntityType | str] = presidio_entities_deny_list or [] self.presidio_language = presidio_language or "en" self.presidio_analyze_chunk_size_bytes: int = self._coerce_analyze_chunk_size(presidio_analyze_chunk_size_bytes) + self.presidio_stable_tokens: bool = bool(presidio_stable_tokens) + self.presidio_token_salt: str = _resolved_token_salt(presidio_token_salt) + if self.presidio_stable_tokens and not self.presidio_token_salt: + raise ValueError( + "presidio_stable_tokens requires presidio_token_salt. An unkeyed digest lets the " + "model provider recover a masked value by hashing candidates and comparing the prefix." + ) # Shared HTTP session to prevent memory leaks (issue #14540) self._http_session: aiohttp.ClientSession | None = None # Lock to prevent race conditions when creating session under concurrent load @@ -786,6 +821,16 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): masked_entity_count[entity_type] = masked_entity_count.get(entity_type, 0) + 1 return redacted_text["text"] + _STABLE_TOKEN_HEX_CHARS: Final[int] = 8 + + def _stable_token_suffix(self, entity_type: str, value: str) -> str: + # The guardrail name namespaces the digest, so two guardrails with + # different salts cannot produce the same token for the same value. + namespace: Final = self.guardrail_name or "" + message: Final = f"{namespace}\x00{entity_type}\x00{value}".encode() + digest: Final = hmac.new(self.presidio_token_salt.encode(), message, hashlib.sha256).hexdigest() + return digest[: self._STABLE_TOKEN_HEX_CHARS] + def _finalize_presidio_anonymize_numbered_tokens( self, text: str, @@ -825,13 +870,14 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): start = ar["start"] end = ar["end"] entity_type = ar["entity_type"] - replacement = f"<{entity_type}>" - seq = seq_map[(start, end)] - if replacement.endswith(">"): - replacement = f"{replacement[:-1]}_{seq}>" - else: - replacement = f"{replacement}_{seq}" - pii_tokens[replacement] = text[start:end] + value = text[start:end] + suffix = ( + self._stable_token_suffix(entity_type, value) + if self.presidio_stable_tokens + else str(seq_map[(start, end)]) + ) + replacement = f"<{entity_type}_{suffix}>" + pii_tokens[replacement] = value new_text = new_text[:start] + replacement + new_text[end:] masked_entity_count[entity_type] = masked_entity_count.get(entity_type, 0) + 1 return new_text diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index b7f3726d017..efc09bb1669 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -156,6 +156,8 @@ def initialize_presidio(litellm_params: LitellmParams, guardrail: Guardrail) -> presidio_anonymizer_api_base=litellm_params.presidio_anonymizer_api_base, presidio_language=litellm_params.presidio_language, presidio_entities_deny_list=litellm_params.presidio_entities_deny_list, + presidio_stable_tokens=getattr(litellm_params, "presidio_stable_tokens", None), + presidio_token_salt=getattr(litellm_params, "presidio_token_salt", None), apply_to_output=False, timeout=litellm_params.timeout, _callback_role="scan", diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py index 08acec0d7ac..be52d8be77f 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py @@ -6,9 +6,10 @@ Tests PII detection and masking for different message formats import asyncio import copy import json +import os import re from contextlib import asynccontextmanager -from typing import Final, Literal +from typing import Dict, Final, List, Literal, Tuple from unittest.mock import MagicMock, patch from aiohttp import web @@ -4288,3 +4289,195 @@ async def test_standalone_restoration_preserves_post_call_selection(event_hook: data, UserAPIKeyAuth(request_route="/v1/chat/completions"), response ) assert response.choices[0].message.content == "Jane" + + +def _analyze_result(entity_type: str, text: str, value: str) -> Dict[str, object]: + start = text.index(value) + return {"entity_type": entity_type, "start": start, "end": start + len(value), "score": 0.9} + + +def _mask( + guardrail: _OPTIONAL_PresidioPIIMasking, text: str, results: List[Dict[str, object]] +) -> Tuple[str, Dict[str, str]]: + request_data: Dict[str, object] = {} + masked = guardrail._finalize_presidio_anonymize_numbered_tokens( + text=text, analyze_results=results, request_data=request_data, masked_entity_count={} + ) + return masked, request_data["metadata"]["pii_tokens"] + + +def _stable_guardrail(salt: str = "unit-test-salt", guardrail_name: str | None = None) -> _OPTIONAL_PresidioPIIMasking: + """Build a stable-token guardrail on a salt held where the real one has to be. + + The salt is only ever read through an os.environ reference, so the tests put + it there rather than passing a literal the constructor refuses.""" + var = f"PRESIDIO_TEST_SALT_{abs(hash(salt)) % 10**8}" + os.environ[var] = salt + return _OPTIONAL_PresidioPIIMasking( + mock_testing=True, + output_parse_pii=True, + presidio_stable_tokens=True, + presidio_token_salt=f"os.environ/{var}", + **({"guardrail_name": guardrail_name} if guardrail_name else {}), + ) + + +def test_stable_tokens_are_identical_across_requests(): + """The same value keeps its token as the conversation grows""" + guardrail = _stable_guardrail() + + first_text = "Call Alice Brenner" + second_text = "Earlier you asked. Then Bob Smith replied. Call Alice Brenner" + first, first_tokens = _mask(guardrail, first_text, [_analyze_result("PERSON", first_text, "Alice Brenner")]) + second, second_tokens = _mask( + guardrail, + second_text, + [ + _analyze_result("PERSON", second_text, "Bob Smith"), + _analyze_result("PERSON", second_text, "Alice Brenner"), + ], + ) + + alice_token = next(token for token, value in first_tokens.items() if value == "Alice Brenner") + assert alice_token in second + assert second_tokens[alice_token] == "Alice Brenner" + + +def test_counter_tokens_drift_across_requests(): + """The default numbering is what stable tokens exist to replace""" + guardrail = _OPTIONAL_PresidioPIIMasking(mock_testing=True, output_parse_pii=True) + + first_text = "Call Alice Brenner" + second_text = "Earlier you asked. Then Bob Smith replied. Call Alice Brenner" + _, first_tokens = _mask(guardrail, first_text, [_analyze_result("PERSON", first_text, "Alice Brenner")]) + _, second_tokens = _mask( + guardrail, + second_text, + [ + _analyze_result("PERSON", second_text, "Bob Smith"), + _analyze_result("PERSON", second_text, "Alice Brenner"), + ], + ) + + assert first_tokens[""] == "Alice Brenner" + assert second_tokens[""] == "Bob Smith" + assert second_tokens[""] == "Alice Brenner" + + +def test_stable_tokens_differ_per_value_and_per_entity_type(): + guardrail = _stable_guardrail() + + text = "Alice Brenner and Bob Smith" + _, tokens = _mask( + guardrail, + text, + [_analyze_result("PERSON", text, "Alice Brenner"), _analyze_result("PERSON", text, "Bob Smith")], + ) + assert len(set(tokens)) == 2 + + _, person = _mask(guardrail, "Toronto", [_analyze_result("PERSON", "Toronto", "Toronto")]) + _, location = _mask(guardrail, "Toronto", [_analyze_result("LOCATION", "Toronto", "Toronto")]) + assert set(person) != set(location) + + +def test_stable_tokens_depend_on_the_salt(): + """Without this, a token could be reversed by hashing candidate values""" + entity = [_analyze_result("PERSON", "Alice Brenner", "Alice Brenner")] + salted, salted_tokens = _mask(_stable_guardrail("salt-a"), "Alice Brenner", entity) + other, other_tokens = _mask(_stable_guardrail("salt-b"), "Alice Brenner", entity) + + assert salted != other + assert set(salted_tokens) != set(other_tokens) + + +def test_stable_tokens_round_trip_through_unmasking(): + guardrail = _stable_guardrail() + text = "Call Alice Brenner today" + masked, tokens = _mask(guardrail, text, [_analyze_result("PERSON", text, "Alice Brenner")]) + + assert guardrail._unmask_pii_text(masked, tokens) == "Call Alice Brenner today" + + +def test_stable_tokens_are_off_by_default(): + guardrail = _OPTIONAL_PresidioPIIMasking(mock_testing=True, output_parse_pii=True) + assert guardrail.presidio_stable_tokens is False + + text = "Call Alice Brenner" + masked, _ = _mask(guardrail, text, [_analyze_result("PERSON", text, "Alice Brenner")]) + assert masked == "Call " + + +def test_stable_token_config_reaches_the_guardrail(monkeypatch): + from litellm.proxy.guardrails.guardrail_initializers import initialize_presidio + + monkeypatch.setenv("PRESIDIO_ANALYZER_API_BASE", "http://localhost:5002") + monkeypatch.setenv("PRESIDIO_ANONYMIZER_API_BASE", "http://localhost:5001") + params = LitellmParams( + guardrail="presidio", + mode="pre_call", + output_parse_pii=True, + presidio_stable_tokens=True, + presidio_token_salt="os.environ/PRESIDIO_CONFIG_SALT", + ) + monkeypatch.setenv("PRESIDIO_CONFIG_SALT", "from-config") + callbacks = initialize_presidio(params, {"guardrail_name": "presidio-unit", "litellm_params": params}) + + assert callbacks + for callback in callbacks: + assert callback.presidio_stable_tokens is True + assert callback.presidio_token_salt == "from-config" + + +def test_a_literal_salt_is_refused(monkeypatch): + """The salt is the HMAC key, and guardrail params are logged in full at debug. + + A literal would therefore reach the proxy log, where anyone holding it could + test candidate values against the tokens they can already see. + """ + monkeypatch.setenv("PRESIDIO_ANALYZER_API_BASE", "http://localhost:5002") + monkeypatch.setenv("PRESIDIO_ANONYMIZER_API_BASE", "http://localhost:5001") + + with pytest.raises(ValueError, match=r"os\.environ"): + _OPTIONAL_PresidioPIIMasking( + mock_testing=True, + output_parse_pii=True, + presidio_stable_tokens=True, + presidio_token_salt="a-literal-secret", + ) + + +def test_stable_token_salt_resolves_an_os_environ_reference(monkeypatch): + monkeypatch.setenv("MY_PII_SALT", "from-env") + guardrail = _OPTIONAL_PresidioPIIMasking( + mock_testing=True, + output_parse_pii=True, + presidio_stable_tokens=True, + presidio_token_salt="os.environ/MY_PII_SALT", + ) + assert guardrail.presidio_token_salt == "from-env" + + +def test_stable_token_salt_refuses_an_unset_os_environ_reference(monkeypatch): + monkeypatch.delenv("MY_PII_SALT", raising=False) + with pytest.raises(ValueError, match="unset or blank"): + _OPTIONAL_PresidioPIIMasking( + mock_testing=True, + output_parse_pii=True, + presidio_stable_tokens=True, + presidio_token_salt="os.environ/MY_PII_SALT", + ) + + +def test_stable_tokens_require_a_salt(): + """An unkeyed digest lets a provider recover the value by hashing candidates""" + with pytest.raises(ValueError, match="presidio_token_salt"): + _OPTIONAL_PresidioPIIMasking(mock_testing=True, output_parse_pii=True, presidio_stable_tokens=True) + + +def test_stable_tokens_are_namespaced_per_guardrail(): + """One salt shared by two guardrails must not produce one token""" + first = _stable_guardrail("shared", guardrail_name="tenant-a") + second = _stable_guardrail("shared", guardrail_name="tenant-b") + entity = [_analyze_result("PERSON", "Alice Brenner", "Alice Brenner")] + + assert _mask(first, "Alice Brenner", entity)[0] != _mask(second, "Alice Brenner", entity)[0] From 5f041d2f194fd4596ee3e90c3968d851abf93080 Mon Sep 17 00:00:00 2001 From: basil-k-aji-dev <70605804+basil-k-aji-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:17:56 +0530 Subject: [PATCH 2/5] feat(presidio): declare the stable-token params instead of reading them via getattr getattr returns Any, so each call site counted as an unknown argument type and the pair tripped the basedpyright budget. Every sibling parameter on this call is a declared field; these two now are as well. --- litellm/proxy/guardrails/guardrail_initializers.py | 4 ++-- litellm/types/guardrails.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index efc09bb1669..56bbd8ff103 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -156,8 +156,8 @@ def initialize_presidio(litellm_params: LitellmParams, guardrail: Guardrail) -> presidio_anonymizer_api_base=litellm_params.presidio_anonymizer_api_base, presidio_language=litellm_params.presidio_language, presidio_entities_deny_list=litellm_params.presidio_entities_deny_list, - presidio_stable_tokens=getattr(litellm_params, "presidio_stable_tokens", None), - presidio_token_salt=getattr(litellm_params, "presidio_token_salt", None), + presidio_stable_tokens=litellm_params.presidio_stable_tokens, + presidio_token_salt=litellm_params.presidio_token_salt, apply_to_output=False, timeout=litellm_params.timeout, _callback_role="scan", diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 46026c12d24..d2ec10b0ab5 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -494,6 +494,20 @@ class PresidioConfigModel(PresidioPresidioConfigModelUserInterface): default=None, description="Path to a JSON file containing ad-hoc recognizers for Presidio", ) + presidio_stable_tokens: bool | None = Field( + default=None, + description=( + "Derive PII placeholders from the value instead of its order in the request, " + "so the same value maps to the same token across turns. Requires presidio_token_salt." + ), + ) + presidio_token_salt: str | None = Field( + default=None, + description=( + "os.environ/ reference holding the HMAC key behind stable tokens. " + "A literal is refused: guardrail params are logged in full at debug level." + ), + ) presidio_analyze_chunk_size_bytes: int | None = Field( default=None, description=( From a0b6cd5a67e17e37d957c3316c3150f1aa79667e Mon Sep 17 00:00:00 2001 From: basil-k-aji-dev <70605804+basil-k-aji-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:35:54 +0530 Subject: [PATCH 3/5] feat(presidio): type the guardrail-only params at the read site Declaring them on LitellmParams cleared the basedpyright budget but changed the proxy OpenAPI spec, which then wants _lazy_openapi_snapshot.json and the dashboard's schema.d.ts regenerated. Both are generated from app.openapi(), so producing them in an environment with different optional dependencies risks committing a spec with routes missing. Annotate the reads instead. getattr returning Any is what counted against the unknown-argument budget; a declared type on the local fixes that without touching the public schema. If these belong on LitellmParams, that is a tidier home for them and a separate change. --- litellm/proxy/guardrails/guardrail_initializers.py | 9 +++++++-- litellm/types/guardrails.py | 14 -------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index 56bbd8ff103..413faa11961 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -137,6 +137,11 @@ def initialize_presidio(litellm_params: LitellmParams, guardrail: Guardrail) -> _OPTIONAL_PresidioPIIMasking, ) + # Read through getattr with a declared type: these two are guardrail-only + # params, so they are not fields on LitellmParams, and an unannotated + # getattr returns Any and counts against the unknown-argument budget. + stable_tokens: Final[bool | None] = getattr(litellm_params, "presidio_stable_tokens", None) + token_salt: Final[str | None] = getattr(litellm_params, "presidio_token_salt", None) explicit_filter_scope: Final = litellm_params.presidio_filter_scope filter_scope: Final = explicit_filter_scope or ("input" if _is_mcp_only_mode(litellm_params.mode) else "both") run_input: Final = filter_scope in ("input", "both") @@ -156,8 +161,8 @@ def initialize_presidio(litellm_params: LitellmParams, guardrail: Guardrail) -> presidio_anonymizer_api_base=litellm_params.presidio_anonymizer_api_base, presidio_language=litellm_params.presidio_language, presidio_entities_deny_list=litellm_params.presidio_entities_deny_list, - presidio_stable_tokens=litellm_params.presidio_stable_tokens, - presidio_token_salt=litellm_params.presidio_token_salt, + presidio_stable_tokens=stable_tokens, + presidio_token_salt=token_salt, apply_to_output=False, timeout=litellm_params.timeout, _callback_role="scan", diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index d2ec10b0ab5..46026c12d24 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -494,20 +494,6 @@ class PresidioConfigModel(PresidioPresidioConfigModelUserInterface): default=None, description="Path to a JSON file containing ad-hoc recognizers for Presidio", ) - presidio_stable_tokens: bool | None = Field( - default=None, - description=( - "Derive PII placeholders from the value instead of its order in the request, " - "so the same value maps to the same token across turns. Requires presidio_token_salt." - ), - ) - presidio_token_salt: str | None = Field( - default=None, - description=( - "os.environ/ reference holding the HMAC key behind stable tokens. " - "A literal is refused: guardrail params are logged in full at debug level." - ), - ) presidio_analyze_chunk_size_bytes: int | None = Field( default=None, description=( From b2fcef83cc4279a120b822ef61c67c5e52130344 Mon Sep 17 00:00:00 2001 From: basil-k-aji-dev <70605804+basil-k-aji-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:25:09 +0530 Subject: [PATCH 4/5] feat(presidio): annotate the analyzer-result locals the token suffix reads entity_type and value come out of an untyped analyzer mapping. They only fed an f-string before, where an Any costs nothing; passing them to _stable_token_suffix makes them call arguments, which is what the remaining unknown-argument error was. --- litellm/proxy/guardrails/guardrail_hooks/presidio.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 086468f16bb..2cbb6c4e8ac 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -869,8 +869,10 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): for ar in reversed(sorted_forward): start = ar["start"] end = ar["end"] - entity_type = ar["entity_type"] - value = text[start:end] + # Annotated because the analyzer result is an untyped mapping: both now + # reach _stable_token_suffix as call arguments, where an Any counts. + entity_type: str = ar["entity_type"] + value: str = text[start:end] suffix = ( self._stable_token_suffix(entity_type, value) if self.presidio_stable_tokens From 5eb69ed1e89abb14b635bacb0a17e6b791562c8b Mon Sep 17 00:00:00 2001 From: basil-k-aji-dev <70605804+basil-k-aji-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:39:43 +0530 Subject: [PATCH 5/5] feat(presidio): type the sequence map the fallback suffix reads seq_map was an untyped dict, so the lookup is Any and str() around it is an unknown argument. The old code interpolated the same value into an f-string, where an Any costs nothing, which is why the count moved only once the fallback became a call. basedpyright on this file: 75 reportUnknownArgumentType, the same as on main. --- litellm/proxy/guardrails/guardrail_hooks/presidio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 2cbb6c4e8ac..d5d1382e635 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -859,7 +859,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): # Assign sequence numbers in forward (left-to-right) order so # that is the first entity in the text, etc. sorted_forward: Final = sorted(analyze_results, key=lambda x: x["start"]) - seq_map: Final = {} + seq_map: Final[dict[tuple[int, int], int]] = {} for idx, ar in enumerate(sorted_forward, start=1): seq_map[(ar["start"], ar["end"])] = idx