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,179 @@
|
|
|
1
|
+
"""Maps exceptions to the party responsible for them (``FailureDomain``).
|
|
2
|
+
|
|
3
|
+
Lives in ``common/`` rather than beside the OTEL/httpx-heavy metric and span
|
|
4
|
+
instrumentation so it can be reused without pulling that machinery in: the OSS
|
|
5
|
+
CLI export (``cli_exporter/export.py``) copies this module alongside the agent
|
|
6
|
+
scanner, and ``agent_scanner/reasoner.py`` needs `classify_exception` at the
|
|
7
|
+
point an LLM call fails but has no use for metrics or spans. The metrics
|
|
8
|
+
module imports from here, never the reverse — same direction as
|
|
9
|
+
``llm_usage.py``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FailureDomain(StrEnum):
|
|
18
|
+
"""Who is at fault. Only `internal` and `provider` should ever page.
|
|
19
|
+
|
|
20
|
+
`provider` is a third party across a network boundary — the LLM API, or the
|
|
21
|
+
flint backend we upload to. `user` is the customer's repo or workflow
|
|
22
|
+
config. `internal` is our own code, including budgets we chose ourselves.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
NONE = "none"
|
|
26
|
+
INTERNAL = "internal"
|
|
27
|
+
PROVIDER = "provider"
|
|
28
|
+
USER = "user"
|
|
29
|
+
UNKNOWN = "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ScannerTimeout(Exception):
|
|
33
|
+
"""A stage stopped because it hit one of *our* budgets.
|
|
34
|
+
|
|
35
|
+
Distinct from a provider-side timeout: exceeding a wall-clock, iteration or
|
|
36
|
+
token budget we chose ourselves is an `internal` fault, not a `provider`
|
|
37
|
+
one, even though both surface as `outcome=timeout`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PreconditionNotMet(Exception):
|
|
42
|
+
"""The run cannot start (no token, no workspace, invalid instance)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Provider SDKs are matched by module prefix rather than imported: litellm is
|
|
46
|
+
# expensive to import and the google-genai error module is not a declared dep
|
|
47
|
+
# of every target that needs to classify an exception.
|
|
48
|
+
_PROVIDER_MODULE_PREFIXES = (
|
|
49
|
+
"google.api_core",
|
|
50
|
+
"google.auth",
|
|
51
|
+
"google.genai",
|
|
52
|
+
"litellm",
|
|
53
|
+
"openai",
|
|
54
|
+
"anthropic",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_USER_EXCEPTIONS = (
|
|
58
|
+
FileNotFoundError,
|
|
59
|
+
NotADirectoryError,
|
|
60
|
+
PermissionError,
|
|
61
|
+
PreconditionNotMet,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_PROVIDER_EXCEPTIONS = (
|
|
65
|
+
ConnectionError,
|
|
66
|
+
TimeoutError,
|
|
67
|
+
# httpx.TransportError covers connect/read/write/pool failures and does NOT
|
|
68
|
+
# derive from the builtin ConnectionError, so it needs listing explicitly —
|
|
69
|
+
# otherwise every unreachable-backend run is misattributed to `internal`.
|
|
70
|
+
httpx.TransportError,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# A missing or rejected LLM credential surfaces in many shapes: google-genai
|
|
74
|
+
# raises a bare `builtins.ValueError` with a message naming the missing
|
|
75
|
+
# credential, openai a keyless `OpenAIError`, others a 401. The 401 variant is
|
|
76
|
+
# already `user` (via `_http_status_of`), but the keyless ones carry no status
|
|
77
|
+
# and no provider module we match, so they fell through to `internal` — which
|
|
78
|
+
# pages. A missing/rejected credential is the customer's CI config, so it is
|
|
79
|
+
# `user`. Matched on the message (never exported — same as ScanSummary's
|
|
80
|
+
# timeout sniff), lower-cased.
|
|
81
|
+
_AUTH_ERROR_MARKERS = (
|
|
82
|
+
"api key",
|
|
83
|
+
"api_key",
|
|
84
|
+
"apikey",
|
|
85
|
+
"credential",
|
|
86
|
+
"authentication",
|
|
87
|
+
"unauthorized",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _looks_like_auth_error(exc: BaseException) -> bool:
|
|
92
|
+
text = str(exc).lower()
|
|
93
|
+
return any(marker in text for marker in _AUTH_ERROR_MARKERS)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_auth_error(exc: BaseException) -> bool:
|
|
97
|
+
"""Whether ``exc`` is a rejected/missing LLM credential specifically.
|
|
98
|
+
|
|
99
|
+
Narrower than ``classify_exception(...) == USER``: a 400/404 (bad request,
|
|
100
|
+
unknown model) is also `user` but is *not* an auth failure. Used to tell the
|
|
101
|
+
customer their **credential** is invalid, so it must not fire on other
|
|
102
|
+
config mistakes. A 401/403 or an auth-shaped message (keyless SDK /
|
|
103
|
+
google-genai's statusless ValueError) counts; everything else does not.
|
|
104
|
+
"""
|
|
105
|
+
return _http_status_of(exc) in (401, 403) or _looks_like_auth_error(exc)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# An unknown/unsupported model surfaces as a 404 or one of these message shapes.
|
|
109
|
+
# Matched lower-cased, like the auth markers, and never exported.
|
|
110
|
+
_MODEL_ERROR_MARKERS = (
|
|
111
|
+
"model not found",
|
|
112
|
+
"model_not_found",
|
|
113
|
+
"does not exist",
|
|
114
|
+
"no such model",
|
|
115
|
+
"unknown model",
|
|
116
|
+
"invalid model",
|
|
117
|
+
"unsupported model",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _looks_like_model_error(exc: BaseException) -> bool:
|
|
122
|
+
text = str(exc).lower()
|
|
123
|
+
return any(marker in text for marker in _MODEL_ERROR_MARKERS)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_model_error(exc: BaseException) -> bool:
|
|
127
|
+
"""Whether ``exc`` is an unknown/unsupported *model*, not a bad credential.
|
|
128
|
+
|
|
129
|
+
Also `user`, and also narrower than the domain: it lets the customer be told
|
|
130
|
+
"the configured model is invalid" specifically. Auth wins — a 401 is a key
|
|
131
|
+
problem, not a model one — so this returns False for anything ``is_auth_error``
|
|
132
|
+
already claims. A 404 (the provider rejecting the model id) or a
|
|
133
|
+
model-not-found message counts.
|
|
134
|
+
"""
|
|
135
|
+
if is_auth_error(exc):
|
|
136
|
+
return False
|
|
137
|
+
return _http_status_of(exc) == 404 or _looks_like_model_error(exc)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def classify_exception(exc: BaseException) -> FailureDomain:
|
|
141
|
+
"""Map an exception to the party responsible for it."""
|
|
142
|
+
if isinstance(exc, ScannerTimeout):
|
|
143
|
+
return FailureDomain.INTERNAL
|
|
144
|
+
if isinstance(exc, _USER_EXCEPTIONS):
|
|
145
|
+
return FailureDomain.USER
|
|
146
|
+
if isinstance(exc, _PROVIDER_EXCEPTIONS):
|
|
147
|
+
return FailureDomain.PROVIDER
|
|
148
|
+
|
|
149
|
+
status = _http_status_of(exc)
|
|
150
|
+
if status is not None:
|
|
151
|
+
# 429 and 5xx are the backend having a bad day; other 4xx means the
|
|
152
|
+
# caller sent something wrong (expired token, bad instance).
|
|
153
|
+
if status == 429 or status >= 500:
|
|
154
|
+
return FailureDomain.PROVIDER
|
|
155
|
+
if status >= 400:
|
|
156
|
+
return FailureDomain.USER
|
|
157
|
+
|
|
158
|
+
# Before the provider-module check: a keyless openai error lives in the
|
|
159
|
+
# `openai` module but is the customer's missing key, not a provider outage.
|
|
160
|
+
# A real 401/403 already returned `user` above, so this only catches the
|
|
161
|
+
# statusless credential shapes (google-genai's ValueError, keyless SDKs).
|
|
162
|
+
if _looks_like_auth_error(exc):
|
|
163
|
+
return FailureDomain.USER
|
|
164
|
+
|
|
165
|
+
module = type(exc).__module__
|
|
166
|
+
if any(module.startswith(prefix) for prefix in _PROVIDER_MODULE_PREFIXES):
|
|
167
|
+
return FailureDomain.PROVIDER
|
|
168
|
+
|
|
169
|
+
if type(exc) is Exception:
|
|
170
|
+
# A bare `raise Exception(...)` carries no signal either way.
|
|
171
|
+
return FailureDomain.UNKNOWN
|
|
172
|
+
return FailureDomain.INTERNAL
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _http_status_of(exc: BaseException) -> int | None:
|
|
176
|
+
"""Best-effort HTTP status from an httpx/requests-style exception."""
|
|
177
|
+
response = getattr(exc, "response", None)
|
|
178
|
+
status = getattr(response, "status_code", None)
|
|
179
|
+
return status if isinstance(status, int) else None
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
llm_provider.py — LLM model factory shared by agent and MCP scanners.
|
|
3
|
+
|
|
4
|
+
Single entry point:
|
|
5
|
+
|
|
6
|
+
make_model(model_string)
|
|
7
|
+
Returns an ADK-compatible LiteLlm model object for all providers.
|
|
8
|
+
Used by both the agentic reasoner (ADK Runner) and single-pass
|
|
9
|
+
completions (triage) via complete_text().
|
|
10
|
+
|
|
11
|
+
complete_text(model, system_prompt, user_message, ...)
|
|
12
|
+
Runs a single-pass LLM completion using generate_content_async
|
|
13
|
+
on the model returned by make_model().
|
|
14
|
+
|
|
15
|
+
Configuration via SCANNER_MODEL env var in 'provider:model' format
|
|
16
|
+
(e.g., 'google:gemini-3.5-flash', 'openai:gpt-5.4', 'anthropic:claude-sonnet-4-6').
|
|
17
|
+
|
|
18
|
+
API keys are read from standard env vars by the underlying frameworks:
|
|
19
|
+
GOOGLE_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import concurrent.futures
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from google.adk.models.google_llm import Gemini
|
|
32
|
+
from google.adk.models.llm_request import LlmRequest
|
|
33
|
+
from google.genai import types as genai_types
|
|
34
|
+
|
|
35
|
+
from . import ADKModel
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
_REDACT_PATTERNS = re.compile(
|
|
40
|
+
r"("
|
|
41
|
+
r"Bearer\s+[A-Za-z0-9\-_.~+/]+=*"
|
|
42
|
+
r"|sk-[A-Za-z0-9]{10,}"
|
|
43
|
+
r"|key-[A-Za-z0-9]{10,}"
|
|
44
|
+
r"|pat-[A-Za-z0-9]{10,}"
|
|
45
|
+
r"|AIza[A-Za-z0-9_\-]{35,}"
|
|
46
|
+
r"|[A-Za-z0-9]{40,}"
|
|
47
|
+
r")",
|
|
48
|
+
re.IGNORECASE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _safe_error(exc: Exception) -> str:
|
|
53
|
+
"""Return a log-safe string with token-like patterns redacted."""
|
|
54
|
+
return _REDACT_PATTERNS.sub("[REDACTED]", str(exc))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
PROVIDER_GOOGLE = "google"
|
|
58
|
+
PROVIDER_LITELLM = "litellm"
|
|
59
|
+
|
|
60
|
+
DEFAULT_MODEL = "gemini-3.6-flash"
|
|
61
|
+
|
|
62
|
+
_PROVIDER_ALIASES = {"gemini": "google"}
|
|
63
|
+
|
|
64
|
+
# The env var(s) each provider's SDK reads its API key from. Used to tell a
|
|
65
|
+
# *missing* key (skip the LLM stage with a warning — the customer never
|
|
66
|
+
# configured one) from an *invalid* one (the provider returns 401, a real
|
|
67
|
+
# user-domain failure). Only providers we can check for are listed; anything
|
|
68
|
+
# absent is assumed configured so an exotic provider is never skipped on a false
|
|
69
|
+
# "missing key" — it runs, and if the key really is absent, fails as before.
|
|
70
|
+
_PROVIDER_API_KEY_ENV_VARS = {
|
|
71
|
+
"google": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
|
72
|
+
"openai": ("OPENAI_API_KEY",),
|
|
73
|
+
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _google_adc_present() -> bool:
|
|
78
|
+
"""Whether Google credentials exist via the Vertex/ADC path (no API key)."""
|
|
79
|
+
if os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("1", "true"):
|
|
80
|
+
return True
|
|
81
|
+
return bool(os.getenv("GOOGLE_APPLICATION_CREDENTIALS"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def api_key_present(model_string: str | None = None) -> bool:
|
|
85
|
+
"""Whether an API key (or equivalent credential) is configured for the model.
|
|
86
|
+
|
|
87
|
+
Resolves the provider the same way `make_model` does, then checks its known
|
|
88
|
+
key env var(s). Returns True for providers we have no mapping for, so a
|
|
89
|
+
provider we can't preflight is left to run rather than being skipped on a
|
|
90
|
+
guess.
|
|
91
|
+
"""
|
|
92
|
+
provider, _ = _resolve_model_string(model_string)
|
|
93
|
+
if provider == PROVIDER_GOOGLE and _google_adc_present():
|
|
94
|
+
return True
|
|
95
|
+
env_vars = _PROVIDER_API_KEY_ENV_VARS.get(provider)
|
|
96
|
+
if env_vars is None:
|
|
97
|
+
return True
|
|
98
|
+
return any(os.getenv(var) for var in env_vars)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Reasoning effort for OpenAI gpt-5 reasoning models. See _openai_reasoning_effort.
|
|
102
|
+
DEFAULT_REASONING_EFFORT = "medium"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _openai_reasoning_effort(name: str) -> str | None:
|
|
106
|
+
"""Return the ``reasoning_effort`` to set for an OpenAI gpt-5 reasoning model.
|
|
107
|
+
|
|
108
|
+
The gpt-5 reasoning-model endpoints (e.g. the ``gpt-5.6-*`` proxy) default to
|
|
109
|
+
reasoning **on** server-side and then reject function tools on
|
|
110
|
+
``/v1/chat/completions`` ("... use /v1/responses or set reasoning_effort to
|
|
111
|
+
'none'"). litellm bridges gpt-5.4+ tool calls to the Responses API — where
|
|
112
|
+
tools and reasoning coexist — **only when ``reasoning_effort`` is set
|
|
113
|
+
explicitly** (any value, including ``"none"``); left unset the call stays on
|
|
114
|
+
chat/completions and fails. So set it here for the gpt-5 reasoning family.
|
|
115
|
+
|
|
116
|
+
Returns ``None`` (leave unset) for everything else — non-gpt-5 models, and
|
|
117
|
+
the ``gpt-5-chat*`` variants which are plain chat models that reject
|
|
118
|
+
``reasoning_effort`` outright. ``SCANNER_REASONING_EFFORT`` overrides the
|
|
119
|
+
level; ``drop_params=True`` drops it for any model that still rejects it.
|
|
120
|
+
"""
|
|
121
|
+
base = name.split("/")[-1]
|
|
122
|
+
if "gpt-5" not in name or base.startswith("gpt-5-chat"):
|
|
123
|
+
return None
|
|
124
|
+
return os.getenv("SCANNER_REASONING_EFFORT", DEFAULT_REASONING_EFFORT)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_model_string(model_string: str) -> tuple[str, str | None]:
|
|
128
|
+
"""Parse 'provider:model' -> (provider, model)."""
|
|
129
|
+
if ":" in model_string:
|
|
130
|
+
provider, model = model_string.split(":", 1)
|
|
131
|
+
else:
|
|
132
|
+
provider, model = model_string, None
|
|
133
|
+
provider = provider.strip().lower()
|
|
134
|
+
provider = _PROVIDER_ALIASES.get(provider, provider)
|
|
135
|
+
return provider, model.strip() if model else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_model_string(model_string: str | None = None) -> tuple[str, str]:
|
|
139
|
+
"""Resolve provider and model from argument or SCANNER_MODEL env var."""
|
|
140
|
+
ms = (model_string or os.getenv("SCANNER_MODEL", "")).strip()
|
|
141
|
+
if not ms:
|
|
142
|
+
return PROVIDER_GOOGLE, DEFAULT_MODEL
|
|
143
|
+
provider, model = parse_model_string(ms)
|
|
144
|
+
return provider, model or DEFAULT_MODEL
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def make_model(
|
|
148
|
+
model_string: str | None = None,
|
|
149
|
+
temperature: float = 0.0,
|
|
150
|
+
*,
|
|
151
|
+
scanner: str = "unknown",
|
|
152
|
+
phase: str = "unknown",
|
|
153
|
+
) -> ADKModel:
|
|
154
|
+
"""Return an ADK-compatible model for any provider.
|
|
155
|
+
|
|
156
|
+
For Google: returns a bare model string (e.g. "gemini-3.6-flash"); ADK
|
|
157
|
+
uses its native Google AI client with GOOGLE_API_KEY. For other
|
|
158
|
+
providers: returns a LiteLlm wrapper. ``scanner`` and ``phase`` are
|
|
159
|
+
accepted for API compatibility but ignored.
|
|
160
|
+
"""
|
|
161
|
+
provider, model = _resolve_model_string(model_string)
|
|
162
|
+
|
|
163
|
+
if provider == PROVIDER_GOOGLE:
|
|
164
|
+
return model or DEFAULT_MODEL
|
|
165
|
+
|
|
166
|
+
# ``drop_params=True`` tells litellm to silently drop params a given model
|
|
167
|
+
# rejects instead of raising, so a single provider quirk doesn't fail the
|
|
168
|
+
# scan — e.g. GPT-5 reasoning models only accept ``temperature=1`` and error
|
|
169
|
+
# on our ``temperature=0.0``. litellm consumes ``drop_params`` itself (it
|
|
170
|
+
# never reaches the provider request body).
|
|
171
|
+
#
|
|
172
|
+
# ``reasoning_effort`` is set (only) for OpenAI gpt-5 reasoning models to
|
|
173
|
+
# route their tool calls through the Responses API — see
|
|
174
|
+
# ``_openai_reasoning_effort``. It rides along as an ``completion`` kwarg
|
|
175
|
+
# (via ADK's ``_additional_args``) so litellm's Responses-API bridge sees it.
|
|
176
|
+
name = model if provider == PROVIDER_LITELLM else f"{provider}/{model}"
|
|
177
|
+
extra_args: dict[str, Any] = {}
|
|
178
|
+
reasoning_effort = _openai_reasoning_effort(name)
|
|
179
|
+
if reasoning_effort is not None:
|
|
180
|
+
extra_args["reasoning_effort"] = reasoning_effort
|
|
181
|
+
|
|
182
|
+
LiteLlm = _import_litellm()
|
|
183
|
+
return LiteLlm(
|
|
184
|
+
model=name,
|
|
185
|
+
temperature=temperature,
|
|
186
|
+
drop_params=True,
|
|
187
|
+
**extra_args,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def is_anthropic_model(model: ADKModel) -> bool:
|
|
192
|
+
"""Return True if *model* looks like an Anthropic / Claude model."""
|
|
193
|
+
name = getattr(model, "model", str(model))
|
|
194
|
+
return "anthropic" in name or "claude" in name
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_openai_model(model: ADKModel) -> bool:
|
|
198
|
+
"""Return True if *model* looks like an OpenAI model."""
|
|
199
|
+
name = getattr(model, "model", str(model))
|
|
200
|
+
return "openai" in name or "gpt" in name
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def apply_provider_limits(
|
|
204
|
+
config: genai_types.GenerateContentConfig,
|
|
205
|
+
model: ADKModel,
|
|
206
|
+
top_p: float | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Adjust *config* in-place for provider-specific constraints.
|
|
209
|
+
|
|
210
|
+
Anthropic models reject requests that set both ``temperature`` and
|
|
211
|
+
``top_p``. This helper centralises that check so every scanner
|
|
212
|
+
doesn't re-implement it.
|
|
213
|
+
|
|
214
|
+
* If the model is **not** Anthropic and *top_p* is given, sets
|
|
215
|
+
``config.top_p``.
|
|
216
|
+
* If the model **is** Anthropic, leaves ``top_p`` unset.
|
|
217
|
+
"""
|
|
218
|
+
if not is_anthropic_model(model) and top_p is not None:
|
|
219
|
+
config.top_p = top_p
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_model_name(model_string: str | None = None) -> str:
|
|
223
|
+
"""Return the resolved model name for logging/metadata."""
|
|
224
|
+
_, model = _resolve_model_string(model_string)
|
|
225
|
+
return model
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _import_litellm() -> type[Any]:
|
|
229
|
+
"""Import and return the LiteLlm class from google-adk or a minimal shim."""
|
|
230
|
+
try:
|
|
231
|
+
from google.adk.models.lite_llm import ( # noqa: PLC0415 - deferred import cost
|
|
232
|
+
LiteLlm,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return LiteLlm
|
|
236
|
+
except ImportError:
|
|
237
|
+
pass
|
|
238
|
+
try:
|
|
239
|
+
from google.adk.models import ( # noqa: PLC0415 - import fallback ladder
|
|
240
|
+
LiteLlm,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return LiteLlm
|
|
244
|
+
except ImportError:
|
|
245
|
+
pass
|
|
246
|
+
try:
|
|
247
|
+
import litellm as _litellm # noqa: F401, PLC0415
|
|
248
|
+
|
|
249
|
+
class _LiteLlmShim:
|
|
250
|
+
def __init__(self, model: str, **kwargs):
|
|
251
|
+
self.model = model
|
|
252
|
+
self._kwargs = kwargs
|
|
253
|
+
|
|
254
|
+
def __repr__(self) -> str:
|
|
255
|
+
return f"LiteLlm(model={self.model!r})"
|
|
256
|
+
|
|
257
|
+
return _LiteLlmShim
|
|
258
|
+
except ImportError as e:
|
|
259
|
+
raise ImportError(
|
|
260
|
+
"google-adk or litellm is required for non-Google providers. "
|
|
261
|
+
"Run: pip install google-adk litellm"
|
|
262
|
+
) from e
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── Single-pass completion ───────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def complete_text(
|
|
269
|
+
model: ADKModel,
|
|
270
|
+
system_prompt: str,
|
|
271
|
+
user_message: str,
|
|
272
|
+
max_tokens: int = 8000,
|
|
273
|
+
temperature: float = 0.0,
|
|
274
|
+
top_p: float = 1.0,
|
|
275
|
+
) -> str | None:
|
|
276
|
+
"""Run a single-pass LLM completion via ``model.generate_content_async``.
|
|
277
|
+
|
|
278
|
+
Bare model strings are still accepted (ADK allows them and callers outside
|
|
279
|
+
``make_model`` may pass one), but they bypass usage instrumentation, so
|
|
280
|
+
``make_model`` never produces one.
|
|
281
|
+
"""
|
|
282
|
+
config = genai_types.GenerateContentConfig(
|
|
283
|
+
system_instruction=system_prompt,
|
|
284
|
+
temperature=temperature,
|
|
285
|
+
max_output_tokens=max_tokens,
|
|
286
|
+
)
|
|
287
|
+
apply_provider_limits(config, model, top_p=top_p)
|
|
288
|
+
contents = [
|
|
289
|
+
genai_types.Content(
|
|
290
|
+
role="user",
|
|
291
|
+
parts=[genai_types.Part.from_text(text=user_message)],
|
|
292
|
+
)
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
if isinstance(model, str):
|
|
296
|
+
model = Gemini(model=model)
|
|
297
|
+
|
|
298
|
+
model_name = getattr(model, "model", str(model))
|
|
299
|
+
|
|
300
|
+
async def _run() -> str | None:
|
|
301
|
+
request = LlmRequest(
|
|
302
|
+
model=model_name,
|
|
303
|
+
contents=contents,
|
|
304
|
+
config=config,
|
|
305
|
+
)
|
|
306
|
+
text = ""
|
|
307
|
+
async for resp in model.generate_content_async(request):
|
|
308
|
+
if resp.content and resp.content.parts:
|
|
309
|
+
for part in resp.content.parts:
|
|
310
|
+
if hasattr(part, "text") and part.text:
|
|
311
|
+
text += part.text
|
|
312
|
+
return text.strip() or None
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
asyncio.get_running_loop()
|
|
316
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
317
|
+
return pool.submit(asyncio.run, _run()).result(timeout=120)
|
|
318
|
+
except RuntimeError:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
return asyncio.run(_run())
|
|
323
|
+
except Exception as e:
|
|
324
|
+
err_type = type(e).__name__
|
|
325
|
+
safe_msg = _safe_error(e)
|
|
326
|
+
# Show a concise one-liner; full details at DEBUG level.
|
|
327
|
+
first_line = safe_msg.split("\n", 1)[0]
|
|
328
|
+
logger.error("LLM call failed (%s): %s", err_type, first_line)
|
|
329
|
+
logger.debug("LLM call failed (full): %s", safe_msg)
|
|
330
|
+
return None
|