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.
- aipop/__init__.py +6 -0
- aipop/adapters/__init__.py +35 -0
- aipop/adapters/anthropic.py +228 -0
- aipop/adapters/auto_probe.py +343 -0
- aipop/adapters/bedrock.py +153 -0
- aipop/adapters/connection_helpers.py +190 -0
- aipop/adapters/custom_http.py +235 -0
- aipop/adapters/error_handlers.py +299 -0
- aipop/adapters/huggingface.py +171 -0
- aipop/adapters/llamacpp.py +111 -0
- aipop/adapters/mcp/__init__.py +60 -0
- aipop/adapters/mcp/auth.py +237 -0
- aipop/adapters/mcp/capabilities.py +289 -0
- aipop/adapters/mcp/errors.py +342 -0
- aipop/adapters/mcp/methods/__init__.py +51 -0
- aipop/adapters/mcp/methods/completion.py +41 -0
- aipop/adapters/mcp/methods/lifecycle.py +85 -0
- aipop/adapters/mcp/methods/logging.py +53 -0
- aipop/adapters/mcp/methods/prompts.py +66 -0
- aipop/adapters/mcp/methods/resources.py +145 -0
- aipop/adapters/mcp/methods/tools.py +189 -0
- aipop/adapters/mcp/protocol.py +341 -0
- aipop/adapters/mcp/session.py +398 -0
- aipop/adapters/mcp/transports/__init__.py +35 -0
- aipop/adapters/mcp/transports/base.py +185 -0
- aipop/adapters/mcp/transports/http.py +480 -0
- aipop/adapters/mcp/transports/stdio.py +281 -0
- aipop/adapters/mcp/transports/websocket.py +328 -0
- aipop/adapters/mcp_adapter.py +607 -0
- aipop/adapters/mock.py +256 -0
- aipop/adapters/ollama.py +141 -0
- aipop/adapters/openai.py +218 -0
- aipop/adapters/quick_adapter.py +390 -0
- aipop/adapters/registry.py +182 -0
- aipop/adapters/wizard.py +397 -0
- aipop/callback/__init__.py +3 -0
- aipop/callback/server.py +124 -0
- aipop/cli/__init__.py +1 -0
- aipop/cli/__main__.py +8 -0
- aipop/cli/batch.py +330 -0
- aipop/cli/cached_lookup.py +121 -0
- aipop/cli/coverage.py +202 -0
- aipop/cli/ctf.py +226 -0
- aipop/cli/debug_commands.py +221 -0
- aipop/cli/display.py +318 -0
- aipop/cli/doctor.py +164 -0
- aipop/cli/errors.py +229 -0
- aipop/cli/harness.py +6162 -0
- aipop/cli/mcp_commands.py +419 -0
- aipop/cli/multi_model.py +315 -0
- aipop/cli/payload_import.py +144 -0
- aipop/cli/payloads.py +186 -0
- aipop/cli/repl.py +315 -0
- aipop/cli/sessions.py +336 -0
- aipop/cli/setup.py +259 -0
- aipop/cli/tools_invoke.py +193 -0
- aipop/cli/workspace_commands.py +227 -0
- aipop/configs/adversarial/autodan.yaml +58 -0
- aipop/configs/adversarial/gcg.yaml +78 -0
- aipop/configs/adversarial/judges.yaml +40 -0
- aipop/configs/adversarial/pair.yaml +49 -0
- aipop/configs/data/adversarial_suffixes.json +269 -0
- aipop/configs/data/guardrail_probes.yaml +228 -0
- aipop/configs/defaults/ci.yaml +47 -0
- aipop/configs/defaults/redteam.yaml +49 -0
- aipop/configs/harness.yaml +38 -0
- aipop/configs/mappings/eu_ai_act.yaml +16 -0
- aipop/configs/mappings/fedramp.yaml +10 -0
- aipop/configs/mappings/nist_ai_rmf.yaml +16 -0
- aipop/configs/mappings/owasp_agentic_top10.yaml +71 -0
- aipop/configs/mutation/default.yaml +31 -0
- aipop/configs/orchestrators/pyrit.yaml +28 -0
- aipop/configs/orchestrators/simple.yaml +7 -0
- aipop/configs/registry/benchmarks.yaml +22 -0
- aipop/configs/registry/tools.yaml +188 -0
- aipop/configs/schemas/fingerprints.sql +74 -0
- aipop/configs/schemas/payloads.sql +64 -0
- aipop/configs/toolkit.yaml +82 -0
- aipop/core/__init__.py +29 -0
- aipop/core/adapters.py +39 -0
- aipop/core/auth.py +253 -0
- aipop/core/detectors.py +63 -0
- aipop/core/engagement.py +119 -0
- aipop/core/error_classifier.py +156 -0
- aipop/core/gates.py +68 -0
- aipop/core/models.py +57 -0
- aipop/core/modes.py +237 -0
- aipop/core/morph.py +325 -0
- aipop/core/mutation_config.py +72 -0
- aipop/core/mutators.py +36 -0
- aipop/core/orchestrator_config.py +97 -0
- aipop/core/orchestrators.py +53 -0
- aipop/core/profiles.py +160 -0
- aipop/core/reporters.py +47 -0
- aipop/core/runners.py +27 -0
- aipop/core/scanner.py +280 -0
- aipop/core/session.py +163 -0
- aipop/core/session_store.py +154 -0
- aipop/core/target_profile.py +303 -0
- aipop/core/test_result.py +212 -0
- aipop/core/verbosity.py +101 -0
- aipop/core/workspace.py +313 -0
- aipop/ctf/__init__.py +10 -0
- aipop/ctf/attacker_config.py +221 -0
- aipop/ctf/ctf_display.py +204 -0
- aipop/ctf/intelligence/__init__.py +23 -0
- aipop/ctf/intelligence/mcp_response_parser.py +453 -0
- aipop/ctf/intelligence/mcp_scorers.py +380 -0
- aipop/ctf/intelligence/planner.py +284 -0
- aipop/ctf/intelligence/response_parser.py +304 -0
- aipop/ctf/intelligence/scorers.py +281 -0
- aipop/ctf/intelligence/state_machine.py +297 -0
- aipop/ctf/mcp_bridge.py +345 -0
- aipop/ctf/orchestrator.py +292 -0
- aipop/ctf/promptfoo_bridge.py +280 -0
- aipop/ctf/pyrit_bridge.py +229 -0
- aipop/ctf/strategies/__init__.py +8 -0
- aipop/ctf/strategies/payloads/payload_engine.py +238 -0
- aipop/ctf/strategies/registry.py +267 -0
- aipop/detectors/__init__.py +17 -0
- aipop/detectors/behavioral.py +488 -0
- aipop/detectors/canary.py +89 -0
- aipop/detectors/canonicalize.py +160 -0
- aipop/detectors/cascade.py +431 -0
- aipop/detectors/harmful_content.py +174 -0
- aipop/detectors/llm_judge.py +195 -0
- aipop/detectors/tool_policy.py +100 -0
- aipop/executors/__init__.py +7 -0
- aipop/executors/recipe_executor.py +372 -0
- aipop/fuzz/__init__.py +3 -0
- aipop/fuzz/engine.py +322 -0
- aipop/gates/__init__.py +17 -0
- aipop/gates/threshold_gate.py +313 -0
- aipop/gates/tool_integrity.py +208 -0
- aipop/harnesses/__init__.py +0 -0
- aipop/harnesses/a2a_integrity.py +265 -0
- aipop/harnesses/approval_manipulation.py +239 -0
- aipop/harnesses/execution_sandbox.py +139 -0
- aipop/harnesses/memory_poisoning.py +214 -0
- aipop/harnesses/multi_agent_runner.py +216 -0
- aipop/harnesses/principal_propagation.py +199 -0
- aipop/harnesses/registry.py +527 -0
- aipop/harnesses/retrieval_injection.py +153 -0
- aipop/harnesses/tool_interception.py +235 -0
- aipop/integrations/__init__.py +7 -0
- aipop/integrations/base.py +200 -0
- aipop/integrations/exceptions.py +17 -0
- aipop/integrations/garak.py +237 -0
- aipop/integrations/orchestrator.py +182 -0
- aipop/integrations/promptfoo.py +237 -0
- aipop/integrations/promptinject.py +255 -0
- aipop/integrations/pyrit.py +283 -0
- aipop/intelligence/__init__.py +21 -0
- aipop/intelligence/adversarial_suffix.py +633 -0
- aipop/intelligence/autodan.py +505 -0
- aipop/intelligence/conversation_replay.py +213 -0
- aipop/intelligence/discovery.py +413 -0
- aipop/intelligence/fingerprint_engine.py +546 -0
- aipop/intelligence/fingerprint_models.py +119 -0
- aipop/intelligence/gcg_core.py +501 -0
- aipop/intelligence/guardrail_fingerprint.py +428 -0
- aipop/intelligence/har_exporter.py +329 -0
- aipop/intelligence/http_recon.py +485 -0
- aipop/intelligence/judge_ensemble.py +157 -0
- aipop/intelligence/judge_models.py +637 -0
- aipop/intelligence/llm_classifier.py +99 -0
- aipop/intelligence/pair.py +411 -0
- aipop/intelligence/pattern_matchers.py +375 -0
- aipop/intelligence/plugins/__init__.py +20 -0
- aipop/intelligence/plugins/autodan_official.py +242 -0
- aipop/intelligence/plugins/base.py +154 -0
- aipop/intelligence/plugins/executor.py +219 -0
- aipop/intelligence/plugins/gcg_official.py +216 -0
- aipop/intelligence/plugins/install.py +550 -0
- aipop/intelligence/plugins/loader.py +587 -0
- aipop/intelligence/plugins/pair_official.py +334 -0
- aipop/intelligence/probe_generator.py +91 -0
- aipop/intelligence/probe_library.py +65 -0
- aipop/intelligence/rate_limiter.py +231 -0
- aipop/intelligence/recon.py +730 -0
- aipop/intelligence/stealth_engine.py +306 -0
- aipop/intelligence/traffic_capture.py +398 -0
- aipop/loaders/__init__.py +24 -0
- aipop/loaders/policy_loader.py +208 -0
- aipop/loaders/recipe_loader.py +186 -0
- aipop/loaders/suite_registry.py +222 -0
- aipop/loaders/yaml_suite.py +280 -0
- aipop/mutators/__init__.py +27 -0
- aipop/mutators/encoding.py +102 -0
- aipop/mutators/gcg_mutator.py +134 -0
- aipop/mutators/genetic.py +153 -0
- aipop/mutators/html.py +68 -0
- aipop/mutators/mutation_engine.py +278 -0
- aipop/mutators/paraphrasing.py +150 -0
- aipop/mutators/unicode_mutator.py +125 -0
- aipop/orchestrators/__init__.py +5 -0
- aipop/orchestrators/pyrit.py +507 -0
- aipop/orchestrators/simple.py +229 -0
- aipop/payloads/__init__.py +12 -0
- aipop/payloads/craft.py +184 -0
- aipop/payloads/git_sync.py +171 -0
- aipop/payloads/payload_manager.py +492 -0
- aipop/payloads/seclists_importer.py +214 -0
- aipop/policies/content_policy.yaml +56 -0
- aipop/policies/tool_allowlist.yaml +27 -0
- aipop/recipes/agentic/agentic_full.yaml +64 -0
- aipop/recipes/compliance/nist_measure.yaml +50 -0
- aipop/recipes/safety/content_policy_baseline.yaml +44 -0
- aipop/recipes/security/full_redteam.yaml +91 -0
- aipop/recipes/security/prompt_injection_baseline.yaml +42 -0
- aipop/redteam/__init__.py +8 -0
- aipop/redteam/aggregator.py +108 -0
- aipop/redteam/indirect_injection.py +336 -0
- aipop/redteam/models.py +22 -0
- aipop/reporters/__init__.py +8 -0
- aipop/reporters/cli_vuln_report.py +315 -0
- aipop/reporters/cvss_cwe_taxonomy.py +523 -0
- aipop/reporters/evidence_pack.py +274 -0
- aipop/reporters/html_reporter.py +466 -0
- aipop/reporters/json_reporter.py +216 -0
- aipop/reporters/junit_reporter.py +132 -0
- aipop/reporters/pdf_generator.py +357 -0
- aipop/reporters/pdf_report.py +186 -0
- aipop/reporters/platform_export.py +166 -0
- aipop/reporters/run_diff.py +145 -0
- aipop/runners/__init__.py +7 -0
- aipop/runners/chain.py +494 -0
- aipop/runners/live.py +494 -0
- aipop/runners/mock.py +573 -0
- aipop/schemas/__init__.py +1 -0
- aipop/schemas/recipe.schema.json +132 -0
- aipop/setup/__init__.py +5 -0
- aipop/setup/installer.py +229 -0
- aipop/setup/profiles.py +105 -0
- aipop/storage/__init__.py +15 -0
- aipop/storage/attack_cache.py +758 -0
- aipop/storage/fingerprint_db.py +130 -0
- aipop/storage/mutation_db.py +222 -0
- aipop/storage/response_cache.py +318 -0
- aipop/storage/suffix_db.py +228 -0
- aipop/suites/adapters/adapter_validation.yaml +61 -0
- aipop/suites/adversarial/context_confusion.yaml +120 -0
- aipop/suites/adversarial/delayed_payloads.yaml +98 -0
- aipop/suites/adversarial/encoding_chains.yaml +138 -0
- aipop/suites/adversarial/fuzz_tests.yaml +221 -0
- aipop/suites/adversarial/gcg_attacks.yaml +372 -0
- aipop/suites/adversarial/multi_turn_crescendo.yaml +104 -0
- aipop/suites/adversarial/rag_injection.yaml +110 -0
- aipop/suites/adversarial/tool_misuse.yaml +120 -0
- aipop/suites/adversarial/unicode_bypass.yaml +191 -0
- aipop/suites/agentic/cve_regression.yaml +164 -0
- aipop/suites/archived/2022_basic_jailbreak.yaml +70 -0
- aipop/suites/comparison/model_comparison.yaml +61 -0
- aipop/suites/normal/basic_utility.yaml +34 -0
- aipop/suites/policies/content_safety.yaml +121 -0
- aipop/suites/rag/rag_poisoning.yaml +202 -0
- aipop/suites/redteam/prompt_injection_advanced.yaml +304 -0
- aipop/suites/tools/tool_policy_validation.yaml +73 -0
- aipop/suites/ui/injection_attacks.yaml +219 -0
- aipop/tools/__init__.py +21 -0
- aipop/tools/installer.py +314 -0
- aipop/utils/adapter_paths.py +40 -0
- aipop/utils/confidence_intervals.py +282 -0
- aipop/utils/config.py +152 -0
- aipop/utils/cost_estimator.py +52 -0
- aipop/utils/cost_tracker.py +367 -0
- aipop/utils/dependency_check.py +84 -0
- aipop/utils/device_detection.py +121 -0
- aipop/utils/error_handling.py +225 -0
- aipop/utils/errors.py +17 -0
- aipop/utils/first_run.py +144 -0
- aipop/utils/gate_display.py +93 -0
- aipop/utils/local_models.py +149 -0
- aipop/utils/log_utils.py +66 -0
- aipop/utils/paths.py +291 -0
- aipop/utils/preflight.py +21 -0
- aipop/utils/progress.py +274 -0
- aipop/utils/rate_limiter.py +40 -0
- aipop/utils/schema_resolver.py +77 -0
- aipop/utils/security.py +246 -0
- aipop/utils/security_check.py +216 -0
- aipop/utils/setup_wizard.py +256 -0
- aipop/utils/validation.py +49 -0
- aipop/validation/__init__.py +5 -0
- aipop/validation/preflight.py +220 -0
- aipop/verification/__init__.py +20 -0
- aipop/verification/multi_turn_scorer.py +217 -0
- aipop/verification/report_generator.py +440 -0
- aipop/verification/statistical_tests.py +202 -0
- aipop/verification/verifier.py +412 -0
- aipop/workflow/__init__.py +5 -0
- aipop/workflow/engagement_tracker.py +317 -0
- aipop-0.7.0.dist-info/METADATA +244 -0
- aipop-0.7.0.dist-info/RECORD +298 -0
- aipop-0.7.0.dist-info/WHEEL +5 -0
- aipop-0.7.0.dist-info/entry_points.txt +2 -0
- aipop-0.7.0.dist-info/licenses/LICENSE +21 -0
- aipop-0.7.0.dist-info/top_level.txt +1 -0
aipop/__init__.py
ADDED
|
@@ -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
|