fix(deepseek): downgrade json_schema response_format to json_object

DeepSeek's /chat/completions rejects response_format={"type":"json_schema"}
with "This response_format type is unavailable now" — no DeepSeek model
supports native Structured Outputs, only JSON mode ({"type":"json_object"}).
DeepSeekChatConfig now downgrades json_schema to json_object and moves the
schema into the prompt via response_schema_prompt (the same helper the Gemini
path uses when it can't enforce a schema natively), so structured-output
requests succeed instead of 400ing.

The downgrade is unconditional rather than gated on supports_response_schema:
the model-cost map marks DeepSeek supports_response_schema=True (#20885) and
downstream consumers filter their model lists on that flag, so it is left
unchanged.

Fixes #7580
Fixes #7646
This commit is contained in:
Daniel Yudelevich 2026-06-26 11:54:09 -07:00
parent 9e7ba65759
commit 5670c8da6b
2 changed files with 132 additions and 0 deletions

View file

@ -8,6 +8,7 @@ import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
handle_messages_with_content_list_to_str_conversion,
)
from litellm.litellm_core_utils.prompt_templates.factory import response_schema_prompt
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from litellm.utils import supports_reasoning
@ -135,6 +136,59 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
and (optional_params.get("thinking") or {}).get("type") == "enabled"
)
def _downgrade_json_schema_to_json_object(
self,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
) -> Tuple[List[AllMessageValues], dict]:
"""
DeepSeek's /chat/completions rejects `response_format={"type": "json_schema"}`
with "This response_format type is unavailable now" — no DeepSeek model
supports native Structured Outputs, only JSON mode (`{"type": "json_object"}`).
See recurring reports #7580 and #7646.
DeepSeek models are marked `supports_response_schema=True` in the model-cost
map (#20885) and downstream consumers filter their model lists on that flag, so
flipping it is not an option. Instead, honour the flag: downgrade json_schema to
json_object and move the schema into the prompt via `response_schema_prompt`
(the same helper the Gemini path uses when it can't enforce a schema natively).
The injected message also satisfies DeepSeek's requirement that the word "json"
appear in the input when json_object mode is used.
The downgrade is unconditional (not gated on `supports_response_schema`) because
no DeepSeek model supports native json_schema.
"""
response_format = optional_params.get("response_format")
if (
not isinstance(response_format, dict)
or response_format.get("type") != "json_schema"
):
return messages, optional_params
json_schema: Optional[dict] = None
nested_schema = response_format.get("json_schema")
if isinstance(nested_schema, dict):
json_schema = nested_schema.get("schema")
elif "response_schema" in response_format:
json_schema = response_format["response_schema"]
if not json_schema:
return messages, optional_params
messages = messages + [
{
"role": "user",
"content": response_schema_prompt(
model=model, response_schema=json_schema
),
}
]
optional_params = {
**optional_params,
"response_format": {"type": "json_object"},
}
return messages, optional_params
@staticmethod
def _drop_unsupported_tools(optional_params: dict) -> dict:
"""
@ -222,6 +276,9 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
like deepseek-v3.2 that support thinking as opt-in but not always-on.
"""
optional_params = self._drop_unsupported_tools(optional_params)
messages, optional_params = self._downgrade_json_schema_to_json_object(
model=model, messages=messages, optional_params=optional_params
)
if self._thinking_mode_active(model=model, optional_params=optional_params):
messages = self._fill_reasoning_content(messages)
return super().transform_request(
@ -245,6 +302,9 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
fix for multi-turn thinking-mode conversations.
"""
optional_params = self._drop_unsupported_tools(optional_params)
messages, optional_params = self._downgrade_json_schema_to_json_object(
model=model, messages=messages, optional_params=optional_params
)
if self._thinking_mode_active(model=model, optional_params=optional_params):
messages = self._fill_reasoning_content(messages)
return await super().async_transform_request(

View file

@ -101,3 +101,75 @@ async def test_async_transform_request_strips_unsupported_tools_from_body():
assert [tool["type"] for tool in body["tools"]] == ["function"]
assert body["tools"][0]["function"]["name"] == "shell"
_JSON_SCHEMA_RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {
"name": "answer",
"schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
"additionalProperties": False,
},
"strict": True,
},
}
def test_downgrade_json_schema_to_json_object():
config = DeepSeekChatConfig()
messages, optional_params = config._downgrade_json_schema_to_json_object(
model="deepseek-chat",
messages=[{"role": "user", "content": "Is the sky blue?"}],
optional_params={"response_format": dict(_JSON_SCHEMA_RESPONSE_FORMAT)},
)
assert optional_params["response_format"] == {"type": "json_object"}
injected = messages[-1]
assert injected["role"] == "user"
# DeepSeek json_object mode requires the word "json" in the prompt
assert "json" in injected["content"].lower()
assert "answer" in injected["content"]
def test_downgrade_is_noop_for_json_object():
config = DeepSeekChatConfig()
original_messages = [{"role": "user", "content": "Is the sky blue?"}]
messages, optional_params = config._downgrade_json_schema_to_json_object(
model="deepseek-chat",
messages=original_messages,
optional_params={"response_format": {"type": "json_object"}},
)
assert optional_params["response_format"] == {"type": "json_object"}
assert messages == original_messages
def test_transform_request_downgrades_json_schema_response_format():
config = DeepSeekChatConfig()
body = config.transform_request(
model="deepseek-chat",
messages=[{"role": "user", "content": "Is the sky blue?"}],
optional_params={"response_format": dict(_JSON_SCHEMA_RESPONSE_FORMAT)},
litellm_params={},
headers={},
)
assert body["response_format"] == {"type": "json_object"}
assert any("answer" in m.get("content", "") for m in body["messages"])
async def test_async_transform_request_downgrades_json_schema_response_format():
config = DeepSeekChatConfig()
body = await config.async_transform_request(
model="deepseek-chat",
messages=[{"role": "user", "content": "Is the sky blue?"}],
optional_params={"response_format": dict(_JSON_SCHEMA_RESPONSE_FORMAT)},
litellm_params={},
headers={},
)
assert body["response_format"] == {"type": "json_object"}
assert any("answer" in m.get("content", "") for m in body["messages"])