diff --git a/libs/partners/deepseek/langchain_deepseek/chat_models.py b/libs/partners/deepseek/langchain_deepseek/chat_models.py index df34c21bc08..eacc4a3bd72 100644 --- a/libs/partners/deepseek/langchain_deepseek/chat_models.py +++ b/libs/partners/deepseek/langchain_deepseek/chat_models.py @@ -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, diff --git a/libs/partners/deepseek/tests/unit_tests/test_chat_models.py b/libs/partners/deepseek/tests/unit_tests/test_chat_models.py index 31be8ab98c1..781ee1fa4d3 100644 --- a/libs/partners/deepseek/tests/unit_tests/test_chat_models.py +++ b/libs/partners/deepseek/tests/unit_tests/test_chat_models.py @@ -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 diff --git a/libs/partners/deepseek/uv.lock b/libs/partners/deepseek/uv.lock index 66b3b57cb5d..16f128380f8 100644 --- a/libs/partners/deepseek/uv.lock +++ b/libs/partners/deepseek/uv.lock @@ -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" },