fix(streaming): stop duplicating tool-call args from cumulative-resend providers (#35718)
DeepSeek / Baidu Qianfan stream tool-call arguments in cumulative mode:
each chunk resends the full arguments-so-far instead of the new fragment.
The stream accumulator blindly concatenated arg deltas with +=, turning
that into '{...}{...}{...}', which failed json.loads and got nuked to '{}'
— a silently corrupted tool call (#35592). Worse on multi-param tools
(search_files, session_search, memory replace) because longer args take
more chunks, giving more resend opportunities.
- Per-slot cumulative latch in the stream accumulator: a delta that is a
strict superset of the accumulated buffer marks the slot cumulative and
replaces (not appends); exact duplicates are dropped only after latching.
Incremental fragments are untouched (default += path).
- Backstop _collapse_repeated_json_arguments() in the repair pipeline
collapses pure identical-resend buffers (K exact repeats of a valid-JSON
unit) for providers that resend the complete object from chunk 1. Only
reached after json.loads already failed, so compliant single objects are
never touched.
Not a gateway or DeepSeek-model bug — any OpenAI-wire provider in
cumulative streaming mode is affected.
This commit is contained in:
@@ -182,6 +182,58 @@ def _escape_invalid_chars_in_json_strings(raw: str) -> str:
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _collapse_repeated_json_arguments(raw_stripped: str) -> str | None:
|
||||
"""Collapse concatenated-duplicate tool-call argument JSON.
|
||||
|
||||
Some OpenAI-compatible streaming providers (DeepSeek / Baidu Qianfan
|
||||
among them) resend the *full* cumulative ``arguments`` string in every
|
||||
stream chunk instead of incremental fragments. Our stream accumulator
|
||||
concatenates argument deltas with ``+=`` (correct for spec-compliant
|
||||
incremental providers), so a cumulative-resend provider yields the
|
||||
object repeated once per chunk:
|
||||
|
||||
'{"path":"x"}{"path":"x"}{"path":"x"}...'
|
||||
|
||||
A spec-compliant provider always produces exactly one valid JSON object
|
||||
after concatenation, so this function is only ever reached for strings
|
||||
that already fail ``json.loads`` (issue #35592).
|
||||
|
||||
Detection is unambiguous and operates on the fully-accumulated string,
|
||||
not on partial per-chunk data: the input must be K>=2 *exact* repeats of
|
||||
a unit substring that itself parses as valid JSON. Returns the single
|
||||
collapsed unit (re-serialised compactly) when that holds, else ``None``
|
||||
so the caller falls through to the generic repair passes.
|
||||
|
||||
Safety: a single object like ``{"command":{"command":"x"}}`` parses on
|
||||
the first ``json.loads`` attempt in the caller and never reaches here;
|
||||
even if it did, it is not an exact-repeat string, so this returns
|
||||
``None`` and leaves it untouched.
|
||||
"""
|
||||
n = len(raw_stripped)
|
||||
if n < 2:
|
||||
return None
|
||||
# The unit must divide the total length evenly for an exact K-repeat.
|
||||
# Try the smallest plausible unit first (divisors of n, ascending),
|
||||
# capping the repeat count so a pathological input can't blow up.
|
||||
for unit_len in range(1, n // 2 + 1):
|
||||
if n % unit_len != 0:
|
||||
continue
|
||||
k = n // unit_len
|
||||
if k < 2:
|
||||
continue
|
||||
unit = raw_stripped[:unit_len]
|
||||
if unit * k != raw_stripped:
|
||||
continue
|
||||
# The repeated unit must itself be valid JSON for this to be a
|
||||
# cumulative-resend collapse and not a coincidental string repeat.
|
||||
try:
|
||||
parsed = json.loads(unit, strict=False)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
return json.dumps(parsed, separators=(",", ":"))
|
||||
return None
|
||||
|
||||
|
||||
def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
|
||||
"""Attempt to repair malformed tool_call argument JSON.
|
||||
|
||||
@@ -220,6 +272,20 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Repair pass 1: collapse concatenated-duplicate JSON. Cumulative-resend
|
||||
# streaming providers (DeepSeek / Baidu Qianfan, #35592) yield the full
|
||||
# arguments object repeated once per stream chunk; our += accumulator
|
||||
# turns that into '{...}{...}{...}'. Only reached when pass 0 above
|
||||
# already failed, so spec-compliant single objects are never touched.
|
||||
collapsed = _collapse_repeated_json_arguments(raw_stripped)
|
||||
if collapsed is not None:
|
||||
logger.warning(
|
||||
"Collapsed cumulative-resend duplicate tool_call arguments for "
|
||||
"%s (%d chars -> %d)",
|
||||
tool_name, len(raw_stripped), len(collapsed),
|
||||
)
|
||||
return collapsed
|
||||
|
||||
# Attempt common JSON repairs
|
||||
fixed = raw_stripped
|
||||
# 1. Strip trailing commas before } or ]
|
||||
@@ -436,6 +502,7 @@ __all__ = [
|
||||
"_sanitize_messages_surrogates",
|
||||
"_escape_invalid_chars_in_json_strings",
|
||||
"_repair_tool_call_arguments",
|
||||
"_collapse_repeated_json_arguments",
|
||||
"_strip_non_ascii",
|
||||
"_sanitize_messages_non_ascii",
|
||||
"_sanitize_tools_non_ascii",
|
||||
|
||||
Reference in New Issue
Block a user