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,