From fa942aec719abd92026dcb56cab8e50a38776611 Mon Sep 17 00:00:00 2001 From: ccurme Date: Sun, 6 Sep 2026 21:23:40 -0400 Subject: [PATCH] fix(deepseek): route strict mode to the beta endpoint (#40249) Co-authored-by: Humberto Bernal Co-authored-by: open-swe[bot] --- .../langchain_deepseek/chat_models.py | 17 +++- .../tests/unit_tests/test_chat_models.py | 88 +++++++++++++++++-- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/libs/partners/deepseek/langchain_deepseek/chat_models.py b/libs/partners/deepseek/langchain_deepseek/chat_models.py index 1f3162ecbc..ed715e8ce7 100644 --- a/libs/partners/deepseek/langchain_deepseek/chat_models.py +++ b/libs/partners/deepseek/langchain_deepseek/chat_models.py @@ -334,6 +334,19 @@ class ChatDeepSeek(BaseChatOpenAI): def _resolve_model_profile(self) -> ModelProfile | None: return _get_default_model_profile(self.model_name) or None + def _with_beta_api_base(self) -> Self: + """Return a copy of this model that targets DeepSeek's beta endpoint.""" + beta_model = self.model_copy( + update={ + "api_base": DEFAULT_BETA_API_BASE, + "client": None, + "async_client": None, + "root_client": None, + "root_async_client": None, + } + ) + return beta_model.validate_environment() # type: ignore[operator] + def _get_request_payload( self, input_: LanguageModelInput, @@ -521,7 +534,7 @@ class ChatDeepSeek(BaseChatOpenAI): # 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}) + beta_model = self._with_beta_api_base() return beta_model.bind_tools( tools, tool_choice=tool_choice, @@ -627,7 +640,7 @@ class ChatDeepSeek(BaseChatOpenAI): # 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}) + beta_model = self._with_beta_api_base() return beta_model.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 a8ea2a69c8..227e8c508f 100644 --- a/libs/partners/deepseek/tests/unit_tests/test_chat_models.py +++ b/libs/partners/deepseek/tests/unit_tests/test_chat_models.py @@ -14,7 +14,11 @@ from openai.types.chat.chat_completion import Choice from pydantic import BaseModel as PydanticBaseModel from pydantic import Field, SecretStr -from langchain_deepseek.chat_models import DEFAULT_API_BASE, ChatDeepSeek +from langchain_deepseek.chat_models import ( + DEFAULT_API_BASE, + DEFAULT_BETA_API_BASE, + ChatDeepSeek, +) MODEL_NAME = "deepseek-chat" @@ -262,6 +266,29 @@ class SampleTool(PydanticBaseModel): value: str = Field(description="A test value") +_MAX_RUNNABLE_DEPTH = 6 + + +def _find_chat_model(runnable: Any, depth: int = 0) -> ChatDeepSeek | None: + """Walk a composed runnable and return the first `ChatDeepSeek` found.""" + if isinstance(runnable, ChatDeepSeek): + return runnable + if depth > _MAX_RUNNABLE_DEPTH: + return None + for attr in ("bound", "first", "last", "runnable", "steps", "steps__"): + value = getattr(runnable, attr, None) + if value is None: + continue + candidates = value if isinstance(value, (list, tuple)) else [value] + if isinstance(value, dict): + candidates = list(value.values()) + for candidate in candidates: + found = _find_chat_model(candidate, depth + 1) + if found is not None: + return found + return None + + class TestChatDeepSeekStrictMode: """Tests for DeepSeek strict mode support. @@ -283,10 +310,36 @@ class TestChatDeepSeekStrictMode: # 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 + # The bound model must target the beta endpoint, and so must the client + # that actually issues the request — updating `api_base` alone leaves + # the inherited `openai` clients pointing at the default base URL. + beta_model = _find_chat_model(bound_model) + assert beta_model is not None + assert beta_model.api_base == DEFAULT_BETA_API_BASE + assert str(beta_model.root_client.base_url).startswith(DEFAULT_BETA_API_BASE) + assert str(beta_model.root_async_client.base_url).startswith( + DEFAULT_BETA_API_BASE + ) + + # The original model is left untouched + assert llm.api_base == DEFAULT_API_BASE + assert str(llm.root_client.base_url).startswith(DEFAULT_API_BASE) + + def test_beta_copy_rebuilds_clients(self) -> None: + """The beta copy must use new clients.""" + llm = ChatDeepSeek( + model="deepseek-chat", + api_key=SecretStr("test_key"), + ) + + beta_model = llm._with_beta_api_base() + + assert beta_model.root_client is not llm.root_client + assert beta_model.root_async_client is not llm.root_async_client + assert str(beta_model.root_client.base_url).startswith(DEFAULT_BETA_API_BASE) + assert str(beta_model.root_async_client.base_url).startswith( + DEFAULT_BETA_API_BASE + ) def test_bind_tools_without_strict_mode_uses_default_endpoint(self) -> None: """Test bind_tools without strict or with strict=False uses default endpoint.""" @@ -303,6 +356,19 @@ class TestChatDeepSeekStrictMode: bound_model_none = llm.bind_tools([SampleTool]) assert bound_model_none is not None + def test_strict_mode_preserves_custom_api_base(self) -> None: + """A custom API base must bypass the DeepSeek beta endpoint.""" + llm = ChatDeepSeek( + model="deepseek-chat", + api_key=SecretStr("test_key"), + base_url="https://proxy.example/v1", + ) + + bound_model = llm.bind_tools([SampleTool], strict=True) + + assert _find_chat_model(bound_model) is llm + assert str(llm.root_client.base_url).startswith("https://proxy.example/v1") + 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( @@ -316,8 +382,16 @@ class TestChatDeepSeekStrictMode: # Create structured output with strict=True structured_model = llm.with_structured_output(SampleTool, strict=True) - # The structured model should work with beta endpoint - assert structured_model is not None + # Walk the resulting runnable to the underlying model and assert that + # the client it would call is pointed at the beta endpoint. + beta_model = _find_chat_model(structured_model) + assert beta_model is not None + assert beta_model.api_base == DEFAULT_BETA_API_BASE + assert str(beta_model.root_client.base_url).startswith(DEFAULT_BETA_API_BASE) + + # The original model is left untouched + assert llm.api_base == DEFAULT_API_BASE + assert str(llm.root_client.base_url).startswith(DEFAULT_API_BASE) class TestChatDeepSeekAzureToolChoice: