fix(deepseek): route strict mode to the beta endpoint (#40249)

Co-authored-by: Humberto Bernal <hbernalmendivil@gmail.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-06 21:23:40 -04:00
1 parent c3acb40e28
commit fa942aec71
2 files changed
+96 -9

No files matched your search

@@ -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,
@@ -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: