switch-trust-cli 1.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.
- switch_trust/__init__.py +0 -0
- switch_trust/cli/__init__.py +0 -0
- switch_trust/cli/__main__.py +3 -0
- switch_trust/cli/builtin_config.json +879 -0
- switch_trust/cli/console.py +127 -0
- switch_trust/cli/eval_cli.py +905 -0
- switch_trust/cli/file_filter.py +172 -0
- switch_trust/cli/init_cli.py +156 -0
- switch_trust/cli/main.py +384 -0
- switch_trust/cli/output_formatters.py +482 -0
- switch_trust/cli/rich_observer.py +89 -0
- switch_trust/cli/runner.py +274 -0
- switch_trust/cli/scan_cli.py +314 -0
- switch_trust/cli/telemetry.py +430 -0
- switch_trust/cli/utils.py +158 -0
- switch_trust/cli/version.py +1 -0
- switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
- switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
- switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
- switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
- switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
- switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
- switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
- switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
- switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
- switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
- switch_trust/data/llm01_prompt_injection.csv +6708 -0
- switch_trust/data/llm02_sensitive_information.csv +1069 -0
- switch_trust/data/llm05_unsafe_output.csv +3882 -0
- switch_trust/data/llm06_excessive_agency.csv +778 -0
- switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
- switch_trust/data/llm09_hallucination-goals.csv +32419 -0
- switch_trust/data/llm09_hallucination.csv +599 -0
- switch_trust/data/pii_leakage.csv +706 -0
- switch_trust/data/secret_leakage.csv +1380 -0
- switch_trust/eval/__init__.py +0 -0
- switch_trust/eval/common/__init__.py +0 -0
- switch_trust/eval/common/converter_anthropic.py +172 -0
- switch_trust/eval/common/converter_genai.py +171 -0
- switch_trust/eval/common/converter_openai.py +198 -0
- switch_trust/eval/common/log.py +116 -0
- switch_trust/eval/common/reference.py +25 -0
- switch_trust/eval/common/schema.py +190 -0
- switch_trust/eval/common/utils.py +119 -0
- switch_trust/eval/core/__init__.py +0 -0
- switch_trust/eval/core/detectors/__init__.py +0 -0
- switch_trust/eval/core/detectors/detector.py +25 -0
- switch_trust/eval/core/detectors/detector_garak.py +112 -0
- switch_trust/eval/core/detectors/detector_model.py +123 -0
- switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
- switch_trust/eval/core/detectors/detector_pii.py +67 -0
- switch_trust/eval/core/detectors/detector_secret.py +88 -0
- switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
- switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
- switch_trust/eval/core/eval/__init__.py +0 -0
- switch_trust/eval/core/eval/eval_creator.py +166 -0
- switch_trust/eval/core/eval/evaluation.py +112 -0
- switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
- switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
- switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
- switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
- switch_trust/eval/core/eval/evaluation_multi.py +276 -0
- switch_trust/eval/core/eval/evaluation_single.py +89 -0
- switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
- switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
- switch_trust/eval/core/eval/metric_conciseness.py +179 -0
- switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
- switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
- switch_trust/eval/core/eval/metric_tone.py +180 -0
- switch_trust/eval/core/eval/metric_toxicity.py +146 -0
- switch_trust/eval/core/eval/observer.py +137 -0
- switch_trust/eval/core/message/__init__.py +0 -0
- switch_trust/eval/core/message/message_collection.py +37 -0
- switch_trust/eval/core/message/message_collection_csv.py +44 -0
- switch_trust/eval/core/message/message_collection_garak.py +46 -0
- switch_trust/eval/core/message/message_collection_memory.py +34 -0
- switch_trust/eval/core/models/__init__.py +0 -0
- switch_trust/eval/core/models/generator_model.py +421 -0
- switch_trust/eval/core/models/model.py +172 -0
- switch_trust/eval/core/models/model_adk.py +186 -0
- switch_trust/eval/core/models/model_anthropic.py +79 -0
- switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
- switch_trust/eval/core/models/model_gemini.py +146 -0
- switch_trust/eval/core/models/model_generic_http.py +120 -0
- switch_trust/eval/core/models/model_http.py +135 -0
- switch_trust/eval/core/models/model_huggingface.py +75 -0
- switch_trust/eval/core/models/model_langserve.py +96 -0
- switch_trust/eval/core/models/model_litellm.py +52 -0
- switch_trust/eval/core/models/model_ollama.py +65 -0
- switch_trust/eval/core/models/model_openai.py +71 -0
- switch_trust/eval/core/models/model_openai_agent.py +92 -0
- switch_trust/eval/core/models/model_openai_compatible.py +66 -0
- switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
- switch_trust/eval/core/models/model_retry.py +204 -0
- switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
- switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
- switch_trust/eval/core/models/response_schema.py +148 -0
- switch_trust/eval/core/optional_deps.py +57 -0
- switch_trust/eval/db/__init__.py +0 -0
- switch_trust/eval/db/base/__init__.py +0 -0
- switch_trust/eval/db/base/common_types.py +16 -0
- switch_trust/eval/db/base/detectors/__init__.py +0 -0
- switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
- switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
- switch_trust/eval/db/base/detectors/detector_types.py +77 -0
- switch_trust/eval/db/base/eval/__init__.py +0 -0
- switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
- switch_trust/eval/db/base/eval/eval_repository.py +120 -0
- switch_trust/eval/db/base/eval/eval_run.py +142 -0
- switch_trust/eval/db/base/eval/eval_types.py +127 -0
- switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
- switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
- switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
- switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
- switch_trust/eval/db/base/message/__init__.py +0 -0
- switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
- switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
- switch_trust/eval/db/base/message/message_collection_types.py +87 -0
- switch_trust/eval/db/base/models/__init__.py +0 -0
- switch_trust/eval/db/base/models/model_helpers.py +253 -0
- switch_trust/eval/db/base/models/model_repository.py +104 -0
- switch_trust/eval/db/base/models/model_types.py +159 -0
- switch_trust/eval/db/json/__init__.py +0 -0
- switch_trust/eval/db/json/repository_json.py +559 -0
- switch_trust/scan/__init__.py +12 -0
- switch_trust/scan/agent_scanner.py +812 -0
- switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
- switch_trust/scan/config/agent_taxonomy.json +517 -0
- switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
- switch_trust/scan/config/compliance_mappings.json +110 -0
- switch_trust/scan/config/triage_prompt.txt +326 -0
- switch_trust/scan/constants.py +15 -0
- switch_trust/scan/exception_classification.py +179 -0
- switch_trust/scan/llm_provider.py +330 -0
- switch_trust/scan/opengrep_resolver.py +12 -0
- switch_trust/scan/reasoner.py +602 -0
- switch_trust/scan/schema.py +315 -0
- switch_trust/scan/scorer.py +372 -0
- switch_trust/scan/static_scanner.py +681 -0
- switch_trust/scan/taxonomy.py +121 -0
- switch_trust/scan/tool_dispatcher.py +773 -0
- switch_trust/scan/trace_logger.py +118 -0
- switch_trust/scan/trace_logger_file.py +143 -0
- switch_trust/scan/trace_logger_log.py +100 -0
- switch_trust/scan/triage.py +614 -0
- switch_trust/schema.py +79 -0
- switch_trust/secret_anonymizer.py +72 -0
- switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
- switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
- switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
- switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
- switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
- switch_trust_cli-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Model implementation for Google ADK agents.
|
|
2
|
+
|
|
3
|
+
ADK agents are served via ``adk api_server`` or ``adk web`` and
|
|
4
|
+
expose a REST API at a configurable host/port. This model sends
|
|
5
|
+
prompts to the ``/run`` endpoint and extracts the agent's text
|
|
6
|
+
response from the returned events.
|
|
7
|
+
|
|
8
|
+
Typical ADK server URL: ``http://localhost:8000``
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from switch_trust.eval.common.schema import Content, Message, Part, Role
|
|
20
|
+
from switch_trust.eval.core.models.model import Model, ModelResponse, ResponseStatus
|
|
21
|
+
from switch_trust.eval.core.models.model_http import raise_for_status
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ADKModel(Model):
|
|
28
|
+
"""Sends prompts to a Google ADK agent served over HTTP.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
app_name: The ADK app/agent name.
|
|
32
|
+
host: The HTTP host where the ADK server is running.
|
|
33
|
+
user_id: User identifier for sessions.
|
|
34
|
+
immediate_result: If True, return the first event's
|
|
35
|
+
content (e.g. a tool call). If False (default),
|
|
36
|
+
return the final text response after all tool
|
|
37
|
+
calls complete.
|
|
38
|
+
headers: Extra HTTP headers sent on every request, e.g. an
|
|
39
|
+
``Authorization`` header for a deployment gated by an
|
|
40
|
+
app-level bearer token rather than Cloud Run IAM.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
app_name: str,
|
|
46
|
+
host: str = "http://localhost:8000",
|
|
47
|
+
user_id: str = "aired",
|
|
48
|
+
immediate_result: bool = False,
|
|
49
|
+
headers: dict[str, str] | None = None,
|
|
50
|
+
connector_factory: Callable[[], aiohttp.BaseConnector] | None = None,
|
|
51
|
+
):
|
|
52
|
+
self._app_name = app_name
|
|
53
|
+
self._host = host.rstrip("/")
|
|
54
|
+
self._user_id = user_id
|
|
55
|
+
self._immediate_result = immediate_result
|
|
56
|
+
self._headers = headers or {}
|
|
57
|
+
# Optional aiohttp connector factory; the worker passes the SSRF-guarded
|
|
58
|
+
# one (platform/ssrf.py). None keeps the default connector.
|
|
59
|
+
self._connector_factory = connector_factory
|
|
60
|
+
|
|
61
|
+
async def _create_session(
|
|
62
|
+
self,
|
|
63
|
+
session: aiohttp.ClientSession,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Create a new session for this request."""
|
|
66
|
+
url = f"{self._host}/apps/{self._app_name}/users/{self._user_id}/sessions"
|
|
67
|
+
async with session.post(url, json={}, headers=self._headers) as resp:
|
|
68
|
+
await raise_for_status(resp)
|
|
69
|
+
data = await resp.json()
|
|
70
|
+
return data["id"]
|
|
71
|
+
|
|
72
|
+
async def _generate(
|
|
73
|
+
self,
|
|
74
|
+
messages: list[Message],
|
|
75
|
+
*,
|
|
76
|
+
output_schema: type[BaseModel] | None = None,
|
|
77
|
+
**_kwargs: Any,
|
|
78
|
+
) -> ModelResponse:
|
|
79
|
+
# An agent under test cannot enforce a JSON schema; output_schema is
|
|
80
|
+
# accepted for interface parity and ignored.
|
|
81
|
+
if len(messages) > 1:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"ADKModel does not support multiple messages; use session-based history"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
connector = self._connector_factory() if self._connector_factory else None
|
|
87
|
+
async with aiohttp.ClientSession(connector=connector) as session:
|
|
88
|
+
session_id = await self._create_session(session)
|
|
89
|
+
|
|
90
|
+
text_parts = [p.text for p in messages[0].content.parts if p.text]
|
|
91
|
+
prompt_text = " ".join(text_parts)
|
|
92
|
+
|
|
93
|
+
payload = {
|
|
94
|
+
"appName": self._app_name,
|
|
95
|
+
"userId": self._user_id,
|
|
96
|
+
"sessionId": session_id,
|
|
97
|
+
"newMessage": {
|
|
98
|
+
"role": "user",
|
|
99
|
+
"parts": [{"text": prompt_text}],
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async with session.post(
|
|
104
|
+
f"{self._host}/run",
|
|
105
|
+
json=payload,
|
|
106
|
+
headers=self._headers,
|
|
107
|
+
) as resp:
|
|
108
|
+
await raise_for_status(resp)
|
|
109
|
+
events = await resp.json()
|
|
110
|
+
|
|
111
|
+
if self._immediate_result:
|
|
112
|
+
response_text = self._extract_first(events)
|
|
113
|
+
else:
|
|
114
|
+
response_text = self._extract_final(events)
|
|
115
|
+
|
|
116
|
+
if response_text is None:
|
|
117
|
+
return ModelResponse(
|
|
118
|
+
message=None,
|
|
119
|
+
status=ResponseStatus.EMPTY_RESPONSE,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
message = Message(
|
|
123
|
+
content=Content(
|
|
124
|
+
role=Role.ASSISTANT,
|
|
125
|
+
parts=[Part.text_part(response_text)],
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
return ModelResponse(message=message)
|
|
129
|
+
|
|
130
|
+
def _extract_final(
|
|
131
|
+
self,
|
|
132
|
+
events: list[dict],
|
|
133
|
+
) -> str | None:
|
|
134
|
+
"""Extract the last text response, skipping tool
|
|
135
|
+
call/response events.
|
|
136
|
+
|
|
137
|
+
FIXME: the skip below drops a *mixed* event — one turn carrying both
|
|
138
|
+
narration and a tool call, e.g. ``[{"text": "Let me look that up"},
|
|
139
|
+
{"functionCall": ...}]``, which Gemini emits routinely — losing the text
|
|
140
|
+
with it. The test is also redundant for pure-tool events, since those
|
|
141
|
+
carry no ``text`` key and the ``if texts`` check below already skips
|
|
142
|
+
them. Deleting the whole ``any(...)`` block both fixes and simplifies
|
|
143
|
+
this, as done in ``model_vertex_agent_runtime._extract_final``. Left
|
|
144
|
+
unchanged here only to keep that PR scoped; needs its own change plus a
|
|
145
|
+
mixed-event test.
|
|
146
|
+
"""
|
|
147
|
+
for event in reversed(events):
|
|
148
|
+
content = event.get("content")
|
|
149
|
+
if content is None:
|
|
150
|
+
continue
|
|
151
|
+
parts = content.get("parts", [])
|
|
152
|
+
# Skip events that are tool calls or responses
|
|
153
|
+
if any(
|
|
154
|
+
"functionCall" in p or "functionResponse" in p
|
|
155
|
+
for p in parts
|
|
156
|
+
if isinstance(p, dict)
|
|
157
|
+
):
|
|
158
|
+
continue
|
|
159
|
+
texts = [p["text"] for p in parts if isinstance(p, dict) and "text" in p]
|
|
160
|
+
if texts:
|
|
161
|
+
return " ".join(texts)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _extract_first(
|
|
165
|
+
self,
|
|
166
|
+
events: list[dict],
|
|
167
|
+
) -> str | None:
|
|
168
|
+
"""Extract the first event's text content."""
|
|
169
|
+
for event in events:
|
|
170
|
+
content = event.get("content")
|
|
171
|
+
if content is None:
|
|
172
|
+
continue
|
|
173
|
+
parts = content.get("parts", [])
|
|
174
|
+
texts = [p["text"] for p in parts if isinstance(p, dict) and "text" in p]
|
|
175
|
+
if texts:
|
|
176
|
+
return " ".join(texts)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def discover_adk_agents(
|
|
181
|
+
host: str = "http://localhost:8000",
|
|
182
|
+
) -> list[str]:
|
|
183
|
+
"""Return a list of agent app names on an ADK server."""
|
|
184
|
+
resp = requests.get(f"{host.rstrip('/')}/list-apps")
|
|
185
|
+
resp.raise_for_status()
|
|
186
|
+
return resp.json()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from anthropic import AsyncAnthropic
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from switch_trust.eval.common import converter_anthropic
|
|
8
|
+
from switch_trust.eval.common.schema import Content, Message, Role
|
|
9
|
+
from switch_trust.eval.core.models.model import Model, ModelResponse
|
|
10
|
+
from switch_trust.eval.core.models.response_schema import to_anthropic_tool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnthropicModel(Model):
|
|
14
|
+
_client: AsyncAnthropic
|
|
15
|
+
_model: str
|
|
16
|
+
_max_tokens: int
|
|
17
|
+
_temperature: float
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
client: AsyncAnthropic,
|
|
22
|
+
model: str,
|
|
23
|
+
max_tokens: int = 1024,
|
|
24
|
+
temperature: float = 0.0,
|
|
25
|
+
):
|
|
26
|
+
self._client = client
|
|
27
|
+
self._model = model
|
|
28
|
+
self._max_tokens = max_tokens
|
|
29
|
+
self._temperature = temperature
|
|
30
|
+
|
|
31
|
+
async def _generate(
|
|
32
|
+
self,
|
|
33
|
+
messages: list[Message],
|
|
34
|
+
*,
|
|
35
|
+
output_schema: type[BaseModel] | None = None,
|
|
36
|
+
**kwargs: Any,
|
|
37
|
+
) -> ModelResponse:
|
|
38
|
+
system_blocks = None
|
|
39
|
+
api_messages = []
|
|
40
|
+
for msg in messages:
|
|
41
|
+
anthropic_msg = converter_anthropic.from_message(msg)
|
|
42
|
+
if msg.content.role == Role.SYSTEM:
|
|
43
|
+
system_blocks = anthropic_msg["content"]
|
|
44
|
+
else:
|
|
45
|
+
api_messages.append(anthropic_msg)
|
|
46
|
+
|
|
47
|
+
if not api_messages:
|
|
48
|
+
api_messages = [{"role": "user", "content": ""}]
|
|
49
|
+
|
|
50
|
+
create_kwargs: dict[str, Any] = {
|
|
51
|
+
"model": self._model,
|
|
52
|
+
"max_tokens": kwargs.pop("max_tokens", self._max_tokens),
|
|
53
|
+
"temperature": kwargs.pop("temperature", self._temperature),
|
|
54
|
+
"messages": api_messages,
|
|
55
|
+
**kwargs,
|
|
56
|
+
}
|
|
57
|
+
if system_blocks is not None:
|
|
58
|
+
create_kwargs["system"] = system_blocks
|
|
59
|
+
|
|
60
|
+
if output_schema is not None and "tools" not in create_kwargs:
|
|
61
|
+
# Anthropic has no cross-version response_format: force a single
|
|
62
|
+
# tool whose input_schema is the target schema and read the
|
|
63
|
+
# tool_use input back as the JSON payload.
|
|
64
|
+
tool, tool_choice = to_anthropic_tool(output_schema)
|
|
65
|
+
create_kwargs["tools"] = [tool]
|
|
66
|
+
create_kwargs["tool_choice"] = tool_choice
|
|
67
|
+
|
|
68
|
+
response = await self._client.messages.create(**create_kwargs)
|
|
69
|
+
|
|
70
|
+
if output_schema is not None:
|
|
71
|
+
for block in response.content:
|
|
72
|
+
if getattr(block, "type", None) == "tool_use":
|
|
73
|
+
payload = json.dumps(block.input)
|
|
74
|
+
return ModelResponse(
|
|
75
|
+
Message(content=Content.text(Role.ASSISTANT, payload)),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
message = converter_anthropic.to_message(response)
|
|
79
|
+
return ModelResponse(message)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Model implementation for Anthropic Agent SDK agents served over HTTP.
|
|
2
|
+
|
|
3
|
+
Anthropic Agent SDK (claude_agent_sdk) agents don't have a
|
|
4
|
+
built-in HTTP server. The expected pattern is a FastAPI wrapper
|
|
5
|
+
with a POST endpoint that accepts ``{"prompt": "<prompt>"}`` and
|
|
6
|
+
returns ``{"response": "<response>"}``.
|
|
7
|
+
|
|
8
|
+
Example server::
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from claude_agent_sdk import (
|
|
13
|
+
query, ClaudeAgentOptions,
|
|
14
|
+
AssistantMessage, TextBlock, ResultMessage,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
app = FastAPI()
|
|
18
|
+
|
|
19
|
+
class Request(BaseModel):
|
|
20
|
+
prompt: str
|
|
21
|
+
|
|
22
|
+
@app.post("/run")
|
|
23
|
+
async def run(req: Request):
|
|
24
|
+
options = ClaudeAgentOptions(
|
|
25
|
+
permission_mode="acceptEdits",
|
|
26
|
+
)
|
|
27
|
+
text = ""
|
|
28
|
+
async for msg in query(
|
|
29
|
+
prompt=req.prompt, options=options,
|
|
30
|
+
):
|
|
31
|
+
if isinstance(msg, AssistantMessage):
|
|
32
|
+
for block in msg.content:
|
|
33
|
+
if isinstance(block, TextBlock):
|
|
34
|
+
text += block.text
|
|
35
|
+
return {"response": text}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from typing import TYPE_CHECKING, Any
|
|
42
|
+
|
|
43
|
+
import aiohttp
|
|
44
|
+
|
|
45
|
+
from switch_trust.eval.common.schema import Content, Message, Part, Role
|
|
46
|
+
from switch_trust.eval.core.models.model import Model, ModelResponse, ResponseStatus
|
|
47
|
+
from switch_trust.eval.core.models.model_http import raise_for_status
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from pydantic import BaseModel
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AnthropicAgentModel(Model):
|
|
54
|
+
"""Sends prompts to an Anthropic Agent SDK agent over HTTP."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
host: str = "http://localhost:8000",
|
|
59
|
+
endpoint: str = "/run",
|
|
60
|
+
connector_factory: Callable[[], aiohttp.BaseConnector] | None = None,
|
|
61
|
+
):
|
|
62
|
+
self._host = host.rstrip("/")
|
|
63
|
+
self._endpoint = endpoint
|
|
64
|
+
# Optional aiohttp connector factory; the worker passes the SSRF-guarded
|
|
65
|
+
# one (platform/ssrf.py). None keeps the default connector.
|
|
66
|
+
self._connector_factory = connector_factory
|
|
67
|
+
|
|
68
|
+
async def _generate(
|
|
69
|
+
self,
|
|
70
|
+
messages: list[Message],
|
|
71
|
+
*,
|
|
72
|
+
output_schema: type[BaseModel] | None = None,
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> ModelResponse:
|
|
75
|
+
# An agent under test cannot enforce a JSON schema; output_schema is
|
|
76
|
+
# accepted for interface parity and ignored.
|
|
77
|
+
if len(messages) > 1:
|
|
78
|
+
raise ValueError("AnthropicAgentModel does not support multiple messages")
|
|
79
|
+
text_parts = [p.text for p in messages[0].content.parts if p.text]
|
|
80
|
+
prompt_text = " ".join(text_parts)
|
|
81
|
+
|
|
82
|
+
connector = self._connector_factory() if self._connector_factory else None
|
|
83
|
+
async with aiohttp.ClientSession(connector=connector) as session:
|
|
84
|
+
async with session.post(
|
|
85
|
+
f"{self._host}{self._endpoint}",
|
|
86
|
+
json={"prompt": prompt_text},
|
|
87
|
+
) as resp:
|
|
88
|
+
await raise_for_status(resp)
|
|
89
|
+
data = await resp.json()
|
|
90
|
+
|
|
91
|
+
output = data.get("response")
|
|
92
|
+
if not output:
|
|
93
|
+
return ModelResponse(
|
|
94
|
+
message=None,
|
|
95
|
+
status=ResponseStatus.EMPTY_RESPONSE,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
message = Message(
|
|
99
|
+
content=Content(
|
|
100
|
+
role=Role.ASSISTANT,
|
|
101
|
+
parts=[Part.text_part(output)],
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
return ModelResponse(message=message)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from google.genai import Client
|
|
4
|
+
from google.genai import types as genai_types
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from switch_trust.eval.common import converter_genai
|
|
8
|
+
from switch_trust.eval.common.schema import Message, Role
|
|
9
|
+
from switch_trust.eval.core.models.model import (
|
|
10
|
+
Model,
|
|
11
|
+
ModelResponse,
|
|
12
|
+
ResponseStatus,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Safety settings that disable content blocking across every active harm
|
|
16
|
+
# category. Red-teaming needs the attacker/judge model to see and produce
|
|
17
|
+
# adversarial content without Vertex's filters returning an empty candidate — a
|
|
18
|
+
# blocked judge yields no JSON to parse, which surfaces as a spurious eval error
|
|
19
|
+
# rather than a real result. HARM_CATEGORY_CIVIC_INTEGRITY is deprecated and
|
|
20
|
+
# omitted. Note this is only appropriate for the generator; a model *under test*
|
|
21
|
+
# should keep its own safety behavior so blocking can be measured.
|
|
22
|
+
SAFETY_OFF: list[genai_types.SafetySetting] = [
|
|
23
|
+
genai_types.SafetySetting(category=category, threshold="OFF")
|
|
24
|
+
for category in (
|
|
25
|
+
genai_types.HarmCategory.HARM_CATEGORY_HARASSMENT,
|
|
26
|
+
genai_types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
|
27
|
+
genai_types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
|
28
|
+
genai_types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
|
29
|
+
)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GeminiModel(Model):
|
|
34
|
+
_client: Client
|
|
35
|
+
_model: str
|
|
36
|
+
_temperature: float
|
|
37
|
+
_safety_settings: list[genai_types.SafetySetting] | None
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
client: Client,
|
|
42
|
+
model: str,
|
|
43
|
+
temperature: float = 0.0,
|
|
44
|
+
safety_settings: list[genai_types.SafetySetting] | None = None,
|
|
45
|
+
):
|
|
46
|
+
self._client = client
|
|
47
|
+
self._model = model
|
|
48
|
+
self._temperature = temperature
|
|
49
|
+
self._safety_settings = safety_settings
|
|
50
|
+
|
|
51
|
+
async def _generate(
|
|
52
|
+
self,
|
|
53
|
+
messages: list[Message],
|
|
54
|
+
*,
|
|
55
|
+
output_schema: type[BaseModel] | None = None,
|
|
56
|
+
**kwargs: Any,
|
|
57
|
+
) -> ModelResponse:
|
|
58
|
+
system_parts: list[dict[str, Any]] = []
|
|
59
|
+
contents: list[dict[str, Any]] = []
|
|
60
|
+
for msg in messages:
|
|
61
|
+
if msg.content.role == Role.SYSTEM:
|
|
62
|
+
system_parts.extend(
|
|
63
|
+
converter_genai.from_content(
|
|
64
|
+
msg.content,
|
|
65
|
+
)["parts"]
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
contents.append(
|
|
69
|
+
converter_genai.from_content(msg.content),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
config = kwargs.pop("config", None)
|
|
73
|
+
if config is None:
|
|
74
|
+
config = genai_types.GenerateContentConfig(
|
|
75
|
+
temperature=self._temperature,
|
|
76
|
+
)
|
|
77
|
+
elif config.temperature is None:
|
|
78
|
+
config.temperature = self._temperature
|
|
79
|
+
if system_parts:
|
|
80
|
+
system_instruction = genai_types.Content(
|
|
81
|
+
parts=[genai_types.Part(**p) for p in system_parts],
|
|
82
|
+
)
|
|
83
|
+
config.system_instruction = system_instruction
|
|
84
|
+
if self._safety_settings is not None and config.safety_settings is None:
|
|
85
|
+
config.safety_settings = self._safety_settings
|
|
86
|
+
if output_schema is not None and config.response_schema is None:
|
|
87
|
+
config.response_mime_type = "application/json"
|
|
88
|
+
config.response_schema = output_schema
|
|
89
|
+
|
|
90
|
+
generate_kwargs: dict[str, Any] = {
|
|
91
|
+
"model": self._model,
|
|
92
|
+
"contents": contents,
|
|
93
|
+
**kwargs,
|
|
94
|
+
}
|
|
95
|
+
if config is not None:
|
|
96
|
+
generate_kwargs["config"] = config
|
|
97
|
+
|
|
98
|
+
response = await self._client.aio.models.generate_content(
|
|
99
|
+
**generate_kwargs,
|
|
100
|
+
)
|
|
101
|
+
if (
|
|
102
|
+
response.candidates is not None
|
|
103
|
+
and len(response.candidates) == 1
|
|
104
|
+
and response.candidates[0].content is not None
|
|
105
|
+
):
|
|
106
|
+
message = converter_genai.to_message(
|
|
107
|
+
response.candidates[0].content,
|
|
108
|
+
)
|
|
109
|
+
return ModelResponse(message)
|
|
110
|
+
return ModelResponse(
|
|
111
|
+
None,
|
|
112
|
+
_classify_block_reason(response),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _classify_block_reason(response) -> ResponseStatus:
|
|
117
|
+
"""Determine why a Gemini response was blocked."""
|
|
118
|
+
# Check prompt-level blocking
|
|
119
|
+
reason = getattr(
|
|
120
|
+
response,
|
|
121
|
+
"prompt_feedback",
|
|
122
|
+
None,
|
|
123
|
+
)
|
|
124
|
+
if reason is not None:
|
|
125
|
+
block_reason = getattr(reason, "block_reason", None)
|
|
126
|
+
if block_reason is not None:
|
|
127
|
+
reason_str = str(block_reason).upper()
|
|
128
|
+
if "SAFETY" in reason_str:
|
|
129
|
+
return ResponseStatus.BLOCKED_SAFETY
|
|
130
|
+
if "PROHIBITED" in reason_str:
|
|
131
|
+
return ResponseStatus.BLOCKED_PROHIBITED
|
|
132
|
+
|
|
133
|
+
# Check candidate-level finish reason
|
|
134
|
+
candidates = getattr(response, "candidates", None)
|
|
135
|
+
if candidates:
|
|
136
|
+
finish = getattr(candidates[0], "finish_reason", None)
|
|
137
|
+
if finish is not None:
|
|
138
|
+
finish_str = str(finish).upper()
|
|
139
|
+
if "SAFETY" in finish_str:
|
|
140
|
+
return ResponseStatus.BLOCKED_SAFETY
|
|
141
|
+
if "RECITATION" in finish_str:
|
|
142
|
+
return ResponseStatus.BLOCKED_RECITATION
|
|
143
|
+
if "PROHIBITED" in finish_str:
|
|
144
|
+
return ResponseStatus.BLOCKED_PROHIBITED
|
|
145
|
+
|
|
146
|
+
return ResponseStatus.BLOCKED_SAFETY
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Model for any HTTP endpoint with configurable JSON fields.
|
|
2
|
+
|
|
3
|
+
Connects to a generic REST API where the request sends
|
|
4
|
+
a prompt in a configurable JSON field and the response
|
|
5
|
+
returns the output in another configurable field.
|
|
6
|
+
|
|
7
|
+
Default format::
|
|
8
|
+
|
|
9
|
+
POST /your-endpoint
|
|
10
|
+
{"input": "Hello"}
|
|
11
|
+
|
|
12
|
+
{"output": "Hi there!"}
|
|
13
|
+
|
|
14
|
+
The ``output_path`` supports dot-separated paths for
|
|
15
|
+
nested responses, e.g. ``"data.response.text"`` or
|
|
16
|
+
``"choices.0.message.content"``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
import aiohttp
|
|
25
|
+
|
|
26
|
+
from switch_trust.eval.common.schema import Content, Message, Part, Role
|
|
27
|
+
from switch_trust.eval.core.models.model import (
|
|
28
|
+
Model,
|
|
29
|
+
ModelResponse,
|
|
30
|
+
ResponseStatus,
|
|
31
|
+
flatten_messages,
|
|
32
|
+
)
|
|
33
|
+
from switch_trust.eval.core.models.model_http import raise_for_status
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from pydantic import BaseModel
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GenericHttpModel(Model):
|
|
40
|
+
_url: str
|
|
41
|
+
_headers: dict[str, str]
|
|
42
|
+
_input_path: str
|
|
43
|
+
_output_path: str
|
|
44
|
+
# Optional aiohttp connector factory. The eval worker passes one that
|
|
45
|
+
# enforces the request-time SSRF guard (platform/ssrf.py); None keeps the
|
|
46
|
+
# default connector for the CLI and local use.
|
|
47
|
+
_connector_factory: Callable[[], aiohttp.BaseConnector] | None
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
url: str,
|
|
52
|
+
headers: dict[str, str] | None = None,
|
|
53
|
+
input_path: str = "input",
|
|
54
|
+
output_path: str = "output",
|
|
55
|
+
connector_factory: Callable[[], aiohttp.BaseConnector] | None = None,
|
|
56
|
+
):
|
|
57
|
+
self._url = url
|
|
58
|
+
self._headers = headers or {}
|
|
59
|
+
self._input_path = input_path
|
|
60
|
+
self._output_path = output_path
|
|
61
|
+
self._connector_factory = connector_factory
|
|
62
|
+
|
|
63
|
+
async def _generate(
|
|
64
|
+
self,
|
|
65
|
+
messages: list[Message],
|
|
66
|
+
*,
|
|
67
|
+
output_schema: type[BaseModel] | None = None,
|
|
68
|
+
**kwargs: Any,
|
|
69
|
+
) -> ModelResponse:
|
|
70
|
+
# A generic REST endpoint under test cannot enforce a JSON schema;
|
|
71
|
+
# output_schema is accepted for interface parity and ignored.
|
|
72
|
+
prompt_text = flatten_messages(messages)
|
|
73
|
+
|
|
74
|
+
connector = self._connector_factory() if self._connector_factory else None
|
|
75
|
+
async with aiohttp.ClientSession(connector=connector) as session:
|
|
76
|
+
async with session.post(
|
|
77
|
+
self._url,
|
|
78
|
+
json={self._input_path: prompt_text},
|
|
79
|
+
headers=self._headers,
|
|
80
|
+
) as resp:
|
|
81
|
+
await raise_for_status(resp)
|
|
82
|
+
data = await resp.json()
|
|
83
|
+
|
|
84
|
+
output = _resolve_path(data, self._output_path)
|
|
85
|
+
if not output:
|
|
86
|
+
return ModelResponse(
|
|
87
|
+
message=None,
|
|
88
|
+
status=ResponseStatus.EMPTY_RESPONSE,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
message = Message(
|
|
92
|
+
content=Content(
|
|
93
|
+
role=Role.ASSISTANT,
|
|
94
|
+
parts=[Part.text_part(str(output))],
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
return ModelResponse(message=message)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_path(data: Any, path: str) -> Any:
|
|
101
|
+
"""Traverse a nested dict/list by dot-separated path.
|
|
102
|
+
|
|
103
|
+
Supports integer indices for lists, e.g.
|
|
104
|
+
``"choices.0.message.content"`` resolves
|
|
105
|
+
``data["choices"][0]["message"]["content"]``.
|
|
106
|
+
"""
|
|
107
|
+
current = data
|
|
108
|
+
for key in path.split("."):
|
|
109
|
+
if current is None:
|
|
110
|
+
return None
|
|
111
|
+
if isinstance(current, list):
|
|
112
|
+
try:
|
|
113
|
+
current = current[int(key)]
|
|
114
|
+
except (ValueError, IndexError):
|
|
115
|
+
return None
|
|
116
|
+
elif isinstance(current, dict):
|
|
117
|
+
current = current.get(key)
|
|
118
|
+
else:
|
|
119
|
+
return None
|
|
120
|
+
return current
|