Skip to content
Closed

Work #6302

3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,5 +142,8 @@ jobs:
if: steps.docs-check.outputs.docs_only != 'true'
- run: mkdir -p data # sqlite DB lives at ./data/app.db
if: steps.docs-check.outputs.docs_only != 'true'
- name: Smoke gate (fresh-install confidence)
if: steps.docs-check.outputs.docs_only != 'true'
run: python -m pytest -q tests/test_smoke_install.py
- run: python -m pytest -q
if: steps.docs-check.outputs.docs_only != 'true'
2 changes: 1 addition & 1 deletion launch-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ $pyVersion = $null

$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
if ($pyLauncher) {
foreach ($v in @("-3.13", "-3.12", "-3.11")) {
foreach ($v in @("-3.14", "-3.13", "-3.12", "-3.11")) {
$ver = Get-PythonVersionText $pyLauncher.Source @($v)
if ($ver) {
$pyExe = $pyLauncher.Source
Expand Down
20 changes: 20 additions & 0 deletions routes/cookbook_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2188,6 +2188,26 @@ async def model_serve(request: Request, req: ServeRequest):
# ollama is found (otherwise macOS falls back to a slow source build).
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
# Issue #6079: a generated shim at ~/bin/llama-server (written
# when only the pip bindings existed) keeps satisfying
# `command -v llama-server` forever, so the native build and
# recovery below never run and every serve stays on the slow
# `python -m llama_cpp.server` path. If a real native binary
# now exists in any common location, relink ~/bin/llama-server
# to it - the shim must never shadow a native install.
runner_lines.append('_ODY_NATIVE=""')
runner_lines.append('for _ody_c in "$HOME/llama.cpp/build/bin/llama-server" "$HOME/llama.cpp/build-cuda/bin/Release/llama-server" "$HOME/llama.cpp/build/bin/Release/llama-server" "$HOME/.local/bin/llama-server" "/usr/local/bin/llama-server" "/opt/homebrew/bin/llama-server" "/usr/bin/llama-server"; do')
runner_lines.append(' if [ -x "$_ody_c" ] && ! grep -q "Auto-generated by Odysseus Cookbook" "$_ody_c" 2>/dev/null; then')
runner_lines.append(' _ODY_NATIVE="$_ody_c"')
runner_lines.append(' break')
runner_lines.append(' fi')
runner_lines.append('done')
runner_lines.append('if [ -n "$_ODY_NATIVE" ]; then')
runner_lines.append(' if [ ! -e "$HOME/bin/llama-server" ] || grep -q "Auto-generated by Odysseus Cookbook" "$HOME/bin/llama-server" 2>/dev/null; then')
runner_lines.append(' ln -sf "$_ODY_NATIVE" "$HOME/bin/llama-server"')
runner_lines.append(' echo "[odysseus] Native llama-server found at $_ODY_NATIVE - relinked ~/bin/llama-server over the generated shim (issue #6079)."')
runner_lines.append(' fi')
runner_lines.append('fi')
runner_lines.append('if [ -d /data/data/com.termux ]; then')
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
Expand Down
3 changes: 1 addition & 2 deletions routes/model_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,8 +604,7 @@ def _is_ollama_base(base_url: str) -> bool:
"snowflake/arctic-embed", "nvidia/nv-embed", "embed",
)
_NON_CHAT_CONTAINS = (
"-realtime", "-transcribe", "-tts", "-codex",
"codex-", "content-safety", "-safety", "-reward", "nvclip",
"-realtime", "-transcribe", "-tts", "content-safety", "-safety", "-reward", "nvclip",
"kosmos", "fuyu", "deplot", "vila", "neva",
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
"topic-control", "calibration",
Expand Down
61 changes: 55 additions & 6 deletions src/service_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,45 @@ def _detail_for(category: str) -> str:
return _ERROR_DETAIL.get(category, _ERROR_DETAIL["error"])


# Concrete, copy-paste-able next steps for each controlled failure category
# (ROADMAP: "clear next steps instead of just 'crashed'"). Never includes
# credentials, raw hostnames with secrets, or server-supplied detail.
_NEXT_STEP = {
"timeout": "The service did not respond in time — check that it is running and the host/port in Settings.",
"connection_refused": "Connection refused — check the service is running and the host/port in Settings.",
"dns_error": "Host could not be resolved — check the configured hostname in Settings.",
"tls_error": "TLS handshake failed — check the service's certificate / CA configuration.",
"network_error": "Network error — check that this machine can reach the service (firewall, VPN).",
"http_error": "The service returned an error status — check its own logs.",
"auth_or_protocol_error": "Authentication or protocol failed — check the account credentials and protocol in Settings.",
"no_models": "The endpoint is reachable but returned no models — check its base URL / model path.",
"no_host": "No host is configured — set it in Settings.",
"error": "See /api/diagnostics/logs for the app log, then check the service's own logs.",
}
_DEFAULT_NEXT_STEP = (
"Check the service state in Settings and its logs; see /api/diagnostics/logs."
)


def _next_step_for(service: str = "", category: Optional[str] = None) -> str:
"""Return an actionable hint for a failure category (secret-free)."""
return _NEXT_STEP.get(category or "error", _DEFAULT_NEXT_STEP)


def _enrich_next_step(service: Dict[str, Any]) -> Dict[str, Any]:
"""Add ``meta.next_step`` to degraded/down service entries (idempotent)."""
if service.get("status") not in (DEGRADED, DOWN):
return service
meta = service.setdefault("meta", {})
if "next_step" in meta:
return service
meta["next_step"] = _next_step_for(
service.get("name", ""),
meta.get("error") if isinstance(meta.get("error"), str) else None,
)
return service


def _http_get(url: str, timeout: float = _PROBE_TIMEOUT):
"""Single network entry point for the HTTP probes (monkeypatched in tests)."""
import httpx
Expand Down Expand Up @@ -322,7 +361,8 @@ def _label(acc: Dict[str, Any]) -> str:
def _check(_i: int, acc: Dict[str, Any]) -> Dict[str, Any]:
name = _label(acc)
if not (acc.get("imap_host") or ""):
return {"name": name, "ok": False, "error": "no_host"}
return {"name": name, "ok": False, "error": "no_host",
"next_step": _next_step_for("email", "no_host")}
try:
conn = connect(acc.get("account_id"))
try:
Expand All @@ -331,12 +371,15 @@ def _check(_i: int, acc: Dict[str, Any]) -> Dict[str, Any]:
pass
return {"name": name, "ok": True, "error": None}
except Exception as e:
return {"name": name, "ok": False, "error": _classify_error(e)}
category = _classify_error(e)
return {"name": name, "ok": False, "error": category,
"next_step": _next_step_for("email", category)}

raw = _bounded_map(accounts, _check, budget=_FANOUT_BUDGET,
concurrency=_PROBE_CONCURRENCY)
per_account = [r if r is not None
else {"name": _label(accounts[i]), "ok": False, "error": "timeout"}
else {"name": _label(accounts[i]), "ok": False, "error": "timeout",
"next_step": _next_step_for("email", "timeout")}
for i, r in enumerate(raw)]
return _rollup_items("email", "mailbox(es)", per_account)

Expand Down Expand Up @@ -367,17 +410,21 @@ def _check(_i: int, ep: Dict[str, Any]) -> Dict[str, Any]:
models = probe(ep.get("base_url"), ep.get("api_key"),
timeout=_PROBE_TIMEOUT) or []
except Exception as e:
category = _classify_error(e)
return {"name": name, "ok": False, "model_count": 0,
"error": _classify_error(e)}
"error": category, "next_step": _next_step_for("providers", category)}
count = len(models)
error = None if count else "no_models"
return {"name": name, "ok": bool(count), "model_count": count,
"error": None if count else "no_models"}
"error": error,
"next_step": None if count else _next_step_for("providers", error)}

raw = _bounded_map(endpoints, _check, budget=_FANOUT_BUDGET,
concurrency=_PROBE_CONCURRENCY)
per_endpoint = [r if r is not None
else {"name": _label(endpoints[i]), "ok": False,
"model_count": 0, "error": "timeout"}
"model_count": 0, "error": "timeout",
"next_step": _next_step_for("providers", "timeout")}
for i, r in enumerate(raw)]
return _rollup_items("providers", "endpoint(s)", per_endpoint, key="endpoints")

Expand Down Expand Up @@ -497,6 +544,8 @@ async def collect_service_health(rag_manager: Any = None,
for n in names]

services = [chroma, *results]
# Attach actionable next-step hints to degraded/down entries (idempotent).
services = [_enrich_next_step(s) for s in services]
return {
"overall": _rollup(services),
"services": services,
Expand Down
51 changes: 46 additions & 5 deletions src/tool_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1375,13 +1375,34 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
# XML-like text inside JSON argument values stays data instead of
# selecting a different tool.
json_body_seen = False
# Wrapper spans consumed below; used to mask wrapper bodies out of the
# bare-invoke fallback so markup inside a malformed JSON payload stays
# data (issue #5333) while later real calls are recovered (#6014).
wrapper_spans: list[tuple[int, int]] = []
skip_before = -1
for _ms, inner_start, inner_end, _me in _iter_delimited(
text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE
):
wrapper_spans.append((_ms, _me))
if inner_start < skip_before:
# Already consumed by a string-aware (extended) parse below.
continue
body = text[inner_start:inner_end]
if _looks_like_json_body(body):
json_body_seen = True
block = _parse_json_tool_call_body(body)
if not block:
# Issue #6013: a closer token inside a JSON string value
# ends the delimiter span early, so the body fails to
# decode. Retry with each later closer in turn; the first
# one whose body decodes is the real wrapper end.
for close_m in _XML_TOOL_CALL_CLOSE_RE.finditer(text, _me):
block = _parse_json_tool_call_body(
text[inner_start:close_m.start()]
)
if block:
skip_before = close_m.end()
break
if block:
blocks.append(block)
continue
Expand All @@ -1398,6 +1419,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
# complete inner tool tag, but forget the closing </tool_call>.
if not blocks:
for m in _XML_OPEN_TOOL_CALL_RE.finditer(text):
# The opener-to-EOS match also fires when the wrapper WAS
# closed (group(1) then swallows the closer and everything
# after). Trust it only where the closed-span scan found
# nothing, or it would mask later valid calls (#6014).
if any(ws <= m.start() < we for ws, we in wrapper_spans):
continue
wrapper_spans.append((m.start(), m.end()))
body = m.group(1)
if _looks_like_json_body(body):
# Same fail-closed rule as above for an unclosed wrapper.
Expand All @@ -1417,11 +1445,24 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if block:
blocks.append(block)
# Try bare <invoke> without wrapper. Skipped when a JSON wrapper body
# was seen but produced no block: this rescan covers the full text,
# wrapper bodies included, and <invoke> markup inside a (possibly
# malformed) JSON payload must stay data rather than dispatch.
if not blocks and not json_body_seen:
for inv_name, inv_body in _iter_xml_invoke(text):
# produced a valid block (blocks non-empty) — this rescan covers the
# full text and markup inside a JSON payload must stay data (#5333).
# Issue #6014: when JSON wrapper bodies were seen but all malformed,
# scan with every wrapper span masked out so a later valid bare call
# is recovered while markup inside the malformed payload stays data.
scan_text = None
if not blocks:
if not json_body_seen:
scan_text = text
elif wrapper_spans:
chars = list(text)
for ws, we in wrapper_spans:
for i in range(max(0, ws), min(we, len(chars))):
if chars[i] != "\n":
chars[i] = " "
scan_text = "".join(chars)
if scan_text is not None:
for inv_name, inv_body in _iter_xml_invoke(scan_text):
block = _parse_xml_invoke(inv_name, inv_body)
if block:
blocks.append(block)
Expand Down
15 changes: 13 additions & 2 deletions src/tool_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -1414,9 +1414,20 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock

# Convert structured args back to the text format each tool expects
if tool_type == "bash":
content = args.get("command", "")
payload = args.get("command", "")
# Issue #6012: a Qwen/Hermes JSON wrapper with a non-string command
# (list/object/number) must fail closed — coercing it produced a
# ToolBlock whose content isn't a str and crashed or mangled execution.
if payload is not None and not isinstance(payload, str):
logger.warning(f"Rejecting non-string command payload for function call {name}: {payload!r}")
return None
content = payload or ""
elif tool_type == "python":
content = args.get("code", "")
payload = args.get("code", "")
if payload is not None and not isinstance(payload, str):
logger.warning(f"Rejecting non-string code payload for function call {name}: {payload!r}")
return None
content = payload or ""
elif tool_type == "web_search":
queries = args.get("queries")
if isinstance(queries, list) and queries:
Expand Down
130 changes: 130 additions & 0 deletions tests/test_cookbook_stale_shim_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Issue #6079: a stale generated llama-server shim must not block native recovery.

Cookbook writes a `llama-server` shim into ~/bin when only the pip bindings
exist. That shim keeps satisfying `command -v llama-server` forever, so the
native build/recovery branch is skipped and every serve stays on the slow
`python -m llama_cpp.server` path even after a real native binary appears.

The runner now relinks ~/bin/llama-server to a real native binary when one
exists. These tests extract the SHIPPED shell lines from the route source and
execute them against a temporary HOME, so the test cannot drift from what the
runner actually emits.
"""
import ast
import os
import re
import shutil
import subprocess
from pathlib import Path

import pytest

REPO = Path(__file__).resolve().parent.parent
ROUTES = REPO / "routes" / "cookbook_routes.py"
SHIM_MARKER = "Auto-generated by Odysseus Cookbook"

pytestmark = pytest.mark.skipif(
shutil.which("bash") is None, reason="bash is required to exercise the runner lines"
)


def _shipped_shell_lines() -> list[str]:
"""Pull the #6079 block's shell text out of the shipped source."""
src = ROUTES.read_text(encoding="utf-8")
start = src.index("# Issue #6079")
region = src[start:]
lines: list[str] = []
for m in re.finditer(r"runner_lines\.append\((.*)\)[ \t]*$", region, re.M):
literal = ast.literal_eval(m.group(1))
lines.append(literal)
if literal == "fi":
break
assert lines, "no runner_lines found after the #6079 marker"
assert lines[-1] == "fi", f"extraction stopped early: {lines[-1]!r}"
return lines


def _run(home: Path) -> subprocess.CompletedProcess:
env = {**os.environ, "HOME": str(home)}
script = "\n".join(_shipped_shell_lines())
return subprocess.run(["bash", "-c", script], env=env,
capture_output=True, text=True, check=False)


def _write(path: Path, content: str, executable: bool = True) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
if executable:
path.chmod(0o755)


def _shim_body() -> str:
return f"#!/usr/bin/env bash\n# {SHIM_MARKER}: a llama-server lookalike\nexec python3 -m llama_cpp.server\n"


def test_stale_shim_is_replaced_by_native_binary(tmp_path):
"""The core #6079 case: shim in ~/bin, real binary in ~/.local/bin."""
_write(tmp_path / "bin" / "llama-server", _shim_body())
native = tmp_path / ".local" / "bin" / "llama-server"
_write(native, "#!/bin/sh\necho native\n")

result = _run(tmp_path)

assert result.returncode == 0, result.stderr
target = tmp_path / "bin" / "llama-server"
assert target.is_symlink(), "stale shim was not relinked to the native binary"
assert target.resolve() == native.resolve()
assert "relinked" in result.stdout


def test_shim_left_alone_when_no_native_binary_exists(tmp_path):
"""Fallback stays intact: nothing native -> the shim keeps working."""
shim = tmp_path / "bin" / "llama-server"
_write(shim, _shim_body())

result = _run(tmp_path)

assert result.returncode == 0, result.stderr
assert not shim.is_symlink()
assert SHIM_MARKER in shim.read_text(encoding="utf-8")
assert "relinked" not in result.stdout


def test_existing_native_link_is_not_relinked_again(tmp_path):
"""Idempotent: a ~/bin entry already pointing at native is left alone."""
native = tmp_path / ".local" / "bin" / "llama-server"
_write(native, "#!/bin/sh\necho native\n")
target = tmp_path / "bin" / "llama-server"
target.parent.mkdir(parents=True, exist_ok=True)
target.symlink_to(native)

result = _run(tmp_path)

assert result.returncode == 0, result.stderr
assert target.resolve() == native.resolve()
assert "relinked" not in result.stdout


def test_another_shim_is_not_treated_as_native(tmp_path):
"""A shim copy elsewhere in the search path must not count as native."""
_write(tmp_path / "bin" / "llama-server", _shim_body())
_write(tmp_path / ".local" / "bin" / "llama-server", _shim_body())

result = _run(tmp_path)

assert result.returncode == 0, result.stderr
assert not (tmp_path / "bin" / "llama-server").is_symlink()
assert "relinked" not in result.stdout


def test_native_build_output_wins_over_other_candidates(tmp_path):
"""Precedence: a source build under ~/llama.cpp/build/bin is preferred."""
build = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
_write(build, "#!/bin/sh\necho built\n")
_write(tmp_path / ".local" / "bin" / "llama-server", "#!/bin/sh\necho local\n")
_write(tmp_path / "bin" / "llama-server", _shim_body())

result = _run(tmp_path)

assert result.returncode == 0, result.stderr
assert (tmp_path / "bin" / "llama-server").resolve() == build.resolve()
Loading
Loading