Compare commits

...
Author SHA1 Message Date
StressTestor 166a6e9cf6 fix(agent): drop the empty assistant turn from an approved-action replay
The approved-action replay appends the sealed tool result with no assistant
prose for that round, which produced an assistant message with content "".
Anthropic's Messages API rejects a non-final assistant message with empty
content, so a resumed turn after a tool approval failed before the model saw
the result. A turn carrying neither prose nor reasoning has nothing to say to
any provider, so it is no longer appended. A round with prose, and a
reasoning-only round that DeepSeek thinking mode needs, both still append.
2026-08-19 03:36:28 -06:00
Dividesbyzer0 43682d4e2e fix(cookbook): activate local Windows venv in bash runner (#5734) 2026-08-18 16:19:33 +02:00
RaresKeY 032967af4b fix(models): show API models by default (#6089) 2026-08-17 13:41:04 +02:00
RaresKeY 0e03aea134 fix(models): align API model checkbox state (#6087) 2026-08-17 11:09:02 +01:00
9 changed files with 289 additions and 21 deletions
+35
View File
@@ -1204,6 +1204,41 @@ def _safe_env_prefix(ep: str | None) -> str | None:
return f'[ -f "{path}" ] && source "{path}" || true'
def _local_windows_bash_env_prefix(ep: str | None) -> str | None:
"""Convert a frontend PowerShell venv prefix for the local Git Bash runner."""
if not ep:
return ep
prefix = ep.strip()
if not prefix.startswith("&"):
return ep
raw_path = prefix[1:].lstrip()
if not raw_path:
return ep
if raw_path.startswith("'"):
if len(raw_path) < 2 or not raw_path.endswith("'"):
return ep
quoted_path = raw_path[1:-1]
if "'" in quoted_path.replace("''", ""):
return ep
path = quoted_path.replace("''", "'")
else:
path = raw_path.rstrip()
if "'" in path or '"' in path:
return ep
if any(c in path for c in "\r\n;&|`$<>"):
return ep
if not path.replace("\\", "/").casefold().endswith("/scripts/activate.ps1"):
return ep
bash_path = _git_bash_path(path)
if "\\" in bash_path:
return ep
bash_path = bash_path[: -len("Activate.ps1")] + "activate"
return "source " + shlex.quote(bash_path)
def _ssh_ps(host, script_path, port=None):
"""Build SSH command to run a PowerShell script on a Windows remote."""
pf = f"-p {port} " if port and port != "22" else ""
+3 -3
View File
@@ -50,7 +50,7 @@ from routes.cookbook_helpers import (
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
_validate_local_dir, _validate_gpus, _shell_path,
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT,
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
_safe_env_prefix, _local_windows_bash_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
load_stored_hf_token,
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
@@ -1336,7 +1336,7 @@ def setup_cookbook_routes() -> APIRouter:
# Local: run hf download in the background (tmux on POSIX, a detached
# process + logfile on Windows where tmux doesn't exist).
if req.env_prefix:
lines.append(_safe_env_prefix(req.env_prefix))
lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
else:
lines.append("deactivate 2>/dev/null; hash -r")
# Show whether the HF token reached this run (masked) — tells a gated
@@ -2166,7 +2166,7 @@ def setup_cookbook_routes() -> APIRouter:
if req.gpus:
runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'")
if req.env_prefix:
runner_lines.append(_safe_env_prefix(req.env_prefix))
runner_lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
else:
runner_lines.append("deactivate 2>/dev/null; hash -r")
_append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd)
+5 -7
View File
@@ -1351,14 +1351,14 @@ def _legacy_visible_api_models(ep) -> List[str]:
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
"""Return model IDs that should appear in the picker for an endpoint.
API providers expose remote inventory from /v1/models. Treat that cache as
inventory, not approval: only manually pinned API models should appear in
the picker. Local/self-hosted endpoints keep the older hide-list behavior.
API providers expose remote inventory from /v1/models. Default to that
visible inventory until an explicit pinned-model allow-list is saved.
Local/self-hosted endpoints keep the older hide-list behavior.
"""
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
if _picker_requires_pinning(base_url, kind):
if not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep) if _hidden_model_ids(ep) else []
pinned = _legacy_visible_api_models(ep)
return pinned, pinned
return _visible_models(
_cached_model_ids(ep),
@@ -2342,9 +2342,7 @@ def setup_model_routes(model_discovery):
else:
response.headers["X-Model-Refresh-Status"] = "failed"
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
if picker_requires_pinning and not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep)
_, pinned = _picker_models_for_endpoint(ep, base, kind)
pinned_set = set(pinned)
return [
{
+11 -4
View File
@@ -3081,10 +3081,17 @@ def _append_tool_results(
messages.append(result_message)
else:
tool_output_text = "\n\n".join(tool_results)
msg = {"role": "assistant", "content": round_response}
if round_reasoning:
msg["reasoning_content"] = round_reasoning
messages.append(msg)
# An approved-action replay injects the sealed tool result with no
# assistant prose for that round, which used to append an assistant turn
# whose content was "". Anthropic's Messages API rejects a non-final
# assistant message with empty content (HTTP 400), so the resumed turn
# died before the model saw the result. A turn carrying neither prose nor
# reasoning has nothing to say to any provider, so skip it entirely.
if round_response.strip() or round_reasoning:
msg = {"role": "assistant", "content": round_response}
if round_reasoning:
msg["reasoning_content"] = round_reasoning
messages.append(msg)
# Tool output (shell/python stdout, file reads, fetched pages, email
# bodies, MCP results) is sourced from outside the server. Wrap it as
# untrusted data so prompt-injection inside a tool result is treated as
+3 -1
View File
@@ -15,6 +15,7 @@ let _getPlatform;
let _serverByVal;
let _isWindows;
let _buildEnvPrefix;
let _psQuote;
let _buildServeCmd;
let _detectBackend;
let _detectToolParser;
@@ -538,7 +539,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
if (srv.downloadDir) payload.local_dir = srv.downloadDir;
if (isWin) {
if (env === 'venv' && envPath) {
payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'conda activate ' + envPath;
}
@@ -652,6 +653,7 @@ export function initDownload(shared) {
_serverByVal = shared._serverByVal;
_isWindows = shared._isWindows;
_buildEnvPrefix = shared._buildEnvPrefix;
_psQuote = shared._psQuote;
_buildServeCmd = shared._buildServeCmd;
_detectBackend = shared._detectBackend;
_detectToolParser = shared._detectToolParser;
+3 -1
View File
@@ -338,6 +338,7 @@ let _sshPrefix;
let _getPlatform;
let _isWindows;
let _buildEnvPrefix;
let _psQuote;
let _loadPresets;
let _savePresets;
let _copyText;
@@ -1971,7 +1972,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
let envPrefix = '';
if (_isWindows()) {
if (_envState.env === 'venv' && _envState.envPath) {
envPrefix = '& ' + (_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'conda activate ' + _envState.envPath;
}
@@ -4402,6 +4403,7 @@ export function initRunning(shared) {
_getPlatform = shared._getPlatform;
_isWindows = shared._isWindows;
_buildEnvPrefix = shared._buildEnvPrefix;
_psQuote = shared._psQuote;
_loadPresets = shared._loadPresets;
_savePresets = shared._savePresets;
_copyText = shared._copyText;
+100
View File
@@ -0,0 +1,100 @@
"""Regression coverage for the assistant turn an approved-action replay appends.
Anthropic's Messages API rejects a non-final assistant message whose content is
empty, so the resumed turn after a tool approval used to fail before the model
ever saw the sealed result. The replay injects its result with no assistant
prose for that round, which is the only path that produced such a turn.
"""
import src.llm_core as llm_core
from src.agent_loop import _append_tool_results
_RESULT = "bash: ok\nhello"
_RECORD = {
"tool_name": "bash",
"content": "printf hello",
"result": {"output": "hello", "exit_code": 0},
"text": _RESULT,
}
def _replay_messages(round_response="", round_reasoning=""):
"""Mirror the approved-action injection in stream_agent_loop."""
messages = [
{"role": "system", "content": "system preface"},
{"role": "user", "content": "run the command and summarise it"},
]
_append_tool_results(
messages,
round_response,
[],
[_RESULT],
[_RESULT],
False,
0,
round_reasoning=round_reasoning,
tool_result_records=[_RECORD],
)
return messages
def _empty_assistant_turns(messages):
return [
index
for index, message in enumerate(messages)
if message.get("role") == "assistant"
and not str(message.get("content") or "").strip()
and not message.get("tool_calls")
]
def test_replay_appends_no_empty_assistant_turn():
assert _empty_assistant_turns(_replay_messages()) == []
def test_replay_payload_is_a_single_non_empty_user_turn_for_anthropic():
# Asserting the whole sequence rather than scanning a slice: once the empty
# spacer is gone the payload is one message, so a "no offenders in
# chat[:-1]" check would pass without inspecting anything.
sanitized = llm_core._sanitize_llm_messages(_replay_messages())
payload = llm_core._build_anthropic_payload(
"claude-sonnet-5", sanitized, 0.2, 512
)
chat = payload["messages"]
assert [message["role"] for message in chat] == ["user"]
assert all(str(message.get("content") or "").strip() for message in chat)
def test_replay_merges_tool_output_after_the_request_with_its_fence_intact():
# Dropping the empty assistant turn leaves two adjacent user messages, which
# the sanitizer merges. Pin that shape: the tool output must still sit
# behind its untrusted fence and must not precede the operator's request.
sanitized = llm_core._sanitize_llm_messages(_replay_messages())
user_turns = [m for m in sanitized if m.get("role") == "user"]
assert len(user_turns) == 1
merged = user_turns[0]["content"]
assert merged.index("run the command") < merged.index("UNTRUSTED SOURCE DATA")
assert merged.index("UNTRUSTED SOURCE DATA") < merged.index("hello")
def test_a_round_with_prose_still_appends_its_assistant_turn():
messages = _replay_messages(round_response="running that now")
assistants = [m for m in messages if m.get("role") == "assistant"]
assert [m["content"] for m in assistants] == ["running that now"]
def test_reasoning_only_round_keeps_its_carrier_and_its_known_empty_content():
"""Characterises the one empty-content turn this change deliberately leaves.
A round with reasoning and no prose still appends its carrier, and that
carrier's content is still "", which Anthropic would still reject. Dropping
it would lose the reasoning DeepSeek thinking mode requires on the next
request, and the approval replay never takes this path because it passes no
reasoning. Left alone on purpose; this test makes the gap visible instead of
silent, and should be updated by whoever closes it.
"""
messages = _replay_messages(round_reasoning="thinking about it")
assistants = [m for m in messages if m.get("role") == "assistant"]
assert len(assistants) == 1
assert assistants[0].get("reasoning_content") == "thinking about it"
assert assistants[0]["content"] == ""
+65
View File
@@ -16,6 +16,7 @@ from routes.cookbook_helpers import (
_llama_cpp_rebuild_cmd,
_append_vllm_linux_preflight_lines,
_local_tooling_path_export,
_local_windows_bash_env_prefix,
_pip_install_attempt,
_pip_install_fallback_chain,
_ollama_bind_from_cmd,
@@ -107,6 +108,70 @@ def test_safe_env_prefix_accepts_powershell_activation_path():
)
@pytest.mark.parametrize(
("prefix", "expected"),
[
("& 'C:\\Users\\me\\venv\\Scripts\\Activate.ps1'", "source /c/Users/me/venv/Scripts/activate"),
(r"& C:\Users\me\venv\Scripts\Activate.ps1", "source /c/Users/me/venv/Scripts/activate"),
(
r"& C:\Users\me\My Envs\venv\Scripts\Activate.ps1",
"source '/c/Users/me/My Envs/venv/Scripts/activate'",
),
(
"& 'C:\\Users\\me\\My Envs\\venv\\Scripts\\Activate.ps1'",
"source '/c/Users/me/My Envs/venv/Scripts/activate'",
),
(r"& D:/Envs/venv/Scripts/Activate.ps1", "source /d/Envs/venv/Scripts/activate"),
],
)
def test_local_windows_bash_env_prefix_converts_powershell_venv_activation(prefix, expected):
converted = _local_windows_bash_env_prefix(prefix)
assert converted == expected
assert _safe_env_prefix(converted).startswith('[ -f "')
@pytest.mark.parametrize(
"prefix",
[
None,
"",
"source /home/me/venv/bin/activate",
"conda activate qwen35",
'eval "$(conda shell.bash hook)" && conda activate qwen35',
r"& \\server\share\venv\Scripts\Activate.ps1",
],
)
def test_local_windows_bash_env_prefix_leaves_other_prefixes_unchanged(prefix):
assert _local_windows_bash_env_prefix(prefix) == prefix
def test_local_windows_bash_env_prefix_handles_long_whitespace_input():
prefix = "\t" * 100_000
assert _local_windows_bash_env_prefix(prefix) == prefix
@pytest.mark.parametrize(
"relative_path",
["static/js/cookbookRunning.js", "static/js/cookbookDownload.js"],
)
def test_primary_windows_venv_emitters_quote_activation_path(relative_path):
source = (Path(__file__).resolve().parents[1] / relative_path).read_text(encoding="utf-8")
assert "'& ' + _psQuote(" in source
assert "_psQuote = shared._psQuote;" in source
def test_windows_venv_conversion_stays_scoped_to_local_git_bash_runners():
source = (Path(__file__).resolve().parents[1] / "routes/cookbook_routes.py").read_text(encoding="utf-8")
guarded_conversion = (
"_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix"
)
assert source.count(guarded_conversion) == 2
def test_validate_local_dir_accepts_external_drive_paths_with_spaces():
path = "/Volumes/T7 2TB/AI Models/llamacpp"
+64 -5
View File
@@ -1107,6 +1107,47 @@ def test_get_api_models_marks_picker_as_pinned_only(monkeypatch):
assert by_id["anthropic/claude-sonnet-4"]["is_pinned"] is False
def test_get_fresh_api_models_marks_cached_inventory_as_pinned(monkeypatch):
ep = _make_endpoint(
base_url="https://api.example.test/v1",
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
pinned_models=None,
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
result = endpoint("ep1", _PinnedFakeRequest(), SimpleNamespace(headers={}))
assert [row["id"] for row in result] == [
"openai/gpt-image-1",
"anthropic/claude-sonnet-4",
]
assert all(row["picker_requires_pinning"] is True for row in result)
assert all(row["is_pinned"] is True for row in result)
def test_get_legacy_api_models_preserves_hidden_selection_as_pinned(monkeypatch):
ep = _make_endpoint(
base_url="https://api.example.test/v1",
cached_models=json.dumps(["m1", "m2", "m3"]),
hidden_models=json.dumps(["m2"]),
pinned_models=None,
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
result = endpoint("ep1", _PinnedFakeRequest(), SimpleNamespace(headers={}))
by_id = {row["id"]: row for row in result}
assert by_id["m1"]["is_pinned"] is True
assert by_id["m2"]["is_pinned"] is False
assert by_id["m3"]["is_pinned"] is True
def test_reprobe_preserves_pinned_models(monkeypatch):
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
db = _PinnedFakeDb([ep])
@@ -1266,7 +1307,7 @@ def test_list_model_endpoints_returns_key_fingerprint(monkeypatch):
assert result[1]["api_key_fingerprint"] == ""
def test_list_api_endpoint_reports_inventory_count_when_none_pinned(monkeypatch):
def test_list_api_endpoint_defaults_inventory_to_visible_when_none_pinned(monkeypatch):
ep = _make_endpoint(
base_url="https://api.example.test/v1",
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
@@ -1279,12 +1320,31 @@ def test_list_api_endpoint_reports_inventory_count_when_none_pinned(monkeypatch)
result = endpoint(_PinnedFakeRequest())
assert result[0]["models"] == []
assert result[0]["models"] == [
"openai/gpt-image-1",
"anthropic/claude-sonnet-4",
]
assert result[0]["pinned_models"] == result[0]["models"]
assert result[0]["model_count"] == 2
assert result[0]["picker_requires_pinning"] is True
assert result[0]["status"] == "online"
def test_api_picker_preserves_explicit_empty_selection():
ep = _make_endpoint(
base_url="https://api.example.test/v1",
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
pinned_models=json.dumps([]),
)
models, pinned = model_routes._picker_models_for_endpoint(
ep, ep.base_url, ep.endpoint_kind
)
assert models == []
assert pinned == []
def test_list_api_endpoint_returns_pinned_picker_models(monkeypatch):
ep = _make_endpoint(
base_url="https://api.example.test/v1",
@@ -1767,7 +1827,7 @@ def test_api_models_openrouter_uses_pinned_models_not_hidden(monkeypatch):
assert result["items"][0]["models"] == ["openai/gpt-image-1"]
def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
def test_api_models_openrouter_defaults_cached_models_visible(monkeypatch):
row = _route_ep(
"openrouter",
"https://openrouter.ai/api/v1",
@@ -1775,7 +1835,6 @@ def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
pinned_models=None,
api_key="fake-key",
)
row.hidden_models = json.dumps(["m2"])
db = _RouteDb([row])
router = model_routes.setup_model_routes(model_discovery=None)
@@ -1788,7 +1847,7 @@ def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
result = _route_endpoint(router, "/api/models")(_route_request())
assert result["items"][0]["endpoint_name"] == "openrouter"
assert result["items"][0]["models"] == ["m1", "m3"]
assert result["items"][0]["models"] == ["m1", "m2", "m3"]
@pytest.mark.asyncio