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,135 @@
1
+ """Shared HTTP-error handling for the aiohttp-backed model/agent types.
2
+
3
+ `resp.raise_for_status()` raises `aiohttp.ClientResponseError` from just
4
+ `status` + `reason` + `url` — the response body, where the useful detail
5
+ lives (e.g. a Google quota error's `error.status`/`consumer`), is discarded.
6
+
7
+ The body is never logged here, deliberately: `endpoint_url`/`db_model.host`
8
+ (consumed by every caller of `raise_for_status()` below) is tenant-controlled
9
+ (the same field `platform/ssrf.py` guards), so the body can carry arbitrary
10
+ tenant/user content — echoed prompts, internal stack traces, anything the
11
+ tenant's server chooses to put in an error response. There is no reliable way
12
+ to tell a safe platform-template error apart from arbitrary tenant content
13
+ from the outside, so instead of logging the body, this module extracts two
14
+ narrow, content-free signals from it and discards the rest.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from urllib.parse import urlsplit, urlunsplit
22
+
23
+ import aiohttp
24
+
25
+ # Bodies are only read for their two content-free signals -- there is never a
26
+ # reason to buffer more than a small error payload needs, and an unbounded
27
+ # read lets a misbehaving tenant server exhaust worker memory with one huge
28
+ # response.
29
+ _MAX_BODY_BYTES = 64 * 1024
30
+
31
+ # The fixed google.rpc.Code vocabulary. Only a status string that is an exact
32
+ # match is ever surfaced -- this makes the field safe to log even though it
33
+ # came from a tenant-reachable response: a server can put arbitrary text in a
34
+ # field called "status", but it can't make arbitrary text collide with one of
35
+ # these 17 fixed names, so anything else is treated as unrecognized and
36
+ # dropped.
37
+ _KNOWN_ERROR_STATUSES = frozenset(
38
+ {
39
+ "OK",
40
+ "CANCELLED",
41
+ "UNKNOWN",
42
+ "INVALID_ARGUMENT",
43
+ "DEADLINE_EXCEEDED",
44
+ "NOT_FOUND",
45
+ "ALREADY_EXISTS",
46
+ "PERMISSION_DENIED",
47
+ "UNAUTHENTICATED",
48
+ "RESOURCE_EXHAUSTED",
49
+ "FAILED_PRECONDITION",
50
+ "ABORTED",
51
+ "OUT_OF_RANGE",
52
+ "UNIMPLEMENTED",
53
+ "INTERNAL",
54
+ "UNAVAILABLE",
55
+ "DATA_LOSS",
56
+ }
57
+ )
58
+ _PROJECT_NUMBER_RE = re.compile(r"/projects/(\d+)/")
59
+
60
+
61
+ class ModelHttpError(Exception):
62
+ """HTTP error from a model/agent endpoint.
63
+
64
+ Deliberately carries no response-body content (see module docstring).
65
+ `error_status` and `same_project_as_url` are the only signals derived
66
+ from the body, and both are content-free.
67
+
68
+ Duck-types `.status` like `aiohttp.ClientResponseError` so the existing
69
+ `_is_transient` (and any future `_is_terminal`) checks in
70
+ `model_retry.py`, which read `status_code`/`status`/`code` via
71
+ `getattr`, keep working unmodified.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ status: int,
77
+ url: str,
78
+ error_status: str | None,
79
+ same_project_as_url: bool | None,
80
+ ):
81
+ self.status = status
82
+ self.url = url
83
+ self.error_status = error_status
84
+ self.same_project_as_url = same_project_as_url
85
+ parts = [f"{status} from {url}"]
86
+ if error_status is not None:
87
+ parts.append(f"error_status={error_status}")
88
+ if same_project_as_url is not None:
89
+ parts.append(f"same_project_as_url={same_project_as_url}")
90
+ super().__init__(", ".join(parts))
91
+
92
+
93
+ async def raise_for_status(resp: aiohttp.ClientResponse) -> None:
94
+ """Like `resp.raise_for_status()`, but derives two safe signals from the
95
+ body first. The body itself is never stored on the raised exception or
96
+ returned -- only `_extract_error_status()`/`_same_project_as_url()`'s
97
+ outputs are."""
98
+ if resp.status < 400:
99
+ return
100
+ raw = await resp.content.read(_MAX_BODY_BYTES)
101
+ body = raw.decode(resp.charset or "utf-8", errors="replace")
102
+ # Query/fragment can carry tenant-supplied secrets (e.g. an API key on a
103
+ # GenericHttpModel/LangServe endpoint URL) -- strip them before this URL
104
+ # flows into the exception string, logs, and the OTel span.
105
+ parts = urlsplit(str(resp.url))
106
+ url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
107
+ raise ModelHttpError(
108
+ resp.status,
109
+ url,
110
+ _extract_error_status(body),
111
+ _same_project_as_url(body, url),
112
+ )
113
+
114
+
115
+ def _extract_error_status(body: str) -> str | None:
116
+ """The fixed-vocabulary ``error.status`` enum (e.g. RESOURCE_EXHAUSTED),
117
+ if the body has that shape *and* the value is one of the known names.
118
+ Never returns ``error.message`` or any other free text -- that's exactly
119
+ the field a tenant server could use to smuggle arbitrary content."""
120
+ try:
121
+ status = json.loads(body)["error"]["status"]
122
+ except (json.JSONDecodeError, KeyError, TypeError):
123
+ return None
124
+ return status if status in _KNOWN_ERROR_STATUSES else None
125
+
126
+
127
+ def _same_project_as_url(body: str, url: str) -> bool | None:
128
+ """Whether the GCP project number already visible in `url` also appears
129
+ as `project_number:<n>` in the body -- as a boolean only, never quoting
130
+ the body itself. `None` when `url` has no project number (the check
131
+ doesn't apply), distinct from `False` (checked, and it didn't match)."""
132
+ match = _PROJECT_NUMBER_RE.search(url)
133
+ if not match:
134
+ return None
135
+ return f"project_number:{match.group(1)}" in body
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from switch_trust.eval.common.schema import Content, Message, PartType, Role
7
+ from switch_trust.eval.core.models.model import Model, ModelResponse
8
+ from switch_trust.eval.core.optional_deps import require_transformers_pipeline
9
+
10
+ if TYPE_CHECKING:
11
+ from pydantic import BaseModel
12
+ from transformers import Pipeline
13
+
14
+
15
+ class HuggingFaceModel(Model):
16
+ """Model adapter for Hugging Face ``text-generation`` pipelines.
17
+
18
+ Accepts either an already-constructed ``Pipeline`` or a model name /
19
+ path to load one on the fly.
20
+ """
21
+
22
+ _pipeline: Pipeline
23
+ _temperature: float
24
+
25
+ def __init__(
26
+ self,
27
+ model: str | Pipeline,
28
+ temperature: float = 0.0,
29
+ **pipeline_kwargs: Any,
30
+ ):
31
+ if isinstance(model, str):
32
+ pipeline = require_transformers_pipeline()
33
+ self._pipeline = pipeline(
34
+ "text-generation",
35
+ model=model,
36
+ **pipeline_kwargs,
37
+ )
38
+ else:
39
+ self._pipeline = model
40
+ self._temperature = temperature
41
+
42
+ async def _generate(
43
+ self,
44
+ messages: list[Message],
45
+ *,
46
+ output_schema: type[BaseModel] | None = None,
47
+ **kwargs: Any,
48
+ ) -> ModelResponse:
49
+ # A local text-generation pipeline cannot enforce a JSON schema;
50
+ # output_schema is accepted for interface parity and ignored.
51
+ return await asyncio.to_thread(
52
+ self._generate_sync,
53
+ messages,
54
+ **kwargs,
55
+ )
56
+
57
+ def _generate_sync(self, messages: list[Message], **kwargs: Any) -> ModelResponse:
58
+ if len(messages) > 1:
59
+ raise ValueError("HuggingFaceModel does not support multiple messages")
60
+ prompt = _collect_text(messages[0].content)
61
+ kwargs.setdefault("max_new_tokens", 256)
62
+ kwargs.setdefault("return_full_text", False)
63
+ kwargs.setdefault("temperature", self._temperature or None)
64
+ outputs = self._pipeline(prompt, **kwargs)
65
+ generated_text = outputs[0]["generated_text"]
66
+ message = Message(
67
+ content=Content.text(Role.ASSISTANT, generated_text),
68
+ )
69
+ return ModelResponse(message)
70
+
71
+
72
+ def _collect_text(content: Content) -> str:
73
+ return " ".join(
74
+ p.text for p in content.parts if p.part_type == PartType.TEXT and p.text
75
+ )
@@ -0,0 +1,96 @@
1
+ """Model for LangServe endpoints.
2
+
3
+ LangServe exposes LangChain runnables as REST APIs with
4
+ a standard ``/{chain}/invoke`` endpoint.
5
+
6
+ Request format::
7
+
8
+ POST /my-chain/invoke
9
+ {"input": "Hello"}
10
+
11
+ Response format::
12
+
13
+ {"output": "Hi there!", "metadata": {...}}
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ import aiohttp
22
+
23
+ from switch_trust.eval.common.schema import Content, Message, Part, Role
24
+ from switch_trust.eval.core.models.model import (
25
+ Model,
26
+ ModelResponse,
27
+ ResponseStatus,
28
+ flatten_messages,
29
+ )
30
+ from switch_trust.eval.core.models.model_http import raise_for_status
31
+
32
+ if TYPE_CHECKING:
33
+ from pydantic import BaseModel
34
+
35
+
36
+ class LangServeModel(Model):
37
+ _url: str
38
+ _headers: dict[str, str]
39
+ # Optional aiohttp connector factory; the worker passes the SSRF-guarded one
40
+ # (platform/ssrf.py). None keeps the default connector.
41
+ _connector_factory: Callable[[], aiohttp.BaseConnector] | None
42
+
43
+ def __init__(
44
+ self,
45
+ base_url: str,
46
+ chain_path: str = "",
47
+ headers: dict[str, str] | None = None,
48
+ connector_factory: Callable[[], aiohttp.BaseConnector] | None = None,
49
+ ):
50
+ base = base_url.rstrip("/")
51
+ path = chain_path.strip("/")
52
+ if path:
53
+ self._url = f"{base}/{path}/invoke"
54
+ else:
55
+ self._url = f"{base}/invoke"
56
+ self._headers = headers or {}
57
+ self._connector_factory = connector_factory
58
+
59
+ async def _generate(
60
+ self,
61
+ messages: list[Message],
62
+ *,
63
+ output_schema: type[BaseModel] | None = None,
64
+ **kwargs: Any,
65
+ ) -> ModelResponse:
66
+ # A LangServe endpoint under test cannot enforce a JSON schema;
67
+ # output_schema is accepted for interface parity and ignored.
68
+ prompt_text = flatten_messages(messages)
69
+
70
+ connector = self._connector_factory() if self._connector_factory else None
71
+ async with aiohttp.ClientSession(connector=connector) as session:
72
+ async with session.post(
73
+ self._url,
74
+ json={"input": prompt_text},
75
+ headers=self._headers,
76
+ ) as resp:
77
+ await raise_for_status(resp)
78
+ data = await resp.json()
79
+
80
+ output = data.get("output")
81
+ if not output:
82
+ return ModelResponse(
83
+ message=None,
84
+ status=ResponseStatus.EMPTY_RESPONSE,
85
+ )
86
+
87
+ if isinstance(output, dict):
88
+ output = output.get("content", str(output))
89
+
90
+ message = Message(
91
+ content=Content(
92
+ role=Role.ASSISTANT,
93
+ parts=[Part.text_part(str(output))],
94
+ ),
95
+ )
96
+ return ModelResponse(message=message)
@@ -0,0 +1,52 @@
1
+ from typing import Any
2
+
3
+ import litellm
4
+ from pydantic import BaseModel
5
+
6
+ from switch_trust.eval.common import converter_openai
7
+ from switch_trust.eval.common.schema import Message
8
+ from switch_trust.eval.core.models.model import (
9
+ Model,
10
+ ModelResponse,
11
+ )
12
+
13
+
14
+ class LiteLLMModel(Model):
15
+ """Model adapter that delegates to LiteLLM.
16
+
17
+ LiteLLM uses the OpenAI message format for all providers, so this
18
+ adapter reuses ``converter_openai`` for serialisation.
19
+ """
20
+
21
+ _model: str
22
+ _temperature: float
23
+
24
+ def __init__(
25
+ self,
26
+ model: str,
27
+ temperature: float = 0.0,
28
+ ):
29
+ self._model = model
30
+ self._temperature = temperature
31
+
32
+ async def _generate(
33
+ self,
34
+ messages: list[Message],
35
+ *,
36
+ output_schema: type[BaseModel] | None = None,
37
+ **kwargs: Any,
38
+ ) -> ModelResponse:
39
+ openai_messages = [converter_openai.from_message(m) for m in messages]
40
+ if output_schema is not None and "response_format" not in kwargs:
41
+ # LiteLLM accepts a Pydantic class and translates it to each
42
+ # provider's structured-output mechanism.
43
+ kwargs["response_format"] = output_schema
44
+ response = await litellm.acompletion(
45
+ model=self._model,
46
+ messages=openai_messages,
47
+ temperature=kwargs.pop("temperature", self._temperature),
48
+ **kwargs,
49
+ )
50
+ choice = response.choices[0]
51
+ message = converter_openai.to_message(choice.message)
52
+ return ModelResponse(message)
@@ -0,0 +1,65 @@
1
+ from typing import Any
2
+
3
+ import requests
4
+ from openai import AsyncOpenAI
5
+ from pydantic import BaseModel
6
+
7
+ from switch_trust.eval.common import converter_openai
8
+ from switch_trust.eval.common.schema import Message
9
+ from switch_trust.eval.core.models.model import (
10
+ Model,
11
+ ModelResponse,
12
+ )
13
+ from switch_trust.eval.core.models.response_schema import to_json_schema
14
+
15
+
16
+ class OllamaModel(Model):
17
+ _client: AsyncOpenAI
18
+ _model: str
19
+ _temperature: float
20
+
21
+ def __init__(
22
+ self,
23
+ model: str,
24
+ host: str = "http://localhost:11434",
25
+ temperature: float = 0.0,
26
+ ):
27
+ self._client = AsyncOpenAI(
28
+ base_url=f"{host}/v1",
29
+ api_key="ollama",
30
+ )
31
+ self._model = model
32
+ self._temperature = temperature
33
+
34
+ async def _generate(
35
+ self,
36
+ messages: list[Message],
37
+ *,
38
+ output_schema: type[BaseModel] | None = None,
39
+ **kwargs: Any,
40
+ ) -> ModelResponse:
41
+ openai_messages = [converter_openai.from_message(m) for m in messages]
42
+ if output_schema is not None:
43
+ # Ollama constrains output via its native top-level ``format`` field
44
+ # (a JSON schema), passed through the OpenAI-compatible body.
45
+ extra_body = kwargs.setdefault("extra_body", {})
46
+ extra_body.setdefault("format", to_json_schema(output_schema))
47
+ response = await self._client.chat.completions.create(
48
+ model=self._model,
49
+ messages=openai_messages,
50
+ temperature=kwargs.pop("temperature", self._temperature),
51
+ **kwargs,
52
+ )
53
+ choice = response.choices[0]
54
+ message = converter_openai.to_message(choice.message)
55
+ return ModelResponse(message)
56
+
57
+
58
+ def discover_ollama_models(
59
+ host: str = "http://localhost:11434",
60
+ ) -> list[str]:
61
+ """Return a list of model names available on an Ollama instance."""
62
+ response = requests.get(f"{host}/api/tags")
63
+ response.raise_for_status()
64
+ data = response.json()
65
+ return [m["name"] for m in data.get("models", [])]
@@ -0,0 +1,71 @@
1
+ from typing import Any
2
+
3
+ from openai import APIError, AsyncOpenAI, BadRequestError
4
+ from pydantic import BaseModel
5
+
6
+ from switch_trust.eval.common import converter_openai
7
+ from switch_trust.eval.common.schema import Message
8
+ from switch_trust.eval.core.models.model import (
9
+ Model,
10
+ ModelResponse,
11
+ ResponseStatus,
12
+ )
13
+ from switch_trust.eval.core.models.response_schema import to_openai_response_format
14
+
15
+
16
+ class OpenAIModel(Model):
17
+ _client: AsyncOpenAI
18
+ _model: str
19
+ _temperature: float
20
+
21
+ def __init__(
22
+ self,
23
+ client: AsyncOpenAI,
24
+ model: str,
25
+ temperature: float = 0.0,
26
+ ):
27
+ self._client = client
28
+ self._model = model
29
+ self._temperature = temperature
30
+
31
+ async def _generate(
32
+ self,
33
+ messages: list[Message],
34
+ *,
35
+ output_schema: type[BaseModel] | None = None,
36
+ **kwargs: Any,
37
+ ) -> ModelResponse:
38
+ openai_messages = [converter_openai.from_message(m) for m in messages]
39
+ if output_schema is not None and "response_format" not in kwargs:
40
+ kwargs["response_format"] = to_openai_response_format(output_schema)
41
+ try:
42
+ response = await self._client.chat.completions.create(
43
+ model=self._model,
44
+ messages=openai_messages,
45
+ temperature=kwargs.pop("temperature", self._temperature),
46
+ **kwargs,
47
+ )
48
+ except APIError as e:
49
+ return ModelResponse(
50
+ None,
51
+ _classify_block_reason(e),
52
+ )
53
+ choice = response.choices[0]
54
+ message = converter_openai.to_message(choice.message)
55
+ return ModelResponse(message)
56
+
57
+
58
+ def _classify_block_reason(api_error: APIError) -> ResponseStatus:
59
+ """Determine why a OpenAI response was blocked."""
60
+
61
+ # Check prompt-level blocking
62
+ if isinstance(api_error, BadRequestError):
63
+ code = (
64
+ api_error.body.get("code", "") if isinstance(api_error.body, dict) else ""
65
+ )
66
+ if code == "cyber_policy":
67
+ return ResponseStatus.BLOCKED_PROHIBITED
68
+ if code in ["invalid_prompt", "bio_policy"]:
69
+ return ResponseStatus.BLOCKED_SAFETY
70
+
71
+ return ResponseStatus.ERROR
@@ -0,0 +1,92 @@
1
+ """Model implementation for OpenAI Agents SDK agents served over HTTP.
2
+
3
+ OpenAI Agents SDK agents don't have a built-in HTTP server.
4
+ The expected pattern is a FastAPI wrapper with a POST endpoint
5
+ that accepts ``{"input": "<prompt>"}`` and returns
6
+ ``{"output": "<response>"}``.
7
+
8
+ Example server::
9
+
10
+ from fastapi import FastAPI
11
+ from pydantic import BaseModel
12
+ from agents import Agent, Runner
13
+
14
+ app = FastAPI()
15
+ agent = Agent(name="my_agent", instructions="...")
16
+
17
+ class Request(BaseModel):
18
+ input: str
19
+
20
+ @app.post("/run")
21
+ async def run(req: Request):
22
+ result = await Runner.run(agent, req.input)
23
+ return {"output": result.final_output}
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Callable
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ import aiohttp
32
+
33
+ from switch_trust.eval.common.schema import Content, Message, Part, Role
34
+ from switch_trust.eval.core.models.model import Model, ModelResponse, ResponseStatus
35
+ from switch_trust.eval.core.models.model_http import raise_for_status
36
+
37
+ if TYPE_CHECKING:
38
+ from pydantic import BaseModel
39
+
40
+
41
+ class OpenAIAgentModel(Model):
42
+ """Sends prompts to an OpenAI Agents SDK agent over HTTP."""
43
+
44
+ def __init__(
45
+ self,
46
+ host: str = "http://localhost:8000",
47
+ endpoint: str = "/run",
48
+ connector_factory: Callable[[], aiohttp.BaseConnector] | None = None,
49
+ ):
50
+ self._host = host.rstrip("/")
51
+ self._endpoint = endpoint
52
+ # Optional aiohttp connector factory; the worker passes the SSRF-guarded
53
+ # one (platform/ssrf.py). None keeps the default connector.
54
+ self._connector_factory = connector_factory
55
+
56
+ async def _generate(
57
+ self,
58
+ messages: list[Message],
59
+ *,
60
+ output_schema: type[BaseModel] | None = None,
61
+ **kwargs: Any,
62
+ ) -> ModelResponse:
63
+ # An agent under test cannot enforce a JSON schema; output_schema is
64
+ # accepted for interface parity and ignored.
65
+ if len(messages) > 1:
66
+ raise ValueError("OpenAIAgentModel does not support multiple messages")
67
+ text_parts = [p.text for p in messages[0].content.parts if p.text]
68
+ prompt_text = " ".join(text_parts)
69
+
70
+ connector = self._connector_factory() if self._connector_factory else None
71
+ async with aiohttp.ClientSession(connector=connector) as session:
72
+ async with session.post(
73
+ f"{self._host}{self._endpoint}",
74
+ json={"input": prompt_text},
75
+ ) as resp:
76
+ await raise_for_status(resp)
77
+ data = await resp.json()
78
+
79
+ output = data.get("output")
80
+ if not output:
81
+ return ModelResponse(
82
+ message=None,
83
+ status=ResponseStatus.EMPTY_RESPONSE,
84
+ )
85
+
86
+ message = Message(
87
+ content=Content(
88
+ role=Role.ASSISTANT,
89
+ parts=[Part.text_part(output)],
90
+ ),
91
+ )
92
+ return ModelResponse(message=message)
@@ -0,0 +1,66 @@
1
+ """Model for any OpenAI-compatible chat completions endpoint.
2
+
3
+ Works with vLLM, SGLang, Ollama, LiteLLM proxy, Azure
4
+ OpenAI, Amazon Bedrock, TGI, and any other server that
5
+ implements the ``/v1/chat/completions`` API.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from openai import AsyncOpenAI
11
+ from pydantic import BaseModel
12
+
13
+ from switch_trust.eval.common import converter_openai
14
+ from switch_trust.eval.common.schema import Message
15
+ from switch_trust.eval.core.models.model import (
16
+ Model,
17
+ ModelResponse,
18
+ )
19
+ from switch_trust.eval.core.models.response_schema import to_openai_response_format
20
+
21
+
22
+ class OpenAICompatibleModel(Model):
23
+ _client: AsyncOpenAI
24
+ _model: str
25
+ _temperature: float
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ model: str,
31
+ api_key: str = "EMPTY",
32
+ temperature: float = 0.0,
33
+ headers: dict[str, str] | None = None,
34
+ ):
35
+ self._client = AsyncOpenAI(
36
+ base_url=base_url.rstrip("/"),
37
+ api_key=api_key,
38
+ default_headers=headers or {},
39
+ )
40
+ self._model = model
41
+ self._temperature = temperature
42
+
43
+ async def _generate(
44
+ self,
45
+ messages: list[Message],
46
+ *,
47
+ output_schema: type[BaseModel] | None = None,
48
+ **kwargs: Any,
49
+ ) -> ModelResponse:
50
+ openai_messages = [converter_openai.from_message(m) for m in messages]
51
+ if output_schema is not None and "response_format" not in kwargs:
52
+ kwargs["response_format"] = to_openai_response_format(output_schema)
53
+ response = await self._client.chat.completions.create(
54
+ model=self._model,
55
+ messages=openai_messages,
56
+ temperature=kwargs.pop(
57
+ "temperature",
58
+ self._temperature,
59
+ ),
60
+ **kwargs,
61
+ )
62
+ choice = response.choices[0]
63
+ message = converter_openai.to_message(
64
+ choice.message,
65
+ )
66
+ return ModelResponse(message)