From ad36aaa71dc896b7f46cb60f15973b4a06fa2a85 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 16:23:23 +0000 Subject: [PATCH] Local /v1/messages: invert disable_parallel_tool_use into parallel_tool_calls (PR #5711) Anthropic Messages API nests `disable_parallel_tool_use` inside the `tool_choice` object (per docs.claude.com/parallel-tool-use). The local Anthropic-compat endpoint dropped that flag because the OpenAI shape it translates into uses a different name and lives at the top level instead. SDK clients (anthropic-python, anthropic-sdk-go, etc.) that already speak this dialect therefore could not opt out of parallel tool calls against the local GGUF model. Extract `disable_parallel_tool_use` from the incoming tool_choice and invert it to `parallel_tool_calls` on the agentic-loop call. Plain-chat and existing tool_choice shapes are untouched. Added a focused unit test that pins the dict/None/bool/string boundary cases. --- studio/backend/routes/inference.py | 11 ++++++++ .../tests/test_sampling_params_routing.py | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 9f5b07317b..3342bc2d78 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4461,6 +4461,16 @@ async def anthropic_messages( if openai_tool_choice is None: openai_tool_choice = "auto" + # Anthropic nests `disable_parallel_tool_use` inside `tool_choice` + # (https://docs.claude.com/en/docs/agents-and-tools/tool-use/implement-tool-use). + # Flip it into the OpenAI-shaped `parallel_tool_calls` toggle so the + # local GGUF tool loop respects clients that opt out of parallel calls. + anthropic_parallel_tool_calls: Optional[bool] = None + if isinstance(payload.tool_choice, dict): + _disable = payload.tool_choice.get("disable_parallel_tool_use") + if isinstance(_disable, bool): + anthropic_parallel_tool_calls = not _disable + cancel_event = threading.Event() # ── Tool routing ────────────────────────────────────────── @@ -4666,6 +4676,7 @@ async def anthropic_messages( auto_heal_tool_calls = True, tool_call_timeout = 300, session_id = payload.session_id, + parallel_tool_calls = anthropic_parallel_tool_calls, ) if payload.stream: diff --git a/studio/backend/tests/test_sampling_params_routing.py b/studio/backend/tests/test_sampling_params_routing.py index 889fe2621e..3aad1e2d41 100644 --- a/studio/backend/tests/test_sampling_params_routing.py +++ b/studio/backend/tests/test_sampling_params_routing.py @@ -819,3 +819,28 @@ def test_chat_settings_payload_accepts_new_sampling_keys(): assert ip.stop == ["END"] assert ip.serviceTier == "standard_only" assert ip.parallelToolCalls is False + + +# ── Local /v1/messages: disable_parallel_tool_use translation ────────── + + +def test_local_anthropic_disable_parallel_tool_use_translation(): + """Anthropic nests `disable_parallel_tool_use` under `tool_choice` + (per docs.claude.com). The local /v1/messages GGUF tool path must + invert it into OpenAI-shaped `parallel_tool_calls` so third-party + clients (Claude SDK, LiteLLM in passthrough mode) opt out of + parallel calls successfully even on the local model.""" + # Mirror the extraction logic in routes/inference.py:anthropic_messages. + def _extract(tc): + if isinstance(tc, dict): + v = tc.get("disable_parallel_tool_use") + if isinstance(v, bool): + return not v + return None + + assert _extract({"type": "auto", "disable_parallel_tool_use": True}) is False + assert _extract({"type": "any", "disable_parallel_tool_use": False}) is True + assert _extract({"type": "auto"}) is None + assert _extract(None) is None + assert _extract("auto") is None # string form (non-dict) → no opinion + assert _extract({"type": "auto", "disable_parallel_tool_use": "yes"}) is None