Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## [Unreleased]

### Added

- **Batch redaction API**: `datafog.redact_many()` scans text fragments
independently while sharing token numbering and pseudonym assignments.
`ScanResult`, `RedactResult`, and `BatchRedactResult` expose computed
`entity_counts` dictionaries so integrations no longer need to recount
result entities.

### Fixed

- **LiteLLM request and response coverage**: the compatibility guardrail now
scans Responses API input/output, top-level instructions and system prompts,
text-completion prompts and choices, tool/function arguments and schemas,
structured-output schemas, Anthropic content, and buffered streaming output.
It supports `during_call` blocking, preserves exact and regex allowlists,
adds locale configuration, keeps legacy option names compatible, and never
yields a streaming fragment before the complete text has been checked.

## [2026-07-05]

### `datafog-python` [4.8.0]
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,18 @@ values never echoed into logs or transcripts:
Manual hook setup and limitations: [examples/claude_code_hook/](examples/claude_code_hook/).

- **LiteLLM guardrail** (`DataFogGuardrail`): redacts or blocks PII in
requests and responses at the gateway, for any LiteLLM-proxied provider.
In-process (~40µs per message scanned; a request clears the guardrail in
well under a millisecond), no sidecar service. Setup:
chat, Responses API, text-completion, tool, schema, and streaming traffic at
the gateway, for any LiteLLM-proxied provider. In-process (~40µs per message
scanned; a request clears the guardrail in well under a millisecond), no
sidecar service. Setup:
[examples/litellm_guardrail/](examples/litellm_guardrail/).

Both default to the high-precision entity set (`EMAIL`, `PHONE`,
- **Batch redaction** (`redact_many`): scans separate text fragments without
joining them, while preserving token numbering and pseudonyms across the
batch. Scan and redaction results expose privacy-safe `entity_counts` for
policy decisions and observability.

Both adapters default to the high-precision entity set (`EMAIL`, `PHONE`,
`CREDIT_CARD`, `SSN`); noisier types are opt-in. Known-safe values can be
exempted with an allowlist: `scan(text, allowlist=[...])` for exact values,
`allowlist_patterns=[...]` for full-match regexes (e.g. `^\d{10}$` to stop
Expand Down
48 changes: 47 additions & 1 deletion datafog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@

# Core API functions - always available (lightweight)
from .core import anonymize_text, detect_pii, get_supported_entities, scan_text
from .engine import Entity, RedactResult, ScanResult
from .engine import BatchRedactResult, Entity, RedactResult, ScanResult
from .engine import redact as _redact_entities
from .engine import redact_many as _redact_many_entities
from .engine import scan as _scan
from .engine import scan_and_redact as _scan_and_redact
from .engine import scan_and_redact_many as _scan_and_redact_many

# Essential models - always available
from .models.common import EntityTypes
Expand Down Expand Up @@ -224,6 +226,48 @@ def redact(
)


def redact_many(
texts: list[str],
entities: list[list[Entity]] | None = None,
engine: str = "regex",
entity_types: list[str] | None = None,
strategy: str = "token",
preset: str | None = None,
locales: list[str] | None = None,
allowlist: list[str] | None = None,
allowlist_patterns: list[str] | None = None,
) -> BatchRedactResult:
"""Redact multiple fragments with stable numbering across the batch.

Detection still runs independently per fragment, so entities cannot span
fragment boundaries. Token counters and pseudonym assignments are shared.
"""
if preset is not None:
try:
strategy = _REDACT_PRESETS[preset]
except KeyError as exc:
allowed = ", ".join(sorted(_REDACT_PRESETS))
raise ValueError(f"preset must be one of: {allowed}") from exc

if entities is not None:
if allowlist or allowlist_patterns:
raise ValueError(
"allowlist/allowlist_patterns cannot be combined with explicit "
"entities; filter the entities before calling redact_many"
)
return _redact_many_entities(texts=texts, entities=entities, strategy=strategy)

return _scan_and_redact_many(
texts=texts,
engine=engine,
entity_types=entity_types,
strategy=strategy,
locales=locales,
allowlist=allowlist,
allowlist_patterns=allowlist_patterns,
)


def protect(
entity_types: list[str] | None = None,
engine: str = "regex",
Expand Down Expand Up @@ -388,8 +432,10 @@ def process(text: str, anonymize: bool = False, method: str = "redact") -> dict:
"Entity",
"ScanResult",
"RedactResult",
"BatchRedactResult",
"scan",
"redact",
"redact_many",
"protect",
"detect",
"process",
Expand Down
127 changes: 125 additions & 2 deletions datafog/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import hashlib
import re
import warnings
from collections.abc import Iterable
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
Expand Down Expand Up @@ -94,6 +95,11 @@ class ScanResult:
text: str
engine_used: str

@property
def entity_counts(self) -> dict[str, int]:
"""Return detected entity totals keyed by canonical entity type."""
return _entity_counts(self.entities)


@dataclass
class RedactResult:
Expand All @@ -103,6 +109,36 @@ class RedactResult:
mapping: dict[str, str]
entities: list[Entity]

@property
def entity_counts(self) -> dict[str, int]:
"""Return redacted entity totals keyed by canonical entity type."""
return _entity_counts(self.entities)


@dataclass
class BatchRedactResult:
"""Result of redacting multiple text fragments as one logical batch."""

redacted_texts: list[str]
mapping: dict[str, str]
entities: list[list[Entity]]

@property
def entity_counts(self) -> dict[str, int]:
"""Return redacted entity totals across every batch fragment."""
return _entity_counts(
entity
for fragment_entities in self.entities
for entity in fragment_entities
)


def _entity_counts(entities: Iterable[Entity]) -> dict[str, int]:
counts: dict[str, int] = {}
for entity in entities:
counts[entity.type] = counts.get(entity.type, 0) + 1
return counts


def _canonical_type(entity_type: str) -> str:
normalized = entity_type.upper().strip()
Expand Down Expand Up @@ -483,10 +519,26 @@ def redact(
if strategy not in {"token", "mask", "hash", "pseudonymize"}:
raise ValueError("strategy must be one of: token, mask, hash, pseudonymize")

return _redact_with_state(
text=text,
entities=entities,
strategy=strategy,
counters={},
pseudonym_by_value={},
)


def _redact_with_state(
text: str,
entities: list[Entity],
strategy: str,
counters: dict[str, int],
pseudonym_by_value: dict[tuple[str, str], str],
) -> RedactResult:
"""Redact one fragment while sharing numbering state with its batch."""

redacted_text = text
mapping: dict[str, str] = {}
counters: dict[str, int] = {}
pseudonym_by_value: dict[tuple[str, str], str] = {}

valid_entities = [
entity
Expand Down Expand Up @@ -539,6 +591,50 @@ def redact(
)


def redact_many(
texts: list[str],
entities: list[list[Entity]],
strategy: str = "token",
) -> BatchRedactResult:
"""Redact text fragments with stable numbering across the whole batch."""
if not isinstance(texts, list) or not all(isinstance(text, str) for text in texts):
raise TypeError("texts must be a list of strings")
if (
not isinstance(entities, list)
or len(entities) != len(texts)
or not all(
isinstance(fragment_entities, list) for fragment_entities in entities
)
):
raise ValueError("entities must contain one entity list per text")
if strategy not in {"token", "mask", "hash", "pseudonymize"}:
raise ValueError("strategy must be one of: token, mask, hash, pseudonymize")

counters: dict[str, int] = {}
pseudonym_by_value: dict[tuple[str, str], str] = {}
redacted_texts: list[str] = []
mapping: dict[str, str] = {}
redacted_entities: list[list[Entity]] = []

for text, fragment_entities in zip(texts, entities, strict=True):
result = _redact_with_state(
text=text,
entities=fragment_entities,
strategy=strategy,
counters=counters,
pseudonym_by_value=pseudonym_by_value,
)
redacted_texts.append(result.redacted_text)
mapping.update(result.mapping)
redacted_entities.append(result.entities)

return BatchRedactResult(
redacted_texts=redacted_texts,
mapping=mapping,
entities=redacted_entities,
)


def scan_and_redact(
text: str,
engine: str = "smart",
Expand All @@ -558,3 +654,30 @@ def scan_and_redact(
allowlist_patterns=allowlist_patterns,
)
return redact(text=text, entities=scan_result.entities, strategy=strategy)


def scan_and_redact_many(
texts: list[str],
engine: str = "smart",
entity_types: Optional[list[str]] = None,
strategy: str = "token",
locales: Optional[list[str]] = None,
allowlist: Optional[list[str]] = None,
allowlist_patterns: Optional[list[str]] = None,
) -> BatchRedactResult:
"""Scan and redact text fragments with batch-stable replacements."""
if not isinstance(texts, list) or not all(isinstance(text, str) for text in texts):
raise TypeError("texts must be a list of strings")

entity_groups = [
scan(
text=text,
engine=engine,
entity_types=entity_types,
locales=locales,
allowlist=allowlist,
allowlist_patterns=allowlist_patterns,
).entities
for text in texts
]
return redact_many(texts=texts, entities=entity_groups, strategy=strategy)
Loading