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,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)