aipop 0.7.0__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 (298) hide show
  1. aipop/__init__.py +6 -0
  2. aipop/adapters/__init__.py +35 -0
  3. aipop/adapters/anthropic.py +228 -0
  4. aipop/adapters/auto_probe.py +343 -0
  5. aipop/adapters/bedrock.py +153 -0
  6. aipop/adapters/connection_helpers.py +190 -0
  7. aipop/adapters/custom_http.py +235 -0
  8. aipop/adapters/error_handlers.py +299 -0
  9. aipop/adapters/huggingface.py +171 -0
  10. aipop/adapters/llamacpp.py +111 -0
  11. aipop/adapters/mcp/__init__.py +60 -0
  12. aipop/adapters/mcp/auth.py +237 -0
  13. aipop/adapters/mcp/capabilities.py +289 -0
  14. aipop/adapters/mcp/errors.py +342 -0
  15. aipop/adapters/mcp/methods/__init__.py +51 -0
  16. aipop/adapters/mcp/methods/completion.py +41 -0
  17. aipop/adapters/mcp/methods/lifecycle.py +85 -0
  18. aipop/adapters/mcp/methods/logging.py +53 -0
  19. aipop/adapters/mcp/methods/prompts.py +66 -0
  20. aipop/adapters/mcp/methods/resources.py +145 -0
  21. aipop/adapters/mcp/methods/tools.py +189 -0
  22. aipop/adapters/mcp/protocol.py +341 -0
  23. aipop/adapters/mcp/session.py +398 -0
  24. aipop/adapters/mcp/transports/__init__.py +35 -0
  25. aipop/adapters/mcp/transports/base.py +185 -0
  26. aipop/adapters/mcp/transports/http.py +480 -0
  27. aipop/adapters/mcp/transports/stdio.py +281 -0
  28. aipop/adapters/mcp/transports/websocket.py +328 -0
  29. aipop/adapters/mcp_adapter.py +607 -0
  30. aipop/adapters/mock.py +256 -0
  31. aipop/adapters/ollama.py +141 -0
  32. aipop/adapters/openai.py +218 -0
  33. aipop/adapters/quick_adapter.py +390 -0
  34. aipop/adapters/registry.py +182 -0
  35. aipop/adapters/wizard.py +397 -0
  36. aipop/callback/__init__.py +3 -0
  37. aipop/callback/server.py +124 -0
  38. aipop/cli/__init__.py +1 -0
  39. aipop/cli/__main__.py +8 -0
  40. aipop/cli/batch.py +330 -0
  41. aipop/cli/cached_lookup.py +121 -0
  42. aipop/cli/coverage.py +202 -0
  43. aipop/cli/ctf.py +226 -0
  44. aipop/cli/debug_commands.py +221 -0
  45. aipop/cli/display.py +318 -0
  46. aipop/cli/doctor.py +164 -0
  47. aipop/cli/errors.py +229 -0
  48. aipop/cli/harness.py +6162 -0
  49. aipop/cli/mcp_commands.py +419 -0
  50. aipop/cli/multi_model.py +315 -0
  51. aipop/cli/payload_import.py +144 -0
  52. aipop/cli/payloads.py +186 -0
  53. aipop/cli/repl.py +315 -0
  54. aipop/cli/sessions.py +336 -0
  55. aipop/cli/setup.py +259 -0
  56. aipop/cli/tools_invoke.py +193 -0
  57. aipop/cli/workspace_commands.py +227 -0
  58. aipop/configs/adversarial/autodan.yaml +58 -0
  59. aipop/configs/adversarial/gcg.yaml +78 -0
  60. aipop/configs/adversarial/judges.yaml +40 -0
  61. aipop/configs/adversarial/pair.yaml +49 -0
  62. aipop/configs/data/adversarial_suffixes.json +269 -0
  63. aipop/configs/data/guardrail_probes.yaml +228 -0
  64. aipop/configs/defaults/ci.yaml +47 -0
  65. aipop/configs/defaults/redteam.yaml +49 -0
  66. aipop/configs/harness.yaml +38 -0
  67. aipop/configs/mappings/eu_ai_act.yaml +16 -0
  68. aipop/configs/mappings/fedramp.yaml +10 -0
  69. aipop/configs/mappings/nist_ai_rmf.yaml +16 -0
  70. aipop/configs/mappings/owasp_agentic_top10.yaml +71 -0
  71. aipop/configs/mutation/default.yaml +31 -0
  72. aipop/configs/orchestrators/pyrit.yaml +28 -0
  73. aipop/configs/orchestrators/simple.yaml +7 -0
  74. aipop/configs/registry/benchmarks.yaml +22 -0
  75. aipop/configs/registry/tools.yaml +188 -0
  76. aipop/configs/schemas/fingerprints.sql +74 -0
  77. aipop/configs/schemas/payloads.sql +64 -0
  78. aipop/configs/toolkit.yaml +82 -0
  79. aipop/core/__init__.py +29 -0
  80. aipop/core/adapters.py +39 -0
  81. aipop/core/auth.py +253 -0
  82. aipop/core/detectors.py +63 -0
  83. aipop/core/engagement.py +119 -0
  84. aipop/core/error_classifier.py +156 -0
  85. aipop/core/gates.py +68 -0
  86. aipop/core/models.py +57 -0
  87. aipop/core/modes.py +237 -0
  88. aipop/core/morph.py +325 -0
  89. aipop/core/mutation_config.py +72 -0
  90. aipop/core/mutators.py +36 -0
  91. aipop/core/orchestrator_config.py +97 -0
  92. aipop/core/orchestrators.py +53 -0
  93. aipop/core/profiles.py +160 -0
  94. aipop/core/reporters.py +47 -0
  95. aipop/core/runners.py +27 -0
  96. aipop/core/scanner.py +280 -0
  97. aipop/core/session.py +163 -0
  98. aipop/core/session_store.py +154 -0
  99. aipop/core/target_profile.py +303 -0
  100. aipop/core/test_result.py +212 -0
  101. aipop/core/verbosity.py +101 -0
  102. aipop/core/workspace.py +313 -0
  103. aipop/ctf/__init__.py +10 -0
  104. aipop/ctf/attacker_config.py +221 -0
  105. aipop/ctf/ctf_display.py +204 -0
  106. aipop/ctf/intelligence/__init__.py +23 -0
  107. aipop/ctf/intelligence/mcp_response_parser.py +453 -0
  108. aipop/ctf/intelligence/mcp_scorers.py +380 -0
  109. aipop/ctf/intelligence/planner.py +284 -0
  110. aipop/ctf/intelligence/response_parser.py +304 -0
  111. aipop/ctf/intelligence/scorers.py +281 -0
  112. aipop/ctf/intelligence/state_machine.py +297 -0
  113. aipop/ctf/mcp_bridge.py +345 -0
  114. aipop/ctf/orchestrator.py +292 -0
  115. aipop/ctf/promptfoo_bridge.py +280 -0
  116. aipop/ctf/pyrit_bridge.py +229 -0
  117. aipop/ctf/strategies/__init__.py +8 -0
  118. aipop/ctf/strategies/payloads/payload_engine.py +238 -0
  119. aipop/ctf/strategies/registry.py +267 -0
  120. aipop/detectors/__init__.py +17 -0
  121. aipop/detectors/behavioral.py +488 -0
  122. aipop/detectors/canary.py +89 -0
  123. aipop/detectors/canonicalize.py +160 -0
  124. aipop/detectors/cascade.py +431 -0
  125. aipop/detectors/harmful_content.py +174 -0
  126. aipop/detectors/llm_judge.py +195 -0
  127. aipop/detectors/tool_policy.py +100 -0
  128. aipop/executors/__init__.py +7 -0
  129. aipop/executors/recipe_executor.py +372 -0
  130. aipop/fuzz/__init__.py +3 -0
  131. aipop/fuzz/engine.py +322 -0
  132. aipop/gates/__init__.py +17 -0
  133. aipop/gates/threshold_gate.py +313 -0
  134. aipop/gates/tool_integrity.py +208 -0
  135. aipop/harnesses/__init__.py +0 -0
  136. aipop/harnesses/a2a_integrity.py +265 -0
  137. aipop/harnesses/approval_manipulation.py +239 -0
  138. aipop/harnesses/execution_sandbox.py +139 -0
  139. aipop/harnesses/memory_poisoning.py +214 -0
  140. aipop/harnesses/multi_agent_runner.py +216 -0
  141. aipop/harnesses/principal_propagation.py +199 -0
  142. aipop/harnesses/registry.py +527 -0
  143. aipop/harnesses/retrieval_injection.py +153 -0
  144. aipop/harnesses/tool_interception.py +235 -0
  145. aipop/integrations/__init__.py +7 -0
  146. aipop/integrations/base.py +200 -0
  147. aipop/integrations/exceptions.py +17 -0
  148. aipop/integrations/garak.py +237 -0
  149. aipop/integrations/orchestrator.py +182 -0
  150. aipop/integrations/promptfoo.py +237 -0
  151. aipop/integrations/promptinject.py +255 -0
  152. aipop/integrations/pyrit.py +283 -0
  153. aipop/intelligence/__init__.py +21 -0
  154. aipop/intelligence/adversarial_suffix.py +633 -0
  155. aipop/intelligence/autodan.py +505 -0
  156. aipop/intelligence/conversation_replay.py +213 -0
  157. aipop/intelligence/discovery.py +413 -0
  158. aipop/intelligence/fingerprint_engine.py +546 -0
  159. aipop/intelligence/fingerprint_models.py +119 -0
  160. aipop/intelligence/gcg_core.py +501 -0
  161. aipop/intelligence/guardrail_fingerprint.py +428 -0
  162. aipop/intelligence/har_exporter.py +329 -0
  163. aipop/intelligence/http_recon.py +485 -0
  164. aipop/intelligence/judge_ensemble.py +157 -0
  165. aipop/intelligence/judge_models.py +637 -0
  166. aipop/intelligence/llm_classifier.py +99 -0
  167. aipop/intelligence/pair.py +411 -0
  168. aipop/intelligence/pattern_matchers.py +375 -0
  169. aipop/intelligence/plugins/__init__.py +20 -0
  170. aipop/intelligence/plugins/autodan_official.py +242 -0
  171. aipop/intelligence/plugins/base.py +154 -0
  172. aipop/intelligence/plugins/executor.py +219 -0
  173. aipop/intelligence/plugins/gcg_official.py +216 -0
  174. aipop/intelligence/plugins/install.py +550 -0
  175. aipop/intelligence/plugins/loader.py +587 -0
  176. aipop/intelligence/plugins/pair_official.py +334 -0
  177. aipop/intelligence/probe_generator.py +91 -0
  178. aipop/intelligence/probe_library.py +65 -0
  179. aipop/intelligence/rate_limiter.py +231 -0
  180. aipop/intelligence/recon.py +730 -0
  181. aipop/intelligence/stealth_engine.py +306 -0
  182. aipop/intelligence/traffic_capture.py +398 -0
  183. aipop/loaders/__init__.py +24 -0
  184. aipop/loaders/policy_loader.py +208 -0
  185. aipop/loaders/recipe_loader.py +186 -0
  186. aipop/loaders/suite_registry.py +222 -0
  187. aipop/loaders/yaml_suite.py +280 -0
  188. aipop/mutators/__init__.py +27 -0
  189. aipop/mutators/encoding.py +102 -0
  190. aipop/mutators/gcg_mutator.py +134 -0
  191. aipop/mutators/genetic.py +153 -0
  192. aipop/mutators/html.py +68 -0
  193. aipop/mutators/mutation_engine.py +278 -0
  194. aipop/mutators/paraphrasing.py +150 -0
  195. aipop/mutators/unicode_mutator.py +125 -0
  196. aipop/orchestrators/__init__.py +5 -0
  197. aipop/orchestrators/pyrit.py +507 -0
  198. aipop/orchestrators/simple.py +229 -0
  199. aipop/payloads/__init__.py +12 -0
  200. aipop/payloads/craft.py +184 -0
  201. aipop/payloads/git_sync.py +171 -0
  202. aipop/payloads/payload_manager.py +492 -0
  203. aipop/payloads/seclists_importer.py +214 -0
  204. aipop/policies/content_policy.yaml +56 -0
  205. aipop/policies/tool_allowlist.yaml +27 -0
  206. aipop/recipes/agentic/agentic_full.yaml +64 -0
  207. aipop/recipes/compliance/nist_measure.yaml +50 -0
  208. aipop/recipes/safety/content_policy_baseline.yaml +44 -0
  209. aipop/recipes/security/full_redteam.yaml +91 -0
  210. aipop/recipes/security/prompt_injection_baseline.yaml +42 -0
  211. aipop/redteam/__init__.py +8 -0
  212. aipop/redteam/aggregator.py +108 -0
  213. aipop/redteam/indirect_injection.py +336 -0
  214. aipop/redteam/models.py +22 -0
  215. aipop/reporters/__init__.py +8 -0
  216. aipop/reporters/cli_vuln_report.py +315 -0
  217. aipop/reporters/cvss_cwe_taxonomy.py +523 -0
  218. aipop/reporters/evidence_pack.py +274 -0
  219. aipop/reporters/html_reporter.py +466 -0
  220. aipop/reporters/json_reporter.py +216 -0
  221. aipop/reporters/junit_reporter.py +132 -0
  222. aipop/reporters/pdf_generator.py +357 -0
  223. aipop/reporters/pdf_report.py +186 -0
  224. aipop/reporters/platform_export.py +166 -0
  225. aipop/reporters/run_diff.py +145 -0
  226. aipop/runners/__init__.py +7 -0
  227. aipop/runners/chain.py +494 -0
  228. aipop/runners/live.py +494 -0
  229. aipop/runners/mock.py +573 -0
  230. aipop/schemas/__init__.py +1 -0
  231. aipop/schemas/recipe.schema.json +132 -0
  232. aipop/setup/__init__.py +5 -0
  233. aipop/setup/installer.py +229 -0
  234. aipop/setup/profiles.py +105 -0
  235. aipop/storage/__init__.py +15 -0
  236. aipop/storage/attack_cache.py +758 -0
  237. aipop/storage/fingerprint_db.py +130 -0
  238. aipop/storage/mutation_db.py +222 -0
  239. aipop/storage/response_cache.py +318 -0
  240. aipop/storage/suffix_db.py +228 -0
  241. aipop/suites/adapters/adapter_validation.yaml +61 -0
  242. aipop/suites/adversarial/context_confusion.yaml +120 -0
  243. aipop/suites/adversarial/delayed_payloads.yaml +98 -0
  244. aipop/suites/adversarial/encoding_chains.yaml +138 -0
  245. aipop/suites/adversarial/fuzz_tests.yaml +221 -0
  246. aipop/suites/adversarial/gcg_attacks.yaml +372 -0
  247. aipop/suites/adversarial/multi_turn_crescendo.yaml +104 -0
  248. aipop/suites/adversarial/rag_injection.yaml +110 -0
  249. aipop/suites/adversarial/tool_misuse.yaml +120 -0
  250. aipop/suites/adversarial/unicode_bypass.yaml +191 -0
  251. aipop/suites/agentic/cve_regression.yaml +164 -0
  252. aipop/suites/archived/2022_basic_jailbreak.yaml +70 -0
  253. aipop/suites/comparison/model_comparison.yaml +61 -0
  254. aipop/suites/normal/basic_utility.yaml +34 -0
  255. aipop/suites/policies/content_safety.yaml +121 -0
  256. aipop/suites/rag/rag_poisoning.yaml +202 -0
  257. aipop/suites/redteam/prompt_injection_advanced.yaml +304 -0
  258. aipop/suites/tools/tool_policy_validation.yaml +73 -0
  259. aipop/suites/ui/injection_attacks.yaml +219 -0
  260. aipop/tools/__init__.py +21 -0
  261. aipop/tools/installer.py +314 -0
  262. aipop/utils/adapter_paths.py +40 -0
  263. aipop/utils/confidence_intervals.py +282 -0
  264. aipop/utils/config.py +152 -0
  265. aipop/utils/cost_estimator.py +52 -0
  266. aipop/utils/cost_tracker.py +367 -0
  267. aipop/utils/dependency_check.py +84 -0
  268. aipop/utils/device_detection.py +121 -0
  269. aipop/utils/error_handling.py +225 -0
  270. aipop/utils/errors.py +17 -0
  271. aipop/utils/first_run.py +144 -0
  272. aipop/utils/gate_display.py +93 -0
  273. aipop/utils/local_models.py +149 -0
  274. aipop/utils/log_utils.py +66 -0
  275. aipop/utils/paths.py +291 -0
  276. aipop/utils/preflight.py +21 -0
  277. aipop/utils/progress.py +274 -0
  278. aipop/utils/rate_limiter.py +40 -0
  279. aipop/utils/schema_resolver.py +77 -0
  280. aipop/utils/security.py +246 -0
  281. aipop/utils/security_check.py +216 -0
  282. aipop/utils/setup_wizard.py +256 -0
  283. aipop/utils/validation.py +49 -0
  284. aipop/validation/__init__.py +5 -0
  285. aipop/validation/preflight.py +220 -0
  286. aipop/verification/__init__.py +20 -0
  287. aipop/verification/multi_turn_scorer.py +217 -0
  288. aipop/verification/report_generator.py +440 -0
  289. aipop/verification/statistical_tests.py +202 -0
  290. aipop/verification/verifier.py +412 -0
  291. aipop/workflow/__init__.py +5 -0
  292. aipop/workflow/engagement_tracker.py +317 -0
  293. aipop-0.7.0.dist-info/METADATA +244 -0
  294. aipop-0.7.0.dist-info/RECORD +298 -0
  295. aipop-0.7.0.dist-info/WHEEL +5 -0
  296. aipop-0.7.0.dist-info/entry_points.txt +2 -0
  297. aipop-0.7.0.dist-info/licenses/LICENSE +21 -0
  298. aipop-0.7.0.dist-info/top_level.txt +1 -0
aipop/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """AI Purple Ops core package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+ __version__ = "0.6.5"
@@ -0,0 +1,35 @@
1
+ """Model adapters for test execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .anthropic import AnthropicAdapter
6
+ from .bedrock import BedrockAdapter
7
+ from .huggingface import HuggingFaceAdapter
8
+ from .llamacpp import LlamaCppAdapter
9
+ from .mcp_adapter import MCPAdapter
10
+ from .mock import MockAdapter
11
+ from .ollama import OllamaAdapter
12
+ from .openai import OpenAIAdapter
13
+ from .registry import AdapterRegistry
14
+
15
+ # Auto-register built-in adapters
16
+ AdapterRegistry.register("mock", MockAdapter)
17
+ AdapterRegistry.register("openai", OpenAIAdapter)
18
+ AdapterRegistry.register("anthropic", AnthropicAdapter)
19
+ AdapterRegistry.register("bedrock", BedrockAdapter)
20
+ AdapterRegistry.register("huggingface", HuggingFaceAdapter)
21
+ AdapterRegistry.register("ollama", OllamaAdapter)
22
+ AdapterRegistry.register("llamacpp", LlamaCppAdapter)
23
+ AdapterRegistry.register("mcp", MCPAdapter)
24
+
25
+ __all__ = [
26
+ "AdapterRegistry",
27
+ "AnthropicAdapter",
28
+ "BedrockAdapter",
29
+ "HuggingFaceAdapter",
30
+ "LlamaCppAdapter",
31
+ "MCPAdapter",
32
+ "MockAdapter",
33
+ "OllamaAdapter",
34
+ "OpenAIAdapter",
35
+ ]
@@ -0,0 +1,228 @@
1
+ """Anthropic Claude adapter with robust error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from typing import Any
9
+
10
+ from tenacity import (
11
+ retry,
12
+ retry_if_exception_type,
13
+ stop_after_attempt,
14
+ wait_exponential,
15
+ )
16
+
17
+ from aipop.core.models import ModelResponse
18
+ from aipop.utils.rate_limiter import RateLimiter
19
+
20
+
21
+ class AnthropicAdapter:
22
+ """Anthropic Claude API adapter."""
23
+
24
+ def __init__(
25
+ self,
26
+ api_key: str | None = None,
27
+ model: str = "claude-3-opus-20240229",
28
+ timeout: int = 30,
29
+ max_retries: int = 3,
30
+ rpm_limit: int = 50,
31
+ proxy: str | None = None,
32
+ ) -> None:
33
+ """Initialize Anthropic adapter.
34
+
35
+ Args:
36
+ api_key: Anthropic API key (defaults to ANTHROPIC_API_KEY env var)
37
+ model: Model name (claude-3-opus, claude-3-sonnet, etc.)
38
+ timeout: Request timeout in seconds
39
+ max_retries: Maximum retry attempts
40
+ rpm_limit: Requests per minute limit (default: 50)
41
+ proxy: HTTP/SOCKS5 proxy URL (e.g., http://127.0.0.1:8080)
42
+ """
43
+ self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
44
+ if not self.api_key:
45
+ raise ValueError(
46
+ "Anthropic API key not found. Set ANTHROPIC_API_KEY environment variable.\n"
47
+ "Example: export ANTHROPIC_API_KEY=sk-ant-...\n"
48
+ "Or use --adapter mock for testing without API keys."
49
+ )
50
+
51
+ self.model = model
52
+ self.timeout = timeout
53
+ self.max_retries = max_retries
54
+ self.proxy = proxy or os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY")
55
+ self.rate_limiter = RateLimiter(rpm=rpm_limit)
56
+
57
+ try:
58
+ from anthropic import Anthropic
59
+
60
+ # Route through proxy if configured
61
+ http_client = None
62
+ if self.proxy:
63
+ try:
64
+ import httpx
65
+ http_client = httpx.Client(proxy=self.proxy, verify=False)
66
+ except ImportError:
67
+ pass
68
+
69
+ self.client = Anthropic(
70
+ api_key=self.api_key,
71
+ timeout=self.timeout,
72
+ http_client=http_client,
73
+ )
74
+ except ImportError as e:
75
+ msg = "Anthropic SDK not installed. Install with: pip install anthropic"
76
+ raise ImportError(msg) from e
77
+
78
+ @retry(
79
+ retry=retry_if_exception_type((Exception,)),
80
+ stop=stop_after_attempt(3),
81
+ wait=wait_exponential(multiplier=1, min=1, max=4),
82
+ )
83
+ def invoke(self, prompt: str, **kwargs: Any) -> ModelResponse: # noqa: ANN401
84
+ """Invoke Claude model with prompt.
85
+
86
+ Args:
87
+ prompt: Input prompt
88
+ **kwargs: Additional parameters (temperature, max_tokens, etc.)
89
+
90
+ Returns:
91
+ ModelResponse with text and metadata
92
+ """
93
+ # Rate limit before API call
94
+ self.rate_limiter.acquire()
95
+
96
+ start_time = time.time()
97
+
98
+ try:
99
+ response = self.client.messages.create(
100
+ model=self.model,
101
+ max_tokens=kwargs.get("max_tokens", 1024),
102
+ messages=[{"role": "user", "content": prompt}],
103
+ )
104
+
105
+ latency_ms = (time.time() - start_time) * 1000
106
+
107
+ # Extract text content
108
+ text = ""
109
+ tool_calls = None
110
+
111
+ if response.content:
112
+ # Extract text from text blocks
113
+ text_blocks = [
114
+ block.text
115
+ for block in response.content
116
+ if hasattr(block, "type") and block.type == "text"
117
+ ]
118
+ text = "".join(text_blocks)
119
+
120
+ # Extract tool_use blocks
121
+ tool_blocks = [
122
+ block
123
+ for block in response.content
124
+ if hasattr(block, "type") and block.type == "tool_use"
125
+ ]
126
+ if tool_blocks:
127
+ tool_calls = [
128
+ {
129
+ "id": block.id,
130
+ "name": block.name,
131
+ "arguments": block.input if hasattr(block, "input") else {},
132
+ }
133
+ for block in tool_blocks
134
+ ]
135
+
136
+ # Calculate cost (approximate)
137
+ cost = self._calculate_cost(
138
+ response.usage.input_tokens,
139
+ response.usage.output_tokens,
140
+ )
141
+
142
+ return ModelResponse(
143
+ text=text,
144
+ tool_calls=tool_calls,
145
+ meta={
146
+ "model": self.model,
147
+ "latency_ms": latency_ms,
148
+ "tokens_prompt": response.usage.input_tokens,
149
+ "tokens_completion": response.usage.output_tokens,
150
+ "cost_usd": cost,
151
+ "finish_reason": response.stop_reason,
152
+ },
153
+ )
154
+
155
+ except Exception as e:
156
+ latency_ms = (time.time() - start_time) * 1000
157
+ raise RuntimeError(f"Anthropic API error: {e}") from e
158
+
159
+ def batch_query(self, prompts: list[str], **kwargs: Any) -> list[ModelResponse]: # noqa: ANN401
160
+ """Execute batch of prompts in parallel using ThreadPoolExecutor.
161
+
162
+ Args:
163
+ prompts: List of prompts to execute
164
+ **kwargs: Additional parameters passed to invoke()
165
+
166
+ Returns:
167
+ List of ModelResponse objects in same order as prompts
168
+ """
169
+ # Use ThreadPoolExecutor for parallel execution
170
+ # Limit to 10 concurrent requests to avoid overwhelming the API
171
+ max_workers = min(10, len(prompts))
172
+
173
+ results = [None] * len(prompts)
174
+
175
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
176
+ # Submit all tasks
177
+ future_to_index = {
178
+ executor.submit(self.invoke, prompt, **kwargs): i
179
+ for i, prompt in enumerate(prompts)
180
+ }
181
+
182
+ # Collect results as they complete
183
+ for future in as_completed(future_to_index):
184
+ index = future_to_index[future]
185
+ try:
186
+ results[index] = future.result()
187
+ except Exception as e:
188
+ # Create error response for failed requests
189
+ results[index] = ModelResponse(
190
+ text="",
191
+ meta={
192
+ "error": str(e),
193
+ "model": self.model,
194
+ "latency_ms": 0,
195
+ "cost_usd": 0,
196
+ },
197
+ )
198
+
199
+ return results
200
+
201
+ def _calculate_cost(self, input_tokens: int, output_tokens: int) -> float:
202
+ """Calculate approximate cost in USD.
203
+
204
+ Args:
205
+ input_tokens: Input tokens
206
+ output_tokens: Output tokens
207
+
208
+ Returns:
209
+ Cost in USD
210
+ """
211
+ # Pricing as of 2025-11-13 (approximate)
212
+ pricing = {
213
+ "claude-3-opus": {"input": 0.015 / 1000, "output": 0.075 / 1000},
214
+ "claude-3-sonnet": {"input": 0.003 / 1000, "output": 0.015 / 1000},
215
+ "claude-3-haiku": {"input": 0.00025 / 1000, "output": 0.00125 / 1000},
216
+ }
217
+
218
+ model_parts = self.model.split("-")[0:3] # Extract parts: ["claude", "3", "opus"]
219
+ model_key = "-".join(model_parts) # Join to "claude-3-opus"
220
+ if model_key not in pricing:
221
+ model_key = "claude-3-sonnet" # Default
222
+
223
+ cost = (
224
+ input_tokens * pricing[model_key]["input"]
225
+ + output_tokens * pricing[model_key]["output"]
226
+ )
227
+
228
+ return cost
@@ -0,0 +1,343 @@
1
+ """Auto-probe adapter — discovers how to talk to a target from its URL.
2
+
3
+ The zero-friction path: `aipop scan --target http://localhost:8000/chat`
4
+ No YAML. No config. AIPOP figures out the request/response format.
5
+
6
+ Probe strategy:
7
+ 1. Check for OpenAPI/Swagger spec at common paths
8
+ 2. Send probe requests with common field names
9
+ 3. Use whatever works
10
+
11
+ If auto-probe fails, tell the user exactly what to specify.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from typing import Any
18
+
19
+ import requests
20
+
21
+ from aipop.adapters.custom_http import CustomHTTPAdapter
22
+ from aipop.core.models import ModelResponse
23
+
24
+
25
+ # Common prompt field names across AI APIs
26
+ PROMPT_FIELDS = ["message", "prompt", "query", "input", "text", "content", "question"]
27
+
28
+ # Common response field names across AI APIs
29
+ RESPONSE_FIELDS = ["response", "reply", "output", "text", "answer", "content", "result",
30
+ "choices.0.message.content", "data.response", "generated_text"]
31
+
32
+ # Common OpenAPI/docs paths
33
+ OPENAPI_PATHS = ["/openapi.json", "/docs", "/swagger.json", "/api-docs",
34
+ "/.well-known/openapi.json"]
35
+
36
+ PROBE_MESSAGE = "Hello, can you help me?"
37
+
38
+
39
+ def probe_target(target_url: str, timeout: int = 15) -> dict[str, Any]:
40
+ """Probe a target URL and discover its request/response format.
41
+
42
+ Args:
43
+ target_url: The endpoint URL to probe
44
+ timeout: Request timeout in seconds
45
+
46
+ Returns:
47
+ Config dict compatible with CustomHTTPAdapter
48
+
49
+ Raises:
50
+ ProbeError: If the target can't be reached or format can't be determined
51
+ """
52
+ results = {
53
+ "base_url": target_url,
54
+ "method": "POST",
55
+ "prompt_field": None,
56
+ "response_field": None,
57
+ "extra_fields": {},
58
+ "openapi": None,
59
+ "probe_log": [],
60
+ }
61
+
62
+ # Step 1: Try to find OpenAPI spec
63
+ base = _extract_base_url(target_url)
64
+ for path in OPENAPI_PATHS:
65
+ try:
66
+ r = requests.get(f"{base}{path}", timeout=5)
67
+ if r.status_code == 200 and "paths" in r.text:
68
+ results["openapi"] = r.json()
69
+ results["probe_log"].append(f"Found OpenAPI spec at {base}{path}")
70
+ _extract_from_openapi(results, target_url)
71
+ if results["prompt_field"] and results["response_field"]:
72
+ return results
73
+ break
74
+ except Exception:
75
+ continue
76
+
77
+ # Step 2: Probe with common field names
78
+ for prompt_field in PROMPT_FIELDS:
79
+ body = {prompt_field: PROBE_MESSAGE}
80
+
81
+ try:
82
+ r = requests.post(target_url, json=body, timeout=timeout,
83
+ headers={"Content-Type": "application/json"})
84
+ except requests.ConnectionError:
85
+ raise ProbeError(
86
+ f"Can't connect to {target_url}. Is the target running?"
87
+ )
88
+ except requests.Timeout:
89
+ raise ProbeError(
90
+ f"Target at {target_url} timed out after {timeout}s."
91
+ )
92
+
93
+ if r.status_code >= 500:
94
+ results["probe_log"].append(
95
+ f" {prompt_field} → {r.status_code} (server error)")
96
+ continue
97
+
98
+ if r.status_code == 422:
99
+ # Validation error — wrong field name, try next
100
+ results["probe_log"].append(
101
+ f" {prompt_field} → 422 (validation error, wrong field)")
102
+ continue
103
+
104
+ if r.status_code == 405:
105
+ # Method not allowed — might be GET
106
+ results["probe_log"].append(
107
+ f" POST → 405 (method not allowed)")
108
+ break
109
+
110
+ if r.status_code in (200, 201):
111
+ results["prompt_field"] = prompt_field
112
+ results["probe_log"].append(
113
+ f" {prompt_field} → {r.status_code} (accepted!)")
114
+
115
+ # Now find the response field
116
+ try:
117
+ data = r.json()
118
+ for response_field in RESPONSE_FIELDS:
119
+ value = _extract_nested(data, response_field)
120
+ if value is not None and isinstance(value, str) and len(value) > 5:
121
+ results["response_field"] = response_field
122
+ results["probe_log"].append(
123
+ f" Response field: {response_field}")
124
+ return results
125
+
126
+ # Didn't find a known field — list what's available
127
+ available = _list_string_fields(data)
128
+ if available:
129
+ # Pick the first string field that looks like a response
130
+ best = _pick_best_response_field(available, data)
131
+ if best:
132
+ results["response_field"] = best
133
+ results["probe_log"].append(
134
+ f" Response field (guessed): {best}")
135
+ return results
136
+
137
+ results["probe_log"].append(
138
+ f" Got response but can't find text field. "
139
+ f"Available: {available}")
140
+ except ValueError:
141
+ results["probe_log"].append(
142
+ f" Got {r.status_code} but response isn't JSON")
143
+ continue
144
+
145
+ if results["prompt_field"] and not results["response_field"]:
146
+ raise ProbeError(
147
+ f"Target accepts requests with field '{results['prompt_field']}' "
148
+ f"but the response format is unknown.\n"
149
+ f"Use --response-field to specify which JSON field contains the text.\n"
150
+ f"Probe log:\n" + "\n".join(results["probe_log"])
151
+ )
152
+
153
+ if not results["prompt_field"]:
154
+ raise ProbeError(
155
+ f"Can't figure out how to talk to {target_url}.\n"
156
+ f"Tried prompt fields: {', '.join(PROMPT_FIELDS)}\n"
157
+ f"Use --prompt-field and --response-field to specify manually.\n"
158
+ f"Or create an adapter YAML: cp templates/adapters/custom_http.yaml "
159
+ f"adapters/my_target.yaml\n"
160
+ f"Probe log:\n" + "\n".join(results["probe_log"])
161
+ )
162
+
163
+ return results
164
+
165
+
166
+ def build_adapter_from_probe(
167
+ target_url: str,
168
+ prompt_field: str | None = None,
169
+ response_field: str | None = None,
170
+ timeout: int = 15,
171
+ ) -> CustomHTTPAdapter:
172
+ """Build a working adapter for a target URL.
173
+
174
+ Auto-probes if field names aren't specified. Uses explicit values if given.
175
+
176
+ Args:
177
+ target_url: The endpoint URL
178
+ prompt_field: Override prompt field name (skip probe for request format)
179
+ response_field: Override response field name (skip probe for response format)
180
+ timeout: Request timeout
181
+
182
+ Returns:
183
+ Ready-to-use CustomHTTPAdapter
184
+ """
185
+ if prompt_field and response_field:
186
+ # User told us everything — no probe needed
187
+ config = {
188
+ "connection": {
189
+ "base_url": target_url,
190
+ "method": "POST",
191
+ "timeout": timeout,
192
+ "headers": {"Content-Type": "application/json"},
193
+ },
194
+ "auth": {"type": "none"},
195
+ "request": {"prompt_field": prompt_field},
196
+ "response": {"text_field": response_field},
197
+ }
198
+ return CustomHTTPAdapter(config)
199
+
200
+ # Probe the target
201
+ probe_result = probe_target(target_url, timeout=timeout)
202
+
203
+ # Override with explicit values if given
204
+ if prompt_field:
205
+ probe_result["prompt_field"] = prompt_field
206
+ if response_field:
207
+ probe_result["response_field"] = response_field
208
+
209
+ config = {
210
+ "connection": {
211
+ "base_url": probe_result["base_url"],
212
+ "method": probe_result["method"],
213
+ "timeout": timeout,
214
+ "headers": {"Content-Type": "application/json"},
215
+ },
216
+ "auth": {"type": "none"},
217
+ "request": {
218
+ "prompt_field": probe_result["prompt_field"],
219
+ "extra_fields": probe_result.get("extra_fields", {}),
220
+ },
221
+ "response": {"text_field": probe_result["response_field"]},
222
+ }
223
+ return CustomHTTPAdapter(config)
224
+
225
+
226
+ def _extract_base_url(url: str) -> str:
227
+ """Extract scheme + host from a full URL."""
228
+ from urllib.parse import urlparse
229
+ parsed = urlparse(url)
230
+ return f"{parsed.scheme}://{parsed.netloc}"
231
+
232
+
233
+ def _extract_nested(data: dict, path: str) -> Any:
234
+ """Extract a value from a nested dict using dot notation."""
235
+ current = data
236
+ for part in path.split("."):
237
+ if isinstance(current, dict) and part in current:
238
+ current = current[part]
239
+ elif isinstance(current, list):
240
+ try:
241
+ current = current[int(part)]
242
+ except (ValueError, IndexError):
243
+ return None
244
+ else:
245
+ return None
246
+ return current
247
+
248
+
249
+ def _list_string_fields(data: dict, prefix: str = "") -> list[str]:
250
+ """List all fields that contain strings in a JSON response."""
251
+ fields = []
252
+ if not isinstance(data, dict):
253
+ return fields
254
+ for key, value in data.items():
255
+ path = f"{prefix}.{key}" if prefix else key
256
+ if isinstance(value, str):
257
+ fields.append(path)
258
+ elif isinstance(value, dict):
259
+ fields.extend(_list_string_fields(value, path))
260
+ return fields
261
+
262
+
263
+ def _pick_best_response_field(fields: list[str], data: dict) -> str | None:
264
+ """Pick the most likely response field from available string fields.
265
+
266
+ Heuristic: longest string value that isn't a field like 'id', 'status', 'model'.
267
+ """
268
+ skip_patterns = {"id", "status", "model", "type", "name", "version",
269
+ "created", "object", "app", "error"}
270
+
271
+ candidates = []
272
+ for field in fields:
273
+ leaf = field.split(".")[-1].lower()
274
+ if leaf in skip_patterns:
275
+ continue
276
+ value = _extract_nested(data, field)
277
+ if isinstance(value, str) and len(value) > 10:
278
+ candidates.append((field, len(value)))
279
+
280
+ if not candidates:
281
+ return None
282
+
283
+ # Return the field with the longest value — most likely the actual response
284
+ candidates.sort(key=lambda x: x[1], reverse=True)
285
+ return candidates[0][0]
286
+
287
+
288
+ def _extract_from_openapi(results: dict, target_url: str) -> None:
289
+ """Try to extract prompt/response fields from an OpenAPI spec."""
290
+ spec = results.get("openapi", {})
291
+ if not spec:
292
+ return
293
+
294
+ # Find the path that matches our target URL
295
+ from urllib.parse import urlparse
296
+ parsed = urlparse(target_url)
297
+ target_path = parsed.path
298
+
299
+ paths = spec.get("paths", {})
300
+ if target_path not in paths:
301
+ return
302
+
303
+ post = paths[target_path].get("post", {})
304
+ if not post:
305
+ return
306
+
307
+ # Check request body schema
308
+ body = post.get("requestBody", {})
309
+ schema_ref = (body.get("content", {})
310
+ .get("application/json", {})
311
+ .get("schema", {}))
312
+
313
+ # Resolve $ref
314
+ if "$ref" in schema_ref:
315
+ ref_name = schema_ref["$ref"].split("/")[-1]
316
+ schema = spec.get("components", {}).get("schemas", {}).get(ref_name, {})
317
+ else:
318
+ schema = schema_ref
319
+
320
+ props = schema.get("properties", {})
321
+ required = schema.get("required", [])
322
+
323
+ # Find the prompt field — required string field, or common names
324
+ for field_name in PROMPT_FIELDS:
325
+ if field_name in props:
326
+ results["prompt_field"] = field_name
327
+ results["probe_log"].append(
328
+ f" OpenAPI: prompt field = {field_name}")
329
+ break
330
+
331
+ if not results["prompt_field"] and required:
332
+ # Use the first required string field
333
+ for req in required:
334
+ if req in props and props[req].get("type") == "string":
335
+ results["prompt_field"] = req
336
+ results["probe_log"].append(
337
+ f" OpenAPI: prompt field (from required) = {req}")
338
+ break
339
+
340
+
341
+ class ProbeError(Exception):
342
+ """Raised when auto-probe can't determine how to talk to the target."""
343
+ pass