admina-framework 0.12.1__tar.gz → 0.12.2__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 (174) hide show
  1. {admina_framework-0.12.1/admina_framework.egg-info → admina_framework-0.12.2}/PKG-INFO +1 -1
  2. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/__init__.py +1 -1
  3. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/dashboard/static/index.html +11 -3
  4. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/forensic.py +21 -0
  5. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/engines/presidio.py +6 -1
  6. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/api/dashboard.py +180 -29
  7. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/api/gateway.py +165 -4
  8. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/main.py +1 -0
  9. {admina_framework-0.12.1 → admina_framework-0.12.2/admina_framework.egg-info}/PKG-INFO +1 -1
  10. {admina_framework-0.12.1 → admina_framework-0.12.2}/pyproject.toml +1 -1
  11. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_forensic_blackbox.py +41 -0
  12. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_endpoints.py +331 -0
  13. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_mount.py +71 -0
  14. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_presidio_engine.py +27 -0
  15. {admina_framework-0.12.1 → admina_framework-0.12.2}/LICENSE +0 -0
  16. {admina_framework-0.12.1 → admina_framework-0.12.2}/NOTICE +0 -0
  17. {admina_framework-0.12.1 → admina_framework-0.12.2}/README.md +0 -0
  18. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/__init__.py +0 -0
  19. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/commands/__init__.py +0 -0
  20. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/main.py +0 -0
  21. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/admina.yaml.j2 +0 -0
  22. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/docker-compose.yml.j2 +0 -0
  23. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/env.j2 +0 -0
  24. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/main.py.j2 +0 -0
  25. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/plugin.py.j2 +0 -0
  26. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/plugin_pyproject.toml.j2 +0 -0
  27. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/plugin_readme.md.j2 +0 -0
  28. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/plugin_test.py.j2 +0 -0
  29. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/__init__.py +0 -0
  30. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/config.py +0 -0
  31. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/event_bus.py +0 -0
  32. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/secrets.py +0 -0
  33. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/types.py +0 -0
  34. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/dashboard/__init__.py +0 -0
  35. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/dashboard/static/heimdall.png +0 -0
  36. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/dashboard/static/vendor/alpinejs.min.js +0 -0
  37. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/__init__.py +0 -0
  38. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/__init__.py +0 -0
  39. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/coordination.py +0 -0
  40. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/egress.py +0 -0
  41. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/fingerprint.py +0 -0
  42. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/firewall.py +0 -0
  43. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/loop_breaker.py +0 -0
  44. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/ai_infra/__init__.py +0 -0
  45. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/ai_infra/llm_engine.py +0 -0
  46. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/ai_infra/rag.py +0 -0
  47. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/ai_infra/webui.py +0 -0
  48. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/__init__.py +0 -0
  49. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/cross_regulation.py +0 -0
  50. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/eu_ai_act.py +0 -0
  51. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/gdpr.py +0 -0
  52. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/nis2.py +0 -0
  53. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/oisg.py +0 -0
  54. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/otel.py +0 -0
  55. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/data_sovereignty/__init__.py +0 -0
  56. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/data_sovereignty/classification.py +0 -0
  57. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/data_sovereignty/pii.py +0 -0
  58. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/data_sovereignty/residency.py +0 -0
  59. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/governance.py +0 -0
  60. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/engines/__init__.py +0 -0
  61. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/__init__.py +0 -0
  62. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/_engines.py +0 -0
  63. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/cheshirecat/__init__.py +0 -0
  64. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/cheshirecat/admina-plugin/admina_governance.py +0 -0
  65. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/crewai/__init__.py +0 -0
  66. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/crewai/callbacks.py +0 -0
  67. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/langchain/__init__.py +0 -0
  68. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/langchain/callbacks.py +0 -0
  69. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/n8n/__init__.py +0 -0
  70. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/openclaw/__init__.py +0 -0
  71. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/__init__.py +0 -0
  72. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/base.py +0 -0
  73. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/__init__.py +0 -0
  74. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/__init__.py +0 -0
  75. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/_streaming.py +0 -0
  76. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/anthropic.py +0 -0
  77. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/bedrock.py +0 -0
  78. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/gemini.py +0 -0
  79. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/mistral.py +0 -0
  80. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/ollama.py +0 -0
  81. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/openai.py +0 -0
  82. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/vllm.py +0 -0
  83. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/alerts/__init__.py +0 -0
  84. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/alerts/log.py +0 -0
  85. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/alerts/webhook.py +0 -0
  86. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/auth/__init__.py +0 -0
  87. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/auth/apikey.py +0 -0
  88. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/compliance/__init__.py +0 -0
  89. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/compliance/eu_ai_act.py +0 -0
  90. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/connectors/__init__.py +0 -0
  91. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/connectors/chromadb.py +0 -0
  92. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/connectors/filesystem.py +0 -0
  93. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/forensic/__init__.py +0 -0
  94. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/forensic/filesystem.py +0 -0
  95. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/guards/__init__.py +0 -0
  96. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/guards/guardrailsai_guard.py +0 -0
  97. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/pii/__init__.py +0 -0
  98. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/pii/spacy_regex.py +0 -0
  99. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/transports/__init__.py +0 -0
  100. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/transports/http_rest.py +0 -0
  101. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/transports/mcp.py +0 -0
  102. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/registry.py +0 -0
  103. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/__init__.py +0 -0
  104. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/api/__init__.py +0 -0
  105. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/api/integration.py +0 -0
  106. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/config.py +0 -0
  107. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/dashboard_session.py +0 -0
  108. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/engine_bridge.py +0 -0
  109. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/governance.py +0 -0
  110. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/multi_upstream.py +0 -0
  111. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/state.py +0 -0
  112. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/py.typed +0 -0
  113. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/__init__.py +0 -0
  114. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/baselines/baseline.json +0 -0
  115. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/SHA256SUMS +0 -0
  116. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/coordination.jsonl +0 -0
  117. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/injection.jsonl +0 -0
  118. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/loop.jsonl +0 -0
  119. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/pii.jsonl +0 -0
  120. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora.py +0 -0
  121. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/detectors.py +0 -0
  122. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/gate.py +0 -0
  123. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/metrics.py +0 -0
  124. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/report.py +0 -0
  125. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/runner.py +0 -0
  126. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/__init__.py +0 -0
  127. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/_compat.py +0 -0
  128. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/compliance_kit.py +0 -0
  129. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/errors.py +0 -0
  130. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/governed_agent.py +0 -0
  131. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/governed_data.py +0 -0
  132. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/governed_model.py +0 -0
  133. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/retry.py +0 -0
  134. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/streaming.py +0 -0
  135. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/SOURCES.txt +0 -0
  136. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/dependency_links.txt +0 -0
  137. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/entry_points.txt +0 -0
  138. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/requires.txt +0 -0
  139. {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/top_level.txt +0 -0
  140. {admina_framework-0.12.1 → admina_framework-0.12.2}/setup.cfg +0 -0
  141. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_benchmark_14us.py +0 -0
  142. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_coordination.py +0 -0
  143. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_coordination_cli.py +0 -0
  144. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_coordination_corpus.py +0 -0
  145. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_coordination_wiring.py +0 -0
  146. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_core_config.py +0 -0
  147. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_domains.py +0 -0
  148. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_domains_governance.py +0 -0
  149. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_analyze.py +0 -0
  150. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_cli.py +0 -0
  151. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_pipeline.py +0 -0
  152. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_policy.py +0 -0
  153. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_surfaces.py +0 -0
  154. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_engines.py +0 -0
  155. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_error_disclosure.py +0 -0
  156. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_fingerprint.py +0 -0
  157. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_firewall_parity.py +0 -0
  158. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_config.py +0 -0
  159. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_guard_fail_mode.py +0 -0
  160. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_helpers.py +0 -0
  161. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_governance_pipeline.py +0 -0
  162. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_guard_fail_mode.py +0 -0
  163. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_mcp_transport.py +0 -0
  164. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_dashboard_session.py +0 -0
  165. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_forensic_would_action.py +0 -0
  166. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_governance_mode_config.py +0 -0
  167. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_guard_fail_mode.py +0 -0
  168. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_mcp_response_pii.py +0 -0
  169. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_metrics_endpoint.py +0 -0
  170. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_plugins.py +0 -0
  171. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_security.py +0 -0
  172. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_redteam_efficacy.py +0 -0
  173. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_redteam_lib.py +0 -0
  174. {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: admina-framework
3
- Version: 0.12.1
3
+ Version: 0.12.2
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>
@@ -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.12.1"
26
+ __version__ = "0.12.2"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -735,12 +735,14 @@
735
735
  </p>
736
736
  <p>
737
737
  Until you submit a real assessment, the dashboard renders all articles at 0% with a default
738
- placeholder. To record your real coverage, POST your declared evidence to
739
- <code class="inline">/api/compliance/gap-analysis</code>.
738
+ placeholder. To record your real coverage, send your declared evidence with a
739
+ <strong>POST</strong> request to <code class="inline">/api/compliance/gap-analysis</code>
740
+ on this host (the route accepts POST only: opening it in the browser answers
741
+ 405 Method Not Allowed).
740
742
  </p>
741
743
  <p style="margin-bottom: 6px;"><strong>Example request:</strong></p>
742
744
  <button class="modal-copy-btn" @click="copyAssessmentCmd($event)">Copy command</button>
743
- <pre class="modal-pre" id="assessment-cmd">curl -X POST http://localhost:8080/api/compliance/gap-analysis \
745
+ <pre class="modal-pre" id="assessment-cmd">curl -X POST <span x-text="apiBase"></span>/api/compliance/gap-analysis \
744
746
  -H "X-API-Key: $ADMINA_API_KEY" -H "Content-Type: application/json" \
745
747
  -d '{"risk_category": "high",
746
748
  "current_compliance": {
@@ -1153,6 +1155,12 @@ function dashboard() {
1153
1155
  },
1154
1156
 
1155
1157
  // ── Helpers ─────────────────────────────────────────
1158
+ // The origin that served this page: the proxy itself, or the dashboard
1159
+ // container that forwards /api/ to it.
1160
+ get apiBase() {
1161
+ return window.location.origin;
1162
+ },
1163
+
1156
1164
  copyAssessmentCmd(ev) {
1157
1165
  const pre = document.getElementById('assessment-cmd');
1158
1166
  if (!pre) return;
@@ -24,6 +24,7 @@ import logging
24
24
  import os
25
25
  import threading
26
26
  import time
27
+ from collections import deque
27
28
  from datetime import UTC, datetime
28
29
  from pathlib import Path
29
30
 
@@ -35,6 +36,8 @@ logger = logging.getLogger("admina.forensic_blackbox")
35
36
  _CHAIN_STATE_KEY = "_chain_state.json"
36
37
  # Sidecar holding the HMAC-SHA256 signature of the chain-state payload.
37
38
  _CHAIN_STATE_SIG_KEY = "_chain_state.json.sig"
39
+ # Records kept in memory for recent_records(), newest last.
40
+ RECENT_RECORDS_MAX = 1000
38
41
 
39
42
 
40
43
  class ForensicBlackBox(BaseForensicStore):
@@ -81,6 +84,10 @@ class ForensicBlackBox(BaseForensicStore):
81
84
  self.chain_head: str = "GENESIS"
82
85
  self.record_count: int = 0
83
86
  self._write_lock = threading.Lock()
87
+ # The last records written by this process, for readers that need
88
+ # recent activity without reading the backend back (the dashboard
89
+ # feed when no analytics store is configured).
90
+ self._recent: deque[dict] = deque(maxlen=RECENT_RECORDS_MAX)
84
91
  self._state_signing_key = state_signing_key or os.environ.get("ADMINA_FORENSIC_STATE_KEY")
85
92
  if self.filesystem_dir is not None:
86
93
  self.filesystem_dir.mkdir(parents=True, exist_ok=True)
@@ -346,6 +353,7 @@ class ForensicBlackBox(BaseForensicStore):
346
353
  # Store the record and persist the updated chain state
347
354
  self._store_to_s3(forensic_record)
348
355
  self._persist_chain_state()
356
+ self._recent.append(forensic_record)
349
357
 
350
358
  return {
351
359
  "sequence_number": self.record_count,
@@ -354,6 +362,19 @@ class ForensicBlackBox(BaseForensicStore):
354
362
  "stored": (self.boto3_client is not None or self.filesystem_dir is not None),
355
363
  }
356
364
 
365
+ def recent_records(self, limit: int = RECENT_RECORDS_MAX) -> list[dict]:
366
+ """Return up to *limit* records written by this process, newest first.
367
+
368
+ Only the last ``RECENT_RECORDS_MAX`` records are kept, in memory, with
369
+ every backend; records written before the process started are not
370
+ read back. Use :meth:`verify_chain` for the persisted chain.
371
+ """
372
+ if limit <= 0:
373
+ return []
374
+ with self._write_lock:
375
+ records = list(self._recent)
376
+ return records[::-1][:limit]
377
+
357
378
  def _store_to_s3(self, record: dict):
358
379
  """Persist a forensic record using the configured backend."""
359
380
  ts = datetime.now(UTC)
@@ -57,6 +57,9 @@ _PRESIDIO_TO_ADMINA: dict[str, str] = {
57
57
  }
58
58
 
59
59
  # Languages Admina configures for Presidio, each mapped to its spaCy model.
60
+ # The Presidio entity types requested from the analyzer.
61
+ _ENTITIES: list[str] = list(_PRESIDIO_TO_ADMINA)
62
+
60
63
  _LANG_MODELS: dict[str, str] = {
61
64
  "en": "en_core_web_sm",
62
65
  "it": "it_core_news_sm",
@@ -111,9 +114,11 @@ class PresidioPIIEngine:
111
114
 
112
115
  # Union of per-language analyses: a recognizer registered for only one
113
116
  # language is missed by the other pass, so both are run and merged.
117
+ # Only the mapped entity types are requested: the other recognizers
118
+ # (URL among them, slow on text with many dots) would be discarded.
114
119
  raw = []
115
120
  for lang in self.languages:
116
- raw.extend(self._analyzer.analyze(text=text, language=lang))
121
+ raw.extend(self._analyzer.analyze(text=text, language=lang, entities=_ENTITIES))
117
122
 
118
123
  spans: list[tuple[int, int, str, str]] = []
119
124
  for r in raw:
@@ -27,7 +27,7 @@ import io as _io
27
27
  import json
28
28
  import logging
29
29
  import time
30
- from datetime import UTC, datetime
30
+ from datetime import UTC, datetime, timedelta
31
31
  from typing import Any
32
32
 
33
33
  from fastapi import APIRouter, Query, Response, WebSocket, WebSocketDisconnect
@@ -76,6 +76,122 @@ def _suggestions_to_csv(payload: dict) -> Response:
76
76
  return Response(content=buf.getvalue(), media_type="text/csv")
77
77
 
78
78
 
79
+ # ── Recent forensic records (feed without ClickHouse) ─────────
80
+ # Forensic record types that stand for one governed request, as a ClickHouse
81
+ # governance_events row does. Other records (coordination verdicts, response
82
+ # guard errors, events posted to /api/v1/audit) are not feed events.
83
+ _FEED_EVENT_TYPES = frozenset({"mcp_request", "gateway_request"})
84
+ # The source named in feed, trend and suggestions answers built from them.
85
+ _FORENSIC_SOURCE = "forensic_recent"
86
+
87
+
88
+ def _plain(value: Any) -> str:
89
+ """*value* as a plain string (the value of an enum member)."""
90
+ return str(getattr(value, "value", value) or "")
91
+
92
+
93
+ def _parse_utc(value: Any) -> datetime | None:
94
+ """An ISO 8601 timestamp as an aware UTC datetime; None if unreadable."""
95
+ try:
96
+ parsed = datetime.fromisoformat(str(value))
97
+ except (TypeError, ValueError):
98
+ return None
99
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
100
+
101
+
102
+ def _row_from_forensic(record: dict) -> dict[str, Any] | None:
103
+ """A forensic record of a governed request in the shape of a ClickHouse
104
+ ``governance_events`` row; None for any other record.
105
+
106
+ ``details`` is the JSON of the record's governance checks, plus
107
+ ``would_action`` when set, as in the ClickHouse row. Hashes are left
108
+ empty: the forensic record of a request does not carry them.
109
+ """
110
+ event = record.get("event")
111
+ if not isinstance(event, dict) or _plain(event.get("event_type")) not in _FEED_EVENT_TYPES:
112
+ return None
113
+ details = dict(event.get("checks") or {})
114
+ if event.get("would_action") is not None:
115
+ details["would_action"] = _plain(event["would_action"])
116
+ return {
117
+ "event_id": str(event.get("event_id", "")),
118
+ "timestamp": str(record.get("timestamp_utc", "")),
119
+ "event_type": _plain(event.get("event_type")),
120
+ "agent_id": str(event.get("agent_id", "")),
121
+ "session_id": str(event.get("session_id", "")),
122
+ "method": str(event.get("method", "")),
123
+ "tool_name": str(event.get("tool_name", "")),
124
+ "action": _plain(event.get("action")).lower(),
125
+ "risk_level": _plain(event.get("risk_level")).lower(),
126
+ "details": json.dumps(details, default=str),
127
+ "latency_ms": event.get("governance_latency_ms", 0.0),
128
+ "request_hash": "",
129
+ "response_hash": "",
130
+ }
131
+
132
+
133
+ def _recent_rows(forensic_box: Any) -> list[dict[str, Any]] | None:
134
+ """The governed requests among the recent records of *forensic_box*,
135
+ newest first, as ``governance_events`` rows; None when the store keeps
136
+ no recent records."""
137
+ recent = getattr(forensic_box, "recent_records", None)
138
+ if not callable(recent):
139
+ return None
140
+ rows = []
141
+ for record in recent():
142
+ row = _row_from_forensic(record)
143
+ if row is not None:
144
+ rows.append(row)
145
+ return rows
146
+
147
+
148
+ def _rows_between(
149
+ rows: list[dict[str, Any]], start: datetime, end: datetime | None = None
150
+ ) -> list[dict[str, Any]]:
151
+ """The *rows* with a timestamp at or after *start* (and before *end*)."""
152
+ out = []
153
+ for row in rows:
154
+ ts = _parse_utc(row["timestamp"])
155
+ if ts is not None and ts >= start and (end is None or ts < end):
156
+ out.append(row)
157
+ return out
158
+
159
+
160
+ def _trend_from_rows(
161
+ rows: list[dict[str, Any]], window_hours: int, bucket_minutes: int
162
+ ) -> dict[str, Any]:
163
+ """The ``/trend`` answer computed from ``governance_events`` rows: counts
164
+ per action in buckets of *bucket_minutes* (aligned to the Unix epoch, as
165
+ ClickHouse's ``toStartOfInterval``) over the last *window_hours*."""
166
+ bucket_s = bucket_minutes * 60
167
+ start = datetime.now(UTC) - timedelta(hours=window_hours)
168
+ buckets: dict[datetime, dict[str, int]] = {}
169
+ for row in _rows_between(rows, start):
170
+ ts = _parse_utc(row["timestamp"])
171
+ if ts is None:
172
+ continue
173
+ key = datetime.fromtimestamp(int(ts.timestamp()) // bucket_s * bucket_s, UTC)
174
+ counts = buckets.setdefault(key, {})
175
+ counts[row["action"]] = counts.get(row["action"], 0) + 1
176
+ series = [
177
+ {
178
+ "ts": key.isoformat(),
179
+ "allow": counts.get("allow", 0),
180
+ "block": counts.get("block", 0),
181
+ "circuit_break": counts.get("circuit_break", 0),
182
+ "redact": counts.get("redact", 0),
183
+ "total": sum(counts.values()),
184
+ }
185
+ for key, counts in sorted(buckets.items())
186
+ ]
187
+ return {
188
+ "window_hours": window_hours,
189
+ "bucket_minutes": bucket_minutes,
190
+ "bucket_count": len(series),
191
+ "buckets": series,
192
+ }
193
+
194
+
79
195
  # ── Governance score ─────────────────────────────────────────
80
196
  def _compute_governance_score(
81
197
  *,
@@ -295,10 +411,19 @@ def create_dashboard_endpoints(
295
411
  limit: int = Query(50, ge=1, le=1000),
296
412
  offset: int = Query(0, ge=0),
297
413
  ) -> dict[str, Any]:
298
- """Recent governance events (paginated)."""
414
+ """Recent governance events (paginated).
415
+
416
+ Without ClickHouse the events are the governed requests among the
417
+ recent records of the forensic store, newest first (records written
418
+ since the proxy started, up to the store's in-memory limit).
419
+ """
299
420
  ch = get_clickhouse()
300
421
  if not ch:
301
- return {"events": [], "count": 0, "error": "ClickHouse not available"}
422
+ rows = _recent_rows(get_forensic_box())
423
+ if rows is None:
424
+ return {"events": [], "count": 0, "error": "ClickHouse not available"}
425
+ events = rows[offset : offset + limit]
426
+ return {"events": events, "count": len(events), "source": _FORENSIC_SOURCE}
302
427
  try:
303
428
  loop = asyncio.get_running_loop()
304
429
  db = get_settings().CLICKHOUSE_DB
@@ -583,7 +708,8 @@ def create_dashboard_endpoints(
583
708
  min_count: skip categories with fewer than this many events.
584
709
  """
585
710
  ch = get_clickhouse()
586
- if not ch:
711
+ recent = None if ch else _recent_rows(get_forensic_box())
712
+ if not ch and recent is None:
587
713
  return {
588
714
  "window_hours": window_hours,
589
715
  "events_analyzed": 0,
@@ -595,19 +721,28 @@ def create_dashboard_endpoints(
595
721
  loop = asyncio.get_running_loop()
596
722
  db = get_settings().CLICKHOUSE_DB
597
723
  mode = getattr(get_settings(), "GOVERNANCE_MODE", "enforce")
724
+ now = datetime.now(UTC)
725
+ window = timedelta(hours=window_hours)
598
726
 
599
727
  # Aggregate by action + risk + a flat category extracted from details.
600
728
  # Schema: (event_id, timestamp, event_type, agent_id, session_id,
601
729
  # method, tool_name, action, risk_level, details, ...)
602
- query = (
603
- f"SELECT action, risk_level, details "
604
- f"FROM {db}.governance_events "
605
- f"WHERE timestamp >= now() - INTERVAL {int(window_hours)} HOUR"
606
- )
607
- async with _ch_lock:
608
- result = await loop.run_in_executor(None, lambda: ch.query(query))
609
-
610
- total = len(result.result_rows)
730
+ if ch:
731
+ query = (
732
+ f"SELECT action, risk_level, details "
733
+ f"FROM {db}.governance_events "
734
+ f"WHERE timestamp >= now() - INTERVAL {int(window_hours)} HOUR"
735
+ )
736
+ async with _ch_lock:
737
+ result = await loop.run_in_executor(None, lambda: ch.query(query))
738
+ window_rows = list(result.result_rows)
739
+ else:
740
+ window_rows = [
741
+ (r["action"], r["risk_level"], r["details"])
742
+ for r in _rows_between(recent, now - window)
743
+ ]
744
+
745
+ total = len(window_rows)
611
746
  if total == 0:
612
747
  return {
613
748
  "window_hours": window_hours,
@@ -630,7 +765,7 @@ def create_dashboard_endpoints(
630
765
  cat_total: dict[str, int] = {}
631
766
  cat_blocked: dict[str, int] = {}
632
767
  cat_would_blocked: dict[str, int] = {}
633
- for row in result.result_rows:
768
+ for row in window_rows:
634
769
  action, _risk, details_raw = row[0], row[1], row[2]
635
770
  # Action is stored lowercase in ClickHouse — normalise once.
636
771
  action_upper = (action or "").upper()
@@ -766,19 +901,27 @@ def create_dashboard_endpoints(
766
901
  # immediately-preceding equal window. If a category's blocked
767
902
  # rate has surged ≥2x with absolute count ≥ min_count, flag it.
768
903
  try:
769
- prev_query = (
770
- f"SELECT details FROM {db}.governance_events "
771
- f"WHERE timestamp >= now() - INTERVAL {int(window_hours * 2)} HOUR "
772
- f" AND timestamp < now() - INTERVAL {int(window_hours)} HOUR "
773
- f" AND lower(action) IN ('block','circuit_break')"
774
- )
775
- async with _ch_lock:
776
- prev_res = await loop.run_in_executor(
777
- None,
778
- lambda: ch.query(prev_query),
904
+ if ch:
905
+ prev_query = (
906
+ f"SELECT details FROM {db}.governance_events "
907
+ f"WHERE timestamp >= now() - INTERVAL {int(window_hours * 2)} HOUR "
908
+ f" AND timestamp < now() - INTERVAL {int(window_hours)} HOUR "
909
+ f" AND lower(action) IN ('block','circuit_break')"
779
910
  )
911
+ async with _ch_lock:
912
+ prev_res = await loop.run_in_executor(
913
+ None,
914
+ lambda: ch.query(prev_query),
915
+ )
916
+ prev_rows = list(prev_res.result_rows)
917
+ else:
918
+ prev_rows = [
919
+ (r["details"],)
920
+ for r in _rows_between(recent, now - 2 * window, now - window)
921
+ if r["action"] in ("block", "circuit_break")
922
+ ]
780
923
  prev_blocked: dict[str, int] = {}
781
- for (det_raw,) in prev_res.result_rows:
924
+ for (det_raw,) in prev_rows:
782
925
  try:
783
926
  d = json.loads(det_raw) if det_raw else {}
784
927
  except (TypeError, ValueError):
@@ -848,6 +991,8 @@ def create_dashboard_endpoints(
848
991
  "would_blocked_by_category": cat_would_blocked,
849
992
  "suggestions": suggestions,
850
993
  }
994
+ if not ch:
995
+ payload["source"] = _FORENSIC_SOURCE
851
996
  if format == "csv":
852
997
  return _suggestions_to_csv(payload)
853
998
  return payload
@@ -878,11 +1023,17 @@ def create_dashboard_endpoints(
878
1023
  """
879
1024
  ch = get_clickhouse()
880
1025
  if not ch:
1026
+ recent = _recent_rows(get_forensic_box())
1027
+ if recent is None:
1028
+ return {
1029
+ "window_hours": window_hours,
1030
+ "bucket_minutes": bucket_minutes,
1031
+ "buckets": [],
1032
+ "error": "ClickHouse not available",
1033
+ }
881
1034
  return {
882
- "window_hours": window_hours,
883
- "bucket_minutes": bucket_minutes,
884
- "buckets": [],
885
- "error": "ClickHouse not available",
1035
+ **_trend_from_rows(recent, window_hours, bucket_minutes),
1036
+ "source": _FORENSIC_SOURCE,
886
1037
  }
887
1038
  try:
888
1039
  loop = asyncio.get_running_loop()
@@ -28,20 +28,152 @@ Routes (prefix /v1):
28
28
  from __future__ import annotations
29
29
 
30
30
  import asyncio
31
+ import hashlib
31
32
  import json
33
+ import logging
32
34
  import re
33
35
  import time
34
36
  import uuid
35
- from collections.abc import AsyncIterator
37
+ from collections.abc import AsyncIterator, Awaitable, Callable
38
+ from datetime import UTC, datetime
36
39
  from typing import Any
37
40
 
38
41
  import httpx
39
42
  from fastapi import APIRouter, HTTPException, Request
40
43
  from fastapi.responses import JSONResponse, StreamingResponse
41
44
 
42
- from admina.core.types import EventType
45
+ from admina.core.event_bus import GovernanceEvent as BusGovernanceEvent
46
+ from admina.core.event_bus import bus as governance_bus
47
+ from admina.core.types import EventType, GovernanceAction, RiskLevel
43
48
  from admina.domains.agent_security.egress import resolve_egress_mode
44
- from admina.domains.governance import redact_response_result, run_pipeline, safe_serialize
49
+ from admina.domains.governance import (
50
+ build_governance_details,
51
+ redact_response_result,
52
+ run_pipeline,
53
+ safe_serialize,
54
+ )
55
+ from admina.proxy.config import GovernanceEvent
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ # Strong references to fire-and-forget tasks, so they are not
60
+ # garbage-collected before completion (see asyncio.create_task docs).
61
+ _background_tasks: set[asyncio.Task] = set()
62
+
63
+
64
+ def _spawn(coro: Any) -> asyncio.Task:
65
+ """Schedule *coro* as a background task and keep a strong ref."""
66
+ task = asyncio.create_task(coro)
67
+ _background_tasks.add(task)
68
+ task.add_done_callback(_background_tasks.discard)
69
+ return task
70
+
71
+
72
+ def _decision_metadata(gov_response: Any) -> dict:
73
+ """Event-bus metadata for a gateway decision.
74
+
75
+ The same fields the /mcp surface publishes, minus ``content``: the
76
+ (redacted) request body never leaves the request on the bus, so live
77
+ subscribers (dashboard feed, alerts, OTel) see the decision only.
78
+ """
79
+ meta = gov_response.to_dict()
80
+ meta.pop("content", None)
81
+ return meta
82
+
83
+
84
+ async def _emit_decision_safe(event: BusGovernanceEvent) -> None:
85
+ """Emit *event* on the governance bus; a failing subscriber is logged
86
+ and never propagates to the request."""
87
+ try:
88
+ await governance_bus.emit(event)
89
+ except Exception:
90
+ logger.warning("Gateway governance event emission failed", exc_info=True)
91
+
92
+
93
+ def _emit_decision(gov_response: Any, session_id: str) -> None:
94
+ """Publish one GOVERNANCE_DECISION event for a gateway request,
95
+ fire-and-forget: it adds no latency and cannot fail the request."""
96
+ _spawn(
97
+ _emit_decision_safe(
98
+ BusGovernanceEvent(
99
+ event_type=EventType.GOVERNANCE_DECISION,
100
+ session_id=session_id,
101
+ action=gov_response.action,
102
+ risk_level=gov_response.risk_level,
103
+ domain="gateway",
104
+ metadata=_decision_metadata(gov_response),
105
+ )
106
+ )
107
+ )
108
+
109
+
110
+ def _count_decision(state: Any, action: str, pii_count: int) -> None:
111
+ """Count a governed gateway request in the proxy counters (``/api/stats``
112
+ and ``/metrics``), as ``/mcp`` does: ``requests_total``, then
113
+ ``requests_blocked`` for BLOCK and CIRCUIT_BREAK or ``requests_allowed``
114
+ otherwise, and ``requests_redacted`` when PII was redacted."""
115
+ inc = getattr(state, "inc_metric", None)
116
+ if inc is None:
117
+ return
118
+ inc("requests_total")
119
+ if action in ("BLOCK", "CIRCUIT_BREAK"):
120
+ inc("requests_blocked")
121
+ else:
122
+ inc("requests_allowed")
123
+ if pii_count > 0:
124
+ inc("requests_redacted")
125
+
126
+
127
+ def _observe_latency(state: Any, start: float) -> None:
128
+ """Add the time since *start* (``time.perf_counter()``) to the proxy's
129
+ average request latency."""
130
+ update = getattr(state, "update_avg_latency", None)
131
+ if update is not None:
132
+ update((time.perf_counter() - start) * 1000)
133
+
134
+
135
+ def _enum_value(enum_cls: Any, value: Any, default: Any) -> Any:
136
+ """*value* (an enum member or its name or value, any case) as a member
137
+ of *enum_cls*; *default* when it is none of them."""
138
+ raw = str(getattr(value, "value", value) or "").lower()
139
+ try:
140
+ return enum_cls(raw)
141
+ except ValueError:
142
+ return default
143
+
144
+
145
+ def _analytics_event(
146
+ pre: Any, *, event_id: str, agent_id: str, session_id: str, prompt_text: str
147
+ ) -> GovernanceEvent:
148
+ """The analytics row (ClickHouse ``governance_events``) of a gateway
149
+ request: the same columns as an ``/mcp`` row, with event type
150
+ ``gateway_request`` and method ``chat.completions``. Like the ``/mcp``
151
+ row it holds the governance checks and a truncated hash of the
152
+ prompt, never the prompt itself."""
153
+ return GovernanceEvent(
154
+ event_id=event_id,
155
+ timestamp=datetime.now(UTC).isoformat(),
156
+ event_type=EventType.GATEWAY_REQUEST,
157
+ agent_id=agent_id,
158
+ session_id=session_id,
159
+ method="chat.completions",
160
+ action=_enum_value(GovernanceAction, pre.action, GovernanceAction.ALLOW),
161
+ risk_level=_enum_value(RiskLevel, pre.risk_level, RiskLevel.LOW),
162
+ details=build_governance_details(pre),
163
+ latency_ms=pre.latency_ms,
164
+ request_hash=hashlib.sha256(prompt_text.encode()).hexdigest()[:32],
165
+ )
166
+
167
+
168
+ async def _store_event_safe(
169
+ store_event: Callable[[GovernanceEvent], Awaitable[None]], event: GovernanceEvent
170
+ ) -> None:
171
+ """Store *event* with *store_event*; a failure is logged and never
172
+ reaches the request."""
173
+ try:
174
+ await store_event(event)
175
+ except Exception:
176
+ logger.warning("Gateway analytics event storage failed", exc_info=True)
45
177
 
46
178
 
47
179
  def _extract_prompt_text(messages: list) -> str:
@@ -262,6 +394,7 @@ def create_gateway_endpoints(
262
394
  *,
263
395
  get_state: Any,
264
396
  get_settings: Any,
397
+ store_event: Callable[[GovernanceEvent], Awaitable[None]] | None = None,
265
398
  ) -> APIRouter:
266
399
  """Create the OpenAI-compatible gateway router (prefix ``/v1``).
267
400
 
@@ -273,6 +406,9 @@ def create_gateway_endpoints(
273
406
  loop_breaker, egress_policy, governance_guards, forensic_box,
274
407
  http_client).
275
408
  get_settings: Callable returning the settings object.
409
+ store_event: Coroutine function storing a :class:`GovernanceEvent`
410
+ in the analytics store; called, fire-and-forget, for every
411
+ governed request while the state has a ``clickhouse`` client.
276
412
  """
277
413
  router = APIRouter(prefix="/v1", tags=["gateway"])
278
414
 
@@ -307,6 +443,7 @@ def create_gateway_endpoints(
307
443
  agent_id = re.sub(r"[\r\n]", "", request.headers.get("X-Agent-Id", "gateway"))[:128]
308
444
  prompt_text = _extract_prompt_text(messages)
309
445
  event_id = uuid.uuid4().hex
446
+ start = time.perf_counter()
310
447
 
311
448
  pre = await run_pipeline(
312
449
  body={"params": {"messages": messages}},
@@ -328,6 +465,8 @@ def create_gateway_endpoints(
328
465
  egress_mode=resolve_egress_mode(cfg.GOVERNANCE_MODE),
329
466
  )
330
467
  action = pre.gov_response.action # uppercase: ALLOW/BLOCK/CIRCUIT_BREAK
468
+ pii_count = pre.checks.get("pii_redaction", {}).get("count", 0)
469
+ _count_decision(state, action, pii_count)
331
470
 
332
471
  await _record_forensic(
333
472
  state.forensic_box,
@@ -338,9 +477,24 @@ def create_gateway_endpoints(
338
477
  risk_level=pre.gov_response.risk_level,
339
478
  pre=pre,
340
479
  )
480
+ if store_event is not None and getattr(state, "clickhouse", None):
481
+ _spawn(
482
+ _store_event_safe(
483
+ store_event,
484
+ _analytics_event(
485
+ pre,
486
+ event_id=event_id,
487
+ agent_id=agent_id,
488
+ session_id=session_id,
489
+ prompt_text=prompt_text,
490
+ ),
491
+ )
492
+ )
341
493
 
342
494
  block_message = cfg.ADMINA_GATEWAY_BLOCK_MESSAGE
343
495
  if action in ("BLOCK", "CIRCUIT_BREAK"):
496
+ _emit_decision(pre.gov_response, session_id)
497
+ _observe_latency(state, start)
344
498
  if stream:
345
499
  return StreamingResponse(
346
500
  _aiter_list(_synthetic_stream(model, block_message)),
@@ -352,7 +506,6 @@ def create_gateway_endpoints(
352
506
  url = f"{upstream}/chat/completions"
353
507
  headers = {"X-Admina-Event-Id": event_id}
354
508
 
355
- pii_count = pre.checks.get("pii_redaction", {}).get("count", 0)
356
509
  fwd_messages = pre.redacted_body["params"]["messages"] if pii_count > 0 else messages
357
510
  forward_body = {**body, "messages": fwd_messages}
358
511
 
@@ -378,6 +531,7 @@ def create_gateway_endpoints(
378
531
  try:
379
532
  upstream_resp = await stream_cm.__aenter__()
380
533
  except httpx.ConnectError:
534
+ _emit_decision(pre.gov_response, session_id)
381
535
  raise HTTPException(status_code=502, detail="Gateway upstream unreachable")
382
536
 
383
537
  async def _proxy() -> AsyncIterator[str]:
@@ -387,15 +541,22 @@ def create_gateway_endpoints(
387
541
  ):
388
542
  yield sse
389
543
  finally:
544
+ # One decision event per stream, at completion (or
545
+ # client disconnect), never per chunk.
546
+ _emit_decision(pre.gov_response, session_id)
547
+ _observe_latency(state, start)
390
548
  await stream_cm.__aexit__(None, None, None)
391
549
 
392
550
  return StreamingResponse(_proxy(), media_type="text/event-stream")
393
551
 
394
552
  # non-streaming passthrough
553
+ _emit_decision(pre.gov_response, session_id)
395
554
  try:
396
555
  resp = await state.http_client.post(url, json=forward_body, headers=headers)
397
556
  except httpx.ConnectError:
398
557
  raise HTTPException(status_code=502, detail="Gateway upstream unreachable")
558
+ finally:
559
+ _observe_latency(state, start)
399
560
  data = resp.json()
400
561
  if cfg.PII_REDACTION_ENABLED and isinstance(data.get("choices"), list):
401
562
  data["choices"], _ = redact_response_result(data["choices"], state.pii_redactor)
@@ -561,6 +561,7 @@ app.include_router(_integration_router)
561
561
  _gateway_router = create_gateway_endpoints(
562
562
  get_state=lambda: app.state.proxy,
563
563
  get_settings=lambda: settings,
564
+ store_event=lambda event: _store_event_async(app.state.proxy.clickhouse, event),
564
565
  )
565
566
  app.include_router(_gateway_router)
566
567
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: admina-framework
3
- Version: 0.12.1
3
+ Version: 0.12.2
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>