Skip to content

Commit 1cf2325

Browse files
barryqyvineethsai7
andauthored
fix: add plain JSON mode override for LLM requests (#60)
Co-authored-by: Vineeth Sai Narajala <vnarajal@cisco.com>
1 parent 422711e commit 1cf2325

6 files changed

Lines changed: 138 additions & 19 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# SKILL_SCANNER_LLM_MODEL=azure/gpt-4.1
1111
# SKILL_SCANNER_LLM_BASE_URL=https://your-resource.openai.azure.com/
1212
# SKILL_SCANNER_LLM_API_VERSION=2025-01-01-preview
13+
# SKILL_SCANNER_LLM_FORCE_JSON_OBJECT=true
1314

1415
# AWS Bedrock (bearer token)
1516
# SKILL_SCANNER_LLM_API_KEY=bedrock-api-key-...

docs/reference/configuration-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Primary settings for the LLM semantic analyzer.
2727
| `SKILL_SCANNER_LLM_MODEL` | Primary model identifier for semantic analysis. | `anthropic/claude-sonnet-4-20250514` |
2828
| `SKILL_SCANNER_LLM_BASE_URL` | Optional custom endpoint base URL for provider routing. | `https://api.openai.com/v1` |
2929
| `SKILL_SCANNER_LLM_API_VERSION` | Optional API version for providers that require one. | `2024-02-15-preview` |
30+
| `SKILL_SCANNER_LLM_FORCE_JSON_OBJECT` | Skip json_schema and start in plain JSON mode for incompatible proxies. | `true` |
3031
3132
## Meta Analyzer
3233
@@ -117,6 +118,7 @@ Paths, allowlists, and other advanced settings.
117118
| `SKILL_SCANNER_LLM_API_KEY` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/config/config.py`, `skill_scanner/core/analyzer_factory.py`, `skill_scanner/core/analyzers/behavioral_analyzer.py`, `skill_scanner/core/analyzers/llm_analyzer.py`, `skill_scanner/core/analyzers/llm_provider_config.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
118119
| `SKILL_SCANNER_LLM_API_VERSION` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/core/analyzer_factory.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
119120
| `SKILL_SCANNER_LLM_BASE_URL` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/core/analyzer_factory.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
121+
| `SKILL_SCANNER_LLM_FORCE_JSON_OBJECT` | `.env.example` |
120122
| `SKILL_SCANNER_LLM_MODEL` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/config/config.py`, `skill_scanner/core/analyzer_factory.py`, `skill_scanner/core/analyzers/behavioral_analyzer.py`, `skill_scanner/core/analyzers/llm_analyzer.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
121123
| `SKILL_SCANNER_META_LLM_API_KEY` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
122124
| `SKILL_SCANNER_META_LLM_API_VERSION` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |

docs/user-guide/installation-and-configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ You only need to set these if you're using the corresponding features. Click a s
5454
- `SKILL_SCANNER_LLM_MODEL`
5555
- `SKILL_SCANNER_LLM_BASE_URL`
5656
- `SKILL_SCANNER_LLM_API_VERSION`
57+
- `SKILL_SCANNER_LLM_FORCE_JSON_OBJECT` — start in plain JSON mode for proxies that reject `json_schema`
5758
5859
</details>
5960

scripts/generate_reference_docs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ def _describe_env_var(var: str) -> str:
393393
"SKILL_SCANNER_LLM_MODEL": "Primary model identifier for semantic analysis.",
394394
"SKILL_SCANNER_LLM_BASE_URL": "Optional custom endpoint base URL for provider routing.",
395395
"SKILL_SCANNER_LLM_API_VERSION": "Optional API version for providers that require one.",
396+
"SKILL_SCANNER_LLM_FORCE_JSON_OBJECT": "Skip json_schema and start in plain JSON mode for incompatible proxies.",
396397
"SKILL_SCANNER_META_LLM_API_KEY": "Meta-analyzer API key override.",
397398
"SKILL_SCANNER_META_LLM_MODEL": "Meta-analyzer model override.",
398399
"SKILL_SCANNER_META_LLM_BASE_URL": "Meta-analyzer base URL override.",
@@ -426,6 +427,7 @@ def _describe_env_var(var: str) -> str:
426427
"SKILL_SCANNER_LLM_MODEL",
427428
"SKILL_SCANNER_LLM_BASE_URL",
428429
"SKILL_SCANNER_LLM_API_VERSION",
430+
"SKILL_SCANNER_LLM_FORCE_JSON_OBJECT",
429431
],
430432
),
431433
(
@@ -484,6 +486,7 @@ def _describe_env_var(var: str) -> str:
484486
"SKILL_SCANNER_LLM_MODEL": "anthropic/claude-sonnet-4-20250514",
485487
"SKILL_SCANNER_LLM_BASE_URL": "https://api.openai.com/v1",
486488
"SKILL_SCANNER_LLM_API_VERSION": "2024-02-15-preview",
489+
"SKILL_SCANNER_LLM_FORCE_JSON_OBJECT": "true",
487490
"SKILL_SCANNER_META_LLM_API_KEY": "(falls back to LLM_API_KEY)",
488491
"SKILL_SCANNER_META_LLM_MODEL": "(falls back to LLM_MODEL)",
489492
"SKILL_SCANNER_META_LLM_BASE_URL": "(falls back to LLM_BASE_URL)",

skill_scanner/core/analyzers/llm_request_handler.py

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import asyncio
2626
import json
2727
import logging
28+
import os
2829
import warnings
2930
from pathlib import Path
3031
from typing import Any
@@ -95,6 +96,12 @@ def __init__(
9596

9697
# Load JSON schema for structured outputs
9798
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"}
98105

99106
def _load_response_schema(self) -> dict[str, Any] | None:
100107
"""Load JSON schema for structured outputs."""
@@ -160,6 +167,55 @@ def _sanitize_schema_for_google(self, schema: dict[str, Any]) -> dict[str, Any]:
160167

161168
return sanitized
162169

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+
163219
async def make_request(self, messages: list[dict[str, str]], context: str = "") -> str:
164220
"""
165221
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
206262
**self.provider_config.get_request_params(),
207263
}
208264

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
227268

228269
response = await acompletion(**request_params, drop_params=True)
229270
content: str = response.choices[0].message.content or ""
230271
return content
231272

232273
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+
233287
last_exception = e
234288
error_msg = str(e).lower()
235289

tests/test_llm_request_handler.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
"""
2222

2323
import json
24+
import os
2425
from pathlib import Path
25-
from unittest.mock import MagicMock
26+
from unittest.mock import AsyncMock, MagicMock, patch
2627

2728
import pytest
2829

@@ -172,3 +173,60 @@ async def test_acompletion_called_with_drop_params(self):
172173
call_kwargs = mock_acompletion.call_args
173174
kwargs = call_kwargs.kwargs if call_kwargs.kwargs else call_kwargs[1]
174175
assert kwargs.get("drop_params") is True, f"acompletion must be called with drop_params=True, got: {kwargs}"
176+
177+
178+
class TestLiteLLMRequestFallback:
179+
"""Tests for switching from json_schema to plain JSON output."""
180+
181+
@pytest.fixture
182+
def litellm_handler(self) -> LLMRequestHandler:
183+
provider_config = MagicMock()
184+
provider_config.model = "gpt-4o"
185+
provider_config.use_google_sdk = False
186+
provider_config.get_request_params.return_value = {}
187+
return LLMRequestHandler(provider_config=provider_config, max_retries=0)
188+
189+
@staticmethod
190+
def _mock_litellm_response(content: str) -> MagicMock:
191+
response = MagicMock()
192+
response.choices = [MagicMock(message=MagicMock(content=content))]
193+
return response
194+
195+
@pytest.mark.asyncio
196+
async def test_falls_back_to_json_object_when_backend_rejects_schema(self, litellm_handler: LLMRequestHandler):
197+
error = RuntimeError("Azure error: Missing required parameter: 'response_format.json_schema'.")
198+
plain_json_response = TestLiteLLMRequestFallback._mock_litellm_response(
199+
'{"overall_assessment":"unsafe","findings":[]}'
200+
)
201+
202+
with patch(
203+
"skill_scanner.core.analyzers.llm_request_handler.acompletion",
204+
AsyncMock(side_effect=[error, plain_json_response]),
205+
) as mocked_acompletion:
206+
result = await litellm_handler.make_request([{"role": "user", "content": "Scan this"}], context="demo")
207+
208+
assert result == '{"overall_assessment":"unsafe","findings":[]}'
209+
assert mocked_acompletion.await_count == 2
210+
assert mocked_acompletion.await_args_list[0].kwargs["response_format"]["type"] == "json_schema"
211+
assert mocked_acompletion.await_args_list[1].kwargs["response_format"]["type"] == "json_object"
212+
assert litellm_handler._use_plain_json_output is True
213+
214+
@pytest.mark.asyncio
215+
async def test_force_json_object_env_skips_schema_attempt(self, litellm_handler: LLMRequestHandler):
216+
plain_json_response = TestLiteLLMRequestFallback._mock_litellm_response(
217+
'{"overall_assessment":"unsafe","findings":[]}'
218+
)
219+
220+
with (
221+
patch.dict(os.environ, {"SKILL_SCANNER_LLM_FORCE_JSON_OBJECT": "1"}, clear=False),
222+
patch(
223+
"skill_scanner.core.analyzers.llm_request_handler.acompletion",
224+
AsyncMock(return_value=plain_json_response),
225+
) as mocked_acompletion,
226+
):
227+
forced_handler = LLMRequestHandler(provider_config=litellm_handler.provider_config, max_retries=0)
228+
result = await forced_handler.make_request([{"role": "user", "content": "Scan this"}], context="demo")
229+
230+
assert result == '{"overall_assessment":"unsafe","findings":[]}'
231+
assert mocked_acompletion.await_count == 1
232+
assert mocked_acompletion.await_args_list[0].kwargs["response_format"]["type"] == "json_object"

0 commit comments

Comments
 (0)