diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index d610a3e98a..db1d15387e 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -775,7 +775,7 @@ class ChatCompletionRequest(BaseModel): ) 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. Independent of confirm_tool_calls; requires stream=true when a code-execution tool is enabled; bypass_permissions still takes precedence.", + 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. Applies to local python/terminal execution only, so it is ignored for external providers and Anthropic server tools (their code runs in the provider's sandbox, not locally). 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, diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 9cda4417d7..035b50cb82 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1788,21 +1788,6 @@ def _payload_may_enable_code_execution(payload) -> bool: return True -def _anthropic_may_run_code_execution(payload, requested_studio_tools: set) -> bool: - """True when the Anthropic ``/v1/messages`` server-tool path could run a - code-execution tool (python/terminal), computed pre-switch from the payload. - Mirrors ``_select_anthropic_server_tools``: an explicit selection - (requested server tools or ``enabled_tools``) must list python/terminal; with - no explicit selection the server path exposes every Studio tool when the tool - loop is enabled.""" - from core.inference.tools import CODE_EXECUTION_TOOL_NAMES - - selected = set(requested_studio_tools) | set(payload.enabled_tools or []) - if requested_studio_tools or payload.enabled_tools is not None: - return bool(CODE_EXECUTION_TOOL_NAMES & selected) - return bool(_effective_enable_tools(payload)) - - 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 @@ -5713,30 +5698,9 @@ async def openai_chat_completions( param = "confirm_tool_calls", ), ) - # confirm_code_execution guards only local python/terminal. An external - # provider that enables its hosted code_execution tool runs code - # server-side, which this local gate cannot intercept -- reject it rather - # than give a false approval guarantee. Scoped to actual provider code - # execution so a non-code provider request (e.g. web_search) is unaffected. - if ( - payload.confirm_code_execution - and not payload.bypass_permissions - and ( - (payload.enabled_tools is not None and "code_execution" in payload.enabled_tools) - or bool(payload.openai_code_exec_container_id) - or bool(payload.anthropic_code_exec_container_id) - ) - ): - raise HTTPException( - status_code = 400, - detail = openai_error_body( - "confirm_code_execution cannot guard provider-hosted code execution; " - "it only applies to local python/terminal tools.", - status = 400, - code = "invalid_request_error", - param = "confirm_code_execution", - ), - ) + # 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) @@ -5822,6 +5786,7 @@ async def openai_chat_completions( 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( @@ -6310,6 +6275,7 @@ async def openai_chat_completions( 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( @@ -10261,26 +10227,9 @@ async def anthropic_messages( ), ) - # confirm_code_execution guards only local python/terminal, which the server - # path runs server-side and cannot intercept. Reject it -- before the switch, - # payload-only, so an invalid request never evicts the loaded model -- but only - # when a code-execution tool is actually selected, so web-search-only requests - # are unaffected. bypass_permissions suppresses the confirm gate, so both flags - # together is fine. - if ( - bool(getattr(payload, "confirm_code_execution", False)) - and not bool(getattr(payload, "bypass_permissions", False)) - and _anthropic_may_run_code_execution(payload, requested_studio_tools) - ): - raise HTTPException( - status_code = 400, - detail = anthropic_error_body( - "confirm_code_execution cannot guard Anthropic server-side code " - "execution; it only applies to local python/terminal tools.", - status = 400, - err_type = "invalid_request_error", - ), - ) + # confirm_code_execution guards only local python/terminal; Anthropic server + # tools (incl. hosted code execution) run server-side, so the flag does not + # apply here and is ignored (documented on the field). # 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 diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index d1532eb245..a0f55d9cb1 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -1718,31 +1718,23 @@ 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 code-execution server tool (python) is what confirm_code_execution - # would guard; since the server path runs it server-side, reject it. - backend = _mock_backend(monkeypatch) - payload = _basic_payload( - confirm_code_execution = True, - tools = [{"type": "python", "name": "python"}], - ) + def test_confirm_code_execution_ignored_for_server_tools(self, monkeypatch): + # confirm_code_execution guards only local python/terminal execution. + # Anthropic server tools run in the provider's sandbox, not locally, so + # the flag does not apply here: it is ignored (never rejects), whether + # the requested server tool is code execution or not. + for tool in ( + {"type": "python", "name": "python"}, + {"type": "web_search_20250305", "name": "web_search"}, + ): + backend = _mock_backend(monkeypatch) + payload = _basic_payload( + confirm_code_execution = True, + tools = [tool], + ) - 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 cannot guard" in exc.value.detail["error"]["message"] - assert backend.calls == [] - - def test_confirm_code_execution_allows_non_code_server_tools(self, monkeypatch): - # web_search is not code execution, so the flag must not reject it. - 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" + assert backend.calls[0][0] == "tools" def test_per_request_enable_tools_false_blocks_server_tool_alias(self, monkeypatch): backend = _mock_backend(monkeypatch) diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py index 0ef14ef752..faec967c52 100644 --- a/studio/backend/tests/test_openai_tool_passthrough.py +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -462,31 +462,10 @@ 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_rejected_for_provider_tools(self, monkeypatch): - 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 - body = resp.json() - assert body["error"]["param"] == "confirm_code_execution" - assert "cannot guard provider-hosted code execution" in body["error"]["message"] - - def test_confirm_code_execution_allows_non_code_provider_tools(self, monkeypatch): - # A provider request that enables only non-code tools has no code execution - # for this flag to guard, so it must not be rejected on the flag alone. + 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} @@ -508,7 +487,7 @@ class TestChatCompletionRequestToolFields: "provider_type": "openai", "external_model": "gpt-4.1", "enable_tools": True, - "enabled_tools": ["web_search"], + "enabled_tools": ["code_execution"], "confirm_code_execution": True, }, ) @@ -1639,6 +1618,58 @@ class TestGgufVisionToolRouting: 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 "