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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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 ""
|