* Fix Gemma 4 GGUF OpenAI API streams * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Avoid duplicate Responses stream disconnect watcher * Keep reasoning-only Responses output hidden * Address Gemma stream review comments * Avoid Responses stream task-group cleanup * Harden OpenAI chat completion streams * Address OpenAI stream review issues * Clean up Studio OpenAI stream helpers * Fix Studio passthrough cold stream timeout * Fix tool parser compatibility exports lint * Preserve audio stream disconnect cancellation * Avoid synthetic finish after passthrough errors * Address stream cleanup and Gemma parser reviews * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gemma 4: parse bare-string tool args and keep safetensors tools for native <|tool_call> - Quote bare unquoted string values in Gemma native tool-call args (e.g. {location:Tokyo,unit:celsius}) so they parse; JSON scalars stay typed. - Stop _detect_safetensors_features from suppressing supports_tools for templates that emit Gemma native <|tool_call>, which the shared parser now reads. - Add tests for both. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden Gemma tool-call parsing and stream-error detection Address three issues in the Gemma-native tool-call path: - _quote_gemma_object_keys stopped a bare (unquoted) string value at the first comma, so an argument like `location:New York, NY` was split mid-value and the synthesized JSON failed to parse, dropping the whole tool call. A bare value now ends only at `}` or a comma that begins the next `key:` pair. - parse_tool_calls_from_text scanned the entire response for Gemma markers even inside a tool call already parsed from a `<tool_call>{...}` JSON block, so a marker-like string inside an argument (data) was promoted to a second, unintended tool call. Matches inside an already-consumed call span are now skipped. - _openai_passthrough_stream relied on _monitor_openai_sse_line to flag a stream error, which returns early when monitor_id is None (skip_api_monitor), so an upstream error chunk left saw_stream_error unset and the synthetic-finish guard emitted a successful finish_reason after a failed stream. Error chunks are now detected independently of API monitoring. Adds tests/test_gemma_tool_parse_edge_cases.py covering the comma and marker-injection cases. * Emit the terminal finish_reason chunk in GGUF streams The OpenAI chat-completions GGUF tool stream and plain stream both built a final ChatCompletionChunk carrying finish_reason but never yielded it, so clients received the optional usage chunk and [DONE] with no chunk carrying finish_reason. OpenAI-compatible consumers rely on that terminal choice to distinguish stop/length/tool_calls. Yield it before the usage chunk and [DONE], matching the other streaming paths. * Parse tool calls in document order and skip nested markers both ways Unify the JSON- and Gemma-format tool-call passes into a single position-ordered scan: - Calls are now emitted in byte order across both formats, so a mixed output like `<|tool_call>call:create{...}<tool_call|> ... <tool_call> {"name":"read",...}</tool_call>` executes create before read, matching the order they appear in (tools run in returned order). - A candidate that starts inside an already-accepted call's span is skipped, in both directions: a JSON marker inside a Gemma argument and a Gemma marker inside a JSON argument are treated as data, not promoted to a second executable tool call. Extends tests/test_gemma_tool_parse_edge_cases.py with the ordering and JSON-in-Gemma nesting cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Quote bare Gemma array elements; order finish before trailing usage - _quote_gemma_object_keys skipped array values, so a Gemma call with a bare-string array argument like labels:[bug,ui] produced invalid JSON and the whole tool call was dropped. Array values are now scanned and bare string elements quoted, while numbers, quoted strings, and JSON literals are preserved. - In the OpenAI passthrough stream, a trailing usage-only chunk (stream_options.include_usage) that arrived before any finish chunk was relayed before the synthetic finish, producing usage -> finish -> [DONE]. Emit the synthetic finish before that usage chunk so the order matches the other streams (finish -> usage -> [DONE]). Extends tests/test_gemma_tool_parse_edge_cases.py with the bare-array cases. * Harden Gemma array parsing, XML-parameter guard, and stream teardown Address five review findings on the Gemma tool-call and OpenAI passthrough streaming paths: - parse_tool_calls_from_text collected JSON and Gemma markers without the _inside_open_parameter guard, so a marker embedded in an existing <function=...><parameter=...> value was promoted to a separate tool call. Candidates that start inside an open XML parameter are now skipped, matching the guard the XML-style parser already applies. - _quote_gemma_array_elements preserved array elements starting with { or [ verbatim, so an array of objects (items:[{path:a}]) or a nested array failed json.loads and the whole call was dropped. Object and nested-array elements are now normalised recursively. - _openai_passthrough_stream synthesized a finish chunk before a trailing usage-only chunk and set saw_finish_reason, which made the EOF guard skip the [DONE] sentinel. The EOF path now emits [DONE] whenever the upstream omitted it, even after a finish chunk was already synthesized. - /generate/stream drove generation through asyncio.to_thread with no disconnect watcher, so a client disconnect during a long generation went unnoticed until the next send. It now runs _await_disconnect_then_cancel against the request, matching the other local streaming endpoints. - _SameTaskStreamingResponse closed the body iterator with aclose() on a send-side disconnect, raising GeneratorExit so the generators' cancellation handlers (which finish the api_monitor entry) never ran. It now throws CancelledError, falling back to aclose() when athrow is unavailable. Extends tests/test_gemma_tool_parse_edge_cases.py with array-of-objects, nested-array, and marker-inside-XML-parameter cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Watch disconnects on Anthropic streams; keep timestamps in Gemma values Two follow-ups on the streaming and tool-parse paths: - _anthropic_tool_stream and _anthropic_plain_stream drove generation through asyncio.to_thread(next, gen, ...) and only polled is_disconnected() between events, so a client disconnect during prefill or a long generation/tool step held the decode slot until the next event or a failed send. Both now run the _await_disconnect_then_cancel watcher used by the other local streams, stop it in finally, and break promptly when cancel_event is set. - _GEMMA_NEXT_KEY_RE treated any comma followed by word-chars-then-colon as the next key, so a bare value such as "meet at 10:00, 11:00 tomorrow" was split into bogus keys. The next-key token must now be identifier-shaped (start with a letter or underscore), so a comma before a timestamp, ratio, or other numeric-then-colon text stays part of the value. Adds a timestamp-in-bare-value regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard nested markers, reset on disconnect, clean unstarted streams Three follow-ups on the tool-parse and streaming paths: - parse_tool_calls_from_text only skipped markers that fell inside a span it had already parsed successfully, so when an unquoted Gemma argument contained a literal marker (code:<|tool_call>call:terminal{...}<tool_call|>) the outer object failed to normalize, its span was never recorded, and the inner marker was promoted to a standalone terminal call. Candidates nested inside any other candidate's brace span are now skipped regardless of whether the enclosing candidate parsed, so a marker in malformed outer data is never executed. - /generate/stream skipped backend.reset_generation_state() when the disconnect watcher set cancel_event between chunks: the loop broke and the finally's reset is guarded on cancel_event being unset. A subprocess backend kept decoding after the client left. The cancel-break path now resets the backend. - _SameTaskStreamingResponse threw CancelledError / called aclose() on the body iterator on a send-side disconnect, but neither runs the try/finally of a generator that never started (early disconnect on http.response.start), so the passthrough's eagerly-opened upstream httpx stream and cancel-registry entry leaked. It now tracks whether the body started and, when it did not, runs an optional unstarted_cleanup hook; the OpenAI passthrough wires it to close the upstream resp/client and exit the cancel tracker. Adds a nested-unquoted-marker regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
456 lines
17 KiB
Python
456 lines
17 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Lightweight tool-call XML parsing and stripping helpers.
|
|
|
|
External inference servers import this module without pulling in the inference
|
|
orchestrator, structlog, httpx, or the rest of the studio backend.
|
|
"""
|
|
|
|
import json
|
|
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.
|
|
_TOOL_CLOSED_PATS = [
|
|
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
|
|
re.compile(r"<\|tool_call>.*?<tool_call\|>", re.DOTALL),
|
|
re.compile(r"<tool_call\|>"),
|
|
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
|
|
]
|
|
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
|
|
re.compile(r"<tool_call>.*$", re.DOTALL),
|
|
re.compile(r"<\|tool_call>.*$", re.DOTALL),
|
|
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
|
|
]
|
|
|
|
# Pre-compiled patterns for tool-call XML parsing.
|
|
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
|
|
_TC_GEMMA_START_RE = re.compile(r"<\|tool_call>call:([\w-]+)\s*\{")
|
|
_TC_FUNC_START_RE = re.compile(r"<function=([\w-]+)>\s*")
|
|
_TC_END_TAG_RE = re.compile(r"</tool_call>")
|
|
_TC_GEMMA_END_TAG_RE = re.compile(r"<tool_call\|>")
|
|
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
|
|
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
|
|
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
|
|
_GEMMA_QUOTE = '<|"|>'
|
|
_PARAM_CLOSE_TAG = "</parameter>"
|
|
_FUNC_CLOSE_TAG = "</function>"
|
|
# 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 _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:
|
|
<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>
|
|
<|tool_call>call:web_search{query:"..."}<tool_call|>
|
|
<tool_call><function=web_search><parameter=query>...</parameter></function></tool_call>
|
|
"""
|
|
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):
|
|
# A marker that begins inside an open <function=...><parameter=...> value
|
|
# is that parameter's data, not its own call; skip it (same guard the
|
|
# XML-style parser below applies to nested <function= markers).
|
|
if _inside_open_parameter(content, m.start()):
|
|
continue
|
|
end = _balanced_brace_end(content, m.end() - 1)
|
|
if end >= 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},
|
|
}
|
|
)
|
|
|
|
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:
|
|
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:
|
|
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 strip_tool_call_markup(text: str, *, final: bool = False) -> str:
|
|
"""Strip tool-call XML markup from text.
|
|
|
|
When ``final`` is False, only fully closed tool-call blocks are removed.
|
|
When ``final`` is True, trailing incomplete tool-call blocks are removed
|
|
too, and the result is stripped of surrounding whitespace.
|
|
"""
|
|
patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
|
|
for pat in patterns:
|
|
text = pat.sub("", text)
|
|
return text.strip() if final else text
|