* 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>
161 lines
6.8 KiB
Python
161 lines
6.8 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
|
|
|
|
"""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
|
|
|
|
|
|
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}<tool_call|>"
|
|
)
|
|
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"}<tool_call|>')
|
|
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}<tool_call|>"
|
|
)
|
|
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 = (
|
|
'<tool_call>{"name":"python","arguments":{"code":'
|
|
'"x = 1 # <|tool_call>call:terminal{command:ls}<tool_call|>"}}</tool_call>'
|
|
)
|
|
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}<tool_call|> and <|tool_call>call:b{y:2}<tool_call|>"
|
|
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}<tool_call|> then "
|
|
'<tool_call>{"name":"read","arguments":{"path":"a"}}</tool_call>'
|
|
)
|
|
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(<tool_call>{"name":"terminal","arguments":{"command":"ls"}}</tool_call>)'
|
|
'<|"|>}<tool_call|>'
|
|
)
|
|
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}<tool_call|>}<tool_call|>"
|
|
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]}<tool_call|>")
|
|
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]}<tool_call|>'
|
|
)
|
|
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}]}<tool_call|>"
|
|
)
|
|
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]]}<tool_call|>")
|
|
assert _args(calls[0]) == {"cells": [["a", "b"], ["c", "d"]]}
|
|
|
|
|
|
def test_gemma_marker_inside_xml_parameter_is_not_a_second_call():
|
|
# An XML-style <function=...> call whose <parameter=code> 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 = (
|
|
"<tool_call><function=python><parameter=code>"
|
|
"x = 1 # <|tool_call>call:terminal{command:ls}<tool_call|>"
|
|
"</parameter></function></tool_call>"
|
|
)
|
|
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 = (
|
|
"<tool_call><function=python><parameter=code>"
|
|
'run(<tool_call>{"name":"terminal","arguments":{"command":"ls"}}</tool_call>)'
|
|
"</parameter></function></tool_call>"
|
|
)
|
|
calls = parse_tool_calls_from_text(content)
|
|
assert [c["function"]["name"] for c in calls] == ["python"], calls
|