Skip to content

Commit 44ca518

Browse files
fuchunfuchun1010
authored andcommitted
fix(litellm): parse DeepSeek-V3 proprietary inline tool-call tokens
DeepSeek-V3 emits tool calls using proprietary special tokens (<|tool▁calls▁begin|>…<|tool▁call▁begin|>function<|tool▁sep|>NAME) embedded in the content field. When LiteLLM does not translate these into structured tool_calls (intermittent), the existing fallback JSON parser rejects the payload because the function name is stored inside the tokens rather than as a 'name' key in the JSON object. Add _parse_deepseek_tool_calls_from_text that detects the proprietary token format, extracts the function name and arguments, and emits standard ChatCompletionMessageToolCall objects. Integrate it into the existing _parse_tool_calls_from_text pipeline. Also add _extract_json_from_deepseek_args helper to handle optional Markdown code fences (json … ) that DeepSeek wraps around the arguments payload. Closes #5024
1 parent 57bdecf commit 44ca518

2 files changed

Lines changed: 271 additions & 0 deletions

File tree

src/google/adk/models/lite_llm.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,6 +1420,142 @@ def _build_tool_call_from_json_dict(
14201420
return tool_call
14211421

14221422

1423+
# DeepSeek models may emit tool calls as inline text using proprietary
1424+
# special tokens. See https://api-docs.deepseek.com/guides/function_calling
1425+
# for the full specification. LiteLLM usually translates these into
1426+
# structured `tool_calls` but when it doesn't (intermittent), the raw
1427+
# tokens land in the `content` field and must be parsed here.
1428+
_DS_TCALLS_BEGIN = "\u003c\uff5ctool\u2581calls\u2581begin\uff5c\u003e"
1429+
_DS_TCALLS_END = "\u003c\uff5ctool\u2581calls\u2581end\uff5c\u003e"
1430+
_DS_TCALL_BEGIN = "\u003c\uff5ctool\u2581call\u2581begin\uff5c\u003e"
1431+
_DS_TCALL_END = "\u003c\uff5ctool\u2581call\u2581end\uff5c\u003e"
1432+
_DS_TSEP = "\u003c\uff5ctool\u2581sep\uff5c\u003e"
1433+
1434+
# Pattern: <|tool▁call▁begin|>function<|tool▁sep|>NAME \n ARGS <|tool▁call▁end|>
1435+
_DS_TOOL_CALL_RE = re.compile(
1436+
re.escape(_DS_TCALL_BEGIN)
1437+
+ r"function"
1438+
+ re.escape(_DS_TSEP)
1439+
+ r"([^\n\r]+?)\s*?\n(.*?)"
1440+
+ re.escape(_DS_TCALL_END),
1441+
re.DOTALL,
1442+
)
1443+
1444+
1445+
def _extract_json_from_deepseek_args(args_text: str) -> Optional[str]:
1446+
"""Extracts a JSON string from DeepSeek arguments text.
1447+
1448+
Args:
1449+
args_text: Raw text containing the function arguments, possibly
1450+
wrapped in Markdown-style code fences.
1451+
1452+
Returns:
1453+
The JSON string, or None if no valid JSON object could be found.
1454+
"""
1455+
if not args_text:
1456+
return None
1457+
# Strip optional Markdown code fences (```json ... ``` or ``` ... ```).
1458+
fence_match = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", args_text)
1459+
if fence_match:
1460+
return fence_match.group(1).strip()
1461+
# Fall back to the first balanced { … } block.
1462+
open_brace = args_text.find("{")
1463+
if open_brace == -1:
1464+
return None
1465+
try:
1466+
candidate, _ = _JSON_DECODER.raw_decode(args_text, open_brace)
1467+
return json.dumps(candidate, ensure_ascii=False)
1468+
except json.JSONDecodeError:
1469+
return None
1470+
1471+
1472+
def _parse_deepseek_tool_calls_from_text(
1473+
text_block: str,
1474+
) -> tuple[list[ChatCompletionMessageToolCall], Optional[str]]:
1475+
"""Parses DeepSeek proprietary inline tool-call tokens from text.
1476+
1477+
When LiteLLM does not translate DeepSeek's special tokens into
1478+
structured ``tool_calls``, the raw tokens appear inside the ``content``
1479+
field. This function extracts them and returns standard
1480+
``ChatCompletionMessageToolCall`` objects.
1481+
1482+
Token reference
1483+
``<|tool▁calls▁begin|>`` … ``<|tool▁calls▁end|>`` → outer wrapper
1484+
``<|tool▁call▁begin|>function<|tool▁sep|>NAME`` → single call start
1485+
``<|tool▁call▁end|>`` → single call end
1486+
1487+
Args:
1488+
text_block: The raw text that may contain DeepSeek tokens.
1489+
1490+
Returns:
1491+
A tuple of ``(tool_calls, remainder)`` where ``remainder`` is the
1492+
original text with all DeepSeek token regions removed.
1493+
"""
1494+
_ensure_litellm_imported()
1495+
1496+
tool_calls: list[ChatCompletionMessageToolCall] = []
1497+
if not text_block:
1498+
return tool_calls, None
1499+
1500+
# Quick guard: only invoke the regex if the outer tokens are present.
1501+
if _DS_TCALLS_BEGIN not in text_block and _DS_TCALL_BEGIN not in text_block:
1502+
return tool_calls, None
1503+
1504+
remainder_parts: list[str] = []
1505+
cursor = 0
1506+
1507+
# Outer loop — there may be multiple <|tool▁calls▁begin|> blocks.
1508+
while True:
1509+
begin_idx = text_block.find(_DS_TCALLS_BEGIN, cursor)
1510+
if begin_idx == -1:
1511+
# No more wrapped blocks; also look for unwrapped top-level call tokens.
1512+
begin_idx = text_block.find(_DS_TCALL_BEGIN, cursor)
1513+
if begin_idx == -1:
1514+
remainder_parts.append(text_block[cursor:])
1515+
break
1516+
1517+
# Everything before the token becomes remainder.
1518+
if begin_idx > cursor:
1519+
remainder_parts.append(text_block[cursor:begin_idx])
1520+
1521+
# Determine whether we are inside a wrapped block.
1522+
in_wrapped_block = text_block[begin_idx : begin_idx + len(_DS_TCALLS_BEGIN)] == _DS_TCALLS_BEGIN # pytype: disable=attribute-error # pylint: disable=line-too-long
1523+
if in_wrapped_block:
1524+
end_idx = text_block.find(
1525+
_DS_TCALLS_END, begin_idx + len(_DS_TCALLS_BEGIN)
1526+
)
1527+
if end_idx == -1:
1528+
remainder_parts.append(text_block[begin_idx:])
1529+
break
1530+
block = text_block[begin_idx + len(_DS_TCALLS_BEGIN) : end_idx]
1531+
cursor = end_idx + len(_DS_TCALLS_END)
1532+
else:
1533+
# Unwrapped call token — scan for a matching end token.
1534+
end_idx = text_block.find(_DS_TCALL_END, begin_idx + len(_DS_TCALL_BEGIN))
1535+
if end_idx == -1:
1536+
remainder_parts.append(text_block[begin_idx:])
1537+
break
1538+
block = text_block[begin_idx : end_idx + len(_DS_TCALL_END)]
1539+
cursor = end_idx + len(_DS_TCALL_END)
1540+
1541+
# Parse individual tool calls inside the block.
1542+
for match in _DS_TOOL_CALL_RE.finditer(block):
1543+
func_name = match.group(1).strip()
1544+
args_raw = match.group(2).strip()
1545+
args_json = _extract_json_from_deepseek_args(args_raw)
1546+
if not func_name or not args_json:
1547+
continue
1548+
tool_call = _build_tool_call_from_json_dict(
1549+
{"name": func_name, "arguments": args_json},
1550+
index=len(tool_calls),
1551+
)
1552+
if tool_call:
1553+
tool_calls.append(tool_call)
1554+
1555+
remainder = "".join(p for p in remainder_parts if p).strip()
1556+
return tool_calls, remainder or None
1557+
1558+
14231559
def _parse_tool_calls_from_text(
14241560
text_block: str,
14251561
) -> tuple[list[ChatCompletionMessageToolCall], Optional[str]]:
@@ -1430,6 +1566,17 @@ def _parse_tool_calls_from_text(
14301566

14311567
_ensure_litellm_imported()
14321568

1569+
# Try DeepSeek proprietary format first, then fall back to generic JSON.
1570+
ds_tool_calls, ds_remainder = _parse_deepseek_tool_calls_from_text(text_block)
1571+
if ds_tool_calls:
1572+
# If the remainder still contains content, re-parse it for
1573+
# additional generic inline JSON tool calls (mixed formats).
1574+
if ds_remainder:
1575+
extra_calls, extra_remainder = _parse_tool_calls_from_text(ds_remainder)
1576+
tool_calls = ds_tool_calls + (extra_calls or [])
1577+
return tool_calls, extra_remainder
1578+
return ds_tool_calls, None
1579+
14331580
remainder_segments = []
14341581
cursor = 0
14351582
text_length = len(text_block)

tests/unittests/models/test_litellm.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from google.adk.models.lite_llm import _MISSING_TOOL_RESULT_MESSAGE
4545
from google.adk.models.lite_llm import _model_response_to_chunk
4646
from google.adk.models.lite_llm import _model_response_to_generate_content_response
47+
from google.adk.models.lite_llm import _parse_deepseek_tool_calls_from_text
4748
from google.adk.models.lite_llm import _parse_tool_calls_from_text
4849
from google.adk.models.lite_llm import _redirect_litellm_loggers_to_stdout
4950
from google.adk.models.lite_llm import _safe_json_serialize
@@ -2797,6 +2798,129 @@ def test_parse_tool_calls_from_text_invalid_json_returns_remainder():
27972798
assert remainder == 'Leading {"unused": "payload"} trailing text'
27982799

27992800

2801+
# ---------------------------------------------------------------------------
2802+
# DeepSeek proprietary inline tool-call format tests
2803+
# ---------------------------------------------------------------------------
2804+
2805+
_DS_BEGIN_CALLS = "\u003c\uff5ctool\u2581calls\u2581begin\uff5c\u003e"
2806+
_DS_END_CALLS = "\u003c\uff5ctool\u2581calls\u2581end\uff5c\u003e"
2807+
_DS_BEGIN_CALL = "\u003c\uff5ctool\u2581call\u2581begin\uff5c\u003e"
2808+
_DS_END_CALL = "\u003c\uff5ctool\u2581call\u2581end\uff5c\u003e"
2809+
_DS_SEP = "\u003c\uff5ctool\u2581sep\uff5c\u003e"
2810+
2811+
2812+
def _ds_tool_call(name: str, args_json: str) -> str:
2813+
"""Build a single DeepSeek-style tool-call block."""
2814+
return (
2815+
f"{_DS_BEGIN_CALL}function{_DS_SEP}{name}\n"
2816+
f"```json\n{args_json}\n```"
2817+
f"{_DS_END_CALL}"
2818+
)
2819+
2820+
2821+
def _ds_wrapped(inner: str) -> str:
2822+
"""Wrap content in <|tool▁calls▁begin|>...<|tool▁calls▁end|>."""
2823+
return f"{_DS_BEGIN_CALLS}{inner}{_DS_END_CALLS}"
2824+
2825+
2826+
def test_parse_deepseek_single_tool_call():
2827+
"""Single DeepSeek tool call with code-fenced JSON args."""
2828+
text = _ds_wrapped(
2829+
_ds_tool_call("get_weather", '{"city": "Beijing", "unit": "celsius"}')
2830+
)
2831+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2832+
assert len(tool_calls) == 1
2833+
assert tool_calls[0].function.name == "get_weather"
2834+
assert json.loads(tool_calls[0].function.arguments) == {
2835+
"city": "Beijing",
2836+
"unit": "celsius",
2837+
}
2838+
assert remainder is None
2839+
2840+
2841+
def test_parse_deepseek_multi_tool_call():
2842+
"""Multiple DeepSeek tool calls in a single wrapped block."""
2843+
inner = _ds_tool_call("func_a", '{"x": 1}') + _ds_tool_call(
2844+
"func_b", '{"y": 2}'
2845+
)
2846+
text = _ds_wrapped(inner)
2847+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2848+
assert len(tool_calls) == 2
2849+
assert tool_calls[0].function.name == "func_a"
2850+
assert json.loads(tool_calls[0].function.arguments) == {"x": 1}
2851+
assert tool_calls[1].function.name == "func_b"
2852+
assert json.loads(tool_calls[1].function.arguments) == {"y": 2}
2853+
assert remainder is None
2854+
2855+
2856+
def test_parse_deepseek_plain_json_args():
2857+
"""DeepSeek tool call without Markdown code fences around args."""
2858+
inner = (
2859+
f"{_DS_BEGIN_CALL}function{_DS_SEP}search\n"
2860+
f'{{"query": "天气"}}'
2861+
f"{_DS_END_CALL}"
2862+
)
2863+
text = _ds_wrapped(inner)
2864+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2865+
assert len(tool_calls) == 1
2866+
assert tool_calls[0].function.name == "search"
2867+
assert json.loads(tool_calls[0].function.arguments) == {"query": "天气"}
2868+
2869+
2870+
def test_parse_deepseek_with_surrounding_text():
2871+
"""DeepSeek tool call embedded in surrounding non-tool text."""
2872+
prefix = "Let me think about this.\n"
2873+
suffix = "\nI'll proceed now."
2874+
inner = _ds_tool_call("calculate", '{"expr": "2+2"}')
2875+
text = prefix + _ds_wrapped(inner) + suffix
2876+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2877+
assert len(tool_calls) == 1
2878+
assert tool_calls[0].function.name == "calculate"
2879+
assert remainder == "Let me think about this.\n\nI'll proceed now."
2880+
2881+
2882+
def test_parse_deepseek_no_tokens_returns_empty():
2883+
"""Text without DeepSeek tokens returns no tool calls and None remainder."""
2884+
text = "Just a regular response, no special tokens here."
2885+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2886+
assert tool_calls == []
2887+
assert remainder is None
2888+
2889+
2890+
def test_parse_tool_calls_from_text_handles_deepseek_format():
2891+
"""Integration: the generic parser delegates to the DeepSeek parser."""
2892+
text = _ds_wrapped(
2893+
_ds_tool_call("fetch_page", '{"url": "https://example.com"}')
2894+
)
2895+
tool_calls, remainder = _parse_tool_calls_from_text(text)
2896+
assert len(tool_calls) == 1
2897+
assert tool_calls[0].function.name == "fetch_page"
2898+
assert json.loads(tool_calls[0].function.arguments) == {
2899+
"url": "https://example.com"
2900+
}
2901+
assert remainder is None
2902+
2903+
2904+
def test_parse_tool_calls_from_text_mixed_formats():
2905+
"""DeepSeek tokens + standard inline JSON in the same text."""
2906+
ds_part = _ds_wrapped(_ds_tool_call("ds_func", '{"a": 1}'))
2907+
standard_part = '{"name": "std_func", "arguments": {"b": 2}}'
2908+
text = ds_part + " some text " + standard_part
2909+
tool_calls, remainder = _parse_tool_calls_from_text(text)
2910+
assert len(tool_calls) == 2
2911+
assert tool_calls[0].function.name == "ds_func"
2912+
assert tool_calls[1].function.name == "std_func"
2913+
assert remainder == "some text"
2914+
2915+
2916+
def test_parse_deepseek_empty_text():
2917+
"""Empty or whitespace-only text returns no tool calls."""
2918+
for text in ("", " ", "\n\n"):
2919+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2920+
assert tool_calls == []
2921+
assert remainder is None
2922+
2923+
28002924
def test_split_message_content_and_tool_calls_inline_text():
28012925
message = {
28022926
"role": "assistant",

0 commit comments

Comments
 (0)