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