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