admina-framework 0.10.1__tar.gz → 0.11.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.
- {admina_framework-0.10.1/admina_framework.egg-info → admina_framework-0.11.0}/PKG-INFO +5 -3
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/__init__.py +1 -1
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/types.py +1 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/forensic.py +87 -6
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/governance.py +26 -1
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/engines/__init__.py +16 -6
- admina_framework-0.11.0/admina/engines/presidio.py +181 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/cheshirecat/admina-plugin/admina_governance.py +3 -3
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/base.py +24 -0
- admina_framework-0.11.0/admina/plugins/builtin/adapters/_streaming.py +49 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/anthropic.py +36 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/ollama.py +24 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/openai.py +33 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/vllm.py +16 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/forensic/filesystem.py +68 -25
- admina_framework-0.11.0/admina/proxy/api/gateway.py +400 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/api/integration.py +5 -5
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/config.py +25 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/main.py +48 -1
- admina_framework-0.11.0/admina/redteam/__init__.py +72 -0
- admina_framework-0.11.0/admina/redteam/baselines/baseline.json +45 -0
- admina_framework-0.11.0/admina/redteam/corpora/SHA256SUMS +3 -0
- admina_framework-0.11.0/admina/redteam/corpora/injection.jsonl +64 -0
- admina_framework-0.11.0/admina/redteam/corpora/loop.jsonl +22 -0
- admina_framework-0.11.0/admina/redteam/corpora/pii.jsonl +42 -0
- admina_framework-0.11.0/admina/redteam/corpora.py +52 -0
- admina_framework-0.11.0/admina/redteam/detectors.py +177 -0
- admina_framework-0.11.0/admina/redteam/gate.py +130 -0
- admina_framework-0.11.0/admina/redteam/metrics.py +73 -0
- admina_framework-0.11.0/admina/redteam/report.py +165 -0
- admina_framework-0.11.0/admina/redteam/runner.py +78 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/__init__.py +2 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/governed_model.py +154 -0
- admina_framework-0.11.0/admina/sdk/streaming.py +81 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0/admina_framework.egg-info}/PKG-INFO +5 -3
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/SOURCES.txt +27 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/requires.txt +5 -2
- {admina_framework-0.10.1 → admina_framework-0.11.0}/pyproject.toml +13 -5
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_engines.py +29 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_forensic_blackbox.py +152 -0
- admina_framework-0.11.0/tests/test_gateway_config.py +37 -0
- admina_framework-0.11.0/tests/test_gateway_endpoints.py +441 -0
- admina_framework-0.11.0/tests/test_gateway_guard_fail_mode.py +175 -0
- admina_framework-0.11.0/tests/test_gateway_helpers.py +209 -0
- admina_framework-0.11.0/tests/test_gateway_mount.py +117 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_governance_pipeline.py +29 -0
- admina_framework-0.11.0/tests/test_guard_fail_mode.py +42 -0
- admina_framework-0.11.0/tests/test_presidio_engine.py +69 -0
- admina_framework-0.11.0/tests/test_proxy_forensic_would_action.py +145 -0
- admina_framework-0.11.0/tests/test_proxy_guard_fail_mode.py +205 -0
- admina_framework-0.11.0/tests/test_redteam_efficacy.py +54 -0
- admina_framework-0.11.0/tests/test_redteam_lib.py +532 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/LICENSE +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/NOTICE +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/README.md +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/commands/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/main.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/admina.yaml.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/docker-compose.yml.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/env.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/main.py.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/plugin.py.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/plugin_pyproject.toml.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/plugin_readme.md.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/plugin_test.py.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/config.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/event_bus.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/secrets.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/dashboard/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/dashboard/static/heimdall.png +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/dashboard/static/index.html +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/dashboard/static/vendor/alpinejs.min.js +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/agent_security/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/agent_security/firewall.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/agent_security/loop_breaker.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/ai_infra/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/ai_infra/llm_engine.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/ai_infra/rag.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/ai_infra/webui.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/cross_regulation.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/eu_ai_act.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/gdpr.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/nis2.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/oisg.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/otel.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/data_sovereignty/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/data_sovereignty/classification.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/data_sovereignty/pii.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/data_sovereignty/residency.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/_engines.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/cheshirecat/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/crewai/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/crewai/callbacks.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/langchain/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/langchain/callbacks.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/n8n/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/openclaw/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/bedrock.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/gemini.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/mistral.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/alerts/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/alerts/log.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/alerts/webhook.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/auth/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/auth/apikey.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/compliance/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/compliance/eu_ai_act.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/connectors/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/connectors/chromadb.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/connectors/filesystem.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/forensic/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/guards/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/guards/guardrailsai_guard.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/pii/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/pii/spacy_regex.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/transports/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/transports/http_rest.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/transports/mcp.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/registry.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/api/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/api/dashboard.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/engine_bridge.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/governance.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/multi_upstream.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/state.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/py.typed +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/_compat.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/compliance_kit.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/errors.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/governed_agent.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/governed_data.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/retry.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/dependency_links.txt +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/entry_points.txt +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/top_level.txt +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/setup.cfg +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_benchmark_14us.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_core_config.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_domains.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_domains_governance.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_firewall_parity.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_mcp_transport.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_proxy_mcp_response_pii.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_proxy_plugins.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_proxy_security.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: admina-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Admina — governed AI development framework
|
|
5
5
|
Author-email: Stefano Noferi <info@admina.org>
|
|
6
6
|
Maintainer-email: Stefano Noferi <info@admina.org>
|
|
@@ -52,7 +52,7 @@ Requires-Dist: opentelemetry-sdk<2,>=1.20; extra == "telemetry"
|
|
|
52
52
|
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2,>=1.20; extra == "telemetry"
|
|
53
53
|
Requires-Dist: opentelemetry-instrumentation-fastapi<1,>=0.40b0; extra == "telemetry"
|
|
54
54
|
Provides-Extra: rust
|
|
55
|
-
Requires-Dist: admina-core<0.
|
|
55
|
+
Requires-Dist: admina-core<0.12,>=0.9.3; extra == "rust"
|
|
56
56
|
Provides-Extra: full
|
|
57
57
|
Requires-Dist: admina-framework[nlp,proxy,telemetry]; extra == "full"
|
|
58
58
|
Provides-Extra: openai
|
|
@@ -69,8 +69,10 @@ Provides-Extra: bedrock
|
|
|
69
69
|
Requires-Dist: boto3<2,>=1.34; extra == "bedrock"
|
|
70
70
|
Provides-Extra: adapters
|
|
71
71
|
Requires-Dist: admina-framework[anthropic,bedrock,gemini,mistral,ollama,openai]; extra == "adapters"
|
|
72
|
+
Provides-Extra: presidio
|
|
73
|
+
Requires-Dist: presidio-analyzer<3,>=2.2; extra == "presidio"
|
|
72
74
|
Provides-Extra: all
|
|
73
|
-
Requires-Dist: admina-framework[adapters,nlp,proxy,telemetry]; extra == "all"
|
|
75
|
+
Requires-Dist: admina-framework[adapters,nlp,presidio,proxy,telemetry]; extra == "all"
|
|
74
76
|
Dynamic: license-file
|
|
75
77
|
|
|
76
78
|
<!--
|
|
@@ -63,6 +63,7 @@ class EventType(str, Enum):
|
|
|
63
63
|
# Proxy / MCP events
|
|
64
64
|
MCP_REQUEST = "mcp_request"
|
|
65
65
|
MCP_RESPONSE = "mcp_response"
|
|
66
|
+
GATEWAY_REQUEST = "gateway_request"
|
|
66
67
|
INJECTION_DETECTED = "injection_detected"
|
|
67
68
|
PII_REDACTED = "pii_redacted"
|
|
68
69
|
LOOP_DETECTED = "loop_detected"
|
|
@@ -18,8 +18,10 @@ Hash-chain integrity, immutable audit trail.
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
import hashlib
|
|
21
|
+
import hmac
|
|
21
22
|
import json
|
|
22
23
|
import logging
|
|
24
|
+
import os
|
|
23
25
|
import threading
|
|
24
26
|
import time
|
|
25
27
|
from datetime import UTC, datetime
|
|
@@ -31,6 +33,8 @@ logger = logging.getLogger("admina.forensic_blackbox")
|
|
|
31
33
|
|
|
32
34
|
# Key used to persist the chain state in the object store.
|
|
33
35
|
_CHAIN_STATE_KEY = "_chain_state.json"
|
|
36
|
+
# Sidecar holding the HMAC-SHA256 signature of the chain-state payload.
|
|
37
|
+
_CHAIN_STATE_SIG_KEY = "_chain_state.json.sig"
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
class ForensicBlackBox(BaseForensicStore):
|
|
@@ -63,6 +67,8 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
63
67
|
# Retry policy for transient S3 errors
|
|
64
68
|
s3_max_retries: int = 5,
|
|
65
69
|
s3_base_delay_s: float = 0.2,
|
|
70
|
+
# Optional HMAC key for the chain-state file (else ADMINA_FORENSIC_STATE_KEY)
|
|
71
|
+
state_signing_key: str | None = None,
|
|
66
72
|
):
|
|
67
73
|
self.boto3_client = boto3_client
|
|
68
74
|
self.bucket = bucket
|
|
@@ -75,6 +81,7 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
75
81
|
self.chain_head: str = "GENESIS"
|
|
76
82
|
self.record_count: int = 0
|
|
77
83
|
self._write_lock = threading.Lock()
|
|
84
|
+
self._state_signing_key = state_signing_key or os.environ.get("ADMINA_FORENSIC_STATE_KEY")
|
|
78
85
|
if self.filesystem_dir is not None:
|
|
79
86
|
self.filesystem_dir.mkdir(parents=True, exist_ok=True)
|
|
80
87
|
self._ensure_bucket()
|
|
@@ -160,12 +167,35 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
160
167
|
)
|
|
161
168
|
return True
|
|
162
169
|
|
|
170
|
+
def _read_s3_state_sig(self) -> str | None:
|
|
171
|
+
"""Read the HMAC sidecar object for the chain state (S3), or None."""
|
|
172
|
+
try:
|
|
173
|
+
obj = self.boto3_client.get_object(Bucket=self.bucket, Key=_CHAIN_STATE_SIG_KEY)
|
|
174
|
+
return obj["Body"].read().decode("utf-8").strip()
|
|
175
|
+
except Exception: # noqa: BLE001 — NoSuchKey or similar
|
|
176
|
+
return None
|
|
177
|
+
|
|
163
178
|
def _restore_chain_state(self):
|
|
164
179
|
"""Restore chain_head and record_count from the configured backend."""
|
|
165
180
|
if self.boto3_client is not None:
|
|
166
181
|
try:
|
|
167
182
|
obj = self.boto3_client.get_object(Bucket=self.bucket, Key=_CHAIN_STATE_KEY)
|
|
168
|
-
|
|
183
|
+
payload = obj["Body"].read()
|
|
184
|
+
except Exception: # noqa: BLE001 — NoSuchKey or similar
|
|
185
|
+
logger.info("No existing forensic chain state in S3, starting fresh")
|
|
186
|
+
self._reconstruct_chain_state_from_records()
|
|
187
|
+
return
|
|
188
|
+
if self._state_signing_key and not self._state_sig_is_valid(
|
|
189
|
+
payload, self._read_s3_state_sig()
|
|
190
|
+
):
|
|
191
|
+
logger.critical(
|
|
192
|
+
"Forensic chain state signature INVALID or MISSING (S3) "
|
|
193
|
+
"— possible tampering; reconstructing from records"
|
|
194
|
+
)
|
|
195
|
+
self._reconstruct_chain_state_from_records()
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
state = json.loads(payload)
|
|
169
199
|
self.chain_head = state.get("chain_head", "GENESIS")
|
|
170
200
|
self.record_count = state.get("record_count", 0)
|
|
171
201
|
logger.info(
|
|
@@ -173,15 +203,37 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
173
203
|
self.record_count,
|
|
174
204
|
self.chain_head[:16],
|
|
175
205
|
)
|
|
176
|
-
except
|
|
177
|
-
logger.
|
|
206
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
207
|
+
logger.error("Corrupt forensic chain state (S3) — reconstructing from records")
|
|
178
208
|
self._reconstruct_chain_state_from_records()
|
|
179
209
|
return
|
|
180
210
|
if self.filesystem_dir is not None:
|
|
181
211
|
state_path = self.filesystem_dir / _CHAIN_STATE_KEY
|
|
182
212
|
if state_path.exists():
|
|
183
213
|
try:
|
|
184
|
-
|
|
214
|
+
payload = state_path.read_bytes()
|
|
215
|
+
except OSError:
|
|
216
|
+
logger.error(
|
|
217
|
+
"Cannot read forensic chain state at %s — reconstructing from records",
|
|
218
|
+
state_path,
|
|
219
|
+
)
|
|
220
|
+
self._reconstruct_chain_state_from_records()
|
|
221
|
+
return
|
|
222
|
+
if self._state_signing_key:
|
|
223
|
+
sig_path = self.filesystem_dir / _CHAIN_STATE_SIG_KEY
|
|
224
|
+
signature = (
|
|
225
|
+
sig_path.read_text(encoding="utf-8").strip() if sig_path.exists() else None
|
|
226
|
+
)
|
|
227
|
+
if not self._state_sig_is_valid(payload, signature):
|
|
228
|
+
logger.critical(
|
|
229
|
+
"Forensic chain state signature INVALID or MISSING at %s "
|
|
230
|
+
"— possible tampering; reconstructing from records",
|
|
231
|
+
state_path,
|
|
232
|
+
)
|
|
233
|
+
self._reconstruct_chain_state_from_records()
|
|
234
|
+
return
|
|
235
|
+
try:
|
|
236
|
+
state = json.loads(payload)
|
|
185
237
|
self.chain_head = state.get("chain_head", "GENESIS")
|
|
186
238
|
self.record_count = state.get("record_count", 0)
|
|
187
239
|
logger.info(
|
|
@@ -189,7 +241,7 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
189
241
|
self.record_count,
|
|
190
242
|
self.chain_head[:16],
|
|
191
243
|
)
|
|
192
|
-
except
|
|
244
|
+
except json.JSONDecodeError:
|
|
193
245
|
logger.error(
|
|
194
246
|
"Corrupt forensic chain state at %s — reconstructing from records",
|
|
195
247
|
state_path,
|
|
@@ -220,12 +272,24 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
220
272
|
Body=payload,
|
|
221
273
|
ContentType="application/json",
|
|
222
274
|
)
|
|
275
|
+
sig = self._sign_state_payload(payload)
|
|
276
|
+
if sig is not None:
|
|
277
|
+
self._s3_call(
|
|
278
|
+
self.boto3_client.put_object,
|
|
279
|
+
Bucket=self.bucket,
|
|
280
|
+
Key=_CHAIN_STATE_SIG_KEY,
|
|
281
|
+
Body=sig.encode("utf-8"),
|
|
282
|
+
ContentType="text/plain",
|
|
283
|
+
)
|
|
223
284
|
except Exception as e: # noqa: BLE001
|
|
224
285
|
logger.warning("Failed to persist chain state (S3): %s", e)
|
|
225
286
|
return
|
|
226
287
|
if self.filesystem_dir is not None:
|
|
227
288
|
try:
|
|
228
289
|
(self.filesystem_dir / _CHAIN_STATE_KEY).write_bytes(payload)
|
|
290
|
+
sig = self._sign_state_payload(payload)
|
|
291
|
+
if sig is not None:
|
|
292
|
+
(self.filesystem_dir / _CHAIN_STATE_SIG_KEY).write_text(sig, encoding="utf-8")
|
|
229
293
|
except OSError as e:
|
|
230
294
|
logger.warning("Failed to persist chain state (filesystem): %s", e)
|
|
231
295
|
|
|
@@ -233,6 +297,23 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
233
297
|
"""SHA-256 hash for chain integrity."""
|
|
234
298
|
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
235
299
|
|
|
300
|
+
def _sign_state_payload(self, payload: bytes) -> str | None:
|
|
301
|
+
"""HMAC-SHA256 hex digest of *payload*, or None when no signing key
|
|
302
|
+
is configured (``ADMINA_FORENSIC_STATE_KEY`` unset)."""
|
|
303
|
+
if not self._state_signing_key:
|
|
304
|
+
return None
|
|
305
|
+
return hmac.new(
|
|
306
|
+
self._state_signing_key.encode("utf-8"), payload, hashlib.sha256
|
|
307
|
+
).hexdigest()
|
|
308
|
+
|
|
309
|
+
def _state_sig_is_valid(self, payload: bytes, signature: str | None) -> bool:
|
|
310
|
+
"""True iff a signing key is set and *signature* matches *payload*
|
|
311
|
+
(constant-time)."""
|
|
312
|
+
expected = self._sign_state_payload(payload)
|
|
313
|
+
if expected is None or not signature:
|
|
314
|
+
return False
|
|
315
|
+
return hmac.compare_digest(signature, expected)
|
|
316
|
+
|
|
236
317
|
def record(self, event: dict) -> dict:
|
|
237
318
|
"""
|
|
238
319
|
Record an event to the forensic black box.
|
|
@@ -439,7 +520,7 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
439
520
|
resp = self._s3_call(self.boto3_client.list_objects_v2, **kwargs)
|
|
440
521
|
for item in resp.get("Contents", []) or []:
|
|
441
522
|
key = item["Key"]
|
|
442
|
-
if key.endswith(_CHAIN_STATE_KEY):
|
|
523
|
+
if key.endswith(_CHAIN_STATE_KEY) or key.endswith(_CHAIN_STATE_SIG_KEY):
|
|
443
524
|
continue
|
|
444
525
|
obj = self._s3_call(self.boto3_client.get_object, Bucket=self.bucket, Key=key)
|
|
445
526
|
out.append(json.loads(obj["Body"].read().decode("utf-8")))
|
|
@@ -40,6 +40,20 @@ logger = logging.getLogger("admina.proxy")
|
|
|
40
40
|
_MAX_SCAN_DEPTH = 6
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def normalize_guard_fail_mode(value: str | None) -> str:
|
|
44
|
+
"""Normalize the guard fail-mode setting to ``"open"`` or ``"closed"``.
|
|
45
|
+
|
|
46
|
+
``"open"`` (default): a guard that raises its contract is skipped and
|
|
47
|
+
recorded as an ``ERROR`` check; the pipeline continues. ``"closed"``:
|
|
48
|
+
a guard exception yields ``action=BLOCK``. ``None`` and the empty string
|
|
49
|
+
resolve to ``"open"``. Any other value raises ``ValueError``.
|
|
50
|
+
"""
|
|
51
|
+
normalized = (value or "open").strip().lower()
|
|
52
|
+
if normalized not in ("open", "closed"):
|
|
53
|
+
raise ValueError(f"guard fail mode must be 'open' or 'closed' (got {value!r})")
|
|
54
|
+
return normalized
|
|
55
|
+
|
|
56
|
+
|
|
43
57
|
@dataclass
|
|
44
58
|
class GovernanceResult:
|
|
45
59
|
"""Result of running the full governance pipeline on a request."""
|
|
@@ -72,6 +86,7 @@ async def run_pipeline(
|
|
|
72
86
|
pii_enabled: bool = True,
|
|
73
87
|
loop_enabled: bool = True,
|
|
74
88
|
mode: str = "enforce",
|
|
89
|
+
guard_fail_mode: str = "open",
|
|
75
90
|
) -> GovernanceResult:
|
|
76
91
|
"""Execute the full governance pipeline and return a GovernanceResult.
|
|
77
92
|
|
|
@@ -83,6 +98,11 @@ async def run_pipeline(
|
|
|
83
98
|
``False`` for single-shot callers (e.g. GovernedModel) that have no
|
|
84
99
|
cross-call session state; the stage is skipped and ``loop_result`` is
|
|
85
100
|
set to a no-op dict so downstream code is unaffected.
|
|
101
|
+
|
|
102
|
+
``guard_fail_mode`` selects request-side behavior when a governance guard
|
|
103
|
+
raises: ``"open"`` (default) records an ``ERROR`` check and continues;
|
|
104
|
+
``"closed"`` sets ``action=BLOCK`` (risk HIGH) while still recording the
|
|
105
|
+
``ERROR`` check.
|
|
86
106
|
"""
|
|
87
107
|
start_time = time.perf_counter()
|
|
88
108
|
result = GovernanceResult()
|
|
@@ -137,11 +157,16 @@ async def run_pipeline(
|
|
|
137
157
|
exc,
|
|
138
158
|
exc_info=True,
|
|
139
159
|
)
|
|
140
|
-
# follow-up: optional fail-closed mode
|
|
141
160
|
result.checks[f"guard_{guard.name}"] = {
|
|
142
161
|
"action": "ERROR",
|
|
143
162
|
"error": str(exc),
|
|
144
163
|
}
|
|
164
|
+
if guard_fail_mode == "closed":
|
|
165
|
+
# Fail-closed: a guard that breaks its contract blocks the
|
|
166
|
+
# request. The ERROR entry above stays as the audit trail.
|
|
167
|
+
result.action = GovernanceAction.BLOCK
|
|
168
|
+
result.risk_level = RiskLevel.HIGH
|
|
169
|
+
break
|
|
145
170
|
|
|
146
171
|
# 5. Apply governance MODE — observe / dry-run downgrade BLOCK to ALLOW
|
|
147
172
|
# but record what would have happened in `would_action` so dashboards
|
|
@@ -373,13 +373,25 @@ def _spacy_regex_pii() -> PIIBridge:
|
|
|
373
373
|
_PII_ENGINE_FACTORIES["spacy-regex"] = _spacy_regex_pii
|
|
374
374
|
|
|
375
375
|
|
|
376
|
+
def _presidio_pii() -> PIIBridge:
|
|
377
|
+
from admina.engines.presidio import get_presidio_pii_engine
|
|
378
|
+
|
|
379
|
+
return get_presidio_pii_engine()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
_PII_ENGINE_FACTORIES["presidio"] = _presidio_pii
|
|
383
|
+
|
|
384
|
+
|
|
376
385
|
def get_pii_engine(name: str | None = None) -> PIIBridge:
|
|
377
386
|
"""Get the configured PII engine.
|
|
378
387
|
|
|
379
|
-
Resolution order: explicit *name* arg >
|
|
380
|
-
``spacy-regex``. An engine selected by name
|
|
381
|
-
Rust auto-detection (Rust accelerates only the
|
|
388
|
+
Resolution order: explicit *name* arg > ``ADMINA_PII_ENGINE`` env >
|
|
389
|
+
admina.yaml ``pii_engine`` > ``spacy-regex``. An engine selected by name
|
|
390
|
+
takes precedence over Rust auto-detection (Rust accelerates only the
|
|
391
|
+
``spacy-regex`` path).
|
|
382
392
|
"""
|
|
393
|
+
if name is None:
|
|
394
|
+
name = os.environ.get("ADMINA_PII_ENGINE") or None
|
|
383
395
|
if name is None:
|
|
384
396
|
try:
|
|
385
397
|
from admina.core.config import load_config
|
|
@@ -391,9 +403,7 @@ def get_pii_engine(name: str | None = None) -> PIIBridge:
|
|
|
391
403
|
factory = _PII_ENGINE_FACTORIES.get(name)
|
|
392
404
|
if factory is None:
|
|
393
405
|
raise ValueError(
|
|
394
|
-
f"Unknown pii_engine {name!r}. Available: "
|
|
395
|
-
f"{sorted(_PII_ENGINE_FACTORIES)}. "
|
|
396
|
-
"(A Presidio-backed engine is planned for a later release.)"
|
|
406
|
+
f"Unknown pii_engine {name!r}. Available: {sorted(_PII_ENGINE_FACTORIES)}."
|
|
397
407
|
)
|
|
398
408
|
return factory()
|
|
399
409
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Admina — Microsoft Presidio PII engine (analyzer-only).
|
|
16
|
+
|
|
17
|
+
Presidio performs *detection*; Admina performs the *masking*, so the output
|
|
18
|
+
format is identical to the spaCy+regex engine (the per-category mask from
|
|
19
|
+
``PII_CATEGORIES``, e.g. ``[EMAIL]`` / ``[PERSON]``). Registered on the
|
|
20
|
+
synchronous ``PIIBridge`` factory path (admina/engines/__init__.py), NOT the
|
|
21
|
+
async ``BasePIIEngine`` plugin ABC.
|
|
22
|
+
|
|
23
|
+
Languages: EN + IT. Each configured language whose spaCy model is installed is
|
|
24
|
+
analyzed and the results are unioned, so an entity a recognizer only registers
|
|
25
|
+
for one language (e.g. IT_FISCAL_CODE under "it") is still caught. Presidio and
|
|
26
|
+
its models are an optional extra:
|
|
27
|
+
pip install 'admina-framework[presidio]'
|
|
28
|
+
python -m spacy download en_core_web_sm it_core_news_sm
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from functools import lru_cache
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from admina.domains.data_sovereignty.pii import PII_CATEGORIES
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("admina.engines.presidio")
|
|
40
|
+
|
|
41
|
+
# Presidio entity_type -> Admina PII_CATEGORIES key. Unmapped Presidio types
|
|
42
|
+
# (DATE_TIME, URL, NRP, ...) are intentionally ignored so the category set and
|
|
43
|
+
# false-positive profile stay aligned with the spaCy+regex engine.
|
|
44
|
+
_PRESIDIO_TO_ADMINA: dict[str, str] = {
|
|
45
|
+
"EMAIL_ADDRESS": "EMAIL",
|
|
46
|
+
"PHONE_NUMBER": "PHONE",
|
|
47
|
+
"CREDIT_CARD": "CREDIT_CARD",
|
|
48
|
+
"IBAN_CODE": "IBAN",
|
|
49
|
+
"IP_ADDRESS": "IP_ADDRESS",
|
|
50
|
+
"US_SSN": "SSN",
|
|
51
|
+
"PERSON": "PERSON",
|
|
52
|
+
"LOCATION": "GPE",
|
|
53
|
+
"ORGANIZATION": "ORG",
|
|
54
|
+
"IT_FISCAL_CODE": "IT_CODICE_FISCALE",
|
|
55
|
+
"ES_NIF": "ES_DNI_NIE",
|
|
56
|
+
"ES_NIE": "ES_DNI_NIE",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Languages Admina configures for Presidio, each mapped to its spaCy model.
|
|
60
|
+
_LANG_MODELS: dict[str, str] = {
|
|
61
|
+
"en": "en_core_web_sm",
|
|
62
|
+
"it": "it_core_news_sm",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_analyzer(languages: tuple[str, ...]):
|
|
67
|
+
"""Build a Presidio AnalyzerEngine over the given (installed) languages."""
|
|
68
|
+
from presidio_analyzer import AnalyzerEngine
|
|
69
|
+
from presidio_analyzer.nlp_engine import NlpEngineProvider
|
|
70
|
+
|
|
71
|
+
configuration = {
|
|
72
|
+
"nlp_engine_name": "spacy",
|
|
73
|
+
"models": [{"lang_code": lang, "model_name": _LANG_MODELS[lang]} for lang in languages],
|
|
74
|
+
}
|
|
75
|
+
nlp_engine = NlpEngineProvider(nlp_configuration=configuration).create_engine()
|
|
76
|
+
return AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=list(languages))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PresidioPIIEngine:
|
|
80
|
+
"""Synchronous ``PIIBridge`` backed by Microsoft Presidio (analyzer-only)."""
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
try:
|
|
84
|
+
import presidio_analyzer # noqa: F401
|
|
85
|
+
except ImportError as exc:
|
|
86
|
+
raise ImportError(
|
|
87
|
+
"The Presidio PII engine requires the [presidio] extra. Install it with "
|
|
88
|
+
"`pip install 'admina-framework[presidio]'` and download the models "
|
|
89
|
+
"`python -m spacy download en_core_web_sm it_core_news_sm`."
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
import spacy
|
|
93
|
+
|
|
94
|
+
languages = tuple(
|
|
95
|
+
lang for lang, model in _LANG_MODELS.items() if spacy.util.is_package(model)
|
|
96
|
+
)
|
|
97
|
+
if not languages:
|
|
98
|
+
raise ImportError(
|
|
99
|
+
"Presidio is installed but no supported spaCy model is present. Run "
|
|
100
|
+
"`python -m spacy download en_core_web_sm it_core_news_sm`."
|
|
101
|
+
)
|
|
102
|
+
self.languages: list[str] = list(languages)
|
|
103
|
+
self._analyzer = _build_analyzer(languages)
|
|
104
|
+
self.total_redacted: int = 0
|
|
105
|
+
self.redactions_by_type: dict[str, int] = {}
|
|
106
|
+
logger.info("[OK] Presidio PII engine ready (languages: %s)", self.languages)
|
|
107
|
+
|
|
108
|
+
def redact(self, text: str) -> dict[str, Any]:
|
|
109
|
+
if not text:
|
|
110
|
+
return {"redacted_text": text, "entities": [], "categories": [], "count": 0}
|
|
111
|
+
|
|
112
|
+
# Union of per-language analyses: a recognizer registered for only one
|
|
113
|
+
# language is missed by the other pass, so both are run and merged.
|
|
114
|
+
raw = []
|
|
115
|
+
for lang in self.languages:
|
|
116
|
+
raw.extend(self._analyzer.analyze(text=text, language=lang))
|
|
117
|
+
|
|
118
|
+
spans: list[tuple[int, int, str, str]] = []
|
|
119
|
+
for r in raw:
|
|
120
|
+
category = _PRESIDIO_TO_ADMINA.get(r.entity_type)
|
|
121
|
+
if category is None:
|
|
122
|
+
continue
|
|
123
|
+
cfg = PII_CATEGORIES.get(category, {})
|
|
124
|
+
if not cfg.get("enabled", False):
|
|
125
|
+
continue
|
|
126
|
+
spans.append((r.start, r.end, category, cfg.get("mask", f"[{category}]")))
|
|
127
|
+
|
|
128
|
+
# Resolve overlaps deterministically: earliest start first, longest on
|
|
129
|
+
# ties; greedily drop any span overlapping an already-accepted one. This
|
|
130
|
+
# also dedupes the same span produced by two language passes.
|
|
131
|
+
spans.sort(key=lambda s: (s[0], -(s[1] - s[0])))
|
|
132
|
+
accepted: list[tuple[int, int, str, str]] = []
|
|
133
|
+
last_end = -1
|
|
134
|
+
for start, end, category, mask in spans:
|
|
135
|
+
if start >= last_end:
|
|
136
|
+
accepted.append((start, end, category, mask))
|
|
137
|
+
last_end = end
|
|
138
|
+
|
|
139
|
+
entities = [
|
|
140
|
+
{
|
|
141
|
+
"type": category,
|
|
142
|
+
"start": start,
|
|
143
|
+
"end": end,
|
|
144
|
+
"original_length": end - start,
|
|
145
|
+
"method": "presidio",
|
|
146
|
+
}
|
|
147
|
+
for (start, end, category, mask) in accepted
|
|
148
|
+
]
|
|
149
|
+
redacted = text
|
|
150
|
+
for start, end, _category, mask in sorted(accepted, key=lambda s: s[0], reverse=True):
|
|
151
|
+
redacted = redacted[:start] + mask + redacted[end:]
|
|
152
|
+
|
|
153
|
+
count = len(entities)
|
|
154
|
+
if count:
|
|
155
|
+
self.total_redacted += count
|
|
156
|
+
for ent in entities:
|
|
157
|
+
self.redactions_by_type[ent["type"]] = (
|
|
158
|
+
self.redactions_by_type.get(ent["type"], 0) + 1
|
|
159
|
+
)
|
|
160
|
+
categories = sorted({ent["type"] for ent in entities})
|
|
161
|
+
return {
|
|
162
|
+
"redacted_text": redacted,
|
|
163
|
+
"entities": entities,
|
|
164
|
+
"categories": categories,
|
|
165
|
+
"count": count,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def get_stats(self) -> dict[str, Any]:
|
|
169
|
+
return {
|
|
170
|
+
"total_redacted": self.total_redacted,
|
|
171
|
+
"redactions_by_type": self.redactions_by_type,
|
|
172
|
+
"spacy_available": True,
|
|
173
|
+
"engine": "presidio",
|
|
174
|
+
"languages": list(self.languages),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@lru_cache(maxsize=1)
|
|
179
|
+
def get_presidio_pii_engine() -> PresidioPIIEngine:
|
|
180
|
+
"""Return a process-wide cached Presidio engine (analyzer construction is costly)."""
|
|
181
|
+
return PresidioPIIEngine()
|
|
@@ -117,7 +117,7 @@ def agent_fast_reply(fast_reply: dict, cat) -> dict:
|
|
|
117
117
|
)
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
if result.get("action") == "
|
|
120
|
+
if result.get("action") == "REDACT" and result.get("redacted_content"):
|
|
121
121
|
# Replace PII in the working memory so the LLM never sees it
|
|
122
122
|
user_msg["text"] = result["redacted_content"]
|
|
123
123
|
cat.working_memory["user_message_json"] = user_msg
|
|
@@ -149,7 +149,7 @@ def before_cat_sends_message(message: dict, cat) -> dict:
|
|
|
149
149
|
result = _validate(text, session_id=session_id)
|
|
150
150
|
|
|
151
151
|
if result is not None:
|
|
152
|
-
if result.get("action") == "
|
|
152
|
+
if result.get("action") == "REDACT" and result.get("redacted_content"):
|
|
153
153
|
message["content"] = result["redacted_content"]
|
|
154
154
|
|
|
155
155
|
if result.get("action") == "BLOCK":
|
|
@@ -201,7 +201,7 @@ def before_cat_recalls_memories(default_query: str, cat) -> str:
|
|
|
201
201
|
)
|
|
202
202
|
return "" # empty query = no retrieval
|
|
203
203
|
|
|
204
|
-
if result.get("action") == "
|
|
204
|
+
if result.get("action") == "REDACT" and result.get("redacted_content"):
|
|
205
205
|
return result["redacted_content"]
|
|
206
206
|
|
|
207
207
|
return default_query
|
|
@@ -29,6 +29,7 @@ it in ``admina.yaml`` under the ``plugins:`` section.
|
|
|
29
29
|
from __future__ import annotations
|
|
30
30
|
|
|
31
31
|
from abc import ABC, abstractmethod
|
|
32
|
+
from collections.abc import AsyncIterator
|
|
32
33
|
from typing import TYPE_CHECKING, Any
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
@@ -102,6 +103,29 @@ class BaseModelAdapter(ABC):
|
|
|
102
103
|
model_name: A model identifier, e.g. ``"llama3"`` or ``"gpt-4o"``.
|
|
103
104
|
"""
|
|
104
105
|
|
|
106
|
+
async def send_stream(
|
|
107
|
+
self,
|
|
108
|
+
prompt: str,
|
|
109
|
+
context: Any = None,
|
|
110
|
+
**kwargs: Any,
|
|
111
|
+
) -> AsyncIterator[str]:
|
|
112
|
+
"""Stream text deltas from the model.
|
|
113
|
+
|
|
114
|
+
Base default: a non-streaming fallback that calls :meth:`send`
|
|
115
|
+
once and yields the whole text as a single chunk. Adapters backed
|
|
116
|
+
by SDKs with mature streaming override this to yield incrementally.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
prompt: The text prompt to send.
|
|
120
|
+
context: Optional conversation context or system prompt.
|
|
121
|
+
**kwargs: Provider-specific parameters (temperature, model, …).
|
|
122
|
+
|
|
123
|
+
Yields:
|
|
124
|
+
Raw (un-redacted) text deltas. The fallback yields exactly one.
|
|
125
|
+
"""
|
|
126
|
+
result = await self.send(prompt, context=context, **kwargs)
|
|
127
|
+
yield result.get("text", "")
|
|
128
|
+
|
|
105
129
|
@property
|
|
106
130
|
@abstractmethod
|
|
107
131
|
def name(self) -> str:
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Shared sync→async streaming bridge for the built-in adapters.
|
|
16
|
+
|
|
17
|
+
The provider SDKs (openai, ollama, anthropic) expose *blocking* streaming
|
|
18
|
+
iterators. ``aiter_sync`` builds the iterator and pulls each item off the
|
|
19
|
+
event loop via ``asyncio.to_thread`` so a slow network read never blocks
|
|
20
|
+
other coroutines.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from collections.abc import AsyncIterator, Callable, Iterator
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
__all__ = ["aiter_sync"]
|
|
30
|
+
|
|
31
|
+
_SENTINEL = object()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def aiter_sync(make_iter: Callable[[], Iterator[Any]]) -> AsyncIterator[Any]:
|
|
35
|
+
"""Bridge a blocking iterator factory to an async iterator.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
make_iter: No-arg callable that (blockingly) creates and returns the
|
|
39
|
+
provider's streaming iterator.
|
|
40
|
+
|
|
41
|
+
Yields:
|
|
42
|
+
Items from the iterator, each fetched in a worker thread.
|
|
43
|
+
"""
|
|
44
|
+
iterator = await asyncio.to_thread(make_iter)
|
|
45
|
+
while True:
|
|
46
|
+
item = await asyncio.to_thread(next, iterator, _SENTINEL)
|
|
47
|
+
if item is _SENTINEL:
|
|
48
|
+
return
|
|
49
|
+
yield item
|
{admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/anthropic.py
RENAMED
|
@@ -29,6 +29,7 @@ import time
|
|
|
29
29
|
from typing import Any
|
|
30
30
|
|
|
31
31
|
from admina.plugins.base import BaseModelAdapter
|
|
32
|
+
from admina.plugins.builtin.adapters._streaming import aiter_sync
|
|
32
33
|
|
|
33
34
|
logger = logging.getLogger("admina.plugins.adapters.anthropic")
|
|
34
35
|
|
|
@@ -137,6 +138,41 @@ class AnthropicAdapter(BaseModelAdapter):
|
|
|
137
138
|
},
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
async def send_stream(
|
|
142
|
+
self,
|
|
143
|
+
prompt: str,
|
|
144
|
+
context: Any = None,
|
|
145
|
+
**kwargs: Any,
|
|
146
|
+
) -> Any:
|
|
147
|
+
"""Stream text deltas from the Anthropic Messages API."""
|
|
148
|
+
client = self._get_client()
|
|
149
|
+
model = kwargs.pop("model", None) or self._default_model
|
|
150
|
+
if not model:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
"AnthropicAdapter needs a model: pass model=... or set ADMINA_ANTHROPIC_MODEL"
|
|
153
|
+
)
|
|
154
|
+
max_tokens = kwargs.pop("max_tokens", self._max_tokens)
|
|
155
|
+
|
|
156
|
+
messages = [{"role": "user", "content": prompt}]
|
|
157
|
+
create_kwargs: dict[str, Any] = {
|
|
158
|
+
"model": model,
|
|
159
|
+
"max_tokens": max_tokens,
|
|
160
|
+
"messages": messages,
|
|
161
|
+
"stream": True,
|
|
162
|
+
**kwargs,
|
|
163
|
+
}
|
|
164
|
+
if context:
|
|
165
|
+
create_kwargs["system"] = str(context)
|
|
166
|
+
|
|
167
|
+
def _make() -> Any:
|
|
168
|
+
return client.messages.create(**create_kwargs)
|
|
169
|
+
|
|
170
|
+
async for event in aiter_sync(_make):
|
|
171
|
+
if getattr(event, "type", None) == "content_block_delta":
|
|
172
|
+
text = getattr(getattr(event, "delta", None), "text", None)
|
|
173
|
+
if text:
|
|
174
|
+
yield text
|
|
175
|
+
|
|
140
176
|
def supports_model(self, model_name: str) -> bool:
|
|
141
177
|
"""Return ``True`` for model names with the ``claude-`` prefix."""
|
|
142
178
|
return model_name.startswith("claude-")
|