diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 8eae0f7f3e..9cda4417d7 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1788,6 +1788,21 @@ 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 @@ -5698,16 +5713,16 @@ async def openai_chat_completions( param = "confirm_tool_calls", ), ) - # Same for confirm_code_execution: an external provider runs code_execution - # server-side, so this local confirm gate cannot intercept it -- reject it - # rather than give the caller a false approval guarantee. + # 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.enable_tools is True - or bool(payload.enabled_tools) - or bool(payload.tools) + (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) ) @@ -5715,7 +5730,8 @@ async def openai_chat_completions( raise HTTPException( status_code = 400, detail = openai_error_body( - "confirm_code_execution is only supported for local streaming tools.", + "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", @@ -10245,6 +10261,27 @@ 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", + ), + ) + # 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. @@ -10437,21 +10474,6 @@ async def anthropic_messages( err_type = "invalid_request_error", ), ) - if bool(getattr(payload, "confirm_code_execution", False)) and not bool( - getattr(payload, "bypass_permissions", False) - ): - 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.", - status = 400, - err_type = "invalid_request_error", - ), - ) from core.inference.tools import ALL_TOOLS openai_tools = _select_anthropic_server_tools( diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 1c265f7890..d1532eb245 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -1718,18 +1718,31 @@ 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_server_tools(self, monkeypatch): + 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"}], + ) + + 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"}], ) - 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 == [] + _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) diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py index 7ca86984f4..0ef14ef752 100644 --- a/studio/backend/tests/test_openai_tool_passthrough.py +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -482,7 +482,39 @@ class TestChatCompletionRequestToolFields: assert resp.status_code == 400 body = resp.json() assert body["error"]["param"] == "confirm_code_execution" - assert "only supported for local streaming tools" in body["error"]["message"] + 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. + 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": ["web_search"], + "confirm_code_execution": True, + }, + ) + + assert resp.status_code != 400 + assert called["proxied"] is True def test_logprobs_rejected_until_supported(self, monkeypatch): class _UnusedBackend: