Compare commits

...
Sign in to create a new pull request.

18 commits

Author SHA1 Message Date
Daniel Han
4967f52b9a Guard against late tool_calls after visible content, filter incomplete fragments
1. If visible content was already emitted (_last_emitted is non-empty)
   when delta.tool_calls arrives, ignore the tool_calls instead of
   reclassifying the turn as a tool call. llama-server never
   interleaves content and tool_calls (they are mutually exclusive),
   but this guard is defensive for other OpenAI-compatible backends.
   [9/10 reviewers]

2. Filter out incomplete structured tool_calls fragments before
   execution. Entries with empty function.name (from truncation by
   max_tokens, disconnect, or interruption) are skipped instead of
   being passed to execute_tool(). [2/10 reviewers]
2026-03-27 10:30:37 +00:00
pre-commit-ci[bot]
91982268dc [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-03-27 09:15:57 +00:00
Daniel Han
10555db27b Include accumulated metrics in STREAMING metadata check 2026-03-27 09:14:18 +00:00
Daniel Han
9bef60db23 Use None instead of {} for empty auth headers in TTS methods 2026-03-27 08:59:01 +00:00
Daniel Han
f86d114352 Revert disconnect throttle, reset prev_text on tool_start, restore XML safety net
Addresses all P1 findings from reviewer round 3 (10 reviewers):

1. Revert disconnect check to every iteration (was every 20th).
   All 10 reviewers flagged this as a correctness regression for
   short streams and sparse tool event loops. The cancel watcher in
   llama_cpp.py is the primary mechanism but the route-layer check
   must remain per-iteration for completeness. [10/10]

2. Reset prev_text on tool_start in gguf_tool_stream. When a tool
   cycle begins after visible content was already streamed, the
   route-layer cumulative delta tracker (prev_text) must be reset
   so the post-tool synthesis response is not truncated or dropped.
   [9/10]

3. Remove the _last_emitted gate from the XML safety net. The gate
   was added to prevent retroactive tool execution after visible
   content, but with prev_text now reset on tool_start (#2), the
   root cause is fixed and the safety net can correctly handle
   content-then-tool-XML responses (matching pre-PR behavior).
   [8/10]
2026-03-27 08:55:58 +00:00
Daniel Han
5392d736d2 Remove test file temporarily 2026-03-27 08:28:18 +00:00
Daniel Han
2507d26970 Redact --api-key value from llama-server startup log
When UNSLOTH_DIRECT_STREAM=1, the generated bearer token was logged
verbatim in the startup command. Replace the secret with <redacted>
before logging.
2026-03-27 08:26:25 +00:00
pre-commit-ci[bot]
24f92fac86 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-03-27 08:22:56 +00:00
Daniel Han
32ce2324f0 Fix safety net, DRAINING metadata, and test import path
1. Safety net no longer retroactively executes tools after visible
   content was already emitted to the user. Once _last_emitted is
   non-empty, the stream is committed to normal content mode.
   Retroactive tool execution after visible output would violate the
   streaming contract and corrupt the route-layer cumulative delta
   tracker (prev_text). The tool XML is still stripped by
   _strip_tool_markup so the user sees clean content.

2. DRAINING false-positive path now merges accumulated metrics from
   prior tool iterations instead of dropping them. Uses the same
   merge formula as the STREAMING path.

3. Test import path fixed to use repo root instead of hardcoded
   sibling directory. Works in clean checkouts and CI.

4. Renamed test_content_then_tool_xml_safety_net to
   test_content_then_tool_xml_no_retroactive_execution to reflect
   the corrected behavior.

17/17 tests pass.
2026-03-27 08:22:24 +00:00
Daniel Han
09399ab877 Check request.is_disconnected() every 20 tokens instead of every token
The disconnect check is an async round-trip that adds overhead on every
loop iteration. Since the cancel watcher in llama_cpp.py already
handles connection teardown (closes the streaming response on cancel),
this route-layer check is a secondary safety net that does not need to
run on every single token.

Check every 20 tokens across all 4 streaming paths:
- gguf_tool_stream (tool-enabled GGUF)
- gguf_stream_chunks (standard GGUF)
- audio_input_generate (audio/whisper input)
- generic backend stream (non-GGUF fallback)
2026-03-27 07:19:48 +00:00
pre-commit-ci[bot]
8c75277505 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-03-27 06:46:45 +00:00
Daniel Han
aa9bea12b9 Address remaining reviewer findings: late tool_call IDs and XML speculation
1. Late-arriving tool_calls.id: when a provider sends the real ID on a
   later delta chunk (after the initial one with index and function
   name), the accumulator now updates the ID instead of keeping the
   synthetic "call_{idx}" placeholder. (P2, 2/10 reviewers)

2. XML speculation respects auto_heal_tool_calls: when auto_heal is
   explicitly disabled, _TOOL_XML_SIGNALS is empty so the BUFFERING
   state never speculatively holds content for XML prefix detection.
   Content starting with literal "<tool_call>" or "<function=" text
   flows straight through without delay. (P2, 1/10 reviewers)

Skipped: finish_reason="tool_calls" without delta.tool_calls fallback
(P1, 1/10 reviewers). llama-server always sends delta.tool_calls
fragments in streaming mode. A non-streaming fallback for this edge
case would add complexity for a scenario that does not occur in
practice with the supported backend.
2026-03-27 06:46:33 +00:00
pre-commit-ci[bot]
e75c2013d5 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-03-27 06:45:13 +00:00
Daniel Han
620b152210 Fix reasoning-only BUFFERING, pre-tool content emission, and code duplication
Addresses review feedback on the streaming tool detection:

1. Reasoning tokens are no longer yielded during BUFFERING/DRAINING
   states. The consumer in routes/inference.py tracks prev_text across
   tool iterations without resetting it, so yielding reasoning during
   a detection pass that resolves to a tool call would corrupt the
   delta computation for subsequent iterations. Reasoning is now
   silently accumulated during detection (matching the old non-streaming
   behavior) and flushed together with content when the buffer resolves
   to STREAMING.

2. Handle reasoning-only responses in the BUFFERING resolver. When a
   thinking model emits only reasoning_content with no content tokens,
   the stream ends while still in BUFFERING state. The resolver now
   detects this case and yields reasoning as plain text (without
   <think> wrapper), matching the final streaming pass behavior for
   models like Qwen3 in always-think mode.

3. Replace duplicated re.sub calls for stripping tool markup with
   the existing _strip_tool_markup(content_text, final=True) helper,
   removing ~40 lines of redundant regex code.

4. Update tests: adjust reasoning test expectations to match the new
   behavior (reasoning batched with content, not streamed individually
   during BUFFERING). Add test_reasoning_only_no_content for the
   reasoning-only edge case. 17/17 tests pass.
2026-03-27 06:45:02 +00:00
pre-commit-ci[bot]
6d6b28db3e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-03-27 06:22:52 +00:00
Daniel Han
531229811d Add unit tests for streaming tool detection state machine
16 tests covering every tool call parsing path:
- Plain text (no tool call) streaming
- Structured delta.tool_calls detection and fragment assembly
- XML <tool_call>JSON</tool_call> detection via buffer
- XML <function=name> tag detection via buffer
- Whitespace before tool XML
- Safety net (content then tool XML)
- Parallel multi-tool calls
- Reasoning token bypass (thinking models)
- Reasoning then tool call
- Empty response handling
- Buffer prefix timeout (HTML not mistaken for tool)
- Non-XML first char instant streaming
- False positive rejection (<tool_tip> vs <tool_call>)
- Arguments split across multiple chunks
- auto_heal_tool_calls=False respects the flag
- Metrics accumulation across tool iterations
2026-03-27 06:22:42 +00:00
pre-commit-ci[bot]
190124d4ac [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-03-27 06:17:48 +00:00
Daniel Han
a067609fc3 Fix ~1.2s TTFT penalty when tools are enabled in Studio
When users enable web search, Python execution, or terminal tools,
every message gets a ~1.2s delay before any text appears -- even when
the model does not call any tool. This happens because
generate_chat_completion_with_tools() does a non-streaming detection
pass (stream: False) first, waits for the complete response, then
checks for tool calls. For the ~90% of messages that don't trigger a
tool call, this blocking wait is entirely wasted.

Root cause: the detection pass payload uses stream: False, forcing
llama-server to generate the entire response before returning any
tokens.

Fix: replace the non-streaming detection pass with a streaming pass
(stream: True) and a speculative buffer state machine that detects
tool signals in the first 1-2 SSE chunks:

- BUFFERING: accumulate content tokens, check first chars for tool
  signal prefixes (<tool_call>, <function=)
- STREAMING: no tool detected, yield tokens to caller immediately
- DRAINING: tool signal found, silently accumulate rest of stream

Three detection paths:
1. Structured delta.tool_calls -- detected instantly, transition to
   DRAINING, accumulate fragments, assemble at stream end.
2. XML tool markup in content -- buffer holds up to 32 chars checking
   for <tool_call> or <function= prefix, then transitions to DRAINING.
3. No tool signal -- first non-whitespace, non-XML char triggers
   immediate transition to STREAMING (fast path, ~90% of requests).

Safety net: after any stream ends in STREAMING state, check accumulated
content for XML tool signals. Handles rare "content before tool call"
edge case.

Additional supporting changes:
- Add headers parameter to _stream_with_retry for auth forwarding
- Share _strip_tool_markup and regex patterns between the detection
  pass and the final streaming pass (removes duplication)
- Remove the iteration==0 non-streaming content shortcut (no longer
  needed since all iterations stream directly)
- Keep the final streaming pass as fallback for max_tool_iterations
  exhaustion

Benchmarked on Qwen3.5-4B Q4_K_XL:
- No tools:              TTFT ~112ms (unchanged)
- Tools enabled, no call: TTFT ~112ms (was ~1207ms)
- Decode TPS:            226 (unchanged in all cases)
2026-03-27 06:16:09 +00:00
2 changed files with 491 additions and 128 deletions

View file

@ -57,6 +57,7 @@ class LlamaCppBackend:
self._stdout_lines: list[str] = [] self._stdout_lines: list[str] = []
self._stdout_thread: Optional[threading.Thread] = None self._stdout_thread: Optional[threading.Thread] = None
self._cancel_event = threading.Event() self._cancel_event = threading.Event()
self._api_key: Optional[str] = None
self._kill_orphaned_servers() self._kill_orphaned_servers()
atexit.register(self._cleanup) atexit.register(self._cleanup)
@ -938,7 +939,23 @@ class LlamaCppBackend:
cmd.extend(["--mmproj", mmproj_path]) cmd.extend(["--mmproj", mmproj_path])
logger.info(f"Using mmproj for vision: {mmproj_path}") logger.info(f"Using mmproj for vision: {mmproj_path}")
logger.info(f"Starting llama-server: {' '.join(cmd)}") # Option C: add --api-key for direct client access when enabled
import os as _os
import secrets as _secrets
if _os.getenv("UNSLOTH_DIRECT_STREAM", "0") == "1":
self._api_key = _secrets.token_urlsafe(32)
cmd.extend(["--api-key", self._api_key])
logger.info("llama-server started with --api-key for direct streaming")
else:
self._api_key = None
_log_cmd = list(cmd)
if "--api-key" in _log_cmd:
_ki = _log_cmd.index("--api-key") + 1
if _ki < len(_log_cmd):
_log_cmd[_ki] = "<redacted>"
logger.info(f"Starting llama-server: {' '.join(_log_cmd)}")
# Set library paths so llama-server can find its shared libs and CUDA DLLs # Set library paths so llama-server can find its shared libs and CUDA DLLs
import os import os
@ -1407,6 +1424,7 @@ class LlamaCppBackend:
url: str, url: str,
payload: dict, payload: dict,
cancel_event: Optional[threading.Event] = None, cancel_event: Optional[threading.Event] = None,
headers: Optional[dict] = None,
): ):
"""Open an httpx streaming POST with cancel support. """Open an httpx streaming POST with cancel support.
@ -1473,7 +1491,11 @@ class LlamaCppBackend:
pool = 10, pool = 10,
) )
with client.stream( with client.stream(
"POST", url, json = payload, timeout = prefill_timeout "POST",
url,
json = payload,
timeout = prefill_timeout,
headers = headers,
) as response: ) as response:
_response_ref[0] = response _response_ref[0] = response
if cancel_event is not None and cancel_event.is_set(): if cancel_event is not None and cancel_event.is_set():
@ -1547,9 +1569,16 @@ class LlamaCppBackend:
# can finish. Cancel during streaming is handled by the # can finish. Cancel during streaming is handled by the
# watcher thread (closes the response on cancel_event). # watcher thread (closes the response on cancel_event).
stream_timeout = httpx.Timeout(connect = 10, read = 0.5, write = 10, pool = 10) stream_timeout = httpx.Timeout(connect = 10, read = 0.5, write = 10, pool = 10)
_auth_headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
with httpx.Client(timeout = stream_timeout) as client: with httpx.Client(timeout = stream_timeout) as client:
with self._stream_with_retry( with self._stream_with_retry(
client, url, payload, cancel_event client,
url,
payload,
cancel_event,
headers = _auth_headers,
) as response: ) as response:
if response.status_code != 200: if response.status_code != 200:
error_body = response.read().decode() error_body = response.read().decode()
@ -1681,14 +1710,44 @@ class LlamaCppBackend:
_accumulated_predicted_ms = 0.0 _accumulated_predicted_ms = 0.0
_accumulated_predicted_n = 0 _accumulated_predicted_n = 0
# ── Shared patterns for stripping tool XML from streamed content ──
import re as _re_tool
_TOOL_CLOSED_PATTERNS = [
_re_tool.compile(r"<tool_call>.*?</tool_call>", _re_tool.DOTALL),
_re_tool.compile(r"<function=\w+>.*?</function>", _re_tool.DOTALL),
]
_TOOL_ALL_PATTERNS = _TOOL_CLOSED_PATTERNS + [
_re_tool.compile(r"<tool_call>.*$", _re_tool.DOTALL),
_re_tool.compile(r"<function=\w+>.*$", _re_tool.DOTALL),
]
def _strip_tool_markup(text: str, *, final: bool = False) -> str:
if not auto_heal_tool_calls:
return text
patterns = _TOOL_ALL_PATTERNS if final else _TOOL_CLOSED_PATTERNS
for pat in patterns:
text = pat.sub("", text)
return text.strip() if final else text
# XML prefixes that signal a tool call in content.
# Empty when auto_heal is disabled so the buffer never
# speculatively holds content for XML detection.
_TOOL_XML_SIGNALS = (
("<tool_call>", "<function=") if auto_heal_tool_calls else ()
)
_MAX_BUFFER_CHARS = 32
for iteration in range(max_tool_iterations): for iteration in range(max_tool_iterations):
if cancel_event is not None and cancel_event.is_set(): if cancel_event is not None and cancel_event.is_set():
return return
# Build payload for non-streaming tool detection pass # Build payload -- stream: True so we detect tool signals
# in the first 1-2 chunks without a non-streaming penalty.
payload = { payload = {
"messages": conversation, "messages": conversation,
"stream": False, "stream": True,
"stream_options": {"include_usage": True},
"temperature": temperature, "temperature": temperature,
"top_p": top_p, "top_p": top_p,
"top_k": top_k if top_k >= 0 else 0, "top_k": top_k if top_k >= 0 else 0,
@ -1706,96 +1765,424 @@ class LlamaCppBackend:
payload["stop"] = stop payload["stop"] = stop
try: try:
with httpx.Client(timeout = None) as client: _auth_headers = (
resp = client.post(url, json = payload) {"Authorization": f"Bearer {self._api_key}"}
if resp.status_code != 200: if self._api_key
raise RuntimeError( else None
f"llama-server returned {resp.status_code}: {resp.text}" )
# ── Speculative buffer state machine ──────────────────
# BUFFERING: accumulating content, checking for tool signals
# STREAMING: no tool detected, yielding tokens to caller
# DRAINING: tool signal found, silently consuming rest
_S_BUFFERING = 0
_S_STREAMING = 1
_S_DRAINING = 2
detect_state = _S_BUFFERING
content_buffer = "" # Raw content held during BUFFERING
content_accum = "" # All content tokens (for tool parsing)
reasoning_accum = ""
cumulative_display = "" # Cumulative text yielded (with <think>)
in_thinking = False
has_content_tokens = False
tool_calls_acc = {} # Structured delta.tool_calls fragments
has_structured_tc = False
_iter_usage = None
_iter_timings = None
_stream_done = False
_last_emitted = ""
stream_timeout = httpx.Timeout(
connect = 10,
read = 0.5,
write = 10,
pool = 10,
)
with httpx.Client(timeout = stream_timeout) as client:
with self._stream_with_retry(
client,
url,
payload,
cancel_event,
headers = _auth_headers,
) as response:
if response.status_code != 200:
error_body = response.read().decode()
raise RuntimeError(
f"llama-server returned {response.status_code}: "
f"{error_body}"
)
raw_buf = ""
for raw_chunk in self._iter_text_cancellable(
response,
cancel_event,
):
raw_buf += raw_chunk
while "\n" in raw_buf:
line, raw_buf = raw_buf.split("\n", 1)
line = line.strip()
if not line:
continue
if line == "data: [DONE]":
# Flush thinking state for STREAMING
if detect_state == _S_STREAMING and in_thinking:
if has_content_tokens:
cumulative_display += "</think>"
yield {
"type": "content",
"text": _strip_tool_markup(
cumulative_display,
final = True,
),
}
else:
cumulative_display = reasoning_accum
yield {
"type": "content",
"text": cumulative_display,
}
_stream_done = True
break # exit inner while
if not line.startswith("data: "):
continue
try:
chunk_data = json.loads(line[6:])
_ct = chunk_data.get("timings")
if _ct:
_iter_timings = _ct
_cu = chunk_data.get("usage")
if _cu:
_iter_usage = _cu
choices = chunk_data.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
# ── Structured tool_calls ──
tc_deltas = delta.get("tool_calls")
if tc_deltas:
# Once visible content has been
# emitted, do not reclassify this
# turn as a tool call.
if _last_emitted:
continue
has_structured_tc = True
detect_state = _S_DRAINING
for tc_d in tc_deltas:
idx = tc_d.get("index", 0)
if idx not in tool_calls_acc:
tool_calls_acc[idx] = {
"id": tc_d.get("id", f"call_{idx}"),
"type": "function",
"function": {
"name": "",
"arguments": "",
},
}
elif tc_d.get("id"):
# Update ID if real one
# arrives on a later delta
tool_calls_acc[idx]["id"] = tc_d["id"]
func = tc_d.get("function", {})
if func.get("name"):
tool_calls_acc[idx]["function"][
"name"
] += func["name"]
if func.get("arguments"):
tool_calls_acc[idx]["function"][
"arguments"
] += func["arguments"]
continue
# ── Reasoning tokens ──
# Only yield in STREAMING state. In BUFFERING
# and DRAINING, accumulate silently so we don't
# corrupt the consumer's prev_text tracker
# (routes/inference.py never resets prev_text
# between tool iterations).
reasoning = delta.get("reasoning_content", "")
if reasoning:
reasoning_accum += reasoning
if detect_state == _S_STREAMING:
if not in_thinking:
cumulative_display += "<think>"
in_thinking = True
cumulative_display += reasoning
yield {
"type": "content",
"text": cumulative_display,
}
# ── Content tokens ──
token = delta.get("content", "")
if token:
has_content_tokens = True
content_accum += token
if detect_state == _S_DRAINING:
pass # accumulate silently
elif detect_state == _S_STREAMING:
if in_thinking:
cumulative_display += "</think>"
in_thinking = False
cumulative_display += token
cleaned = _strip_tool_markup(
cumulative_display,
)
if len(cleaned) > len(_last_emitted):
_last_emitted = cleaned
yield {
"type": "content",
"text": cleaned,
}
elif detect_state == _S_BUFFERING:
content_buffer += token
stripped_buf = content_buffer.lstrip()
if not stripped_buf:
continue
# Check tool signal prefixes
is_prefix = False
is_match = False
for sig in _TOOL_XML_SIGNALS:
if stripped_buf.startswith(sig):
is_match = True
break
if sig.startswith(stripped_buf):
is_prefix = True
break
if is_match:
detect_state = _S_DRAINING
elif (
is_prefix
and len(stripped_buf)
< _MAX_BUFFER_CHARS
):
pass # keep buffering
else:
# Not a tool -- flush buffer
detect_state = _S_STREAMING
# Flush any reasoning accumulated
# during BUFFERING phase
if reasoning_accum:
cumulative_display += "<think>"
cumulative_display += (
reasoning_accum
)
cumulative_display += "</think>"
cumulative_display += content_buffer
cleaned = _strip_tool_markup(
cumulative_display,
)
if len(cleaned) > len(_last_emitted):
_last_emitted = cleaned
yield {
"type": "content",
"text": cleaned,
}
except json.JSONDecodeError:
logger.debug(
f"Skipping malformed SSE line: " f"{line[:100]}"
)
if _stream_done:
break # exit outer for
# ── Resolve BUFFERING at stream end ──
if detect_state == _S_BUFFERING:
stripped_buf = content_buffer.lstrip()
if (
stripped_buf
and auto_heal_tool_calls
and any(s in stripped_buf for s in _TOOL_XML_SIGNALS)
):
detect_state = _S_DRAINING
elif content_accum or reasoning_accum:
detect_state = _S_STREAMING
if content_buffer:
# Flush any reasoning accumulated first
if reasoning_accum:
cumulative_display += "<think>"
cumulative_display += reasoning_accum
cumulative_display += "</think>"
cumulative_display += content_buffer
yield {
"type": "content",
"text": _strip_tool_markup(
cumulative_display,
final = True,
),
}
elif reasoning_accum and not has_content_tokens:
# Reasoning-only response (no content tokens):
# show reasoning as plain text, matching
# the final streaming pass behavior for
# models that put everything in reasoning.
cumulative_display = reasoning_accum
yield {
"type": "content",
"text": cumulative_display,
}
else:
return
# ── STREAMING path: no tool call ──
if detect_state == _S_STREAMING:
# Safety net: check for XML tool signals in content.
# The route layer resets prev_text on tool_start, so
# post-tool synthesis streams correctly even if
# content was already emitted before the tool XML.
_safety_tc = None
if auto_heal_tool_calls and any(
s in content_accum for s in _TOOL_XML_SIGNALS
):
_safety_tc = self._parse_tool_calls_from_text(
content_accum,
) )
data = resp.json() if not _safety_tc:
except httpx.ConnectError: # Content was already streamed. Yield metadata.
raise RuntimeError("Lost connection to llama-server") yield {"type": "status", "text": ""}
_fu = _iter_usage or {}
_fc = _fu.get("completion_tokens", 0)
_fp = _fu.get("prompt_tokens", 0)
_tc = _fc + _accumulated_completion_tokens
if (
_iter_usage
or _iter_timings
or _accumulated_completion_tokens
):
_mt = dict(_iter_timings) if _iter_timings else {}
if _accumulated_predicted_ms or _accumulated_predicted_n:
_mt["predicted_ms"] = (
_mt.get("predicted_ms", 0)
+ _accumulated_predicted_ms
)
_tn = (
_mt.get("predicted_n", 0) + _accumulated_predicted_n
)
_mt["predicted_n"] = _tn
_tms = _mt["predicted_ms"]
if _tms > 0:
_mt["predicted_per_second"] = _tn / (_tms / 1000.0)
yield {
"type": "metadata",
"usage": {
"prompt_tokens": _fp,
"completion_tokens": _tc,
"total_tokens": _fp + _tc,
},
"timings": _mt,
}
return
choices = data.get("choices", []) # Safety net caught tool XML -- treat as tool call
if not choices: tool_calls = _safety_tc
return content_text = _strip_tool_markup(
content_accum,
choice = choices[0] final = True,
finish_reason = choice.get("finish_reason", "")
message = choice.get("message", {})
# If model wants to call tools
tool_calls = message.get("tool_calls")
# Fallback: detect tool calls embedded as XML/text in content
# Some models output <tool_call> XML instead of structured tool_calls,
# or bare <function=...> tags without <tool_call> wrapper.
content_text = message.get("content", "") or ""
if (
auto_heal_tool_calls
and not tool_calls
and ("<tool_call>" in content_text or "<function=" in content_text)
):
tool_calls = self._parse_tool_calls_from_text(content_text)
if tool_calls:
# Strip the tool call markup from content.
# Use greedy match within <tool_call> blocks since they
# can contain arbitrary content including code.
import re
# Strip <tool_call>...</tool_call> blocks (greedy inside)
content_text = re.sub(
r"<tool_call>.*?</tool_call>",
"",
content_text,
flags = re.DOTALL,
) )
# Strip unterminated <tool_call>... to end
content_text = re.sub(
r"<tool_call>.*$",
"",
content_text,
flags = re.DOTALL,
)
# Strip bare <function=...>...</function> blocks
content_text = re.sub(
r"<function=\w+>.*?</function>",
"",
content_text,
flags = re.DOTALL,
)
# Strip unterminated bare <function=...> to end
content_text = re.sub(
r"<function=\w+>.*$",
"",
content_text,
flags = re.DOTALL,
).strip()
logger.info( logger.info(
f"Parsed {len(tool_calls)} tool call(s) from content text" f"Safety net: parsed {len(tool_calls)} tool call(s) "
f"from streamed content"
) )
else:
# ── DRAINING path: assemble tool_calls ──
tool_calls = None
content_text = content_accum
if has_structured_tc:
# Filter out incomplete fragments (e.g. from
# truncation by max_tokens or disconnect).
tool_calls = [
tool_calls_acc[i]
for i in sorted(tool_calls_acc)
if (tool_calls_acc[i].get("function", {})
.get("name", "").strip())
] or None
if (
not tool_calls
and auto_heal_tool_calls
and any(s in content_accum for s in _TOOL_XML_SIGNALS)
):
tool_calls = self._parse_tool_calls_from_text(
content_accum,
)
if tool_calls and not has_structured_tc:
content_text = _strip_tool_markup(
content_text,
final = True,
)
if tool_calls:
logger.info(
f"Parsed {len(tool_calls)} tool call(s) from "
f"{'structured delta' if has_structured_tc else 'content text'}"
)
if not tool_calls:
# DRAINING but no tool calls (false positive).
# Merge accumulated metrics from prior tool
# iterations so they are not silently dropped.
yield {"type": "status", "text": ""}
if content_accum:
yield {"type": "content", "text": content_accum}
_fu = _iter_usage or {}
_fc = _fu.get("completion_tokens", 0)
_fp = _fu.get("prompt_tokens", 0)
_tc = _fc + _accumulated_completion_tokens
if (
_iter_usage
or _iter_timings
or _accumulated_completion_tokens
):
_mt = dict(_iter_timings) if _iter_timings else {}
if _accumulated_predicted_ms or _accumulated_predicted_n:
_mt["predicted_ms"] = (
_mt.get("predicted_ms", 0)
+ _accumulated_predicted_ms
)
_tn = (
_mt.get("predicted_n", 0) + _accumulated_predicted_n
)
_mt["predicted_n"] = _tn
_tms = _mt["predicted_ms"]
if _tms > 0:
_mt["predicted_per_second"] = _tn / (_tms / 1000.0)
yield {
"type": "metadata",
"usage": {
"prompt_tokens": _fp,
"completion_tokens": _tc,
"total_tokens": _fp + _tc,
},
"timings": _mt,
}
return
if finish_reason == "tool_calls" or (tool_calls and len(tool_calls) > 0): # ── Execute tool calls ──
# Only accumulate metrics for responses that are actually used _accumulated_completion_tokens += (_iter_usage or {}).get(
_accumulated_completion_tokens += data.get("usage", {}).get(
"completion_tokens", 0 "completion_tokens", 0
) )
_iter_timings = data.get("timings", {}) _it = _iter_timings or {}
_accumulated_predicted_ms += _iter_timings.get("predicted_ms", 0) _accumulated_predicted_ms += _it.get("predicted_ms", 0)
_accumulated_predicted_n += _iter_timings.get("predicted_n", 0) _accumulated_predicted_n += _it.get("predicted_n", 0)
# Append the assistant message with tool_calls to conversation
assistant_msg = {"role": "assistant", "content": content_text} assistant_msg = {"role": "assistant", "content": content_text}
if tool_calls: if tool_calls:
assistant_msg["tool_calls"] = tool_calls assistant_msg["tool_calls"] = tool_calls
conversation.append(assistant_msg) conversation.append(assistant_msg)
# Execute each tool call
for tc in tool_calls or []: for tc in tool_calls or []:
func = tc.get("function", {}) func = tc.get("function", {})
tool_name = func.get("name", "") tool_name = func.get("name", "")
raw_args = func.get("arguments", {}) raw_args = func.get("arguments", {})
# Handle arguments as either string or dict
if isinstance(raw_args, str): if isinstance(raw_args, str):
try: try:
arguments = json.loads(raw_args) arguments = json.loads(raw_args)
@ -1807,7 +2194,6 @@ class LlamaCppBackend:
else: else:
arguments = raw_args arguments = raw_args
# Yield status update
if tool_name == "web_search": if tool_name == "web_search":
status_text = f"Searching: {arguments.get('query', '')}" status_text = f"Searching: {arguments.get('query', '')}"
elif tool_name == "python": elif tool_name == "python":
@ -1830,7 +2216,6 @@ class LlamaCppBackend:
status_text = f"Calling: {tool_name}" status_text = f"Calling: {tool_name}"
yield {"type": "status", "text": status_text} yield {"type": "status", "text": status_text}
# Emit tool_start so the frontend can record inputs
yield { yield {
"type": "tool_start", "type": "tool_start",
"tool_name": tool_name, "tool_name": tool_name,
@ -1838,7 +2223,6 @@ class LlamaCppBackend:
"arguments": arguments, "arguments": arguments,
} }
# Execute the tool
_effective_timeout = ( _effective_timeout = (
None if tool_call_timeout >= 9999 else tool_call_timeout None if tool_call_timeout >= 9999 else tool_call_timeout
) )
@ -1850,7 +2234,6 @@ class LlamaCppBackend:
session_id = session_id, session_id = session_id,
) )
# Emit tool_end so the frontend can record outputs
yield { yield {
"type": "tool_end", "type": "tool_end",
"tool_name": tool_name, "tool_name": tool_name,
@ -1858,7 +2241,6 @@ class LlamaCppBackend:
"result": result, "result": result,
} }
# Append tool result to conversation
tool_msg = { tool_msg = {
"role": "tool", "role": "tool",
"name": tool_name, "name": tool_name,
@ -1872,26 +2254,12 @@ class LlamaCppBackend:
# Continue the loop to let model respond with context # Continue the loop to let model respond with context
continue continue
# No tool calls -- model answered directly. except httpx.ConnectError:
# If no tools were executed at all, just yield the content raise RuntimeError("Lost connection to llama-server")
# from this response instead of making a redundant second request. except Exception as e:
if iteration == 0 and content_text: if cancel_event is not None and cancel_event.is_set():
yield {"type": "status", "text": ""} return
yield {"type": "content", "text": content_text} raise
_direct_usage = data.get("usage")
_direct_timings = data.get("timings")
if _direct_usage or _direct_timings:
yield {
"type": "metadata",
"usage": _direct_usage,
"timings": _direct_timings,
}
return
# Tools were called in previous iterations; do a final
# streaming pass so the model can synthesize a response
# incorporating the tool results.
break
# Clear status # Clear status
yield {"type": "status", "text": ""} yield {"type": "status", "text": ""}
@ -1917,28 +2285,6 @@ class LlamaCppBackend:
stream_payload["stop"] = stop stream_payload["stop"] = stop
stream_payload["stream_options"] = {"include_usage": True} stream_payload["stream_options"] = {"include_usage": True}
import re as _re_final
# Closed blocks only -- safe to strip mid-stream without shrinking later.
_TOOL_CLOSED_PATTERNS = [
_re_final.compile(r"<tool_call>.*?</tool_call>", _re_final.DOTALL),
_re_final.compile(r"<function=\w+>.*?</function>", _re_final.DOTALL),
]
# Open-ended patterns strip from an opening tag to end-of-string.
# Only applied on the final flush to avoid non-monotonic shrinking.
_TOOL_ALL_PATTERNS = _TOOL_CLOSED_PATTERNS + [
_re_final.compile(r"<tool_call>.*$", _re_final.DOTALL),
_re_final.compile(r"<function=\w+>.*$", _re_final.DOTALL),
]
def _strip_tool_markup(text: str, *, final: bool = False) -> str:
if not auto_heal_tool_calls:
return text
patterns = _TOOL_ALL_PATTERNS if final else _TOOL_CLOSED_PATTERNS
for pat in patterns:
text = pat.sub("", text)
return text.strip() if final else text
cumulative = "" cumulative = ""
_last_emitted = "" _last_emitted = ""
in_thinking = False in_thinking = False
@ -1950,9 +2296,16 @@ class LlamaCppBackend:
try: try:
stream_timeout = httpx.Timeout(connect = 10, read = 0.5, write = 10, pool = 10) stream_timeout = httpx.Timeout(connect = 10, read = 0.5, write = 10, pool = 10)
_auth_headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
with httpx.Client(timeout = stream_timeout) as client: with httpx.Client(timeout = stream_timeout) as client:
with self._stream_with_retry( with self._stream_with_retry(
client, url, stream_payload, cancel_event client,
url,
stream_payload,
cancel_event,
headers = _auth_headers,
) as response: ) as response:
if response.status_code != 200: if response.status_code != 200:
error_body = response.read().decode() error_body = response.read().decode()
@ -2078,7 +2431,10 @@ class LlamaCppBackend:
if not self.is_loaded: if not self.is_loaded:
return None return None
try: try:
with httpx.Client(timeout = 10) as client: _auth_headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
with httpx.Client(timeout = 10, headers = _auth_headers) as client:
def _detok(tid: int) -> str: def _detok(tid: int) -> str:
r = client.post( r = client.post(
@ -2196,7 +2552,12 @@ class LlamaCppBackend:
if need_ids: if need_ids:
payload["n_probs"] = 1 payload["n_probs"] = 1
with httpx.Client(timeout = httpx.Timeout(300, connect = 10)) as client: _auth_headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
with httpx.Client(
timeout = httpx.Timeout(300, connect = 10), headers = _auth_headers
) as client:
resp = client.post(f"{self.base_url}/completion", json = payload) resp = client.post(f"{self.base_url}/completion", json = payload)
if resp.status_code != 200: if resp.status_code != 200:
raise RuntimeError( raise RuntimeError(

View file

@ -1128,6 +1128,8 @@ async def openai_chat_completions(
continue continue
if event["type"] in ("tool_start", "tool_end"): if event["type"] in ("tool_start", "tool_end"):
if event["type"] == "tool_start":
prev_text = ""
yield f"data: {json.dumps(event)}\n\n" yield f"data: {json.dumps(event)}\n\n"
continue continue