nat-engine 1__py3-none-any.whl

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 (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,434 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """WebSocket System-Under-Test implementation.
7
+
8
+ :class:`WebSocketSUT` connects to WebSocket endpoints, sends/receives messages,
9
+ and detects anomalies (unexpected disconnects, malformed frames, high latency).
10
+ It implements the same ``execute()`` / ``health_check()`` interface as
11
+ :class:`~mannf.product.integrations.http_sut.HttpApiSUT`.
12
+
13
+ The adapter uses the ``websockets`` library when available. When ``websockets``
14
+ is not installed, execute() returns a skip result with an informative error
15
+ so the rest of NAT continues to function.
16
+
17
+ Anomaly detection includes:
18
+
19
+ * **Disconnect rate** — fraction of test runs that ended with an unexpected
20
+ close from the server.
21
+ * **Message latency** — time between send and first received response.
22
+ * **Frame integrity** — responses that cannot be decoded as JSON are flagged.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import logging
30
+ import time
31
+ from typing import Any, Dict, List, Optional
32
+
33
+ from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
34
+ from mannf.core.distributed.system_under_test import SystemUnderTest
35
+ from mannf.core.testing.models import TestCase, TestResult
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _websockets_available() -> bool:
41
+ """Return True if the ``websockets`` package is installed."""
42
+ try:
43
+ import websockets # noqa: F401
44
+ return True
45
+ except ImportError:
46
+ return False
47
+
48
+
49
+ def _parse_ws_url(url: str) -> tuple[str, int, str]:
50
+ """Parse a WebSocket URL into (host, port, path)."""
51
+ scheme_stripped = url
52
+ for prefix in ("wss://", "ws://"):
53
+ if url.startswith(prefix):
54
+ scheme_stripped = url[len(prefix):]
55
+ break
56
+ if "/" in scheme_stripped:
57
+ hostport, _, path = scheme_stripped.partition("/")
58
+ path = "/" + path
59
+ else:
60
+ hostport = scheme_stripped
61
+ path = "/"
62
+ if ":" in hostport:
63
+ host, _, port_str = hostport.partition(":")
64
+ try:
65
+ port = int(port_str)
66
+ except ValueError:
67
+ port = 443 if url.startswith("wss://") else 80
68
+ else:
69
+ host = hostport
70
+ port = 443 if url.startswith("wss://") else 80
71
+ return host, port, path
72
+
73
+
74
+ class WebSocketSUT(SystemUnderTest):
75
+ """Execute WebSocket test cases against a live WebSocket server.
76
+
77
+ Parameters
78
+ ----------
79
+ ws_url:
80
+ Base WebSocket URL (e.g. ``"ws://localhost:8765"``).
81
+ timeout:
82
+ Connect and send/receive timeout in seconds (default: 10).
83
+ headers:
84
+ Additional HTTP headers sent during the WebSocket handshake.
85
+ max_message_size:
86
+ Maximum size (bytes) of a single received message (default: 1 MB).
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ ws_url: str,
92
+ timeout: float = 10.0,
93
+ headers: Optional[Dict[str, str]] = None,
94
+ max_message_size: int = 1024 * 1024,
95
+ ) -> None:
96
+ self.ws_url = ws_url.rstrip("/")
97
+ self.timeout = timeout
98
+ self.headers: Dict[str, str] = headers or {}
99
+ self.max_message_size = max_message_size
100
+
101
+ # Connection state tracking for anomaly detection / risk scoring
102
+ self._disconnect_count: int = 0
103
+ self._total_runs: int = 0
104
+ self._latency_history: List[float] = []
105
+
106
+ host, port, _path = _parse_ws_url(ws_url)
107
+ self._registry = EndpointRegistry()
108
+ self._registry.register(
109
+ Endpoint(
110
+ name="default",
111
+ host=host,
112
+ port=port,
113
+ protocol="wss" if ws_url.startswith("wss://") else "ws",
114
+ )
115
+ )
116
+
117
+ # ------------------------------------------------------------------
118
+ # SystemUnderTest interface
119
+ # ------------------------------------------------------------------
120
+
121
+ @property
122
+ def registry(self) -> EndpointRegistry:
123
+ return self._registry
124
+
125
+ @property
126
+ def disconnect_rate(self) -> float:
127
+ """Fraction of runs that ended with an unexpected server disconnect."""
128
+ if self._total_runs == 0:
129
+ return 0.0
130
+ return self._disconnect_count / self._total_runs
131
+
132
+ @property
133
+ def average_latency_ms(self) -> float:
134
+ """Average send→receive latency in milliseconds across all runs."""
135
+ if not self._latency_history:
136
+ return 0.0
137
+ return sum(self._latency_history) / len(self._latency_history)
138
+
139
+ async def execute(self, test_case: TestCase) -> TestResult:
140
+ """Connect, send a message (if specified), and receive a response.
141
+
142
+ ``test_case.target`` is treated as the channel path to append to
143
+ ``ws_url`` (e.g. ``"/chat"`` → ``"ws://localhost:8765/chat"``).
144
+
145
+ ``test_case.inputs`` may contain:
146
+
147
+ * ``action`` — one of ``"send_receive"`` (default), ``"connect_close"``,
148
+ ``"receive_only"``, ``"reconnect"`` (disconnect then reconnect).
149
+ * ``payload`` — dict or string to send as a message.
150
+ * ``expected_response_keys`` — list of JSON keys expected in the first
151
+ received message.
152
+ * ``receive_timeout`` — per-receive timeout in seconds (default: ``timeout``).
153
+ """
154
+ if not _websockets_available():
155
+ return TestResult(
156
+ test_case_id=test_case.id,
157
+ passed=False,
158
+ actual_output=None,
159
+ execution_time_ms=0.0,
160
+ error=(
161
+ "websockets package is not installed. "
162
+ "Install it with: pip install websockets"
163
+ ),
164
+ )
165
+
166
+ action: str = str(test_case.inputs.get("action", "send_receive")).lower()
167
+ channel_path: str = test_case.target or ""
168
+ if channel_path and not channel_path.startswith("/"):
169
+ channel_path = "/" + channel_path
170
+
171
+ full_url = self.ws_url + channel_path
172
+
173
+ self._total_runs += 1
174
+
175
+ if action == "connect_close":
176
+ return await self._action_connect_close(test_case, full_url)
177
+ elif action == "receive_only":
178
+ return await self._action_receive_only(test_case, full_url)
179
+ elif action == "reconnect":
180
+ return await self._action_reconnect(test_case, full_url)
181
+ else:
182
+ return await self._action_send_receive(test_case, full_url)
183
+
184
+ async def health_check(self) -> bool:
185
+ """Check that the WebSocket server is reachable by opening and closing a connection."""
186
+ if not _websockets_available():
187
+ return False
188
+ try:
189
+ import websockets
190
+
191
+ async def _health_check_connect_close() -> None:
192
+ ws = await websockets.connect(
193
+ self.ws_url,
194
+ additional_headers=self.headers,
195
+ max_size=self.max_message_size,
196
+ )
197
+ await ws.close()
198
+
199
+ await asyncio.wait_for(_health_check_connect_close(), timeout=min(self.timeout, 5.0))
200
+ return True
201
+ except Exception: # noqa: BLE001
202
+ return False
203
+
204
+ # ------------------------------------------------------------------
205
+ # Action helpers
206
+ # ------------------------------------------------------------------
207
+
208
+ async def _action_connect_close(self, test_case: TestCase, url: str) -> TestResult:
209
+ """Simply connect and close — verify the handshake succeeds."""
210
+ start = time.monotonic()
211
+ try:
212
+ import websockets
213
+
214
+ async def _test_connection() -> None:
215
+ _ws = await websockets.connect(
216
+ url,
217
+ additional_headers=self.headers,
218
+ max_size=self.max_message_size,
219
+ )
220
+ await _ws.close()
221
+
222
+ await asyncio.wait_for(_test_connection(), timeout=self.timeout)
223
+ elapsed_ms = (time.monotonic() - start) * 1000.0
224
+ return TestResult(
225
+ test_case_id=test_case.id,
226
+ passed=True,
227
+ actual_output={"connected": True},
228
+ execution_time_ms=elapsed_ms,
229
+ error=None,
230
+ )
231
+ except Exception as exc: # noqa: BLE001
232
+ elapsed_ms = (time.monotonic() - start) * 1000.0
233
+ self._disconnect_count += 1
234
+ return TestResult(
235
+ test_case_id=test_case.id,
236
+ passed=False,
237
+ actual_output=None,
238
+ execution_time_ms=elapsed_ms,
239
+ error=f"WebSocket connection failed: {exc}",
240
+ )
241
+
242
+ async def _action_send_receive(self, test_case: TestCase, url: str) -> TestResult:
243
+ """Connect, send a payload, and receive one response message."""
244
+ import websockets
245
+
246
+ payload = test_case.inputs.get("payload")
247
+ expected_keys: list = test_case.inputs.get("expected_response_keys") or []
248
+ receive_timeout = float(test_case.inputs.get("receive_timeout") or self.timeout)
249
+
250
+ if isinstance(payload, dict):
251
+ message_str = json.dumps(payload)
252
+ else:
253
+ message_str = str(payload) if payload is not None else ""
254
+
255
+ start = time.monotonic()
256
+ try:
257
+ ws = await asyncio.wait_for(
258
+ websockets.connect(
259
+ url,
260
+ additional_headers=self.headers,
261
+ max_size=self.max_message_size,
262
+ ),
263
+ timeout=self.timeout,
264
+ )
265
+
266
+ send_time = time.monotonic()
267
+ if message_str:
268
+ await asyncio.wait_for(ws.send(message_str), timeout=self.timeout)
269
+
270
+ # Receive
271
+ raw_response: Any = None
272
+ try:
273
+ raw_response = await asyncio.wait_for(
274
+ ws.recv(), timeout=receive_timeout
275
+ )
276
+ except asyncio.TimeoutError:
277
+ await ws.close()
278
+ elapsed_ms = (time.monotonic() - start) * 1000.0
279
+ self._disconnect_count += 1
280
+ return TestResult(
281
+ test_case_id=test_case.id,
282
+ passed=False,
283
+ actual_output=None,
284
+ execution_time_ms=elapsed_ms,
285
+ error="Timed out waiting for WebSocket response",
286
+ )
287
+
288
+ recv_latency_ms = (time.monotonic() - send_time) * 1000.0
289
+ self._latency_history.append(recv_latency_ms)
290
+
291
+ # Try to parse as JSON
292
+ parsed_response: Any = raw_response
293
+ try:
294
+ parsed_response = json.loads(raw_response)
295
+ except (json.JSONDecodeError, TypeError):
296
+ pass
297
+
298
+ await ws.close()
299
+ elapsed_ms = (time.monotonic() - start) * 1000.0
300
+
301
+ # Validate expected keys
302
+ passed = True
303
+ error_msg: Optional[str] = None
304
+ if expected_keys and isinstance(parsed_response, dict):
305
+ missing = [k for k in expected_keys if k not in parsed_response]
306
+ if missing:
307
+ passed = False
308
+ error_msg = f"Response missing expected keys: {missing}"
309
+
310
+ return TestResult(
311
+ test_case_id=test_case.id,
312
+ passed=passed,
313
+ actual_output=parsed_response,
314
+ execution_time_ms=elapsed_ms,
315
+ error=error_msg,
316
+ )
317
+
318
+ except Exception as exc: # noqa: BLE001
319
+ elapsed_ms = (time.monotonic() - start) * 1000.0
320
+ self._disconnect_count += 1
321
+ return TestResult(
322
+ test_case_id=test_case.id,
323
+ passed=False,
324
+ actual_output=None,
325
+ execution_time_ms=elapsed_ms,
326
+ error=f"WebSocket error: {exc}",
327
+ )
328
+
329
+ async def _action_receive_only(self, test_case: TestCase, url: str) -> TestResult:
330
+ """Connect and wait for an unsolicited server message."""
331
+ import websockets
332
+
333
+ receive_timeout = float(test_case.inputs.get("receive_timeout") or self.timeout)
334
+ start = time.monotonic()
335
+ try:
336
+ ws = await asyncio.wait_for(
337
+ websockets.connect(
338
+ url,
339
+ additional_headers=self.headers,
340
+ max_size=self.max_message_size,
341
+ ),
342
+ timeout=self.timeout,
343
+ )
344
+
345
+ recv_start = time.monotonic()
346
+ try:
347
+ raw = await asyncio.wait_for(ws.recv(), timeout=receive_timeout)
348
+ except asyncio.TimeoutError:
349
+ await ws.close()
350
+ elapsed_ms = (time.monotonic() - start) * 1000.0
351
+ return TestResult(
352
+ test_case_id=test_case.id,
353
+ passed=False,
354
+ actual_output=None,
355
+ execution_time_ms=elapsed_ms,
356
+ error="Timed out waiting for server-push message",
357
+ )
358
+
359
+ recv_latency_ms = (time.monotonic() - recv_start) * 1000.0
360
+ self._latency_history.append(recv_latency_ms)
361
+ await ws.close()
362
+ elapsed_ms = (time.monotonic() - start) * 1000.0
363
+
364
+ try:
365
+ parsed = json.loads(raw)
366
+ except (json.JSONDecodeError, TypeError):
367
+ parsed = raw
368
+
369
+ return TestResult(
370
+ test_case_id=test_case.id,
371
+ passed=True,
372
+ actual_output=parsed,
373
+ execution_time_ms=elapsed_ms,
374
+ error=None,
375
+ )
376
+
377
+ except Exception as exc: # noqa: BLE001
378
+ elapsed_ms = (time.monotonic() - start) * 1000.0
379
+ self._disconnect_count += 1
380
+ return TestResult(
381
+ test_case_id=test_case.id,
382
+ passed=False,
383
+ actual_output=None,
384
+ execution_time_ms=elapsed_ms,
385
+ error=f"WebSocket error: {exc}",
386
+ )
387
+
388
+ async def _action_reconnect(self, test_case: TestCase, url: str) -> TestResult:
389
+ """Disconnect abruptly then reconnect — verify server handles reconnection."""
390
+ import websockets
391
+
392
+ start = time.monotonic()
393
+ try:
394
+ # First connection — close without handshake
395
+ ws = await asyncio.wait_for(
396
+ websockets.connect(
397
+ url,
398
+ additional_headers=self.headers,
399
+ max_size=self.max_message_size,
400
+ ),
401
+ timeout=self.timeout,
402
+ )
403
+ await ws.close(code=1001) # going away
404
+
405
+ # Second connection — should succeed
406
+ ws2 = await asyncio.wait_for(
407
+ websockets.connect(
408
+ url,
409
+ additional_headers=self.headers,
410
+ max_size=self.max_message_size,
411
+ ),
412
+ timeout=self.timeout,
413
+ )
414
+ await ws2.close()
415
+
416
+ elapsed_ms = (time.monotonic() - start) * 1000.0
417
+ return TestResult(
418
+ test_case_id=test_case.id,
419
+ passed=True,
420
+ actual_output={"reconnected": True},
421
+ execution_time_ms=elapsed_ms,
422
+ error=None,
423
+ )
424
+
425
+ except Exception as exc: # noqa: BLE001
426
+ elapsed_ms = (time.monotonic() - start) * 1000.0
427
+ self._disconnect_count += 1
428
+ return TestResult(
429
+ test_case_id=test_case.id,
430
+ passed=False,
431
+ actual_output=None,
432
+ execution_time_ms=elapsed_ms,
433
+ error=f"WebSocket reconnection failed: {exc}",
434
+ )
@@ -0,0 +1,25 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """LLM provider package for NAT.
7
+
8
+ Provides a pluggable LLM abstraction layer for augmenting test case generation
9
+ with AI-generated edge-case, adversarial, and semantic inputs.
10
+
11
+ Example usage::
12
+
13
+ from mannf.product.llm import LLMProvider, get_provider
14
+ from mannf.product.llm.config import LLMConfig
15
+
16
+ config = LLMConfig(provider="openai", api_key="sk-...")
17
+ provider = get_provider(config)
18
+ if provider.is_available():
19
+ cases = await provider.generate_test_cases(endpoint_info, spec_context, n=5)
20
+ """
21
+
22
+ from mannf.product.llm.base import LLMProvider
23
+ from mannf.product.llm.factory import get_provider
24
+
25
+ __all__ = ["LLMProvider", "get_provider"]
@@ -0,0 +1,94 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Anthropic LLM provider (httpx-based, no anthropic SDK dependency)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import os
14
+ from typing import Any, Optional
15
+
16
+ import httpx
17
+
18
+ from mannf.product.llm.base import LLMProvider
19
+ from mannf.product.llm.config import LLMConfig
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
24
+ _ANTHROPIC_VERSION = "2023-06-01"
25
+
26
+
27
+ class AnthropicProvider(LLMProvider):
28
+ """LLM provider that calls the Anthropic Messages API.
29
+
30
+ Uses ``httpx`` directly — no ``anthropic`` SDK dependency required.
31
+
32
+ Parameters
33
+ ----------
34
+ config:
35
+ :class:`~mannf.product.llm.config.LLMConfig` instance. When ``None``, a
36
+ default config is created (reads ``ANTHROPIC_API_KEY`` from env).
37
+ """
38
+
39
+ def __init__(self, config: Optional[LLMConfig] = None) -> None:
40
+ if config is None:
41
+ config = LLMConfig(provider="anthropic")
42
+ self._config = config
43
+ self._api_key: str = config.api_key or os.environ.get("ANTHROPIC_API_KEY", "")
44
+
45
+ @property
46
+ def provider_name(self) -> str:
47
+ return "anthropic"
48
+
49
+ def is_available(self) -> bool:
50
+ return bool(self._api_key)
51
+
52
+ async def generate(self, prompt: str, **kwargs: Any) -> str:
53
+ """Send *prompt* to the Anthropic Messages API and return the response text."""
54
+ if not self.is_available():
55
+ logger.warning("AnthropicProvider: no API key configured")
56
+ return ""
57
+
58
+ headers = {
59
+ "x-api-key": self._api_key,
60
+ "anthropic-version": _ANTHROPIC_VERSION,
61
+ "Content-Type": "application/json",
62
+ }
63
+ payload = {
64
+ "model": self._config.model,
65
+ "max_tokens": self._config.max_tokens,
66
+ "temperature": self._config.temperature,
67
+ "messages": [{"role": "user", "content": prompt}],
68
+ }
69
+
70
+ last_exc: Exception = RuntimeError("No attempts made")
71
+ for attempt in range(self._config.max_retries):
72
+ try:
73
+ async with httpx.AsyncClient(timeout=self._config.timeout) as client:
74
+ resp = await client.post(_ANTHROPIC_API_URL, headers=headers, json=payload)
75
+ resp.raise_for_status()
76
+ data = resp.json()
77
+ return data["content"][0]["text"]
78
+ except (httpx.HTTPStatusError, httpx.RequestError, KeyError, json.JSONDecodeError) as exc:
79
+ last_exc = exc
80
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (400, 401, 403):
81
+ logger.error("AnthropicProvider: non-retryable error %s", exc)
82
+ return ""
83
+ wait = 2 ** attempt
84
+ logger.warning(
85
+ "AnthropicProvider: attempt %d/%d failed (%s), retrying in %ds",
86
+ attempt + 1,
87
+ self._config.max_retries,
88
+ exc,
89
+ wait,
90
+ )
91
+ await asyncio.sleep(wait)
92
+
93
+ logger.error("AnthropicProvider: all retries exhausted: %s", last_exc)
94
+ return ""