feat(deepseek): support strict beta structured output (#32727)

**Description:** This PR adds support for DeepSeek's beta strict mode
feature for structured
outputs and tool calling. It overrides `bind_tools()` and
`with_structured_output()` to automatically use
DeepSeek's beta endpoint (https://api.deepseek.com/beta) when
`strict=True`. Both methods need overriding because they're independent
entry points and user can call either directly. When DeepSeek's strict
mode graduates from beta, we can just remove both overriden methods. You
can read more about the beta feature here:
https://api-docs.deepseek.com/guides/function_calling#strict-mode-beta
  
**Issue:** Implements #32670 


**Dependencies:** None


**Sample Code**

```python
from langchain_deepseek import ChatDeepSeek
from pydantic import BaseModel, Field
from typing import Optional
import os


# Enter your DeepSeek API Key here
API_KEY = "YOUR_API_KEY"


# location, temperature, condition are required fields
# humidity is optional field with default value
class WeatherInfo(BaseModel):
    location: str = Field(description="City name")
    temperature: int = Field(description="Temperature in Celsius")
    condition: str = Field(description="Weather condition (sunny, cloudy, rainy)")
    humidity: Optional[int] = Field(default=None, description="Humidity percentage")


llm = ChatDeepSeek(
    model="deepseek-chat",
    api_key=API_KEY,
)

# just to confirm that a new instance will use the default base url (instead of beta)
print(f"Default API base: {llm.api_base}")



# Test 1: bind_tools with strict=True shoud list all the tools calls
print("\nTest 1: bind_tools with strict=True")
llm_with_tools = llm.bind_tools([WeatherInfo], strict=True)
response = llm_with_tools.invoke("Tell me the weather in New York. It's 22 degrees, sunny.")
print(response.tool_calls)



# Test 2: with_structured_output with strict=True
print("\nTest 2: with_structured_output with strict=True")
structured_llm = llm.with_structured_output(WeatherInfo, strict=True)
result = structured_llm.invoke("Tell me the weather in New York.")
print(f"  Result: {result}")
assert isinstance(result, WeatherInfo), "Result should be a WeatherInfo instance"
```

---------

Co-authored-by: Mason Daugherty <mason@langchain.dev>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
Shahroz Ahmad
2025-11-09 23:24:33 -04:00
committed by GitHub
parent c6801fe159
commit 31b5e4810c
3 changed files with 132 additions and 72 deletions

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from collections.abc import Callable, Iterator, Sequence
from json import JSONDecodeError
from typing import Any, Literal, TypeAlias
@@ -12,15 +12,17 @@ from langchain_core.callbacks import (
CallbackManagerForLLMRun,
)
from langchain_core.language_models import LangSmithParams, LanguageModelInput
from langchain_core.messages import AIMessageChunk, BaseMessage
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
from langchain_core.outputs import ChatGenerationChunk, ChatResult
from langchain_core.runnables import Runnable
from langchain_core.tools import BaseTool
from langchain_core.utils import from_env, secret_from_env
from langchain_openai.chat_models.base import BaseChatOpenAI
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
from typing_extensions import Self
DEFAULT_API_BASE = "https://api.deepseek.com/v1"
DEFAULT_BETA_API_BASE = "https://api.deepseek.com/beta"
_DictOrPydanticClass: TypeAlias = dict[str, Any] | type[BaseModel]
_DictOrPydantic: TypeAlias = dict[str, Any] | BaseModel
@@ -39,7 +41,7 @@ class ChatDeepSeek(BaseChatOpenAI):
Key init args — completion params:
model:
Name of DeepSeek model to use, e.g. `"deepseek-chat"`.
Name of DeepSeek model to use, e.g. `'deepseek-chat'`.
temperature:
Sampling temperature.
max_tokens:
@@ -368,6 +370,50 @@ class ChatDeepSeek(BaseChatOpenAI):
e.pos,
) from e
def bind_tools(
self,
tools: Sequence[dict[str, Any] | type | Callable | BaseTool],
*,
tool_choice: dict | str | bool | None = None,
strict: bool | None = None,
parallel_tool_calls: bool | None = None,
**kwargs: Any,
) -> Runnable[LanguageModelInput, AIMessage]:
"""Bind tool-like objects to this chat model.
Overrides parent to use beta endpoint when `strict=True`.
Args:
tools: A list of tool definitions to bind to this chat model.
tool_choice: Which tool to require the model to call.
strict: If True, uses beta API for strict schema validation.
parallel_tool_calls: Set to `False` to disable parallel tool use.
**kwargs: Additional parameters passed to parent `bind_tools`.
Returns:
A Runnable that takes same inputs as a chat model.
"""
# If strict mode is enabled and using default API base, switch to beta endpoint
if strict is True and self.api_base == DEFAULT_API_BASE:
# Create a new instance with beta endpoint
beta_model = self.model_copy(update={"api_base": DEFAULT_BETA_API_BASE})
return beta_model.bind_tools(
tools,
tool_choice=tool_choice,
strict=strict,
parallel_tool_calls=parallel_tool_calls,
**kwargs,
)
# Otherwise use parent implementation
return super().bind_tools(
tools,
tool_choice=tool_choice,
strict=strict,
parallel_tool_calls=parallel_tool_calls,
**kwargs,
)
def with_structured_output(
self,
schema: _DictOrPydanticClass | None = None,
@@ -423,10 +469,14 @@ class ChatDeepSeek(BaseChatOpenAI):
strict:
Whether to enable strict schema adherence when generating the function
call. This parameter is included for compatibility with other chat
models, and if specified will be passed to the Chat Completions API
in accordance with the OpenAI API specification. However, the DeepSeek
API may ignore the parameter.
call. When set to `True`, DeepSeek will use the beta API endpoint
(`https://api.deepseek.com/beta`) for strict schema validation.
This ensures model outputs exactly match the defined schema.
!!! note
DeepSeek's strict mode requires all object properties to be marked
as required in the schema.
kwargs: Additional keyword args aren't supported.
@@ -448,6 +498,19 @@ class ChatDeepSeek(BaseChatOpenAI):
# methods) be handled.
if method == "json_schema":
method = "function_calling"
# If strict mode is enabled and using default API base, switch to beta endpoint
if strict is True and self.api_base == DEFAULT_API_BASE:
# Create a new instance with beta endpoint
beta_model = self.model_copy(update={"api_base": DEFAULT_BETA_API_BASE})
return beta_model.with_structured_output(
schema,
method=method,
include_raw=include_raw,
strict=strict,
**kwargs,
)
return super().with_structured_output(
schema,
method=method,

View File

@@ -9,9 +9,10 @@ from langchain_core.messages import AIMessageChunk, ToolMessage
from langchain_tests.unit_tests import ChatModelUnitTests
from openai import BaseModel
from openai.types.chat import ChatCompletionMessage
from pydantic import SecretStr
from pydantic import BaseModel as PydanticBaseModel
from pydantic import Field, SecretStr
from langchain_deepseek.chat_models import ChatDeepSeek
from langchain_deepseek.chat_models import DEFAULT_API_BASE, ChatDeepSeek
MODEL_NAME = "deepseek-chat"
@@ -243,71 +244,66 @@ class TestChatDeepSeekCustomUnit:
payload = chat_model._get_request_payload([tool_message])
assert payload["messages"][0]["content"] == "test string"
def test_create_chat_result_with_model_provider(self) -> None:
"""Test that `model_provider` is added to `response_metadata`."""
chat_model = ChatDeepSeek(model=MODEL_NAME, api_key=SecretStr("api_key"))
mock_message = MagicMock()
mock_message.content = "Main content"
mock_message.role = "assistant"
mock_response = MockOpenAIResponse(
choices=[MagicMock(message=mock_message)],
error=None,
class SampleTool(PydanticBaseModel):
"""Sample tool schema for testing."""
value: str = Field(description="A test value")
class TestChatDeepSeekStrictMode:
"""Tests for DeepSeek strict mode support.
This tests the experimental beta feature that uses the beta API endpoint
when `strict=True` is used. These tests can be removed when strict mode
becomes stable in the default base API.
"""
def test_bind_tools_with_strict_mode_uses_beta_endpoint(self) -> None:
"""Test that bind_tools with strict=True uses the beta endpoint."""
llm = ChatDeepSeek(
model="deepseek-chat",
api_key=SecretStr("test_key"),
)
result = chat_model._create_chat_result(mock_response)
assert (
result.generations[0].message.response_metadata.get("model_provider")
== "deepseek"
# Verify default endpoint
assert llm.api_base == DEFAULT_API_BASE
# Bind tools with strict=True
bound_model = llm.bind_tools([SampleTool], strict=True)
# The bound model should have its internal model using beta endpoint
# We can't directly access the internal model, but we can verify the behavior
# by checking that the binding operation succeeds
assert bound_model is not None
def test_bind_tools_without_strict_mode_uses_default_endpoint(self) -> None:
"""Test bind_tools without strict or with strict=False uses default endpoint."""
llm = ChatDeepSeek(
model="deepseek-chat",
api_key=SecretStr("test_key"),
)
def test_convert_chunk_with_model_provider(self) -> None:
"""Test that `model_provider` is added to `response_metadata` for chunks."""
chat_model = ChatDeepSeek(model=MODEL_NAME, api_key=SecretStr("api_key"))
chunk: dict[str, Any] = {
"choices": [
{
"delta": {
"content": "Main content",
},
},
],
}
# Test with strict=False
bound_model_false = llm.bind_tools([SampleTool], strict=False)
assert bound_model_false is not None
chunk_result = chat_model._convert_chunk_to_generation_chunk(
chunk,
AIMessageChunk,
None,
)
if chunk_result is None:
msg = "Expected chunk_result not to be None"
raise AssertionError(msg)
assert (
chunk_result.message.response_metadata.get("model_provider") == "deepseek"
# Test with strict=None (default)
bound_model_none = llm.bind_tools([SampleTool])
assert bound_model_none is not None
def test_with_structured_output_strict_mode_uses_beta_endpoint(self) -> None:
"""Test that with_structured_output with strict=True uses beta endpoint."""
llm = ChatDeepSeek(
model="deepseek-chat",
api_key=SecretStr("test_key"),
)
def test_create_chat_result_with_model_provider_multiple_generations(
self,
) -> None:
"""Test that `model_provider` is added to all generations when `n > 1`."""
chat_model = ChatDeepSeek(model=MODEL_NAME, api_key=SecretStr("api_key"))
mock_message_1 = MagicMock()
mock_message_1.content = "First response"
mock_message_1.role = "assistant"
mock_message_2 = MagicMock()
mock_message_2.content = "Second response"
mock_message_2.role = "assistant"
# Verify default endpoint
assert llm.api_base == DEFAULT_API_BASE
mock_response = MockOpenAIResponse(
choices=[
MagicMock(message=mock_message_1),
MagicMock(message=mock_message_2),
],
error=None,
)
# Create structured output with strict=True
structured_model = llm.with_structured_output(SampleTool, strict=True)
result = chat_model._create_chat_result(mock_response)
assert len(result.generations) == 2 # noqa: PLR2004
for generation in result.generations:
assert (
generation.message.response_metadata.get("model_provider") == "deepseek"
)
# The structured model should work with beta endpoint
assert structured_model is not None

View File

@@ -370,7 +370,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.0.0"
version = "1.0.4"
source = { editable = "../../core" }
dependencies = [
{ name = "jsonpatch" },
@@ -404,6 +404,7 @@ test = [
{ name = "blockbuster", specifier = ">=1.5.18,<1.6.0" },
{ name = "freezegun", specifier = ">=1.2.2,<2.0.0" },
{ name = "grandalf", specifier = ">=0.8.0,<1.0.0" },
{ name = "langchain-model-profiles", directory = "../../model-profiles" },
{ name = "langchain-tests", directory = "../../standard-tests" },
{ name = "numpy", marker = "python_full_version < '3.13'", specifier = ">=1.26.4" },
{ name = "numpy", marker = "python_full_version >= '3.13'", specifier = ">=2.1.0" },
@@ -420,6 +421,7 @@ test = [
]
test-integration = []
typing = [
{ name = "langchain-model-profiles", directory = "../../model-profiles" },
{ name = "langchain-text-splitters", directory = "../../text-splitters" },
{ name = "mypy", specifier = ">=1.18.1,<1.19.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.2,<7.0.0.0" },
@@ -475,7 +477,7 @@ typing = [{ name = "mypy", specifier = ">=1.10.0,<2.0.0" }]
[[package]]
name = "langchain-openai"
version = "1.0.0"
version = "1.0.2"
source = { editable = "../openai" }
dependencies = [
{ name = "langchain-core" },
@@ -498,7 +500,6 @@ test = [
{ name = "langchain", editable = "../../langchain_v1" },
{ name = "langchain-core", editable = "../../core" },
{ name = "langchain-tests", editable = "../../standard-tests" },
{ name = "langgraph-prebuilt", specifier = ">=0.7.0rc1" },
{ name = "numpy", marker = "python_full_version < '3.13'", specifier = ">=1.26.4" },
{ name = "numpy", marker = "python_full_version >= '3.13'", specifier = ">=2.1.0" },
{ name = "pytest", specifier = ">=7.3.0,<8.0.0" },
@@ -526,7 +527,7 @@ typing = [
[[package]]
name = "langchain-tests"
version = "1.0.0"
version = "1.0.1"
source = { editable = "../../standard-tests" }
dependencies = [
{ name = "httpx" },