* inference: add passthrough tool-call healing core (heal_gate, heal_openai_message, StreamToolCallHealer, nudge helpers)
Small GGUF models often emit tool calls as text (<tool_call>{...}</tool_call>,
Gemma <|tool_call>, <function=> XML) instead of structured tool_calls. Studio's
enable-tools loop already heals these, but the client-tool passthrough
(unsloth run --disable-tools, unsloth start agents) relays them verbatim, so
the agent sees prose and the turn dies.
This module is the shared response-side repair layer the passthrough routes
will call: promote parsed text-form calls to structured calls, but only for
function names the client actually declared; coerce arguments through the same
canonical-key healing as the tool loop; never touch the upstream request body
(llama-server KV/slot reuse stays byte-identical). StreamToolCallHealer is the
streaming buffer-and-repair state machine: prose forwards immediately, only a
partial-signal tail or a suspected tool block is held, false alarms flush
verbatim, and a 64 KiB bound caps memory. nudge_should_retry/nudge_messages
support an opt-in single-retry nudge for non-streaming routes (wired later).
Kill-switch: UNSLOTH_DISABLE_TOOL_CALL_HEALING=1. Reuses
core/tool_healing.parse_tool_calls_from_text, strip_tool_call_markup, and
tool_loop_controller.coerce_tool_arguments unchanged.
* inference: heal text-form tool calls on the OpenAI and Responses passthrough
Wire the passthrough healing core into /v1/chat/completions and /v1/responses,
default ON whenever the request declares client tools:
Non-streaming: heal_openai_message runs inside the existing response-mutation
loop; a promoted call flips finish_reason to tool_calls and nulls the content,
and the verbatim-bytes fast path still applies when nothing was healed.
/v1/responses non-streaming inherits this through openai_chat_completions.
Streaming: a StreamToolCallHealer per stream. Ordinary prose relays
byte-for-byte (a fast path keeps upstream bytes when the healer passes a chunk
through whole); once a tool signal appears, content is held, and at the
finish/[DONE] boundary either synthetic delta.tool_calls chunks replace the
markup (finish_reason rewritten to tool_calls, including the synthetic-finish
path) or a false alarm flushes the held text verbatim. Structured upstream
deltas put the healer to sleep after flushing anything held, so grammar-mode
responses stay byte-identical. The Responses stream feeds healed calls through
the same per-call state machinery as structured deltas (indexes live in a
disjoint range so a healed call can never merge into a structured call's
state), and the visible/reasoning split runs first so reasoning text is never
promoted. parallel_tool_calls=false caps healed calls on every path.
The upstream request body is never touched and healing issues no extra
generation, so llama-server slot/KV-cache reuse is unchanged. Opt-out per
request with auto_heal_tool_calls=false (Responses reads it from the
extra-body); requests without tools relay verbatim.
* inference: heal text-form tool calls on the Anthropic /v1/messages passthrough
Streaming: AnthropicPassthroughEmitter.enable_healing(allowed_tools) routes
content deltas through the shared StreamToolCallHealer. A promoted call closes
any open text block (only the safe prose prefix ever streamed into it), opens a
synthetic tool_use block with a fresh toolu_* id, carries one input_json_delta,
and closes; finish() then forces stop_reason to tool_use unless a truncation
(max_tokens) wins. Structured upstream deltas flush anything held and put the
healer to sleep, so grammar-mode responses are untouched, as is every stream
where enable_healing is never called (Studio's own loop, no-tools requests).
disable_parallel_tool_use caps healed calls too.
Non-streaming: the OpenAI message dict is healed BEFORE block building, so the
existing tool_use promotion loop and stop_reason line treat promoted calls
exactly like native ones (finish_reason length still maps to max_tokens). The
legacy tool-XML strip still runs on remaining text, so opted-out requests keep
today's cleanup behavior byte-for-byte.
auto_heal_tool_calls is now a typed field on AnthropicMessagesRequest
(default True, mirroring Chat Completions) and threads into both passthrough
calls. Healing never touches the upstream request body.
* inference: opt-in single-retry tool-call nudge on the non-streaming passthrough
When the model clearly tried to call a tool (a tool signal in the text) but
healing produced nothing usable, re-ask once: the retry body is the original
body plus an assistant turn (the model's own failed text) and a short user
nudge naming the declared tools. The prompt prefix stays byte-identical, so
llama-server reuses the slot's KV cache and only the two-message suffix is
prefilled. The retry replaces the original response only when it actually
yields a promotable or structured call; on any error or still-garbage output
the original response is returned unchanged. Exactly one retry, non-streaming
OpenAI and Anthropic passthroughs only (a stream has already emitted bytes).
OPT-IN per user decision: nudge_tool_calls=true per request (typed on both
ChatCompletionRequest and AnthropicMessagesRequest, lifted from the Responses
extra-body), or UNSLOTH_TOOL_CALL_NUDGE=1 to flip the process default.
auto_heal_tool_calls=false disables healing AND the nudge.
Also align the non-streaming heal on allow_incomplete=True: the response is
final, so a trailing unclosed tool block is a model failure worth repairing,
matching the enable-tools loop's drain semantics.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* inference: never assume the upstream response shape in the nudge helpers
llama-server error bodies can carry message: null (or no choices at all), and
_last_assistant_text / response_has_promotable_calls / nudge_should_retry
called .get() on the message without a dict check, so a malformed upstream
response raised an AttributeError the surrounding except tuples did not catch,
failing the request instead of degrading to 'nothing to heal'. Route the shape
probing through one _first_choice_message helper that returns None for any
non-dict message, and add a parametrized test over the malformed shapes.
* inference: constrain healing by tool_choice, preserve length finish_reason, keep healed event order in Responses streams
Three review findings on the passthrough healer:
- heal_gate now honors the request's tool_choice: "none" disables healing
outright and a forced function narrows the promotion allowlist to that
one function, so healing can never contradict the request's tool-choice
constraint. Wired through the OpenAI chat (stream and non-stream),
Responses, and Anthropic (converted shape) passthroughs.
- The OpenAI non-streaming heal only upgrades finish_reason "stop" to
"tool_calls"; a truncated generation keeps "length" (the healed call
stays attached) matching the streaming and Anthropic paths.
- The Responses stream emits healer events in order instead of collapsing
all text ahead of the healed calls, so text after a healed call no longer
jumps ahead of the function_call item and output indexes are claimed in
the order the model produced them.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* inference: all-or-nothing promotion when a response mixes declared and undeclared text-form calls
Promoting a subset used to strip ALL tool markup from the content, which
silently deleted the text of any call naming an undeclared tool. The heal
now declines entirely when any parsed call is unpromotable, so the whole
message relays verbatim (pre-PR behavior) and no bytes are ever lost. In
streaming, a declared call that completed before an undeclared one arrived
is already emitted; the late undeclared markup still flushes as raw text.
The nudge helpers mirror the same contract via a shared predicate.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* tests: wrap long lines in the Responses healing tests to the project style
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* inference: span-exact healing, disjoint healed stream indexes, per-call Responses message items, allowlisted nudge acceptance
Four review findings on the passthrough healer:
- parse_tool_calls_from_text gains an optional with_spans return so healing
removes EXACTLY the promoted calls' markup. This supersedes the previous
all-or-nothing rule: declared calls promote and every unpromoted byte
(undeclared calls, unparseable closed blocks, suppressed alternate
formats such as a <function=...> block after a JSON call) relays as text.
The stream healer also processes one block per pass, so text between two
healed calls keeps its document position instead of trailing them.
- The OpenAI chat stream shifts native tool-call delta indexes past any
already-emitted healed calls; clients merge deltas by index, so a healed
call and a later native call can no longer merge into one.
- A healed call in the Responses stream closes the open message item and
trailing text opens a fresh one with a later output index, matching the
native stream shape; response.completed snapshots every message item
with its own text.
- The nudge retry only replaces the original response when the retry's
structured call names a DECLARED tool; a hallucinated undeclared call is
not an improvement.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: stop the heal path folding trailing prose into a closed function call
parse_tool_calls_from_text(allow_incomplete=True) cut a <function=...> body only
at an end-anchored </function>, so a fully closed call followed by trailing prose
(<function=..>..</parameter></function> words) folded </parameter></function> and
the prose into the tool argument and deleted the prose from visible content. The
strict path (allow_incomplete=False) already cut at the real </function> via rfind.
Do the same in both modes: trim the body at the real </function> when present and
end the removal span there, falling back to the end-anchored strip and body_end
only when the call is genuinely truncated. Add a regression test.
* inference: one shared single-call budget for healed and native calls
Codex round 5: the parallel-call caps counted healed and native calls
separately, so a healed text-form call followed by a native structured
delta double-emitted on all three streaming surfaces when the client
disabled parallel calls.
- OpenAI SSE: once a healed call went out with parallel_tool_calls
false, native tool_call deltas are dropped instead of index-shifted.
- Anthropic emitter: native deltas skip block allocation when the
healed-plus-native count already filled the single slot, and healed
emission counts open native states too.
- Responses stream: native deltas that survived the chunk-level cap are
skipped once a healed call claimed the slot.
Also adds a span assertion for the closed-</function> trailing-prose
parse fixed in the previous commit.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: relay undeclared text-form calls as text on Anthropic non-streaming
heal_openai_message promotes only declared text-form tool calls and
span-trims just their markup, deliberately leaving every unpromoted byte
(undeclared text-form calls included) in the content to relay as text.
The Anthropic non-streaming builder then ran a blanket _TOOL_XML_RE strip
over that content unconditionally, deleting the undeclared block before
building the text part, so Anthropic clients silently lost a call the
OpenAI non-streaming path preserves. The strip was harmless when healing
was all-or-nothing but became data loss once healing turned span-exact.
Gate the legacy strip on whether healing promoted a call, matching the
OpenAI passthrough and the intent already stated in the comment above.
Add a route-level regression test for the mixed declared+undeclared case.
* inference: require fully declared nudge retries; keep unpromoted Anthropic text
Codex round 6, two findings:
- response_has_promotable_calls accepted a nudge retry when any one
structured call named a declared tool, so a mixed retry (hallucinated
undeclared call plus a declared one) replaced the original and the
caller forwarded the undeclared call, or with parallel_tool_calls
false could keep only it. All structured retry calls must be declared.
- The Anthropic non-streaming builder still ran the legacy _TOOL_XML_RE
strip after span-exact healing, deleting undeclared or malformed call
text that healing deliberately preserved. The legacy strip now runs
only when healing is off (no declared tools, or opted out), matching
the OpenAI passthrough.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* inference: keep unpromoted Anthropic text whenever healing is active
The previous commit skipped the legacy strip only when a call was
actually promoted, so an undeclared-only (or malformed-only) response
was still silently emptied: exactly the dead-turn shape this path
exists to fix, and inconsistent with the OpenAI passthrough, which
relays those bytes verbatim. Gate the strip on healing being active
instead; opt-out and no-tools requests keep the legacy strip.
* Fix schema-aware tool healing for PR #6801
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fix passthrough healing ordering for PR #6801
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fix stream finish ordering for PR #6801
* [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: wasimysaid <wasimysdev@gmail.com>
Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com>
1358 lines
53 KiB
Python
1358 lines
53 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
|
|
|
|
"""Unit tests for core/inference/passthrough_healing.py: promoting text-form
|
|
tool calls back into structured calls on the client-tool passthrough. The
|
|
route-level wiring (OpenAI / Anthropic / Responses endpoints) is covered in
|
|
their own endpoint test files; this file exercises the shared state machine
|
|
and helpers directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
from core.inference.passthrough_healing import ( # noqa: E402
|
|
StreamToolCallHealer,
|
|
heal_gate,
|
|
heal_openai_message,
|
|
nudge_messages,
|
|
nudge_should_retry,
|
|
response_has_promotable_calls,
|
|
)
|
|
|
|
TOOLS = [
|
|
{"type": "function", "function": {"name": "Bash", "parameters": {}}},
|
|
{"type": "function", "function": {"name": "Read", "parameters": {}}},
|
|
]
|
|
|
|
BASH_COMMAND_TOOL = {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "Bash",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"command": {"type": "string"}},
|
|
"required": ["command"],
|
|
},
|
|
},
|
|
}
|
|
XML_BASH = '<tool_call>{"name":"Bash","arguments":{"cmd":"ls"}}</tool_call>'
|
|
XML_UNDECLARED = '<tool_call>{"name":"Nuke","arguments":{}}</tool_call>'
|
|
|
|
|
|
def _events_text(events):
|
|
return "".join(text for kind, text in events if kind == "text")
|
|
|
|
|
|
def _events_calls(events):
|
|
return [call for kind, call in events if kind == "tool_call"]
|
|
|
|
|
|
class TestHealGate:
|
|
def test_returns_declared_names(self):
|
|
assert heal_gate(None, TOOLS) == {"Bash", "Read"}
|
|
assert heal_gate(True, TOOLS) == {"Bash", "Read"}
|
|
|
|
def test_opt_out_and_no_tools(self):
|
|
assert heal_gate(False, TOOLS) is None
|
|
assert heal_gate(None, []) is None
|
|
assert heal_gate(None, None) is None
|
|
|
|
def test_malformed_tool_entries_ignored(self):
|
|
assert heal_gate(None, ["nonsense", {"function": "x"}, {}]) is None
|
|
|
|
def test_tool_choice_none_disables(self):
|
|
assert heal_gate(None, TOOLS, "none") is None
|
|
|
|
def test_tool_choice_forced_function_narrows_allowlist(self):
|
|
forced = {"type": "function", "function": {"name": "Bash"}}
|
|
assert heal_gate(None, TOOLS, forced) == {"Bash"}
|
|
|
|
def test_tool_choice_forced_undeclared_function_disables(self):
|
|
forced = {"type": "function", "function": {"name": "Nuke"}}
|
|
assert heal_gate(None, TOOLS, forced) is None
|
|
|
|
def test_tool_choice_auto_and_required_keep_full_set(self):
|
|
assert heal_gate(None, TOOLS, "auto") == {"Bash", "Read"}
|
|
assert heal_gate(None, TOOLS, "required") == {"Bash", "Read"}
|
|
|
|
def test_tool_choice_unrecognized_dict_keeps_full_set(self):
|
|
assert heal_gate(None, TOOLS, {"type": "function"}) == {"Bash", "Read"}
|
|
|
|
|
|
class TestHealOpenaiMessage:
|
|
def test_promotes_xml_and_strips_content(self):
|
|
msg = {"role": "assistant", "content": XML_BASH}
|
|
assert heal_openai_message(msg, {"Bash"}) is True
|
|
assert msg["content"] is None
|
|
(call,) = msg["tool_calls"]
|
|
assert call["function"]["name"] == "Bash"
|
|
assert json.loads(call["function"]["arguments"]) == {"cmd": "ls"}
|
|
|
|
def test_keeps_surrounding_prose(self):
|
|
msg = {"role": "assistant", "content": f"Let me check.\n{XML_BASH}"}
|
|
assert heal_openai_message(msg, {"Bash"}) is True
|
|
assert msg["content"] == "Let me check."
|
|
|
|
def test_undeclared_name_not_promoted(self):
|
|
msg = {"role": "assistant", "content": XML_UNDECLARED}
|
|
assert heal_openai_message(msg, {"Bash"}) is False
|
|
assert msg["content"] == XML_UNDECLARED
|
|
assert "tool_calls" not in msg
|
|
|
|
def test_structured_calls_untouched(self):
|
|
msg = {"role": "assistant", "content": XML_BASH, "tool_calls": [{"id": "x"}]}
|
|
assert heal_openai_message(msg, {"Bash"}) is False
|
|
assert msg["content"] == XML_BASH
|
|
|
|
def test_prose_only_untouched(self):
|
|
msg = {"role": "assistant", "content": "just an answer"}
|
|
assert heal_openai_message(msg, {"Bash"}) is False
|
|
|
|
def test_bare_string_arguments_use_schema_key(self):
|
|
msg = {
|
|
"role": "assistant",
|
|
"content": '<tool_call>{"name":"Bash","arguments":"echo hi"}</tool_call>',
|
|
}
|
|
assert heal_openai_message(msg, {"Bash"}, [BASH_COMMAND_TOOL]) is True
|
|
args = json.loads(msg["tool_calls"][0]["function"]["arguments"])
|
|
assert args == {"command": "echo hi"}
|
|
|
|
def test_bare_string_arguments_decline_ambiguous_schema(self):
|
|
msg = {
|
|
"role": "assistant",
|
|
"content": '<tool_call>{"name":"Bash","arguments":"echo hi"}</tool_call>',
|
|
}
|
|
assert heal_openai_message(msg, {"Bash"}, TOOLS) is False
|
|
assert "tool_calls" not in msg
|
|
|
|
def test_mixed_declared_and_undeclared_promotes_declared_keeps_undeclared_text(self):
|
|
# Span-exact removal: only the promoted Bash markup is dropped; the
|
|
# undeclared Nuke call's text stays in the content byte-intact.
|
|
content = f"pre {XML_BASH} mid {XML_UNDECLARED} post"
|
|
msg = {"role": "assistant", "content": content}
|
|
assert heal_openai_message(msg, {"Bash"}) is True
|
|
(call,) = msg["tool_calls"]
|
|
assert call["function"]["name"] == "Bash"
|
|
assert XML_UNDECLARED in msg["content"]
|
|
assert "pre" in msg["content"] and "post" in msg["content"]
|
|
assert XML_BASH not in msg["content"]
|
|
|
|
def test_multiple_declared_calls_all_promoted(self):
|
|
content = f"{XML_BASH} and {XML_BASH}"
|
|
msg = {"role": "assistant", "content": content}
|
|
assert heal_openai_message(msg, {"Bash"}) is True
|
|
assert len(msg["tool_calls"]) == 2
|
|
|
|
def test_mixed_formats_promote_in_document_order(self):
|
|
func_read = "<function=Read><parameter=path>a.txt</parameter></function>"
|
|
content = f"{func_read} then {XML_BASH}"
|
|
msg = {"role": "assistant", "content": content}
|
|
assert heal_openai_message(msg, {"Bash", "Read"}) is True
|
|
assert [call["function"]["name"] for call in msg["tool_calls"]] == ["Read", "Bash"]
|
|
assert msg["content"] == "then"
|
|
|
|
def test_unparseable_closed_block_not_deleted(self):
|
|
# A closed <tool_call> block whose body never parses is model output,
|
|
# not a promotable call; it must survive promotion of its neighbor.
|
|
garbage = "<tool_call>not json at all</tool_call>"
|
|
content = f"{XML_BASH} {garbage}"
|
|
msg = {"role": "assistant", "content": content}
|
|
assert heal_openai_message(msg, {"Bash"}) is True
|
|
assert garbage in msg["content"]
|
|
|
|
|
|
class TestStreamHealer:
|
|
def test_plain_text_passes_through(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed("hello ") + healer.feed("world") + healer.finalize()
|
|
assert _events_text(events) == "hello world"
|
|
assert not _events_calls(events)
|
|
|
|
def test_complete_call_in_one_chunk(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed(f"On it. {XML_BASH}") + healer.finalize()
|
|
assert _events_text(events) == "On it. "
|
|
(call,) = _events_calls(events)
|
|
assert call["function"]["name"] == "Bash"
|
|
assert healer.healed
|
|
|
|
def test_signal_split_across_chunks(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = []
|
|
for piece in ["<tool", '_call>{"name":"Bash",', '"arguments":{}}</tool_call>']:
|
|
events += healer.feed(piece)
|
|
events += healer.finalize()
|
|
assert _events_text(events) == ""
|
|
assert len(_events_calls(events)) == 1
|
|
|
|
def test_closed_malformed_tool_block_flushes_immediately(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed("<tool_call>not json</tool_call> after")
|
|
assert _events_text(events) == "<tool_call>not json</tool_call> after"
|
|
assert not _events_calls(events)
|
|
|
|
def test_mixed_formats_stream_in_document_order(self):
|
|
healer = StreamToolCallHealer({"Bash", "Read"})
|
|
func_read = "<function=Read><parameter=path>a.txt</parameter></function>"
|
|
events = healer.feed(f"{func_read} then {XML_BASH}") + healer.finalize()
|
|
assert [call["function"]["name"] for call in _events_calls(events)] == ["Read", "Bash"]
|
|
assert _events_text(events).strip() == "then"
|
|
|
|
def test_false_alarm_html_flushes(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed("use the <div> tag") + healer.finalize()
|
|
assert _events_text(events) == "use the <div> tag"
|
|
assert not _events_calls(events)
|
|
|
|
def test_partial_signal_tail_held_then_flushed_at_end(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed("trailing <tool")
|
|
assert _events_text(events) == "trailing " # tail held back
|
|
events += healer.finalize()
|
|
assert _events_text(events) == "trailing <tool"
|
|
|
|
def test_mixed_calls_promote_declared_flush_undeclared_in_order(self):
|
|
# Declared + undeclared in the same buffer: the declared call is
|
|
# promoted, the undeclared markup flushes as text, and event order
|
|
# follows document order (call first here, since it came first).
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
text = f"{XML_BASH} then {XML_UNDECLARED} post"
|
|
events = healer.feed(text) + healer.finalize()
|
|
assert [k for k, _ in events if k == "tool_call"] == ["tool_call"]
|
|
assert events[0][0] == "tool_call"
|
|
joined = _events_text(events)
|
|
assert XML_UNDECLARED in joined
|
|
assert "then" in joined and "post" in joined
|
|
|
|
def test_text_between_two_healed_calls_keeps_document_order(self):
|
|
# call A, " middle ", call B in ONE buffer must stream as
|
|
# call A -> text -> call B, never both calls then the text.
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed(f"{XML_BASH} middle {XML_BASH}") + healer.finalize()
|
|
kinds = [k for k, _ in events]
|
|
assert kinds == ["tool_call", "text", "tool_call"]
|
|
assert events[1][1] == " middle "
|
|
|
|
def test_undeclared_then_declared_keeps_document_order(self):
|
|
# The undeclared block precedes the declared call; its raw text must
|
|
# be emitted BEFORE the promoted call event, never after.
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed(f"{XML_UNDECLARED} then {XML_BASH}") + healer.finalize()
|
|
kinds = [k for k, _ in events]
|
|
assert kinds.index("tool_call") == len(kinds) - 1
|
|
(call,) = _events_calls(events)
|
|
assert call["function"]["name"] == "Bash"
|
|
assert XML_UNDECLARED in _events_text(events)
|
|
|
|
def test_declared_promoted_then_late_undeclared_flushes_raw(self):
|
|
# Streaming causality: the declared call completed and was already
|
|
# emitted before the undeclared one arrived. The undeclared markup
|
|
# must still reach the client as raw text (no data loss).
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed(f"{XML_BASH} then ")
|
|
assert len(_events_calls(events)) == 1
|
|
events += healer.feed(XML_UNDECLARED) + healer.finalize()
|
|
assert XML_UNDECLARED in _events_text(events)
|
|
assert len(_events_calls(events)) == 1
|
|
|
|
def test_undeclared_tool_flushes_raw(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed(XML_UNDECLARED) + healer.finalize()
|
|
assert _events_text(events) == XML_UNDECLARED
|
|
assert not _events_calls(events)
|
|
|
|
def test_two_calls_and_text_between(self):
|
|
healer = StreamToolCallHealer({"Bash", "Read"})
|
|
xml_read = '<tool_call>{"name":"Read","arguments":{"path":"f"}}</tool_call>'
|
|
events = healer.feed(f"{XML_BASH} then {xml_read}") + healer.finalize()
|
|
calls = _events_calls(events)
|
|
assert [c["function"]["name"] for c in calls] == ["Bash", "Read"]
|
|
assert [c["id"] for c in calls] == ["call_0", "call_1"]
|
|
assert _events_text(events).strip() == "then"
|
|
|
|
def test_incomplete_call_healed_at_finalize(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed('<tool_call>{"name":"Bash","arguments":{"cmd":"ls"}}')
|
|
assert events == [] # held
|
|
events = healer.finalize()
|
|
(call,) = _events_calls(events)
|
|
assert call["function"]["name"] == "Bash"
|
|
|
|
def test_teaching_text_flushes_at_finalize(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
events = healer.feed("<tool_call> is the marker syntax") + healer.finalize()
|
|
assert _events_text(events) == "<tool_call> is the marker syntax"
|
|
assert not _events_calls(events)
|
|
|
|
def test_hold_bound_flushes(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
blob = "<tool_call>" + "x" * (64 * 1024 + 10)
|
|
events = healer.feed(blob) + healer.finalize()
|
|
assert _events_text(events) == blob
|
|
assert not _events_calls(events)
|
|
|
|
def test_dormant_after_structured_delta(self):
|
|
healer = StreamToolCallHealer({"Bash"})
|
|
held = healer.feed("prefix <tool")
|
|
flush = healer.structured_tool_call_seen()
|
|
after = healer.feed(XML_BASH) + healer.finalize()
|
|
assert _events_text(held + flush + after) == f"prefix <tool{XML_BASH}"
|
|
assert not _events_calls(after)
|
|
|
|
|
|
class TestNudgeHelpers:
|
|
def _resp(
|
|
self,
|
|
content,
|
|
tool_calls = None,
|
|
):
|
|
msg = {"role": "assistant", "content": content}
|
|
if tool_calls:
|
|
msg["tool_calls"] = tool_calls
|
|
return {"choices": [{"message": msg, "finish_reason": "stop"}]}
|
|
|
|
def test_retry_on_unparseable_signal(self):
|
|
# Signal present but the JSON never parses and no declared name matches.
|
|
data = self._resp("<tool_call>call Bash somehow???")
|
|
assert nudge_should_retry(data, {"Read"}) is True
|
|
|
|
def test_no_retry_on_clean_prose(self):
|
|
assert nudge_should_retry(self._resp("all done"), {"Bash"}) is False
|
|
|
|
def test_no_retry_when_heal_would_succeed(self):
|
|
assert nudge_should_retry(self._resp(XML_BASH), {"Bash"}) is False
|
|
|
|
def test_no_retry_with_structured_calls(self):
|
|
data = self._resp("", tool_calls = [{"id": "x"}])
|
|
assert nudge_should_retry(data, {"Bash"}) is False
|
|
|
|
def test_no_retry_when_healing_disabled(self):
|
|
assert nudge_should_retry(self._resp("<tool_call>???"), None) is False
|
|
|
|
def test_nudge_messages_shape(self):
|
|
data = self._resp("<tool_call>garbage")
|
|
suffix = nudge_messages(data, {"Bash", "Read"})
|
|
assert [m["role"] for m in suffix] == ["assistant", "user"]
|
|
assert suffix[0]["content"] == "<tool_call>garbage"
|
|
assert "`Bash` or `Read`" in suffix[1]["content"]
|
|
|
|
def test_retry_with_undeclared_structured_call_is_not_an_improvement(self):
|
|
# The retry replaces the original only when it carries a USABLE call:
|
|
# a structured call naming an undeclared tool must not count.
|
|
undeclared = [
|
|
{"id": "x", "type": "function", "function": {"name": "Nuke", "arguments": "{}"}}
|
|
]
|
|
declared = [
|
|
{"id": "y", "type": "function", "function": {"name": "Bash", "arguments": "{}"}}
|
|
]
|
|
assert response_has_promotable_calls(self._resp("", undeclared), {"Bash"}) is False
|
|
assert response_has_promotable_calls(self._resp("", declared), {"Bash"}) is True
|
|
|
|
def test_retry_with_mixed_structured_calls_is_not_an_improvement(self):
|
|
# ALL structured calls must be declared: the caller forwards the whole
|
|
# list (and a parallel cap could keep only the FIRST), so a mixed retry
|
|
# could still hand the client an undeclared tool.
|
|
mixed = [
|
|
{"id": "x", "type": "function", "function": {"name": "Nuke", "arguments": "{}"}},
|
|
{"id": "y", "type": "function", "function": {"name": "Bash", "arguments": "{}"}},
|
|
]
|
|
assert response_has_promotable_calls(self._resp("", mixed), {"Bash"}) is False
|
|
assert (
|
|
response_has_promotable_calls(self._resp("", list(reversed(mixed))), {"Bash"}) is False
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"data",
|
|
[
|
|
None,
|
|
"not a dict",
|
|
{},
|
|
{"choices": []},
|
|
{"choices": [{}]},
|
|
{"choices": [{"message": None}]}, # llama-server error bodies do this
|
|
{"choices": [{"message": "not a dict"}]},
|
|
{"choices": [{"message": {"content": None}}]},
|
|
{"error": {"message": "boom"}},
|
|
],
|
|
)
|
|
def test_malformed_response_shapes_never_raise(self, data):
|
|
# A malformed upstream body must degrade to "nothing to heal/nudge",
|
|
# never crash the request with an AttributeError.
|
|
assert nudge_should_retry(data, {"Bash"}) is False
|
|
assert response_has_promotable_calls(data, {"Bash"}) is False
|
|
suffix = nudge_messages(data, {"Bash"})
|
|
assert suffix[0] == {"role": "assistant", "content": ""}
|
|
|
|
|
|
# ── Route-level wiring (OpenAI passthrough) ─────────────────────────────
|
|
# Mirrors the fake-llama-server patterns in test_openai_tool_passthrough.py.
|
|
|
|
import asyncio # noqa: E402
|
|
import threading # noqa: E402
|
|
from types import SimpleNamespace # noqa: E402
|
|
|
|
import httpx # noqa: E402
|
|
|
|
from core.inference.api_monitor import ApiMonitor # noqa: E402
|
|
from models.inference import ChatCompletionRequest, ChatMessage # noqa: E402
|
|
from routes.inference import ( # noqa: E402
|
|
_openai_passthrough_non_streaming,
|
|
_openai_passthrough_stream,
|
|
)
|
|
|
|
LOOKUP_TOOL = {
|
|
"type": "function",
|
|
"function": {"name": "lookup", "parameters": {"type": "object", "properties": {}}},
|
|
}
|
|
LOOKUP_XML = '<tool_call>{"name":"lookup","arguments":{"q":"x"}}</tool_call>'
|
|
|
|
|
|
def _payload(**kwargs):
|
|
defaults = dict(
|
|
model = "default",
|
|
messages = [ChatMessage(role = "user", content = "hi")],
|
|
tools = [LOOKUP_TOOL],
|
|
)
|
|
defaults.update(kwargs)
|
|
return ChatCompletionRequest(**defaults)
|
|
|
|
|
|
def _llama_backend():
|
|
return SimpleNamespace(
|
|
base_url = "http://llama.test",
|
|
context_length = 4096,
|
|
_request_reasoning_kwargs = lambda *_args, **_kwargs: None,
|
|
)
|
|
|
|
|
|
def _upstream_message(
|
|
content,
|
|
tool_calls = None,
|
|
finish_reason = "stop",
|
|
):
|
|
message = {"role": "assistant", "content": content}
|
|
if tool_calls is not None:
|
|
message["tool_calls"] = tool_calls
|
|
return {
|
|
"id": "chatcmpl-up",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gguf",
|
|
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
|
|
}
|
|
|
|
|
|
class ScriptedClient:
|
|
"""Fake nonstreaming_client() returning scripted JSON bodies, counting POSTs."""
|
|
|
|
def __init__(self, bodies):
|
|
self.bodies = list(bodies)
|
|
self.posts = []
|
|
|
|
async def post(
|
|
self,
|
|
_url,
|
|
json = None,
|
|
timeout = None,
|
|
):
|
|
self.posts.append(json)
|
|
return httpx.Response(200, json = self.bodies[min(len(self.posts) - 1, len(self.bodies) - 1)])
|
|
|
|
|
|
async def _drive_non_streaming(monkeypatch, payload, bodies):
|
|
import routes.inference as inf_mod
|
|
|
|
client = ScriptedClient(bodies)
|
|
monkeypatch.setattr(inf_mod, "nonstreaming_client", lambda: client)
|
|
response = await _openai_passthrough_non_streaming(
|
|
_llama_backend(), payload, "gguf", monitor_id = None
|
|
)
|
|
return client, json.loads(response.body)
|
|
|
|
|
|
async def _drive_stream(monkeypatch, payload, lines):
|
|
import routes.inference as inf_mod
|
|
|
|
class Request:
|
|
async def is_disconnected(self):
|
|
return False
|
|
|
|
async def fake_send(*_args, **_kwargs):
|
|
return httpx.Response(200, content = b"")
|
|
|
|
async def fake_items(*_args, **_kwargs):
|
|
for line in lines:
|
|
yield line
|
|
|
|
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
|
|
monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
|
|
monkeypatch.setattr(inf_mod, "api_monitor", ApiMonitor(max_entries = 3))
|
|
response = await _openai_passthrough_stream(
|
|
Request(),
|
|
threading.Event(),
|
|
_llama_backend(),
|
|
payload,
|
|
"gguf",
|
|
"chatcmpl-test",
|
|
monitor_id = None,
|
|
)
|
|
return [chunk async for chunk in response.body_iterator]
|
|
|
|
|
|
def _stream_payloads(chunks):
|
|
out = []
|
|
for chunk in chunks:
|
|
for line in chunk.splitlines():
|
|
if line.startswith("data: ") and line[6:] != "[DONE]":
|
|
out.append(json.loads(line[6:]))
|
|
return out
|
|
|
|
|
|
class TestOpenaiNonStreamingRoute:
|
|
def test_heals_xml_to_tool_calls(self, monkeypatch):
|
|
async def _run():
|
|
client, data = await _drive_non_streaming(
|
|
monkeypatch, _payload(), [_upstream_message(LOOKUP_XML)]
|
|
)
|
|
choice = data["choices"][0]
|
|
assert choice["finish_reason"] == "tool_calls"
|
|
(call,) = choice["message"]["tool_calls"]
|
|
assert call["function"]["name"] == "lookup"
|
|
assert json.loads(call["function"]["arguments"]) == {"q": "x"}
|
|
assert choice["message"]["content"] is None
|
|
assert data["usage"]["total_tokens"] == 3 # usage preserved
|
|
assert len(client.posts) == 1 # healing never re-requests
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_bare_string_uses_client_schema_key(self, monkeypatch):
|
|
async def _run():
|
|
content = '<tool_call>{"name":"Bash","arguments":"echo hi"}</tool_call>'
|
|
_, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(tools = [BASH_COMMAND_TOOL]),
|
|
[_upstream_message(content)],
|
|
)
|
|
(call,) = data["choices"][0]["message"]["tool_calls"]
|
|
assert json.loads(call["function"]["arguments"]) == {"command": "echo hi"}
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_opt_out_relays_verbatim(self, monkeypatch):
|
|
async def _run():
|
|
_, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(auto_heal_tool_calls = False),
|
|
[_upstream_message(LOOKUP_XML)],
|
|
)
|
|
choice = data["choices"][0]
|
|
assert choice["message"]["content"] == LOOKUP_XML
|
|
assert "tool_calls" not in choice["message"]
|
|
assert choice["finish_reason"] == "stop"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_no_tools_untouched(self, monkeypatch):
|
|
async def _run():
|
|
_, data = await _drive_non_streaming(
|
|
monkeypatch, _payload(tools = None), [_upstream_message(LOOKUP_XML)]
|
|
)
|
|
assert data["choices"][0]["message"]["content"] == LOOKUP_XML
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_undeclared_tool_not_promoted(self, monkeypatch):
|
|
async def _run():
|
|
xml = '<tool_call>{"name":"rogue","arguments":{}}</tool_call>'
|
|
_, data = await _drive_non_streaming(monkeypatch, _payload(), [_upstream_message(xml)])
|
|
assert data["choices"][0]["message"]["content"] == xml
|
|
assert "tool_calls" not in data["choices"][0]["message"]
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_structured_calls_untouched(self, monkeypatch):
|
|
async def _run():
|
|
native = [
|
|
{
|
|
"id": "call_up",
|
|
"type": "function",
|
|
"function": {"name": "lookup", "arguments": "{}"},
|
|
}
|
|
]
|
|
_, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(),
|
|
[_upstream_message("", tool_calls = native, finish_reason = "tool_calls")],
|
|
)
|
|
assert data["choices"][0]["message"]["tool_calls"] == native
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_length_finish_reason_preserved(self, monkeypatch):
|
|
async def _run():
|
|
# Truncated generation: the healed call stays attached but the
|
|
# client must still see the truncation, so length is never
|
|
# upgraded to tool_calls.
|
|
_, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(),
|
|
[_upstream_message(LOOKUP_XML, finish_reason = "length")],
|
|
)
|
|
choice = data["choices"][0]
|
|
assert choice["finish_reason"] == "length"
|
|
(call,) = choice["message"]["tool_calls"]
|
|
assert call["function"]["name"] == "lookup"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_tool_choice_none_relays_verbatim(self, monkeypatch):
|
|
async def _run():
|
|
_, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(tool_choice = "none"),
|
|
[_upstream_message(LOOKUP_XML)],
|
|
)
|
|
message = data["choices"][0]["message"]
|
|
assert message["content"] == LOOKUP_XML
|
|
assert "tool_calls" not in message
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_tool_choice_forcing_other_function_not_promoted(self, monkeypatch):
|
|
async def _run():
|
|
_, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(tool_choice = {"type": "function", "function": {"name": "other"}}),
|
|
[_upstream_message(LOOKUP_XML)],
|
|
)
|
|
message = data["choices"][0]["message"]
|
|
assert message["content"] == LOOKUP_XML
|
|
assert "tool_calls" not in message
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_mixed_declared_and_undeclared_promotes_and_keeps_text(self, monkeypatch):
|
|
async def _run():
|
|
rogue = '<tool_call>{"name":"rogue","arguments":{}}</tool_call>'
|
|
mixed = f"{LOOKUP_XML} also {rogue}"
|
|
_, data = await _drive_non_streaming(
|
|
monkeypatch, _payload(), [_upstream_message(mixed)]
|
|
)
|
|
choice = data["choices"][0]
|
|
(call,) = choice["message"]["tool_calls"]
|
|
assert call["function"]["name"] == "lookup"
|
|
assert rogue in choice["message"]["content"]
|
|
assert choice["finish_reason"] == "tool_calls"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_healed_then_native_stream_indexes_disjoint(self, monkeypatch):
|
|
async def _run():
|
|
# A healed text-form call goes out first (index 0); a native
|
|
# structured delta follows. Clients merge deltas by index, so the
|
|
# native call must be shifted off index 0 or the two would merge.
|
|
native_line = (
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":'
|
|
'[{"index":0,"id":"call_native","type":"function","function":'
|
|
'{"name":"lookup","arguments":"{}"}}]}}]}'
|
|
)
|
|
lines = [
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{"content":'
|
|
+ json.dumps(LOOKUP_XML)
|
|
+ "}}]}",
|
|
native_line,
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
|
|
"data: [DONE]",
|
|
]
|
|
chunks = await _drive_stream(monkeypatch, _payload(stream = True), lines)
|
|
indexes = {}
|
|
for payload_data in _stream_payloads(chunks):
|
|
for ch in payload_data.get("choices", []):
|
|
for tc in (ch.get("delta") or {}).get("tool_calls") or []:
|
|
indexes.setdefault(tc["index"], tc.get("id"))
|
|
assert indexes.get(0, "").startswith("call_") and indexes[0] != "call_native"
|
|
assert indexes.get(1) == "call_native"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_role_delta_precedes_healed_stream_content(self, monkeypatch):
|
|
async def _run():
|
|
lines = [
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","content":'
|
|
+ json.dumps(LOOKUP_XML)
|
|
+ "}}]}",
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
"data: [DONE]",
|
|
]
|
|
chunks = await _drive_stream(monkeypatch, _payload(stream = True), lines)
|
|
payloads = _stream_payloads(chunks)
|
|
first_delta = payloads[0]["choices"][0]["delta"]
|
|
assert first_delta == {"role": "assistant"}
|
|
assert "tool_calls" in payloads[1]["choices"][0]["delta"]
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_same_chunk_role_content_finish_delays_finish_until_after_healed_tool(
|
|
self, monkeypatch
|
|
):
|
|
async def _run():
|
|
lines = [
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","content":'
|
|
+ json.dumps(LOOKUP_XML)
|
|
+ '},"finish_reason":"stop"}]}',
|
|
"data: [DONE]",
|
|
]
|
|
chunks = await _drive_stream(monkeypatch, _payload(stream = True), lines)
|
|
payloads = _stream_payloads(chunks)
|
|
assert payloads[0]["choices"][0]["finish_reason"] is None
|
|
assert payloads[0]["choices"][0]["delta"] == {"role": "assistant"}
|
|
assert "tool_calls" in payloads[1]["choices"][0]["delta"]
|
|
assert payloads[-1]["choices"][0]["finish_reason"] == "tool_calls"
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
GARBAGE_SIGNAL = "<tool_call>call lookup somehow???"
|
|
|
|
|
|
class TestNudgeRetryOpenai:
|
|
def test_retry_recovers_call(self, monkeypatch):
|
|
async def _run():
|
|
client, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(nudge_tool_calls = True),
|
|
[_upstream_message(GARBAGE_SIGNAL), _upstream_message(LOOKUP_XML)],
|
|
)
|
|
assert len(client.posts) == 2 # exactly one retry
|
|
# Prefix byte-identical, nudge suffix appended (KV-cache reuse guard).
|
|
original, retry = client.posts
|
|
assert retry["messages"][: len(original["messages"])] == original["messages"]
|
|
suffix = retry["messages"][len(original["messages"]) :]
|
|
assert [m["role"] for m in suffix] == ["assistant", "user"]
|
|
assert suffix[0]["content"] == GARBAGE_SIGNAL
|
|
# The healed retry response is returned.
|
|
(call,) = data["choices"][0]["message"]["tool_calls"]
|
|
assert call["function"]["name"] == "lookup"
|
|
assert data["choices"][0]["finish_reason"] == "tool_calls"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_retry_still_garbage_returns_original(self, monkeypatch):
|
|
async def _run():
|
|
client, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(nudge_tool_calls = True),
|
|
[_upstream_message(GARBAGE_SIGNAL), _upstream_message(GARBAGE_SIGNAL + "2")],
|
|
)
|
|
assert len(client.posts) == 2
|
|
assert data["choices"][0]["message"]["content"] == GARBAGE_SIGNAL
|
|
assert "tool_calls" not in data["choices"][0]["message"]
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_default_off_single_post(self, monkeypatch):
|
|
async def _run():
|
|
client, _ = await _drive_non_streaming(
|
|
monkeypatch, _payload(), [_upstream_message(GARBAGE_SIGNAL)]
|
|
)
|
|
assert len(client.posts) == 1
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_no_retry_on_clean_prose(self, monkeypatch):
|
|
async def _run():
|
|
client, _ = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(nudge_tool_calls = True),
|
|
[_upstream_message("all done")],
|
|
)
|
|
assert len(client.posts) == 1
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_no_retry_when_heal_succeeds(self, monkeypatch):
|
|
async def _run():
|
|
client, data = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(nudge_tool_calls = True),
|
|
[_upstream_message(LOOKUP_XML)],
|
|
)
|
|
assert len(client.posts) == 1
|
|
assert data["choices"][0]["message"]["tool_calls"]
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_heal_opt_out_disables_nudge_too(self, monkeypatch):
|
|
async def _run():
|
|
client, _ = await _drive_non_streaming(
|
|
monkeypatch,
|
|
_payload(auto_heal_tool_calls = False, nudge_tool_calls = True),
|
|
[_upstream_message(GARBAGE_SIGNAL)],
|
|
)
|
|
assert len(client.posts) == 1
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
class TestNudgeRetryAnthropic:
|
|
async def _drive(
|
|
self,
|
|
monkeypatch,
|
|
bodies,
|
|
nudge = None,
|
|
):
|
|
import routes.inference as inf_mod
|
|
from routes.inference import _anthropic_passthrough_non_streaming
|
|
|
|
client = ScriptedClient(bodies)
|
|
monkeypatch.setattr(inf_mod, "nonstreaming_client", lambda: client)
|
|
response = await _anthropic_passthrough_non_streaming(
|
|
_llama_backend(),
|
|
[{"role": "user", "content": "hi"}],
|
|
[LOOKUP_TOOL],
|
|
0.7,
|
|
0.95,
|
|
None,
|
|
256,
|
|
"msg_test",
|
|
"gguf",
|
|
nudge_tool_calls = nudge,
|
|
)
|
|
return client, json.loads(response.body)
|
|
|
|
def test_retry_recovers_tool_use(self, monkeypatch):
|
|
async def _run():
|
|
client, data = await self._drive(
|
|
monkeypatch,
|
|
[_upstream_message(GARBAGE_SIGNAL), _upstream_message(LOOKUP_XML)],
|
|
nudge = True,
|
|
)
|
|
assert len(client.posts) == 2
|
|
(block,) = [b for b in data["content"] if b["type"] == "tool_use"]
|
|
assert block["name"] == "lookup"
|
|
assert data["stop_reason"] == "tool_use"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_healed_tool_use_precedes_trailing_text(self, monkeypatch):
|
|
async def _run():
|
|
_, data = await self._drive(monkeypatch, [_upstream_message(f"{LOOKUP_XML} done")])
|
|
assert [block["type"] for block in data["content"]] == ["tool_use", "text"]
|
|
assert data["content"][1]["text"] == "done"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_default_off(self, monkeypatch):
|
|
async def _run():
|
|
client, _ = await self._drive(monkeypatch, [_upstream_message(GARBAGE_SIGNAL)])
|
|
assert len(client.posts) == 1
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
class TestAnthropicPassthroughHealingText:
|
|
"""Non-streaming Anthropic passthrough must relay unpromoted (undeclared)
|
|
text-form calls as text, matching the OpenAI passthrough contract. Once
|
|
heal_openai_message promotes the declared call it span-trims only that
|
|
markup and deliberately leaves the undeclared bytes in the content; the
|
|
legacy blanket _TOOL_XML_RE strip must not delete them.
|
|
"""
|
|
|
|
async def _drive(self, monkeypatch, upstream):
|
|
import routes.inference as inf_mod
|
|
from routes.inference import _anthropic_passthrough_non_streaming
|
|
|
|
client = ScriptedClient([upstream])
|
|
monkeypatch.setattr(inf_mod, "nonstreaming_client", lambda: client)
|
|
response = await _anthropic_passthrough_non_streaming(
|
|
_llama_backend(),
|
|
[{"role": "user", "content": "hi"}],
|
|
[LOOKUP_TOOL],
|
|
0.7,
|
|
0.95,
|
|
None,
|
|
256,
|
|
"msg_test",
|
|
"gguf",
|
|
)
|
|
return json.loads(response.body)
|
|
|
|
def test_mixed_declared_and_undeclared_relays_undeclared_as_text(self, monkeypatch):
|
|
async def _run():
|
|
content = f"Running now. {LOOKUP_XML} then {XML_UNDECLARED} done."
|
|
data = await self._drive(monkeypatch, _upstream_message(content))
|
|
# Declared lookup call is promoted into a structured tool_use block.
|
|
(tool_use,) = [b for b in data["content"] if b["type"] == "tool_use"]
|
|
assert tool_use["name"] == "lookup"
|
|
text = " ".join(b["text"] for b in data["content"] if b["type"] == "text")
|
|
assert XML_UNDECLARED in text
|
|
assert "Running now." in text and "done." in text
|
|
assert LOOKUP_XML not in text
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
class TestAnthropicEmitterHealing:
|
|
def _events(
|
|
self,
|
|
emitter,
|
|
chunks,
|
|
finish = True,
|
|
):
|
|
lines = []
|
|
for chunk in chunks:
|
|
lines += emitter.feed_chunk(chunk)
|
|
if finish:
|
|
lines += emitter.finish()
|
|
return [json.loads(ln.split("data: ", 1)[1]) for ln in lines if "data: " in ln]
|
|
|
|
def _emitter(
|
|
self,
|
|
allowed = ("lookup",),
|
|
**kwargs,
|
|
):
|
|
from core.inference.anthropic_compat import AnthropicPassthroughEmitter
|
|
|
|
emitter = AnthropicPassthroughEmitter()
|
|
emitter.enable_healing(set(allowed), **kwargs)
|
|
return emitter
|
|
|
|
def _chunk(
|
|
self,
|
|
content = None,
|
|
tool_calls = None,
|
|
finish_reason = None,
|
|
):
|
|
delta = {}
|
|
if content is not None:
|
|
delta["content"] = content
|
|
if tool_calls is not None:
|
|
delta["tool_calls"] = tool_calls
|
|
return {"choices": [{"delta": delta, "finish_reason": finish_reason}]}
|
|
|
|
def test_xml_becomes_tool_use_block_and_stop_reason(self):
|
|
events = self._events(
|
|
self._emitter(),
|
|
[
|
|
self._chunk(content = LOOKUP_XML),
|
|
self._chunk(finish_reason = "stop"),
|
|
],
|
|
)
|
|
starts = [e for e in events if e.get("type") == "content_block_start"]
|
|
(tool_start,) = [e for e in starts if e["content_block"]["type"] == "tool_use"]
|
|
assert tool_start["content_block"]["name"] == "lookup"
|
|
assert tool_start["content_block"]["id"].startswith("toolu_")
|
|
(args,) = [
|
|
e["delta"]["partial_json"]
|
|
for e in events
|
|
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "input_json_delta"
|
|
]
|
|
assert json.loads(args) == {"q": "x"}
|
|
(message_delta,) = [e for e in events if e.get("type") == "message_delta"]
|
|
assert message_delta["delta"]["stop_reason"] == "tool_use"
|
|
|
|
def test_mid_block_signal_closes_text_block_first(self):
|
|
events = self._events(
|
|
self._emitter(),
|
|
[
|
|
self._chunk(content = f"Let me check {LOOKUP_XML}"),
|
|
self._chunk(finish_reason = "stop"),
|
|
],
|
|
)
|
|
kinds = [
|
|
(e["type"], (e.get("content_block") or e.get("delta") or {}).get("type"))
|
|
for e in events
|
|
if e["type"].startswith("content_block")
|
|
]
|
|
# text opens, streams the safe prefix, closes; then the tool_use block.
|
|
assert kinds[0] == ("content_block_start", "text")
|
|
assert kinds[1] == ("content_block_delta", "text_delta")
|
|
assert kinds[2] == ("content_block_stop", None)
|
|
assert kinds[3] == ("content_block_start", "tool_use")
|
|
texts = [
|
|
e["delta"]["text"]
|
|
for e in events
|
|
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
|
|
]
|
|
assert "".join(texts) == "Let me check "
|
|
|
|
def test_false_alarm_streams_as_text(self):
|
|
events = self._events(
|
|
self._emitter(),
|
|
[self._chunk(content = "use the <div> tag"), self._chunk(finish_reason = "stop")],
|
|
)
|
|
texts = [
|
|
e["delta"]["text"]
|
|
for e in events
|
|
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
|
|
]
|
|
assert "".join(texts) == "use the <div> tag"
|
|
(message_delta,) = [e for e in events if e.get("type") == "message_delta"]
|
|
assert message_delta["delta"]["stop_reason"] == "end_turn"
|
|
|
|
def test_signal_split_across_chunks(self):
|
|
events = self._events(
|
|
self._emitter(),
|
|
[
|
|
self._chunk(content = "<tool"),
|
|
self._chunk(content = '_call>{"name":"lookup","arguments":{}}'),
|
|
self._chunk(finish_reason = "stop"),
|
|
],
|
|
)
|
|
starts = [e for e in events if e.get("type") == "content_block_start"]
|
|
assert [e["content_block"]["type"] for e in starts] == ["tool_use"]
|
|
texts = [
|
|
e
|
|
for e in events
|
|
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
|
|
]
|
|
assert texts == []
|
|
|
|
def test_max_tokens_wins_over_healed_stop_reason(self):
|
|
events = self._events(
|
|
self._emitter(),
|
|
[self._chunk(content = LOOKUP_XML), self._chunk(finish_reason = "length")],
|
|
)
|
|
(message_delta,) = [e for e in events if e.get("type") == "message_delta"]
|
|
assert message_delta["delta"]["stop_reason"] == "max_tokens"
|
|
|
|
def test_structured_deltas_disable_healing_and_flush(self):
|
|
structured = [
|
|
{
|
|
"index": 0,
|
|
"id": "call_up",
|
|
"function": {"name": "lookup", "arguments": "{}"},
|
|
}
|
|
]
|
|
events = self._events(
|
|
self._emitter(),
|
|
[
|
|
self._chunk(content = "held <tool"),
|
|
self._chunk(tool_calls = structured),
|
|
self._chunk(finish_reason = "tool_calls"),
|
|
],
|
|
)
|
|
texts = [
|
|
e["delta"]["text"]
|
|
for e in events
|
|
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
|
|
]
|
|
assert "".join(texts) == "held <tool" # nothing swallowed
|
|
starts = [e for e in events if e.get("type") == "content_block_start"]
|
|
assert [e["content_block"]["type"] for e in starts] == ["text", "tool_use"]
|
|
|
|
def test_disable_parallel_caps_healed_calls(self):
|
|
two = LOOKUP_XML + '<tool_call>{"name":"lookup","arguments":{"q":"y"}}</tool_call>'
|
|
events = self._events(
|
|
self._emitter(disable_parallel_tool_use = True),
|
|
[self._chunk(content = two), self._chunk(finish_reason = "stop")],
|
|
)
|
|
starts = [
|
|
e
|
|
for e in events
|
|
if e.get("type") == "content_block_start" and e["content_block"]["type"] == "tool_use"
|
|
]
|
|
assert len(starts) == 1
|
|
|
|
def test_disable_parallel_drops_native_after_healed(self):
|
|
# A healed call consumed the single allowed slot; a later native
|
|
# structured call (index 0, so it survives the caller's chunk-level
|
|
# cap) must not open a second tool_use block.
|
|
structured = [
|
|
{
|
|
"index": 0,
|
|
"id": "call_up",
|
|
"function": {"name": "lookup", "arguments": "{}"},
|
|
}
|
|
]
|
|
events = self._events(
|
|
self._emitter(disable_parallel_tool_use = True),
|
|
[
|
|
self._chunk(content = LOOKUP_XML),
|
|
self._chunk(tool_calls = structured),
|
|
self._chunk(finish_reason = "tool_calls"),
|
|
],
|
|
)
|
|
starts = [
|
|
e
|
|
for e in events
|
|
if e.get("type") == "content_block_start" and e["content_block"]["type"] == "tool_use"
|
|
]
|
|
assert len(starts) == 1
|
|
|
|
def test_no_healing_means_verbatim_text(self):
|
|
from core.inference.anthropic_compat import AnthropicPassthroughEmitter
|
|
|
|
emitter = AnthropicPassthroughEmitter() # enable_healing never called
|
|
events = self._events(
|
|
emitter,
|
|
[self._chunk(content = LOOKUP_XML), self._chunk(finish_reason = "stop")],
|
|
)
|
|
texts = [
|
|
e["delta"]["text"]
|
|
for e in events
|
|
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
|
|
]
|
|
assert "".join(texts) == LOOKUP_XML
|
|
|
|
|
|
class TestAnthropicNonStreamingRoute:
|
|
async def _drive(
|
|
self,
|
|
monkeypatch,
|
|
bodies,
|
|
auto_heal = None,
|
|
tools = None,
|
|
tool_choice = "auto",
|
|
):
|
|
import routes.inference as inf_mod
|
|
from routes.inference import _anthropic_passthrough_non_streaming
|
|
|
|
client = ScriptedClient(bodies)
|
|
monkeypatch.setattr(inf_mod, "nonstreaming_client", lambda: client)
|
|
response = await _anthropic_passthrough_non_streaming(
|
|
_llama_backend(),
|
|
[{"role": "user", "content": "hi"}],
|
|
tools if tools is not None else [LOOKUP_TOOL],
|
|
0.7,
|
|
0.95,
|
|
None,
|
|
256,
|
|
"msg_test",
|
|
"gguf",
|
|
tool_choice = tool_choice,
|
|
auto_heal_tool_calls = auto_heal,
|
|
)
|
|
return client, json.loads(response.body)
|
|
|
|
def test_promotes_xml_to_tool_use(self, monkeypatch):
|
|
async def _run():
|
|
_, data = await self._drive(monkeypatch, [_upstream_message(LOOKUP_XML)])
|
|
(block,) = [b for b in data["content"] if b["type"] == "tool_use"]
|
|
assert block["name"] == "lookup"
|
|
assert block["input"] == {"q": "x"}
|
|
assert data["stop_reason"] == "tool_use"
|
|
assert not any(b["type"] == "text" for b in data["content"])
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_opt_out_keeps_legacy_strip(self, monkeypatch):
|
|
async def _run():
|
|
_, data = await self._drive(
|
|
monkeypatch, [_upstream_message(f"plan {LOOKUP_XML}")], auto_heal = False
|
|
)
|
|
assert data["stop_reason"] == "end_turn"
|
|
(block,) = data["content"]
|
|
assert block["type"] == "text"
|
|
assert block["text"] == "plan" # XML stripped, nothing promoted
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_undeclared_tool_not_promoted(self, monkeypatch):
|
|
async def _run():
|
|
xml = '<tool_call>{"name":"rogue","arguments":{}}</tool_call>'
|
|
_, data = await self._drive(monkeypatch, [_upstream_message(xml)])
|
|
assert data["stop_reason"] == "end_turn"
|
|
assert not any(b["type"] == "tool_use" for b in data["content"])
|
|
# Healing preserves what it does not promote: the undeclared call
|
|
# reaches the client as text instead of being silently stripped.
|
|
(text_block,) = [b for b in data["content"] if b["type"] == "text"]
|
|
assert text_block["text"] == xml
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_mixed_undeclared_text_preserved_after_heal(self, monkeypatch):
|
|
async def _run():
|
|
# Declared call promoted to tool_use; the undeclared call's markup
|
|
# stays in the text block (the legacy strip must not run after a
|
|
# span-exact heal), matching the OpenAI passthrough.
|
|
rogue = '<tool_call>{"name":"rogue","arguments":{}}</tool_call>'
|
|
_, data = await self._drive(monkeypatch, [_upstream_message(f"{LOOKUP_XML} {rogue}")])
|
|
(tool_block,) = [b for b in data["content"] if b["type"] == "tool_use"]
|
|
assert tool_block["name"] == "lookup"
|
|
(text_block,) = [b for b in data["content"] if b["type"] == "text"]
|
|
assert rogue in text_block["text"]
|
|
assert data["stop_reason"] == "tool_use"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_length_beats_tool_use(self, monkeypatch):
|
|
async def _run():
|
|
_, data = await self._drive(
|
|
monkeypatch, [_upstream_message(LOOKUP_XML, finish_reason = "length")]
|
|
)
|
|
assert data["stop_reason"] == "max_tokens"
|
|
assert any(b["type"] == "tool_use" for b in data["content"])
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_tool_choice_none_keeps_legacy_strip(self, monkeypatch):
|
|
async def _run():
|
|
# Anthropic {"type": "none"} arrives here converted to "none":
|
|
# the request forbade tool calls, so nothing is promoted and the
|
|
# legacy XML strip applies as before healing existed.
|
|
_, data = await self._drive(
|
|
monkeypatch,
|
|
[_upstream_message(f"plan {LOOKUP_XML}")],
|
|
tool_choice = "none",
|
|
)
|
|
assert data["stop_reason"] == "end_turn"
|
|
(block,) = data["content"]
|
|
assert block["type"] == "text"
|
|
assert block["text"] == "plan"
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
class TestOpenaiStreamingRoute:
|
|
def test_heals_streamed_xml(self, monkeypatch):
|
|
async def _run():
|
|
pieces = ["<tool_call>", '{"name":"lookup",', '"arguments":{"q":"x"}}', "</tool_call>"]
|
|
lines = [
|
|
'data: {"id":"c1","model":"gguf","created":1,"choices":[{"index":0,"delta":{"content":%s}}]}'
|
|
% json.dumps(p)
|
|
for p in pieces
|
|
]
|
|
lines += [
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
"data: [DONE]",
|
|
]
|
|
chunks = await _drive_stream(monkeypatch, _payload(), lines)
|
|
payloads = _stream_payloads(chunks)
|
|
tool_deltas = [
|
|
tc
|
|
for p in payloads
|
|
for c in p.get("choices", [])
|
|
for tc in (c.get("delta") or {}).get("tool_calls") or []
|
|
]
|
|
(call,) = tool_deltas
|
|
assert call["function"]["name"] == "lookup"
|
|
assert json.loads(call["function"]["arguments"]) == {"q": "x"}
|
|
finishes = [
|
|
c["finish_reason"]
|
|
for p in payloads
|
|
for c in p.get("choices", [])
|
|
if c.get("finish_reason")
|
|
]
|
|
assert finishes == ["tool_calls"]
|
|
# None of the XML leaked as visible content.
|
|
text = "".join(
|
|
(c.get("delta") or {}).get("content") or ""
|
|
for p in payloads
|
|
for c in p.get("choices", [])
|
|
)
|
|
assert "<tool_call>" not in text
|
|
assert chunks[-1] == "data: [DONE]\n\n"
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_parallel_cap_drops_native_after_healed(self, monkeypatch):
|
|
async def _run():
|
|
# parallel_tool_calls=false: a healed call consumed the single
|
|
# allowed slot, and the upstream SSE cap keeps native index 0, so
|
|
# the route must drop the later native call itself.
|
|
xml = '<tool_call>{"name":"lookup","arguments":{"q":"x"}}</tool_call>'
|
|
native = (
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":'
|
|
'[{"index":0,"id":"call_up","type":"function","function":'
|
|
'{"name":"lookup","arguments":"{}"}}]}}]}'
|
|
)
|
|
lines = [
|
|
'data: {"id":"c1","model":"gguf","created":1,"choices":'
|
|
'[{"index":0,"delta":{"content":%s}}]}' % json.dumps(xml),
|
|
native,
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
|
|
"data: [DONE]",
|
|
]
|
|
chunks = await _drive_stream(monkeypatch, _payload(parallel_tool_calls = False), lines)
|
|
payloads = _stream_payloads(chunks)
|
|
tool_deltas = [
|
|
tc
|
|
for p in payloads
|
|
for c in p.get("choices", [])
|
|
for tc in (c.get("delta") or {}).get("tool_calls") or []
|
|
]
|
|
(call,) = tool_deltas
|
|
assert call["id"] == "call_0" # the healed call; native was dropped
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_false_alarm_text_flushes(self, monkeypatch):
|
|
async def _run():
|
|
lines = [
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{"content":"use the <div> tag"}}]}',
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
"data: [DONE]",
|
|
]
|
|
chunks = await _drive_stream(monkeypatch, _payload(), lines)
|
|
payloads = _stream_payloads(chunks)
|
|
text = "".join(
|
|
(c.get("delta") or {}).get("content") or ""
|
|
for p in payloads
|
|
for c in p.get("choices", [])
|
|
)
|
|
assert text == "use the <div> tag"
|
|
finishes = [
|
|
c["finish_reason"]
|
|
for p in payloads
|
|
for c in p.get("choices", [])
|
|
if c.get("finish_reason")
|
|
]
|
|
assert finishes == ["stop"]
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_incomplete_xml_healed_at_done(self, monkeypatch):
|
|
async def _run():
|
|
# No close tag and no finish chunk: healed at the [DONE] boundary,
|
|
# synthetic finish must say tool_calls.
|
|
lines = [
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{"content":"<tool_call>{\\"name\\":\\"lookup\\",\\"arguments\\":{}}"}}]}',
|
|
"data: [DONE]",
|
|
]
|
|
chunks = await _drive_stream(monkeypatch, _payload(), lines)
|
|
payloads = _stream_payloads(chunks)
|
|
tool_deltas = [
|
|
tc
|
|
for p in payloads
|
|
for c in p.get("choices", [])
|
|
for tc in (c.get("delta") or {}).get("tool_calls") or []
|
|
]
|
|
assert len(tool_deltas) == 1
|
|
finishes = [
|
|
c["finish_reason"]
|
|
for p in payloads
|
|
for c in p.get("choices", [])
|
|
if c.get("finish_reason")
|
|
]
|
|
assert finishes == ["tool_calls"]
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_structured_upstream_calls_relay_verbatim(self, monkeypatch):
|
|
async def _run():
|
|
line = (
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":'
|
|
'[{"index":0,"id":"call_up","type":"function","function":'
|
|
'{"name":"lookup","arguments":"{}"}}]}}]}'
|
|
)
|
|
lines = [
|
|
line,
|
|
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
|
|
"data: [DONE]",
|
|
]
|
|
chunks = await _drive_stream(monkeypatch, _payload(), lines)
|
|
assert chunks[0] == line + "\n\n" # byte-for-byte relay
|
|
|
|
asyncio.run(_run())
|