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.
This commit is contained in:
Will Miao
2026-07-23 09:17:29 +08:00
parent 7b8b778f83
commit 2aabd1d90e
2 changed files with 92 additions and 8 deletions

View File

@@ -566,18 +566,52 @@ class LLMService:
if effective_max is None: if effective_max is None:
effective_max = 4096 effective_max = 4096
result = await self.chat_completion( # Use json_schema (not json_object) for broader provider compatibility:
messages=messages, # LM Studio and some other OpenAI-compatible servers reject
model=model, # json_object but accept json_schema. {"type": "object"} is
temperature=temperature, # functionally equivalent — it accepts any JSON object without
response_format={"type": "json_object"}, # constraining specific fields.
max_tokens=effective_max, 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 "" content = result.get("content", "") or ""
if not content: if not content:
raise LLMResponseError( raise LLMResponseError(
"LLM returned empty content in json_object mode. " "LLM returned empty content. "
f"Raw response: {json.dumps(result)[:500]}" f"Raw response: {json.dumps(result)[:500]}"
) )

View File

@@ -243,6 +243,56 @@ class TestLLMServiceChatCompletionJson:
assert result == {"key": "value"} 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 @pytest.mark.asyncio
async def test_chat_completion_json_raises_on_non_json(self, llm_service): async def test_chat_completion_json_raises_on_non_json(self, llm_service):
# Non-JSON content raises LLMResponseError (salvage also fails) # Non-JSON content raises LLMResponseError (salvage also fails)