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,421 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Global generator/judge model used for LLM-as-judge detection,
|
|
3
|
+
metric evaluations, adversarial probing, and evaluation
|
|
4
|
+
generation.
|
|
5
|
+
|
|
6
|
+
Configured via the GENERATOR_MODEL environment variable:
|
|
7
|
+
GENERATOR_MODEL=type:model_name
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
GENERATOR_MODEL=gemini:gemini-2.5-flash-lite
|
|
11
|
+
GENERATOR_MODEL=openai:gpt-4o-mini
|
|
12
|
+
GENERATOR_MODEL=anthropic:claude-haiku-4-20250414
|
|
13
|
+
GENERATOR_MODEL=litellm:gpt-4o
|
|
14
|
+
GENERATOR_MODEL=ollama:llama3
|
|
15
|
+
|
|
16
|
+
For the ``gemini`` type, the client authenticates against the Gemini Developer
|
|
17
|
+
API by default (via ``GOOGLE_API_KEY``). Set GOOGLE_VERTEX_CREDENTIALS_JSON to a
|
|
18
|
+
service-account key (raw JSON or base64-encoded JSON) to route through Vertex AI
|
|
19
|
+
instead; GOOGLE_CLOUD_LOCATION is then required and GOOGLE_CLOUD_PROJECT defaults
|
|
20
|
+
to the ``project_id`` inside the key.
|
|
21
|
+
|
|
22
|
+
Config handling follows one rule: the environment is read and validated in
|
|
23
|
+
exactly one place — ``GeneratorConfig.from_env()`` — which returns a validated
|
|
24
|
+
object the rest of this module builds from. The factories (``_create_inner`` /
|
|
25
|
+
``_create_gemini_client``) take that typed config and never touch ``os.environ``,
|
|
26
|
+
so a malformed value is caught at the funnel (and, in the worker, at startup via
|
|
27
|
+
``set_generator_config``) rather than mid-run.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import binascii
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from pydantic import BaseModel
|
|
41
|
+
|
|
42
|
+
from switch_trust.eval.common.schema import Message
|
|
43
|
+
from switch_trust.eval.core.models.model import Model, ModelResponse
|
|
44
|
+
from switch_trust.eval.core.models.model_retry import RetryModel
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class VertexConfig:
|
|
51
|
+
"""Vertex AI wiring for a ``gemini`` generator client.
|
|
52
|
+
|
|
53
|
+
Present only when GOOGLE_VERTEX_CREDENTIALS_JSON is set; a Developer-API
|
|
54
|
+
gemini generator leaves ``GeneratorConfig.vertex`` as ``None``. Built through
|
|
55
|
+
``from_key`` so the service-account JSON is decoded and structurally
|
|
56
|
+
validated — and the project/location resolved — before it is ever used.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Parsed, structurally validated service-account key. Secret (holds the
|
|
60
|
+
# private key), so it is never echoed in errors or logs.
|
|
61
|
+
service_account: dict
|
|
62
|
+
project: str
|
|
63
|
+
location: str
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def from_key(raw: str, project: str, location: str) -> VertexConfig:
|
|
67
|
+
"""Build+validate from the raw key and the (possibly empty) project/location.
|
|
68
|
+
|
|
69
|
+
Project defaults to the key's ``project_id`` when not given; location has
|
|
70
|
+
no default and is required, since a Vertex client cannot be built without
|
|
71
|
+
it. Raises ``ValueError`` on any missing/malformed piece.
|
|
72
|
+
"""
|
|
73
|
+
info = _parse_service_account(raw, source="GOOGLE_VERTEX_CREDENTIALS_JSON")
|
|
74
|
+
project = project or str(info.get("project_id") or "")
|
|
75
|
+
if not location:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"Vertex AI generator requires GOOGLE_CLOUD_LOCATION alongside "
|
|
78
|
+
"GOOGLE_VERTEX_CREDENTIALS_JSON"
|
|
79
|
+
)
|
|
80
|
+
if not project:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"Vertex generator requires a project (GOOGLE_CLOUD_PROJECT, or a "
|
|
83
|
+
"project_id in the service-account key)"
|
|
84
|
+
)
|
|
85
|
+
return VertexConfig(service_account=info, project=project, location=location)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class GeneratorConfig:
|
|
90
|
+
"""Validated generator configuration — the single environment funnel.
|
|
91
|
+
|
|
92
|
+
``from_env`` is the only place the environment is read; it parses and
|
|
93
|
+
validates the shape, so everything downstream works with a typed object and
|
|
94
|
+
focuses on *what* to build, not *how* to fetch config.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
model_type: str
|
|
98
|
+
model_name: str
|
|
99
|
+
# gemini-only: the Vertex client wiring, or None for the Developer-API path.
|
|
100
|
+
vertex: VertexConfig | None = None
|
|
101
|
+
# gemini-only: whether a GOOGLE_API_KEY was present at parse time. Only its
|
|
102
|
+
# presence is captured — the key itself is read from the environment by the
|
|
103
|
+
# google-genai client at build time. Used by ``validate`` to insist on a
|
|
104
|
+
# usable Developer-API credential when Vertex is not configured.
|
|
105
|
+
gemini_api_key_present: bool = False
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def from_env() -> GeneratorConfig:
|
|
109
|
+
"""Parse and validate the generator configuration from the environment."""
|
|
110
|
+
spec = os.environ.get("GENERATOR_MODEL", "").strip()
|
|
111
|
+
if not spec:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"GENERATOR_MODEL must be set (format type:model_name, "
|
|
114
|
+
"e.g. gemini:gemini-2.5-flash)"
|
|
115
|
+
)
|
|
116
|
+
if ":" not in spec:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Invalid GENERATOR_MODEL format: {spec!r}. Expected type:model_name"
|
|
119
|
+
)
|
|
120
|
+
model_type, model_name = (part.strip() for part in spec.split(":", 1))
|
|
121
|
+
if not model_type or not model_name:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Invalid GENERATOR_MODEL format: {spec!r}. Expected type:model_name"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
vertex: VertexConfig | None = None
|
|
127
|
+
api_key_present = False
|
|
128
|
+
if model_type == "gemini":
|
|
129
|
+
raw = os.environ.get("GOOGLE_VERTEX_CREDENTIALS_JSON", "").strip()
|
|
130
|
+
if raw:
|
|
131
|
+
vertex = VertexConfig.from_key(
|
|
132
|
+
raw,
|
|
133
|
+
project=os.environ.get("GOOGLE_CLOUD_PROJECT", "").strip(),
|
|
134
|
+
location=os.environ.get("GOOGLE_CLOUD_LOCATION", "").strip(),
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
api_key_present = bool(os.environ.get("GOOGLE_API_KEY", "").strip())
|
|
138
|
+
|
|
139
|
+
return GeneratorConfig(
|
|
140
|
+
model_type=model_type,
|
|
141
|
+
model_name=model_name,
|
|
142
|
+
vertex=vertex,
|
|
143
|
+
gemini_api_key_present=api_key_present,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def validate(self) -> None:
|
|
147
|
+
"""Fail-fast startup check, beyond the shape checks ``from_env`` did.
|
|
148
|
+
|
|
149
|
+
Requires a usable gemini credential combination (Vertex creds or
|
|
150
|
+
GOOGLE_API_KEY — ADC alone is accepted at runtime but not treated as
|
|
151
|
+
"configured" here) and exercises model construction, which makes no API
|
|
152
|
+
call but does validate provider wiring and the service-account key's
|
|
153
|
+
private-key material. Called once by the worker before it goes ready so a
|
|
154
|
+
misconfigured generator aborts the process rather than the first job.
|
|
155
|
+
"""
|
|
156
|
+
if (
|
|
157
|
+
self.model_type == "gemini"
|
|
158
|
+
and self.vertex is None
|
|
159
|
+
and not self.gemini_api_key_present
|
|
160
|
+
):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"gemini generator requires either GOOGLE_VERTEX_CREDENTIALS_JSON + "
|
|
163
|
+
"GOOGLE_CLOUD_LOCATION (Vertex AI) or GOOGLE_API_KEY "
|
|
164
|
+
"(Gemini Developer API)"
|
|
165
|
+
)
|
|
166
|
+
# Build the model once to exercise wiring / credential structure; discard.
|
|
167
|
+
_create_inner(self)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Installed once at worker startup so every model build reuses the config that
|
|
171
|
+
# already passed through the from_env() validation funnel instead of re-reading
|
|
172
|
+
# the environment. Left None (the CLI path), get_generator_model() falls back to
|
|
173
|
+
# GeneratorConfig.from_env().
|
|
174
|
+
_config: GeneratorConfig | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def set_generator_config(config: GeneratorConfig) -> None:
|
|
178
|
+
"""Install the validated config get_generator_model() builds from.
|
|
179
|
+
|
|
180
|
+
The worker calls this once at startup with the object returned by
|
|
181
|
+
``GeneratorConfig.from_env()`` (after ``validate()``), so the many lazy
|
|
182
|
+
``get_generator_model()`` call sites reuse one validated config rather than
|
|
183
|
+
each re-parsing the environment.
|
|
184
|
+
"""
|
|
185
|
+
global _config
|
|
186
|
+
_config = config
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class GeneratorModel(Model):
|
|
190
|
+
"""Decorator that flags generator/judge calls made without a schema.
|
|
191
|
+
|
|
192
|
+
The generator model is used for LLM-as-judge detection and synthetic
|
|
193
|
+
prompt generation, which parse the response as JSON. Callers should pass an
|
|
194
|
+
``output_schema`` so the underlying provider constrains the output; a call
|
|
195
|
+
without one falls back to unconstrained text and is prone to parse
|
|
196
|
+
failures, so we log a warning to surface the omission.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
_model: Model
|
|
200
|
+
|
|
201
|
+
def __init__(self, model: Model) -> None:
|
|
202
|
+
self._model = model
|
|
203
|
+
|
|
204
|
+
async def _generate(
|
|
205
|
+
self,
|
|
206
|
+
messages: list[Message],
|
|
207
|
+
*,
|
|
208
|
+
output_schema: type[BaseModel] | None = None,
|
|
209
|
+
**kwargs: Any,
|
|
210
|
+
) -> ModelResponse:
|
|
211
|
+
if output_schema is None:
|
|
212
|
+
logger.warning(
|
|
213
|
+
"Generator model called without an output_schema; structured "
|
|
214
|
+
"output is not enforced and response parsing may fail"
|
|
215
|
+
)
|
|
216
|
+
return await self._model.generate(
|
|
217
|
+
messages, output_schema=output_schema, **kwargs
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def get_generator_model() -> Model:
|
|
222
|
+
"""Create a fresh generator model instance.
|
|
223
|
+
|
|
224
|
+
Always creates a new instance because async SDK clients are bound to the
|
|
225
|
+
event loop they're first used on. Caching across loops causes 'Future
|
|
226
|
+
attached to a different loop' errors. The *config* is reused when installed
|
|
227
|
+
via ``set_generator_config`` (worker) or parsed on demand (CLI); only the
|
|
228
|
+
model/client is rebuilt.
|
|
229
|
+
"""
|
|
230
|
+
return GeneratorModel(_create_generator_model())
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _create_generator_model() -> Model:
|
|
234
|
+
config = _config if _config is not None else GeneratorConfig.from_env()
|
|
235
|
+
inner = _create_inner(config)
|
|
236
|
+
logger.debug(
|
|
237
|
+
"Creating generator model: %s:%s", config.model_type, config.model_name
|
|
238
|
+
)
|
|
239
|
+
return RetryModel(inner, max_retries=5)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _create_inner(config: GeneratorConfig) -> Model:
|
|
243
|
+
model_type, model_name = config.model_type, config.model_name
|
|
244
|
+
if model_type == "gemini":
|
|
245
|
+
from switch_trust.eval.core.models.model_gemini import ( # noqa: PLC0415 - patched at source in tests
|
|
246
|
+
SAFETY_OFF,
|
|
247
|
+
GeminiModel,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# The generator is the attacker/judge; it must not be safety-blocked, or
|
|
251
|
+
# adversarial turns and judge JSON come back empty (especially on Vertex).
|
|
252
|
+
return GeminiModel(
|
|
253
|
+
_create_gemini_client(config.vertex),
|
|
254
|
+
model_name,
|
|
255
|
+
safety_settings=SAFETY_OFF,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
elif model_type == "openai":
|
|
259
|
+
from openai import AsyncOpenAI # noqa: PLC0415 - patched at source in tests
|
|
260
|
+
|
|
261
|
+
from switch_trust.eval.core.models.model_openai import ( # noqa: PLC0415 - patched at source in tests
|
|
262
|
+
OpenAIModel,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return OpenAIModel(AsyncOpenAI(), model_name)
|
|
266
|
+
|
|
267
|
+
elif model_type == "anthropic":
|
|
268
|
+
from anthropic import ( # noqa: PLC0415 - patched at source in tests
|
|
269
|
+
AsyncAnthropic,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
from switch_trust.eval.core.models.model_anthropic import ( # noqa: PLC0415 - patched at source in tests
|
|
273
|
+
AnthropicModel,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return AnthropicModel(AsyncAnthropic(), model_name)
|
|
277
|
+
|
|
278
|
+
elif model_type == "litellm":
|
|
279
|
+
from switch_trust.eval.core.models.model_litellm import ( # noqa: PLC0415 - patched at source in tests
|
|
280
|
+
LiteLLMModel,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return LiteLLMModel(model_name)
|
|
284
|
+
|
|
285
|
+
elif model_type == "ollama":
|
|
286
|
+
from switch_trust.eval.core.models.model_ollama import ( # noqa: PLC0415 - patched at source in tests
|
|
287
|
+
OllamaModel,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return OllamaModel(model_name)
|
|
291
|
+
|
|
292
|
+
else:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f"Unknown generator model type: {model_type!r}. "
|
|
295
|
+
"Supported: gemini, openai, anthropic, litellm, ollama"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _create_gemini_client(vertex: VertexConfig | None):
|
|
300
|
+
"""Build the google-genai client for the generator model.
|
|
301
|
+
|
|
302
|
+
With ``vertex`` unset this is the unchanged default: ``Client()`` picks up
|
|
303
|
+
the Gemini Developer API (GOOGLE_API_KEY) or a fully env-driven Vertex/ADC
|
|
304
|
+
setup. With it set, the client is built in Vertex AI mode from the (already
|
|
305
|
+
parsed and validated) service-account key, so a key handed to us as JSON is
|
|
306
|
+
usable without writing it to a file or relying on ADC discovery.
|
|
307
|
+
|
|
308
|
+
Imports stay lazy (no module-scope google dependency), so the return type is
|
|
309
|
+
left undeclared rather than pulling ``google.genai.Client`` to the top.
|
|
310
|
+
"""
|
|
311
|
+
from google.genai import ( # noqa: PLC0415 - lazy import keeps google.genai out of module-scope deps
|
|
312
|
+
Client,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if vertex is None:
|
|
316
|
+
return Client()
|
|
317
|
+
|
|
318
|
+
from google.oauth2 import ( # noqa: PLC0415 - lazy import keeps google.oauth2 out of module-scope deps
|
|
319
|
+
service_account,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
credentials = service_account.Credentials.from_service_account_info(
|
|
323
|
+
vertex.service_account,
|
|
324
|
+
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
325
|
+
)
|
|
326
|
+
logger.debug("Creating Vertex AI Gemini client (project=%s)", vertex.project)
|
|
327
|
+
return Client(
|
|
328
|
+
vertexai=True,
|
|
329
|
+
project=vertex.project,
|
|
330
|
+
location=vertex.location,
|
|
331
|
+
credentials=credentials,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _parse_service_account(raw: str, source: str = "service-account key") -> dict:
|
|
336
|
+
"""Decode + structurally validate a service-account key.
|
|
337
|
+
|
|
338
|
+
Accepts raw JSON or base64-encoded JSON (base64 so the multi-line key
|
|
339
|
+
survives shell env files), then checks it is actually a service-account key
|
|
340
|
+
with the fields Vertex auth needs — a tight structural check rather than a
|
|
341
|
+
bare parse — and that ``private_key`` is a structurally intact PEM (see
|
|
342
|
+
``_validate_private_key_pem``), so a newline-mangled key fails here with an
|
|
343
|
+
actionable message rather than as a terse google-auth error later. ``source``
|
|
344
|
+
names the origin of ``raw`` in error messages so the function is not coupled
|
|
345
|
+
to any single caller/env var. The errors never echo ``raw`` — it is secret
|
|
346
|
+
material (contains a private key).
|
|
347
|
+
"""
|
|
348
|
+
try:
|
|
349
|
+
info = json.loads(raw)
|
|
350
|
+
except json.JSONDecodeError:
|
|
351
|
+
try:
|
|
352
|
+
decoded = base64.b64decode(raw, validate=True)
|
|
353
|
+
except (binascii.Error, ValueError) as exc:
|
|
354
|
+
raise ValueError(f"{source} is neither valid JSON nor base64") from exc
|
|
355
|
+
try:
|
|
356
|
+
info = json.loads(decoded)
|
|
357
|
+
except json.JSONDecodeError as exc:
|
|
358
|
+
raise ValueError(f"{source} base64-decoded to invalid JSON") from exc
|
|
359
|
+
|
|
360
|
+
if not isinstance(info, dict):
|
|
361
|
+
raise ValueError(f"{source} must be a JSON object")
|
|
362
|
+
if info.get("type") != "service_account":
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f'{source} must be a service-account key (its "type" must be '
|
|
365
|
+
'"service_account")'
|
|
366
|
+
)
|
|
367
|
+
missing = [
|
|
368
|
+
field
|
|
369
|
+
for field in ("client_email", "private_key", "token_uri")
|
|
370
|
+
if not info.get(field)
|
|
371
|
+
]
|
|
372
|
+
if missing:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f"{source} is missing required service-account fields: {', '.join(missing)}"
|
|
375
|
+
)
|
|
376
|
+
_validate_private_key_pem(str(info["private_key"]), source)
|
|
377
|
+
return info
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _validate_private_key_pem(private_key: str, source: str) -> None:
|
|
381
|
+
"""Check the ``private_key`` field is a structurally intact PEM block.
|
|
382
|
+
|
|
383
|
+
The structural checks above only confirm ``private_key`` is *present*; the
|
|
384
|
+
actual PEM is parsed much later by google-auth
|
|
385
|
+
(``from_service_account_info``), which fails with a terse "invalid private
|
|
386
|
+
key" that gives no hint at the real cause. That cause is almost always the
|
|
387
|
+
key's newlines being mangled while the secret is seeded by hand: a JSON key
|
|
388
|
+
piped through a shell/YAML/``jq -r`` step has its ``\\n`` either collapsed
|
|
389
|
+
(the PEM becomes one line) or double-escaped (``\\\\n``), and either way the
|
|
390
|
+
JSON still parses and this field is still non-empty. Catch that here, at the
|
|
391
|
+
config funnel, with a message that names the fix instead of leaving it to a
|
|
392
|
+
google-auth stack trace mid-startup.
|
|
393
|
+
|
|
394
|
+
A cheap structural check, deliberately no crypto dependency (this lives in
|
|
395
|
+
the OSS-exported ``core/``): require the PEM header/footer markers and real
|
|
396
|
+
newlines, and reject the literal ``\\n`` escape sequence. The message never
|
|
397
|
+
echoes the key material.
|
|
398
|
+
"""
|
|
399
|
+
if r"\n" in private_key:
|
|
400
|
+
raise ValueError(
|
|
401
|
+
f"{source} private_key contains the literal escape sequence '\\n' "
|
|
402
|
+
"rather than real newlines — its newlines were double-escaped during "
|
|
403
|
+
"seeding. Re-seed from the untouched key file (e.g. "
|
|
404
|
+
"--secret-string file://key.json), or store the whole key as "
|
|
405
|
+
"single-line base64."
|
|
406
|
+
)
|
|
407
|
+
if "-----BEGIN" not in private_key or "-----END" not in private_key:
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"{source} private_key is not a PEM block (missing BEGIN/END "
|
|
410
|
+
"markers) — the key material was corrupted during seeding. Re-seed "
|
|
411
|
+
"from the untouched key file, or store the whole key as single-line "
|
|
412
|
+
"base64."
|
|
413
|
+
)
|
|
414
|
+
if "\n" not in private_key.strip("\n"):
|
|
415
|
+
raise ValueError(
|
|
416
|
+
f"{source} private_key has no internal newlines — the PEM was "
|
|
417
|
+
"collapsed onto one line during seeding, which google-auth rejects "
|
|
418
|
+
"as an invalid private key. Re-seed from the untouched key file "
|
|
419
|
+
"(e.g. --secret-string file://key.json), or store the whole key as "
|
|
420
|
+
"single-line base64 so the newlines survive."
|
|
421
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from switch_trust.eval.common.schema import Content, Message, PartType, Role
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ResponseStatus(str, Enum):
|
|
14
|
+
"""Outcome of a model generation request."""
|
|
15
|
+
|
|
16
|
+
OK = "ok"
|
|
17
|
+
BLOCKED_SAFETY = "blocked_safety"
|
|
18
|
+
BLOCKED_RECITATION = "blocked_recitation"
|
|
19
|
+
BLOCKED_PROHIBITED = "blocked_prohibited"
|
|
20
|
+
EMPTY_RESPONSE = "empty_response"
|
|
21
|
+
ERROR = "error"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ModelContent = str | Message | list[Message]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ModelResponse:
|
|
28
|
+
message: Message | None
|
|
29
|
+
status: ResponseStatus
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
message: Message | None,
|
|
34
|
+
status: ResponseStatus = ResponseStatus.OK,
|
|
35
|
+
):
|
|
36
|
+
self.message = message
|
|
37
|
+
self.status = status
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _message_text(msg: Message) -> str:
|
|
41
|
+
parts = [p.text for p in msg.content.parts if p.text]
|
|
42
|
+
return " ".join(parts) if parts else ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _prompt_length(contents: ModelContent) -> int:
|
|
46
|
+
if isinstance(contents, str):
|
|
47
|
+
return len(contents)
|
|
48
|
+
if isinstance(contents, Message):
|
|
49
|
+
return len(_message_text(contents))
|
|
50
|
+
return sum(len(_message_text(m)) for m in contents) if contents else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Model(ABC):
|
|
54
|
+
async def generate(
|
|
55
|
+
self,
|
|
56
|
+
contents: ModelContent,
|
|
57
|
+
*,
|
|
58
|
+
output_schema: type[BaseModel] | None = None,
|
|
59
|
+
**kwargs: Any,
|
|
60
|
+
) -> ModelResponse:
|
|
61
|
+
"""Generate a response from the model.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
contents: The input to the model. Can be:
|
|
65
|
+
- A string (converted to a user message)
|
|
66
|
+
- A single Message
|
|
67
|
+
- A list of Messages
|
|
68
|
+
output_schema: Optional Pydantic model describing the JSON shape
|
|
69
|
+
the response must take. When set, the adapter constrains the
|
|
70
|
+
output using the provider's native structured-output mechanism.
|
|
71
|
+
Models under test that cannot enforce a schema ignore it.
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(contents, str):
|
|
74
|
+
messages = [
|
|
75
|
+
Message(
|
|
76
|
+
content=Content.text(Role.USER, contents),
|
|
77
|
+
),
|
|
78
|
+
]
|
|
79
|
+
elif isinstance(contents, Message):
|
|
80
|
+
messages = [contents]
|
|
81
|
+
else:
|
|
82
|
+
messages = contents
|
|
83
|
+
|
|
84
|
+
prompt_len = _prompt_length(contents)
|
|
85
|
+
model_name = type(self).__name__
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
response = await self._generate(
|
|
89
|
+
messages, output_schema=output_schema, **kwargs
|
|
90
|
+
)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error(
|
|
93
|
+
"%s: prompt=%d chars (%s: %s)",
|
|
94
|
+
model_name,
|
|
95
|
+
prompt_len,
|
|
96
|
+
type(e).__name__,
|
|
97
|
+
e,
|
|
98
|
+
exc_info=True,
|
|
99
|
+
)
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
response_len = len(_message_text(response.message)) if response.message else 0
|
|
103
|
+
if response.status != ResponseStatus.OK:
|
|
104
|
+
logger.warning(
|
|
105
|
+
"%s: prompt=%d chars, status=%s",
|
|
106
|
+
model_name,
|
|
107
|
+
prompt_len,
|
|
108
|
+
response.status.value,
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
logger.debug(
|
|
112
|
+
"%s: prompt=%d chars, response=%d chars",
|
|
113
|
+
model_name,
|
|
114
|
+
prompt_len,
|
|
115
|
+
response_len,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
async def _generate(
|
|
122
|
+
self,
|
|
123
|
+
messages: list[Message],
|
|
124
|
+
*,
|
|
125
|
+
output_schema: type[BaseModel] | None = None,
|
|
126
|
+
**kwargs: Any,
|
|
127
|
+
) -> ModelResponse:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# -- Helper functions -----------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def flatten_messages(messages: list[Message]) -> str:
|
|
135
|
+
if len(messages) == 1:
|
|
136
|
+
parts = [p.text for p in messages[0].content.parts if p.text]
|
|
137
|
+
return " ".join(parts)
|
|
138
|
+
|
|
139
|
+
lines: list[str] = []
|
|
140
|
+
for msg in messages:
|
|
141
|
+
role = msg.content.role.value.upper()
|
|
142
|
+
text = " ".join(p.text for p in msg.content.parts if p.text)
|
|
143
|
+
lines.append(f"{role}: {text}")
|
|
144
|
+
return "\n\n".join(lines)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def extract_text_from_message(message: Message) -> str:
|
|
148
|
+
return "".join(part.text for part in message.content.parts if part.text is not None)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def extract_final_text(message: Message) -> str:
|
|
152
|
+
"""Extract only the final text output, excluding
|
|
153
|
+
thinking, tool calls, and tool results."""
|
|
154
|
+
text_parts = [
|
|
155
|
+
part.text
|
|
156
|
+
for part in message.content.parts
|
|
157
|
+
if part.text is not None and part.part_type == PartType.TEXT
|
|
158
|
+
]
|
|
159
|
+
if text_parts:
|
|
160
|
+
return "".join(text_parts)
|
|
161
|
+
return extract_text_from_message(message)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def extract_text_from_conversation(
|
|
165
|
+
messages: list[Message],
|
|
166
|
+
) -> str:
|
|
167
|
+
lines: list[str] = []
|
|
168
|
+
for msg in messages:
|
|
169
|
+
role = msg.content.role.value.upper()
|
|
170
|
+
text = extract_final_text(msg)
|
|
171
|
+
lines.append(f"{role}: {text}")
|
|
172
|
+
return "\n\n".join(lines)
|