Skip to content

ChatBedrockConverse prevents DeepSeek V3 from returning text whenever tools are bound (introduced by #688) #1259

Description

Since #688, ChatBedrockConverse rewrites a missing tool_choice to any for deepseek.v3* models and omits auto from their capability table. If tools are in use, the model is always forced to call a tool and can never answer in text, and asking for auto explicitly is refused by lanchain-aws code before any request is made. Bedrock itself accepts auto (and no toolChoice) on both DeepSeek V3.1 and V3.2 today.

Versions: langchain-aws 1.7.5 (also reproduced on 1.6.0 and 1.7.4), langchain-core 1.6.1, boto3 1.43.82. Region us-east-1 unless noted.

The Converse API accepts tool selection as documented

With tools, no toolChoice (Bedrock's default is auto), asked for text:

import boto3
rt = boto3.client("bedrock-runtime", region_name="us-east-1")
tool = {"toolSpec": {"name": "get_weather", "description": "Get the weather for a city.",
        "inputSchema": {"json": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}}
r = rt.converse(modelId="deepseek.v3.2",
                messages=[{"role": "user", "content": [{"text": "Reply with only the text OK. Do not use any tools."}]}],
                inferenceConfig={"maxTokens": 100}, toolConfig={"tools": [tool]})
print(r["stopReason"], r["output"]["message"]["content"])
# end_turn [{'text': 'OK'}]

Asked for the weather instead, the same request returns a valid toolUse block.

Through langchain-aws auto is incorrectly refused

from langchain_aws import ChatBedrockConverse
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Get the weather for a city."""
    return f"Sunny in {city}"

llm = ChatBedrockConverse(model="deepseek.v3.2", provider="deepseek", region_name="us-east-1", max_tokens=100)
print(llm.supports_tool_choice_values)                      # ('any', 'tool')  <- no 'auto'

msg = llm.bind_tools([get_weather]).invoke("Reply with only the text OK. Do not use any tools.")
print(msg.tool_calls)                                        # [{'name': 'get_weather', ...}]  <- forced tool call
# request body sent: toolConfig.toolChoice == {"any": {}}

llm.bind_tools([get_weather], tool_choice="auto")
# ValueError: Model deepseek.v3.2 does not currently support tool_choice of type auto.
root cause: Both come from `_resolve_tool_choice` in `bedrock_converse.py`:
if not tool_choice:
    if "deepseek.v3" in self._get_base_model():
        return _format_tool_choice("any")      # None -> any
    return None
...
if tool_choice_type in supported:              # 'auto' not in ('any', 'tool') -> raise

Other providers are unaffected

Same code, model="us.anthropic.claude-haiku-4-5-20251001-v1:0":

llm.bind_tools([get_weather]).invoke("Reply with only the text OK. Do not use any tools.").content
# 'OK'   (no toolChoice sent; caps ('auto', 'any', 'tool'))

Not a problem for some other model families: Raw Converse behaves the same for Claude, Grok 4.6, GPT-5.6 and Qwen3, and when toolChoice not provided as a parameter, we get text when asked and a tool call when appropriate.

Impact

On DeepSeek v3 with tools configured, all model calls go out with tool_choice='any', preventing text responses.

Proposed fix

  1. Remove the deepseek.v3 branch in _resolve_tool_choice, and we get Bedrock defaults.
  2. Add "auto" to the DeepSeek entries in the supports_tool_choice_values table.
Further details: measurements and origin

#927 doesn't resolve DeepSeek. Workaround on current releases: pass supports_tool_choice_values=("auto", "any") at construction and set tool_choice="auto" explicitly (we used a custom middleware to do this).

#688 said: "DeepSeek-V3.1 does not work with the auto and named tool choice values. While these options will not be rejected on Converse API side, only any will produce a valid tool call output." and "DeepSeek-V3.2-Exp supports tool choice in addition to any, but auto is still not supported." I cannot say whether that held in October 2025; it does not hold now.

Raw Converse, prompt "What's the weather in Boston? Use the tool.", valid toolUse (correct name, non-empty city) per 3 trials:

model region no toolChoice auto tool
deepseek.v3-v1:0 (V3.1) us-west-2 3/3 3/3 3/3
deepseek.v3.2 us-west-2 3/3 3/3 3/3
deepseek.v3.2 us-east-1 3/3 3/3 3/3

Prompt "Reply with only the text OK.": end_turn with a text block under no toolChoice and under auto; tool_use under any, as expected.

Two-turn loop through ChatBedrockConverse.bind_tools (tool call, tool result, second call): the second call is sent with toolChoice: {"any": {}} and returns another tool call rather than the sentence asked for.

Full reproducer with an outbound-request tap, showing the toolChoice value actually sent for each case:

"""Demo: langchain-aws forces `toolChoice: any` on DeepSeek V3 whenever tools are bound."""
import json
import boto3
from langchain_aws import ChatBedrockConverse
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool

MODEL, REGION = "deepseek.v3.2", "us-east-1"

sent: list[dict] = []
_orig_client = boto3.Session.client
def _tapped_client(self, *args, **kwargs):
    client = _orig_client(self, *args, **kwargs)
    if (args[0] if args else kwargs.get("service_name")) == "bedrock-runtime":
        client.meta.events.register("before-call.bedrock-runtime.Converse",
                                    lambda params, **_: sent.append(json.loads(params["body"])))
    return client
boto3.Session.client = _tapped_client

@tool
def get_weather(city: str) -> str:
    """Get the weather for a city."""
    return f"Sunny, 22C in {city}"

def tool_choice_sent():
    return sent[-1].get("toolConfig", {}).get("toolChoice") if sent else None

def describe(msg):
    return f"TOOL CALL: {msg.tool_calls[0]['name']}" if msg.tool_calls else f"TEXT: {msg.content!r:.40}"

llm = ChatBedrockConverse(model=MODEL, provider="deepseek", region_name=REGION, max_tokens=200)
ASK = "Reply with only the text OK. Do not use any tools."

sent.clear(); r = llm.invoke(ASK)
print("1. no tools:            ", tool_choice_sent(), describe(r))
bound = llm.bind_tools([get_weather])
sent.clear(); r = bound.invoke(ASK)
print("2. tools, no choice:    ", tool_choice_sent(), describe(r))
try:
    llm.bind_tools([get_weather], tool_choice="auto").invoke(ASK)
except ValueError as exc:
    print("3. tools, auto:          REFUSED:", str(exc)[:80])
msgs = [HumanMessage("What's the weather in Boston? Use the tool, then answer in one sentence.")]
first = bound.invoke(msgs); msgs.append(first)
for c in first.tool_calls:
    msgs.append(ToolMessage(content=get_weather.invoke(c["args"]), tool_call_id=c["id"]))
sent.clear(); final = bound.invoke(msgs)
print("4. after tool result:   ", tool_choice_sent(), describe(final))

Output:

1. no tools:             None TEXT: 'OK'
2. tools, no choice:     {'any': {}} TOOL CALL: get_weather
3. tools, auto:          REFUSED: Model deepseek.v3.2 does not currently support tool_choice of type auto. ...
4. after tool result:    {'any': {}} TOOL CALL: get_weather

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions