* 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
16 KiB
Python
456 lines
16 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
|
|
|
|
"""Capability advertisement contract: classifier honesty, worker->orchestrator
|
|
IPC hop, route-layer end-to-end. Pure helpers + fakes; no torch/transformers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
_backend_root = Path(__file__).resolve().parent.parent
|
|
if str(_backend_root) not in sys.path:
|
|
sys.path.insert(0, str(_backend_root))
|
|
|
|
|
|
# Qwen3 snippet covering tools, enable_thinking, preserve_thinking.
|
|
QWEN3_TEMPLATE = """
|
|
{%- if tools %}
|
|
{{- '<|im_start|>system\\nFor each function call, return a json object'
|
|
' wrapped inside <tool_call></tool_call> tags.\\n' }}
|
|
{%- for tool in tools %}
|
|
{{- tool | tojson }}
|
|
{%- endfor %}
|
|
{%- endif %}
|
|
{%- for message in messages %}
|
|
{%- if message.role == 'tool' %}
|
|
{{- '<|im_start|>tool\\n' + message.content + '<|im_end|>\\n' }}
|
|
{%- endif %}
|
|
{%- endfor %}
|
|
{%- if enable_thinking is defined and enable_thinking %}
|
|
{{- '<think>' }}
|
|
{%- endif %}
|
|
{%- if preserve_thinking %}
|
|
{{- assistant.reasoning_content }}
|
|
{%- endif %}
|
|
"""
|
|
|
|
|
|
GPT_OSS_TEMPLATE = """
|
|
<|start|>system<|message|>You are gpt-oss.
|
|
reasoning_effort: {{ reasoning_effort }}
|
|
<|end|>
|
|
"""
|
|
|
|
|
|
PLAIN_TEMPLATE = """
|
|
{%- for message in messages %}
|
|
{{- message.role + ': ' + message.content + '\\n' }}
|
|
{%- endfor %}
|
|
"""
|
|
|
|
|
|
# ── Tests: classifier honesty ────────────────────────────────────────
|
|
|
|
|
|
def test_detect_reasoning_flags_qwen3_supports_tools_and_reasoning():
|
|
from core.inference.llama_cpp import detect_reasoning_flags
|
|
|
|
flags = detect_reasoning_flags(QWEN3_TEMPLATE, "unsloth/Qwen3-0.6B")
|
|
assert flags["supports_tools"] is True
|
|
assert flags["supports_reasoning"] is True
|
|
assert flags["reasoning_style"] == "enable_thinking"
|
|
assert flags["supports_preserve_thinking"] is True
|
|
assert flags["reasoning_always_on"] is False
|
|
|
|
|
|
def test_detect_reasoning_flags_plain_template_all_false():
|
|
from core.inference.llama_cpp import detect_reasoning_flags
|
|
|
|
flags = detect_reasoning_flags(PLAIN_TEMPLATE, "some/PlainChat")
|
|
assert flags["supports_tools"] is False
|
|
assert flags["supports_reasoning"] is False
|
|
assert flags["supports_preserve_thinking"] is False
|
|
assert flags["reasoning_always_on"] is False
|
|
|
|
|
|
def test_detect_reasoning_flags_none_template_returns_all_false():
|
|
from core.inference.llama_cpp import detect_reasoning_flags
|
|
|
|
flags = detect_reasoning_flags(None)
|
|
assert flags["supports_tools"] is False
|
|
assert flags["supports_reasoning"] is False
|
|
assert flags["supports_preserve_thinking"] is False
|
|
assert flags["reasoning_always_on"] is False
|
|
assert flags["reasoning_style"] == "enable_thinking"
|
|
|
|
|
|
def test_detect_safetensors_features_passes_template_through_to_classifier():
|
|
"""Route wrapper forwards a real template to the inner classifier."""
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
backend = SimpleNamespace(active_model_name = "unsloth/Qwen3-0.6B")
|
|
flags = _detect_safetensors_features(backend, QWEN3_TEMPLATE)
|
|
assert flags["supports_tools"] is True
|
|
assert flags["supports_reasoning"] is True
|
|
|
|
|
|
def test_detect_safetensors_features_none_template_returns_all_false():
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
backend = SimpleNamespace(active_model_name = "unsloth/Qwen3-0.6B")
|
|
flags = _detect_safetensors_features(backend, None)
|
|
assert flags == {
|
|
"supports_reasoning": False,
|
|
"reasoning_style": "enable_thinking",
|
|
"reasoning_always_on": False,
|
|
"reasoning_effort_levels": [],
|
|
"supports_preserve_thinking": False,
|
|
"supports_tools": False,
|
|
}
|
|
|
|
|
|
def test_detect_safetensors_features_gptoss_disables_tools():
|
|
"""gpt-oss Harmony: tools off even if template marks it."""
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
backend = MagicMock()
|
|
backend.active_model_name = "unsloth/gpt-oss-20b"
|
|
backend._is_gpt_oss_model.return_value = True
|
|
|
|
flags = _detect_safetensors_features(backend, QWEN3_TEMPLATE)
|
|
assert flags["supports_reasoning"] is True
|
|
assert flags["reasoning_style"] == "reasoning_effort"
|
|
assert flags["supports_tools"] is False
|
|
|
|
|
|
# Llama-3 / Mistral advertise tools but emit <|python_tag|> / [TOOL_CALLS],
|
|
# which our parser can't read. The route helper must not flip supports_tools=True
|
|
# for them, else the UI enables a pill the agentic loop can't honour.
|
|
|
|
LLAMA3_TEMPLATE = """
|
|
{%- if tools %}
|
|
{{- '<|start_header_id|>system<|end_header_id|>' }}
|
|
{{- 'You have access to the following tools.' }}
|
|
{%- for tool in tools %}
|
|
{{- tool | tojson }}
|
|
{%- endfor %}
|
|
{%- endif %}
|
|
{%- for message in messages %}
|
|
{%- if message.role == 'tool' %}
|
|
{{- '<|start_header_id|>ipython<|end_header_id|>' }}
|
|
{{- '<|python_tag|>' }}
|
|
{{- message.content }}
|
|
{%- endif %}
|
|
{%- endfor %}
|
|
"""
|
|
|
|
MISTRAL_TEMPLATE = """
|
|
{%- if tools %}
|
|
{%- for tool in tools %}
|
|
{{- tool | tojson }}
|
|
{%- endfor %}
|
|
{%- endif %}
|
|
{%- for message in messages %}
|
|
{%- if message.role == 'tool' %}
|
|
{{- '[TOOL_CALLS]' + message.content + '[/TOOL_CALLS]' }}
|
|
{%- endif %}
|
|
{%- endfor %}
|
|
"""
|
|
|
|
|
|
def test_detect_safetensors_features_llama3_template_suppresses_tools():
|
|
"""Llama-3 emits <|python_tag|>; safetensors loop cannot parse it."""
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
backend = SimpleNamespace(active_model_name = "unsloth/Llama-3.2-3B-Instruct")
|
|
flags = _detect_safetensors_features(backend, LLAMA3_TEMPLATE)
|
|
assert flags["supports_tools"] is False
|
|
|
|
|
|
def test_detect_safetensors_features_mistral_template_suppresses_tools():
|
|
"""Mistral emits [TOOL_CALLS]; safetensors loop cannot parse it."""
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
backend = SimpleNamespace(active_model_name = "unsloth/mistral-7b-instruct-v0.3")
|
|
flags = _detect_safetensors_features(backend, MISTRAL_TEMPLATE)
|
|
assert flags["supports_tools"] is False
|
|
|
|
|
|
def test_detect_safetensors_features_qwen_tool_call_keeps_tools_on():
|
|
"""Sanity check: gate only suppresses non-Qwen formats."""
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
backend = SimpleNamespace(active_model_name = "unsloth/Qwen3-0.6B")
|
|
flags = _detect_safetensors_features(backend, QWEN3_TEMPLATE)
|
|
assert flags["supports_tools"] is True
|
|
|
|
|
|
def test_detect_safetensors_features_function_xml_format_keeps_tools_on():
|
|
"""Templates emitting <function=name> XML are parser-compatible."""
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
tpl_with_function_xml = (
|
|
"{%- if tools %}<|im_start|>system\n"
|
|
"Tool call format: <function=name><parameter=k>v</parameter></function>"
|
|
"<|im_end|>{%- endif %}"
|
|
)
|
|
backend = SimpleNamespace(active_model_name = "custom/with-function-xml")
|
|
flags = _detect_safetensors_features(backend, tpl_with_function_xml)
|
|
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{...}<tool_call|>, 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}<tool_call|>{%- 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 ``<tool_call>\n<function=name>...``. Faithful slice so the
|
|
# classifier never silently regresses for this family.
|
|
|
|
QWEN35_TOOL_INSTRUCTION = (
|
|
"{%- if tools %}\n"
|
|
" <|im_start|>system\n"
|
|
" # Tools\n"
|
|
" <tools>\n"
|
|
" {%- for tool in tools %}{{ tool | tojson }}{%- endfor %}\n"
|
|
" </tools>\n"
|
|
" If you choose to call a function ONLY reply in the following format:\n"
|
|
" <tool_call>\n"
|
|
" <function=example_function_name>\n"
|
|
" <parameter=example_parameter_1>\n"
|
|
" value_1\n"
|
|
" </parameter>\n"
|
|
" </function>\n"
|
|
" </tool_call>\n"
|
|
" <|im_end|>\n"
|
|
"{%- endif %}\n"
|
|
"{%- if enable_thinking is defined and enable_thinking %}{{- '<think>' }}{%- endif %}\n"
|
|
)
|
|
|
|
|
|
def test_detect_safetensors_features_qwen35_keeps_tools_on():
|
|
"""unsloth/Qwen3.5-0.8B family must surface tools+reasoning on."""
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
backend = SimpleNamespace(active_model_name = "unsloth/Qwen3.5-0.8B")
|
|
flags = _detect_safetensors_features(backend, QWEN35_TOOL_INSTRUCTION)
|
|
assert flags["supports_tools"] is True
|
|
assert flags["supports_reasoning"] is True
|
|
assert flags["reasoning_style"] == "enable_thinking"
|
|
|
|
|
|
# ── Tests: IPC bridge contract ───────────────────────────────────────
|
|
|
|
|
|
def test_orchestrator_mirrors_chat_template_info_into_models_dict():
|
|
"""Worker → orchestrator copies chat_template_info verbatim."""
|
|
from core.inference.orchestrator import InferenceOrchestrator
|
|
|
|
orch = InferenceOrchestrator.__new__(InferenceOrchestrator)
|
|
orch.models = {}
|
|
orch.active_model_name = None
|
|
orch.loading_models = set()
|
|
|
|
model_info = {
|
|
"identifier": "unsloth/Qwen3-0.6B",
|
|
"display_name": "Qwen3-0.6B",
|
|
"is_vision": False,
|
|
"is_lora": False,
|
|
"is_gguf": False,
|
|
"is_audio": False,
|
|
"audio_type": None,
|
|
"has_audio_input": False,
|
|
"chat_template_info": {
|
|
"has_template": True,
|
|
"template": QWEN3_TEMPLATE,
|
|
"format_type": "chatml",
|
|
"template_name": "qwen3",
|
|
"special_tokens": {"bos_token": "<|im_start|>"},
|
|
},
|
|
}
|
|
|
|
# Replay orchestrator.load_model's mirror block.
|
|
orch.active_model_name = model_info["identifier"]
|
|
orch.models[orch.active_model_name] = {
|
|
"is_vision": model_info.get("is_vision", False),
|
|
"is_lora": model_info.get("is_lora", False),
|
|
"display_name": model_info.get("display_name", "x"),
|
|
"is_audio": model_info.get("is_audio", False),
|
|
"audio_type": model_info.get("audio_type"),
|
|
"has_audio_input": model_info.get("has_audio_input", False),
|
|
}
|
|
_tpl_info = model_info.get("chat_template_info")
|
|
if isinstance(_tpl_info, dict):
|
|
orch.models[orch.active_model_name]["chat_template_info"] = _tpl_info
|
|
|
|
entry = orch.models[orch.active_model_name]
|
|
tpl = entry.get("chat_template_info", {}).get("template")
|
|
assert tpl == QWEN3_TEMPLATE
|
|
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
flags = _detect_safetensors_features(
|
|
SimpleNamespace(active_model_name = orch.active_model_name), tpl
|
|
)
|
|
assert flags["supports_tools"] is True
|
|
assert flags["supports_reasoning"] is True
|
|
|
|
|
|
def test_orchestrator_missing_chat_template_info_falls_back_to_all_false():
|
|
"""Old / malformed worker reply: no crash, all flags False."""
|
|
from core.inference.orchestrator import InferenceOrchestrator
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
orch = InferenceOrchestrator.__new__(InferenceOrchestrator)
|
|
orch.models = {}
|
|
orch.active_model_name = "unsloth/Qwen3-0.6B"
|
|
|
|
model_info = {
|
|
"identifier": "unsloth/Qwen3-0.6B",
|
|
"is_vision": False,
|
|
"is_lora": False,
|
|
# NB: no chat_template_info key
|
|
}
|
|
orch.models[orch.active_model_name] = {
|
|
"is_vision": False,
|
|
"is_lora": False,
|
|
}
|
|
_tpl_info = model_info.get("chat_template_info")
|
|
if isinstance(_tpl_info, dict):
|
|
orch.models[orch.active_model_name]["chat_template_info"] = _tpl_info
|
|
|
|
entry = orch.models[orch.active_model_name]
|
|
tpl = entry.get("chat_template_info", {}).get("template")
|
|
assert tpl is None
|
|
|
|
flags = _detect_safetensors_features(
|
|
SimpleNamespace(active_model_name = orch.active_model_name), tpl
|
|
)
|
|
assert flags["supports_tools"] is False
|
|
|
|
|
|
def test_worker_load_reply_payload_includes_chat_template_info():
|
|
"""Worker IPC reply carries chat_template_info dict."""
|
|
|
|
class _StubBackend:
|
|
def __init__(self, identifier, template):
|
|
self.active_model_name = identifier
|
|
self.models = {
|
|
identifier: {
|
|
"chat_template_info": {
|
|
"has_template": True,
|
|
"template": template,
|
|
"format_type": "chatml",
|
|
"template_name": "qwen3",
|
|
"special_tokens": {"bos_token": "<|im_start|>"},
|
|
}
|
|
}
|
|
}
|
|
|
|
backend = _StubBackend("unsloth/Qwen3-0.6B", QWEN3_TEMPLATE)
|
|
mc = SimpleNamespace(
|
|
identifier = "unsloth/Qwen3-0.6B",
|
|
display_name = "Qwen3-0.6B",
|
|
is_vision = False,
|
|
is_lora = False,
|
|
)
|
|
|
|
# Replay the worker's payload-build block.
|
|
model_info = {
|
|
"identifier": mc.identifier,
|
|
"display_name": mc.display_name,
|
|
"is_vision": mc.is_vision,
|
|
"is_lora": mc.is_lora,
|
|
"is_gguf": False,
|
|
}
|
|
_bm = getattr(backend, "models", {}) or {}
|
|
_entry = _bm.get(mc.identifier) or _bm.get(getattr(backend, "active_model_name", None)) or {}
|
|
_tpl_info = _entry.get("chat_template_info")
|
|
if isinstance(_tpl_info, dict):
|
|
model_info["chat_template_info"] = {
|
|
"has_template": bool(_tpl_info.get("has_template", False)),
|
|
"template": _tpl_info.get("template"),
|
|
"format_type": _tpl_info.get("format_type", "generic"),
|
|
"template_name": _tpl_info.get("template_name"),
|
|
"special_tokens": _tpl_info.get("special_tokens", {}) or {},
|
|
}
|
|
|
|
assert "chat_template_info" in model_info
|
|
assert model_info["chat_template_info"]["template"] == QWEN3_TEMPLATE
|
|
assert model_info["chat_template_info"]["has_template"] is True
|
|
|
|
|
|
def test_worker_load_reply_payload_survives_missing_template():
|
|
"""Tokenizer with no chat_template still yields a valid reply."""
|
|
|
|
class _StubBackend:
|
|
def __init__(self):
|
|
self.active_model_name = "legacy/no-template"
|
|
self.models = {"legacy/no-template": {}} # no chat_template_info
|
|
|
|
backend = _StubBackend()
|
|
mc = SimpleNamespace(
|
|
identifier = "legacy/no-template",
|
|
display_name = "legacy",
|
|
is_vision = False,
|
|
is_lora = False,
|
|
)
|
|
|
|
model_info = {
|
|
"identifier": mc.identifier,
|
|
"display_name": mc.display_name,
|
|
"is_vision": mc.is_vision,
|
|
"is_lora": mc.is_lora,
|
|
"is_gguf": False,
|
|
}
|
|
_bm = getattr(backend, "models", {}) or {}
|
|
_entry = _bm.get(mc.identifier) or {}
|
|
_tpl_info = _entry.get("chat_template_info")
|
|
if isinstance(_tpl_info, dict):
|
|
model_info["chat_template_info"] = dict(_tpl_info)
|
|
|
|
assert "chat_template_info" not in model_info
|
|
|
|
|
|
# ── End-to-end: route layer sees the template, advertises True ───────
|
|
|
|
|
|
def test_route_layer_emits_supports_tools_true_for_qwen3_safetensors():
|
|
"""E2E: Qwen3 safetensors flips supports_tools=True."""
|
|
from routes.inference import _detect_safetensors_features
|
|
|
|
backend = SimpleNamespace(
|
|
active_model_name = "unsloth/Qwen3-0.6B",
|
|
models = {
|
|
"unsloth/Qwen3-0.6B": {
|
|
"is_vision": False,
|
|
"chat_template_info": {
|
|
"has_template": True,
|
|
"template": QWEN3_TEMPLATE,
|
|
"format_type": "chatml",
|
|
},
|
|
}
|
|
},
|
|
)
|
|
|
|
_model_info = backend.models.get(backend.active_model_name, {})
|
|
_tpl = _model_info.get("chat_template_info", {}).get("template")
|
|
flags = _detect_safetensors_features(backend, _tpl)
|
|
|
|
assert flags["supports_tools"] is True
|
|
assert flags["supports_reasoning"] is True
|
|
assert flags["supports_preserve_thinking"] is True
|