diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 7a548136f2a..44fa40b9329 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -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( diff --git a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py index ec51e5d303d..cd38cadc7e9 100644 --- a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py +++ b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py @@ -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"])