|
25 | 25 | import asyncio |
26 | 26 | import json |
27 | 27 | import logging |
| 28 | +import os |
28 | 29 | import warnings |
29 | 30 | from pathlib import Path |
30 | 31 | from typing import Any |
@@ -95,6 +96,12 @@ def __init__( |
95 | 96 |
|
96 | 97 | # Load JSON schema for structured outputs |
97 | 98 | self.response_schema = self._load_response_schema() |
| 99 | + self._use_plain_json_output = self._env_flag_enabled("SKILL_SCANNER_LLM_FORCE_JSON_OBJECT") |
| 100 | + |
| 101 | + def _env_flag_enabled(self, env_name: str) -> bool: |
| 102 | + """Treat common truthy env values as enabled.""" |
| 103 | + raw_value = os.getenv(env_name, "") |
| 104 | + return raw_value.strip().lower() in {"1", "true", "yes", "on"} |
98 | 105 |
|
99 | 106 | def _load_response_schema(self) -> dict[str, Any] | None: |
100 | 107 | """Load JSON schema for structured outputs.""" |
@@ -160,6 +167,55 @@ def _sanitize_schema_for_google(self, schema: dict[str, Any]) -> dict[str, Any]: |
160 | 167 |
|
161 | 168 | return sanitized |
162 | 169 |
|
| 170 | + def _should_use_json_object(self) -> bool: |
| 171 | + """Pick the safest response format for the current backend.""" |
| 172 | + if self._use_plain_json_output: |
| 173 | + return True |
| 174 | + |
| 175 | + model_lower = self.provider_config.model.lower() |
| 176 | + unsupported_json_schema_providers = ["deepseek"] |
| 177 | + return any(name in model_lower for name in unsupported_json_schema_providers) |
| 178 | + |
| 179 | + def _build_response_format(self) -> dict[str, Any] | None: |
| 180 | + """Build the response format for LiteLLM requests.""" |
| 181 | + if not self.response_schema: |
| 182 | + return None |
| 183 | + |
| 184 | + if self._should_use_json_object(): |
| 185 | + return {"type": "json_object"} |
| 186 | + |
| 187 | + return { |
| 188 | + "type": "json_schema", |
| 189 | + "json_schema": { |
| 190 | + "name": "security_analysis_response", |
| 191 | + "schema": self.response_schema, |
| 192 | + "strict": True, |
| 193 | + }, |
| 194 | + } |
| 195 | + |
| 196 | + def _should_fallback_to_json_object(self, error: Exception, response_format: dict[str, Any] | None) -> bool: |
| 197 | + """Detect backends that reject structured output and need plain JSON mode.""" |
| 198 | + if not response_format or response_format.get("type") != "json_schema": |
| 199 | + return False |
| 200 | + |
| 201 | + error_msg = str(error).lower() |
| 202 | + if "response_format.json_schema" in error_msg: |
| 203 | + return True |
| 204 | + |
| 205 | + if "json_schema" in error_msg and any( |
| 206 | + phrase in error_msg |
| 207 | + for phrase in [ |
| 208 | + "missing required parameter", |
| 209 | + "unsupported", |
| 210 | + "not supported", |
| 211 | + "invalid", |
| 212 | + "unknown parameter", |
| 213 | + ] |
| 214 | + ): |
| 215 | + return True |
| 216 | + |
| 217 | + return False |
| 218 | + |
163 | 219 | async def make_request(self, messages: list[dict[str, str]], context: str = "") -> str: |
164 | 220 | """ |
165 | 221 | Make LLM request with retry logic and exponential backoff. |
@@ -206,30 +262,28 @@ async def _make_litellm_request(self, messages: list[dict[str, str]], context: s |
206 | 262 | **self.provider_config.get_request_params(), |
207 | 263 | } |
208 | 264 |
|
209 | | - # Add structured output support using LiteLLM's unified format |
210 | | - # According to LiteLLM docs: https://docs.litellm.ai/docs/completion/json_mode |
211 | | - # Format: response_format={ "type": "json_schema", "json_schema": { "name": "...", "schema": {...}, "strict": true } } |
212 | | - # Works for: OpenAI, Anthropic Claude, Gemini (via LiteLLM), Bedrock, Vertex AI, Groq, Ollama, Databricks |
213 | | - if self.response_schema: |
214 | | - model_lower = self.provider_config.model.lower() |
215 | | - unsupported_json_schema_providers = ["deepseek"] |
216 | | - if any(p in model_lower for p in unsupported_json_schema_providers): |
217 | | - request_params["response_format"] = {"type": "json_object"} |
218 | | - else: |
219 | | - request_params["response_format"] = { |
220 | | - "type": "json_schema", |
221 | | - "json_schema": { |
222 | | - "name": "security_analysis_response", |
223 | | - "schema": self.response_schema, |
224 | | - "strict": True, |
225 | | - }, |
226 | | - } |
| 265 | + response_format = self._build_response_format() |
| 266 | + if response_format: |
| 267 | + request_params["response_format"] = response_format |
227 | 268 |
|
228 | 269 | response = await acompletion(**request_params, drop_params=True) |
229 | 270 | content: str = response.choices[0].message.content or "" |
230 | 271 | return content |
231 | 272 |
|
232 | 273 | except Exception as e: |
| 274 | + response_format = request_params.get("response_format") |
| 275 | + if self._should_fallback_to_json_object(e, response_format): |
| 276 | + logger.warning( |
| 277 | + "Structured output rejected for %s, retrying with plain JSON output", |
| 278 | + context, |
| 279 | + ) |
| 280 | + self._use_plain_json_output = True |
| 281 | + retry_params = dict(request_params) |
| 282 | + retry_params["response_format"] = {"type": "json_object"} |
| 283 | + response = await acompletion(**retry_params, drop_params=True) |
| 284 | + content: str = response.choices[0].message.content or "" |
| 285 | + return content |
| 286 | + |
233 | 287 | last_exception = e |
234 | 288 | error_msg = str(e).lower() |
235 | 289 |
|
|
0 commit comments