From 2aabd1d90ebce52fff4c855711671ad42a9adbfd Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 23 Jul 2026 09:17:29 +0800 Subject: [PATCH] fix(ai): use json_schema instead of json_object for broader provider compatibility (#1033) LM Studio and some other OpenAI-compatible servers reject response_format=json_object but accept json_schema. Switch to the equivalent json_schema format and add a fallback that retries without response_format when the provider rejects the format type. --- py/services/llm_service.py | 50 +++++++++++++++++++++++++----- tests/services/test_llm_service.py | 50 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/py/services/llm_service.py b/py/services/llm_service.py index b6854302..ea4e83dc 100644 --- a/py/services/llm_service.py +++ b/py/services/llm_service.py @@ -566,18 +566,52 @@ class LLMService: if effective_max is None: effective_max = 4096 - result = await self.chat_completion( - messages=messages, - model=model, - temperature=temperature, - response_format={"type": "json_object"}, - max_tokens=effective_max, - ) + # Use json_schema (not json_object) for broader provider compatibility: + # LM Studio and some other OpenAI-compatible servers reject + # json_object but accept json_schema. {"type": "object"} is + # functionally equivalent — it accepts any JSON object without + # constraining specific fields. + response_format = { + "type": "json_schema", + "json_schema": { + "name": "metadata", + "schema": {"type": "object"}, + }, + } + + try: + result = await self.chat_completion( + messages=messages, + model=model, + temperature=temperature, + response_format=response_format, + max_tokens=effective_max, + ) + except LLMResponseError as e: + # Only fall back when the provider rejects the response_format + # type value (e.g. "'response_format.type' must be..."). Avoid + # catching unrelated 400 errors whose body happens to mention + # "response_format" (e.g. "model does not support + # response_format restrictions on this endpoint"). + if "'response_format.type'" not in str(e).lower(): + raise + logger.info( + "Provider rejected response_format, retrying without it. " + "Falling back to prompt-only JSON mode. Error: %s", + e, + ) + result = await self.chat_completion( + messages=messages, + model=model, + temperature=temperature, + response_format=None, + max_tokens=effective_max, + ) content = result.get("content", "") or "" if not content: raise LLMResponseError( - "LLM returned empty content in json_object mode. " + "LLM returned empty content. " f"Raw response: {json.dumps(result)[:500]}" ) diff --git a/tests/services/test_llm_service.py b/tests/services/test_llm_service.py index 5fb1a5e1..c4d38fe8 100644 --- a/tests/services/test_llm_service.py +++ b/tests/services/test_llm_service.py @@ -243,6 +243,56 @@ class TestLLMServiceChatCompletionJson: assert result == {"key": "value"} + @pytest.mark.asyncio + async def test_chat_completion_json_falls_back_on_response_format_rejection( + self, llm_service, + ): + """Retry without response_format when provider rejects it (HTTP 400).""" + error_response = MockResponse( + 400, + text_data=( + '{"error":"\'response_format.type\' must be ' + '\'json_schema\' or \'text\'"}' + ), + ) + success_response = MockResponse( + 200, + json_data={ + "choices": [{"message": {"content": '{"key": "value"}'}}], + "usage": {}, + "model": "local-model", + }, + ) + + call_index = 0 + + class FallbackMockSession: + def __init__(self): + self.last_url = None + self.last_json = None + + def post(self, url, json=None, headers=None): + nonlocal call_index + self.last_url = url + self.last_json = json + call_index += 1 + return error_response if call_index == 1 else success_response + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + with mock.patch("aiohttp.ClientSession", return_value=FallbackMockSession()): + result = await llm_service.chat_completion_json( + system_prompt="You are helpful.", + user_prompt="Return JSON.", + ) + + assert result == {"key": "value"} + assert call_index == 2 + @pytest.mark.asyncio async def test_chat_completion_json_raises_on_non_json(self, llm_service): # Non-JSON content raises LLMResponseError (salvage also fails)