diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index 5e6287f528..7371a2d8ea 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -8446,6 +8446,7 @@ class LlamaCppBackend:
seed: Optional[int] = None,
disable_parallel_tool_use: bool = False,
confirm_tool_calls: bool = False,
+ confirm_code_execution: bool = False,
bypass_permissions: bool = False,
) -> Generator[dict, None, None]:
"""
@@ -8456,7 +8457,11 @@ class LlamaCppBackend:
{"type": "content", "text": "token"} -- streamed content tokens (cumulative)
{"type": "reasoning", "text": "token"} -- streamed reasoning tokens (cumulative)
"""
- from core.inference.tools import build_rag_autoinject, execute_tool
+ from core.inference.tools import (
+ CODE_EXECUTION_TOOL_NAMES,
+ build_rag_autoinject,
+ execute_tool,
+ )
if not self.is_loaded:
raise RuntimeError("llama-server is not loaded")
@@ -8813,9 +8818,17 @@ class LlamaCppBackend:
in provisional_started_tool_calls.values()
)
# Later parallel cards only reconcile when parallel use is enabled.
+ # Suppress the early card whenever this call will be
+ # gated for confirmation, so a python/terminal call under
+ # confirm_code_execution never flashes "running" before the
+ # approve/deny prompt.
_confirm_gated = (
- confirm_tool_calls and not bypass_permissions
- )
+ confirm_tool_calls
+ or (
+ confirm_code_execution
+ and current_name in CODE_EXECUTION_TOOL_NAMES
+ )
+ ) and not bypass_permissions
# Keep small-argument tools on the normal path.
_args_len = len(
tool_calls_acc[idx]["function"].get("arguments", "")
@@ -9373,7 +9386,16 @@ class LlamaCppBackend:
# Bypass wins over the confirm gate at the loop level too,
# so a direct internal caller with both flags never prompts.
- needs_confirm = bool(confirm_tool_calls) and not bypass_permissions
+ # confirm_code_execution narrows the gate to python/terminal so
+ # a code-execution call pauses for approval even when
+ # confirm_tool_calls is off (search/render tools stay instant).
+ needs_confirm = (
+ bool(confirm_tool_calls)
+ or (
+ bool(confirm_code_execution)
+ and decision.tool_name in CODE_EXECUTION_TOOL_NAMES
+ )
+ ) and not bypass_permissions
approval_id = new_approval_id() if needs_confirm else ""
decision_slot = (
begin_tool_decision(session_id, approval_id) if needs_confirm else None
diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py
index 19d2230278..018d245186 100644
--- a/studio/backend/core/inference/orchestrator.py
+++ b/studio/backend/core/inference/orchestrator.py
@@ -1225,6 +1225,7 @@ class InferenceOrchestrator:
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
confirm_tool_calls: bool = False,
+ confirm_code_execution: bool = False,
bypass_permissions: bool = False,
use_adapter: Optional[Union[bool, str]] = None,
stats_holder: Optional[dict] = None,
@@ -1291,6 +1292,7 @@ class InferenceOrchestrator:
session_id = session_id,
rag_scope = rag_scope,
confirm_tool_calls = confirm_tool_calls,
+ confirm_code_execution = confirm_code_execution,
bypass_permissions = bypass_permissions,
)
diff --git a/studio/backend/core/inference/safetensors_agentic.py b/studio/backend/core/inference/safetensors_agentic.py
index 81c25b777e..003355fe4f 100644
--- a/studio/backend/core/inference/safetensors_agentic.py
+++ b/studio/backend/core/inference/safetensors_agentic.py
@@ -426,6 +426,7 @@ def run_safetensors_tool_loop(
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
confirm_tool_calls: bool = False,
+ confirm_code_execution: bool = False,
bypass_permissions: bool = False,
) -> Generator[dict, None, None]:
"""Drive an agentic tool loop on top of a cumulative-text generator.
@@ -453,7 +454,7 @@ def run_safetensors_tool_loop(
conversation = list(messages)
# Forced first-pass RAG (mirrors the GGUF loop) so doc Qs don't lose to web_search.
- from core.inference.tools import build_rag_autoinject
+ from core.inference.tools import CODE_EXECUTION_TOOL_NAMES, build_rag_autoinject
_auto = None if confirm_tool_calls else build_rag_autoinject(conversation, rag_scope)
if _auto:
@@ -1056,7 +1057,15 @@ def run_safetensors_tool_loop(
# Bypass wins over the confirm gate at the loop level too, so a
# direct internal caller passing both flags never prompts.
- needs_confirm = bool(confirm_tool_calls) and not bypass_permissions
+ # confirm_code_execution narrows the gate to python/terminal so a
+ # code-execution call pauses for approval even when confirm_tool_calls
+ # is off (search/render tools still run without a prompt).
+ needs_confirm = (
+ bool(confirm_tool_calls)
+ or (
+ bool(confirm_code_execution) and decision.tool_name in CODE_EXECUTION_TOOL_NAMES
+ )
+ ) and not bypass_permissions
approval_id = new_approval_id() if needs_confirm else ""
decision_slot = begin_tool_decision(session_id, approval_id) if needs_confirm else None
start_event = decision.tool_start_event()
diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py
index 82c50933fc..9414d54ed5 100644
--- a/studio/backend/core/inference/tools.py
+++ b/studio/backend/core/inference/tools.py
@@ -832,6 +832,12 @@ ALL_TOOLS = [
SEARCH_KNOWLEDGE_BASE_TOOL,
]
+# Built-in tools that run arbitrary model-authored code/commands (through the
+# sandbox unless bypassed). The confirmation gate can target just these
+# (confirm_code_execution) so a code-execution call pauses for approval while
+# search/render tools stay instant.
+CODE_EXECUTION_TOOL_NAMES = frozenset({"python", "terminal"})
+
# OpenAI's function.name regex ^[a-zA-Z0-9_-]{1,64}$, enforced before streaming.
# MCP tool names with '.', '/', spaces, etc. would 400 the whole request, so we
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index 0f27b695fe..9289385a0d 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -773,6 +773,10 @@ class ChatCompletionRequest(BaseModel):
None,
description = "[x-unsloth] When true, pause before each tool call and wait for the user to allow/deny it via POST /api/inference/tool-confirm.",
)
+ confirm_code_execution: Optional[bool] = Field(
+ None,
+ description = "[x-unsloth] When true, pause only before local code-execution tool calls (python/terminal) and wait for the user to allow/deny each via POST /api/inference/tool-confirm; other tools (web_search, render_html, ...) still run without a prompt. Supported on the OpenAI-compatible local endpoints (/v1/chat/completions, /v1/responses). It is ignored for external providers (their hosted code runs in the provider's sandbox, not locally); on Anthropic /v1/messages it is rejected when a local code-execution tool is selected, since that path does not wire the confirmation prompt. Independent of confirm_tool_calls; requires stream=true when a local code-execution tool is enabled; bypass_permissions still takes precedence.",
+ )
bypass_permissions: Optional[bool] = Field(
False,
description = "[x-unsloth] Bypass Permissions: when true, skip the tool-call confirmation gate AND disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits). Secret env vars are still stripped. Takes precedence over confirm_tool_calls.",
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index 5332037e0d..9f92fabf84 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -1763,6 +1763,34 @@ async def _select_request_tools(
return tools
+def _enables_code_execution_tool(tools: list[dict]) -> bool:
+ """True when a resolved tool list includes a local code-execution tool
+ (python/terminal). ``confirm_code_execution`` only gates those, so the
+ streaming requirement is scoped to requests that actually expose one."""
+ from core.inference.tools import CODE_EXECUTION_TOOL_NAMES
+ return any(
+ isinstance(t, dict)
+ and isinstance(t.get("function"), dict)
+ and t["function"].get("name") in CODE_EXECUTION_TOOL_NAMES
+ for t in (tools or [])
+ )
+
+
+def _payload_may_enable_code_execution(payload) -> bool:
+ """True when a request could resolve a local code-execution tool before the
+ tool list is built (used by the pre-switch guard). Mirrors
+ ``_select_request_tools``: built-ins are off unless the tool loop is enabled,
+ and an explicit ``enabled_tools`` filter must then list python/terminal
+ (MCP/client tools are never code execution)."""
+ from core.inference.tools import CODE_EXECUTION_TOOL_NAMES
+
+ if not _effective_enable_tools(payload):
+ return False
+ if payload.enabled_tools is not None:
+ return bool(set(payload.enabled_tools) & CODE_EXECUTION_TOOL_NAMES)
+ return True
+
+
def _apply_rag_nudge(nudge: str, tools: list[dict], *, rag_scope) -> str:
"""Append the RAG grounding nudge to ``nudge`` when the knowledge-base tool
is active (search_knowledge_base present and a retrieval scope is set). The
@@ -5673,6 +5701,9 @@ async def openai_chat_completions(
param = "confirm_tool_calls",
),
)
+ # confirm_code_execution guards only local python/terminal; an external
+ # provider runs any hosted code_execution in its own sandbox, so the flag
+ # simply does not apply here and is ignored (documented on the field).
if _wants_multiple_choices(payload):
_raise_unsupported_n("external provider chat completions")
return await _proxy_to_external_provider(payload, request, current_subject)
@@ -5749,6 +5780,28 @@ async def openai_chat_completions(
param = "confirm_tool_calls",
),
)
+ # Same pre-switch guard for confirm_code_execution, but scoped to requests
+ # that could actually run a local code-execution tool: the gate only
+ # applies to python/terminal, so a non-code tool request (e.g. web_search)
+ # must not be rejected, and a code-execution one must not evict the
+ # resident model only to 400 after the swap.
+ if (
+ payload.confirm_code_execution
+ and not payload.bypass_permissions
+ and not payload.stream
+ and payload.max_tool_calls_per_message != 0
+ and _payload_may_enable_code_execution(payload)
+ ):
+ raise HTTPException(
+ status_code = 400,
+ detail = openai_error_body(
+ "confirm_code_execution requires stream=true when a "
+ "code-execution tool (python/terminal) is enabled.",
+ status = 400,
+ code = "invalid_request_error",
+ param = "confirm_code_execution",
+ ),
+ )
# Reject a malformed tool_choice forcing object before the switch: a
# {"type": "function", "function": {}} with no name would otherwise be
# forwarded to llama-server and rejected only after the model swapped.
@@ -6221,6 +6274,23 @@ async def openai_chat_completions(
param = "confirm_tool_calls",
),
)
+ if (
+ payload.confirm_code_execution
+ and not payload.bypass_permissions
+ and not payload.stream
+ and payload.max_tool_calls_per_message != 0
+ and _enables_code_execution_tool(tools_to_use)
+ ):
+ raise _reject(
+ 400,
+ openai_error_body(
+ "confirm_code_execution requires stream=true when a "
+ "code-execution tool (python/terminal) is enabled.",
+ status = 400,
+ code = "invalid_request_error",
+ param = "confirm_code_execution",
+ ),
+ )
if _wants_multiple_choices(payload):
raise _reject_unsupported_n("GGUF tool chat completions")
# ── Tool-use system prompt nudge ──────────────────────
@@ -6289,6 +6359,8 @@ async def openai_chat_completions(
# never prompt while bypassing.
confirm_tool_calls = bool(payload.confirm_tool_calls)
and not bool(payload.bypass_permissions),
+ confirm_code_execution = bool(payload.confirm_code_execution)
+ and not bool(payload.bypass_permissions),
bypass_permissions = bool(payload.bypass_permissions),
)
@@ -6945,6 +7017,22 @@ async def openai_chat_completions(
param = "confirm_tool_calls",
),
)
+ if (
+ payload.confirm_code_execution
+ and not payload.bypass_permissions
+ and not payload.stream
+ and _enables_code_execution_tool(_sf_tools_to_use)
+ ):
+ raise _reject(
+ 400,
+ openai_error_body(
+ "confirm_code_execution requires stream=true when a "
+ "code-execution tool (python/terminal) is enabled.",
+ status = 400,
+ code = "invalid_request_error",
+ param = "confirm_code_execution",
+ ),
+ )
_sf_nudge = _build_tool_action_nudge(
tools = _sf_tools_to_use,
model_name = model_name,
@@ -7014,6 +7102,8 @@ async def openai_chat_completions(
# never prompt while bypassing.
confirm_tool_calls = bool(payload.confirm_tool_calls)
and not bool(payload.bypass_permissions),
+ confirm_code_execution = bool(payload.confirm_code_execution)
+ and not bool(payload.bypass_permissions),
bypass_permissions = bool(payload.bypass_permissions),
use_adapter = payload.use_adapter,
stats_holder = _sf_stats_holder,
@@ -10140,6 +10230,11 @@ async def anthropic_messages(
),
)
+ # confirm_code_execution is handled below inside the server-tool branch (it is
+ # rejected when a local python/terminal tool is actually selected, mirroring
+ # confirm_tool_calls), so nothing to do pre-switch here: a non-code request is
+ # unaffected and a disabled request never enters that branch.
+
# require_vision rejects a swap to a text-only target before it runs, so an
# image request can't evict the resident vision model only to hit the vision
# guard (_normalize_anthropic_openai_images) below after the load.
@@ -10340,6 +10435,36 @@ async def anthropic_messages(
payload.enabled_tools,
)
+ # confirm_code_execution guards local python/terminal execution. On this
+ # path a Studio tool alias like {"type":"python"} maps to the local tool
+ # loop and runs code on this host -- but the Anthropic Messages SSE
+ # translation does not wire the confirmation prompt (which is why
+ # confirm_tool_calls is rejected above). Silently ignoring the flag would
+ # run python/terminal without the promised prompt, so reject it when a
+ # local code-execution tool is actually selected. A non-code selection
+ # (e.g. web_search) is unaffected, and bypass_permissions suppresses the
+ # gate. Gated on server_tools above, so a disabled request never reaches
+ # here.
+ if (
+ bool(getattr(payload, "confirm_code_execution", False))
+ and not bool(getattr(payload, "bypass_permissions", False))
+ and _enables_code_execution_tool(openai_tools)
+ ):
+ api_monitor.fail(
+ monitor_id,
+ "confirm_code_execution is not supported for Anthropic Messages server tools.",
+ )
+ raise HTTPException(
+ status_code = 400,
+ detail = anthropic_error_body(
+ "confirm_code_execution is not supported for Anthropic Messages "
+ "server tools; it only guards local python/terminal execution on "
+ "the OpenAI-compatible endpoints (/v1/chat/completions).",
+ status = 400,
+ err_type = "invalid_request_error",
+ ),
+ )
+
# Build tool-use system prompt nudge (same logic as /chat/completions)
_nudge = _build_tool_action_nudge(
tools = openai_tools,
diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py
index 170b456eac..cd64925494 100644
--- a/studio/backend/tests/test_anthropic_messages.py
+++ b/studio/backend/tests/test_anthropic_messages.py
@@ -1718,6 +1718,35 @@ class TestAnthropicMessagesToolRouting:
assert "confirm_tool_calls is not supported" in exc.value.detail["error"]["message"]
assert backend.calls == []
+ def test_confirm_code_execution_rejected_for_code_server_tools(self, monkeypatch):
+ # A Studio {"type":"python"} alias runs the local python executor via the
+ # tool loop, but this path does not wire the confirmation prompt (like
+ # confirm_tool_calls above). Ignoring the flag would run code without the
+ # prompt, so it is rejected when a local code-execution tool is selected.
+ backend = _mock_backend(monkeypatch)
+ payload = _basic_payload(
+ confirm_code_execution = True,
+ tools = [{"type": "python", "name": "python"}],
+ )
+
+ with pytest.raises(HTTPException) as exc:
+ _drive(anthropic_messages(payload, request = None, current_subject = "t"))
+ assert exc.value.status_code == 400
+ assert "confirm_code_execution is not supported" in exc.value.detail["error"]["message"]
+ assert backend.calls == []
+
+ def test_confirm_code_execution_ignored_for_non_code_server_tools(self, monkeypatch):
+ # web_search is not code execution, so the flag does not apply and the
+ # request proceeds normally rather than being rejected.
+ backend = _mock_backend(monkeypatch)
+ payload = _basic_payload(
+ confirm_code_execution = True,
+ tools = [{"type": "web_search_20250305", "name": "web_search"}],
+ )
+
+ _drive(anthropic_messages(payload, request = None, current_subject = "t"))
+ assert backend.calls[0][0] == "tools"
+
def test_per_request_enable_tools_false_blocks_server_tool_alias(self, monkeypatch):
backend = _mock_backend(monkeypatch)
payload = _basic_payload(
diff --git a/studio/backend/tests/test_confirm_code_execution.py b/studio/backend/tests/test_confirm_code_execution.py
new file mode 100644
index 0000000000..f6776e53ab
--- /dev/null
+++ b/studio/backend/tests/test_confirm_code_execution.py
@@ -0,0 +1,234 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Tests for ``confirm_code_execution``: a narrower confirmation gate that
+pauses only before local code-execution tools (python/terminal) while other
+tools (web_search, render_html, ...) still run without a prompt.
+
+These drive the real ``run_safetensors_tool_loop`` with hand-crafted fake
+generators (no model), mirroring ``test_tool_confirm_loop.py``, and cover the
+scoping predicate the route layer uses to require streaming.
+"""
+
+import pytest
+
+from core.inference.safetensors_agentic import run_safetensors_tool_loop
+from state import tool_approvals
+from state.tool_approvals import resolve_tool_decision
+
+_SESSION = "code-exec-session"
+
+_TOOLS = [
+ {"type": "function", "function": {"name": "python"}},
+ {"type": "function", "function": {"name": "terminal"}},
+ {"type": "function", "function": {"name": "web_search"}},
+]
+
+
+@pytest.fixture(autouse = True)
+def _clear_pending():
+ with tool_approvals._lock:
+ tool_approvals._pending.clear()
+ yield
+ with tool_approvals._lock:
+ tool_approvals._pending.clear()
+
+
+class _FakeExecuteTool:
+ def __init__(self):
+ self.calls = []
+
+ def __call__(
+ self,
+ name,
+ arguments,
+ *,
+ cancel_event = None,
+ timeout = None,
+ session_id = None,
+ rag_scope = None,
+ disable_sandbox = False,
+ ):
+ self.calls.append((name, arguments))
+ return f"RESULT[{name}]"
+
+
+def _tool_call(name, args_json):
+ return f'{{"name": "{name}", "arguments": {args_json}}}'
+
+
+def _multi_turn(turns):
+ turn_iter = iter(turns)
+
+ def _gen(_messages):
+ try:
+ yield next(turn_iter)
+ except StopIteration:
+ return
+
+ return _gen
+
+
+def _drive(turns, decisions, **loop_kwargs):
+ """Run the loop, resolving each gated tool_start with the next decision.
+
+ Non-gated calls (awaiting_confirmation False) execute without consuming a
+ decision. Returns (events, execute_calls).
+ """
+ decision_iter = iter(decisions)
+ exec_fn = _FakeExecuteTool()
+ gen = run_safetensors_tool_loop(
+ single_turn = _multi_turn(turns),
+ messages = [{"role": "user", "content": "hi"}],
+ tools = _TOOLS,
+ execute_tool = exec_fn,
+ session_id = _SESSION,
+ **loop_kwargs,
+ )
+ events = []
+ for ev in gen:
+ events.append(ev)
+ if ev["type"] == "tool_start" and ev.get("awaiting_confirmation"):
+ resolve_tool_decision(ev["approval_id"], next(decision_iter), session_id = _SESSION)
+ return events, exec_fn.calls
+
+
+def _starts(events):
+ return [e for e in events if e["type"] == "tool_start"]
+
+
+def _ends(events):
+ return [e for e in events if e["type"] == "tool_end"]
+
+
+# ── confirm_code_execution gates only python/terminal ────────────────────────
+
+
+def test_python_call_is_gated_and_executes_on_allow():
+ events, calls = _drive(
+ [_tool_call("python", '{"code": "print(1)"}'), "done"],
+ ["allow"],
+ confirm_code_execution = True,
+ )
+ starts = _starts(events)
+ assert len(starts) == 1
+ assert starts[0]["awaiting_confirmation"] is True
+ assert starts[0]["approval_id"]
+ assert calls == [("python", {"code": "print(1)"})]
+ assert _ends(events)[0]["result"] == "RESULT[python]"
+
+
+def test_terminal_call_is_gated_and_skipped_on_deny():
+ events, calls = _drive(
+ [_tool_call("terminal", '{"command": "ls"}'), "done"],
+ ["deny"],
+ confirm_code_execution = True,
+ )
+ starts = _starts(events)
+ assert len(starts) == 1
+ assert starts[0]["awaiting_confirmation"] is True
+ # Denied: the tool never runs.
+ assert calls == []
+
+
+def test_web_search_is_not_gated_by_confirm_code_execution():
+ # No decision is supplied: a gated call would block waiting for one.
+ events, calls = _drive(
+ [_tool_call("web_search", '{"query": "cats"}'), "done"],
+ [],
+ confirm_code_execution = True,
+ )
+ starts = _starts(events)
+ assert len(starts) == 1
+ assert starts[0]["awaiting_confirmation"] is False
+ assert not starts[0]["approval_id"]
+ assert calls == [("web_search", {"query": "cats"})]
+
+
+def test_bypass_permissions_overrides_confirm_code_execution():
+ events, calls = _drive(
+ [_tool_call("python", '{"code": "print(1)"}'), "done"],
+ [],
+ confirm_code_execution = True,
+ bypass_permissions = True,
+ )
+ starts = _starts(events)
+ assert starts[0]["awaiting_confirmation"] is False
+ assert calls == [("python", {"code": "print(1)"})]
+
+
+def test_default_off_does_not_gate_code_execution():
+ events, calls = _drive(
+ [_tool_call("python", '{"code": "print(1)"}'), "done"],
+ [],
+ # Neither flag set: unchanged legacy behavior, python runs immediately.
+ )
+ starts = _starts(events)
+ assert starts[0]["awaiting_confirmation"] is False
+ assert calls == [("python", {"code": "print(1)"})]
+
+
+def test_confirm_tool_calls_still_gates_every_tool():
+ # confirm_tool_calls is the broad gate; web_search is prompted under it even
+ # though confirm_code_execution would not touch it.
+ events, calls = _drive(
+ [_tool_call("web_search", '{"query": "cats"}'), "done"],
+ ["allow"],
+ confirm_tool_calls = True,
+ )
+ starts = _starts(events)
+ assert starts[0]["awaiting_confirmation"] is True
+ assert calls == [("web_search", {"query": "cats"})]
+
+
+# ── scoping predicates used by the route streaming requirement ───────────────
+
+
+def _spec(name):
+ return {"type": "function", "function": {"name": name}}
+
+
+def test_enables_code_execution_tool_predicate():
+ from routes.inference import _enables_code_execution_tool
+
+ assert _enables_code_execution_tool([_spec("python")]) is True
+ assert _enables_code_execution_tool([_spec("terminal")]) is True
+ assert _enables_code_execution_tool([_spec("web_search"), _spec("python")]) is True
+ # A non-code tool list must not trip the streaming requirement.
+ assert _enables_code_execution_tool([_spec("web_search"), _spec("render_html")]) is False
+ assert _enables_code_execution_tool([]) is False
+ assert _enables_code_execution_tool(None) is False
+
+
+def test_payload_may_enable_code_execution_predicate(monkeypatch):
+ import routes.inference as inf
+
+ monkeypatch.setattr("state.tool_policy.get_tool_policy", lambda: None)
+
+ class _P:
+ def __init__(
+ self,
+ enable_tools = None,
+ enabled_tools = None,
+ ):
+ self.enable_tools = enable_tools
+ self.enabled_tools = enabled_tools
+
+ # Built-ins off -> never code execution, even if enabled_tools lists python.
+ assert inf._payload_may_enable_code_execution(_P(enable_tools = None)) is False
+ assert (
+ inf._payload_may_enable_code_execution(_P(enable_tools = False, enabled_tools = ["python"]))
+ is False
+ )
+ # Built-ins on, explicit filter without a code tool -> not code execution (the fix).
+ assert (
+ inf._payload_may_enable_code_execution(_P(enable_tools = True, enabled_tools = ["web_search"]))
+ is False
+ )
+ # Built-ins on, code tool in the filter -> code execution.
+ assert (
+ inf._payload_may_enable_code_execution(_P(enable_tools = True, enabled_tools = ["terminal"]))
+ is True
+ )
+ # Built-ins on, no filter -> all built-ins including python/terminal.
+ assert inf._payload_may_enable_code_execution(_P(enable_tools = True)) is True
diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py
index ccbd78e2b1..faec967c52 100644
--- a/studio/backend/tests/test_openai_tool_passthrough.py
+++ b/studio/backend/tests/test_openai_tool_passthrough.py
@@ -462,6 +462,39 @@ class TestChatCompletionRequestToolFields:
assert body["error"]["param"] == "confirm_tool_calls"
assert "only supported for local streaming tools" in body["error"]["message"]
+ def test_confirm_code_execution_ignored_for_provider_tools(self, monkeypatch):
+ # confirm_code_execution guards only local python/terminal. An external
+ # provider runs any hosted code_execution in its own sandbox, so the flag
+ # is ignored (not rejected) -- even when provider code_execution is enabled.
+ import routes.inference as inference_route
+
+ called = {"proxied": False}
+
+ async def _fake_proxy(payload, request, current_subject):
+ called["proxied"] = True
+ return {"ok": True}
+
+ monkeypatch.setattr(inference_route, "_proxy_to_external_provider", _fake_proxy)
+
+ class _UnusedBackend:
+ is_loaded = False
+
+ client = self._v1_client(monkeypatch, _UnusedBackend())
+ resp = client.post(
+ "/v1/chat/completions",
+ json = {
+ "messages": [{"role": "user", "content": "hi"}],
+ "provider_type": "openai",
+ "external_model": "gpt-4.1",
+ "enable_tools": True,
+ "enabled_tools": ["code_execution"],
+ "confirm_code_execution": True,
+ },
+ )
+
+ assert resp.status_code != 400
+ assert called["proxied"] is True
+
def test_logprobs_rejected_until_supported(self, monkeypatch):
class _UnusedBackend:
is_loaded = False
@@ -612,6 +645,51 @@ class TestChatCompletionRequestToolFields:
assert "confirm_tool_calls requires stream=true" in entry["error"]
assert monitor.active_count() == 0
+ def test_confirm_code_execution_requires_streaming_for_safetensors_tools(self, monkeypatch):
+ import routes.inference as inference_route
+
+ class _NoGGUFBackend:
+ is_loaded = False
+ supports_tools = False
+
+ class _InferenceBackend:
+ active_model_name = "test-model"
+ models = {"test-model": {"chat_template_info": {"template": "chatml"}}}
+
+ def generate_chat_completion_with_tools(self, **kwargs):
+ raise AssertionError("tool loop should be rejected before starting")
+
+ def generate_chat_completion(self, **kwargs):
+ raise AssertionError("plain path should not be used")
+
+ monkeypatch.setattr(
+ inference_route,
+ "_detect_safetensors_features",
+ lambda backend, chat_template: {"supports_tools": True},
+ )
+ monitor = ApiMonitor(max_entries = 3)
+ monkeypatch.setattr(inference_route, "api_monitor", monitor)
+ client = self._v1_client(monkeypatch, _NoGGUFBackend(), _InferenceBackend())
+ resp = client.post(
+ "/v1/chat/completions",
+ json = {
+ "messages": [{"role": "user", "content": "run code"}],
+ "enable_tools": True,
+ "enabled_tools": ["python"],
+ "confirm_code_execution": True,
+ "stream": False,
+ },
+ )
+
+ assert resp.status_code == 400
+ body = resp.json()
+ assert body["error"]["param"] == "confirm_code_execution"
+ assert "requires stream=true" in body["error"]["message"]
+ [entry] = monitor.snapshot()
+ assert entry["status"] == "error"
+ assert "confirm_code_execution requires stream=true" in entry["error"]
+ assert monitor.active_count() == 0
+
def test_multiturn_tool_loop_messages(self):
req = ChatCompletionRequest(
messages = [
@@ -1493,6 +1571,105 @@ class TestGgufVisionToolRouting:
assert "confirm_tool_calls requires stream=true" in entry["error"]
assert monitor.active_count() == 0
+ def test_confirm_code_execution_requires_streaming_for_gguf_tools(self, monkeypatch):
+ import routes.inference as inf_mod
+
+ def _plain(**kwargs):
+ raise AssertionError("plain GGUF path should not be used")
+
+ def _tools(**kwargs):
+ raise AssertionError("tool loop should be rejected before starting")
+
+ backend = SimpleNamespace(
+ is_loaded = True,
+ is_vision = False,
+ supports_tools = True,
+ model_identifier = "test-gguf",
+ context_length = 4096,
+ generate_chat_completion = _plain,
+ generate_chat_completion_with_tools = _tools,
+ )
+ monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend)
+ monitor = ApiMonitor(max_entries = 3)
+ monkeypatch.setattr(inf_mod, "api_monitor", monitor)
+
+ payload = ChatCompletionRequest(
+ model = "default",
+ enable_tools = True,
+ enabled_tools = ["python"],
+ confirm_code_execution = True,
+ stream = False,
+ messages = [{"role": "user", "content": "run code"}],
+ )
+
+ with pytest.raises(HTTPException) as exc:
+ self._drive(
+ openai_chat_completions(
+ payload,
+ request = self._Request(),
+ current_subject = "test",
+ )
+ )
+ assert exc.value.status_code == 400
+ assert exc.value.detail["error"]["param"] == "confirm_code_execution"
+ assert "requires stream=true" in exc.value.detail["error"]["message"]
+ [entry] = monitor.snapshot()
+ assert entry["status"] == "error"
+ assert "confirm_code_execution requires stream=true" in entry["error"]
+ assert monitor.active_count() == 0
+
+ def test_confirm_code_execution_budget_zero_not_rejected_gguf(self, monkeypatch):
+ # max_tool_calls_per_message=0 disables tool execution (max_tool_iterations=0),
+ # so no code-execution tool can run and the stream requirement must not reject.
+ import routes.inference as inf_mod
+
+ reached = {"tools": False}
+
+ def _plain(**kwargs):
+ raise AssertionError("plain GGUF path should not be used")
+
+ def _tools(**kwargs):
+ # Reaching here proves the request cleared both the pre-switch and the
+ # per-handler confirm_code_execution guards instead of being rejected.
+ reached["tools"] = True
+ raise RuntimeError("reached tool backend")
+
+ backend = SimpleNamespace(
+ is_loaded = True,
+ is_vision = False,
+ supports_tools = True,
+ model_identifier = "test-gguf",
+ context_length = 4096,
+ generate_chat_completion = _plain,
+ generate_chat_completion_with_tools = _tools,
+ _maybe_recover_from_mtp_crash = lambda e: None,
+ )
+ monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend)
+ monkeypatch.setattr(inf_mod, "api_monitor", ApiMonitor(max_entries = 3))
+
+ payload = ChatCompletionRequest(
+ model = "default",
+ enable_tools = True,
+ enabled_tools = ["python"],
+ confirm_code_execution = True,
+ stream = False,
+ max_tool_calls_per_message = 0,
+ messages = [{"role": "user", "content": "run code"}],
+ )
+
+ with pytest.raises(HTTPException) as exc:
+ self._drive(
+ openai_chat_completions(
+ payload,
+ request = self._Request(),
+ current_subject = "test",
+ )
+ )
+ # Reaching the tool backend (and not a confirm_code_execution 400) proves the
+ # budget-zero request was not rejected by the stream requirement.
+ assert reached["tools"] is True
+ assert exc.value.status_code != 400
+
def test_standard_gguf_stream_splits_reasoning_content(self, monkeypatch):
def _generate(**_kwargs):
yield "