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,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
@@ -0,0 +1,12 @@
1
+ """
2
+ opengrep_resolver.py — Locate the OpenGrep binary.
3
+ """
4
+
5
+ import shutil
6
+
7
+
8
+ def find_opengrep_binary() -> str | None:
9
+ """
10
+ Returns the path to the binary, or None if not found.
11
+ """
12
+ return shutil.which("opengrep")