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,80 @@
1
+ """Per-endpoint concurrency cap and initial jitter for HTTP-backed models.
2
+
3
+ Multiple :class:`Model` instances are built for the same tenant agent endpoint
4
+ (the worker rebuilds a fresh ``Model`` per job) so the semaphore is held on the
5
+ class rather than the instance: every instance sharing a URL shares one budget.
6
+
7
+ asyncio is single-threaded, so no lock is needed around the dict lookup — the
8
+ check-then-insert in :meth:`_get_semaphore` never awaits between the two ops.
9
+
10
+ Bucketing is by ``endpoint_url`` as passed by the factory, which uses
11
+ ``db_model.host``. For HTTP model types whose real request URL is ``host +
12
+ endpoint`` (GenericHttp, LangServe), calls to distinct paths on the same host
13
+ share one budget — that matches "one tenant server" as a rate-limit unit.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import random
20
+ from typing import TYPE_CHECKING, Any, ClassVar
21
+
22
+ from switch_trust.eval.core.models.model import Model, ModelResponse
23
+
24
+ if TYPE_CHECKING:
25
+ from pydantic import BaseModel
26
+
27
+ from switch_trust.eval.common.schema import Message
28
+
29
+
30
+ class PerEndpointLimiter(Model):
31
+ """Bounds concurrent calls per endpoint URL and jitters the initial burst.
32
+
33
+ Wraps an inner :class:`Model`. Delegates to the inner model's public
34
+ :meth:`Model.generate` (not ``_generate``) so the outer ``generate``
35
+ wrapper's ``TokenUsage`` accumulator dedupe (see ``core/models/model.py``
36
+ ``TokenUsageAccumulator.record_once``) still runs at every layer.
37
+ """
38
+
39
+ _semaphores: ClassVar[dict[str, asyncio.Semaphore]] = {}
40
+
41
+ def __init__(
42
+ self,
43
+ model: Model,
44
+ endpoint_url: str,
45
+ limit: int = 8,
46
+ jitter_max: float = 1.0,
47
+ ):
48
+ self._model = model
49
+ self._endpoint_url = endpoint_url
50
+ self._limit = limit
51
+ self._jitter_max = jitter_max
52
+
53
+ def _get_semaphore(self) -> asyncio.Semaphore:
54
+ sem = PerEndpointLimiter._semaphores.get(self._endpoint_url)
55
+ if sem is None:
56
+ sem = asyncio.Semaphore(self._limit)
57
+ PerEndpointLimiter._semaphores[self._endpoint_url] = sem
58
+ return sem
59
+
60
+ async def _generate(
61
+ self,
62
+ messages: list[Message],
63
+ *,
64
+ output_schema: type[BaseModel] | None = None,
65
+ **kwargs: Any,
66
+ ) -> ModelResponse:
67
+ if self._jitter_max > 0:
68
+ await asyncio.sleep(random.uniform(0, self._jitter_max))
69
+ async with self._get_semaphore():
70
+ return await self._model.generate(
71
+ messages, output_schema=output_schema, **kwargs
72
+ )
73
+
74
+ @classmethod
75
+ def _reset_state_for_testing(cls) -> None:
76
+ # asyncio.Semaphore binds to the loop it was created on, and
77
+ # IsolatedAsyncioTestCase spins up a fresh loop per test. Without
78
+ # clearing the class-level dict, later tests would await on a
79
+ # semaphore attached to a dead loop and hang.
80
+ cls._semaphores.clear()
@@ -0,0 +1,204 @@
1
+ import asyncio
2
+ import logging
3
+ import random
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from switch_trust.eval.common.schema import Message
9
+ from switch_trust.eval.core.models.model import Model, ModelResponse
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _TRANSIENT_EXCEPTIONS = (
14
+ ConnectionError,
15
+ TimeoutError,
16
+ OSError,
17
+ )
18
+
19
+ _TRANSIENT_STATUS_CODES = {429, 500, 502, 503, 504}
20
+ # Terminal statuses stop retries immediately. 401/403 mean bad credentials
21
+ # and 404 means the endpoint is wrong - none recover by waiting.
22
+ _TERMINAL_STATUS_CODES = {401, 403, 404}
23
+ # Upper bound on the delay we accept from a tenant-controlled Retry-After
24
+ # header. Anything larger is clamped so a misconfigured or hostile endpoint
25
+ # can't park a worker coroutine indefinitely (defeats the pacing goal).
26
+ # 60s covers typical 429 recovery windows for the providers we've seen.
27
+ _MAX_RETRY_AFTER_SECONDS = 60.0
28
+
29
+
30
+ def _status_of(exc: Exception) -> int | None:
31
+ for attr in ("status_code", "status", "code"):
32
+ status = getattr(exc, attr, None)
33
+ if isinstance(status, int):
34
+ return status
35
+ return None
36
+
37
+
38
+ def _is_terminal(exc: Exception) -> bool:
39
+ status = _status_of(exc)
40
+ return status is not None and status in _TERMINAL_STATUS_CODES
41
+
42
+
43
+ def _is_transient(exc: Exception) -> bool:
44
+ if isinstance(exc, _TRANSIENT_EXCEPTIONS):
45
+ return True
46
+ status = _status_of(exc)
47
+ return status is not None and status in _TRANSIENT_STATUS_CODES
48
+
49
+
50
+ def _extract_retry_after(exc: Exception) -> float | None:
51
+ """Parse a Retry-After (integer seconds) header off an exception.
52
+
53
+ Duck-types across ``aiohttp.ClientResponseError`` (``exc.headers``) and
54
+ SDK errors like ``openai.APIStatusError`` (``exc.response.headers``).
55
+ HTTP-date form is not supported. The parsed value is clamped to
56
+ ``[0, _MAX_RETRY_AFTER_SECONDS]`` because the endpoint is
57
+ tenant-controlled and cannot be trusted to name a sane delay.
58
+ """
59
+ for source in (exc, getattr(exc, "response", None)):
60
+ headers = getattr(source, "headers", None) if source is not None else None
61
+ if headers is None:
62
+ continue
63
+ try:
64
+ raw = headers.get("Retry-After") or headers.get("retry-after")
65
+ except AttributeError:
66
+ continue
67
+ if raw is None:
68
+ continue
69
+ try:
70
+ delay = float(int(str(raw).strip()))
71
+ except (ValueError, TypeError):
72
+ return None
73
+ return max(0.0, min(delay, _MAX_RETRY_AFTER_SECONDS))
74
+ return None
75
+
76
+
77
+ class ExponentialRetryModel(Model):
78
+ """Wraps a Model and retries on transient errors with
79
+ exponential backoff."""
80
+
81
+ def __init__(
82
+ self,
83
+ model: Model,
84
+ max_retries: int = 5,
85
+ base_delay: float = 2.0,
86
+ ):
87
+ self._model = model
88
+ self._max_retries = max_retries
89
+ self._base_delay = base_delay
90
+
91
+ async def _generate(
92
+ self,
93
+ messages: list[Message],
94
+ *,
95
+ output_schema: type[BaseModel] | None = None,
96
+ **kwargs: Any,
97
+ ) -> ModelResponse:
98
+ last_exc: Exception | None = None
99
+ for attempt in range(self._max_retries + 1):
100
+ try:
101
+ return await self._model.generate(
102
+ messages, output_schema=output_schema, **kwargs
103
+ )
104
+ except Exception as exc:
105
+ if _is_terminal(exc):
106
+ raise
107
+ if not _is_transient(exc) or attempt == self._max_retries:
108
+ raise
109
+ last_exc = exc
110
+ retry_after = _extract_retry_after(exc)
111
+ if retry_after is not None:
112
+ # Add jitter on top of the server-suggested delay: 100
113
+ # concurrent 429s all carry the same Retry-After, so
114
+ # sleeping exactly that value has every task fire again
115
+ # at the same instant and hit the same limit. Same
116
+ # shape as the exponential-backoff branch below.
117
+ delay = retry_after + random.uniform(0, retry_after)
118
+ delay_source = "retry-after"
119
+ else:
120
+ base = self._base_delay * (2**attempt)
121
+ delay = base + random.uniform(0, base)
122
+ delay_source = "backoff"
123
+ logger.warning(
124
+ "Transient error (attempt %d/%d), retrying in %.1fs [%s] (%s: %s)",
125
+ attempt + 1,
126
+ self._max_retries,
127
+ delay,
128
+ delay_source,
129
+ type(exc).__name__,
130
+ exc,
131
+ )
132
+ await asyncio.sleep(delay)
133
+ raise last_exc # unreachable, but keeps type checker happy
134
+
135
+
136
+ def _fibonacci_delays(max_value: float):
137
+ """Yield fibonacci-sequence delays capped at max_value."""
138
+ a, b = 1.0, 1.0
139
+ while True:
140
+ yield min(a, max_value)
141
+ a, b = b, a + b
142
+
143
+
144
+ class FibonacciRetryModel(Model):
145
+ """Wraps a Model and retries on transient errors with
146
+ fibonacci backoff and jitter, capped at a maximum delay."""
147
+
148
+ def __init__(
149
+ self,
150
+ model: Model,
151
+ max_retries: int = 5,
152
+ max_delay: float = 70.0,
153
+ ):
154
+ self._model = model
155
+ self._max_retries = max_retries
156
+ self._max_delay = max_delay
157
+
158
+ async def _generate(
159
+ self,
160
+ messages: list[Message],
161
+ *,
162
+ output_schema: type[BaseModel] | None = None,
163
+ **kwargs: Any,
164
+ ) -> ModelResponse:
165
+ last_exc: Exception | None = None
166
+ delays = _fibonacci_delays(self._max_delay)
167
+ for attempt in range(self._max_retries + 1):
168
+ try:
169
+ return await self._model.generate(
170
+ messages, output_schema=output_schema, **kwargs
171
+ )
172
+ except Exception as exc:
173
+ if _is_terminal(exc):
174
+ raise
175
+ if not _is_transient(exc) or attempt == self._max_retries:
176
+ raise
177
+ last_exc = exc
178
+ base = next(delays)
179
+ retry_after = _extract_retry_after(exc)
180
+ if retry_after is not None:
181
+ # Add jitter on top of the server-suggested delay: 100
182
+ # concurrent 429s all carry the same Retry-After, so
183
+ # sleeping exactly that value has every task fire again
184
+ # at the same instant and hit the same limit. Same
185
+ # shape as the exponential-backoff branch below.
186
+ delay = retry_after + random.uniform(0, retry_after)
187
+ delay_source = "retry-after"
188
+ else:
189
+ delay = random.uniform(0, base)
190
+ delay_source = "backoff"
191
+ logger.warning(
192
+ "Transient error (attempt %d/%d), retrying in %.1fs [%s] (%s: %s)",
193
+ attempt + 1,
194
+ self._max_retries,
195
+ delay,
196
+ delay_source,
197
+ type(exc).__name__,
198
+ exc,
199
+ )
200
+ await asyncio.sleep(delay)
201
+ raise last_exc # unreachable, but keeps type checker happy
202
+
203
+
204
+ RetryModel = ExponentialRetryModel
@@ -0,0 +1,38 @@
1
+ """Sync wrapper around an async Model for use in sync contexts.
2
+
3
+ Used by garak integration which requires synchronous model calls.
4
+ Creates its own event loop to bridge async-to-sync.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from typing import Any
10
+
11
+ from switch_trust.eval.core.models.model import Model, ModelContent, ModelResponse
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SyncModelWrapper:
17
+ """Wraps an async Model for use in sync code (e.g. garak)."""
18
+
19
+ def __init__(self, model: Model):
20
+ self._model = model
21
+ self._loop = asyncio.new_event_loop()
22
+
23
+ def generate(
24
+ self,
25
+ contents: ModelContent,
26
+ **kwargs: Any,
27
+ ) -> ModelResponse:
28
+ logger.debug("SyncModelWrapper: bridging async model call")
29
+ return self._loop.run_until_complete(
30
+ self._model.generate(contents, **kwargs),
31
+ )
32
+
33
+ def close(self):
34
+ self._loop.close()
35
+
36
+ def __del__(self):
37
+ if not self._loop.is_closed():
38
+ self._loop.close()
@@ -0,0 +1,315 @@
1
+ """Model implementation for Google Agent Runtime (Vertex AI reasoning engines).
2
+
3
+ Agents deployed to Agent Runtime are invoked by POSTing to the
4
+ engine resource with a ``:streamQuery`` suffix::
5
+
6
+ POST .../reasoningEngines/{engine_id}:streamQuery
7
+ {"class_method": "stream_query", "input": {"message": "…", "user_id": "…"}}
8
+
9
+ The product has been renamed twice — Reasoning Engine, then Vertex AI Agent
10
+ Engine, now Agent Runtime under Gemini Enterprise Agent Platform — but Google
11
+ kept the old API identifiers for backwards compatibility. So ``reasoningEngines``
12
+ in the URL is correct, and the service still returns ``Agent Engine Error: …``
13
+ on a bad ``class_method``. See ``platform/MODEL_TYPES.md``.
14
+
15
+ Two things make this incompatible with :class:`GenericHttpModel`, which is
16
+ why it needs its own type:
17
+
18
+ * the body is **nested** and carries a sibling ``class_method`` discriminator.
19
+ ``input`` must be a ``google.protobuf.Struct`` — passing the flat
20
+ ``{"input": "<prompt>"}`` that ``GenericHttpModel`` sends is rejected with
21
+ ``400 Invalid value at 'input' (…Struct)``;
22
+ * the response is a **stream of newline-delimited JSON** events, not one JSON
23
+ object, so a single ``resp.json()`` cannot read it.
24
+
25
+ The non-streaming ``:query`` sibling is not an alternative for ADK-deployed
26
+ agents: their ``AdkApp`` wrapper marks every agent-invoking method
27
+ (``stream_query``, ``async_stream_query``, ``streaming_agent_run_with_events``)
28
+ as streaming, so ``:query`` exposes only session and memory management. Calling
29
+ it for ``stream_query`` returns ``404 … method not found``.
30
+
31
+ ``session_id`` is deliberately omitted from the request: ``AdkApp`` creates a
32
+ fresh session per call when it is absent, which is what an eval turn wants —
33
+ each prompt is scored independently, with no history bleeding between them.
34
+
35
+ Authentication accepts either form of ``endpoint_credential``:
36
+
37
+ * a **service-account key** (raw or base64 JSON), from which a scoped access
38
+ token is minted and transparently refreshed — the durable option, since
39
+ Agent Runtime sits behind Google OAuth and a pasted token expires in an hour;
40
+ * a **raw OAuth access token** (e.g. ``gcloud auth print-access-token``), sent
41
+ as-is. Convenient for a one-off local run, but it will start returning
42
+ ``401 Unauthorized`` once it expires.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import asyncio
48
+ import json
49
+ from collections.abc import Callable
50
+ from typing import TYPE_CHECKING, Any
51
+
52
+ import aiohttp
53
+
54
+ from switch_trust.eval.common.schema import Content, Message, Part, Role
55
+ from switch_trust.eval.core.models.model import (
56
+ Model,
57
+ ModelResponse,
58
+ ResponseStatus,
59
+ flatten_messages,
60
+ )
61
+ from switch_trust.eval.core.models.model_http import raise_for_status
62
+
63
+ if TYPE_CHECKING:
64
+ from pydantic import BaseModel
65
+
66
+ # Agent Runtime is a Cloud Platform API; the broad scope is what service-account
67
+ # tokens for aiplatform.googleapis.com are issued against.
68
+ CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
69
+
70
+ DEFAULT_CLASS_METHOD = "stream_query"
71
+ DEFAULT_USER_ID = "eval"
72
+
73
+
74
+ class VertexAgentRuntimeModel(Model):
75
+ """Sends prompts to a Google Agent Runtime reasoning engine.
76
+
77
+ Args:
78
+ url: The full engine URL including the ``:streamQuery`` suffix.
79
+ credential: A service-account key (raw or base64 JSON) or a raw OAuth
80
+ access token. When None, no ``Authorization`` header is added and
81
+ ``headers`` is expected to carry one.
82
+ headers: Extra headers sent with every request.
83
+ user_id: Identifies the caller to the agent; also scopes the
84
+ auto-created session.
85
+ class_method: The engine method to invoke. Engines that expose a
86
+ differently named streaming entry point (e.g.
87
+ ``streaming_agent_run_with_events``) can override it.
88
+ immediate_result: If True, return the first event's text (e.g. a tool
89
+ call). If False (default), return the final text response after all
90
+ tool calls complete.
91
+ connector_factory: Optional aiohttp connector factory; the worker passes
92
+ the SSRF-guarded one (platform/ssrf.py).
93
+ """
94
+
95
+ _url: str
96
+ _headers: dict[str, str]
97
+ _user_id: str
98
+ _class_method: str
99
+ _immediate_result: bool
100
+ _connector_factory: Callable[[], aiohttp.BaseConnector] | None
101
+ # Set when the credential was a service-account key; None when a raw access
102
+ # token (or nothing) was supplied.
103
+ _google_credentials: Any | None
104
+ _static_token: str | None
105
+ _refresh_lock: asyncio.Lock
106
+
107
+ def __init__(
108
+ self,
109
+ url: str,
110
+ credential: str | None = None,
111
+ headers: dict[str, str] | None = None,
112
+ user_id: str = DEFAULT_USER_ID,
113
+ class_method: str = DEFAULT_CLASS_METHOD,
114
+ immediate_result: bool = False,
115
+ connector_factory: Callable[[], aiohttp.BaseConnector] | None = None,
116
+ ):
117
+ self._url = url
118
+ self._headers = dict(headers or {})
119
+ self._user_id = user_id
120
+ self._class_method = class_method
121
+ self._immediate_result = immediate_result
122
+ self._connector_factory = connector_factory
123
+ self._google_credentials = _service_account_credentials(credential)
124
+ self._static_token = (
125
+ credential if credential and self._google_credentials is None else None
126
+ )
127
+ self._refresh_lock = asyncio.Lock()
128
+
129
+ def _refresh_credentials(self) -> None:
130
+ """Refresh the access token in place.
131
+
132
+ Blocking: google-auth's transport is synchronous, so callers run this
133
+ off the event loop.
134
+ """
135
+ from google.auth.transport.requests import ( # noqa: PLC0415 - blocking transport, imported at use
136
+ Request,
137
+ )
138
+
139
+ self._google_credentials.refresh(Request())
140
+
141
+ async def _auth_headers(self) -> dict[str, str]:
142
+ credentials = self._google_credentials
143
+ if credentials is not None:
144
+ # One model instance serves every prompt in a run concurrently, so
145
+ # the moment the token expires all of them see `valid == False` at
146
+ # once. Without the lock that is N simultaneous refreshes — N token
147
+ # requests, and a race on the token/expiry pair, which google-auth
148
+ # does not guard. Re-check inside the lock so only the first waiter
149
+ # refreshes; the rest fall through to the fresh token. The valid
150
+ # path stays a plain attribute read, off the lock and off a thread.
151
+ if not credentials.valid:
152
+ async with self._refresh_lock:
153
+ if not credentials.valid:
154
+ await asyncio.to_thread(self._refresh_credentials)
155
+ return {"Authorization": f"Bearer {credentials.token}"}
156
+ if self._static_token:
157
+ return {"Authorization": f"Bearer {self._static_token}"}
158
+ return {}
159
+
160
+ async def _generate(
161
+ self,
162
+ messages: list[Message],
163
+ *,
164
+ output_schema: type[BaseModel] | None = None,
165
+ **_kwargs: Any,
166
+ ) -> ModelResponse:
167
+ # An agent under test cannot enforce a JSON schema; output_schema is
168
+ # accepted for interface parity and ignored.
169
+ prompt_text = flatten_messages(messages)
170
+
171
+ payload = {
172
+ "class_method": self._class_method,
173
+ "input": {
174
+ "message": prompt_text,
175
+ "user_id": self._user_id,
176
+ },
177
+ }
178
+ headers = {**self._headers, **await self._auth_headers()}
179
+
180
+ connector = self._connector_factory() if self._connector_factory else None
181
+ async with aiohttp.ClientSession(connector=connector) as session:
182
+ async with session.post(
183
+ self._url,
184
+ json=payload,
185
+ headers=headers,
186
+ ) as resp:
187
+ await raise_for_status(resp)
188
+ # Read the whole stream rather than parsing incrementally: an
189
+ # eval turn needs the complete event list to pick the final
190
+ # text, and responses are a handful of KB.
191
+ body = await resp.text()
192
+
193
+ events = _parse_events(body)
194
+ if self._immediate_result:
195
+ response_text = _extract_first(events)
196
+ else:
197
+ response_text = _extract_final(events)
198
+
199
+ if not response_text:
200
+ return ModelResponse(
201
+ message=None,
202
+ status=ResponseStatus.EMPTY_RESPONSE,
203
+ )
204
+
205
+ message = Message(
206
+ content=Content(
207
+ role=Role.ASSISTANT,
208
+ parts=[Part.text_part(response_text)],
209
+ ),
210
+ )
211
+ return ModelResponse(message=message)
212
+
213
+
214
+ def _service_account_credentials(credential: str | None) -> Any | None:
215
+ """Build google-auth credentials when ``credential`` is a service-account key.
216
+
217
+ Returns None for a raw access token (or no credential), which is the signal
218
+ to send it verbatim as a bearer token. Reuses the generator's parser so the
219
+ base64 handling and the structural/PEM validation stay in one place — a
220
+ newline-mangled key then fails with the same actionable message here.
221
+ """
222
+ if not credential:
223
+ return None
224
+
225
+ from google.oauth2 import ( # noqa: PLC0415 - only needed on the service-account path
226
+ service_account,
227
+ )
228
+
229
+ from switch_trust.eval.core.models.generator_model import ( # noqa: PLC0415 - avoids a module-level cycle
230
+ _parse_service_account,
231
+ )
232
+
233
+ try:
234
+ info = _parse_service_account(credential, source="endpoint_credential")
235
+ except ValueError:
236
+ # Not a service-account key — treat it as a raw access token.
237
+ return None
238
+
239
+ return service_account.Credentials.from_service_account_info(
240
+ info,
241
+ scopes=[CLOUD_PLATFORM_SCOPE],
242
+ )
243
+
244
+
245
+ def _parse_events(body: str) -> list[dict]:
246
+ """Parse an Agent Runtime response into a list of event objects.
247
+
248
+ The success path is newline-delimited JSON, one event per line, but a
249
+ single-event response is indistinguishable from a bare JSON object and
250
+ errors come back as a JSON array — so try whole-body JSON first and fall
251
+ back to per-line parsing. Unparseable lines are skipped rather than
252
+ raising: a trailing partial line must not discard the events already read.
253
+ """
254
+ text = body.strip()
255
+ if not text:
256
+ return []
257
+
258
+ try:
259
+ parsed = json.loads(text)
260
+ except json.JSONDecodeError:
261
+ pass
262
+ else:
263
+ return _as_event_list(parsed)
264
+
265
+ events: list[dict] = []
266
+ for line in text.splitlines():
267
+ line = line.strip()
268
+ if not line:
269
+ continue
270
+ try:
271
+ events.extend(_as_event_list(json.loads(line)))
272
+ except json.JSONDecodeError:
273
+ continue
274
+ return events
275
+
276
+
277
+ def _as_event_list(parsed: Any) -> list[dict]:
278
+ if isinstance(parsed, dict):
279
+ return [parsed]
280
+ if isinstance(parsed, list):
281
+ return [item for item in parsed if isinstance(item, dict)]
282
+ return []
283
+
284
+
285
+ def _texts(event: dict) -> list[str] | None:
286
+ """Return an event's text parts, or None if it carries no content."""
287
+ content = event.get("content")
288
+ if not isinstance(content, dict):
289
+ return None
290
+ parts = content.get("parts")
291
+ if not isinstance(parts, list):
292
+ return None
293
+ return [
294
+ part["text"]
295
+ for part in parts
296
+ if isinstance(part, dict) and isinstance(part.get("text"), str)
297
+ ]
298
+
299
+
300
+ def _extract_final(events: list[dict]) -> str | None:
301
+ """Extract the last text response, ignoring tool call/response events."""
302
+ for event in reversed(events):
303
+ texts = _texts(event)
304
+ if texts:
305
+ return " ".join(texts)
306
+ return None
307
+
308
+
309
+ def _extract_first(events: list[dict]) -> str | None:
310
+ """Extract the first event's text content."""
311
+ for event in events:
312
+ texts = _texts(event)
313
+ if texts:
314
+ return " ".join(texts)
315
+ return None