diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 091edd768f..9487b11136 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1763,16 +1763,6 @@ async def _select_request_tools( return tools -def _enables_code_execution_tool(tools: list[dict]) -> bool: - """True when the resolved tool list includes a local code-execution tool - (python/terminal). Used to scope the ``confirm_code_execution`` stream - requirement so a request that never exposes code execution isn't rejected.""" - from core.inference.tools import CODE_EXECUTION_TOOL_NAMES - return any( - (t.get("function") or {}).get("name") in CODE_EXECUTION_TOOL_NAMES for t in (tools or []) - ) - - 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 @@ -5683,6 +5673,29 @@ 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. + 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) + 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 is only supported for local streaming tools.", + status = 400, + code = "invalid_request_error", + param = "confirm_code_execution", + ), + ) if _wants_multiple_choices(payload): _raise_unsupported_n("external provider chat completions") return await _proxy_to_external_provider(payload, request, current_subject) @@ -5759,6 +5772,31 @@ async def openai_chat_completions( param = "confirm_tool_calls", ), ) + # Same pre-switch guard for confirm_code_execution: the code-execution + # confirm gate also needs streaming, so a non-stream request 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 ( + _effective_enable_tools(payload) + or (bool(payload.mcp_enabled) and _confirm_cli_policy is not False) + or bool(payload.enabled_tools) + or bool(payload.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 requires stream=true for local tool execution.", + 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. @@ -6235,13 +6273,11 @@ async def openai_chat_completions( payload.confirm_code_execution and not payload.bypass_permissions and not payload.stream - 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.", + "confirm_code_execution requires stream=true for local tool execution.", status = 400, code = "invalid_request_error", param = "confirm_code_execution", @@ -6977,13 +7013,11 @@ async def openai_chat_completions( 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.", + "confirm_code_execution requires stream=true for local tool execution.", status = 400, code = "invalid_request_error", param = "confirm_code_execution", diff --git a/studio/backend/tests/test_confirm_code_execution.py b/studio/backend/tests/test_confirm_code_execution.py index ac8cfc1012..e6d41e5a25 100644 --- a/studio/backend/tests/test_confirm_code_execution.py +++ b/studio/backend/tests/test_confirm_code_execution.py @@ -179,21 +179,3 @@ def test_confirm_tool_calls_still_gates_every_tool(): starts = _starts(events) assert starts[0]["awaiting_confirmation"] is True assert calls == [("web_search", {"query": "cats"})] - - -# ── scoping predicate used by the route stream 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 - 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 diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py index 07368976a0..7ca86984f4 100644 --- a/studio/backend/tests/test_openai_tool_passthrough.py +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -462,6 +462,28 @@ 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 "only supported for local streaming tools" in body["error"]["message"] + def test_logprobs_rejected_until_supported(self, monkeypatch): class _UnusedBackend: is_loaded = False