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.
Files changed (157) hide show
  1. switch_trust/__init__.py +0 -0
  2. switch_trust/cli/__init__.py +0 -0
  3. switch_trust/cli/__main__.py +3 -0
  4. switch_trust/cli/builtin_config.json +879 -0
  5. switch_trust/cli/console.py +127 -0
  6. switch_trust/cli/eval_cli.py +905 -0
  7. switch_trust/cli/file_filter.py +172 -0
  8. switch_trust/cli/init_cli.py +156 -0
  9. switch_trust/cli/main.py +384 -0
  10. switch_trust/cli/output_formatters.py +482 -0
  11. switch_trust/cli/rich_observer.py +89 -0
  12. switch_trust/cli/runner.py +274 -0
  13. switch_trust/cli/scan_cli.py +314 -0
  14. switch_trust/cli/telemetry.py +430 -0
  15. switch_trust/cli/utils.py +158 -0
  16. switch_trust/cli/version.py +1 -0
  17. switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
  18. switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
  19. switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
  20. switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
  21. switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
  22. switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
  23. switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
  24. switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
  25. switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
  26. switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
  27. switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
  28. switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
  29. switch_trust/data/llm01_prompt_injection.csv +6708 -0
  30. switch_trust/data/llm02_sensitive_information.csv +1069 -0
  31. switch_trust/data/llm05_unsafe_output.csv +3882 -0
  32. switch_trust/data/llm06_excessive_agency.csv +778 -0
  33. switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
  34. switch_trust/data/llm09_hallucination-goals.csv +32419 -0
  35. switch_trust/data/llm09_hallucination.csv +599 -0
  36. switch_trust/data/pii_leakage.csv +706 -0
  37. switch_trust/data/secret_leakage.csv +1380 -0
  38. switch_trust/eval/__init__.py +0 -0
  39. switch_trust/eval/common/__init__.py +0 -0
  40. switch_trust/eval/common/converter_anthropic.py +172 -0
  41. switch_trust/eval/common/converter_genai.py +171 -0
  42. switch_trust/eval/common/converter_openai.py +198 -0
  43. switch_trust/eval/common/log.py +116 -0
  44. switch_trust/eval/common/reference.py +25 -0
  45. switch_trust/eval/common/schema.py +190 -0
  46. switch_trust/eval/common/utils.py +119 -0
  47. switch_trust/eval/core/__init__.py +0 -0
  48. switch_trust/eval/core/detectors/__init__.py +0 -0
  49. switch_trust/eval/core/detectors/detector.py +25 -0
  50. switch_trust/eval/core/detectors/detector_garak.py +112 -0
  51. switch_trust/eval/core/detectors/detector_model.py +123 -0
  52. switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
  53. switch_trust/eval/core/detectors/detector_pii.py +67 -0
  54. switch_trust/eval/core/detectors/detector_secret.py +88 -0
  55. switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
  56. switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
  57. switch_trust/eval/core/eval/__init__.py +0 -0
  58. switch_trust/eval/core/eval/eval_creator.py +166 -0
  59. switch_trust/eval/core/eval/evaluation.py +112 -0
  60. switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
  61. switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
  62. switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
  63. switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
  64. switch_trust/eval/core/eval/evaluation_multi.py +276 -0
  65. switch_trust/eval/core/eval/evaluation_single.py +89 -0
  66. switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
  67. switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
  68. switch_trust/eval/core/eval/metric_conciseness.py +179 -0
  69. switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
  70. switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
  71. switch_trust/eval/core/eval/metric_tone.py +180 -0
  72. switch_trust/eval/core/eval/metric_toxicity.py +146 -0
  73. switch_trust/eval/core/eval/observer.py +137 -0
  74. switch_trust/eval/core/message/__init__.py +0 -0
  75. switch_trust/eval/core/message/message_collection.py +37 -0
  76. switch_trust/eval/core/message/message_collection_csv.py +44 -0
  77. switch_trust/eval/core/message/message_collection_garak.py +46 -0
  78. switch_trust/eval/core/message/message_collection_memory.py +34 -0
  79. switch_trust/eval/core/models/__init__.py +0 -0
  80. switch_trust/eval/core/models/generator_model.py +421 -0
  81. switch_trust/eval/core/models/model.py +172 -0
  82. switch_trust/eval/core/models/model_adk.py +186 -0
  83. switch_trust/eval/core/models/model_anthropic.py +79 -0
  84. switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
  85. switch_trust/eval/core/models/model_gemini.py +146 -0
  86. switch_trust/eval/core/models/model_generic_http.py +120 -0
  87. switch_trust/eval/core/models/model_http.py +135 -0
  88. switch_trust/eval/core/models/model_huggingface.py +75 -0
  89. switch_trust/eval/core/models/model_langserve.py +96 -0
  90. switch_trust/eval/core/models/model_litellm.py +52 -0
  91. switch_trust/eval/core/models/model_ollama.py +65 -0
  92. switch_trust/eval/core/models/model_openai.py +71 -0
  93. switch_trust/eval/core/models/model_openai_agent.py +92 -0
  94. switch_trust/eval/core/models/model_openai_compatible.py +66 -0
  95. switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
  96. switch_trust/eval/core/models/model_retry.py +204 -0
  97. switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
  98. switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
  99. switch_trust/eval/core/models/response_schema.py +148 -0
  100. switch_trust/eval/core/optional_deps.py +57 -0
  101. switch_trust/eval/db/__init__.py +0 -0
  102. switch_trust/eval/db/base/__init__.py +0 -0
  103. switch_trust/eval/db/base/common_types.py +16 -0
  104. switch_trust/eval/db/base/detectors/__init__.py +0 -0
  105. switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
  106. switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
  107. switch_trust/eval/db/base/detectors/detector_types.py +77 -0
  108. switch_trust/eval/db/base/eval/__init__.py +0 -0
  109. switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
  110. switch_trust/eval/db/base/eval/eval_repository.py +120 -0
  111. switch_trust/eval/db/base/eval/eval_run.py +142 -0
  112. switch_trust/eval/db/base/eval/eval_types.py +127 -0
  113. switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
  114. switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
  115. switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
  116. switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  117. switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
  118. switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
  119. switch_trust/eval/db/base/message/__init__.py +0 -0
  120. switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
  121. switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
  122. switch_trust/eval/db/base/message/message_collection_types.py +87 -0
  123. switch_trust/eval/db/base/models/__init__.py +0 -0
  124. switch_trust/eval/db/base/models/model_helpers.py +253 -0
  125. switch_trust/eval/db/base/models/model_repository.py +104 -0
  126. switch_trust/eval/db/base/models/model_types.py +159 -0
  127. switch_trust/eval/db/json/__init__.py +0 -0
  128. switch_trust/eval/db/json/repository_json.py +559 -0
  129. switch_trust/scan/__init__.py +12 -0
  130. switch_trust/scan/agent_scanner.py +812 -0
  131. switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
  132. switch_trust/scan/config/agent_taxonomy.json +517 -0
  133. switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
  134. switch_trust/scan/config/compliance_mappings.json +110 -0
  135. switch_trust/scan/config/triage_prompt.txt +326 -0
  136. switch_trust/scan/constants.py +15 -0
  137. switch_trust/scan/exception_classification.py +179 -0
  138. switch_trust/scan/llm_provider.py +330 -0
  139. switch_trust/scan/opengrep_resolver.py +12 -0
  140. switch_trust/scan/reasoner.py +602 -0
  141. switch_trust/scan/schema.py +315 -0
  142. switch_trust/scan/scorer.py +372 -0
  143. switch_trust/scan/static_scanner.py +681 -0
  144. switch_trust/scan/taxonomy.py +121 -0
  145. switch_trust/scan/tool_dispatcher.py +773 -0
  146. switch_trust/scan/trace_logger.py +118 -0
  147. switch_trust/scan/trace_logger_file.py +143 -0
  148. switch_trust/scan/trace_logger_log.py +100 -0
  149. switch_trust/scan/triage.py +614 -0
  150. switch_trust/schema.py +79 -0
  151. switch_trust/secret_anonymizer.py +72 -0
  152. switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
  153. switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
  154. switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
  155. switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
  156. switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
  157. 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