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.
Files changed (155) hide show
  1. {admina_framework-0.10.1/admina_framework.egg-info → admina_framework-0.11.0}/PKG-INFO +5 -3
  2. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/__init__.py +1 -1
  3. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/types.py +1 -0
  4. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/forensic.py +87 -6
  5. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/governance.py +26 -1
  6. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/engines/__init__.py +16 -6
  7. admina_framework-0.11.0/admina/engines/presidio.py +181 -0
  8. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/cheshirecat/admina-plugin/admina_governance.py +3 -3
  9. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/base.py +24 -0
  10. admina_framework-0.11.0/admina/plugins/builtin/adapters/_streaming.py +49 -0
  11. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/anthropic.py +36 -0
  12. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/ollama.py +24 -0
  13. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/openai.py +33 -0
  14. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/vllm.py +16 -0
  15. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/forensic/filesystem.py +68 -25
  16. admina_framework-0.11.0/admina/proxy/api/gateway.py +400 -0
  17. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/api/integration.py +5 -5
  18. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/config.py +25 -0
  19. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/main.py +48 -1
  20. admina_framework-0.11.0/admina/redteam/__init__.py +72 -0
  21. admina_framework-0.11.0/admina/redteam/baselines/baseline.json +45 -0
  22. admina_framework-0.11.0/admina/redteam/corpora/SHA256SUMS +3 -0
  23. admina_framework-0.11.0/admina/redteam/corpora/injection.jsonl +64 -0
  24. admina_framework-0.11.0/admina/redteam/corpora/loop.jsonl +22 -0
  25. admina_framework-0.11.0/admina/redteam/corpora/pii.jsonl +42 -0
  26. admina_framework-0.11.0/admina/redteam/corpora.py +52 -0
  27. admina_framework-0.11.0/admina/redteam/detectors.py +177 -0
  28. admina_framework-0.11.0/admina/redteam/gate.py +130 -0
  29. admina_framework-0.11.0/admina/redteam/metrics.py +73 -0
  30. admina_framework-0.11.0/admina/redteam/report.py +165 -0
  31. admina_framework-0.11.0/admina/redteam/runner.py +78 -0
  32. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/__init__.py +2 -0
  33. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/governed_model.py +154 -0
  34. admina_framework-0.11.0/admina/sdk/streaming.py +81 -0
  35. {admina_framework-0.10.1 → admina_framework-0.11.0/admina_framework.egg-info}/PKG-INFO +5 -3
  36. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/SOURCES.txt +27 -0
  37. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/requires.txt +5 -2
  38. {admina_framework-0.10.1 → admina_framework-0.11.0}/pyproject.toml +13 -5
  39. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_engines.py +29 -0
  40. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_forensic_blackbox.py +152 -0
  41. admina_framework-0.11.0/tests/test_gateway_config.py +37 -0
  42. admina_framework-0.11.0/tests/test_gateway_endpoints.py +441 -0
  43. admina_framework-0.11.0/tests/test_gateway_guard_fail_mode.py +175 -0
  44. admina_framework-0.11.0/tests/test_gateway_helpers.py +209 -0
  45. admina_framework-0.11.0/tests/test_gateway_mount.py +117 -0
  46. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_governance_pipeline.py +29 -0
  47. admina_framework-0.11.0/tests/test_guard_fail_mode.py +42 -0
  48. admina_framework-0.11.0/tests/test_presidio_engine.py +69 -0
  49. admina_framework-0.11.0/tests/test_proxy_forensic_would_action.py +145 -0
  50. admina_framework-0.11.0/tests/test_proxy_guard_fail_mode.py +205 -0
  51. admina_framework-0.11.0/tests/test_redteam_efficacy.py +54 -0
  52. admina_framework-0.11.0/tests/test_redteam_lib.py +532 -0
  53. {admina_framework-0.10.1 → admina_framework-0.11.0}/LICENSE +0 -0
  54. {admina_framework-0.10.1 → admina_framework-0.11.0}/NOTICE +0 -0
  55. {admina_framework-0.10.1 → admina_framework-0.11.0}/README.md +0 -0
  56. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/__init__.py +0 -0
  57. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/commands/__init__.py +0 -0
  58. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/main.py +0 -0
  59. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/admina.yaml.j2 +0 -0
  60. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/docker-compose.yml.j2 +0 -0
  61. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/env.j2 +0 -0
  62. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/main.py.j2 +0 -0
  63. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/plugin.py.j2 +0 -0
  64. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/plugin_pyproject.toml.j2 +0 -0
  65. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/plugin_readme.md.j2 +0 -0
  66. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/cli/templates/plugin_test.py.j2 +0 -0
  67. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/__init__.py +0 -0
  68. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/config.py +0 -0
  69. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/event_bus.py +0 -0
  70. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/core/secrets.py +0 -0
  71. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/dashboard/__init__.py +0 -0
  72. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/dashboard/static/heimdall.png +0 -0
  73. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/dashboard/static/index.html +0 -0
  74. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/dashboard/static/vendor/alpinejs.min.js +0 -0
  75. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/__init__.py +0 -0
  76. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/agent_security/__init__.py +0 -0
  77. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/agent_security/firewall.py +0 -0
  78. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/agent_security/loop_breaker.py +0 -0
  79. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/ai_infra/__init__.py +0 -0
  80. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/ai_infra/llm_engine.py +0 -0
  81. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/ai_infra/rag.py +0 -0
  82. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/ai_infra/webui.py +0 -0
  83. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/__init__.py +0 -0
  84. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/cross_regulation.py +0 -0
  85. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/eu_ai_act.py +0 -0
  86. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/gdpr.py +0 -0
  87. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/nis2.py +0 -0
  88. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/oisg.py +0 -0
  89. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/compliance/otel.py +0 -0
  90. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/data_sovereignty/__init__.py +0 -0
  91. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/data_sovereignty/classification.py +0 -0
  92. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/data_sovereignty/pii.py +0 -0
  93. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/domains/data_sovereignty/residency.py +0 -0
  94. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/__init__.py +0 -0
  95. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/_engines.py +0 -0
  96. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/cheshirecat/__init__.py +0 -0
  97. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/crewai/__init__.py +0 -0
  98. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/crewai/callbacks.py +0 -0
  99. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/langchain/__init__.py +0 -0
  100. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/langchain/callbacks.py +0 -0
  101. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/n8n/__init__.py +0 -0
  102. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/integrations/openclaw/__init__.py +0 -0
  103. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/__init__.py +0 -0
  104. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/__init__.py +0 -0
  105. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/__init__.py +0 -0
  106. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/bedrock.py +0 -0
  107. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/gemini.py +0 -0
  108. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/adapters/mistral.py +0 -0
  109. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/alerts/__init__.py +0 -0
  110. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/alerts/log.py +0 -0
  111. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/alerts/webhook.py +0 -0
  112. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/auth/__init__.py +0 -0
  113. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/auth/apikey.py +0 -0
  114. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/compliance/__init__.py +0 -0
  115. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/compliance/eu_ai_act.py +0 -0
  116. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/connectors/__init__.py +0 -0
  117. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/connectors/chromadb.py +0 -0
  118. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/connectors/filesystem.py +0 -0
  119. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/forensic/__init__.py +0 -0
  120. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/guards/__init__.py +0 -0
  121. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/guards/guardrailsai_guard.py +0 -0
  122. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/pii/__init__.py +0 -0
  123. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/pii/spacy_regex.py +0 -0
  124. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/transports/__init__.py +0 -0
  125. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/transports/http_rest.py +0 -0
  126. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/builtin/transports/mcp.py +0 -0
  127. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/plugins/registry.py +0 -0
  128. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/__init__.py +0 -0
  129. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/api/__init__.py +0 -0
  130. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/api/dashboard.py +0 -0
  131. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/engine_bridge.py +0 -0
  132. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/governance.py +0 -0
  133. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/multi_upstream.py +0 -0
  134. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/proxy/state.py +0 -0
  135. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/py.typed +0 -0
  136. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/_compat.py +0 -0
  137. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/compliance_kit.py +0 -0
  138. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/errors.py +0 -0
  139. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/governed_agent.py +0 -0
  140. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/governed_data.py +0 -0
  141. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina/sdk/retry.py +0 -0
  142. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/dependency_links.txt +0 -0
  143. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/entry_points.txt +0 -0
  144. {admina_framework-0.10.1 → admina_framework-0.11.0}/admina_framework.egg-info/top_level.txt +0 -0
  145. {admina_framework-0.10.1 → admina_framework-0.11.0}/setup.cfg +0 -0
  146. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_benchmark_14us.py +0 -0
  147. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_core_config.py +0 -0
  148. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_domains.py +0 -0
  149. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_domains_governance.py +0 -0
  150. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_firewall_parity.py +0 -0
  151. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_mcp_transport.py +0 -0
  152. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_proxy_mcp_response_pii.py +0 -0
  153. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_proxy_plugins.py +0 -0
  154. {admina_framework-0.10.1 → admina_framework-0.11.0}/tests/test_proxy_security.py +0 -0
  155. {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.10.1
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.11,>=0.9.3; extra == "rust"
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
  <!--
@@ -23,7 +23,7 @@ from __future__ import annotations
23
23
 
24
24
  from admina.sdk import ComplianceKit, GovernedAgent, GovernedData, GovernedModel
25
25
 
26
- __version__ = "0.10.1"
26
+ __version__ = "0.11.0"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -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
- state = json.loads(obj["Body"].read().decode("utf-8"))
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 Exception: # noqa: BLE001 — NoSuchKey or similar
177
- logger.info("No existing forensic chain state in S3, starting fresh")
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
- state = json.loads(state_path.read_text(encoding="utf-8"))
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 (OSError, json.JSONDecodeError):
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 > admina.yaml ``pii_engine`` >
380
- ``spacy-regex``. An engine selected by name takes precedence over
381
- Rust auto-detection (Rust accelerates only the ``spacy-regex`` path).
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") == "MODIFY" and result.get("redacted_content"):
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") == "MODIFY" and result.get("redacted_content"):
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") == "MODIFY" and result.get("redacted_content"):
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
@@ -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-")