Files
unslothai--unsloth/studio/backend/tests/test_sf_client_tools_passthrough.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

787 lines
29 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
"""Client-tools passthrough healing for the safetensors/MLX backend.
Parity for #6801: when a NON-GGUF model is loaded and the request declares its
own ``tools`` with server-side tools OFF, text-form tool calls are promoted back
into structured ``tool_calls`` (declared tools only) via the shared healer. MLX
rides the same orchestrator path, so a single scripted backend covers both.
"""
import asyncio
import json
from types import SimpleNamespace
from models.inference import ChatCompletionRequest, ChatMessage
from routes.inference import openai_chat_completions
from core.inference.api_monitor import ApiMonitor
LOOKUP_TOOL = {
"type": "function",
"function": {
"name": "lookup",
"description": "Look something up",
"parameters": {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
},
},
}
SEARCH_TOOL = {
"type": "function",
"function": {
"name": "search",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
_CALL_XML = '<tool_call>{"name": "lookup", "arguments": {"q": "cats"}}</tool_call>'
_SEARCH_XML = '<tool_call>{"name": "search", "arguments": {"query": "dogs"}}</tool_call>'
class _Request:
state = SimpleNamespace()
url = SimpleNamespace(path = "/v1/chat/completions")
method = "POST"
scope: dict = {}
async def is_disconnected(self):
return False
class _ScriptedBackend:
"""Non-GGUF backend: ``generate_chat_response`` replays scripted
CUMULATIVE snapshots. ``responder(messages, tools)`` returns the snapshot
list for one generation, so nudge tests can vary output across turns."""
active_model_name = "sf-model"
def __init__(
self,
responder,
*,
stats = None,
):
self.models = {
"sf-model": {
"chat_template_info": {"template": "<tool_call> chatml"},
"context_length": 2048,
}
}
self._responder = responder
self._stats = stats
self.calls: list = []
self.reset_count = 0
def generate_chat_response(
self,
*,
messages,
tools = None,
stats_holder = None,
**kwargs,
):
self.calls.append({"messages": messages, "tools": tools, **kwargs})
snapshots = self._responder(messages, tools)
if stats_holder is not None and self._stats is not None:
stats_holder["stats"] = self._stats
for snap in snapshots:
yield snap
def reset_generation_state(self):
self.reset_count += 1
def _fixed(*snapshots):
"""Responder that always replays the given cumulative snapshots."""
return lambda messages, tools: list(snapshots)
def _llama_stub():
return SimpleNamespace(
is_loaded = False,
supports_tools = False,
is_vision = False,
context_length = None,
)
def _install(
monkeypatch,
backend,
*,
supports_tools = True,
):
import routes.inference as inf
from state.tool_policy import reset_tool_policy
reset_tool_policy()
monitor = ApiMonitor(max_entries = 8)
monkeypatch.setattr(inf, "api_monitor", monitor)
monkeypatch.setattr(inf, "get_llama_cpp_backend", lambda: _llama_stub())
monkeypatch.setattr(inf, "get_inference_backend", lambda: backend)
monkeypatch.setattr(
inf,
"_detect_safetensors_features",
lambda *a, **k: {"supports_tools": supports_tools},
)
return monitor
def _request(**kwargs):
base = dict(model = "default", messages = [ChatMessage(role = "user", content = "hi")])
base.update(kwargs)
return ChatCompletionRequest(**base)
def _call(payload, monkeypatch, backend, **install_kwargs):
_install(monkeypatch, backend, **install_kwargs)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
return asyncio.run(_run())
def _json_body(response):
return json.loads(response.body if hasattr(response, "body") else response.content)
def _collect_sse(response):
async def _run():
return [c async for c in response.body_iterator]
return asyncio.run(_run())
def _sse_objects(chunks):
out = []
for chunk in chunks:
if isinstance(chunk, bytes):
chunk = chunk.decode()
for line in str(chunk).splitlines():
if line.startswith("data: "):
data = line.removeprefix("data: ")
if data != "[DONE]":
out.append(json.loads(data))
return out
# ── Non-streaming ─────────────────────────────────────────────────
def test_xml_healed_to_tool_calls_non_streaming(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["content"] is None
calls = choice["message"]["tool_calls"]
assert len(calls) == 1
assert calls[0]["function"]["name"] == "lookup"
assert json.loads(calls[0]["function"]["arguments"]) == {"q": "cats"}
# The client tools reached the generator (template injection).
assert backend.calls[0]["tools"] == [LOOKUP_TOOL]
def test_undeclared_call_stays_text(monkeypatch):
xml = '<tool_call>{"name": "other", "arguments": {}}</tool_call>'
backend = _ScriptedBackend(_fixed(xml))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"].get("tool_calls") is None
assert choice["message"]["content"] == xml
def test_opt_out_relays_verbatim(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False, auto_heal_tool_calls = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"].get("tool_calls") is None
assert choice["message"]["content"] == _CALL_XML
def test_env_kill_switch_relays_verbatim(monkeypatch):
import core.inference.passthrough_healing as ph
monkeypatch.setattr(ph, "_HEALING_DISABLED", True)
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"].get("tool_calls") is None
assert choice["message"]["content"] == _CALL_XML
def test_no_tools_request_untouched(monkeypatch):
backend = _ScriptedBackend(_fixed("just a plain answer"))
payload = _request(stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
# No tools and no tool messages -> plain path, normal ChatCompletion.
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"]["content"] == "just a plain answer"
assert choice["message"].get("tool_calls") is None
def test_prose_around_call_retained(monkeypatch):
text = "Let me look:\n" + _CALL_XML + "\ndone"
backend = _ScriptedBackend(_fixed(text))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["content"] == "Let me look:\n\ndone"
assert choice["message"]["tool_calls"][0]["function"]["name"] == "lookup"
def test_empty_output_is_valid_stop(monkeypatch):
backend = _ScriptedBackend(_fixed(""))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"]["content"] in ("", None)
assert choice["message"].get("tool_calls") is None
def test_tool_role_follow_up_turn_preserves_history(monkeypatch):
backend = _ScriptedBackend(_fixed("The weather is sunny."))
payload = _request(
tools = [LOOKUP_TOOL],
stream = False,
messages = [
ChatMessage(role = "user", content = "weather?"),
ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": "weather"}'},
}
],
),
ChatMessage(role = "tool", tool_call_id = "call_0", content = "sunny"),
],
)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["message"]["content"] == "The weather is sunny."
# The tool history reached the generator intact (role=tool + assistant.tool_calls).
sent = backend.calls[0]["messages"]
roles = [m["role"] for m in sent]
assert "tool" in roles
assistant = next(m for m in sent if m["role"] == "assistant")
assert assistant.get("tool_calls")
def test_dict_arguments_history_does_not_crash(monkeypatch):
# Non-spec client: assistant tool_calls[].function.arguments as a dict.
backend = _ScriptedBackend(_fixed("ok"))
payload = _request(
tools = [LOOKUP_TOOL],
stream = False,
messages = [
ChatMessage(role = "user", content = "hi"),
ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": {"q": "x"}},
}
],
),
ChatMessage(role = "tool", tool_call_id = "call_0", content = "y"),
],
)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["message"]["content"] == "ok"
def test_forced_tool_choice_narrows_promotion(monkeypatch):
# tool_choice forces `search`; a `lookup` text call must NOT promote.
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(
tools = [LOOKUP_TOOL, SEARCH_TOOL],
stream = False,
tool_choice = {"type": "function", "function": {"name": "search"}},
)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"].get("tool_calls") is None
def test_parallel_cap_non_streaming(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML + _SEARCH_XML))
payload = _request(tools = [LOOKUP_TOOL, SEARCH_TOOL], stream = False, parallel_tool_calls = False)
body = _json_body(_call(payload, monkeypatch, backend))
calls = body["choices"][0]["message"]["tool_calls"]
assert len(calls) == 1
assert calls[0]["function"]["name"] == "lookup"
def test_usage_recorded_when_stats_present(monkeypatch):
stats = {"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}}
backend = _ScriptedBackend(_fixed(_CALL_XML), stats = stats)
payload = _request(tools = [LOOKUP_TOOL], stream = False)
monitor = _install(monkeypatch, backend)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
asyncio.run(_run())
[entry] = monitor.snapshot()
assert entry["prompt_tokens"] == 7
assert entry["completion_tokens"] == 3
# ── Nudge ─────────────────────────────────────────────────────────
def test_nudge_default_off_single_generation(monkeypatch):
# Signal present but unparseable; without opt-in, no retry.
truncated = '<tool_call>{"name": "lookup"'
backend = _ScriptedBackend(_fixed(truncated))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
_call(payload, monkeypatch, backend)
assert len(backend.calls) == 1
def test_nudge_opt_in_retry_recovers(monkeypatch):
truncated = '<tool_call>{"name": "lookup"'
def responder(messages, tools):
nudged = any(
"native tool-call format" in (m.get("content") or "")
for m in messages
if m.get("role") == "user"
)
return [_CALL_XML] if nudged else [truncated]
backend = _ScriptedBackend(responder)
payload = _request(tools = [LOOKUP_TOOL], stream = False, nudge_tool_calls = True)
body = _json_body(_call(payload, monkeypatch, backend))
assert len(backend.calls) == 2
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["tool_calls"][0]["function"]["name"] == "lookup"
def test_nudge_double_failure_relays_original(monkeypatch):
truncated = '<tool_call>{"name": "lookup"'
backend = _ScriptedBackend(_fixed(truncated))
payload = _request(tools = [LOOKUP_TOOL], stream = False, nudge_tool_calls = True)
body = _json_body(_call(payload, monkeypatch, backend))
assert len(backend.calls) == 2 # exactly one retry
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"]["content"] == truncated
# ── Streaming ─────────────────────────────────────────────────────
def test_streaming_heals_split_call_into_one_delta(monkeypatch):
# Cumulative snapshots that build the call across many increments.
pieces = ["<tool", '<tool_call>{"name": "loo', '<tool_call>{"name": "lookup", "argum']
cumulative = pieces + [_CALL_XML]
backend = _ScriptedBackend(_fixed(*cumulative))
payload = _request(tools = [LOOKUP_TOOL], stream = True)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
tool_deltas = [
tc
for o in objs
for tc in (o.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", []) or []
]
assert len(tool_deltas) == 1
assert tool_deltas[0]["function"]["name"] == "lookup"
finishes = [
o["choices"][0]["finish_reason"]
for o in objs
if o["choices"] and o["choices"][0].get("finish_reason")
]
assert finishes == ["tool_calls"]
def test_streaming_cancel_does_not_finalize_tool_call(monkeypatch):
# A stream cancelled via the registry ("Stop") must NOT promote the
# buffered-but-unclosed tool markup at finalize, else it executes a tool
# the user just cancelled. Guarded on cancel_event at the finalize step.
import routes.inference as inf
cancel_id = "cancel-me-6870"
# Balanced JSON but no closing </tool_call> -> healer HOLDS it until finalize.
held = '<tool_call>{"name": "lookup", "arguments": {"q": "cats"}}'
class _CancelMidStream(_ScriptedBackend):
def __init__(self):
super().__init__(_fixed(held))
def generate_chat_response(
self,
*,
messages,
tools = None,
stats_holder = None,
**kwargs,
):
self.calls.append({"messages": messages, "tools": tools, **kwargs})
yield held # healer holds the unclosed call
inf._cancel_by_cancel_id_or_stash(cancel_id) # user hits Stop before EOF
backend = _CancelMidStream()
payload = _request(tools = [LOOKUP_TOOL], stream = True, cancel_id = cancel_id)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
tool_deltas = [
tc
for o in objs
for tc in (o.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", []) or []
]
assert tool_deltas == [] # no tool promoted after cancel
finishes = [
o["choices"][0]["finish_reason"]
for o in objs
if o["choices"] and o["choices"][0].get("finish_reason")
]
assert "tool_calls" not in finishes # ends with finish_reason=stop, not tool_calls
def test_streaming_no_tools_verbatim(monkeypatch):
backend = _ScriptedBackend(_fixed("hello ", "hello world"))
payload = _request(stream = True)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
text = "".join(
(o["choices"][0]["delta"].get("content") or "")
for o in objs
if o["choices"] and "delta" in o["choices"][0]
)
assert text == "hello world"
finishes = [
o["choices"][0]["finish_reason"]
for o in objs
if o["choices"] and o["choices"][0].get("finish_reason")
]
assert finishes == ["stop"]
def test_streaming_repeated_snapshot_no_duplicate_call(monkeypatch):
# Repeated then shrunk cumulative snapshots must not double-heal.
backend = _ScriptedBackend(_fixed(_CALL_XML, _CALL_XML, _CALL_XML[:5], _CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = True)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
tool_deltas = [
tc
for o in objs
for tc in (o.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", []) or []
]
assert len(tool_deltas) == 1
def test_streaming_parallel_cap(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML + _SEARCH_XML))
payload = _request(tools = [LOOKUP_TOOL, SEARCH_TOOL], stream = True, parallel_tool_calls = False)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
tool_deltas = [
tc
for o in objs
for tc in (o.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", []) or []
]
assert len(tool_deltas) == 1
assert tool_deltas[0]["function"]["name"] == "lookup"
def test_streaming_generator_error_closes_cleanly(monkeypatch):
def responder(messages, tools):
raise RuntimeError("boom /secret/path")
backend = _ScriptedBackend(responder)
payload = _request(tools = [LOOKUP_TOOL], stream = True)
response = _call(payload, monkeypatch, backend)
chunks = _collect_sse(response)
joined = "".join(c.decode() if isinstance(c, bytes) else c for c in chunks)
assert "An internal error occurred" in joined
assert "secret/path" not in joined # CWE-209: no path leak
assert backend.reset_count >= 1
def test_streaming_disconnect_resets_once(monkeypatch):
class _DisconnectRequest(_Request):
async def is_disconnected(self):
return True
backend = _ScriptedBackend(_fixed("a", "ab", "abc"))
payload = _request(tools = [LOOKUP_TOOL], stream = True)
_install(monkeypatch, backend)
async def _run():
resp = await openai_chat_completions(
payload, request = _DisconnectRequest(), current_subject = "u"
)
return [c async for c in resp.body_iterator]
asyncio.run(_run())
assert backend.reset_count == 1
def test_mlx_uses_same_path(monkeypatch):
# MLX and safetensors share get_inference_backend(); one scripted backend covers both.
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["finish_reason"] == "tool_calls"
def test_tool_choice_none_does_not_advertise_tools(monkeypatch):
# tool_choice="none": no tools rendered into the template; history templating still applies.
backend = _ScriptedBackend(_fixed("plain answer"))
payload = _request(tools = [LOOKUP_TOOL], tool_choice = "none", stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["message"]["content"] == "plain answer"
assert backend.calls[0]["tools"] is None
def test_developer_message_folded_into_system_prompt(monkeypatch):
# The "developer" role folds into one leading system message (local templates reject it).
backend = _ScriptedBackend(_fixed("ok"))
payload = _request(
messages = [
ChatMessage(role = "developer", content = "always be terse"),
ChatMessage(role = "user", content = "hi"),
],
tools = [LOOKUP_TOOL],
stream = False,
)
_call(payload, monkeypatch, backend)
sent = backend.calls[0]["messages"]
assert sent[0]["role"] == "system"
assert "always be terse" in sent[0]["content"]
assert all(m.get("role") != "developer" for m in sent)
def test_failed_nudge_retry_keeps_original_response(monkeypatch):
# A raising retry must not 500; the first response is returned.
state = {"n": 0}
def responder(messages, tools):
state["n"] += 1
if state["n"] == 1:
return ['<tool_call>{"name":"lookup"'] # unhealable signal
raise RuntimeError("retry blew up")
backend = _ScriptedBackend(responder)
payload = _request(tools = [LOOKUP_TOOL], nudge_tool_calls = True, stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
assert state["n"] == 2
assert body["choices"][0]["finish_reason"] == "stop"
assert body["choices"][0]["message"]["content"] == '<tool_call>{"name":"lookup"'
def test_discarded_nudge_retry_reports_first_attempt_usage(monkeypatch):
# Double-failure nudge: the first response is delivered, but the retry's
# generate() overwrites stats_holder. The monitor must record the FIRST
# attempt's usage, not the discarded retry's.
first_stats = {"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}}
retry_stats = {"usage": {"prompt_tokens": 99, "completion_tokens": 99, "total_tokens": 198}}
class _PerCallStatsBackend(_ScriptedBackend):
def __init__(self):
# Unhealable truncated markup on both attempts -> retry is discarded.
super().__init__(lambda m, t: ['<tool_call>{"name":"lookup"'])
self._stats_seq = [first_stats, retry_stats]
def generate_chat_response(
self,
*,
messages,
tools = None,
stats_holder = None,
**kwargs,
):
self.calls.append({"messages": messages, "tools": tools, **kwargs})
stats = self._stats_seq[min(len(self.calls) - 1, len(self._stats_seq) - 1)]
if stats_holder is not None:
stats_holder["stats"] = stats
for snap in self._responder(messages, tools):
yield snap
backend = _PerCallStatsBackend()
payload = _request(tools = [LOOKUP_TOOL], nudge_tool_calls = True, stream = False)
monitor = _install(monkeypatch, backend)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
asyncio.run(_run())
assert len(backend.calls) == 2 # first attempt + one discarded retry
[entry] = monitor.snapshot()
# The delivered response is the first attempt, so its usage must be reported.
assert entry["prompt_tokens"] == 7
assert entry["completion_tokens"] == 3
def test_monitor_records_healed_call_not_raw_xml(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
monitor = _install(monkeypatch, backend)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
asyncio.run(_run())
snap = monitor.snapshot(include_details = True)
replies = json.dumps(snap)
assert "<tool_call>" not in replies
assert "lookup" in replies
def test_streaming_monitor_records_healed_call_not_raw_xml(monkeypatch):
# Monitor mirrors what the client received, never the healed-away raw markup.
backend = _ScriptedBackend(
_fixed("Sure. ", 'Sure. <tool_call>{"name": "loo', "Sure. " + _CALL_XML)
)
payload = _request(tools = [LOOKUP_TOOL], stream = True)
monitor = _install(monkeypatch, backend)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
response = asyncio.run(_run())
_collect_sse(response)
replies = json.dumps(monitor.snapshot(include_details = True))
assert "<tool_call>" not in replies
assert "Sure. " in replies
assert "[tool_calls] lookup(" in replies
def test_forced_tool_choice_narrows_templated_tools(monkeypatch):
# A forced function is the only schema rendered into the template.
backend = _ScriptedBackend(_fixed(_SEARCH_XML))
payload = _request(
tools = [LOOKUP_TOOL, SEARCH_TOOL],
stream = False,
tool_choice = {"type": "function", "function": {"name": "search"}},
)
body = _json_body(_call(payload, monkeypatch, backend))
templated = backend.calls[0]["tools"]
assert [t["function"]["name"] for t in templated] == ["search"]
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["tool_calls"][0]["function"]["name"] == "search"
def test_multimodal_content_parts_flattened_for_local_template(monkeypatch):
# Remote image URLs leave image=None, so content arrives as a part LIST:
# text parts are kept, the image part dropped.
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(
messages = [
ChatMessage(
role = "user",
content = [
{"type": "text", "text": "what is this?"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/cat.png"},
},
],
)
],
tools = [LOOKUP_TOOL],
stream = False,
)
body = _json_body(_call(payload, monkeypatch, backend))
templated = backend.calls[0]["messages"]
assert all(isinstance(m.get("content"), str) for m in templated)
assert any(m["content"] == "what is this?" for m in templated)
assert body["choices"][0]["finish_reason"] == "tool_calls"
def test_string_arguments_history_deserialized_for_template(monkeypatch):
# JSON-string tool_calls arguments become dicts in the templated copy;
# the HTTP response stays OpenAI-shaped.
backend = _ScriptedBackend(_fixed("done"))
payload = _request(
tools = [LOOKUP_TOOL],
stream = False,
messages = [
ChatMessage(role = "user", content = "weather?"),
ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": "weather"}'},
}
],
),
ChatMessage(role = "tool", tool_call_id = "call_0", content = "sunny"),
],
)
_json_body(_call(payload, monkeypatch, backend))
assistant = next(m for m in backend.calls[0]["messages"] if m["role"] == "assistant")
assert assistant["tool_calls"][0]["function"]["arguments"] == {"q": "weather"}
def test_unparseable_arguments_string_left_untouched(monkeypatch):
backend = _ScriptedBackend(_fixed("ok"))
payload = _request(
tools = [LOOKUP_TOOL],
stream = False,
messages = [
ChatMessage(role = "user", content = "hi"),
ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": "not json {"},
}
],
),
ChatMessage(role = "tool", tool_call_id = "call_0", content = "y"),
],
)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["message"]["content"] == "ok"
assistant = next(m for m in backend.calls[0]["messages"] if m["role"] == "assistant")
assert assistant["tool_calls"][0]["function"]["arguments"] == "not json {"
def test_mcp_enabled_without_server_tools_uses_passthrough(monkeypatch):
# mcp_enabled=true with an empty registry must not silently drop the
# declared tools; the gate keys on the server-side path claiming the request.
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False, mcp_enabled = True)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["tool_calls"][0]["function"]["name"] == "lookup"
assert backend.calls[0]["tools"] == [LOOKUP_TOOL]