Files

461 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Recover DeepSeek DSML tool calls leaked through assistant text.
Some intranet OpenAI-compatible gateways render DeepSeek V4/V4.1 prompts
correctly but do not run the matching output parser. The model consequently
returns its native DSML envelope in ``message.content`` and leaves
``message.tool_calls`` empty. This module is a deliberately opt-in fallback
that converts only complete, schema-advertised calls back to OpenAI tool-call
chunks.
The parser is fail-closed:
* only tools advertised on the current provider request are accepted;
* names are matched exactly (misspellings are never corrected);
* an incomplete/malformed DSML envelope raises instead of executing a partial
call or exposing the protocol as ordinary assistant text;
* ordinary text is streamed without buffering except for the short prefix
needed to detect a marker split across provider chunks.
"""
from __future__ import annotations
import json
import re
import uuid
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from langchain_core.messages import AIMessageChunk
from langchain_core.outputs import ChatGenerationChunk
class DSMLToolCallProtocolError(RuntimeError):
"""A configured DSML provider returned an unsafe or incomplete call."""
@dataclass(frozen=True, slots=True)
class RecoveredToolCall:
name: str
arguments: dict[str, Any]
call_id: str
@dataclass(frozen=True, slots=True)
class ParsedDSMLContent:
content: str
calls: tuple[RecoveredToolCall, ...]
found: bool
_DSML_TOKEN = r"(?:|DSML||\|DSML\|)"
_OUTER_NAMES = r"(?:tool_calls|toolcalls|function_calls|calls|tool)"
_START_RE = re.compile(
rf"<\s*{_DSML_TOKEN}\s*{_OUTER_NAMES}\s*>",
re.IGNORECASE,
)
_END_RE = re.compile(
rf"</\s*{_DSML_TOKEN}\s*{_OUTER_NAMES}\s*>",
re.IGNORECASE,
)
_ANY_DSML_RE = re.compile(rf"<\s*/?\s*{_DSML_TOKEN}", re.IGNORECASE)
_INVOKE_RE = re.compile(
rf"<\s*{_DSML_TOKEN}\s*invoke\s+name\s*=\s*"
rf"(?P<quote>[\"'])(?P<name>[^\"']+)(?P=quote)\s*>"
rf"(?P<body>.*?)"
rf"</\s*{_DSML_TOKEN}\s*invoke\s*>",
re.IGNORECASE | re.DOTALL,
)
_PARAM_RE = re.compile(
rf"<\s*{_DSML_TOKEN}\s*parameter\s+name\s*=\s*"
rf"(?P<quote>[\"'])(?P<name>[^\"']+)(?P=quote)"
rf"(?:\s+string\s*=\s*(?P<squote>[\"'])(?P<string>true|false)(?P=squote))?\s*>"
rf"(?P<value>.*?)"
rf"</\s*{_DSML_TOKEN}\s*parameter\s*>",
re.IGNORECASE | re.DOTALL,
)
def _marker_candidates() -> tuple[str, ...]:
names = ("tool_calls", "toolcalls", "function_calls", "calls", "tool")
tokens = ("|DSML|", "|DSML|")
return tuple(
f"<{token}{space}{name}>"
for token in tokens
for space in ("", " ")
for name in names
)
_START_CANDIDATES = _marker_candidates()
def _longest_possible_start_suffix(text: str) -> int:
"""Length of the suffix that may be a split DSML opening marker."""
max_len = min(len(text), max(map(len, _START_CANDIDATES)))
for size in range(max_len, 0, -1):
suffix = text[-size:]
if any(candidate.startswith(suffix) for candidate in _START_CANDIDATES):
return size
return 0
def _parse_parameter_value(raw: str, string_flag: str | None) -> Any:
if (string_flag or "").lower() == "true":
return raw
try:
return json.loads(raw)
except (TypeError, ValueError) as exc:
if string_flag is None:
# DeepSeek occasionally omits the flag for an otherwise ordinary
# string parameter. Match the provider parser's safe literal
# fallback, while keeping explicit string=false strict.
return raw
raise DSMLToolCallProtocolError(
"DeepSeek DSML contained invalid JSON for a non-string parameter"
) from exc
def _parse_invoke_body(body: str) -> dict[str, Any]:
params: dict[str, Any] = {}
spans: list[tuple[int, int]] = []
for match in _PARAM_RE.finditer(body):
name = match.group("name").strip()
if not name or name in params:
raise DSMLToolCallProtocolError(
"DeepSeek DSML contained an empty or duplicate parameter name"
)
params[name] = _parse_parameter_value(
match.group("value"),
match.group("string"),
)
spans.append(match.span())
if spans:
remainder_parts: list[str] = []
cursor = 0
for start, end in spans:
remainder_parts.append(body[cursor:start])
cursor = end
remainder_parts.append(body[cursor:])
if "".join(remainder_parts).strip():
raise DSMLToolCallProtocolError(
"DeepSeek DSML invoke mixed parameter tags with unexpected text"
)
return params
stripped = body.strip()
if not stripped:
return {}
try:
direct = json.loads(stripped)
except (TypeError, ValueError) as exc:
raise DSMLToolCallProtocolError(
"DeepSeek DSML invoke contained neither parameters nor a JSON object"
) from exc
if not isinstance(direct, dict):
raise DSMLToolCallProtocolError(
"DeepSeek DSML direct arguments must be a JSON object"
)
return direct
def parse_dsml_content(
text: str,
*,
allowed_tools: set[str],
) -> ParsedDSMLContent:
"""Parse complete V4/V4.1 DSML blocks and remove them from content."""
if not text or not _ANY_DSML_RE.search(text):
return ParsedDSMLContent(content=text, calls=(), found=False)
calls: list[RecoveredToolCall] = []
visible_parts: list[str] = []
cursor = 0
found = False
while True:
start = _START_RE.search(text, cursor)
if start is None:
visible_parts.append(text[cursor:])
break
found = True
visible_parts.append(text[cursor : start.start()])
end = _END_RE.search(text, start.end())
if end is None:
raise DSMLToolCallProtocolError(
"DeepSeek DSML tool-call block was not closed"
)
inner = text[start.end() : end.start()]
invoke_spans: list[tuple[int, int]] = []
for invoke in _INVOKE_RE.finditer(inner):
name = invoke.group("name").strip()
if name not in allowed_tools:
raise DSMLToolCallProtocolError(
f"DeepSeek DSML requested an unadvertised tool: {name!r}"
)
calls.append(
RecoveredToolCall(
name=name,
arguments=_parse_invoke_body(invoke.group("body")),
call_id=f"call_deerflow_dsml_{uuid.uuid4().hex}",
)
)
invoke_spans.append(invoke.span())
if not invoke_spans:
raise DSMLToolCallProtocolError(
"DeepSeek DSML tool-call block contained no complete invoke"
)
remainder_parts: list[str] = []
inner_cursor = 0
for invoke_start, invoke_end in invoke_spans:
remainder_parts.append(inner[inner_cursor:invoke_start])
inner_cursor = invoke_end
remainder_parts.append(inner[inner_cursor:])
if "".join(remainder_parts).strip():
raise DSMLToolCallProtocolError(
"DeepSeek DSML tool-call block contained unexpected text"
)
cursor = end.end()
if not found:
# A DSML sentinel was present but did not match any supported complete
# envelope. Do not leak it as chat text or guess a repair.
raise DSMLToolCallProtocolError(
"DeepSeek DSML marker used an unsupported tool-call envelope"
)
return ParsedDSMLContent(
content="".join(visible_parts),
calls=tuple(calls),
found=True,
)
def _payload_tool_names(payload: Mapping[str, Any]) -> set[str]:
names: set[str] = set()
tools = payload.get("tools")
if not isinstance(tools, list):
return names
for tool in tools:
function = tool.get("function") if isinstance(tool, Mapping) else None
name = function.get("name") if isinstance(function, Mapping) else None
if isinstance(name, str) and name:
names.add(name)
return names
@dataclass(slots=True)
class DSMLToolCallRecovery:
"""Per-provider-invocation DSML compatibility state."""
enabled: bool
allowed_tools: set[str] = field(default_factory=set)
pending: str = ""
capture: str = ""
capture_template: ChatGenerationChunk | None = None
recovered: bool = False
def configure_from_payload(self, payload: Mapping[str, Any]) -> None:
if self.enabled:
self.allowed_tools = _payload_tool_names(payload)
def recover_nonstream_response(self, response: dict[str, Any]) -> None:
if not self.enabled or not self.allowed_tools:
return
choices = response.get("choices")
if not isinstance(choices, list):
return
for choice in choices:
if not isinstance(choice, dict):
continue
message = choice.get("message")
if not isinstance(message, dict) or message.get("tool_calls"):
continue
content = message.get("content")
if not isinstance(content, str):
continue
parsed = parse_dsml_content(content, allowed_tools=self.allowed_tools)
if not parsed.found:
continue
message["content"] = parsed.content
message["tool_calls"] = [
{
"id": call.call_id,
"type": "function",
"function": {
"name": call.name,
"arguments": json.dumps(
call.arguments,
ensure_ascii=False,
separators=(",", ":"),
),
},
}
for call in parsed.calls
]
choice["finish_reason"] = "tool_calls"
self.recovered = True
def feed(self, chunk: ChatGenerationChunk) -> list[ChatGenerationChunk]:
if not self.enabled or not self.allowed_tools:
return [chunk]
message = chunk.message
if not isinstance(message, AIMessageChunk):
return [chunk]
if message.tool_call_chunks or message.tool_calls:
if self.capture:
raise DSMLToolCallProtocolError(
"Provider mixed native tool calls with an incomplete DSML block"
)
return [chunk]
content = message.content
if not isinstance(content, str):
return [chunk]
if self.recovered:
if _ANY_DSML_RE.search(content):
raise DSMLToolCallProtocolError(
"Provider emitted another DSML block after a recovered tool call"
)
return [self._force_tool_finish(chunk)]
if self.capture:
self.capture += content
self.capture_template = chunk
return self._complete_capture_if_ready()
combined = self.pending + content
had_pending = bool(self.pending)
self.pending = ""
start = _START_RE.search(combined)
if start is not None:
before = combined[: start.start()]
self.capture = combined[start.start() :]
self.capture_template = chunk
output: list[ChatGenerationChunk] = []
if before or self._has_noncontent_signal(message):
output.append(self._replace_content(chunk, before))
output.extend(self._complete_capture_if_ready())
return output
hold = _longest_possible_start_suffix(combined)
if hold:
safe = combined[:-hold]
self.pending = combined[-hold:]
self.capture_template = chunk
if safe or self._has_noncontent_signal(message):
return [self._replace_content(chunk, safe)]
return []
return [self._replace_content(chunk, combined)] if had_pending else [chunk]
def finish(self) -> list[ChatGenerationChunk]:
if not self.enabled:
return []
if self.capture:
raise DSMLToolCallProtocolError(
"Provider stream ended inside a DeepSeek DSML tool-call block"
)
if not self.pending:
return []
pending = self.pending
self.pending = ""
if self.capture_template is None:
return []
return [self._replace_content(self.capture_template, pending)]
def _complete_capture_if_ready(self) -> list[ChatGenerationChunk]:
if _END_RE.search(self.capture) is None:
return []
parsed = parse_dsml_content(
self.capture,
allowed_tools=self.allowed_tools,
)
if not parsed.calls or self.capture_template is None:
raise DSMLToolCallProtocolError(
"DeepSeek DSML recovery produced no complete tool call"
)
template = self.capture_template
self.capture = ""
self.recovered = True
message = template.message
assert isinstance(message, AIMessageChunk)
tool_chunks = [
{
"name": call.name,
"args": json.dumps(
call.arguments,
ensure_ascii=False,
separators=(",", ":"),
),
"id": call.call_id,
"index": index,
"type": "tool_call_chunk",
}
for index, call in enumerate(parsed.calls)
]
response_metadata = dict(message.response_metadata)
if response_metadata.get("finish_reason"):
response_metadata["finish_reason"] = "tool_calls"
recovered_message = AIMessageChunk(
content=parsed.content,
additional_kwargs=dict(message.additional_kwargs),
response_metadata=response_metadata,
id=message.id,
name=message.name,
usage_metadata=message.usage_metadata,
tool_call_chunks=tool_chunks, # type: ignore[arg-type]
)
generation_info = dict(template.generation_info or {})
if generation_info.get("finish_reason"):
generation_info["finish_reason"] = "tool_calls"
return [
ChatGenerationChunk(
message=recovered_message,
generation_info=generation_info,
)
]
@staticmethod
def _replace_content(
chunk: ChatGenerationChunk,
content: str,
) -> ChatGenerationChunk:
return ChatGenerationChunk(
message=chunk.message.model_copy(update={"content": content}),
generation_info=chunk.generation_info,
)
@staticmethod
def _has_noncontent_signal(message: AIMessageChunk) -> bool:
return bool(
message.additional_kwargs
or message.response_metadata
or message.usage_metadata
)
@staticmethod
def _force_tool_finish(chunk: ChatGenerationChunk) -> ChatGenerationChunk:
message = chunk.message
if not isinstance(message, AIMessageChunk):
return chunk
response_metadata = dict(message.response_metadata)
if response_metadata.get("finish_reason"):
response_metadata["finish_reason"] = "tool_calls"
generation_info = dict(chunk.generation_info or {})
if generation_info.get("finish_reason"):
generation_info["finish_reason"] = "tool_calls"
return ChatGenerationChunk(
message=message.model_copy(update={"response_metadata": response_metadata}),
generation_info=generation_info or chunk.generation_info,
)
__all__ = [
"DSMLToolCallProtocolError",
"DSMLToolCallRecovery",
"ParsedDSMLContent",
"RecoveredToolCall",
"parse_dsml_content",
]