Fix spaced DSML variant parsing and reasoning stream filtering.

Support < | | DSML | | ...> markup from vLLM gateways, filter DSML in reasoning_content during streaming, and fix invoke parse false negatives.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-27 16:25:58 +08:00
parent 97d5c1744a
commit 43f6a792eb
3 changed files with 121 additions and 15 deletions

View file

@ -53,6 +53,24 @@ def _dsml_close_tokens() -> list[str]:
return tokens
_RE_SPACED_DSML_OPEN = re.compile(
rf"<\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+",
flags=re.IGNORECASE,
)
_RE_SPACED_DSML_CLOSE = re.compile(
rf"</\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+",
flags=re.IGNORECASE,
)
_RE_DSML_BLOCK_OPEN = re.compile(
rf"<\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+"
rf"(?:{'|'.join(_DSML_WRAPPER_KINDS)})\s*>",
flags=re.IGNORECASE,
)
_RE_DSML_BLOCK_CLOSE = re.compile(
rf"</\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+"
rf"(?:{'|'.join(_DSML_WRAPPER_KINDS)})\s*>",
flags=re.IGNORECASE,
)
_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)
@ -64,9 +82,25 @@ def normalize_dsml_markup(text: str) -> str:
s = str(text or "")
s = s.replace("<||DSML||", f"<{_DSML_PIPE}DSML{_DSML_PIPE}")
s = s.replace("</||DSML||", f"</{_DSML_PIPE}DSML{_DSML_PIPE}")
s = _RE_SPACED_DSML_OPEN.sub(f"<{_DSML_PIPE}DSML{_DSML_PIPE}", s)
s = _RE_SPACED_DSML_CLOSE.sub(f"</{_DSML_PIPE}DSML{_DSML_PIPE}", s)
return s
def _find_earliest_block_open(text: str) -> tuple[int, int] | None:
m = _RE_DSML_BLOCK_OPEN.search(text)
if not m:
return None
return int(m.start()), int(m.end())
def _find_earliest_block_close(text: str) -> tuple[int, int] | None:
m = _RE_DSML_BLOCK_CLOSE.search(text)
if not m:
return None
return int(m.start()), int(m.end())
def _find_earliest_token(text: str, tokens: list[str]) -> tuple[int, str] | None:
best: tuple[int, str] | None = None
for token in tokens:
@ -183,6 +217,8 @@ def _parse_invokes(inner: str) -> list[tuple[str, dict[str, Any]]] | None:
while pos < len(inner):
m = _RE_INVOKE_OPEN.search(inner, pos)
if not m:
if not out and re.search(r"invoke\s+name\s*=", inner[pos:], flags=re.IGNORECASE):
return None
break
name = str(m.group(1) or "").strip()
sub_start = int(m.end())
@ -252,6 +288,8 @@ def try_parse_dsml_tool_calls_from_fields(
parsed = try_parse_deepseek_v4_dsml_tool_calls(text)
if parsed is None:
continue
if not parsed and re.search(r"invoke\s+name\s*=", text, flags=re.IGNORECASE):
continue
stripped = strip_first_dsml_tool_calls_block(text)
clean = stripped if stripped is not None else ""
if field_name == "content":
@ -315,9 +353,18 @@ class DeepSeekTextFilter:
while self._buffer:
if self._inside_dsml:
close = _find_earliest_token(self._buffer, _DSML_CLOSE_TOKENS)
close = _find_earliest_block_close(self._buffer)
if close:
idx, token = close
idx, end = close
self._dsml_capture += self._buffer[:idx]
self._captured_blocks.append(self._dsml_capture)
self._dsml_capture = ""
self._buffer = self._buffer[end:]
self._inside_dsml = False
continue
legacy_close = _find_earliest_token(self._buffer, _DSML_CLOSE_TOKENS)
if legacy_close:
idx, token = legacy_close
self._dsml_capture += self._buffer[:idx]
self._captured_blocks.append(self._dsml_capture)
self._dsml_capture = ""
@ -334,9 +381,18 @@ class DeepSeekTextFilter:
self._inside_dsml = False
return output
open_match = _find_earliest_token(self._buffer, _DSML_OPEN_TOKENS)
open_match = _find_earliest_block_open(self._buffer)
if open_match:
idx, token = open_match
idx, end = open_match
emit(self._buffer[:idx])
self._buffer = self._buffer[end:]
self._inside_dsml = True
self._dsml_capture = ""
continue
legacy_open = _find_earliest_token(self._buffer, _DSML_OPEN_TOKENS)
if legacy_open:
idx, token = legacy_open
emit(self._buffer[:idx])
self._buffer = self._buffer[idx + len(token) :]
self._inside_dsml = True

View file

@ -707,13 +707,14 @@ class OpenAIChatModel(ChatModel):
return self._llm_response_from_completion(completion, on_token=on_token)
tool_acc: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
recover_dsml = _should_recover_dsml_tool_calls(
self.model,
self.base_url,
thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False)),
)
dsml_filter = DeepSeekTextFilter() if recover_dsml else None
content_visible_parts: list[str] = []
reasoning_visible_parts: list[str] = []
def _emit_visible_text(parts: list[str]) -> None:
if not on_token:
@ -723,16 +724,21 @@ class OpenAIChatModel(ChatModel):
on_token(part)
def _consume_stream(stream_obj: Any) -> None:
nonlocal tool_acc, reasoning_parts
nonlocal tool_acc
for chunk in stream_obj:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
rc = getattr(delta, "reasoning_content", None) or ""
if rc:
reasoning_parts.append(rc)
if on_token:
on_token(rc)
if dsml_filter is not None:
parts = dsml_filter.push(rc)
reasoning_visible_parts.extend(parts)
_emit_visible_text(parts)
else:
reasoning_visible_parts.append(rc)
if on_token:
on_token(rc)
if delta.tool_calls:
for tc in delta.tool_calls:
idx = int(tc.index)
@ -753,8 +759,11 @@ class OpenAIChatModel(ChatModel):
slot["thought_signature"] = sig
if delta.content:
if dsml_filter is not None:
_emit_visible_text(dsml_filter.push(delta.content))
parts = dsml_filter.push(delta.content)
content_visible_parts.extend(parts)
_emit_visible_text(parts)
else:
content_visible_parts.append(delta.content)
if on_token:
on_token(delta.content)
@ -764,12 +773,15 @@ class OpenAIChatModel(ChatModel):
raise
if dsml_filter is not None:
_emit_visible_text(dsml_filter.flush())
content = dsml_filter.visible_text
for part in dsml_filter.flush():
content_visible_parts.append(part)
if on_token and part:
on_token(part)
content = "".join(content_visible_parts)
reasoning_text = "".join(reasoning_visible_parts).strip()
else:
content = ""
reasoning_text = "".join(reasoning_parts).strip()
content = "".join(content_visible_parts)
reasoning_text = "".join(reasoning_visible_parts).strip()
tool_calls: list[LLMToolCall] = []
for idx in sorted(tool_acc.keys()):

View file

@ -67,6 +67,44 @@ def test_malformed_returns_none() -> None:
assert try_parse_deepseek_v4_dsml_tool_calls("<||DSML||tool_calls>broken") is None
def test_parse_spaced_pipe_variant_from_screenshot() -> None:
text = (
"< | | DSML | | tool_calls>\n"
'< | | DSML | | invoke name="read_file">\n'
'< | | DSML | | parameter name="path" string="true">_local/x.json</ | | DSML | | parameter>\n'
"</ | | DSML | | invoke>\n"
'< | | DSML | | invoke name="grep">\n'
'< | | DSML | | parameter name="pattern" string="true">foo</ | | DSML | | parameter>\n'
"</ | | DSML | | invoke>\n"
"</ | | DSML | | tool_calls>"
)
calls = try_parse_deepseek_v4_dsml_tool_calls(text)
assert calls is not None and len(calls) == 2
assert calls[0].name == "read_file"
assert calls[0].arguments == {"path": "_local/x.json"}
assert calls[1].name == "grep"
def test_stream_filter_spaced_pipe_variant() -> None:
text = (
"prefix\n"
"< | | DSML | | tool_calls>\n"
'< | | DSML | | invoke name="run_command"></ | | DSML | | invoke>\n'
"</ | | DSML | | tool_calls>"
)
filt = DeepSeekTextFilter()
visible = ""
for part in filt.push(text):
visible += part
for part in filt.flush():
visible += part
assert "prefix" in visible
assert "DSML" not in visible
recovered = filt.recovered_tool_calls()
assert len(recovered) == 1
assert recovered[0].name == "run_command"
def test_parse_function_calls_wrapper_v32() -> None:
text = (
"<||DSML||function_calls>\n"