Skip to content

Commit c5b2caa

Browse files
fuchun1010GWeale
authored andcommitted
fix(litellm): parse DeepSeek-V3 proprietary inline tool-call tokens
Merge #5654 Closes #5024 ## Problem DeepSeek-V3 emits tool calls using proprietary special tokens embedded in the content field: ``` <|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>analysis_input ```json {"work_dir_name":"..."} ```<|tool▁call▁end|><|tool▁calls▁end|> ``` When LiteLLM does not translate these into structured `tool_calls` (intermittent), ADK's fallback JSON parser finds the JSON object but rejects it because the function name (`analysis_input`) is embedded in the tokens (`<|tool▁sep|>analysis_input`) rather than as a `name` key inside the JSON payload. **Result:** tool call is silently dropped and the raw tokens appear as text content. ## Solution - Added `_parse_deepseek_tool_calls_from_text` — detects the proprietary token format, extracts function name + arguments, and emits standard `ChatCompletionMessageToolCall` objects - Added `_extract_json_from_deepseek_args` helper — handles optional Markdown code fences (```` ```json ``` ````) around the arguments payload - Integrated into the existing `_parse_tool_calls_from_text` as the first-pass parser, with fallback to generic inline JSON parsing - Supports: single tool calls, multi-tool calls, code-fenced JSON, bare JSON, surrounding text, mixed formats ## Testing Plan **Unit Tests:** Added 8 new tests covering: - Single tool call with code-fenced JSON args - Multiple tool calls in a single wrapped block - Bare JSON args (no code fences) - Tool call embedded in surrounding text - Text without DeepSeek tokens (no false positives) - Empty/whitespace-only text - Integration test via `_parse_tool_calls_from_text` - Mixed formats (DeepSeek tokens + standard inline JSON) **Regression:** Full `test_litellm.py`: **264 passed, 0 failed** ## Files Changed | File | Changes | |------|--------| | `src/google/adk/models/lite_llm.py` | +147 lines (2 new functions + integration) | | `tests/unittests/models/test_litellm.py` | +124 lines (8 new test functions) | Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#5654 from fuchun1010:fix/deepseek-tool-call-parsing 44ca518 PiperOrigin-RevId: 940608071
1 parent 51e2f52 commit c5b2caa

2 files changed

Lines changed: 298 additions & 0 deletions

File tree

src/google/adk/models/lite_llm.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,6 +1494,152 @@ def _build_tool_call_from_json_dict(
14941494
return tool_call
14951495

14961496

1497+
# DeepSeek models may emit tool calls as inline text using proprietary
1498+
# special tokens. See https://api-docs.deepseek.com/guides/function_calling
1499+
# for the full specification. LiteLLM usually translates these into
1500+
# structured `tool_calls` but when it doesn't (intermittent), the raw
1501+
# tokens land in the `content` field and must be parsed here.
1502+
_DS_TCALLS_BEGIN = "\u003c\uff5ctool\u2581calls\u2581begin\uff5c\u003e"
1503+
_DS_TCALLS_END = "\u003c\uff5ctool\u2581calls\u2581end\uff5c\u003e"
1504+
_DS_TCALL_BEGIN = "\u003c\uff5ctool\u2581call\u2581begin\uff5c\u003e"
1505+
_DS_TCALL_END = "\u003c\uff5ctool\u2581call\u2581end\uff5c\u003e"
1506+
_DS_TSEP = "\u003c\uff5ctool\u2581sep\uff5c\u003e"
1507+
1508+
# Pattern: <|tool▁call▁begin|>function<|tool▁sep|>NAME \n ARGS <|tool▁call▁end|>
1509+
_DS_TOOL_CALL_RE = re.compile(
1510+
re.escape(_DS_TCALL_BEGIN)
1511+
+ r"function"
1512+
+ re.escape(_DS_TSEP)
1513+
+ r"([^\n\r]+?)\s*?\n(.*?)"
1514+
+ re.escape(_DS_TCALL_END),
1515+
re.DOTALL,
1516+
)
1517+
1518+
1519+
def _extract_json_from_deepseek_args(args_text: str) -> Optional[str]:
1520+
"""Extracts a JSON string from DeepSeek arguments text.
1521+
1522+
Args:
1523+
args_text: Raw text containing the function arguments, possibly
1524+
wrapped in Markdown-style code fences.
1525+
1526+
Returns:
1527+
The JSON string, or None if no valid JSON object could be found.
1528+
"""
1529+
if not args_text:
1530+
return None
1531+
# Strip optional Markdown code fences (```json ... ``` or ``` ... ```).
1532+
fence_match = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", args_text)
1533+
if fence_match:
1534+
candidate = fence_match.group(1).strip()
1535+
try:
1536+
json.loads(candidate)
1537+
return candidate
1538+
except json.JSONDecodeError:
1539+
pass
1540+
# Fall back to the first balanced { … } block.
1541+
open_brace = args_text.find("{")
1542+
if open_brace == -1:
1543+
return None
1544+
try:
1545+
candidate, _ = _JSON_DECODER.raw_decode(args_text, open_brace)
1546+
return json.dumps(candidate, ensure_ascii=False)
1547+
except json.JSONDecodeError:
1548+
return None
1549+
1550+
1551+
def _parse_deepseek_tool_calls_from_text(
1552+
text_block: str,
1553+
) -> tuple[list[ChatCompletionMessageToolCall], Optional[str]]:
1554+
"""Parses DeepSeek proprietary inline tool-call tokens from text.
1555+
1556+
When LiteLLM does not translate DeepSeek's special tokens into
1557+
structured ``tool_calls``, the raw tokens appear inside the ``content``
1558+
field. This function extracts them and returns standard
1559+
``ChatCompletionMessageToolCall`` objects.
1560+
1561+
Token reference
1562+
``<|tool▁calls▁begin|>`` … ``<|tool▁calls▁end|>`` → outer wrapper
1563+
``<|tool▁call▁begin|>function<|tool▁sep|>NAME`` → single call start
1564+
``<|tool▁call▁end|>`` → single call end
1565+
1566+
Args:
1567+
text_block: The raw text that may contain DeepSeek tokens.
1568+
1569+
Returns:
1570+
A tuple of ``(tool_calls, remainder)`` where ``remainder`` is the
1571+
original text with all DeepSeek token regions removed.
1572+
"""
1573+
_ensure_litellm_imported()
1574+
1575+
tool_calls: list[ChatCompletionMessageToolCall] = []
1576+
if not text_block:
1577+
return tool_calls, None
1578+
1579+
# Quick guard: only invoke the regex if the outer tokens are present.
1580+
if _DS_TCALLS_BEGIN not in text_block and _DS_TCALL_BEGIN not in text_block:
1581+
return tool_calls, None
1582+
1583+
remainder_parts: list[str] = []
1584+
cursor = 0
1585+
1586+
# Outer loop — wrapped <|tool▁calls▁begin|> blocks and unwrapped
1587+
# <|tool▁call▁begin|> tokens may interleave, so process whichever
1588+
# token appears first.
1589+
while True:
1590+
calls_idx = text_block.find(_DS_TCALLS_BEGIN, cursor)
1591+
call_idx = text_block.find(_DS_TCALL_BEGIN, cursor)
1592+
if calls_idx == -1 and call_idx == -1:
1593+
remainder_parts.append(text_block[cursor:])
1594+
break
1595+
1596+
if calls_idx != -1 and (call_idx == -1 or calls_idx < call_idx):
1597+
begin_idx = calls_idx
1598+
in_wrapped_block = True
1599+
else:
1600+
begin_idx = call_idx
1601+
in_wrapped_block = False
1602+
1603+
# Everything before the token becomes remainder.
1604+
if begin_idx > cursor:
1605+
remainder_parts.append(text_block[cursor:begin_idx])
1606+
1607+
if in_wrapped_block:
1608+
end_idx = text_block.find(
1609+
_DS_TCALLS_END, begin_idx + len(_DS_TCALLS_BEGIN)
1610+
)
1611+
if end_idx == -1:
1612+
remainder_parts.append(text_block[begin_idx:])
1613+
break
1614+
block = text_block[begin_idx + len(_DS_TCALLS_BEGIN) : end_idx]
1615+
cursor = end_idx + len(_DS_TCALLS_END)
1616+
else:
1617+
# Unwrapped call token — scan for a matching end token.
1618+
end_idx = text_block.find(_DS_TCALL_END, begin_idx + len(_DS_TCALL_BEGIN))
1619+
if end_idx == -1:
1620+
remainder_parts.append(text_block[begin_idx:])
1621+
break
1622+
block = text_block[begin_idx : end_idx + len(_DS_TCALL_END)]
1623+
cursor = end_idx + len(_DS_TCALL_END)
1624+
1625+
# Parse individual tool calls inside the block.
1626+
for match in _DS_TOOL_CALL_RE.finditer(block):
1627+
func_name = match.group(1).strip()
1628+
args_raw = match.group(2).strip()
1629+
args_json = _extract_json_from_deepseek_args(args_raw)
1630+
if not func_name or not args_json:
1631+
continue
1632+
tool_call = _build_tool_call_from_json_dict(
1633+
{"name": func_name, "arguments": args_json},
1634+
index=len(tool_calls),
1635+
)
1636+
if tool_call:
1637+
tool_calls.append(tool_call)
1638+
1639+
remainder = "".join(p for p in remainder_parts if p).strip()
1640+
return tool_calls, remainder or None
1641+
1642+
14971643
def _parse_tool_calls_from_text(
14981644
text_block: str,
14991645
) -> tuple[list[ChatCompletionMessageToolCall], Optional[str]]:
@@ -1504,6 +1650,17 @@ def _parse_tool_calls_from_text(
15041650

15051651
_ensure_litellm_imported()
15061652

1653+
# Try DeepSeek proprietary format first, then fall back to generic JSON.
1654+
ds_tool_calls, ds_remainder = _parse_deepseek_tool_calls_from_text(text_block)
1655+
if ds_tool_calls:
1656+
# If the remainder still contains content, re-parse it for
1657+
# additional generic inline JSON tool calls (mixed formats).
1658+
if ds_remainder:
1659+
extra_calls, extra_remainder = _parse_tool_calls_from_text(ds_remainder)
1660+
tool_calls = ds_tool_calls + (extra_calls or [])
1661+
return tool_calls, extra_remainder
1662+
return ds_tool_calls, None
1663+
15071664
remainder_segments = []
15081665
cursor = 0
15091666
text_length = len(text_block)

tests/unittests/models/test_litellm.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from google.adk.models.lite_llm import _content_to_message_param
3232
from google.adk.models.lite_llm import _convert_reasoning_value_to_parts
3333
from google.adk.models.lite_llm import _enforce_strict_openai_schema
34+
from google.adk.models.lite_llm import _extract_json_from_deepseek_args
3435
from google.adk.models.lite_llm import _extract_reasoning_value
3536
from google.adk.models.lite_llm import _extract_thought_signature_from_tool_call
3637
from google.adk.models.lite_llm import _FILE_ID_REQUIRED_PROVIDERS
@@ -47,6 +48,7 @@
4748
from google.adk.models.lite_llm import _MISSING_TOOL_RESULT_MESSAGE
4849
from google.adk.models.lite_llm import _model_response_to_chunk
4950
from google.adk.models.lite_llm import _model_response_to_generate_content_response
51+
from google.adk.models.lite_llm import _parse_deepseek_tool_calls_from_text
5052
from google.adk.models.lite_llm import _parse_tool_calls_from_text
5153
from google.adk.models.lite_llm import _redact_file_uri_for_log
5254
from google.adk.models.lite_llm import _redirect_litellm_loggers_to_stdout
@@ -2911,6 +2913,145 @@ def test_parse_tool_calls_from_text_invalid_json_returns_remainder():
29112913
assert remainder == 'Leading {"unused": "payload"} trailing text'
29122914

29132915

2916+
# ---------------------------------------------------------------------------
2917+
# DeepSeek proprietary inline tool-call format tests
2918+
# ---------------------------------------------------------------------------
2919+
2920+
_DS_BEGIN_CALLS = "\u003c\uff5ctool\u2581calls\u2581begin\uff5c\u003e"
2921+
_DS_END_CALLS = "\u003c\uff5ctool\u2581calls\u2581end\uff5c\u003e"
2922+
_DS_BEGIN_CALL = "\u003c\uff5ctool\u2581call\u2581begin\uff5c\u003e"
2923+
_DS_END_CALL = "\u003c\uff5ctool\u2581call\u2581end\uff5c\u003e"
2924+
_DS_SEP = "\u003c\uff5ctool\u2581sep\uff5c\u003e"
2925+
2926+
2927+
def _ds_tool_call(name: str, args_json: str) -> str:
2928+
"""Build a single DeepSeek-style tool-call block."""
2929+
return (
2930+
f"{_DS_BEGIN_CALL}function{_DS_SEP}{name}\n"
2931+
f"```json\n{args_json}\n```"
2932+
f"{_DS_END_CALL}"
2933+
)
2934+
2935+
2936+
def _ds_wrapped(inner: str) -> str:
2937+
"""Wrap content in <|tool▁calls▁begin|>...<|tool▁calls▁end|>."""
2938+
return f"{_DS_BEGIN_CALLS}{inner}{_DS_END_CALLS}"
2939+
2940+
2941+
def test_parse_deepseek_single_tool_call():
2942+
"""Single DeepSeek tool call with code-fenced JSON args."""
2943+
text = _ds_wrapped(
2944+
_ds_tool_call("get_weather", '{"city": "Beijing", "unit": "celsius"}')
2945+
)
2946+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2947+
assert len(tool_calls) == 1
2948+
assert tool_calls[0].function.name == "get_weather"
2949+
assert json.loads(tool_calls[0].function.arguments) == {
2950+
"city": "Beijing",
2951+
"unit": "celsius",
2952+
}
2953+
assert remainder is None
2954+
2955+
2956+
def test_parse_deepseek_multi_tool_call():
2957+
"""Multiple DeepSeek tool calls in a single wrapped block."""
2958+
inner = _ds_tool_call("func_a", '{"x": 1}') + _ds_tool_call(
2959+
"func_b", '{"y": 2}'
2960+
)
2961+
text = _ds_wrapped(inner)
2962+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2963+
assert len(tool_calls) == 2
2964+
assert tool_calls[0].function.name == "func_a"
2965+
assert json.loads(tool_calls[0].function.arguments) == {"x": 1}
2966+
assert tool_calls[1].function.name == "func_b"
2967+
assert json.loads(tool_calls[1].function.arguments) == {"y": 2}
2968+
assert remainder is None
2969+
2970+
2971+
def test_parse_deepseek_plain_json_args():
2972+
"""DeepSeek tool call without Markdown code fences around args."""
2973+
inner = (
2974+
f"{_DS_BEGIN_CALL}function{_DS_SEP}search\n"
2975+
f'{{"query": "天气"}}'
2976+
f"{_DS_END_CALL}"
2977+
)
2978+
text = _ds_wrapped(inner)
2979+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2980+
assert len(tool_calls) == 1
2981+
assert tool_calls[0].function.name == "search"
2982+
assert json.loads(tool_calls[0].function.arguments) == {"query": "天气"}
2983+
2984+
2985+
def test_parse_deepseek_with_surrounding_text():
2986+
"""DeepSeek tool call embedded in surrounding non-tool text."""
2987+
prefix = "Let me think about this.\n"
2988+
suffix = "\nI'll proceed now."
2989+
inner = _ds_tool_call("calculate", '{"expr": "2+2"}')
2990+
text = prefix + _ds_wrapped(inner) + suffix
2991+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
2992+
assert len(tool_calls) == 1
2993+
assert tool_calls[0].function.name == "calculate"
2994+
assert remainder == "Let me think about this.\n\nI'll proceed now."
2995+
2996+
2997+
def test_parse_deepseek_no_tokens_returns_empty():
2998+
"""Text without DeepSeek tokens returns no tool calls and None remainder."""
2999+
text = "Just a regular response, no special tokens here."
3000+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
3001+
assert tool_calls == []
3002+
assert remainder is None
3003+
3004+
3005+
def test_parse_tool_calls_from_text_handles_deepseek_format():
3006+
"""Integration: the generic parser delegates to the DeepSeek parser."""
3007+
text = _ds_wrapped(
3008+
_ds_tool_call("fetch_page", '{"url": "https://example.com"}')
3009+
)
3010+
tool_calls, remainder = _parse_tool_calls_from_text(text)
3011+
assert len(tool_calls) == 1
3012+
assert tool_calls[0].function.name == "fetch_page"
3013+
assert json.loads(tool_calls[0].function.arguments) == {
3014+
"url": "https://example.com"
3015+
}
3016+
assert remainder is None
3017+
3018+
3019+
def test_parse_tool_calls_from_text_mixed_formats():
3020+
"""DeepSeek tokens + standard inline JSON in the same text."""
3021+
ds_part = _ds_wrapped(_ds_tool_call("ds_func", '{"a": 1}'))
3022+
standard_part = '{"name": "std_func", "arguments": {"b": 2}}'
3023+
text = ds_part + " some text " + standard_part
3024+
tool_calls, remainder = _parse_tool_calls_from_text(text)
3025+
assert len(tool_calls) == 2
3026+
assert tool_calls[0].function.name == "ds_func"
3027+
assert tool_calls[1].function.name == "std_func"
3028+
assert remainder == "some text"
3029+
3030+
3031+
def test_parse_deepseek_empty_text():
3032+
"""Empty or whitespace-only text returns no tool calls."""
3033+
for text in ("", " ", "\n\n"):
3034+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(text)
3035+
assert tool_calls == []
3036+
assert remainder is None
3037+
3038+
3039+
def test_parse_deepseek_unwrapped_call_before_wrapped_block():
3040+
"""Unwrapped call preceding a wrapped block is not dropped."""
3041+
unwrapped = _ds_tool_call("first", '{"x": 1}')
3042+
wrapped = _ds_wrapped(_ds_tool_call("second", '{"y": 2}'))
3043+
tool_calls, remainder = _parse_deepseek_tool_calls_from_text(
3044+
unwrapped + wrapped
3045+
)
3046+
assert [tc.function.name for tc in tool_calls] == ["first", "second"]
3047+
assert remainder is None
3048+
3049+
3050+
def test_extract_json_from_deepseek_args_invalid_fence_returns_none():
3051+
"""Invalid JSON inside a code fence is rejected rather than returned."""
3052+
assert _extract_json_from_deepseek_args('```json\n{"a": 1,}\n```') is None
3053+
3054+
29143055
def test_split_message_content_and_tool_calls_inline_text():
29153056
message = {
29163057
"role": "assistant",

0 commit comments

Comments
 (0)