fix(llm): stop DeepSeek enrichment failing on json_schema rejection

Enriching a model with `llm_provider=deepseek` failed outright with
HTTP 400 "This response_format type is unavailable now".  Probing the
endpoint shows why:

    response_format absent      -> 200
    {"type": "json_object"}     -> 200
    {"type": "json_schema",...} -> 400

`chat_completion_json` preferred `json_schema` for a real reason -- LM
Studio and other local OpenAI-compatible servers reject `json_object`
but accept `json_schema` -- and guarded the fallback with a substring
test for `'response_format.type'` (the wording of those servers'
rejection).  DeepSeek's message is "This response_format type is
unavailable now", which does not contain that substring, so the guard
re-raised and the retry never ran.

Make the format a per-provider chain instead of a single guess:

- `_JSON_OBJECT_ONLY_PROVIDERS` lists providers known to reject
  json_schema (currently just deepseek).  They ask for `json_object`
  first, so the common case costs one request and no wasted retry.
- Everyone else keeps `json_schema` first, then downgrades through
  `json_object` and finally prompt-only mode.
- A downgrade now happens on any error mentioning `response_format`,
  which covers wording variants without swallowing unrelated failures:
  auth errors, unknown models, and rate limits still surface unchanged
  because their messages never name the parameter.

`json_object` is sufficient here: the skill prompt already specifies the
exact JSON shape, and `_try_salvage_json` repairs imperfect output.

Verified against the real configured endpoint with the real
`enrich_hf_metadata` prompt, prompt renderer, and ModelScope model card
for jj3550945163/Krea-2-LORA: a 9,815-character prompt returns
parseable JSON (base_model "Flux.1 Krea", description, tags, notes).

Three regression tests cover the DeepSeek ordering, the
json_schema -> json_object downgrade, and the no-retry-on-unrelated-400
path.  Full backend suite: 2856 passed.
This commit is contained in:
Will Miao
2026-09-14 10:27:33 +08:00
parent 31ef9ffa06
commit 326df32933
2 changed files with 178 additions and 34 deletions
+120
View File
@@ -76,6 +76,25 @@ class MockSession:
pass
class RecordingSession:
"""Mock session that records each request payload and replays responses."""
def __init__(self, responses):
self._responses = list(responses)
self.payloads = []
def post(self, url, json=None, headers=None):
self.payloads.append(json)
index = min(len(self.payloads) - 1, len(self._responses) - 1)
return self._responses[index]
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
@pytest.fixture
def llm_service():
"""Create an LLMService with mock settings."""
@@ -298,6 +317,107 @@ class TestLLMServiceChatCompletionJson:
assert result == {"key": "value"}
assert call_index == 2
@pytest.mark.asyncio
async def test_chat_completion_json_prefers_json_object_for_deepseek(self):
"""DeepSeek rejects json_schema, so json_object is used first.
Regression: DeepSeek answers json_schema with
"This response_format type is unavailable now", which the old
substring check did not recognise, so enrichment failed outright.
"""
settings = MockSettings(
llm_enabled=True,
llm_provider="deepseek",
llm_api_key="sk-test-key",
llm_api_base="https://api.deepseek.com/v1",
llm_model="deepseek-v4-flash",
)
service = LLMService(settings)
session = RecordingSession(
[
MockResponse(
200,
json_data={
"choices": [{"message": {"content": '{"key": "value"}'}}],
"usage": {},
},
)
]
)
with mock.patch("aiohttp.ClientSession", return_value=session):
result = await service.chat_completion_json(
system_prompt="You are helpful.",
user_prompt="Return JSON.",
)
assert result == {"key": "value"}
assert len(session.payloads) == 1
assert session.payloads[0]["response_format"] == {"type": "json_object"}
@pytest.mark.asyncio
async def test_chat_completion_json_downgrades_from_json_schema(
self, llm_service,
):
"""json_schema → json_object when the provider rejects json_schema."""
session = RecordingSession(
[
MockResponse(
400,
text_data=(
'{"error":{"message":"This response_format type is '
'unavailable now","type":"invalid_request_error"}}'
),
),
MockResponse(
200,
json_data={
"choices": [{"message": {"content": '{"key": "value"}'}}],
"usage": {},
},
),
]
)
with mock.patch("aiohttp.ClientSession", return_value=session):
result = await llm_service.chat_completion_json(
system_prompt="You are helpful.",
user_prompt="Return JSON.",
)
assert result == {"key": "value"}
assert [p.get("response_format") for p in session.payloads] == [
{
"type": "json_schema",
"json_schema": {"name": "metadata", "schema": {"type": "object"}},
},
{"type": "json_object"},
]
@pytest.mark.asyncio
async def test_chat_completion_json_does_not_retry_unrelated_errors(
self, llm_service,
):
"""Unrelated 400s are surfaced unchanged, without format downgrades."""
session = RecordingSession(
[
MockResponse(
400,
text_data='{"error":{"message":"Model not found"}}',
)
]
)
with mock.patch("aiohttp.ClientSession", return_value=session):
with pytest.raises(LLMResponseError, match="HTTP 400"):
await llm_service.chat_completion_json(
system_prompt="You are helpful.",
user_prompt="Return JSON.",
)
assert len(session.payloads) == 1
@pytest.mark.asyncio
async def test_chat_completion_json_raises_on_non_json(self, llm_service):
# Non-JSON content raises LLMResponseError (salvage also fails)