diff --git a/studio/backend/core/inference/tool_call_parser.py b/studio/backend/core/inference/tool_call_parser.py
index 8d5d45269e..ca3d1e4cbc 100644
--- a/studio/backend/core/inference/tool_call_parser.py
+++ b/studio/backend/core/inference/tool_call_parser.py
@@ -7,25 +7,31 @@ Tolerates missing closing tags in either ``{json}``
or ``v...`` shape.
"""
-import json
-import re
+from core import tool_healing as _tool_healing
-# _TOOL_CLOSED_PATS: closed pairs only. _TOOL_ALL_PATS: also trailing unclosed
-# runs so truncated tails don't leak markup. The [\w-] name set matches OpenAI's
-# so hyphenated MCP tool names (mcp__srv__list-issues) parse like built-ins.
-_TOOL_CLOSED_PATS = [
- re.compile(r".*?", re.DOTALL),
- re.compile(r".*?", re.DOTALL),
-]
-_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
- re.compile(r".*$", re.DOTALL),
- re.compile(r".*$", re.DOTALL),
-]
+_TOOL_ALL_PATS = _tool_healing._TOOL_ALL_PATS
+
+
+def parse_tool_calls_from_text(
+ content: str,
+ *,
+ id_offset: int = 0,
+ allow_incomplete: bool = True,
+) -> list[dict]:
+ return _tool_healing.parse_tool_calls_from_text(
+ content,
+ id_offset = id_offset,
+ allow_incomplete = allow_incomplete,
+ )
+
+
+def strip_tool_markup(text: str, *, final: bool = False) -> str:
+ return _tool_healing.strip_tool_call_markup(text, final = final)
# Prefixes the streaming buffer watches for to gate in-progress text.
-TOOL_XML_SIGNALS = ("", "", "<|tool_call>", "\s*\{")
-_TC_FUNC_START_RE = re.compile(r"\s*")
-_TC_END_TAG_RE = re.compile(r"")
-_TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$")
-# [\w-] so hyphenated MCP param names (issue-number) aren't dropped.
-_TC_PARAM_START_RE = re.compile(r"\s*")
-_TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$")
-_PARAM_CLOSE_TAG = ""
-_FUNC_CLOSE_TAG = ""
-
-
-def _inside_open_parameter(content: str, pos: int) -> bool:
- """Return True when ``pos`` falls inside an unclosed parameter value."""
- last_param_start = -1
- for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
- last_param_start = match.start()
- if last_param_start < 0:
- return False
- last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
- last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
- return last_param_start > max(last_param_close, last_func_close)
-
-
-def strip_tool_markup(text: str, *, final: bool = False) -> str:
- """Strip tool-call XML from streamed text.
-
- ``final=False`` only removes closed pairs (used during streaming so
- in-progress XML stays buffered). ``final=True`` also removes a
- trailing unclosed run and trims the result.
- """
- pats = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
- for pat in pats:
- text = pat.sub("", text)
- return text.strip() if final else text
-
-
-def parse_tool_calls_from_text(
- content: str,
- *,
- id_offset: int = 0,
- allow_incomplete: bool = True,
-) -> list[dict]:
- """Parse OpenAI-format ``tool_calls`` from model text.
-
- Returns a list of ``{"id", "type", "function": {"name", "arguments"}}``
- dicts. ``arguments`` is always a JSON string so callers can hand it
- straight back into an OpenAI-style response.
-
- Handles two shapes:
-
- - JSON inside ```` tags:
- ``{"name":"web_search","arguments":{"query":"..."}}``
- - XML-style function blocks:
- ``v``
-
- ``allow_incomplete=True`` keeps the historical healing behavior for
- missing closing tags. ``allow_incomplete=False`` accepts only
- well-formed wrappers so disabled Auto-Heal can still parse valid
- local tool protocol without repairing truncated output.
- """
- tool_calls: list[dict] = []
-
- # Pattern 1: {json}. Balanced-brace scan, skipping braces in
- # JSON strings.
- for m in _TC_JSON_START_RE.finditer(content):
- brace_start = m.end() - 1 # opening {
- depth, i = 0, brace_start
- in_string = False
- while i < len(content):
- ch = content[i]
- if in_string:
- if ch == "\\" and i + 1 < len(content):
- i += 2
- continue
- if ch == '"':
- in_string = False
- elif ch == '"':
- in_string = True
- elif ch == "{":
- depth += 1
- elif ch == "}":
- depth -= 1
- if depth == 0:
- break
- i += 1
- if depth != 0:
- continue
- if not allow_incomplete:
- tail_after_json = content[i + 1 :].lstrip()
- if _TC_END_TAG_RE.match(tail_after_json) is None:
- continue
- json_str = content[brace_start : i + 1]
- try:
- obj = json.loads(json_str)
- tc = {
- "id": f"call_{id_offset + len(tool_calls)}",
- "type": "function",
- "function": {
- "name": obj.get("name", ""),
- "arguments": obj.get("arguments", {}),
- },
- }
- if isinstance(tc["function"]["arguments"], dict):
- tc["function"]["arguments"] = json.dumps(tc["function"]["arguments"])
- tool_calls.append(tc)
- except (json.JSONDecodeError, ValueError):
- pass
-
- # Pattern 2: v... -- closing tags optional;
- # isn't a body boundary since code values can contain it.
- if not tool_calls:
- func_starts = [
- fm
- for fm in _TC_FUNC_START_RE.finditer(content)
- if not _inside_open_parameter(content, fm.start())
- ]
- for idx, fm in enumerate(func_starts):
- func_name = fm.group(1)
- body_start = fm.end()
- next_func = func_starts[idx + 1].start() if idx + 1 < len(func_starts) else len(content)
- end_tag = _TC_END_TAG_RE.search(content[body_start:])
- if end_tag:
- body_end = body_start + end_tag.start()
- else:
- body_end = len(content)
- body_end = min(body_end, next_func)
- body = content[body_start:body_end]
- if not allow_incomplete:
- # Bound the body at the closing tag rather than
- # the end of the response, so a complete call followed by
- # trailing prose is still accepted (matching the JSON-style
- # path, which already tolerates trailing text).
- # rfind picks the last , so a literal
- # inside a code parameter value stays in the body.
- close_idx = body.rfind(_FUNC_CLOSE_TAG)
- if close_idx < 0:
- continue
- body = body[:close_idx]
- else:
- body = _TC_FUNC_CLOSE_RE.sub("", body)
-
- arguments: dict = {}
- param_starts = list(_TC_PARAM_START_RE.finditer(body))
- if len(param_starts) == 1:
- # Single param: take everything to body end so an embedded
- # in code strings is preserved.
- pm = param_starts[0]
- val = body[pm.end() :]
- if not allow_incomplete:
- stripped_val = val.rstrip()
- if not stripped_val.endswith(_PARAM_CLOSE_TAG):
- continue
- val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
- else:
- val = _TC_PARAM_CLOSE_RE.sub("", val)
- arguments[pm.group(1)] = val.strip()
- else:
- valid_params = True
- for pidx, pm in enumerate(param_starts):
- param_name = pm.group(1)
- val_start = pm.end()
- next_param = (
- param_starts[pidx + 1].start()
- if pidx + 1 < len(param_starts)
- else len(body)
- )
- val = body[val_start:next_param]
- if not allow_incomplete:
- stripped_val = val.rstrip()
- if not stripped_val.endswith(_PARAM_CLOSE_TAG):
- valid_params = False
- break
- val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
- else:
- val = _TC_PARAM_CLOSE_RE.sub("", val)
- arguments[param_name] = val.strip()
- if not valid_params:
- continue
-
- tc = {
- "id": f"call_{id_offset + len(tool_calls)}",
- "type": "function",
- "function": {
- "name": func_name,
- "arguments": json.dumps(arguments),
- },
- }
- tool_calls.append(tc)
-
- return tool_calls
-
-
def has_tool_signal(text: str) -> bool:
"""Return True if ``text`` contains any tool-call XML signal."""
return any(s in text for s in TOOL_XML_SIGNALS)
diff --git a/studio/backend/core/tool_healing.py b/studio/backend/core/tool_healing.py
index 973520d5cd..fe8b94a659 100644
--- a/studio/backend/core/tool_healing.py
+++ b/studio/backend/core/tool_healing.py
@@ -1,14 +1,10 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-"""Tool-call XML parsing and stripping helpers.
+"""Lightweight tool-call XML parsing and stripping helpers.
-Extracted verbatim from studio/backend/core/inference/llama_cpp.py so external
-inference servers can reuse the logic without importing the inference
+External inference servers import this module without pulling in the inference
orchestrator, structlog, httpx, or the rest of the studio backend.
-
-Regexes and bodies are byte-for-byte identical to the original; any change must
-preserve that. test_tool_healing_extraction_is_exact.py verifies via AST.
"""
import json
@@ -17,8 +13,21 @@ import re
# Pre-compiled patterns for tool XML stripping. The hyphen in the name
# char-class lets dashed MCP tool/parameter names (mcp__srv__list-issues,
# issue-number) parse alongside the built-ins.
+#
+# The Gemma close marker is anchored to ``(?:|\Z)`` (the safe form
+# routes/inference.py's _TOOL_XML_RE uses): the plain ``<\|tool_call>.*?``
+# this PR introduced backtracks from every open position on a run of unclosed
+# markers (quadratic, and strip_tool_markup_streaming re-scans the cumulative
+# buffer per token), whereas the ``\Z`` alternative lets the first open consume
+# to EOF in one linear pass. strip_tool_call_markup additionally strips Gemma
+# spans via the brace/quote-aware _strip_gemma_native_spans, so a literal close
+# marker inside a <|"|>-quoted argument cannot truncate the span and leak its
+# suffix; the regex below is the streaming-stripper fallback.
+_TC_GEMMA_CLOSED_PAT = re.compile(r"<\|tool_call>.*?(?:|\Z)", re.DOTALL)
_TOOL_CLOSED_PATS = [
re.compile(r".*?", re.DOTALL),
+ _TC_GEMMA_CLOSED_PAT,
+ re.compile(r""),
re.compile(r".*?", re.DOTALL),
]
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
@@ -28,77 +37,358 @@ _TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
# Pre-compiled patterns for tool-call XML parsing.
_TC_JSON_START_RE = re.compile(r"\s*\{")
+_TC_GEMMA_START_RE = re.compile(r"<\|tool_call>call:([\w-]+)\s*\{")
_TC_FUNC_START_RE = re.compile(r"\s*")
_TC_END_TAG_RE = re.compile(r"")
+_TC_GEMMA_END_TAG_RE = re.compile(r"")
_TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$")
_TC_PARAM_START_RE = re.compile(r"\s*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$")
+_GEMMA_QUOTE = '<|"|>'
+_PARAM_CLOSE_TAG = ""
+_FUNC_CLOSE_TAG = ""
+# A bare (unquoted) Gemma value ends at `}` or at a comma that begins the next
+# `key:` pair. A comma NOT followed by a key token is part of the value (e.g.
+# `location:New York, NY`), so it must not terminate the value. The key token
+# must be identifier-shaped (start with a letter or underscore); a comma
+# followed by digits-then-colon is value text such as a timestamp or ratio
+# (`meet at 10:00, 11:00 tomorrow`), not a new key.
+_GEMMA_NEXT_KEY_RE = re.compile(r"\s*[A-Za-z_][\w-]*\s*:")
-def parse_tool_calls_from_text(content: str) -> list[dict]:
- """
- Parse tool calls from XML markup in content text.
+def _balanced_brace_end(
+ content: str,
+ brace_start: int,
+ *,
+ gemma_quotes: bool = False,
+) -> int:
+ depth = 0
+ i = brace_start
+ in_string = False
+ in_gemma_string = False
+ while i < len(content):
+ if gemma_quotes and not in_string and content.startswith(_GEMMA_QUOTE, i):
+ in_gemma_string = not in_gemma_string
+ i += len(_GEMMA_QUOTE)
+ continue
+ ch = content[i]
+ if in_gemma_string:
+ i += 1
+ continue
+ if in_string:
+ if ch == "\\" and i + 1 < len(content):
+ i += 2
+ continue
+ if ch == '"':
+ in_string = False
+ elif ch == '"':
+ in_string = True
+ elif ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ return i
+ i += 1
+ return -1
+
+
+def _balanced_bracket_end(src: str, start: int) -> int:
+ """Index of the ``]`` matching the ``[`` at ``start``, or -1. Tracks nested
+ ``[]``/``{}`` and double-quoted strings."""
+ depth = 0
+ i = start
+ in_string = False
+ while i < len(src):
+ ch = src[i]
+ if in_string:
+ if ch == "\\" and i + 1 < len(src):
+ i += 2
+ continue
+ if ch == '"':
+ in_string = False
+ elif ch == '"':
+ in_string = True
+ elif ch in "[{":
+ depth += 1
+ elif ch in "]}":
+ depth -= 1
+ if depth == 0:
+ return i
+ i += 1
+ return -1
+
+
+def _split_top_level_commas(src: str) -> list:
+ """Split on commas that are not inside a nested ``[]``/``{}`` or a string."""
+ parts: list[str] = []
+ depth = 0
+ in_string = False
+ start = 0
+ i = 0
+ while i < len(src):
+ ch = src[i]
+ if in_string:
+ if ch == "\\" and i + 1 < len(src):
+ i += 2
+ continue
+ if ch == '"':
+ in_string = False
+ elif ch == '"':
+ in_string = True
+ elif ch in "[{":
+ depth += 1
+ elif ch in "]}":
+ depth -= 1
+ elif ch == "," and depth == 0:
+ parts.append(src[start:i])
+ start = i + 1
+ i += 1
+ parts.append(src[start:])
+ return parts
+
+
+def _quote_gemma_array_elements(body: str) -> str:
+ """Normalise the elements of a Gemma array value so json.loads succeeds.
+
+ Gemma may emit ``labels:[bug,ui]`` without per-element quotes, or arrays of
+ objects (``items:[{path:a}]``) whose keys/values also lack quotes; left
+ as-is json.loads fails and the whole call is dropped. Bare string elements
+ are quoted, object and nested-array elements are normalised recursively, and
+ quoted strings (already normalised from ``<|"|>``), numbers, and JSON
+ literals are preserved."""
+ out: list[str] = []
+ for element in _split_top_level_commas(body):
+ stripped = element.strip()
+ if not stripped or stripped[0] == '"':
+ out.append(element)
+ continue
+ if stripped[0] == "{":
+ # Object element: quote its keys/bare values like a top-level object.
+ out.append(_quote_gemma_object_keys(stripped))
+ continue
+ if stripped[0] == "[":
+ # Nested array: normalise its elements too.
+ inner_end = _balanced_bracket_end(stripped, 0)
+ if inner_end == len(stripped) - 1:
+ out.append("[" + _quote_gemma_array_elements(stripped[1:inner_end]) + "]")
+ else:
+ out.append(element)
+ continue
+ try:
+ json.loads(stripped)
+ out.append(element)
+ except (json.JSONDecodeError, ValueError):
+ out.append(json.dumps(stripped))
+ return ",".join(out)
+
+
+def _normalise_gemma_quoted_strings(src: str) -> str:
+ parts: list[str] = []
+ i = 0
+ while i < len(src):
+ if not src.startswith(_GEMMA_QUOTE, i):
+ parts.append(src[i])
+ i += 1
+ continue
+ end = src.find(_GEMMA_QUOTE, i + len(_GEMMA_QUOTE))
+ if end < 0:
+ parts.append(src[i:])
+ break
+ raw_value = src[i + len(_GEMMA_QUOTE) : end]
+ parts.append(json.dumps(raw_value))
+ i = end + len(_GEMMA_QUOTE)
+ return "".join(parts)
+
+
+def _quote_gemma_object_keys(src: str) -> str:
+ parts: list[str] = []
+ i = 0
+ in_string = False
+ while i < len(src):
+ ch = src[i]
+ if in_string:
+ parts.append(ch)
+ if ch == "\\" and i + 1 < len(src):
+ parts.append(src[i + 1])
+ i += 2
+ continue
+ if ch == '"':
+ in_string = False
+ i += 1
+ continue
+ if ch == '"':
+ in_string = True
+ parts.append(ch)
+ i += 1
+ continue
+ if ch not in "{,":
+ parts.append(ch)
+ i += 1
+ continue
+
+ parts.append(ch)
+ i += 1
+ key_start = i
+ while i < len(src) and src[i].isspace():
+ i += 1
+ key_name_start = i
+ while i < len(src) and (src[i].isalnum() or src[i] in "_-"):
+ i += 1
+ key_name = src[key_name_start:i]
+ colon_pos = i
+ while colon_pos < len(src) and src[colon_pos].isspace():
+ colon_pos += 1
+ if key_name and colon_pos < len(src) and src[colon_pos] == ":":
+ parts.append(src[key_start:key_name_start])
+ parts.append(json.dumps(key_name))
+ parts.append(src[i:colon_pos])
+ parts.append(":")
+ i = colon_pos + 1
+ # Gemma may emit bare string values ({unit:celsius}); quote them so
+ # json.loads succeeds. JSON scalars/objects/arrays/quoted stay as-is.
+ ws = i
+ while i < len(src) and src[i].isspace():
+ i += 1
+ parts.append(src[ws:i])
+ if i < len(src) and src[i] == "[":
+ # Array value: quote bare string elements (e.g. labels:[bug,ui])
+ # so json.loads succeeds instead of dropping the call.
+ arr_end = _balanced_bracket_end(src, i)
+ if arr_end < 0:
+ parts.append(src[i:])
+ i = len(src)
+ else:
+ parts.append("[" + _quote_gemma_array_elements(src[i + 1 : arr_end]) + "]")
+ i = arr_end + 1
+ elif i < len(src) and src[i] not in '"{':
+ v_start = i
+ # Consume the bare value up to `}` or a comma that starts the
+ # next key:value pair; a comma inside the value (e.g.
+ # `New York, NY`) does not terminate it.
+ while i < len(src):
+ if src[i] == "}":
+ break
+ if src[i] == "," and _GEMMA_NEXT_KEY_RE.match(src, i + 1):
+ break
+ i += 1
+ raw = src[v_start:i]
+ try:
+ json.loads(raw.strip())
+ parts.append(raw)
+ except (json.JSONDecodeError, ValueError):
+ parts.append(json.dumps(raw.strip()) if raw.strip() else raw)
+ else:
+ parts.append(src[key_start:i])
+ return "".join(parts)
+
+
+def _gemma_arguments_to_json(args_src: str) -> dict:
+ """Parse Gemma 4's native call:name{key:value} argument object."""
+ args_src = args_src.strip()
+ if not args_src:
+ return {}
+ src = _normalise_gemma_quoted_strings(args_src)
+ src = "{" + src + "}"
+ src = _quote_gemma_object_keys(src)
+ return json.loads(src)
+
+
+def _inside_open_parameter(content: str, pos: int) -> bool:
+ """Return True when ``pos`` falls inside an unclosed parameter value."""
+ last_param_start = -1
+ for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
+ last_param_start = match.start()
+ if last_param_start < 0:
+ return False
+ last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
+ last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
+ return last_param_start > max(last_param_close, last_func_close)
+
+
+def parse_tool_calls_from_text(
+ content: str,
+ *,
+ id_offset: int = 0,
+ allow_incomplete: bool = True,
+) -> list[dict]:
+ """Parse OpenAI-format tool calls from model text.
Handles formats like:
{"name":"web_search","arguments":{"query":"..."}}
+ <|tool_call>call:web_search{query:"..."}
...
- Closing tags (, , ) are all
- optional since models frequently omit them.
"""
- tool_calls = []
-
- # Pattern 1: JSON inside tags. Balanced-brace extraction that
- # skips braces inside JSON strings.
+ tool_calls: list[dict] = []
+ # Collect JSON- and Gemma-format candidates with their byte spans, then
+ # accept them in document order. Both order and spans matter:
+ # * tools execute in returned order, so a call appearing earlier in the
+ # text must be emitted first even across the two formats;
+ # * a tool-call marker INSIDE another call's argument string is data, not a
+ # call, so a candidate starting within an already accepted span is
+ # skipped (covers a JSON marker nested in a Gemma arg and a Gemma marker
+ # nested in a JSON arg alike, regardless of which format is outer).
+ candidates = [] # (start, brace_end, kind, match)
for m in _TC_JSON_START_RE.finditer(content):
- brace_start = m.end() - 1 # position of the opening {
- depth, i = 0, brace_start
- in_string = False
- while i < len(content):
- ch = content[i]
- if in_string:
- if ch == "\\" and i + 1 < len(content):
- i += 2 # skip escaped character
- continue
- if ch == '"':
- in_string = False
- elif ch == '"':
- in_string = True
- elif ch == "{":
- depth += 1
- elif ch == "}":
- depth -= 1
- if depth == 0:
- break
- i += 1
- if depth == 0:
- json_str = content[brace_start : i + 1]
- try:
- obj = json.loads(json_str)
- tc = {
- "id": f"call_{len(tool_calls)}",
- "type": "function",
- "function": {
- "name": obj.get("name", ""),
- "arguments": obj.get("arguments", {}),
- },
- }
- if isinstance(tc["function"]["arguments"], dict):
- tc["function"]["arguments"] = json.dumps(tc["function"]["arguments"])
- tool_calls.append(tc)
- except (json.JSONDecodeError, ValueError):
- pass
+ # A marker that begins inside an open value
+ # is that parameter's data, not its own call; skip it (same guard the
+ # XML-style parser below applies to nested = 0:
+ candidates.append((m.start(), end, "json", m))
+ for m in _TC_GEMMA_START_RE.finditer(content):
+ if _inside_open_parameter(content, m.start()):
+ continue
+ end = _balanced_brace_end(content, m.end() - 1, gemma_quotes = True)
+ if end >= 0:
+ candidates.append((m.start(), end, "gemma", m))
+ candidates.sort(key = lambda c: c[0])
+
+ spans = [(s, e) for s, e, _kind, _m in candidates]
+ for idx, (start, end, kind, m) in enumerate(candidates):
+ # Skip a candidate nested inside another candidate's brace span: it is
+ # the enclosing call's argument data, not its own call. Checked against
+ # every candidate span (not only the ones that parsed successfully), so a
+ # marker inside an outer call that later fails to normalize is still
+ # never promoted to its own executable tool call.
+ if any(s <= start and end <= e for j, (s, e) in enumerate(spans) if j != idx):
+ continue
+ if not allow_incomplete:
+ tail = content[end + 1 :].lstrip()
+ close_re = _TC_END_TAG_RE if kind == "json" else _TC_GEMMA_END_TAG_RE
+ if close_re.match(tail) is None:
+ continue
+ try:
+ if kind == "json":
+ obj = json.loads(content[m.end() - 1 : end + 1])
+ name = obj.get("name", "")
+ arguments = obj.get("arguments", {})
+ if isinstance(arguments, dict):
+ arguments = json.dumps(arguments)
+ else:
+ name = m.group(1)
+ arguments = json.dumps(_gemma_arguments_to_json(content[m.end() : end]))
+ except (json.JSONDecodeError, ValueError):
+ continue
+ tool_calls.append(
+ {
+ "id": f"call_{id_offset + len(tool_calls)}",
+ "type": "function",
+ "function": {"name": name, "arguments": arguments},
+ }
+ )
- # Pattern 2: XML-style value
- # All closing tags optional; models frequently omit them.
if not tool_calls:
- # Step 1: Find positions and extract bodies. Use only
- # or the next
- # can appear in code values); trim a trailing afterwards.
- func_starts = list(_TC_FUNC_START_RE.finditer(content))
+ func_starts = [
+ fm
+ for fm in _TC_FUNC_START_RE.finditer(content)
+ if not _inside_open_parameter(content, fm.start())
+ ]
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()
- # Boundaries: next
next_func = func_starts[idx + 1].start() if idx + 1 < len(func_starts) else len(content)
end_tag = _TC_END_TAG_RE.search(content[body_start:])
if end_tag:
@@ -107,36 +397,52 @@ def parse_tool_calls_from_text(content: str) -> list[dict]:
body_end = len(content)
body_end = min(body_end, next_func)
body = content[body_start:body_end]
- body = _TC_FUNC_CLOSE_RE.sub("", body) # trim closing
+ if not allow_incomplete:
+ close_idx = body.rfind(_FUNC_CLOSE_TAG)
+ if close_idx < 0:
+ continue
+ body = body[:close_idx]
+ else:
+ body = _TC_FUNC_CLOSE_RE.sub("", body)
- # Step 2: Extract parameters from body. For single-parameter
- # functions, use body end as the only boundary to avoid matching
- # inside code strings.
- arguments = {}
+ arguments: dict = {}
param_starts = list(_TC_PARAM_START_RE.finditer(body))
if len(param_starts) == 1:
- # Value is everything after the tag to end of body, less a
- # trailing .
pm = param_starts[0]
val = body[pm.end() :]
- val = _TC_PARAM_CLOSE_RE.sub("", val)
+ if not allow_incomplete:
+ stripped_val = val.rstrip()
+ if not stripped_val.endswith(_PARAM_CLOSE_TAG):
+ continue
+ val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
+ else:
+ val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[pm.group(1)] = val.strip()
else:
+ valid_params = True
for pidx, pm in enumerate(param_starts):
param_name = pm.group(1)
val_start = pm.end()
- # Value ends at next
+ if not allow_incomplete:
+ stripped_val = val.rstrip()
+ if not stripped_val.endswith(_PARAM_CLOSE_TAG):
+ valid_params = False
+ break
+ val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
+ else:
+ val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[param_name] = val.strip()
+ if not valid_params:
+ continue
tc = {
- "id": f"call_{len(tool_calls)}",
+ "id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {
"name": func_name,
@@ -147,6 +453,41 @@ def parse_tool_calls_from_text(content: str) -> list[dict]:
return tool_calls
+def _strip_gemma_native_spans(text: str, *, final: bool) -> str:
+ """Remove complete Gemma-native ``<|tool_call>call:NAME{...}``
+ spans, brace- and quote-balanced so a literal ```` inside a
+ ``<|"|>``-quoted argument does not truncate the span and leak its suffix
+ (which the plain ``.*?`` regex does). A span without a balanced closing
+ ``}`` or a trailing close marker is incomplete: dropped to EOF when
+ ``final`` (the response is over), otherwise kept verbatim so a call that is
+ still streaming is not stripped mid-token.
+ """
+ out: list[str] = []
+ cursor = 0
+ for match in _TC_GEMMA_START_RE.finditer(text):
+ start = match.start()
+ if start < cursor:
+ continue
+ brace_end = _balanced_brace_end(text, match.end() - 1, gemma_quotes = True)
+ if brace_end < 0:
+ if final:
+ out.append(text[cursor:start])
+ cursor = len(text)
+ continue
+ tail = text[brace_end + 1 :]
+ leading_ws = len(tail) - len(tail.lstrip())
+ close = _TC_GEMMA_END_TAG_RE.match(tail, leading_ws)
+ if close is None:
+ if final:
+ out.append(text[cursor:start])
+ cursor = len(text)
+ continue
+ out.append(text[cursor:start])
+ cursor = brace_end + 1 + close.end()
+ out.append(text[cursor:])
+ return "".join(out)
+
+
def strip_tool_call_markup(text: str, *, final: bool = False) -> str:
"""Strip tool-call XML markup from text.
@@ -154,7 +495,14 @@ def strip_tool_call_markup(text: str, *, final: bool = False) -> str:
When ``final`` is True, trailing incomplete tool-call blocks are removed
too, and the result is stripped of surrounding whitespace.
"""
+ # Gemma-native spans are stripped brace/quote-aware first; the regex form is
+ # not quote-aware and would truncate a span at a close marker inside a quoted
+ # argument. Skip that regex below and let the remaining patterns handle the
+ # JSON/XML formats and any orphan close marker.
+ text = _strip_gemma_native_spans(text, final = final)
patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
for pat in patterns:
+ if pat is _TC_GEMMA_CLOSED_PAT:
+ continue
text = pat.sub("", text)
return text.strip() if final else text
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index b8432f588c..26825a472e 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -1102,6 +1102,8 @@ class ChoiceDelta(BaseModel):
role: Optional[str] = None
content: Optional[str] = None
+ reasoning_content: Optional[str] = None
+ tool_calls: Optional[list[dict]] = None
OpenAIFinishReason = Literal["stop", "length", "tool_calls", "content_filter", "function_call"]
@@ -1137,6 +1139,8 @@ class CompletionMessage(BaseModel):
role: Literal["assistant"] = "assistant"
content: str
refusal: Optional[str] = None
+ reasoning_content: Optional[str] = None
+ tool_calls: Optional[list[dict]] = None
class CompletionChoice(BaseModel):
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index 53d81961c3..01e60be873 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -12,6 +12,7 @@ import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse, JSONResponse, Response
+from starlette.requests import ClientDisconnect
from typing import Any, List, Optional, Union
import json
import httpx
@@ -235,8 +236,15 @@ def _sse_streaming_response(content) -> StreamingResponse:
a one-shot connection. Two callers build their response inline instead: the
external-provider proxy omits ``Connection: close``, and the OpenAI
passthrough returns an empty ``keep-alive`` stream when the request is
- cancelled before the upstream response starts."""
- return StreamingResponse(
+ cancelled before the upstream response starts.
+
+ Built on ``_SameTaskStreamingResponse`` (not Starlette's stock
+ ``StreamingResponse``) so the SSE generator runs in the request task. The
+ legacy AnyIO task-group wrapper trips "Attempted to exit a cancel scope in a
+ different task" on Python 3.13 + httpx, which surfaced as a mid-stream
+ ``response.failed``. The streaming paths that take their response inline use
+ ``_SameTaskStreamingResponse`` directly for the same reason."""
+ return _SameTaskStreamingResponse(
content,
media_type = "text/event-stream",
headers = {
@@ -750,6 +758,139 @@ def _set_stream_response_read_timeout(
pass
+_STREAM_DISCONNECT_POLL_TIMEOUT_S = 0.25
+
+
+class _CompatSameTaskTimeout:
+ """Same-task timeout fallback for Python versions before asyncio.timeout."""
+
+ def __init__(self, timeout_s: float):
+ self.timeout_s = timeout_s
+ self._task = None
+ self._handle = None
+ self._timed_out = False
+ self._cancelling = 0
+
+ async def __aenter__(self):
+ self._task = asyncio.current_task()
+ if self._task is None:
+ return self
+ if hasattr(self._task, "cancelling"):
+ self._cancelling = self._task.cancelling()
+ loop = asyncio.get_running_loop()
+ self._handle = loop.call_later(max(self.timeout_s, 0), self._cancel_task)
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ if self._handle is not None:
+ self._handle.cancel()
+ if exc_type is not None and issubclass(exc_type, asyncio.CancelledError):
+ if self._timed_out:
+ if self._task is not None and hasattr(self._task, "uncancel"):
+ if self._task.uncancel() > self._cancelling:
+ return None
+ raise asyncio.TimeoutError from exc
+ return None
+
+ def _cancel_task(self) -> None:
+ self._timed_out = True
+ if self._task is not None:
+ self._task.cancel()
+
+
+def _same_task_timeout(timeout_s: float):
+ timeout_ctx = getattr(asyncio, "timeout", None)
+ if timeout_ctx is not None:
+ return timeout_ctx(timeout_s)
+ return _CompatSameTaskTimeout(timeout_s)
+
+
+class _SameTaskStreamingResponse(StreamingResponse):
+ """StreamingResponse without Starlette's legacy AnyIO task-group wrapper."""
+
+ def __init__(
+ self,
+ *args,
+ unstarted_cleanup = None,
+ **kwargs,
+ ) -> None:
+ super().__init__(*args, **kwargs)
+ # Async callable invoked when the client disconnects before the body
+ # iterator is ever advanced. A generator that never started cannot run
+ # its own try/finally, so a stream that acquires resources before its
+ # first yield (the passthrough opens an upstream httpx stream eagerly)
+ # passes this to release them.
+ self._unstarted_cleanup = unstarted_cleanup
+
+ async def __call__(self, scope, receive, send) -> None:
+ # Track whether the body iterator was ever advanced: send() only emits a
+ # body message after the generator yields its first chunk, so a failure
+ # before then means it never entered its try/finally.
+ body_started = False
+
+ async def _tracking_send(message) -> None:
+ nonlocal body_started
+ if message.get("type") == "http.response.body":
+ body_started = True
+ await send(message)
+
+ try:
+ await self.stream_response(_tracking_send)
+ except OSError:
+ # Client disconnected mid-send.
+ if body_started:
+ # The generator produced at least one chunk and is suspended in
+ # its try/finally. Throw CancelledError into it (not aclose's
+ # GeneratorExit) so its `except asyncio.CancelledError` handler
+ # runs and finishes any api_monitor entry; GeneratorExit would
+ # skip it and only run `finally`. Fall back to aclose() without
+ # athrow.
+ athrow = getattr(self.body_iterator, "athrow", None)
+ if athrow is not None:
+ try:
+ await athrow(asyncio.CancelledError())
+ except (asyncio.CancelledError, StopAsyncIteration, RuntimeError):
+ pass
+ else:
+ aclose = getattr(self.body_iterator, "aclose", None)
+ if aclose is not None:
+ await aclose()
+ else:
+ # http.response.start failed before the body iterator advanced,
+ # so its try/finally never armed and aclose()/athrow() are no-ops
+ # on an unstarted generator. Release any resources acquired
+ # before the first yield via the explicit cleanup hook.
+ aclose = getattr(self.body_iterator, "aclose", None)
+ if aclose is not None:
+ await aclose()
+ # getattr (not self._unstarted_cleanup) so a response built via
+ # __new__ (some tests, pickling) without __init__ does not raise
+ # AttributeError here.
+ cleanup = getattr(self, "_unstarted_cleanup", None)
+ if cleanup is not None:
+ try:
+ await cleanup()
+ except Exception:
+ pass
+ raise ClientDisconnect()
+ if self.background is not None:
+ await self.background()
+
+
+def _tracked_cancel_unstarted_cleanup(tracker):
+ """Build an ``unstarted_cleanup`` for a local stream that entered ``tracker``
+ (a ``_TrackedCancel``) before returning the response. The generator exits the
+ tracker in its ``finally``, but that never runs if the client disconnects
+ before the body iterator starts, leaking the cancel-registry entry. This
+ exits the tracker on that pre-start path only (mutually exclusive with the
+ generator's finally, so it never double-exits)."""
+
+ async def _cleanup() -> None:
+ tracker.__exit__(None, None, None)
+
+ return _cleanup
+
+
async def _aclose_stream_resources(
*,
watchers = (),
@@ -875,8 +1016,23 @@ async def _aiter_llama_stream_items(
raise httpx.ReadTimeout("The model did not produce a first token in time.")
if response is not None:
_set_stream_response_read_timeout(response, remaining_s)
- item = await asyncio.wait_for(async_iter.__anext__(), timeout = remaining_s)
+ # Keep httpx/httpcore's AnyIO cancel scope in this task.
+ # asyncio.wait_for would drive __anext__ in a child task.
+ async with _same_task_timeout(remaining_s):
+ item = await async_iter.__anext__()
else:
+ if (
+ request is not None
+ and response is not None
+ and post_first_item_read_timeout_s is not None
+ and last_item_at is not None
+ ):
+ stall_remaining_s = post_first_item_read_timeout_s - (
+ time.monotonic() - last_item_at
+ )
+ if stall_remaining_s <= 0:
+ raise httpx.ReadTimeout("The model stopped producing tokens mid-response.")
+ _set_stream_response_read_timeout(response, stall_remaining_s)
item = await async_iter.__anext__()
except asyncio.TimeoutError as exc:
if waiting_first_item:
@@ -890,6 +1046,12 @@ async def _aiter_llama_stream_items(
if now >= first_token_deadline:
raise
continue
+ if (
+ request is not None
+ and post_first_item_read_timeout_s is not None
+ and now - last_item_at < post_first_item_read_timeout_s
+ ):
+ continue
raise httpx.ReadTimeout("The model stopped producing tokens mid-response.")
if (
last_item_at is None
@@ -1125,16 +1287,17 @@ def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
model_identifier = model_id,
log_source = "safetensors",
)
- # Our safetensors loop only parses {json} and
- # .... Llama uses <|python_tag|>, Mistral uses
- # [TOOL_CALLS]; advertising tools for those enables a pill the parser
- # can't honour. GGUF is unaffected -- llama-server normalises every
- # format into structured deltas.
+ # Our safetensors loop only parses {json},
+ # ..., and Gemma native <|tool_call>....
+ # Llama uses <|python_tag|>, Mistral uses [TOOL_CALLS]; advertising tools for
+ # those enables a pill the parser can't honour. GGUF is unaffected --
+ # llama-server normalises every format into structured deltas.
if (
flags.get("supports_tools")
and chat_template
and "" not in chat_template
and "" not in chat_template
):
logger.info(
"safetensors: template advertises tools but uses an "
@@ -1297,6 +1460,24 @@ async def _await_disconnect_then_close(request, resp, cancel_event) -> None:
return
+async def _await_disconnect_then_cancel(request, cancel_event) -> None:
+ """Set ``cancel_event`` when a same-task local stream disconnects."""
+ try:
+ while not await request.is_disconnected():
+ await asyncio.sleep(0.1)
+ cancel_event.set()
+ except asyncio.CancelledError:
+ return
+
+
+async def _stop_local_disconnect_cancel_watcher(watcher) -> None:
+ watcher.cancel()
+ try:
+ await watcher
+ except (asyncio.CancelledError, Exception):
+ pass
+
+
# Centralized local/server tool nudge. Keep render_html guidance gated to turns
# where the canvas tool is actually present in the tool schema; otherwise
# small local models can hallucinate a missing tool call instead of following
@@ -1418,7 +1599,9 @@ _TOOL_XML_RE = _re.compile(
# Hyphen in the name char-class matches MCP tool names with dashes
# (mcp__srv__list-issues) that would otherwise leak past this strip.
r"<(?:tool_call|function=[\w-]+)>.*?(?:(?:tool_call|function)>|\Z)"
+ r"|<\|tool_call>.*?(?:|\Z)"
r"|(?:tool_call|function)>"
+ r"|"
r"|\s*\Z",
_re.DOTALL,
)
@@ -3221,7 +3404,9 @@ async def get_api_monitor_entry(entry_id: str, current_subject: str = Depends(ge
@router.post("/generate/stream")
async def generate_stream(
- request: GenerateRequest, current_subject: str = Depends(get_current_subject)
+ request: GenerateRequest,
+ fastapi_request: Request,
+ current_subject: str = Depends(get_current_subject),
):
"""
Generate a chat response with Server-Sent Events (SSE) streaming.
@@ -3271,6 +3456,13 @@ async def generate_stream(
async def stream():
gen = None
completed = False
+ # Cancel the generation when the client disconnects. The generator only
+ # awaits asyncio.to_thread(next, gen, ...), so without a concurrent
+ # watcher a disconnect during a long prefill/generation would go
+ # unnoticed until the next send and the backend would keep generating.
+ disconnect_watcher = asyncio.create_task(
+ _await_disconnect_then_cancel(fastapi_request, cancel_event)
+ )
try:
gen = backend.generate_chat_response(
messages = request.messages,
@@ -3285,12 +3477,22 @@ async def generate_stream(
)
_DONE = object()
while True:
+ if cancel_event.is_set():
+ # The disconnect watcher set cancel_event between chunks.
+ # Reset the backend here: closing the Python generator does
+ # not signal a subprocess backend, so without this it keeps
+ # decoding after the client is gone. The finally's reset is
+ # guarded on cancel_event being unset, so it will not run
+ # again for this path.
+ backend.reset_generation_state()
+ break
chunk = await asyncio.to_thread(next, gen, _DONE)
if chunk is _DONE:
+ completed = True
break
yield f"data: {json.dumps({'content': chunk})}\n\n"
- completed = True
- yield "data: [DONE]\n\n"
+ if completed:
+ yield "data: [DONE]\n\n"
except asyncio.CancelledError:
cancel_event.set()
@@ -3302,6 +3504,7 @@ async def generate_stream(
logger.error(f"Error during generation: {e}", exc_info = True)
yield f"data: {json.dumps({'error': _friendly_error(e)})}\n\n"
finally:
+ await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
if not completed and not cancel_event.is_set():
cancel_event.set()
backend.reset_generation_state()
@@ -4725,6 +4928,9 @@ async def openai_chat_completions(
_tracker.__enter__()
async def audio_input_stream():
+ disconnect_watcher = asyncio.create_task(
+ _await_disconnect_then_cancel(request, cancel_event)
+ )
try:
yield _chat_role_chunk(completion_id, created, model_name)
@@ -4760,9 +4966,19 @@ async def openai_chat_completions(
api_monitor.fail(monitor_id, _friendly_error(e))
yield f"data: {json.dumps({'error': {'message': _friendly_error(e), 'type': 'server_error'}})}\n\n"
finally:
+ await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
_tracker.__exit__(None, None, None)
- return _sse_streaming_response(audio_input_stream())
+ return _SameTaskStreamingResponse(
+ audio_input_stream(),
+ unstarted_cleanup = _tracked_cancel_unstarted_cleanup(_tracker),
+ media_type = "text/event-stream",
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "close",
+ "X-Accel-Buffering": "no",
+ },
+ )
else:
try:
full_text = "".join(audio_input_generate())
@@ -4937,6 +5153,28 @@ async def openai_chat_completions(
completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
created = int(time.time())
+ def _new_chat_reasoning_extractor():
+ return _ResponsesReasoningExtractor(
+ parse_think_markers = _responses_should_parse_think_markers(
+ payload,
+ llama_backend,
+ )
+ )
+
+ def _gguf_chat_delta_line(delta: ChoiceDelta, finish_reason = None) -> str:
+ chunk = ChatCompletionChunk(
+ id = completion_id,
+ created = created,
+ model = model_name,
+ choices = [
+ ChunkChoice(
+ delta = delta,
+ finish_reason = finish_reason,
+ )
+ ],
+ )
+ return f"data: {chunk.model_dump_json(exclude_none = True)}\n\n"
+
# ── Tool-calling path (agentic loop) ──────────────────
# `_effective_enable_tools` lets `unsloth run --enable-tools/--disable-tools`
# hard-override the per-request value, else falls back to
@@ -5049,6 +5287,9 @@ async def openai_chat_completions(
async def gguf_tool_stream():
gen = None
+ disconnect_watcher = asyncio.create_task(
+ _await_disconnect_then_cancel(request, cancel_event)
+ )
try:
yield _chat_role_chunk(completion_id, created, model_name)
@@ -5056,9 +5297,25 @@ async def openai_chat_completions(
# stays free for disconnect detection.
gen = gguf_generate_with_tools()
prev_text = ""
+ reasoning_extractor = _new_chat_reasoning_extractor()
_stream_usage = None
_stream_timings = None
_stream_finish = None
+
+ def _flush_reasoning_extractor():
+ final_reasoning, final_visible = reasoning_extractor.finish()
+ chunks = []
+ if final_reasoning:
+ chunks.append(
+ _gguf_chat_delta_line(
+ ChoiceDelta(reasoning_content = final_reasoning)
+ )
+ )
+ if final_visible:
+ api_monitor.append_reply(monitor_id, final_visible)
+ chunks.append(_gguf_chat_delta_line(ChoiceDelta(content = final_visible)))
+ return chunks
+
while True:
if cancel_event.is_set():
break
@@ -5077,7 +5334,10 @@ async def openai_chat_completions(
# cumulative cursor so the next assistant turn
# streams cleanly.
if not event["text"]:
+ for chunk in _flush_reasoning_extractor():
+ yield chunk
prev_text = ""
+ reasoning_extractor = _new_chat_reasoning_extractor()
# Emit tool status as a custom SSE event (including
# empty ones to clear UI badges)
status_data = json.dumps(
@@ -5091,7 +5351,10 @@ async def openai_chat_completions(
if event["type"] in ("tool_start", "tool_end"):
if event["type"] == "tool_start":
+ for chunk in _flush_reasoning_extractor():
+ yield chunk
prev_text = ""
+ reasoning_extractor = _new_chat_reasoning_extractor()
yield f"data: {json.dumps(event)}\n\n"
continue
@@ -5113,15 +5376,33 @@ async def openai_chat_completions(
prev_text = clean_cumulative
if not new_text:
continue
- api_monitor.append_reply(monitor_id, new_text)
- yield _chat_content_chunk(completion_id, created, model_name, new_text)
+ reasoning_delta, visible_delta = reasoning_extractor.feed(new_text)
+ if reasoning_delta:
+ yield _gguf_chat_delta_line(
+ ChoiceDelta(reasoning_content = reasoning_delta)
+ )
+ if visible_delta:
+ api_monitor.append_reply(monitor_id, visible_delta)
+ yield _gguf_chat_delta_line(ChoiceDelta(content = visible_delta))
- yield _chat_final_chunk(
- completion_id,
- created,
- model_name,
- _clamp_finish_reason(_stream_finish),
+ for chunk in _flush_reasoning_extractor():
+ yield chunk
+
+ final_chunk = ChatCompletionChunk(
+ id = completion_id,
+ created = created,
+ model = model_name,
+ choices = [
+ ChunkChoice(
+ delta = ChoiceDelta(),
+ finish_reason = _clamp_finish_reason(_stream_finish),
+ )
+ ],
)
+ # Emit the terminal chunk carrying finish_reason before the
+ # optional usage chunk and [DONE], so OpenAI-compatible
+ # clients can detect stop/length/tool_calls.
+ yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n"
usage_line = _openai_stream_usage_chunk(
payload,
completion_id,
@@ -5150,6 +5431,7 @@ async def openai_chat_completions(
error_chunk = _openai_stream_error_chunk(e)
yield f"data: {json.dumps(error_chunk)}\n\n"
finally:
+ await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
if gen is not None:
try:
gen.close()
@@ -5157,7 +5439,16 @@ async def openai_chat_completions(
pass
_tracker.__exit__(None, None, None)
- return _sse_streaming_response(gguf_tool_stream())
+ return _SameTaskStreamingResponse(
+ gguf_tool_stream(),
+ unstarted_cleanup = _tracked_cancel_unstarted_cleanup(_tracker),
+ media_type = "text/event-stream",
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "close",
+ "X-Accel-Buffering": "no",
+ },
+ )
# ── Standard GGUF path (no tools) ─────────────────────
@@ -5193,6 +5484,9 @@ async def openai_chat_completions(
_tracker.__enter__()
async def gguf_stream_chunks():
+ disconnect_watcher = asyncio.create_task(
+ _await_disconnect_then_cancel(request, cancel_event)
+ )
try:
yield _chat_role_chunk(completion_id, created, model_name)
@@ -5200,6 +5494,7 @@ async def openai_chat_completions(
# stays free for disconnect detection.
gen = gguf_generate()
prev_text = ""
+ reasoning_extractor = _new_chat_reasoning_extractor()
_stream_usage = None
_stream_timings = None
_stream_finish = None
@@ -5233,15 +5528,38 @@ async def openai_chat_completions(
prev_text = cumulative
if not new_text:
continue
- api_monitor.append_reply(monitor_id, new_text)
- yield _chat_content_chunk(completion_id, created, model_name, new_text)
+ reasoning_delta, visible_delta = reasoning_extractor.feed(new_text)
+ if reasoning_delta:
+ yield _gguf_chat_delta_line(
+ ChoiceDelta(reasoning_content = reasoning_delta)
+ )
+ if visible_delta:
+ api_monitor.append_reply(monitor_id, visible_delta)
+ yield _gguf_chat_delta_line(ChoiceDelta(content = visible_delta))
- yield _chat_final_chunk(
- completion_id,
- created,
- model_name,
- _clamp_finish_reason(_stream_finish),
+ final_reasoning, final_visible = reasoning_extractor.finish()
+ if final_reasoning:
+ yield _gguf_chat_delta_line(ChoiceDelta(reasoning_content = final_reasoning))
+ if final_visible:
+ api_monitor.append_reply(monitor_id, final_visible)
+ yield _gguf_chat_delta_line(ChoiceDelta(content = final_visible))
+
+ # Final chunk
+ final_chunk = ChatCompletionChunk(
+ id = completion_id,
+ created = created,
+ model = model_name,
+ choices = [
+ ChunkChoice(
+ delta = ChoiceDelta(),
+ finish_reason = _clamp_finish_reason(_stream_finish),
+ )
+ ],
)
+ # Emit the terminal chunk carrying finish_reason before the
+ # optional usage chunk and [DONE], so OpenAI-compatible
+ # clients can detect stop/length/tool_calls.
+ yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n"
usage_line = _openai_stream_usage_chunk(
payload,
completion_id,
@@ -5268,9 +5586,19 @@ async def openai_chat_completions(
error_chunk = _openai_stream_error_chunk(e)
yield f"data: {json.dumps(error_chunk)}\n\n"
finally:
+ await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
_tracker.__exit__(None, None, None)
- return _sse_streaming_response(gguf_stream_chunks())
+ return _SameTaskStreamingResponse(
+ gguf_stream_chunks(),
+ unstarted_cleanup = _tracked_cancel_unstarted_cleanup(_tracker),
+ media_type = "text/event-stream",
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "close",
+ "X-Accel-Buffering": "no",
+ },
+ )
else:
try:
# ``n`` requests several independent completions; the single
@@ -5297,14 +5625,24 @@ async def openai_chat_completions(
continue
full_text = token
+ reasoning_text, visible_text = _extract_responses_reasoning(
+ full_text,
+ parse_think_markers = _responses_should_parse_think_markers(
+ payload,
+ llama_backend,
+ ),
+ )
+ message_kwargs = {"content": visible_text}
+ if reasoning_text:
+ message_kwargs["reasoning_content"] = reasoning_text
_choices.append(
CompletionChoice(
index = _idx,
- message = CompletionMessage(content = full_text),
+ message = CompletionMessage(**message_kwargs),
finish_reason = _clamp_finish_reason(completion_finish),
)
)
- _monitor_replies.append(full_text)
+ _monitor_replies.append(visible_text)
if completion_usage:
# The prompt is shared across all n choices, so count its
# tokens ONCE (OpenAI bills only generated tokens for each
@@ -5326,7 +5664,7 @@ async def openai_chat_completions(
prompt_tokens_details = _prompt_tokens_details(_prompt_details),
),
)
- monitor_reply = full_text
+ monitor_reply = _monitor_replies[-1] if _monitor_replies else ""
if _n > 1:
monitor_reply = "\n\n".join(
f"Choice {_idx + 1}:\n{text}" for _idx, text in enumerate(_monitor_replies)
@@ -5536,6 +5874,9 @@ async def openai_chat_completions(
async def sf_tool_stream():
gen = None
+ disconnect_watcher = asyncio.create_task(
+ _await_disconnect_then_cancel(request, cancel_event)
+ )
try:
yield _chat_role_chunk(completion_id, created, model_name)
@@ -5627,6 +5968,7 @@ async def openai_chat_completions(
}
yield f"data: {json.dumps(error_chunk)}\n\n"
finally:
+ await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
if gen is not None:
try:
gen.close()
@@ -5635,7 +5977,16 @@ async def openai_chat_completions(
_sf_tracker.__exit__(None, None, None)
if payload.stream:
- return _sse_streaming_response(sf_tool_stream())
+ return _SameTaskStreamingResponse(
+ sf_tool_stream(),
+ unstarted_cleanup = _tracked_cancel_unstarted_cleanup(_sf_tracker),
+ media_type = "text/event-stream",
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "close",
+ "X-Accel-Buffering": "no",
+ },
+ )
# Non-streaming JSON: drain the loop, build one ChatCompletion.
try:
@@ -5737,6 +6088,9 @@ async def openai_chat_completions(
_tracker.__enter__()
async def stream_chunks():
+ disconnect_watcher = asyncio.create_task(
+ _await_disconnect_then_cancel(request, cancel_event)
+ )
try:
yield _chat_role_chunk(completion_id, created, model_name)
@@ -5813,9 +6167,19 @@ async def openai_chat_completions(
}
yield f"data: {json.dumps(error_chunk)}\n\n"
finally:
+ await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
_tracker.__exit__(None, None, None)
- return _sse_streaming_response(stream_chunks())
+ return _SameTaskStreamingResponse(
+ stream_chunks(),
+ unstarted_cleanup = _tracked_cancel_unstarted_cleanup(_tracker),
+ media_type = "text/event-stream",
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "close",
+ "X-Accel-Buffering": "no",
+ },
+ )
# ── Non-streaming response ────────────────────────────────────
else:
@@ -6533,8 +6897,9 @@ def _responses_should_parse_think_markers(
if llama_backend is not None and getattr(llama_backend, "is_loaded", False):
if getattr(llama_backend, "reasoning_always_on", False):
return True
- if not getattr(llama_backend, "supports_reasoning", False):
- return False
+ if getattr(llama_backend, "supports_reasoning", False):
+ return True
+ return False
if chat_req.enable_thinking is True:
return True
return chat_req.enable_thinking is None and chat_req.reasoning_effort not in (None, "none")
@@ -6830,8 +7195,6 @@ async def _responses_non_streaming(
# the model produced content, so clients expecting a pure tool-call turn
# (finish_reason="tool_calls") don't see a spurious empty message item.
output_items: list[dict] = []
- if reasoning_text and not text and not tool_calls:
- text = reasoning_text
if reasoning_text:
output_items.append(_responses_reasoning_output_item(reasoning_text))
if text:
@@ -7144,8 +7507,8 @@ async def _responses_stream(
client = httpx.AsyncClient(timeout = _llama_streaming_generation_timeout())
resp = None
lines_iter = None
- disconnect_event = threading.Event()
disconnect_watcher = None
+ disconnect_event = threading.Event()
try:
req = client.build_request(
"POST", target_url, json = body, headers = {"Connection": "close"}
@@ -7205,10 +7568,10 @@ async def _responses_stream(
)
return
+ lines_iter = resp.aiter_lines()
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_close(request, resp, disconnect_event)
)
- lines_iter = resp.aiter_lines()
async for raw_line in _aiter_llama_stream_items(
lines_iter,
cancel_event = disconnect_event,
@@ -7328,6 +7691,7 @@ async def _responses_stream(
_apply_usage(chunk_data.get("usage"))
except asyncio.CancelledError:
+ disconnect_event.set()
api_monitor.finish(monitor_id, "cancelled")
raise
except (httpx.RemoteProtocolError, httpx.ReadError, httpx.CloseError) as e:
@@ -7394,21 +7758,6 @@ async def _responses_stream(
"delta": final_visible,
},
)
- if full_reasoning and not full_text and not tool_call_state:
- for event in _ensure_message_open():
- yield event
- full_text = full_reasoning
- api_monitor.set_reply(monitor_id, full_text)
- yield _sse(
- "response.output_text.delta",
- {
- "type": "response.output_text.delta",
- "item_id": message_state["item_id"],
- "output_index": message_state["output_index"],
- "content_index": 0,
- "delta": full_text,
- },
- )
close_items: list[tuple[int, str, dict[str, Any]]] = []
if reasoning_state["opened"]:
@@ -7569,7 +7918,15 @@ async def _responses_stream(
api_monitor.finish(monitor_id)
yield _sse("response.completed", completed_response)
- return _sse_streaming_response(event_generator())
+ return _SameTaskStreamingResponse(
+ event_generator(),
+ media_type = "text/event-stream",
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "close",
+ "X-Accel-Buffering": "no",
+ },
+ )
@router.post("/responses")
@@ -8185,9 +8542,17 @@ async def _anthropic_tool_stream(
drop_until_tool_end = False
gen = run_gen()
+ # Concurrent disconnect watcher: the loop only polls is_disconnected()
+ # between events, so a client disconnect during a long prefill or
+ # generation step would otherwise hold the decode slot until the next
+ # event or a failed send. The watcher sets cancel_event so the backend
+ # stops promptly.
+ disconnect_watcher = asyncio.create_task(
+ _await_disconnect_then_cancel(request, cancel_event)
+ )
try:
while True:
- if await request.is_disconnected():
+ if cancel_event.is_set() or await request.is_disconnected():
cancel_event.set()
return
event = await asyncio.to_thread(next, gen, _sentinel)
@@ -8235,6 +8600,8 @@ async def _anthropic_tool_stream(
if _error_event is not None:
yield _error_event
return
+ finally:
+ await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
stop_reason = openai_finish_to_anthropic_stop(
captured_finish_reason, had_tool_calls = ends_on_tool_use
@@ -8271,9 +8638,17 @@ async def _anthropic_plain_stream(
captured_finish_reason = None
gen = run_gen()
+ # Concurrent disconnect watcher: the loop only polls is_disconnected()
+ # between chunks, so a client disconnect during a long prefill or
+ # generation step would otherwise hold the decode slot until the next
+ # chunk or a failed send. The watcher sets cancel_event so the backend
+ # stops promptly.
+ disconnect_watcher = asyncio.create_task(
+ _await_disconnect_then_cancel(request, cancel_event)
+ )
try:
while True:
- if await request.is_disconnected():
+ if cancel_event.is_set() or await request.is_disconnected():
cancel_event.set()
return
cumulative = await asyncio.to_thread(next, gen, _sentinel)
@@ -8296,6 +8671,8 @@ async def _anthropic_plain_stream(
if _error_event is not None:
yield _error_event
return
+ finally:
+ await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
stop_reason = openai_finish_to_anthropic_stop(captured_finish_reason, had_tool_calls = False)
for line in emitter.finish(stop_reason = stop_reason, stop_sequence = None):
@@ -9139,6 +9516,19 @@ async def _openai_passthrough_stream(
response ``id``, ``finish_reason`` (including ``"tool_calls"``),
``delta.tool_calls``, and any client-requested trailing ``usage`` chunk so
the client sees a standard OpenAI response.
+
+ Reasoning/tool-call extraction here is delegated to llama-server: this path
+ forwards to its ``/v1/chat/completions`` (Studio launches with ``--jinja``
+ and ``--reasoning-format auto``), which parses Gemma-native ```` into
+ ``reasoning_content`` and ``<|tool_call>`` into structured ``tool_calls``
+ server-side, so the relayed ``delta.content`` carries no raw markup. This is
+ deliberately NOT re-parsed with the local reasoning extractor / Gemma parser
+ (verified end to end on the current llama.cpp build), unlike Studio's own
+ ``/completion``-level generation paths, which must parse the raw text
+ themselves. The dependency is on llama.cpp's chat parser: if a future build
+ or chat template stops splitting ````/``<|tool_call>``, raw markup
+ would relay into ``content`` and this path would need the local extractor as
+ a safety net.
"""
target_url = f"{llama_backend.base_url}/v1/chat/completions"
body = _build_openai_passthrough_body(
@@ -9187,7 +9577,7 @@ async def _openai_passthrough_stream(
except Exception:
pass
_tracker.__exit__(None, None, None)
- return StreamingResponse(
+ return _SameTaskStreamingResponse(
iter(()),
media_type = "text/event-stream",
headers = {
@@ -9238,6 +9628,29 @@ async def _openai_passthrough_stream(
_await_disconnect_then_close(request, resp, cancel_event)
)
monitor_done = False
+ saw_finish_reason = False
+ saw_done = False
+ saw_stream_error = False
+ saw_tool_call_delta = False
+ last_chunk_id = completion_id
+ last_chunk_model = model_name
+ last_chunk_created = int(time.time())
+
+ def _synthetic_finish_line() -> str:
+ finish_reason = "tool_calls" if saw_tool_call_delta else "stop"
+ chunk = ChatCompletionChunk(
+ id = last_chunk_id,
+ created = last_chunk_created,
+ model = last_chunk_model,
+ choices = [
+ ChunkChoice(
+ delta = ChoiceDelta(),
+ finish_reason = finish_reason,
+ )
+ ],
+ )
+ return f"data: {chunk.model_dump_json(exclude_none = True)}"
+
try:
lines_iter = resp.aiter_lines()
async for raw_line in _aiter_llama_stream_items(
@@ -9251,23 +9664,117 @@ async def _openai_passthrough_stream(
continue
if not raw_line.startswith("data: "):
continue
+ data_text = raw_line[6:].strip()
+ if data_text == "[DONE]":
+ saw_done = True
+ if (
+ not saw_finish_reason
+ and not saw_stream_error
+ and not cancel_event.is_set()
+ ):
+ finish_line = _synthetic_finish_line()
+ _monitor_openai_sse_line(
+ monitor_id,
+ finish_line,
+ llama_backend.context_length,
+ )
+ yield finish_line + "\n\n"
+ saw_finish_reason = True
+ _monitor_openai_sse_line(
+ monitor_id,
+ raw_line,
+ llama_backend.context_length,
+ )
+ yield raw_line + "\n\n"
+ monitor_done = True
+ break
# Honor parallel_tool_calls=false (best-effort): drop tool_call
# deltas with index>=1 so only the first call streams. Only
# lines carrying tool_calls are reparsed; everything else is
# relayed byte-for-byte.
if payload.parallel_tool_calls is False and '"tool_calls"' in raw_line:
raw_line = _cap_parallel_tool_calls_sse_line(raw_line)
+ data_text = raw_line[6:].strip()
+ try:
+ chunk_data = json.loads(data_text)
+ except json.JSONDecodeError:
+ chunk_data = None
+ if isinstance(chunk_data, dict):
+ if isinstance(chunk_data.get("id"), str):
+ last_chunk_id = chunk_data["id"]
+ if isinstance(chunk_data.get("model"), str):
+ last_chunk_model = chunk_data["model"]
+ if isinstance(chunk_data.get("created"), int):
+ last_chunk_created = chunk_data["created"]
+ choices = chunk_data.get("choices")
+ if isinstance(choices, list) and choices:
+ choice = choices[0]
+ if isinstance(choice, dict):
+ if choice.get("finish_reason"):
+ saw_finish_reason = True
+ delta = choice.get("delta")
+ if isinstance(delta, dict) and delta.get("tool_calls"):
+ saw_tool_call_delta = True
+ # Detect an upstream error chunk independently of API
+ # monitoring: when monitor_id is None (skip_api_monitor),
+ # _monitor_openai_sse_line returns before inspecting the
+ # error, so without this the synthetic-finish guard would
+ # emit a successful finish_reason after a failed stream.
+ if _monitor_openai_error_message(chunk_data):
+ saw_stream_error = True
monitor_event = _monitor_openai_sse_line(
monitor_id,
raw_line,
llama_backend.context_length,
)
+ if monitor_event == "error":
+ saw_stream_error = True
+ # If a trailing usage-only chunk (include_usage) arrives before
+ # any finish chunk, emit the synthetic finish first so the order
+ # stays finish -> usage -> [DONE], matching the other streams.
+ if (
+ isinstance(chunk_data, dict)
+ and chunk_data.get("usage")
+ and not (
+ isinstance(chunk_data.get("choices"), list) and chunk_data["choices"]
+ )
+ and not saw_finish_reason
+ and not saw_stream_error
+ and not cancel_event.is_set()
+ ):
+ finish_line = _synthetic_finish_line()
+ _monitor_openai_sse_line(
+ monitor_id, finish_line, llama_backend.context_length
+ )
+ yield finish_line + "\n\n"
+ saw_finish_reason = True
# Relay verbatim to preserve llama-server's native id,
# finish_reason, delta.tool_calls, and usage chunks.
yield raw_line + "\n\n"
- if monitor_event == "done" or raw_line[6:].strip() == "[DONE]":
+ if monitor_event == "done":
monitor_done = True
break
+ if not saw_done and not saw_stream_error and not cancel_event.is_set():
+ # Synthesize a finish chunk only if one was not already
+ # emitted (e.g. before a trailing usage-only chunk), but
+ # always close with [DONE] whenever the upstream omitted it,
+ # so the stream ends on the [DONE] sentinel either way.
+ if not saw_finish_reason:
+ finish_line = _synthetic_finish_line()
+ _monitor_openai_sse_line(
+ monitor_id,
+ finish_line,
+ llama_backend.context_length,
+ )
+ yield finish_line + "\n\n"
+ done_line = "data: [DONE]"
+ _monitor_openai_sse_line(
+ monitor_id,
+ done_line,
+ llama_backend.context_length,
+ )
+ yield done_line + "\n\n"
+ monitor_done = True
if not monitor_done:
api_monitor.finish(
monitor_id,
@@ -9303,7 +9810,24 @@ async def _openai_passthrough_stream(
)
_tracker.__exit__(None, None, None)
- return _sse_streaming_response(_stream())
+ async def _unstarted_cleanup() -> None:
+ # Client disconnected before the body stream started, so _stream()'s
+ # finally never ran. Release the eagerly-opened upstream resp/client
+ # and the cancel-registry entry here; the watchers and line iterator
+ # are created inside _stream(), so there is nothing else to close.
+ await _aclose_stream_resources(resp = resp, client = client)
+ _tracker.__exit__(None, None, None)
+
+ return _SameTaskStreamingResponse(
+ _stream(),
+ media_type = "text/event-stream",
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "close",
+ "X-Accel-Buffering": "no",
+ },
+ unstarted_cleanup = _unstarted_cleanup,
+ )
except BaseException:
_tracker.__exit__(None, None, None)
raise
diff --git a/studio/backend/tests/test_gemma_tool_parse_edge_cases.py b/studio/backend/tests/test_gemma_tool_parse_edge_cases.py
new file mode 100644
index 0000000000..d573522bcc
--- /dev/null
+++ b/studio/backend/tests/test_gemma_tool_parse_edge_cases.py
@@ -0,0 +1,174 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Edge cases in Gemma-native tool-call parsing.
+
+Covers two failure modes:
+ 1. A bare (unquoted) string argument that contains a comma, e.g.
+ ``location:New York, NY`` -- the comma must not be treated as the next
+ key boundary, or the whole call is dropped.
+ 2. A tool-call marker that appears INSIDE another call's argument string is
+ data, not a real call, so it must not be promoted to a second tool call.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
+if _BACKEND_DIR not in sys.path:
+ sys.path.insert(0, _BACKEND_DIR)
+
+from core.inference.tool_call_parser import parse_tool_calls_from_text
+from core.tool_healing import strip_tool_call_markup
+
+
+def _args(call: dict) -> dict:
+ return json.loads(call["function"]["arguments"])
+
+
+def test_bare_string_argument_with_comma_is_kept():
+ calls = parse_tool_calls_from_text(
+ "<|tool_call>call:get_weather{location:New York, NY,unit:celsius}"
+ )
+ assert len(calls) == 1, calls
+ assert calls[0]["function"]["name"] == "get_weather"
+ assert _args(calls[0]) == {"location": "New York, NY", "unit": "celsius"}
+
+
+def test_normal_multi_key_arguments_still_split():
+ calls = parse_tool_calls_from_text('<|tool_call>call:f{a:1,b:hello,c:"x,y"}')
+ assert len(calls) == 1, calls
+ # Numbers stay numeric, bare strings get quoted, an explicit quoted comma
+ # stays inside its value.
+ assert _args(calls[0]) == {"a": 1, "b": "hello", "c": "x,y"}
+
+
+def test_bare_value_with_timestamps_after_comma_is_kept():
+ # A comma followed by digits-then-colon (a timestamp/ratio) is value text,
+ # not a new key, so the whole query must be preserved as one argument.
+ calls = parse_tool_calls_from_text(
+ "<|tool_call>call:remind{query:meet at 10:00, 11:00 tomorrow,priority:high}"
+ )
+ assert len(calls) == 1, calls
+ assert _args(calls[0]) == {"query": "meet at 10:00, 11:00 tomorrow", "priority": "high"}
+
+
+def test_marker_inside_json_argument_is_not_a_second_call():
+ # A python call whose `code` argument contains a Gemma marker string. The
+ # marker is data and must not execute as a second `terminal` call.
+ content = (
+ '{"name":"python","arguments":{"code":'
+ '"x = 1 # <|tool_call>call:terminal{command:ls}"}}'
+ )
+ calls = parse_tool_calls_from_text(content)
+ assert [c["function"]["name"] for c in calls] == ["python"], calls
+
+
+def test_two_separate_gemma_calls_both_parse():
+ content = "<|tool_call>call:a{x:1} and <|tool_call>call:b{y:2}"
+ calls = parse_tool_calls_from_text(content)
+ assert [c["function"]["name"] for c in calls] == ["a", "b"], calls
+ assert _args(calls[0]) == {"x": 1}
+ assert _args(calls[1]) == {"y": 2}
+
+
+def test_mixed_format_calls_preserve_document_order():
+ # A Gemma-native call precedes a JSON-format call in the text; tools execute
+ # in returned order, so `create` must come before `read`.
+ content = (
+ "<|tool_call>call:create{path:a} then "
+ '{"name":"read","arguments":{"path":"a"}}'
+ )
+ calls = parse_tool_calls_from_text(content)
+ assert [c["function"]["name"] for c in calls] == ["create", "read"], calls
+
+
+def test_json_marker_inside_gemma_argument_is_not_a_second_call():
+ # The reverse of the JSON-outer case: a JSON-style marker inside a Gemma
+ # call's quoted argument is code text, not a second `terminal` call.
+ content = (
+ '<|tool_call>call:python{code:<|"|>'
+ 'print({"name":"terminal","arguments":{"command":"ls"}})'
+ '<|"|>}'
+ )
+ calls = parse_tool_calls_from_text(content)
+ assert [c["function"]["name"] for c in calls] == ["python"], calls
+
+
+def test_nested_gemma_marker_in_unquoted_arg_does_not_run_inner_call():
+ # An UNQUOTED Gemma value containing a literal marker: the outer object fails
+ # to normalize (the inner braces/marker break the JSON), but the inner marker
+ # is nested in the outer candidate span, so it must not be promoted to a
+ # standalone `terminal` call. The safe outcome is no executed tool call.
+ content = "<|tool_call>call:python{code:<|tool_call>call:terminal{command:ls}}"
+ calls = parse_tool_calls_from_text(content)
+ assert "terminal" not in [c["function"]["name"] for c in calls], calls
+
+
+def test_bare_string_array_argument_is_quoted():
+ # Gemma may emit an array of bare strings without per-element quotes; they
+ # must be quoted so the call is not dropped.
+ calls = parse_tool_calls_from_text("<|tool_call>call:label{labels:[bug,ui]}")
+ assert len(calls) == 1, calls
+ assert _args(calls[0]) == {"labels": ["bug", "ui"]}
+
+
+def test_array_keeps_numbers_and_quoted_elements():
+ calls = parse_tool_calls_from_text(
+ '<|tool_call>call:f{nums:[1,2],tags:[<|"|>a,b<|"|>,c]}'
+ )
+ assert _args(calls[0]) == {"nums": [1, 2], "tags": ["a,b", "c"]}
+
+
+def test_array_of_objects_is_normalised():
+ # Arrays of objects are a common tool-schema shape; their (unquoted) keys and
+ # bare values must be normalised too, not left verbatim, or the call drops.
+ calls = parse_tool_calls_from_text(
+ "<|tool_call>call:batch{items:[{path:a,mode:r},{path:b,mode:w}]}"
+ )
+ assert len(calls) == 1, calls
+ assert _args(calls[0]) == {"items": [{"path": "a", "mode": "r"}, {"path": "b", "mode": "w"}]}
+
+
+def test_nested_array_elements_are_normalised():
+ calls = parse_tool_calls_from_text("<|tool_call>call:grid{cells:[[a,b],[c,d]]}")
+ assert _args(calls[0]) == {"cells": [["a", "b"], ["c", "d"]]}
+
+
+def test_gemma_marker_inside_xml_parameter_is_not_a_second_call():
+ # An XML-style call whose value contains a
+ # Gemma marker: the marker is the parameter's data, not a separate terminal
+ # call, so only the python call must be returned.
+ content = (
+ ""
+ "x = 1 # <|tool_call>call:terminal{command:ls}"
+ ""
+ )
+ calls = parse_tool_calls_from_text(content)
+ assert [c["function"]["name"] for c in calls] == ["python"], calls
+ assert "terminal" in _args(calls[0])["code"]
+
+
+def test_json_marker_inside_xml_parameter_is_not_a_second_call():
+ content = (
+ ""
+ 'run({"name":"terminal","arguments":{"command":"ls"}})'
+ ""
+ )
+ calls = parse_tool_calls_from_text(content)
+ assert [c["function"]["name"] for c in calls] == ["python"], calls
+
+
+def test_gemma_close_marker_inside_quoted_arg_is_not_leaked_when_stripping():
+ # A literal inside a <|"|>-quoted argument must not truncate the
+ # span: the parser keeps it as data, and stripping must remove the whole span
+ # (brace/quote-aware), not stop at the inner marker and leak the suffix.
+ text = '<|tool_call>call:python{code:<|"|>print("")<|"|>}'
+ calls = parse_tool_calls_from_text(text)
+ assert len(calls) == 1, calls
+ assert _args(calls[0]) == {"code": 'print("")'}
+ assert strip_tool_call_markup("before " + text + " after") == "before after"
+ assert strip_tool_call_markup("before " + text + " after", final = True) == "before after"
diff --git a/studio/backend/tests/test_llama_route_timeouts.py b/studio/backend/tests/test_llama_route_timeouts.py
index 5aee6198ba..24866bd03e 100644
--- a/studio/backend/tests/test_llama_route_timeouts.py
+++ b/studio/backend/tests/test_llama_route_timeouts.py
@@ -5,6 +5,7 @@ import asyncio
import os
import sys
import time
+import threading
from types import SimpleNamespace
_backend = os.path.join(os.path.dirname(__file__), "..")
@@ -40,6 +41,123 @@ def test_stream_first_item_deadline_after_headers():
asyncio.run(_run())
+def test_stream_first_item_deadline_does_not_hop_tasks():
+ async def _run():
+ outer_task = asyncio.current_task()
+ seen_tasks = []
+
+ class _One:
+ def __init__(self):
+ self.done = False
+
+ async def __anext__(self):
+ seen_tasks.append(asyncio.current_task())
+ if self.done:
+ raise StopAsyncIteration
+ self.done = True
+ return "data: {}"
+
+ out = []
+ async for item in inf_mod._aiter_llama_stream_items(
+ _One(),
+ first_token_deadline = time.monotonic() + 1,
+ ):
+ out.append(item)
+
+ assert out == ["data: {}"]
+ assert seen_tasks == [outer_task, outer_task]
+
+ asyncio.run(_run())
+
+
+def test_stream_first_item_deadline_uses_compat_timeout_without_task_hop(monkeypatch):
+ monkeypatch.setattr(inf_mod.asyncio, "timeout", None, raising = False)
+
+ async def _run():
+ outer_task = asyncio.current_task()
+ seen_tasks = []
+
+ class _One:
+ def __init__(self):
+ self.done = False
+
+ async def __anext__(self):
+ seen_tasks.append(asyncio.current_task())
+ if self.done:
+ raise StopAsyncIteration
+ self.done = True
+ return "data: {}"
+
+ out = []
+ async for item in inf_mod._aiter_llama_stream_items(
+ _One(),
+ first_token_deadline = time.monotonic() + 1,
+ ):
+ out.append(item)
+
+ assert out == ["data: {}"]
+ assert seen_tasks == [outer_task, outer_task]
+
+ asyncio.run(_run())
+
+
+def test_stream_wait_stops_on_known_disconnect_before_read():
+ async def _run():
+ state = SimpleNamespace(disconnect_checks = 0)
+ cancel_event = threading.Event()
+
+ class _Request:
+ async def is_disconnected(self):
+ state.disconnect_checks += 1
+ return True
+
+ class _Unread:
+ async def __anext__(self):
+ raise AssertionError("stream should stop before reading upstream")
+
+ async for _ in inf_mod._aiter_llama_stream_items(
+ _Unread(),
+ cancel_event = cancel_event,
+ request = _Request(),
+ first_token_deadline = time.monotonic() + 1,
+ ):
+ raise AssertionError("stream should stop after disconnect")
+
+ assert cancel_event.is_set()
+ assert state.disconnect_checks == 1
+
+ asyncio.run(_run())
+
+
+def test_stream_wait_does_not_shorten_upstream_read_for_disconnect_poll():
+ async def _run():
+ response = SimpleNamespace(request = SimpleNamespace(extensions = {"timeout": {}}))
+ seen_read_timeouts = []
+
+ class _Request:
+ async def is_disconnected(self):
+ return False
+
+ class _NoItem:
+ async def __anext__(self):
+ seen_read_timeouts.append(response.request.extensions["timeout"]["read"])
+ raise StopAsyncIteration
+
+ async for _ in inf_mod._aiter_llama_stream_items(
+ _NoItem(),
+ cancel_event = threading.Event(),
+ request = _Request(),
+ response = response,
+ first_token_deadline = time.monotonic() + 1,
+ ):
+ raise AssertionError("stream should end")
+
+ assert seen_read_timeouts
+ assert seen_read_timeouts[0] > inf_mod._STREAM_DISCONNECT_POLL_TIMEOUT_S
+
+ asyncio.run(_run())
+
+
def test_preheader_send_cleanup_on_disconnect_and_cancel():
async def _run(cancel_parent):
state = SimpleNamespace(disconnected = False, closed = False, cancelled = False)
diff --git a/studio/backend/tests/test_mcp_servers.py b/studio/backend/tests/test_mcp_servers.py
index 90b1ade03c..12239e7113 100644
--- a/studio/backend/tests/test_mcp_servers.py
+++ b/studio/backend/tests/test_mcp_servers.py
@@ -423,6 +423,46 @@ def test_tool_healing_strip_handles_hyphenated_function_names():
assert out == "before after"
+def test_tool_healing_strip_handles_gemma_native_tool_call():
+ from core.tool_healing import strip_tool_call_markup
+ out = strip_tool_call_markup(
+ 'before <|tool_call>call:mcp__srv__list-issues{repo:"octocat/hello"} after'
+ )
+ assert out == "before after"
+
+
+def test_tool_healing_strip_handles_gemma_close_only_marker():
+ from core.tool_healing import strip_tool_call_markup
+ assert strip_tool_call_markup("before after") == "before after"
+ assert strip_tool_call_markup("before after", final = True) == "before after"
+
+
+def test_tool_healing_parser_handles_gemma_native_windows_path():
+ from core.tool_healing import parse_tool_calls_from_text
+ import json as _json
+
+ calls = parse_tool_calls_from_text(
+ r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}'
+ )
+ assert len(calls) == 1
+ assert calls[0]["function"]["name"] == "ls"
+ assert _json.loads(calls[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
+
+
+def test_tool_healing_json_parser_preserves_literal_gemma_quote_token():
+ from core.tool_healing import parse_tool_calls_from_text
+ import json as _json
+
+ text = (
+ ""
+ + _json.dumps({"name": "python", "arguments": {"code": "print('<|\"|>')"}})
+ + ""
+ )
+ calls = parse_tool_calls_from_text(text, allow_incomplete = False)
+ assert len(calls) == 1
+ assert _json.loads(calls[0]["function"]["arguments"]) == {"code": "print('<|\"|>')"}
+
+
def test_gguf_allow_list_blocks_unadvertised_tool(monkeypatch):
"""A tool call not in the per-request list must be refused by the GGUF
agentic loop (mirroring the safetensors path)."""
diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py
index 2586076321..aaef9e4dcc 100644
--- a/studio/backend/tests/test_openai_tool_passthrough.py
+++ b/studio/backend/tests/test_openai_tool_passthrough.py
@@ -48,6 +48,7 @@ from routes.inference import (
_openai_passthrough_stream,
_openai_stream_usage_chunk,
_proxy_to_external_provider,
+ _SameTaskStreamingResponse,
_set_or_prepend_system_message,
openai_completions,
openai_embeddings,
@@ -1245,6 +1246,79 @@ class TestGgufVisionToolRouting:
return TestGgufVisionToolRouting._drive(_consume())
+ @staticmethod
+ def _sse_payloads(chunks):
+ payloads = []
+ for chunk in chunks:
+ if isinstance(chunk, bytes):
+ chunk = chunk.decode()
+ for line in str(chunk).splitlines():
+ if not line.startswith("data: "):
+ continue
+ data = line.removeprefix("data: ")
+ if data == "[DONE]":
+ continue
+ try:
+ payloads.append(json.loads(data))
+ except json.JSONDecodeError:
+ pass
+ return payloads
+
+ def _run_gguf_case(
+ self,
+ monkeypatch,
+ *,
+ generate = None,
+ tool_generate = None,
+ payload_kwargs = None,
+ backend_kwargs = None,
+ ):
+ import routes.inference as inf_mod
+
+ reset_tool_policy()
+
+ def _plain(**_kwargs):
+ raise AssertionError("plain GGUF path should not be used")
+
+ backend_data = {
+ "is_loaded": True,
+ "is_vision": False,
+ "supports_tools": tool_generate is not None,
+ "supports_reasoning": True,
+ "reasoning_always_on": True,
+ "_is_audio": False,
+ "model_identifier": "test-gguf",
+ "context_length": 4096,
+ "generate_chat_completion": generate or _plain,
+ }
+ if tool_generate is not None:
+ backend_data["generate_chat_completion_with_tools"] = tool_generate
+ if backend_kwargs:
+ backend_data.update(backend_kwargs)
+ backend = SimpleNamespace(**backend_data)
+
+ monitor = ApiMonitor(max_entries = 3)
+ monkeypatch.setattr(inf_mod, "api_monitor", monitor)
+ monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend)
+
+ request_data = {
+ "model": "default",
+ "messages": [{"role": "user", "content": "hi"}],
+ }
+ if payload_kwargs:
+ request_data.update(payload_kwargs)
+ payload = ChatCompletionRequest(**request_data)
+ response = self._drive(
+ openai_chat_completions(payload, request = self._Request(), current_subject = "test")
+ )
+ result = SimpleNamespace(response = response, monitor = monitor, backend = backend)
+ if request_data.get("stream"):
+ result.chunks = self._consume_response(response)
+ result.payloads = self._sse_payloads(result.chunks)
+ else:
+ result.body = json.loads(response.body)
+ return result
+
def test_image_request_with_enabled_tools_enters_gguf_tool_loop(self, monkeypatch):
import routes.inference as inf_mod
@@ -1390,6 +1464,152 @@ class TestGgufVisionToolRouting:
assert "confirm_tool_calls requires stream=true" in entry["error"]
assert monitor.active_count() == 0
+ def test_standard_gguf_stream_splits_reasoning_content(self, monkeypatch):
+ def _generate(**_kwargs):
+ yield "plan"
+ yield "planvis"
+ yield "planvisible"
+ yield {
+ "type": "metadata",
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
+ "finish_reason": "stop",
+ }
+
+ result = self._run_gguf_case(
+ monkeypatch,
+ generate = _generate,
+ payload_kwargs = {"stream": True},
+ )
+ deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
+
+ assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
+ assert "".join(d.get("content", "") for d in deltas) == "visible"
+ assert all("" not in d.get("content", "") for d in deltas)
+ [entry] = result.monitor.snapshot()
+ assert entry["reply"] == "visible"
+
+ def test_reasoning_capable_gguf_stream_splits_reasoning_by_default(self, monkeypatch):
+ def _generate(**_kwargs):
+ yield "planvisible"
+ yield {
+ "type": "metadata",
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
+ "finish_reason": "stop",
+ }
+
+ result = self._run_gguf_case(
+ monkeypatch,
+ generate = _generate,
+ payload_kwargs = {"stream": True},
+ backend_kwargs = {"reasoning_always_on": False},
+ )
+ deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
+
+ assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
+ assert "".join(d.get("content", "") for d in deltas) == "visible"
+ [entry] = result.monitor.snapshot()
+ assert entry["reply"] == "visible"
+
+ def test_reasoning_capable_gguf_stream_sanitizes_think_tags_when_disabled(self, monkeypatch):
+ def _generate(**_kwargs):
+ yield "leakedvisible"
+ yield {
+ "type": "metadata",
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
+ "finish_reason": "stop",
+ }
+
+ result = self._run_gguf_case(
+ monkeypatch,
+ generate = _generate,
+ payload_kwargs = {"stream": True, "enable_thinking": False},
+ backend_kwargs = {"reasoning_always_on": False},
+ )
+ deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
+
+ assert "".join(d.get("reasoning_content", "") for d in deltas) == "leaked"
+ assert "".join(d.get("content", "") for d in deltas) == "visible"
+ assert all("" not in d.get("content", "") for d in deltas)
+ [entry] = result.monitor.snapshot()
+ assert entry["reply"] == "visible"
+
+ def test_gguf_tool_stream_splits_reasoning_and_strips_gemma_tool_marker(self, monkeypatch):
+ def _tools(**_kwargs):
+ yield {
+ "type": "content",
+ "text": 'planvisible <|tool_call>call:terminal{command:"ls"}',
+ }
+ yield {
+ "type": "metadata",
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
+ "finish_reason": "stop",
+ }
+
+ result = self._run_gguf_case(
+ monkeypatch,
+ tool_generate = _tools,
+ payload_kwargs = {
+ "stream": True,
+ "enable_tools": True,
+ "enabled_tools": ["terminal"],
+ "messages": [{"role": "user", "content": "list files"}],
+ },
+ )
+ deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
+
+ assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
+ combined_content = "".join(d.get("content", "") for d in deltas)
+ assert combined_content == "visible "
+ assert "<|tool_call>" not in combined_content
+ [entry] = result.monitor.snapshot()
+ assert entry["reply"] == "visible "
+
+ def test_gguf_tool_stream_flushes_held_text_before_status_reset(self, monkeypatch):
+ def _tools(**_kwargs):
+ yield {"type": "content", "text": "answer <"}
+ yield {"type": "status", "text": ""}
+ yield {
+ "type": "metadata",
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
+ "finish_reason": "stop",
+ }
+
+ result = self._run_gguf_case(
+ monkeypatch,
+ tool_generate = _tools,
+ payload_kwargs = {
+ "stream": True,
+ "enable_tools": True,
+ "enabled_tools": ["terminal"],
+ "messages": [{"role": "user", "content": "say literal"}],
+ },
+ )
+ deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
+
+ combined_content = "".join(d.get("content", "") for d in deltas)
+ assert combined_content == "answer <"
+ [entry] = result.monitor.snapshot()
+ assert entry["reply"] == "answer <"
+
+ def test_non_streaming_gguf_splits_reasoning_content(self, monkeypatch):
+ def _generate(**_kwargs):
+ yield "planvisible"
+ yield {
+ "type": "metadata",
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
+ "finish_reason": "stop",
+ }
+
+ result = self._run_gguf_case(monkeypatch, generate = _generate)
+ body = result.body
+ message = body["choices"][0]["message"]
+
+ assert message["content"] == "visible"
+ assert message["reasoning_content"] == "plan"
+ [entry] = result.monitor.snapshot()
+ assert entry["reply"] == "visible"
+
def test_non_streaming_gguf_n_records_all_monitor_replies(self, monkeypatch):
import routes.inference as inf_mod
@@ -1552,6 +1772,61 @@ class TestApiMonitorProviderAndCompletionStreams:
async def is_disconnected(self):
return False
+ async def _run_passthrough_stream(self, monkeypatch, lines):
+ import routes.inference as inf_mod
+
+ class Request:
+ async def is_disconnected(self):
+ return False
+
+ async def fake_send(*_args, **_kwargs):
+ return httpx.Response(200, content = b"")
+
+ async def fake_items(*_args, **_kwargs):
+ for line in lines:
+ yield line
+
+ monitor = ApiMonitor(max_entries = 3)
+ monkeypatch.setattr(inf_mod, "api_monitor", monitor)
+ monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
+ monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
+ monitor_id = monitor.start(
+ endpoint = "/v1/chat/completions",
+ method = "POST",
+ model = "gguf",
+ prompt = "hi",
+ )
+ payload = ChatCompletionRequest(
+ model = "default",
+ messages = [ChatMessage(role = "user", content = "hi")],
+ stream = True,
+ tools = [
+ {
+ "type": "function",
+ "function": {
+ "name": "lookup",
+ "parameters": {"type": "object", "properties": {}},
+ },
+ }
+ ],
+ )
+
+ response = await _openai_passthrough_stream(
+ Request(),
+ threading.Event(),
+ SimpleNamespace(
+ base_url = "http://llama.test",
+ context_length = 4096,
+ _request_reasoning_kwargs = lambda *_args, **_kwargs: None,
+ ),
+ payload,
+ "gguf",
+ "chatcmpl-test",
+ monitor_id = monitor_id,
+ )
+ chunks = [chunk async for chunk in response.body_iterator]
+ return SimpleNamespace(chunks = chunks, body = "".join(chunks), monitor = monitor)
+
def test_external_non_streaming_json_updates_monitor(self, monkeypatch):
async def _run():
import routes.inference as inf_mod
@@ -1980,6 +2255,7 @@ class TestApiMonitorProviderAndCompletionStreams:
"chatcmpl-test",
monitor_id = monitor_id,
)
+ assert isinstance(response, _SameTaskStreamingResponse)
iterator = response.body_iterator
first = await anext(iterator)
assert "hello" in first
@@ -1997,6 +2273,88 @@ class TestApiMonitorProviderAndCompletionStreams:
asyncio.run(_run())
+ def test_passthrough_stream_synthesizes_missing_finish_reason(self, monkeypatch):
+ async def _run():
+ result = await self._run_passthrough_stream(
+ monkeypatch,
+ [
+ (
+ 'data: {"id":"upstream","created":123,"model":"gguf",'
+ '"choices":[{"index":0,"delta":{"content":"hello"}}]}'
+ ),
+ "data: [DONE]",
+ ],
+ )
+ body = result.body
+
+ assert '"finish_reason":"stop"' in body.replace(" ", "")
+ assert "data: [DONE]" in body
+ assert result.monitor.active_count() == 0
+
+ asyncio.run(_run())
+
+ def test_passthrough_stream_synthesizes_tool_call_finish_reason(self, monkeypatch):
+ async def _run():
+ result = await self._run_passthrough_stream(
+ monkeypatch,
+ [
+ (
+ 'data: {"id":"upstream","created":123,"model":"gguf",'
+ '"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,'
+ '"id":"call_1","type":"function","function":{"name":"lookup",'
+ '"arguments":"{}"}}]}}]}'
+ ),
+ "data: [DONE]",
+ ],
+ )
+ compact = result.body.replace(" ", "")
+
+ assert '"finish_reason":"tool_calls"' in compact
+ assert '"finish_reason":"stop"' not in compact
+ assert "data: [DONE]" in result.body
+ assert result.monitor.active_count() == 0
+
+ asyncio.run(_run())
+
+ def test_passthrough_stream_error_done_skips_synthetic_finish_reason(self, monkeypatch):
+ async def _run():
+ result = await self._run_passthrough_stream(
+ monkeypatch,
+ [
+ 'data: {"error":{"message":"boom","type":"server_error"}}',
+ "data: [DONE]",
+ ],
+ )
+ compact = result.body.replace(" ", "")
+
+ assert '"error":{"message":"boom","type":"server_error"}' in compact
+ assert '"finish_reason"' not in compact
+ assert "data: [DONE]" in result.body
+ [entry] = result.monitor.snapshot()
+ assert entry["status"] == "error"
+ assert entry["error"] == "boom"
+ assert result.monitor.active_count() == 0
+
+ asyncio.run(_run())
+
+ def test_passthrough_stream_error_eof_skips_synthetic_finish_reason(self, monkeypatch):
+ async def _run():
+ result = await self._run_passthrough_stream(
+ monkeypatch,
+ ['data: {"error":{"message":"boom","type":"server_error"}}'],
+ )
+ compact = result.body.replace(" ", "")
+
+ assert '"error":{"message":"boom","type":"server_error"}' in compact
+ assert '"finish_reason"' not in compact
+ assert "data: [DONE]" not in result.body
+ [entry] = result.monitor.snapshot()
+ assert entry["status"] == "error"
+ assert entry["error"] == "boom"
+ assert result.monitor.active_count() == 0
+
+ asyncio.run(_run())
+
def test_passthrough_non_streaming_cancel_finalizes_monitor(self, monkeypatch):
async def _run():
import routes.inference as inf_mod
@@ -2058,65 +2416,20 @@ class TestApiMonitorProviderAndCompletionStreams:
def test_passthrough_clean_eof_finalizes_monitor(self, monkeypatch):
async def _run():
- import routes.inference as inf_mod
-
- class Request:
- async def is_disconnected(self):
- return False
-
- async def fake_send(*_args, **_kwargs):
- return httpx.Response(200, content = b"")
-
- async def fake_items(*_args, **_kwargs):
- yield 'data: {"choices":[{"delta":{"content":"hello"}}]}'
-
- monitor = ApiMonitor(max_entries = 3)
- monkeypatch.setattr(inf_mod, "api_monitor", monitor)
- monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
- monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
- monitor_id = monitor.start(
- endpoint = "/v1/chat/completions",
- method = "POST",
- model = "gguf",
- prompt = "hi",
- )
- payload = ChatCompletionRequest(
- model = "default",
- messages = [ChatMessage(role = "user", content = "hi")],
- stream = True,
- tools = [
- {
- "type": "function",
- "function": {
- "name": "lookup",
- "parameters": {"type": "object", "properties": {}},
- },
- }
- ],
+ result = await self._run_passthrough_stream(
+ monkeypatch,
+ ['data: {"choices":[{"delta":{"content":"hello"}}]}'],
)
+ chunks = result.chunks
- response = await _openai_passthrough_stream(
- Request(),
- threading.Event(),
- SimpleNamespace(
- base_url = "http://llama.test",
- context_length = 4096,
- _request_reasoning_kwargs = lambda *_args, **_kwargs: None,
- ),
- payload,
- "gguf",
- "chatcmpl-test",
- monitor_id = monitor_id,
- )
- chunks = []
- async for chunk in response.body_iterator:
- chunks.append(chunk)
-
- assert chunks == ['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n']
- [entry] = monitor.snapshot()
+ assert chunks[0] == 'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'
+ compact = "".join(chunks).replace(" ", "")
+ assert '"finish_reason":"stop"' in compact
+ assert chunks[-1] == "data: [DONE]\n\n"
+ [entry] = result.monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == "hello"
- assert monitor.active_count() == 0
+ assert result.monitor.active_count() == 0
asyncio.run(_run())
diff --git a/studio/backend/tests/test_responses_tool_passthrough.py b/studio/backend/tests/test_responses_tool_passthrough.py
index 0bea355668..4147746b54 100644
--- a/studio/backend/tests/test_responses_tool_passthrough.py
+++ b/studio/backend/tests/test_responses_tool_passthrough.py
@@ -59,8 +59,10 @@ from models.inference import (
ResponsesUsage,
)
from routes.inference import (
+ _SameTaskStreamingResponse,
_build_chat_request,
_chat_tool_calls_to_responses_output,
+ _extract_responses_reasoning,
_normalise_responses_input,
_responses_tool_output_content,
_responses_non_streaming,
@@ -782,6 +784,15 @@ class TestResponsesNonStreamingAdapter:
assert "" not in body["output"][1]["content"][0]["text"]
assert "" not in body["output"][1]["content"][0]["text"]
+ def test_unclosed_think_block_extracts_as_reasoning(self):
+ reasoning, visible = _extract_responses_reasoning(
+ "partial plan",
+ parse_think_markers = True,
+ )
+
+ assert reasoning == "partial plan"
+ assert visible == ""
+
def test_monitor_records_translated_visible_text(self, monkeypatch):
import routes.inference as inf_mod
@@ -927,6 +938,38 @@ class TestResponsesNonStreamingAdapter:
assert [item["type"] for item in body["output"]] == ["message"]
assert body["output"][0]["content"][0]["text"] == "show x tags"
+ def test_reasoning_capable_gguf_parses_think_tags_by_default(self, monkeypatch):
+ body = self._run_with_message(
+ monkeypatch,
+ {"content": "plananswer"},
+ llama_backend = SimpleNamespace(
+ is_loaded = True,
+ reasoning_always_on = False,
+ supports_reasoning = True,
+ ),
+ )
+
+ assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
+ assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "plan"}]
+ assert body["output"][1]["content"][0]["text"] == "answer"
+
+ def test_reasoning_capable_gguf_sanitizes_think_tags_when_disabled(self, monkeypatch):
+ payload = ResponsesRequest(input = "hi", reasoning = {"effort": "none"})
+ body = self._run_with_message(
+ monkeypatch,
+ {"content": "leakedanswer"},
+ payload = payload,
+ llama_backend = SimpleNamespace(
+ is_loaded = True,
+ reasoning_always_on = False,
+ supports_reasoning = True,
+ ),
+ )
+
+ assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
+ assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "leaked"}]
+ assert body["output"][1]["content"][0]["text"] == "answer"
+
def test_structured_reasoning_content_extracts_text_parts(self, monkeypatch):
body = self._run_with_message(
monkeypatch,
@@ -949,7 +992,7 @@ class TestResponsesNonStreamingAdapter:
assert [item["type"] for item in body["output"]] == ["message"]
assert body["output"][0]["content"][0]["text"] == "33"
- def test_reasoning_only_is_also_visible_message_text(self, monkeypatch):
+ def test_reasoning_only_stays_out_of_visible_message_text(self, monkeypatch):
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "high"})
body = self._run_with_message(
monkeypatch,
@@ -957,9 +1000,8 @@ class TestResponsesNonStreamingAdapter:
payload = payload,
)
- assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
+ assert [item["type"] for item in body["output"]] == ["reasoning"]
assert body["output"][0]["content"][0]["text"] == "plan"
- assert body["output"][1]["content"][0]["text"] == "plan"
# =====================================================================
@@ -1033,6 +1075,36 @@ class TestResponsesStreamAdapter:
),
)
+ def test_stream_response_avoids_legacy_receive_watcher(self, monkeypatch):
+ self._install_stream_mock(
+ monkeypatch,
+ [{"choices": [{"delta": {"content": "33"}}]}],
+ )
+ payload = ResponsesRequest(input = "hi", stream = True)
+ messages = [ChatMessage(role = "user", content = "hi")]
+
+ async def run():
+ response = await _responses_stream(payload, messages, self._Request())
+ assert isinstance(response, _SameTaskStreamingResponse)
+
+ sent = []
+
+ async def receive():
+ raise AssertionError("Responses streams poll disconnects in the generator")
+
+ async def send(message):
+ sent.append(message)
+
+ await response({"type": "http", "asgi": {"spec_version": "2.3"}}, receive, send)
+ return sent
+
+ sent = asyncio.run(run())
+
+ assert sent[0]["type"] == "http.response.start"
+ body = b"".join(message.get("body", b"") for message in sent).decode()
+ assert "response.output_text.delta" in body
+ assert '"delta":"33"' in body.replace(" ", "")
+
def test_split_think_markers_stream_as_reasoning_and_visible_text(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "x tags"}}]},
+ {"choices": [{"delta": {"content": "plananswer"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
@@ -1350,13 +1423,15 @@ class TestResponsesStreamAdapter:
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
- assert reasoning_deltas == []
- assert "".join(event["delta"] for event in text_deltas) == "show x tags"
+ assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
+ assert "".join(event["delta"] for event in text_deltas) == "answer"
completed = self._payloads(lines, "response.completed")[0]
- assert [item["type"] for item in completed["response"]["output"]] == ["message"]
- assert completed["response"]["output"][0]["content"][0]["text"] == (
- "show x tags"
- )
+ assert [item["type"] for item in completed["response"]["output"]] == [
+ "reasoning",
+ "message",
+ ]
+ assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
+ assert completed["response"]["output"][1]["content"][0]["text"] == "answer"
def test_non_reasoning_gguf_stream_keeps_literal_think_tags_visible(self, monkeypatch):
chunks = [
@@ -1384,7 +1459,7 @@ class TestResponsesStreamAdapter:
"show x tags"
)
- def test_reasoning_only_streams_as_visible_message_text(self, monkeypatch):
+ def test_reasoning_only_stream_stays_out_of_visible_message_text(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "plan"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
@@ -1402,14 +1477,34 @@ class TestResponsesStreamAdapter:
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
- assert "".join(event["delta"] for event in text_deltas) == "plan"
+ assert text_deltas == []
completed = self._payloads(lines, "response.completed")[0]
- assert [item["type"] for item in completed["response"]["output"]] == [
- "reasoning",
- "message",
- ]
+ assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"]
+ assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
+
+ def test_unclosed_think_stream_stays_out_of_visible_message_text(self, monkeypatch):
+ chunks = [
+ {"choices": [{"delta": {"content": "plan"}}]},
+ {"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
+ ]
+ self._install_stream_mock(monkeypatch, chunks)
+ payload = ResponsesRequest(input = "hi", stream = True, reasoning = {"effort": "high"})
+ messages = [ChatMessage(role = "user", content = "hi")]
+
+ async def run():
+ response = await _responses_stream(payload, messages, self._Request())
+ return await self._collect(response)
+
+ lines = asyncio.run(run())
+
+ reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
+ text_deltas = self._payloads(lines, "response.output_text.delta")
+ assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
+ assert text_deltas == []
+ completed = self._payloads(lines, "response.completed")[0]
+ assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"]
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
- assert completed["response"]["output"][1]["content"][0]["text"] == "plan"
def test_structured_reasoning_content_streams_as_reasoning(self, monkeypatch):
chunks = [
diff --git a/studio/backend/tests/test_safetensors_capability_advertise.py b/studio/backend/tests/test_safetensors_capability_advertise.py
index 13cb6bbd46..671af93708 100644
--- a/studio/backend/tests/test_safetensors_capability_advertise.py
+++ b/studio/backend/tests/test_safetensors_capability_advertise.py
@@ -203,6 +203,20 @@ def test_detect_safetensors_features_function_xml_format_keeps_tools_on():
assert flags["supports_tools"] is True
+def test_detect_safetensors_features_gemma_native_tool_call_keeps_tools_on():
+ """Gemma 4 emits <|tool_call>call:name{...}, which the shared
+ parser now reads, so the gate must not suppress tools for it."""
+ from routes.inference import _detect_safetensors_features
+
+ tpl_with_gemma_native = (
+ "{%- if tools -%}Tool call format: "
+ "<|tool_call>call:name{key:value}{%- endif -%}"
+ )
+ backend = SimpleNamespace(active_model_name = "unsloth/gemma-4-12b-it")
+ flags = _detect_safetensors_features(backend, tpl_with_gemma_native)
+ assert flags["supports_tools"] is True
+
+
# Qwen3.5 family pin: the live GGUF + safetensors templates both wrap tool
# calls as ``\n...``. Faithful slice so the
# classifier never silently regresses for this family.
diff --git a/studio/backend/tests/test_safetensors_tool_loop.py b/studio/backend/tests/test_safetensors_tool_loop.py
index d61c0b389c..3f2d49f0dd 100644
--- a/studio/backend/tests/test_safetensors_tool_loop.py
+++ b/studio/backend/tests/test_safetensors_tool_loop.py
@@ -10,6 +10,7 @@ calls, tool-result feedback, bad-JSON heal, duplicate-call short-circuit,
``__IMAGES__`` sentinel stripping, executor errors, cancel, and the iteration cap.
"""
+import json
import threading
from typing import cast
@@ -62,6 +63,51 @@ class TestParser:
assert parse_tool_calls_from_text(text)[0]["function"]["name"] == "python"
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
+ def test_gemma_native_tool_call(self):
+ text = '<|tool_call>call:terminal{command:"ls -la",workdir:"."}'
+ result = parse_tool_calls_from_text(text)
+ assert len(result) == 1
+ assert result[0]["function"]["name"] == "terminal"
+ args = json.loads(result[0]["function"]["arguments"])
+ assert args == {"command": "ls -la", "workdir": "."}
+
+ def test_gemma_native_tool_call_template_quotes(self):
+ text = '<|tool_call>call:web_search{query:<|"|>openai news<|"|>}'
+ result = parse_tool_calls_from_text(text)
+ assert len(result) == 1
+ assert result[0]["function"]["name"] == "web_search"
+ assert json.loads(result[0]["function"]["arguments"]) == {"query": "openai news"}
+
+ def test_gemma_native_tool_call_template_quotes_escape_backslashes(self):
+ text = r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}'
+ result = parse_tool_calls_from_text(text)
+ assert len(result) == 1
+ assert result[0]["function"]["name"] == "ls"
+ assert json.loads(result[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
+
+ def test_gemma_native_tool_call_hyphenated_argument_name(self):
+ text = '<|tool_call>call:mcp__srv__create-issue{issue-title:"Bug report"}'
+ result = parse_tool_calls_from_text(text)
+ assert len(result) == 1
+ assert result[0]["function"]["name"] == "mcp__srv__create-issue"
+ assert json.loads(result[0]["function"]["arguments"]) == {"issue-title": "Bug report"}
+
+ def test_gemma_native_tool_call_keeps_braces_inside_string_value(self):
+ text = '<|tool_call>call:terminal{command:"echo {foo:bar}"}'
+ result = parse_tool_calls_from_text(text)
+ assert len(result) == 1
+ assert result[0]["function"]["name"] == "terminal"
+ assert json.loads(result[0]["function"]["arguments"]) == {"command": "echo {foo:bar}"}
+
+ def test_gemma_native_tool_call_bare_string_values(self):
+ text = "<|tool_call>call:get_weather{location:Tokyo,unit:celsius}"
+ result = parse_tool_calls_from_text(text)
+ assert len(result) == 1
+ assert json.loads(result[0]["function"]["arguments"]) == {
+ "location": "Tokyo",
+ "unit": "celsius",
+ }
+
def test_xml_function_call(self):
text = "print('hi')"
result = parse_tool_calls_from_text(text)
@@ -121,6 +167,7 @@ class TestParser:
def test_has_tool_signal(self):
assert has_tool_signal("blah x")
+ assert has_tool_signal("blah <|tool_call>call:terminal")
assert has_tool_signal("hi ...")
assert not has_tool_signal("hello world")
@@ -139,6 +186,8 @@ class TestParser:
def test_strip_markup_closed(self):
text = "before {} after"
assert strip_tool_markup(text) == "before after"
+ text = 'before <|tool_call>call:terminal{command:"ls"} after'
+ assert strip_tool_markup(text) == "before after"
def test_strip_markup_unclosed_final(self):
text = "before {partial"
@@ -146,6 +195,7 @@ class TestParser:
assert strip_tool_markup(text, final = True) == "before"
# Without final=True the unclosed run is preserved.
assert "partial" in strip_tool_markup(text)
+ assert strip_tool_markup("before <|tool_call>call:terminal{", final = True) == "before"
def test_streaming_strip_respects_disabled_healing(self):
raw = 'before {"name":"web_search"'
diff --git a/studio/backend/tests/test_tool_call_parser_strict.py b/studio/backend/tests/test_tool_call_parser_strict.py
index 8ff41342d7..931d8a705d 100644
--- a/studio/backend/tests/test_tool_call_parser_strict.py
+++ b/studio/backend/tests/test_tool_call_parser_strict.py
@@ -106,6 +106,54 @@ class TestParityWithJsonStyle:
assert json.loads(js[0]["function"]["arguments"]) == {"query": q}
+class TestGemmaNativeStyle:
+ def test_closed_native_call_with_trailing_prose_is_accepted(self):
+ text = (
+ '<|tool_call>call:terminal{command:"ls -la",workdir:"."}' " running it now"
+ )
+ calls = parse_tool_calls_from_text(text, allow_incomplete = False)
+ assert len(calls) == 1
+ assert calls[0]["function"]["name"] == "terminal"
+ assert json.loads(calls[0]["function"]["arguments"]) == {
+ "command": "ls -la",
+ "workdir": ".",
+ }
+
+ def test_unclosed_native_call_requires_healing(self):
+ text = '<|tool_call>call:terminal{command:"ls"}'
+ assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
+ calls = parse_tool_calls_from_text(text, allow_incomplete = True)
+ assert len(calls) == 1
+ assert calls[0]["function"]["name"] == "terminal"
+
+ def test_hyphenated_native_argument_name_is_accepted(self):
+ text = '<|tool_call>call:mcp__srv__create-issue{issue-title:"Bug report"}'
+ calls = parse_tool_calls_from_text(text, allow_incomplete = False)
+ assert len(calls) == 1
+ assert calls[0]["function"]["name"] == "mcp__srv__create-issue"
+ assert json.loads(calls[0]["function"]["arguments"]) == {"issue-title": "Bug report"}
+
+ def test_native_template_quotes_preserve_windows_path(self):
+ text = r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}'
+ calls = parse_tool_calls_from_text(text, allow_incomplete = False)
+ assert len(calls) == 1
+ assert json.loads(calls[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
+
+ def test_bare_unquoted_string_values_are_accepted(self):
+ # Gemma can emit enum/string args unquoted; bare JSON scalars stay typed.
+ text = (
+ "<|tool_call>call:get_weather{location:Tokyo,unit:celsius,days:3,live:true}"
+ )
+ calls = parse_tool_calls_from_text(text, allow_incomplete = False)
+ assert len(calls) == 1
+ assert json.loads(calls[0]["function"]["arguments"]) == {
+ "location": "Tokyo",
+ "unit": "celsius",
+ "days": 3,
+ "live": True,
+ }
+
+
class TestHealingPathUnaffected:
def test_auto_heal_still_repairs_unclosed_function(self):
text = "cats"
diff --git a/studio/backend/tests/test_tool_xml_strip.py b/studio/backend/tests/test_tool_xml_strip.py
index 2ba3310fbe..c2dc1fe8db 100644
--- a/studio/backend/tests/test_tool_xml_strip.py
+++ b/studio/backend/tests/test_tool_xml_strip.py
@@ -125,6 +125,14 @@ def test_strips_orphan_closing_tag():
# Mid-string intentionally preserved (see preserve test).
+def test_strips_gemma_native_orphan_closing_tag():
+ cleaned = _TOOL_XML_RE.sub("", "Tool call drained.Visible tail.")
+
+ assert "" not in cleaned
+ assert "Tool call drained." in cleaned
+ assert "Visible tail." in cleaned
+
+
# ── Tail-only (PR #5735 follow-up) ───────────────────
diff --git a/tests/studio/test_stream_cancel_registration_timing.py b/tests/studio/test_stream_cancel_registration_timing.py
index 2e6b5f14da..73e60a5b0f 100644
--- a/tests/studio/test_stream_cancel_registration_timing.py
+++ b/tests/studio/test_stream_cancel_registration_timing.py
@@ -146,24 +146,50 @@ def test_async_generators_cleanup_tracker_in_finally():
)
-def test_streaming_responses_have_no_background_task():
- top = None
- for n in ast.walk(_TREE):
- if isinstance(n, ast.AsyncFunctionDef) and n.name == "openai_chat_completions":
- top = n
- break
- assert top is not None
+def test_chat_completions_streams_avoid_starlette_task_group():
+ top = _async_function("openai_chat_completions")
+ legacy_calls = []
+ same_task_calls = 0
for sub in ast.walk(top):
if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name)):
continue
- if sub.func.id != "StreamingResponse":
+ if sub.func.id == "StreamingResponse":
+ legacy_calls.append(sub.lineno)
+ if sub.func.id == "_SameTaskStreamingResponse":
+ same_task_calls += 1
+ assert not legacy_calls, (
+ "Streaming /v1/chat/completions must use _SameTaskStreamingResponse, "
+ "not Starlette's legacy task-group StreamingResponse. Lines: "
+ f"{legacy_calls}"
+ )
+ assert same_task_calls >= 5
+
+
+def test_openai_passthrough_stream_avoids_starlette_task_group():
+ top = _async_function("_openai_passthrough_stream")
+ legacy_calls = []
+ same_task_calls = 0
+ for sub in ast.walk(top):
+ if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name)):
continue
- kwargs = {kw.arg for kw in sub.keywords if kw.arg}
- assert "background" not in kwargs, (
- "StreamingResponse in openai_chat_completions must not pass "
- "`background=` -- cleanup now lives in the generator's finally "
- "block; a BackgroundTask would be skipped on abrupt disconnect"
- )
+ if sub.func.id == "StreamingResponse":
+ legacy_calls.append(sub.lineno)
+ if sub.func.id == "_SameTaskStreamingResponse":
+ same_task_calls += 1
+ assert not legacy_calls, (
+ "OpenAI passthrough streams must use _SameTaskStreamingResponse, "
+ "not Starlette's legacy task-group StreamingResponse. Lines: "
+ f"{legacy_calls}"
+ )
+ assert same_task_calls >= 2
+
+
+def test_local_chat_streams_install_same_task_disconnect_watcher():
+ top = _async_function("openai_chat_completions")
+ assert _calls_name(top, "_await_disconnect_then_cancel"), (
+ "Local same-task streams must watch request disconnects themselves; "
+ "do not restore Starlette's task-group StreamingResponse for this."
+ )
def test_direct_llama_server_streams_install_disconnect_watcher():
@@ -185,6 +211,33 @@ def test_direct_llama_server_streams_install_disconnect_watcher():
)
+def test_audio_input_stream_installs_disconnect_watcher():
+ audio = _async_function("audio_input_stream")
+ has_watcher = False
+ has_cleanup = False
+ for sub in ast.walk(audio):
+ if isinstance(sub, ast.Call):
+ fn = sub.func
+ if (
+ isinstance(fn, ast.Attribute)
+ and fn.attr == "create_task"
+ and isinstance(fn.value, ast.Name)
+ and fn.value.id == "asyncio"
+ and sub.args
+ and isinstance(sub.args[0], ast.Call)
+ and isinstance(sub.args[0].func, ast.Name)
+ and sub.args[0].func.id == "_await_disconnect_then_cancel"
+ ):
+ has_watcher = True
+ if isinstance(fn, ast.Name) and fn.id == "_stop_local_disconnect_cancel_watcher":
+ has_cleanup = True
+ assert has_watcher, (
+ "audio_input_stream must install a disconnect watcher so client "
+ "disconnects set cancel_event while asyncio.to_thread(next, ...) is blocked"
+ )
+ assert has_cleanup, "audio_input_stream must stop its disconnect watcher in finally"
+
+
# ── Behavioral helpers ───────────────────────────────────────
_WANTED = {
@@ -222,6 +275,21 @@ def _load_registry_module():
return mod
+def _load_same_task_response_module():
+ for n in _TREE.body:
+ if isinstance(n, ast.ClassDef) and n.name == "_SameTaskStreamingResponse":
+ source = ast.get_source_segment(SRC, n)
+ break
+ else:
+ raise AssertionError("_SameTaskStreamingResponse missing")
+ mod = {}
+ exec(
+ "class StreamingResponse: pass\nclass ClientDisconnect(Exception): pass\n" + source,
+ mod,
+ )
+ return mod
+
+
def _make_stream(tracker, raise_exc):
async def gen():
try:
@@ -326,6 +394,40 @@ def test_finally_cleanup_on_aclose():
assert "sid-abort" not in m["_CANCEL_REGISTRY"]
+def test_same_task_response_closes_body_iterator_on_send_disconnect():
+ m = _load_same_task_response_module()
+ closed = False
+
+ async def body():
+ nonlocal closed
+ try:
+ yield "data: first\n\n"
+ finally:
+ closed = True
+
+ async def run():
+ agen = body()
+ await agen.__anext__()
+ response = m["_SameTaskStreamingResponse"].__new__(m["_SameTaskStreamingResponse"])
+ response.body_iterator = agen
+ response.background = None
+ response._unstarted_cleanup = None
+
+ async def stream_response(_send):
+ raise OSError("client disconnected")
+
+ response.stream_response = stream_response
+ try:
+ await response({}, None, lambda _message: None)
+ except m["ClientDisconnect"]:
+ pass
+ else:
+ raise AssertionError("expected ClientDisconnect")
+
+ asyncio.run(run())
+ assert closed
+
+
def test_preset_cancel_event_exits_cleanly_with_done():
# Pending-replay: a stashed cancel pre-set cancel_event. The loop must break
# cleanly with final_chunk + [DONE], not propagate GeneratorExit from the GGUF wrapper.