* Fix Gemma 4 GGUF OpenAI API streams * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Avoid duplicate Responses stream disconnect watcher * Keep reasoning-only Responses output hidden * Address Gemma stream review comments * Avoid Responses stream task-group cleanup * Harden OpenAI chat completion streams * Address OpenAI stream review issues * Clean up Studio OpenAI stream helpers * Fix Studio passthrough cold stream timeout * Fix tool parser compatibility exports lint * Preserve audio stream disconnect cancellation * Avoid synthetic finish after passthrough errors * Address stream cleanup and Gemma parser reviews * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gemma 4: parse bare-string tool args and keep safetensors tools for native <|tool_call> - Quote bare unquoted string values in Gemma native tool-call args (e.g. {location:Tokyo,unit:celsius}) so they parse; JSON scalars stay typed. - Stop _detect_safetensors_features from suppressing supports_tools for templates that emit Gemma native <|tool_call>, which the shared parser now reads. - Add tests for both. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden Gemma tool-call parsing and stream-error detection Address three issues in the Gemma-native tool-call path: - _quote_gemma_object_keys stopped a bare (unquoted) string value at the first comma, so an argument like `location:New York, NY` was split mid-value and the synthesized JSON failed to parse, dropping the whole tool call. A bare value now ends only at `}` or a comma that begins the next `key:` pair. - parse_tool_calls_from_text scanned the entire response for Gemma markers even inside a tool call already parsed from a `<tool_call>{...}` JSON block, so a marker-like string inside an argument (data) was promoted to a second, unintended tool call. Matches inside an already-consumed call span are now skipped. - _openai_passthrough_stream relied on _monitor_openai_sse_line to flag a stream error, which returns early when monitor_id is None (skip_api_monitor), so an upstream error chunk left saw_stream_error unset and the synthetic-finish guard emitted a successful finish_reason after a failed stream. Error chunks are now detected independently of API monitoring. Adds tests/test_gemma_tool_parse_edge_cases.py covering the comma and marker-injection cases. * Emit the terminal finish_reason chunk in GGUF streams The OpenAI chat-completions GGUF tool stream and plain stream both built a final ChatCompletionChunk carrying finish_reason but never yielded it, so clients received the optional usage chunk and [DONE] with no chunk carrying finish_reason. OpenAI-compatible consumers rely on that terminal choice to distinguish stop/length/tool_calls. Yield it before the usage chunk and [DONE], matching the other streaming paths. * Parse tool calls in document order and skip nested markers both ways Unify the JSON- and Gemma-format tool-call passes into a single position-ordered scan: - Calls are now emitted in byte order across both formats, so a mixed output like `<|tool_call>call:create{...}<tool_call|> ... <tool_call> {"name":"read",...}</tool_call>` executes create before read, matching the order they appear in (tools run in returned order). - A candidate that starts inside an already-accepted call's span is skipped, in both directions: a JSON marker inside a Gemma argument and a Gemma marker inside a JSON argument are treated as data, not promoted to a second executable tool call. Extends tests/test_gemma_tool_parse_edge_cases.py with the ordering and JSON-in-Gemma nesting cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Quote bare Gemma array elements; order finish before trailing usage - _quote_gemma_object_keys skipped array values, so a Gemma call with a bare-string array argument like labels:[bug,ui] produced invalid JSON and the whole tool call was dropped. Array values are now scanned and bare string elements quoted, while numbers, quoted strings, and JSON literals are preserved. - In the OpenAI passthrough stream, a trailing usage-only chunk (stream_options.include_usage) that arrived before any finish chunk was relayed before the synthetic finish, producing usage -> finish -> [DONE]. Emit the synthetic finish before that usage chunk so the order matches the other streams (finish -> usage -> [DONE]). Extends tests/test_gemma_tool_parse_edge_cases.py with the bare-array cases. * Harden Gemma array parsing, XML-parameter guard, and stream teardown Address five review findings on the Gemma tool-call and OpenAI passthrough streaming paths: - parse_tool_calls_from_text collected JSON and Gemma markers without the _inside_open_parameter guard, so a marker embedded in an existing <function=...><parameter=...> value was promoted to a separate tool call. Candidates that start inside an open XML parameter are now skipped, matching the guard the XML-style parser already applies. - _quote_gemma_array_elements preserved array elements starting with { or [ verbatim, so an array of objects (items:[{path:a}]) or a nested array failed json.loads and the whole call was dropped. Object and nested-array elements are now normalised recursively. - _openai_passthrough_stream synthesized a finish chunk before a trailing usage-only chunk and set saw_finish_reason, which made the EOF guard skip the [DONE] sentinel. The EOF path now emits [DONE] whenever the upstream omitted it, even after a finish chunk was already synthesized. - /generate/stream drove generation through asyncio.to_thread with no disconnect watcher, so a client disconnect during a long generation went unnoticed until the next send. It now runs _await_disconnect_then_cancel against the request, matching the other local streaming endpoints. - _SameTaskStreamingResponse closed the body iterator with aclose() on a send-side disconnect, raising GeneratorExit so the generators' cancellation handlers (which finish the api_monitor entry) never ran. It now throws CancelledError, falling back to aclose() when athrow is unavailable. Extends tests/test_gemma_tool_parse_edge_cases.py with array-of-objects, nested-array, and marker-inside-XML-parameter cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Watch disconnects on Anthropic streams; keep timestamps in Gemma values Two follow-ups on the streaming and tool-parse paths: - _anthropic_tool_stream and _anthropic_plain_stream drove generation through asyncio.to_thread(next, gen, ...) and only polled is_disconnected() between events, so a client disconnect during prefill or a long generation/tool step held the decode slot until the next event or a failed send. Both now run the _await_disconnect_then_cancel watcher used by the other local streams, stop it in finally, and break promptly when cancel_event is set. - _GEMMA_NEXT_KEY_RE treated any comma followed by word-chars-then-colon as the next key, so a bare value such as "meet at 10:00, 11:00 tomorrow" was split into bogus keys. The next-key token must now be identifier-shaped (start with a letter or underscore), so a comma before a timestamp, ratio, or other numeric-then-colon text stays part of the value. Adds a timestamp-in-bare-value regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard nested markers, reset on disconnect, clean unstarted streams Three follow-ups on the tool-parse and streaming paths: - parse_tool_calls_from_text only skipped markers that fell inside a span it had already parsed successfully, so when an unquoted Gemma argument contained a literal marker (code:<|tool_call>call:terminal{...}<tool_call|>) the outer object failed to normalize, its span was never recorded, and the inner marker was promoted to a standalone terminal call. Candidates nested inside any other candidate's brace span are now skipped regardless of whether the enclosing candidate parsed, so a marker in malformed outer data is never executed. - /generate/stream skipped backend.reset_generation_state() when the disconnect watcher set cancel_event between chunks: the loop broke and the finally's reset is guarded on cancel_event being unset. A subprocess backend kept decoding after the client left. The cancel-break path now resets the backend. - _SameTaskStreamingResponse threw CancelledError / called aclose() on the body iterator on a send-side disconnect, but neither runs the try/finally of a generator that never started (early disconnect on http.response.start), so the passthrough's eagerly-opened upstream httpx stream and cancel-registry entry leaked. It now tracks whether the body started and, when it did not, runs an optional unstarted_cleanup hook; the OpenAI passthrough wires it to close the upstream resp/client and exit the cancel tracker. Adds a nested-unquoted-marker regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
283 lines
11 KiB
Python
283 lines
11 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for `_TOOL_XML_RE` (routes/inference.py) -- strips tool-call XML that
|
|
leaks past the speculative buffer in core/inference/llama_cpp.py when the
|
|
open/close pair is split across the visible/DRAIN boundary.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types as _types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
# Extract the regex from source (routes module needs heavy stubbing to import).
|
|
import re as _re
|
|
|
|
_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text()
|
|
_m = _re.search(r"_TOOL_XML_RE = _re\.compile\((.*?)\n\)", _src, _re.DOTALL)
|
|
assert _m, "could not extract _TOOL_XML_RE source"
|
|
_ns = {"_re": _re}
|
|
exec(f"_TOOL_XML_RE = _re.compile({_m.group(1)})", _ns)
|
|
_TOOL_XML_RE = _ns["_TOOL_XML_RE"]
|
|
_helper = _re.search(
|
|
r"def _strip_tool_xml_for_display\(text: str, \*, auto_heal_tool_calls: bool\) -> str:\n"
|
|
r"(?: .+\n)+",
|
|
_src,
|
|
)
|
|
assert _helper, "could not extract _strip_tool_xml_for_display source"
|
|
exec(_helper.group(0), _ns)
|
|
_strip_tool_xml_for_display = _ns["_strip_tool_xml_for_display"]
|
|
|
|
|
|
# ── Well-formed pairs ─────────────────────────────────────────────
|
|
|
|
|
|
def test_route_display_strip_respects_disabled_auto_heal_contract():
|
|
text = 'literal <tool_call>{"name":"web_search"}</tool_call> survives'
|
|
assert _strip_tool_xml_for_display(text, auto_heal_tool_calls = False) == text
|
|
assert "<tool_call>" not in _strip_tool_xml_for_display(text, auto_heal_tool_calls = True)
|
|
|
|
|
|
def test_strips_well_formed_tool_call():
|
|
text = (
|
|
"Let me search.\n"
|
|
"<tool_call>\n"
|
|
"<function=web_search>\n"
|
|
"<parameter=query>\nBillboard 2015\n</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>\n"
|
|
"Here are the songs:"
|
|
)
|
|
cleaned = _TOOL_XML_RE.sub("", text)
|
|
assert "<tool_call>" not in cleaned
|
|
assert "<function=" not in cleaned
|
|
assert "</tool_call>" not in cleaned
|
|
assert "</function>" not in cleaned
|
|
assert "Here are the songs:" in cleaned, "non-XML content must survive"
|
|
assert "Let me search." in cleaned
|
|
|
|
|
|
def test_strips_function_only_well_formed():
|
|
text = "Setup.\n<function=python>\n<parameter=code>\nprint(1)\n</parameter>\n</function>\nDone."
|
|
cleaned = _TOOL_XML_RE.sub("", text)
|
|
assert "<function=" not in cleaned
|
|
assert "Setup." in cleaned
|
|
assert "Done." in cleaned
|
|
|
|
|
|
# ── Orphan openings ───────────────────────────────────────────────
|
|
|
|
|
|
def test_strips_orphan_tool_call_no_close():
|
|
text = (
|
|
"Reasoning.\n</think>"
|
|
"<tool_call>\n"
|
|
"<function=web_search>\n"
|
|
"<parameter=query>\nBillboard 2015\n</parameter>\n"
|
|
"</function"
|
|
)
|
|
cleaned = _TOOL_XML_RE.sub("", text)
|
|
assert "<tool_call>" not in cleaned
|
|
assert "<function=" not in cleaned
|
|
assert "Reasoning." in cleaned
|
|
|
|
|
|
def test_strips_orphan_function_no_close():
|
|
text = "I'll call python:\n<function=python>\n<parameter=code>\nprint(1)\n</parameter>"
|
|
cleaned = _TOOL_XML_RE.sub("", text)
|
|
assert "<function=" not in cleaned
|
|
assert "I'll call python:" in cleaned
|
|
|
|
|
|
def test_strips_orphan_only_opening_tag():
|
|
cleaned = _TOOL_XML_RE.sub("", "Search starting.\n<tool_call>")
|
|
assert "<tool_call>" not in cleaned
|
|
assert "Search starting." in cleaned
|
|
|
|
|
|
def test_strips_multiple_orphans():
|
|
text = (
|
|
"First call:\n<tool_call>\n<function=python>\n<parameter=code>\nx=1\n"
|
|
"Second call:\n<function=web_search>\n<parameter=query>\nhi\n"
|
|
)
|
|
cleaned = _TOOL_XML_RE.sub("", text)
|
|
assert "<tool_call>" not in cleaned
|
|
assert "<function=" not in cleaned
|
|
|
|
|
|
# ── Orphan closes ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_strips_orphan_closing_tag():
|
|
# Real shape from Qwen3.6-27B Q8 sweep (open got DRAINED, close leaked).
|
|
text = "...the table rows directly.\n</parameter>\n</function>\n</tool_call><think>Continuing</think>"
|
|
cleaned = _TOOL_XML_RE.sub("", text)
|
|
assert "</tool_call>" not in cleaned
|
|
assert "</function>" not in cleaned
|
|
# Mid-string </parameter> intentionally preserved (see preserve test).
|
|
|
|
|
|
def test_strips_gemma_native_orphan_closing_tag():
|
|
cleaned = _TOOL_XML_RE.sub("", "Tool call drained.<tool_call|>Visible tail.")
|
|
|
|
assert "<tool_call|>" not in cleaned
|
|
assert "Tool call drained." in cleaned
|
|
assert "Visible tail." in cleaned
|
|
|
|
|
|
# ── Tail-only </parameter> (PR #5735 follow-up) ───────────────────
|
|
|
|
|
|
def test_strips_tail_only_parameter_orphan():
|
|
# Outer </function></tool_call> truncated by EOS, inner <parameter=...> DRAINED.
|
|
cleaned = _TOOL_XML_RE.sub("", "and the text is not readable.\n</parameter>\n\n")
|
|
assert "</parameter>" not in cleaned
|
|
assert "and the text is not readable." in cleaned
|
|
|
|
|
|
def test_strips_tail_only_parameter_orphan_single_newline():
|
|
cleaned = _TOOL_XML_RE.sub("", "Global Economic Prospects\n</parameter>\n")
|
|
assert "</parameter>" not in cleaned
|
|
assert "Global Economic Prospects" in cleaned
|
|
|
|
|
|
def test_strips_tail_only_parameter_orphan_no_trailing_ws():
|
|
cleaned = _TOOL_XML_RE.sub("", "Final answer.</parameter>")
|
|
assert "</parameter>" not in cleaned
|
|
assert "Final answer." in cleaned
|
|
|
|
|
|
def test_preserves_mid_string_parameter_in_code_sample():
|
|
# Tail-anchor on `</parameter>` so doc/example prose survives.
|
|
text = (
|
|
"Here is the Qwen tool-call format:\n"
|
|
"```xml\n"
|
|
"<tool_call><function=foo><parameter=arg>value</parameter></function></tool_call>\n"
|
|
"```\n"
|
|
"Note the closing </parameter> sits inside <function>."
|
|
)
|
|
cleaned = _TOOL_XML_RE.sub("", text)
|
|
assert "Note the closing </parameter> sits inside" in cleaned
|
|
|
|
|
|
def test_strips_well_formed_then_orphan():
|
|
text = (
|
|
"Round one:\n<tool_call>\n<function=python>\n<parameter=code>\n1\n"
|
|
"</parameter>\n</function>\n</tool_call>\n"
|
|
"Now round two:\n<tool_call>\n<function=web_search>\n<parameter=query>\n"
|
|
"what is X\n</parameter>\n</function"
|
|
)
|
|
cleaned = _TOOL_XML_RE.sub("", text)
|
|
assert "<tool_call>" not in cleaned
|
|
assert "<function=" not in cleaned
|
|
assert "Round one:" in cleaned
|
|
assert "Now round two:" in cleaned
|
|
|
|
|
|
# ── Preservation (no false positives) ────────────────────────────
|
|
|
|
|
|
def test_preserves_plain_text():
|
|
text = "1. Animals — Maroon 5\n2. Take Me to Church — Hozier"
|
|
assert _TOOL_XML_RE.sub("", text) == text
|
|
|
|
|
|
def test_preserves_code_fences():
|
|
text = "```python\nimport sys\nprint(sys.version)\n```"
|
|
assert _TOOL_XML_RE.sub("", text) == text
|
|
|
|
|
|
def test_preserves_html_in_prose():
|
|
text = "Use the <html> tag for documents."
|
|
assert _TOOL_XML_RE.sub("", text) == text
|
|
|
|
|
|
# ── Real-world leak samples from the 2026-05-22 sweep ────────────
|
|
|
|
|
|
REAL_LEAKS = [
|
|
# Qwen3.5-35B-A3B UD-Q4_K_XL billboard s22 -- orphan open
|
|
'rectly.\n\nLet me try searching for Wikipedia pages that might have weekly chart data for 2015.\n</think><tool_call>\n<function=web_search>\n<parameter=query>\n"Billboard Hot 100" "2015" "weekly" "chart" "position" "3"\n</parameter>\n</function',
|
|
# Qwen3.6-27B UD-Q2_K_XL billboard s14 -- orphan open
|
|
'arch `site:wikipedia.org "peaked at number 3" "2015" Billboard`\nI\'ll do a quick web search.\n</think><tool_call>\n<function=web_search>\n<parameter=query>\n"peaked at number 3" Billboard Hot 100 2015 list\n</parameter>\n</function',
|
|
# Qwen3.6-27B UD-Q2_K_XL billboard s15 -- orphan open
|
|
'rd Hot 100 top-ten singles in 2015".\nI\'ll use web_search to find this exact Wikipedia page.\n</think><tool_call>\n<function=web_search>\n<parameter=query>\n"List of Billboard Hot 100 top-ten singles in 2015" wikipedia\n</parameter>\n</function',
|
|
# Qwen3.6-27B Q8_0 billboard s02 -- orphan close
|
|
"the table rows directly.\n</parameter>\n</function>\n</tool_call><think>The user wants me to list and categorize all songs that charted #3 on the Billboard Hot 100 in 2015. I have been trying to get this data",
|
|
# Qwen3.6-35B-A3B Q8_0 billboard s21 -- orphan close
|
|
"parse it more carefully.\n</parameter>\n</function>\n</tool_call><think>The user wants a list of songs that charted #3 on the Billboard Hot 100 in 2015, categorized.",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"leak", REAL_LEAKS, ids = [f"sweep_sample_{i}" for i in range(len(REAL_LEAKS))]
|
|
)
|
|
def test_real_world_sweep_leaks_get_stripped(leak):
|
|
cleaned = _TOOL_XML_RE.sub("", leak)
|
|
assert "<tool_call>" not in cleaned, f"leak survived: {cleaned!r}"
|
|
assert "<function=" not in cleaned, f"leak survived: {cleaned!r}"
|
|
|
|
|
|
# ── Real-world tail-only </parameter> from gdpval sweep ──────────
|
|
|
|
|
|
# All end-anchored: outer </function></tool_call> truncated by EOS, inner
|
|
# <parameter=...> open DRAINED, leaving bare </parameter> tail.
|
|
GDPVAL_PARAMETER_LEAKS = [
|
|
# Qwen3.5-27B Q8_0 / worldbank s00
|
|
"the page contains image data and the text is not readable.\n</parameter>\n\n",
|
|
# Qwen3.5-27B Q8_0 / worldbank s42 (preceded by mojibake)
|
|
"...some mojibake content here...\n</parameter>\n\n",
|
|
# Qwen3.5-27B UD-Q4_K_XL / coppa s07
|
|
"blocked, while others may still be in effect. The law is currently under further review by the Ninth Circuit.\n</parameter>\n\n",
|
|
# Qwen3.5-27B UD-Q4_K_XL / police_training s00
|
|
"comprehensive training report\n</parameter>\n\n",
|
|
# Qwen3.5-27B UD-Q4_K_XL / worldbank s00
|
|
"Global Economic Prospects\nJune 2025\nGlobal Economic Prospects\n</parameter>\n",
|
|
# Qwen3.6-27B Q8_0 / overpass s07
|
|
"Let me create a comprehensive query and instructions document.\n</parameter>\n\n",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"leak",
|
|
GDPVAL_PARAMETER_LEAKS,
|
|
ids = [f"gdpval_param_orphan_{i}" for i in range(len(GDPVAL_PARAMETER_LEAKS))],
|
|
)
|
|
def test_gdpval_parameter_orphans_get_stripped(leak):
|
|
cleaned = _TOOL_XML_RE.sub("", leak)
|
|
assert "</parameter>" not in cleaned, f"leak survived: {cleaned!r}"
|
|
|
|
|
|
# ── Backtracking guards ──────────────────────────────────────────
|
|
|
|
|
|
def test_no_catastrophic_backtracking_on_open_bracket_spam():
|
|
# 256KB of '<' must fail fast (literal mismatch char 2), not backtrack.
|
|
import time
|
|
|
|
adv = "<" * (1024 * 256) + "X"
|
|
t0 = time.perf_counter()
|
|
_TOOL_XML_RE.sub("", adv)
|
|
elapsed = time.perf_counter() - t0
|
|
assert elapsed < 0.5, f"regex took {elapsed*1000:.0f}ms on 256KB '<' spam"
|
|
|
|
|
|
def test_no_catastrophic_backtracking_on_orphan_opening_spam():
|
|
# 1000 unclosed openings: first alt must consume them all greedily.
|
|
import time
|
|
|
|
adv = "<tool_call>X" * 1000
|
|
t0 = time.perf_counter()
|
|
cleaned = _TOOL_XML_RE.sub("", adv)
|
|
elapsed = time.perf_counter() - t0
|
|
assert elapsed < 0.1, f"regex took {elapsed*1000:.0f}ms on 1000x orphan opens"
|
|
assert "<tool_call>" not in cleaned
|