Add DeepSeek DSML-to-tool_calls conversion at transport and runtime layers.

Promote DSML markup from assistant text into native tool calls during streaming and completion, filter DSML from UI tokens, and document AIA_DSML_TEXT_TOOLS for local vLLM proxies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-27 15:39:11 +08:00
parent 6f754ee5ea
commit 97d5c1744a
7 changed files with 589 additions and 89 deletions

View file

@ -1,4 +1,4 @@
"""Parse DeepSeek-V4 DSML ``tool_calls`` blocks from assistant text.
"""Parse DeepSeek DSML tool-call blocks from assistant text.
Reference: `encoding/README.md` and `encoding/encoding_dsv4.py` in the upstream
`deepseek-ai/DeepSeek-V4-Pro` repository on Hugging Face (DSML grammar for
@ -11,41 +11,54 @@ separator (U+FF5C ``|``); we normalize those before parsing.
from __future__ import annotations
import json
import os
import re
import uuid
from typing import Any
from svc.llm.transports.base import LLMToolCall
# Official DeepSeek-V4 DSML token uses FULLWIDTH VERTICAL LINE (U+FF5C).
# Official DeepSeek DSML token uses FULLWIDTH VERTICAL LINE (U+FF5C).
_DSML_PIPE = "\uFF5C"
_DSML_BARS = ("|", _DSML_PIPE)
_DSML_WRAPPER_KINDS = ("tool_calls", "function_calls", "tool_call", "function_call")
_RE_TOOL_CALLS_OPEN = re.compile(
rf"<\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*tool_calls\s*>",
flags=re.IGNORECASE,
)
_RE_TOOL_CALLS_CLOSE = re.compile(
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*tool_calls\s*>",
flags=re.IGNORECASE,
)
_RE_INVOKE_OPEN = re.compile(
rf"<\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*invoke\s+name\s*=\s*\"([^\"]+)\"\s*>",
rf"<\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*DSML\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*invoke\s+name\s*=\s*\"([^\"]+)\"\s*>",
flags=re.IGNORECASE,
)
_RE_INVOKE_CLOSE = re.compile(
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*invoke\s*>",
rf"</\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*DSML\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*invoke\s*>",
flags=re.IGNORECASE,
)
_RE_PARAM_OPEN = re.compile(
rf"<\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*parameter\s+name\s*=\s*\"([^\"]+)\"\s+string\s*=\s*\"(true|false)\"\s*>",
rf"<\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*DSML\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*parameter\s+name\s*=\s*\"([^\"]+)\"\s+string\s*=\s*\"(true|false)\"\s*>",
flags=re.IGNORECASE,
)
_RE_PARAM_CLOSE = re.compile(
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*parameter\s*>",
rf"</\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*DSML\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*parameter\s*>",
flags=re.IGNORECASE,
)
def _dsml_open_tokens() -> list[str]:
tokens = [f"<{bar}DSML{bar}{kind}>" for bar in _DSML_BARS for kind in _DSML_WRAPPER_KINDS]
tokens.extend(f"<||DSML||{kind}>" for kind in _DSML_WRAPPER_KINDS)
return tokens
def _dsml_close_tokens() -> list[str]:
tokens = [f"</{bar}DSML{bar}{kind}>" for bar in _DSML_BARS for kind in _DSML_WRAPPER_KINDS]
tokens.extend(f"</||DSML||{kind}>" for kind in _DSML_WRAPPER_KINDS)
return tokens
_DSML_OPEN_TOKENS = _dsml_open_tokens()
_DSML_CLOSE_TOKENS = _dsml_close_tokens()
_MAX_OPEN_TOKEN_LEN = max(len(t) for t in _DSML_OPEN_TOKENS)
_MAX_CLOSE_TOKEN_LEN = max(len(t) for t in _DSML_CLOSE_TOKENS)
def normalize_dsml_markup(text: str) -> str:
"""Map common gateway variants to the canonical DSML delimiter sequence."""
s = str(text or "")
@ -54,21 +67,51 @@ def normalize_dsml_markup(text: str) -> str:
return s
def _find_earliest_token(text: str, tokens: list[str]) -> tuple[int, str] | None:
best: tuple[int, str] | None = None
for token in tokens:
idx = text.find(token)
if idx != -1 and (best is None or idx < best[0]):
best = (idx, token)
return best
def _longest_dsml_open_prefix_suffix_length(text: str) -> int:
max_len = min(len(text), _MAX_OPEN_TOKEN_LEN - 1)
for length in range(max_len, 0, -1):
suffix = text[-length:]
if any(token.startswith(suffix) for token in _DSML_OPEN_TOKENS):
return length
return 0
def _wrapper_kind_regex(kind: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
bars = rf"[{''.join(re.escape(b) for b in _DSML_BARS)}]"
open_re = re.compile(rf"<\s*{bars}\s*DSML\s*{bars}\s*{re.escape(kind)}\s*>", flags=re.IGNORECASE)
close_re = re.compile(rf"</\s*{bars}\s*DSML\s*{bars}\s*{re.escape(kind)}\s*>", flags=re.IGNORECASE)
return open_re, close_re
def _find_tool_calls_block_span(normalized: str) -> tuple[int, int] | None:
m_open = _RE_TOOL_CALLS_OPEN.search(normalized)
if not m_open:
return None
start = int(m_open.start())
from_pos = int(m_open.end())
m_close = _RE_TOOL_CALLS_CLOSE.search(normalized, from_pos)
if not m_close:
return None
end = int(m_close.end())
return (start, end)
best: tuple[int, int] | None = None
for kind in _DSML_WRAPPER_KINDS:
open_re, close_re = _wrapper_kind_regex(kind)
m_open = open_re.search(normalized)
if not m_open:
continue
start = int(m_open.start())
from_pos = int(m_open.end())
m_close = close_re.search(normalized, from_pos)
if not m_close:
continue
end = int(m_close.end())
if best is None or start < best[0]:
best = (start, end)
return best
def strip_first_dsml_tool_calls_block(text: str) -> str | None:
"""Remove the first well-formed ``tool_calls`` DSML block; return None if none found."""
"""Remove the first well-formed DSML wrapper block; return None if none found."""
raw = str(text or "")
if not raw:
return None
@ -94,6 +137,19 @@ def _decode_param_value(raw_value: str, *, string_flag: str) -> Any:
return v_strip
def _parse_invoke_body_json(body: str) -> dict[str, Any] | None:
stripped = str(body or "").strip()
if not stripped or not stripped.startswith("{"):
return None
try:
parsed = json.loads(stripped)
except Exception:
return None
if not isinstance(parsed, dict):
return None
return dict(parsed)
def _parse_invoke_body(body: str) -> dict[str, Any] | None:
args: dict[str, Any] = {}
pos = 0
@ -113,6 +169,11 @@ def _parse_invoke_body(body: str) -> dict[str, Any] | None:
return None
args[pname] = _decode_param_value(raw_val, string_flag=sflag)
pos = int(cm.end())
if args:
return args
json_args = _parse_invoke_body_json(b)
if json_args is not None:
return json_args
return args
@ -137,25 +198,7 @@ def _parse_invokes(inner: str) -> list[tuple[str, dict[str, Any]]] | None:
return out
def try_parse_deepseek_v4_dsml_tool_calls(text: str) -> list[LLMToolCall] | None:
"""
If ``text`` contains a complete first ``tool_calls`` DSML block, return
``LLMToolCall`` rows (may be empty if the block has no ``invoke`` tags).
Returns ``None`` when no block is found or the block is malformed.
"""
raw = str(text or "")
if not raw.strip():
return None
norm = normalize_dsml_markup(raw)
span = _find_tool_calls_block_span(norm)
if span is None:
return None
a, b = span
inner = norm[a:b]
invokes = _parse_invokes(inner)
if invokes is None:
return None
def _invokes_to_llm_tool_calls(invokes: list[tuple[str, dict[str, Any]]]) -> list[LLMToolCall]:
out: list[LLMToolCall] = []
for name, args in invokes:
out.append(
@ -169,8 +212,158 @@ def try_parse_deepseek_v4_dsml_tool_calls(text: str) -> list[LLMToolCall] | None
return out
def _parse_dsml_block_inner(inner: str) -> list[LLMToolCall] | None:
invokes = _parse_invokes(normalize_dsml_markup(inner))
if invokes is None:
return None
return _invokes_to_llm_tool_calls(invokes)
def try_parse_deepseek_v4_dsml_tool_calls(text: str) -> list[LLMToolCall] | None:
"""
If ``text`` contains a complete first DSML wrapper block, return
``LLMToolCall`` rows (may be empty if the block has no ``invoke`` tags).
Returns ``None`` when no block is found or the block is malformed.
"""
raw = str(text or "")
if not raw.strip():
return None
norm = normalize_dsml_markup(raw)
span = _find_tool_calls_block_span(norm)
if span is None:
return None
a, b = span
inner = norm[a:b]
return _parse_dsml_block_inner(inner)
def try_parse_dsml_tool_calls_from_fields(
*,
content: str = "",
reasoning_content: str = "",
) -> tuple[list[LLMToolCall] | None, str, str]:
"""Search ``content`` then ``reasoning_content`` for DSML tool calls.
Returns ``(calls, cleaned_content, cleaned_reasoning)``. When parsing succeeds,
the field that contained the block is stripped; the other field is unchanged.
"""
for field_name, text in (("content", str(content or "")), ("reasoning_content", str(reasoning_content or ""))):
parsed = try_parse_deepseek_v4_dsml_tool_calls(text)
if parsed is None:
continue
stripped = strip_first_dsml_tool_calls_block(text)
clean = stripped if stripped is not None else ""
if field_name == "content":
return parsed, clean, str(reasoning_content or "")
return parsed, str(content or ""), clean
return None, str(content or ""), str(reasoning_content or "")
def dsml_text_tools_enabled(*, base_url: str = "", model_id: str = "") -> bool:
"""Whether DSML-in-text should be promoted to native tool calls."""
env = str(os.getenv("AIA_DSML_TEXT_TOOLS") or "").strip().lower()
if env in {"0", "false", "no", "off"}:
return False
if env in {"1", "true", "yes", "on"}:
return True
bu = str(base_url or "").strip().lower()
mid = str(model_id or "").strip().lower()
if "deepseek" in bu or "deepseek" in mid:
return True
if mid.startswith("deepseek-"):
return True
return False
class DeepSeekTextFilter:
"""Stream filter: hide DSML from visible text and capture blocks for recovery."""
def __init__(self) -> None:
self._buffer = ""
self._inside_dsml = False
self._dsml_capture = ""
self._captured_blocks: list[str] = []
self._visible_parts: list[str] = []
def push(self, chunk: str) -> list[str]:
self._buffer += str(chunk or "")
return self._consume(final=False)
def flush(self) -> list[str]:
return self._consume(final=True)
@property
def visible_text(self) -> str:
return "".join(self._visible_parts)
def recovered_tool_calls(self) -> list[LLMToolCall]:
out: list[LLMToolCall] = []
for block in self._captured_blocks:
parsed = _parse_dsml_block_inner(block)
if parsed:
out.extend(parsed)
return out
def _consume(self, *, final: bool) -> list[str]:
output: list[str] = []
def emit(text: str) -> None:
if text:
output.append(text)
self._visible_parts.append(text)
while self._buffer:
if self._inside_dsml:
close = _find_earliest_token(self._buffer, _DSML_CLOSE_TOKENS)
if close:
idx, token = close
self._dsml_capture += self._buffer[:idx]
self._captured_blocks.append(self._dsml_capture)
self._dsml_capture = ""
self._buffer = self._buffer[idx + len(token) :]
self._inside_dsml = False
continue
keep = 0 if final else min(len(self._buffer), _MAX_CLOSE_TOKEN_LEN - 1)
self._dsml_capture += self._buffer[: len(self._buffer) - keep]
self._buffer = self._buffer[len(self._buffer) - keep :]
if final:
if self._dsml_capture:
self._captured_blocks.append(self._dsml_capture)
self._dsml_capture = ""
self._inside_dsml = False
return output
open_match = _find_earliest_token(self._buffer, _DSML_OPEN_TOKENS)
if open_match:
idx, token = open_match
emit(self._buffer[:idx])
self._buffer = self._buffer[idx + len(token) :]
self._inside_dsml = True
self._dsml_capture = ""
continue
if final:
emit(self._buffer)
self._buffer = ""
return output
keep = _longest_dsml_open_prefix_suffix_length(self._buffer)
emit_len = len(self._buffer) - keep
if emit_len <= 0:
return output
emit(self._buffer[:emit_len])
self._buffer = self._buffer[emit_len:]
return output
return output
__all__ = [
"DeepSeekTextFilter",
"dsml_text_tools_enabled",
"normalize_dsml_markup",
"strip_first_dsml_tool_calls_block",
"try_parse_deepseek_v4_dsml_tool_calls",
"try_parse_dsml_tool_calls_from_fields",
]