diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index db1d15387e..9289385a0d 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. 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.", + 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, diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 035b50cb82..970c11d8c4 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -10227,9 +10227,10 @@ async def anthropic_messages( ), ) - # 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). + # 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 @@ -10431,6 +10432,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 a0f55d9cb1..cd64925494 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -1718,23 +1718,34 @@ class TestAnthropicMessagesToolRouting: assert "confirm_tool_calls is not supported" in exc.value.detail["error"]["message"] assert backend.calls == [] - 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], - ) + 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 backend.calls[0][0] == "tools" + 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)