diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 07574abf4c..79c8287e5b 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -237,6 +237,7 @@ class ExternalProviderClient: reasoning_effort: Optional[str] = None, enabled_tools: Optional[list[str]] = None, enable_prompt_caching: Optional[bool] = None, + openai_code_exec_container_id: Optional[str] = None, stream: bool = True, ) -> AsyncGenerator[str, None]: """ @@ -282,6 +283,7 @@ class ExternalProviderClient: reasoning_effort, enabled_tools, enable_prompt_caching, + openai_code_exec_container_id, ): yield line return @@ -1285,6 +1287,33 @@ class ExternalProviderClient: ) body["tools"] = anthropic_tools + # Anthropic server-side code execution — see + # https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool + # `code_execution_20250825` runs Python + bash + str_replace + # file edits inside a 5 GB sandboxed container per request, with + # no internet access. The tool entry itself takes no extra + # parameters; on the SSE stream Anthropic emits two sub-tool + # names — `bash_code_execution` and + # `text_editor_code_execution` — wrapped in the standard + # server_tool_use / *_tool_result block shape. The matching + # beta header (`code-execution-2025-08-25`) is set further down + # in this function alongside the request headers. + # v1 wires the tool only; file uploads (container_upload + # content blocks and generated-file retrieval via the Files + # API) are a deliberate follow-up. + code_execution_enabled = bool( + enabled_tools and "code_execution" in enabled_tools + ) + if code_execution_enabled: + anthropic_tools = list(body.get("tools") or []) + anthropic_tools.append( + { + "type": "code_execution_20250825", + "name": "code_execution", + } + ) + body["tools"] = anthropic_tools + url = f"{self.base_url}/messages" completion_id = f"chatcmpl-anthropic-{model.replace('/', '-')}" @@ -1314,12 +1343,29 @@ class ExternalProviderClient: logger.info("Proxying Anthropic Messages API to %s (model=%s)", url, model) + request_headers = self._auth_headers() + if code_execution_enabled: + # Anthropic accepts comma-separated beta features in a single + # `anthropic-beta` header. Merge our flag onto whatever the + # registry's extra_headers contributed (currently nothing on + # the beta axis, just anthropic-version) so future betas + # added at the registry level keep working. + existing_beta = request_headers.get("anthropic-beta", "").strip() + beta_parts = ( + [p.strip() for p in existing_beta.split(",") if p.strip()] + if existing_beta + else [] + ) + if "code-execution-2025-08-25" not in beta_parts: + beta_parts.append("code-execution-2025-08-25") + request_headers["anthropic-beta"] = ",".join(beta_parts) + try: async with _http_client.stream( "POST", url, json = body, - headers = self._auth_headers(), + headers = request_headers, timeout = self._stream_timeout, ) as response: if response.status_code != 200: @@ -1353,6 +1399,28 @@ class ExternalProviderClient: current_server_tool_use: Optional[dict[str, Any]] = None current_result_block: Optional[dict[str, Any]] = None web_search_calls: dict[str, dict[str, Any]] = {} + # code_execution state. Anthropic's + # `code_execution_20250825` tool emits the same + # server_tool_use → *_tool_result block shape as + # web_search, but the server_tool_use carries one of + # two sub-tool names (`bash_code_execution` or + # `text_editor_code_execution`) and the result block + # type matches (`bash_code_execution_tool_result` / + # `text_editor_code_execution_tool_result`). Kept + # parallel to web_search state so the two paths don't + # collide when both pills are on in the same turn. + current_code_exec_use: Optional[dict[str, Any]] = None + current_code_exec_result: Optional[dict[str, Any]] = None + code_execution_calls: dict[str, dict[str, Any]] = {} + # Counts surfaced in the final log line so reports of + # "Code execution did nothing" can be triaged at a + # glance. generated_files_count is interesting for the + # future Files API PR — when bash creates files inside + # the container, they show up as file_id entries on + # bash_code_execution_result.content, and v1 drops + # them. Track the count so we know how often it would + # have mattered. + code_execution_generated_files = 0 # Cache usage tracking. message_start carries the input # accounting (incl. cache_creation_input_tokens and # cache_read_input_tokens); message_delta carries cumulative @@ -1406,6 +1474,48 @@ class ExternalProviderClient: blocks.append(f"Title: {title}\nURL: {url}") return "\n---\n".join(blocks) + def _format_code_execution_result( + inner: dict[str, Any], + ) -> str: + """Render an Anthropic code-execution result block as + the preformatted text payload the frontend's + CodeExecutionToolUI displays inside a
. Handles
+                    bash, text_editor (view/create/str_replace), and the
+                    matching error variants.
+                    """
+                    inner_type = inner.get("type") or ""
+                    if inner_type.endswith("_error"):
+                        return f"Error: {inner.get('error_code', 'unknown')}"
+                    if inner_type == "bash_code_execution_result":
+                        stdout = inner.get("stdout") or ""
+                        stderr = inner.get("stderr") or ""
+                        return_code = inner.get("return_code")
+                        parts: list[str] = []
+                        if stdout:
+                            parts.append(stdout)
+                        if stderr:
+                            parts.append(f"--- stderr ---\n{stderr}")
+                        if isinstance(return_code, int) and return_code != 0:
+                            parts.append(f"return_code: {return_code}")
+                        return "\n".join(parts) if parts else "(no output)"
+                    if inner_type == "text_editor_code_execution_result":
+                        # view: file content; create: is_file_update flag;
+                        # str_replace: diff `lines` list. The matching
+                        # server_tool_use carries the command + path, but
+                        # that's encoded into the tool_start arguments
+                        # already — here we only format the result body.
+                        if "lines" in inner and isinstance(inner.get("lines"), list):
+                            return "\n".join(str(line) for line in inner["lines"])
+                        if "is_file_update" in inner:
+                            return (
+                                "Updated" if inner.get("is_file_update") else "Created"
+                            )
+                        content_field = inner.get("content")
+                        if isinstance(content_field, str):
+                            return content_field
+                        return "(file operation complete)"
+                    return "(code execution complete)"
+
                 try:
                     while True:
                         try:
@@ -1447,9 +1557,10 @@ class ExternalProviderClient:
                         if event_type == "content_block_start":
                             content_block = event.get("content_block") or {}
                             block_type = content_block.get("type")
+                            block_name = content_block.get("name")
                             if (
                                 block_type == "server_tool_use"
-                                and content_block.get("name") == "web_search"
+                                and block_name == "web_search"
                             ):
                                 tool_use_id = content_block.get("id", "") or (
                                     f"ws_{len(web_search_calls)}"
@@ -1475,6 +1586,44 @@ class ExternalProviderClient:
                                     if isinstance(content, list)
                                     else [],
                                 }
+                            elif block_type == "server_tool_use" and block_name in (
+                                "bash_code_execution",
+                                "text_editor_code_execution",
+                            ):
+                                tool_use_id = content_block.get("id", "") or (
+                                    f"ce_{len(code_execution_calls)}"
+                                )
+                                kind = (
+                                    "bash"
+                                    if block_name == "bash_code_execution"
+                                    else "text_editor"
+                                )
+                                current_code_exec_use = {
+                                    "id": tool_use_id,
+                                    "kind": kind,
+                                    "buffer": "",
+                                }
+                                code_execution_calls[tool_use_id] = {
+                                    "kind": kind,
+                                    "arguments": {},
+                                    "result": None,
+                                }
+                            elif block_type in (
+                                "bash_code_execution_tool_result",
+                                "text_editor_code_execution_tool_result",
+                            ):
+                                # Anthropic ships the full result content
+                                # on the start event for code-exec result
+                                # blocks (unlike web_search, which can
+                                # split across deltas). Capture it and
+                                # finalize on content_block_stop so the
+                                # ordering matches the web_search path.
+                                tool_use_id = content_block.get("tool_use_id", "")
+                                inner = content_block.get("content") or {}
+                                current_code_exec_result = {
+                                    "tool_use_id": tool_use_id,
+                                    "inner": inner if isinstance(inner, dict) else {},
+                                }
 
                         elif event_type == "content_block_delta":
                             delta = event.get("delta", {})
@@ -1508,15 +1657,20 @@ class ExternalProviderClient:
                                 # per-call by Anthropic via the
                                 # `web_search_tool_result` block; we don't
                                 # need to scrape them off the text events.
-                            elif (
-                                delta_type == "input_json_delta"
-                                and current_server_tool_use is not None
-                            ):
-                                # Streamed partial_json carrying the search
-                                # query. Buffer until content_block_stop.
-                                current_server_tool_use["buffer"] += delta.get(
-                                    "partial_json", ""
-                                )
+                            elif delta_type == "input_json_delta":
+                                # Streamed partial_json carrying tool inputs
+                                # — the search query for web_search, or the
+                                # command/path/etc. for code execution.
+                                # Route to whichever buffer is open. The two
+                                # state slots are exclusive in practice
+                                # (Anthropic doesn't interleave tool input
+                                # streams), but checking both keeps the
+                                # dispatch robust if that ever changes.
+                                partial = delta.get("partial_json", "")
+                                if current_server_tool_use is not None:
+                                    current_server_tool_use["buffer"] += partial
+                                elif current_code_exec_use is not None:
+                                    current_code_exec_use["buffer"] += partial
                             # signature_delta and any other delta types are
                             # intentionally skipped — they carry trust /
                             # verification metadata, not user-visible content.
@@ -1572,6 +1726,68 @@ class ExternalProviderClient:
                                     }
                                 )
                                 current_result_block = None
+                            elif current_code_exec_use is not None:
+                                # End of a code-execution server_tool_use —
+                                # parse the buffered input_json into a
+                                # {command, path, ...} dict and emit
+                                # tool_start. The matching tool_end fires
+                                # on the result block's content_block_stop.
+                                buffer = current_code_exec_use["buffer"]
+                                parsed_args: dict[str, Any] = {}
+                                if buffer:
+                                    try:
+                                        parsed_obj = _json.loads(buffer)
+                                        if isinstance(parsed_obj, dict):
+                                            parsed_args = parsed_obj
+                                    except Exception:
+                                        parsed_args = {}
+                                tool_use_id = current_code_exec_use["id"]
+                                kind = current_code_exec_use["kind"]
+                                emit_args = {"kind": kind, **parsed_args}
+                                if tool_use_id in code_execution_calls:
+                                    code_execution_calls[tool_use_id]["arguments"] = (
+                                        emit_args
+                                    )
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_start",
+                                        "tool_name": "code_execution",
+                                        "tool_call_id": tool_use_id,
+                                        "arguments": emit_args,
+                                    }
+                                )
+                                current_code_exec_use = None
+                            elif current_code_exec_result is not None:
+                                # End of a code-execution result block —
+                                # format the inner result into the text
+                                # payload CodeExecutionToolUI renders.
+                                tool_use_id = current_code_exec_result["tool_use_id"]
+                                inner = current_code_exec_result["inner"]
+                                # Track generated-file count for the
+                                # follow-up Files API PR. v1 drops them.
+                                if isinstance(inner, dict):
+                                    file_blocks = inner.get("content")
+                                    if isinstance(file_blocks, list):
+                                        for entry in file_blocks:
+                                            if isinstance(entry, dict) and entry.get(
+                                                "file_id"
+                                            ):
+                                                code_execution_generated_files += 1
+                                result_text = _format_code_execution_result(
+                                    inner if isinstance(inner, dict) else {}
+                                )
+                                if tool_use_id in code_execution_calls:
+                                    code_execution_calls[tool_use_id]["result"] = (
+                                        result_text
+                                    )
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_end",
+                                        "tool_call_id": tool_use_id,
+                                        "result": result_text,
+                                    }
+                                )
+                                current_code_exec_result = None
                             elif thinking_open:
                                 # Close the  tag when the thinking block
                                 # ends, in case no text_delta follows (e.g.
@@ -1639,10 +1855,20 @@ class ExternalProviderClient:
                     # instead. cache_creation tokens are billed at a
                     # small premium; cache_read tokens are billed at a
                     # discount.
+                    code_execution_invocations = len(code_execution_calls)
+                    code_execution_results = sum(
+                        1
+                        for c in code_execution_calls.values()
+                        if c.get("result") is not None
+                    )
                     logger.info(
                         "Anthropic stream complete (model=%s, "
                         "web_search_requested=%s, web_search_invocations=%s, "
                         "results=%s, queries=%s, "
+                        "code_execution_requested=%s, "
+                        "code_execution_invocations=%s, "
+                        "code_execution_results=%s, "
+                        "code_execution_generated_files=%s, "
                         "input_tokens=%s, output_tokens=%s, "
                         "cache_creation_input_tokens=%s, "
                         "cache_read_input_tokens=%s, events=%s)",
@@ -1651,6 +1877,10 @@ class ExternalProviderClient:
                         web_search_invocations,
                         total_results,
                         queries,
+                        code_execution_enabled,
+                        code_execution_invocations,
+                        code_execution_results,
+                        code_execution_generated_files,
                         last_usage.get("input_tokens"),
                         last_usage.get("output_tokens"),
                         last_usage.get("cache_creation_input_tokens"),
@@ -1693,6 +1923,7 @@ class ExternalProviderClient:
         reasoning_effort: Optional[str],
         enabled_tools: Optional[list[str]] = None,
         enable_prompt_caching: Optional[bool] = None,
+        openai_code_exec_container_id: Optional[str] = None,
     ) -> AsyncGenerator[str, None]:
         """
         Call OpenAI's /v1/responses endpoint and translate its SSE stream back
@@ -1817,15 +2048,41 @@ class ExternalProviderClient:
 
         # OpenAI server-side tools — see
         #   https://developers.openai.com/api/docs/guides/tools
-        # The frontend's Search button maps to the unified
-        # enabled_tools=["web_search"] shorthand; translate that into the
-        # Responses-API tool schema. Other built-in tools (file_search,
-        # code_interpreter, image_generation, computer_use_preview) can be
-        # added with the same pattern when we surface their toggles.
+        #   https://developers.openai.com/api/docs/guides/tools-shell
+        # The frontend's Search/Code buttons map to the unified
+        # enabled_tools shorthand; translate that into the Responses-API
+        # tool schema. Other built-in tools (file_search,
+        # code_interpreter, image_generation, computer_use_preview) can
+        # be added with the same pattern when we surface their toggles.
+        code_execution_enabled_openai = bool(
+            enabled_tools and "code_execution" in enabled_tools and is_openai_cloud
+        )
         if enabled_tools:
             tools_array: list[dict[str, Any]] = []
             if "web_search" in enabled_tools:
                 tools_array.append({"type": "web_search"})
+            if code_execution_enabled_openai:
+                # `container_auto` lets OpenAI auto-create a fresh
+                # container per request; we capture the resulting
+                # container_id off the SSE stream and the chat-adapter
+                # persists it onto the thread record. Subsequent turns
+                # in the same thread pass it back as
+                # `openai_code_exec_container_id`, which we translate to
+                # `container_reference` here so the model sees
+                # filesystem state from prior turns. Container expires
+                # after ~20 min of inactivity per OpenAI's default
+                # policy — a stale id 400s, the chat-adapter clears it
+                # via container_invalidated, and the next turn falls
+                # back to auto-create.
+                shell_env: dict[str, Any]
+                if openai_code_exec_container_id:
+                    shell_env = {
+                        "type": "container_reference",
+                        "container_id": openai_code_exec_container_id,
+                    }
+                else:
+                    shell_env = {"type": "container_auto"}
+                tools_array.append({"type": "shell", "environment": shell_env})
             if tools_array:
                 body["tools"] = tools_array
 
@@ -1850,6 +2107,29 @@ class ExternalProviderClient:
                         response.status_code,
                         error_text[:500],
                     )
+                    # Detect stale-container errors so the frontend can
+                    # drop its persisted id. OpenAI doesn't pin an
+                    # error code in the public docs for this case, so
+                    # match a couple of likely substrings. If we sent
+                    # a container_reference and the response is 4xx
+                    # with any hint of "container not found / expired",
+                    # emit container_invalidated; the next turn will
+                    # fall back to container_auto.
+                    if (
+                        openai_code_exec_container_id
+                        and 400 <= response.status_code < 500
+                    ):
+                        lowered = error_text.lower()
+                        if "container" in lowered and (
+                            "expired" in lowered
+                            or "not_found" in lowered
+                            or "not found" in lowered
+                            or "no such container" in lowered
+                        ):
+                            yield (
+                                f"data: "
+                                f"{_json.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'choices': [{'index': 0, 'delta': {}, 'finish_reason': None}], '_toolEvent': {'type': 'container_invalidated'}})}"
+                            )
                     yield _error_sse_line(
                         response.status_code, error_text, self.provider_type
                     )
@@ -1887,6 +2167,28 @@ class ExternalProviderClient:
                 # web_search_calls: { item_id -> {query} }
                 web_search_calls: dict[str, dict[str, Any]] = {}
                 all_url_citations: list[dict[str, str]] = []
+                # Shell-tool (code execution) state. OpenAI emits
+                # `shell_call` items (model requesting a command list)
+                # paired with `shell_call_output` items (execution
+                # results). We mirror the Anthropic code-execution UX
+                # by emitting one `_toolEvent` tool_start per
+                # shell_call and one tool_end per shell_call_output;
+                # they're linked via `shell_call_output.call_id`
+                # matching `shell_call.id`. Items are independent of
+                # web_search (different keyed map).
+                # shell_calls: { call_id -> {commands, output} }
+                shell_calls: dict[str, dict[str, Any]] = {}
+                # Container id captured from the response stream. When
+                # it differs from the inbound id, emit a synthetic
+                # `container_ready` _toolEvent so the frontend can
+                # persist it onto the thread record for the next turn.
+                # Where OpenAI surfaces it is documented loosely; we
+                # probe two known fields (response.container_id on
+                # response.completed, item.environment.container_id on
+                # shell_call output items) and latch the first one we
+                # see.
+                latched_container_id: Optional[str] = None
+                container_id_emitted = False
 
                 def _emit_tool_event(payload: dict[str, Any]) -> str:
                     chunk = {
@@ -1903,6 +2205,45 @@ class ExternalProviderClient:
                     }
                     return f"data: {_json.dumps(chunk)}"
 
+                def _format_shell_output(output: Any) -> str:
+                    """Render an OpenAI `shell_call_output.output` list
+                    as the preformatted text payload the frontend's
+                    CodeExecutionToolUI displays inside a 
. Each
+                    entry has stdout/stderr/outcome — concatenate them
+                    with a separator block per entry and append
+                    `return_code` / `(timeout)` annotations only when
+                    they convey information beyond "succeeded".
+                    """
+                    if not isinstance(output, list):
+                        return ""
+                    parts: list[str] = []
+                    for entry in output:
+                        if not isinstance(entry, dict):
+                            continue
+                        stdout = entry.get("stdout") or ""
+                        stderr = entry.get("stderr") or ""
+                        outcome = entry.get("outcome") or {}
+                        chunk_parts: list[str] = []
+                        if stdout:
+                            chunk_parts.append(stdout)
+                        if stderr:
+                            chunk_parts.append(f"--- stderr ---\n{stderr}")
+                        if isinstance(outcome, dict):
+                            outcome_type = outcome.get("type")
+                            if outcome_type == "exit":
+                                exit_code = outcome.get("exit_code")
+                                if isinstance(exit_code, int) and exit_code != 0:
+                                    chunk_parts.append(f"return_code: {exit_code}")
+                            elif outcome_type == "timeout":
+                                chunk_parts.append("(timeout)")
+                        if chunk_parts:
+                            parts.append("\n".join(chunk_parts))
+                    return (
+                        "\n--- next command ---\n".join(parts)
+                        if parts
+                        else "(no output)"
+                    )
+
                 def _record_url_citation(payload: dict[str, Any]) -> None:
                     """Append a url_citation onto the shared all_url_citations
                     list. Dedup by URL — the same source can be cited multiple
@@ -2025,6 +2366,37 @@ class ExternalProviderClient:
                                     f"ws_{len(web_search_calls)}"
                                 )
                                 web_search_calls.setdefault(item_id, {"query": ""})
+                            # Shell-tool: register the call eagerly so
+                            # the matching shell_call_output can link
+                            # back even if `done` arrives out of order.
+                            # Also probe for container_id on the
+                            # environment field — when container_auto
+                            # auto-creates one, this is the first place
+                            # the new id might surface (OpenAI doesn't
+                            # promise this in docs, but the field is
+                            # cheap to scan and lets us emit
+                            # container_ready earlier than
+                            # response.completed).
+                            if (
+                                isinstance(item, dict)
+                                and item.get("type") == "shell_call"
+                            ):
+                                item_id = item.get("id", "") or (
+                                    f"sc_{len(shell_calls)}"
+                                )
+                                shell_calls.setdefault(
+                                    item_id,
+                                    {"commands": [], "output": None},
+                                )
+                                env = item.get("environment")
+                                if isinstance(env, dict):
+                                    probe = env.get("container_id") or env.get("id")
+                                    if (
+                                        isinstance(probe, str)
+                                        and probe.startswith("cntr_")
+                                        and latched_container_id is None
+                                    ):
+                                        latched_container_id = probe
 
                         elif event_type == "response.output_item.done":
                             item = event.get("item", {})
@@ -2080,6 +2452,65 @@ class ExternalProviderClient:
                                         "result": "",
                                     }
                                 )
+                            elif item.get("type") == "shell_call":
+                                # OpenAI ships the commands array on the
+                                # action field. Join them onto one
+                                # command string for the tool card —
+                                # the renderer is shared with Anthropic
+                                # bash, which only carries a single
+                                # `command`. Multiple commands in one
+                                # shell_call get joined with newlines so
+                                # they still render as one card.
+                                item_id = item.get("id", "") or (
+                                    f"sc_{len(shell_calls)}"
+                                )
+                                action = item.get("action") or {}
+                                commands = (
+                                    action.get("commands")
+                                    if isinstance(action, dict)
+                                    else None
+                                ) or []
+                                joined_command = (
+                                    "\n".join(str(c) for c in commands)
+                                    if isinstance(commands, list)
+                                    else ""
+                                )
+                                shell_calls.setdefault(
+                                    item_id,
+                                    {"commands": [], "output": None},
+                                )
+                                shell_calls[item_id]["commands"] = (
+                                    list(commands) if isinstance(commands, list) else []
+                                )
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_start",
+                                        "tool_name": "code_execution",
+                                        "tool_call_id": item_id,
+                                        "arguments": {
+                                            "kind": "bash",
+                                            "command": joined_command,
+                                        },
+                                    }
+                                )
+                            elif item.get("type") == "shell_call_output":
+                                # `call_id` links back to the shell_call's
+                                # `id`, which is what we used as the
+                                # tool_call_id on tool_start. Match on
+                                # call_id when present so the matching
+                                # card transitions to complete.
+                                call_id = item.get("call_id") or item.get("id") or ""
+                                output = item.get("output") or []
+                                if call_id in shell_calls:
+                                    shell_calls[call_id]["output"] = output
+                                result_text = _format_shell_output(output)
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_end",
+                                        "tool_call_id": call_id,
+                                        "result": result_text,
+                                    }
+                                )
 
                         elif isinstance(event_type, str) and "reasoning" in event_type:
                             reasoning_delta = _extract_reasoning_text(event)
@@ -2097,6 +2528,39 @@ class ExternalProviderClient:
                             if reasoning_open:
                                 yield _chunk_with_text("")
                                 reasoning_open = False
+                            # Probe response.container_id (top-level) and
+                            # response.container.id for the shell-tool
+                            # container id. OpenAI's docs don't pin the
+                            # exact field, so we scan both. Emit
+                            # `container_ready` only when the value
+                            # differs from the inbound one — no churn on
+                            # reuse.
+                            response_obj = event.get("response") or {}
+                            if isinstance(response_obj, dict):
+                                probe_id = response_obj.get("container_id")
+                                if not probe_id:
+                                    container_field = response_obj.get("container")
+                                    if isinstance(container_field, dict):
+                                        probe_id = container_field.get("id")
+                                if (
+                                    isinstance(probe_id, str)
+                                    and probe_id.startswith("cntr_")
+                                    and latched_container_id is None
+                                ):
+                                    latched_container_id = probe_id
+                            if (
+                                latched_container_id
+                                and not container_id_emitted
+                                and latched_container_id
+                                != openai_code_exec_container_id
+                            ):
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "container_ready",
+                                        "container_id": latched_container_id,
+                                    }
+                                )
+                                container_id_emitted = True
                             # Apply the aggregated citation list onto the
                             # *last* web_search call by overwriting its
                             # tool_end result. The frontend's
@@ -2227,10 +2691,19 @@ class ExternalProviderClient:
                         details = last_usage.get("input_tokens_details")
                         if isinstance(details, dict):
                             cached_input_tokens = details.get("cached_tokens")
+                    code_execution_requested = code_execution_enabled_openai
+                    code_execution_invocations = len(shell_calls)
+                    code_execution_results = sum(
+                        1 for sc in shell_calls.values() if sc.get("output") is not None
+                    )
                     logger.info(
                         "OpenAI Responses stream complete (model=%s, "
                         "web_search_requested=%s, web_search_invocations=%s, "
                         "citations=%s, queries=%s, reasoning_emitted=%s, "
+                        "code_execution_requested=%s, "
+                        "code_execution_invocations=%s, "
+                        "code_execution_results=%s, "
+                        "container_id_in=%s, container_id_out=%s, "
                         "input_tokens=%s, output_tokens=%s, "
                         "cached_input_tokens=%s)",
                         model,
@@ -2239,6 +2712,11 @@ class ExternalProviderClient:
                         total_citations,
                         queries,
                         reasoning_emitted,
+                        code_execution_requested,
+                        code_execution_invocations,
+                        code_execution_results,
+                        openai_code_exec_container_id,
+                        latched_container_id,
                         (last_usage or {}).get("input_tokens"),
                         (last_usage or {}).get("output_tokens"),
                         cached_input_tokens,
@@ -2359,6 +2837,90 @@ class ExternalProviderClient:
             )
             raise
 
+    def _container_headers(self) -> dict[str, str]:
+        """Auth headers plus the OpenAI-Beta opt-in for /v1/containers.
+
+        OpenAI's containers API requires ``OpenAI-Beta: containers=v1``.
+        Without it, DELETE silently no-ops: the API returns 200 with a
+        ``{"deleted": true}`` body but does not actually remove the
+        container (verified 2026-05-15). The header is required for
+        list / create / delete to behave consistently.
+        """
+        headers = self._auth_headers()
+        headers["OpenAI-Beta"] = "containers=v1"
+        return headers
+
+    async def list_openai_containers(self) -> list[dict[str, Any]]:
+        """
+        GET /v1/containers on the user's OpenAI account.
+
+        Returns the raw container records (id, name, created_at,
+        last_active_at, expires_after, status). The route layer
+        reshapes these into the UI summary shape.
+
+        Only valid against api.openai.com — non-cloud OpenAI-compat
+        servers don't implement /v1/containers and would 404 here.
+        Caller is responsible for the is_openai_cloud guard.
+        """
+        response = await _http_client.get(
+            f"{self.base_url}/containers",
+            headers = self._container_headers(),
+            timeout = self._timeout,
+        )
+        response.raise_for_status()
+        data = response.json()
+        containers = data.get("data") if isinstance(data, dict) else None
+        return list(containers) if isinstance(containers, list) else []
+
+    async def create_openai_container(
+        self,
+        name: str,
+        ttl_minutes: int,
+    ) -> dict[str, Any]:
+        """
+        POST /v1/containers with ``expires_after.anchor="last_active_at"``.
+        ``ttl_minutes`` is the idle timeout — every API call that
+        touches the container resets the timer.
+        """
+        body = {
+            "name": name,
+            "expires_after": {
+                "anchor": "last_active_at",
+                "minutes": ttl_minutes,
+            },
+        }
+        response = await _http_client.post(
+            f"{self.base_url}/containers",
+            json = body,
+            headers = self._container_headers(),
+            timeout = self._timeout,
+        )
+        response.raise_for_status()
+        return response.json()
+
+    async def delete_openai_container(self, container_id: str) -> None:
+        """DELETE /v1/containers/{id}. 404s are surfaced as HTTPError.
+
+        Verifies the response body reports ``deleted: true``. OpenAI
+        returns a 2xx ``deleted: true`` body even when the request is
+        silently rejected (e.g. missing OpenAI-Beta header), so a
+        status-only check is not sufficient.
+        """
+        response = await _http_client.delete(
+            f"{self.base_url}/containers/{container_id}",
+            headers = self._container_headers(),
+            timeout = self._timeout,
+        )
+        response.raise_for_status()
+        try:
+            payload = response.json()
+        except ValueError:
+            payload = None
+        if not (isinstance(payload, dict) and payload.get("deleted") is True):
+            raise httpx.HTTPError(
+                f"OpenAI did not confirm container deletion: {response.text[:200]}"
+            )
+
     async def close(self) -> None:
         """No-op — the underlying client is shared across requests."""
 
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index f2eed314ee..6a042d35d7 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -604,6 +604,81 @@ class ChatCompletionRequest(BaseModel):
             "vllm, local, etc.). Treated as enabled when omitted."
         ),
     )
+    openai_code_exec_container_id: Optional[str] = Field(
+        None,
+        description = (
+            "[x-unsloth] OpenAI shell-tool container id from the prior response "
+            "in the same chat thread. When set and `code_execution` is in "
+            "`enabled_tools`, the next /v1/responses call uses "
+            "environment.type='container_reference' so filesystem state "
+            "persists across turns. Unset → environment.type='container_auto' "
+            "and OpenAI creates a fresh container. Only meaningful for the "
+            "OpenAI cloud + gpt-5.5 family path; ignored otherwise."
+        ),
+    )
+
+
+# ── OpenAI shell-tool container management ─────────────────────
+
+
+class OpenAIContainerRequest(BaseModel):
+    """
+    Shared body for the three OpenAI container endpoints (list / create
+    / delete). Carries the encrypted API key + base URL so the route
+    handler can decrypt it and proxy to the user's OpenAI account.
+    Same pattern as the inference proxy endpoints — keeps the key off
+    persistent storage on the backend.
+    """
+
+    encrypted_api_key: str = Field(
+        ...,
+        description = "[x-unsloth] RSA-encrypted, base64-encoded OpenAI API key.",
+    )
+    provider_base_url: Optional[str] = Field(
+        None,
+        description = "[x-unsloth] OpenAI base URL. Only api.openai.com is supported; non-cloud bases are rejected with 400.",
+    )
+
+
+class CreateOpenAIContainerBody(OpenAIContainerRequest):
+    name: str = Field(
+        ...,
+        min_length = 1,
+        max_length = 256,
+        description = "Human-readable container name. Surfaces in the picker UI.",
+    )
+    ttl_minutes: int = Field(
+        20,
+        ge = 1,
+        le = 10080,  # 1 week
+        description = (
+            "Idle-timeout TTL the new container will inherit (anchor="
+            "last_active_at). OpenAI's default is 20; we cap at one "
+            "week as a safety bound."
+        ),
+    )
+
+
+class DeleteOpenAIContainerBody(OpenAIContainerRequest):
+    container_id: str = Field(
+        ...,
+        description = "OpenAI container id (cntr_...) to delete.",
+    )
+
+
+class OpenAIContainerSummary(BaseModel):
+    """One row from GET /v1/containers, reshaped for the UI."""
+
+    id: str
+    name: Optional[str] = None
+    created_at: Optional[int] = None
+    last_active_at: Optional[int] = None
+    expires_after_minutes: Optional[int] = None
+    status: Optional[str] = None
+
+
+class ListOpenAIContainersResponse(BaseModel):
+    containers: list[OpenAIContainerSummary]
 
 
 # ── Streaming response chunks ────────────────────────────────────
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index e8732397b0..8f5bd9aab5 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -194,6 +194,11 @@ from models.inference import (
     AnthropicResponseTextBlock,
     AnthropicResponseToolUseBlock,
     AnthropicUsage,
+    CreateOpenAIContainerBody,
+    DeleteOpenAIContainerBody,
+    ListOpenAIContainersResponse,
+    OpenAIContainerRequest,
+    OpenAIContainerSummary,
 )
 from core.inference.anthropic_compat import (
     anthropic_messages_to_openai,
@@ -1598,6 +1603,7 @@ async def _proxy_to_external_provider(
             reasoning_effort = payload.reasoning_effort,
             enabled_tools = payload.enabled_tools,
             enable_prompt_caching = payload.enable_prompt_caching,
+            openai_code_exec_container_id = payload.openai_code_exec_container_id,
             stream = payload.stream,
         )
         try:
@@ -1627,6 +1633,158 @@ async def _proxy_to_external_provider(
     )
 
 
+# ── OpenAI shell-tool container management ───────────────────────
+
+
+def _resolve_openai_cloud_client(
+    body: OpenAIContainerRequest,
+) -> ExternalProviderClient:
+    """
+    Decrypt the API key + validate the base URL points at OpenAI cloud,
+    then build an ExternalProviderClient for the three container CRUD
+    endpoints below. The shell tool only exists on api.openai.com, so
+    rejecting non-cloud bases up front prevents confusing 404s on
+    ollama / llama.cpp / vLLM / custom presets.
+    """
+    base_url = body.provider_base_url or get_base_url("openai")
+    if not base_url or "api.openai.com" not in base_url:
+        raise HTTPException(
+            status_code = 400,
+            detail = (
+                "OpenAI container management is only available on the "
+                "managed cloud (api.openai.com). The provider's base URL "
+                f"points at {base_url!r}."
+            ),
+        )
+    try:
+        api_key = decrypt_api_key(body.encrypted_api_key)
+    except Exception as exc:
+        logger.warning("external_provider.decrypt_failed", error = str(exc))
+        raise HTTPException(
+            status_code = 400,
+            detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.",
+        )
+    return ExternalProviderClient(
+        provider_type = "openai",
+        base_url = base_url,
+        api_key = api_key,
+    )
+
+
+def _summarize_container(raw: dict) -> OpenAIContainerSummary:
+    expires = raw.get("expires_after")
+    expires_minutes: Optional[int] = None
+    if isinstance(expires, dict):
+        minutes = expires.get("minutes")
+        if isinstance(minutes, int):
+            expires_minutes = minutes
+    return OpenAIContainerSummary(
+        id = str(raw.get("id") or ""),
+        name = raw.get("name"),
+        created_at = raw.get("created_at")
+        if isinstance(raw.get("created_at"), int)
+        else None,
+        last_active_at = raw.get("last_active_at")
+        if isinstance(raw.get("last_active_at"), int)
+        else None,
+        expires_after_minutes = expires_minutes,
+        status = raw.get("status") if isinstance(raw.get("status"), str) else None,
+    )
+
+
+@router.post(
+    "/external/openai/containers/list",
+    response_model = ListOpenAIContainersResponse,
+)
+async def list_openai_containers(
+    body: OpenAIContainerRequest,
+    current_subject: str = Depends(get_current_subject),
+) -> ListOpenAIContainersResponse:
+    """List the user's OpenAI shell-tool containers."""
+    client = _resolve_openai_cloud_client(body)
+    try:
+        try:
+            raw = await client.list_openai_containers()
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500] if exc.response is not None else str(exc)
+            raise HTTPException(
+                status_code = exc.response.status_code if exc.response else 502,
+                detail = f"OpenAI rejected /containers list: {detail}",
+            )
+        except httpx.HTTPError as exc:
+            raise HTTPException(
+                status_code = 502,
+                detail = f"Failed to reach OpenAI: {exc}",
+            )
+        return ListOpenAIContainersResponse(
+            containers = [_summarize_container(c) for c in raw if isinstance(c, dict)],
+        )
+    finally:
+        await client.close()
+
+
+@router.post(
+    "/external/openai/containers/create",
+    response_model = OpenAIContainerSummary,
+)
+async def create_openai_container(
+    body: CreateOpenAIContainerBody,
+    current_subject: str = Depends(get_current_subject),
+) -> OpenAIContainerSummary:
+    """Create a named container with the user-chosen idle TTL."""
+    client = _resolve_openai_cloud_client(body)
+    try:
+        try:
+            raw = await client.create_openai_container(
+                name = body.name,
+                ttl_minutes = body.ttl_minutes,
+            )
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500] if exc.response is not None else str(exc)
+            raise HTTPException(
+                status_code = exc.response.status_code if exc.response else 502,
+                detail = f"OpenAI rejected /containers create: {detail}",
+            )
+        except httpx.HTTPError as exc:
+            raise HTTPException(
+                status_code = 502,
+                detail = f"Failed to reach OpenAI: {exc}",
+            )
+        if not isinstance(raw, dict):
+            raise HTTPException(
+                status_code = 502,
+                detail = "OpenAI returned an unexpected container payload.",
+            )
+        return _summarize_container(raw)
+    finally:
+        await client.close()
+
+
+@router.post("/external/openai/containers/delete", status_code = 204)
+async def delete_openai_container(
+    body: DeleteOpenAIContainerBody,
+    current_subject: str = Depends(get_current_subject),
+) -> None:
+    """Delete a named container by id."""
+    client = _resolve_openai_cloud_client(body)
+    try:
+        try:
+            await client.delete_openai_container(body.container_id)
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500] if exc.response is not None else str(exc)
+            raise HTTPException(
+                status_code = exc.response.status_code if exc.response else 502,
+                detail = f"OpenAI rejected /containers delete: {detail}",
+            )
+        except httpx.HTTPError as exc:
+            raise HTTPException(
+                status_code = 502,
+                detail = f"Failed to reach OpenAI: {exc}",
+            )
+    finally:
+        await client.close()
+
+
 @router.post("/chat/completions")
 async def openai_chat_completions(
     payload: ChatCompletionRequest,
diff --git a/studio/backend/tests/test_anthropic_code_execution.py b/studio/backend/tests/test_anthropic_code_execution.py
new file mode 100644
index 0000000000..b427ad2c0b
--- /dev/null
+++ b/studio/backend/tests/test_anthropic_code_execution.py
@@ -0,0 +1,419 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+Unit tests for Anthropic's server-side `code_execution_20250825` tool
+translation in `_stream_anthropic`.
+
+Covers:
+- Request body: when ``enabled_tools=["code_execution"]``, the outbound
+  ``tools`` array carries ``{"type": "code_execution_20250825", "name":
+  "code_execution"}`` and the ``anthropic-beta`` header includes
+  ``code-execution-2025-08-25``.
+- Combined request: ``enabled_tools=["web_search", "code_execution"]``
+  sends both tool entries; the beta header still merges the code-exec
+  flag onto whatever the registry contributed.
+- SSE translation: a `bash_code_execution` server_tool_use +
+  `bash_code_execution_tool_result` pair emits one tool_start and one
+  tool_end ``_toolEvent`` chunk with the expected arguments and result.
+- SSE translation: a `text_editor_code_execution` create + result emits
+  a tool_start with ``kind="text_editor"`` + parsed args, and tool_end
+  with ``"Created"`` (or ``"Updated"``) based on the ``is_file_update``
+  flag.
+- Error path: a ``bash_code_execution_tool_result_error`` with
+  ``error_code="container_expired"`` renders as ``"Error:
+  container_expired"`` in the tool_end ``result``.
+"""
+
+import asyncio
+import json
+
+import httpx
+
+from core.inference import external_provider as ep_mod
+from core.inference.external_provider import ExternalProviderClient
+
+
+def _drive(coro):
+    return asyncio.new_event_loop().run_until_complete(coro)
+
+
+async def _collect(agen):
+    out = []
+    async for line in agen:
+        out.append(line)
+    return out
+
+
+def _mock_http_client(monkeypatch, handler):
+    transport = httpx.MockTransport(handler)
+    monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
+
+
+def _make_client() -> ExternalProviderClient:
+    return ExternalProviderClient(
+        provider_type = "anthropic",
+        base_url = "https://api.anthropic.com/v1",
+        api_key = "sk-ant-test",
+    )
+
+
+def _anthropic_sse(events: list[dict]) -> bytes:
+    chunks: list[str] = []
+    for event in events:
+        chunks.append(f"event: {event['type']}")
+        chunks.append(f"data: {json.dumps(event)}")
+        chunks.append("")
+    return ("\n".join(chunks) + "\n").encode("utf-8")
+
+
+def _tool_events(lines: list[str]) -> list[dict]:
+    """Extract `_toolEvent` payloads from emitted SSE data lines."""
+    out: list[dict] = []
+    for line in lines:
+        if not line.startswith("data:"):
+            continue
+        raw = line[len("data:") :].strip()
+        if not raw or raw == "[DONE]":
+            continue
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError:
+            continue
+        if isinstance(parsed, dict) and "_toolEvent" in parsed:
+            out.append(parsed["_toolEvent"])
+    return out
+
+
+def test_code_execution_tool_appended_to_request_body(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        captured["headers"] = dict(request.headers)
+        return httpx.Response(
+            200,
+            content = _anthropic_sse([{"type": "message_stop"}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_anthropic(
+            messages = [{"role": "user", "content": "compute 2 + 2"}],
+            model = "claude-opus-4-7",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enabled_tools = ["code_execution"],
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    body = captured["body"]
+    tools = body.get("tools") or []
+    assert {
+        "type": "code_execution_20250825",
+        "name": "code_execution",
+    } in tools
+    # No web_search entry when only code_execution is enabled.
+    assert all(t.get("type") != "web_search_20250305" for t in tools)
+    # Beta header carries the documented flag.
+    beta_header = captured["headers"].get("anthropic-beta", "")
+    assert "code-execution-2025-08-25" in beta_header
+
+
+def test_code_execution_with_web_search_sends_both_tools(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        captured["headers"] = dict(request.headers)
+        return httpx.Response(
+            200,
+            content = _anthropic_sse([{"type": "message_stop"}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_anthropic(
+            messages = [{"role": "user", "content": "look it up and chart it"}],
+            model = "claude-opus-4-7",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enabled_tools = ["web_search", "code_execution"],
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    tool_types = {t.get("type") for t in tools if isinstance(t, dict)}
+    assert "web_search_20250305" in tool_types
+    assert "code_execution_20250825" in tool_types
+    assert "code-execution-2025-08-25" in captured["headers"].get("anthropic-beta", "")
+
+
+def test_no_code_execution_tool_when_pill_off(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        captured["headers"] = dict(request.headers)
+        return httpx.Response(
+            200,
+            content = _anthropic_sse([{"type": "message_stop"}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_anthropic(
+            messages = [{"role": "user", "content": "hi"}],
+            model = "claude-opus-4-7",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    assert all(t.get("type") != "code_execution_20250825" for t in tools)
+    # Beta header must NOT mention code-execution when the tool isn't on
+    # — that flag is opt-in only.
+    assert "code-execution-2025-08-25" not in captured["headers"].get(
+        "anthropic-beta", ""
+    )
+
+
+def test_bash_code_execution_emits_tool_start_and_end(monkeypatch):
+    sse_events = [
+        {"type": "message_start", "message": {"usage": {}}},
+        {
+            "type": "content_block_start",
+            "index": 0,
+            "content_block": {
+                "type": "server_tool_use",
+                "id": "srvtoolu_1",
+                "name": "bash_code_execution",
+            },
+        },
+        {
+            "type": "content_block_delta",
+            "index": 0,
+            "delta": {
+                "type": "input_json_delta",
+                "partial_json": '{"command": "ls -la"}',
+            },
+        },
+        {"type": "content_block_stop", "index": 0},
+        {
+            "type": "content_block_start",
+            "index": 1,
+            "content_block": {
+                "type": "bash_code_execution_tool_result",
+                "tool_use_id": "srvtoolu_1",
+                "content": {
+                    "type": "bash_code_execution_result",
+                    "stdout": "total 24\ndrwxr-xr-x 2 user user 4096 Jan 1 12:00 .",
+                    "stderr": "",
+                    "return_code": 0,
+                },
+            },
+        },
+        {"type": "content_block_stop", "index": 1},
+        {"type": "message_stop"},
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _anthropic_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_anthropic(
+                messages = [{"role": "user", "content": "list files"}],
+                model = "claude-opus-4-7",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+
+    assert len(events) == 2
+    start, end = events
+    assert start["type"] == "tool_start"
+    assert start["tool_name"] == "code_execution"
+    assert start["tool_call_id"] == "srvtoolu_1"
+    assert start["arguments"] == {"kind": "bash", "command": "ls -la"}
+
+    assert end["type"] == "tool_end"
+    assert end["tool_call_id"] == "srvtoolu_1"
+    assert "total 24" in end["result"]
+    # Non-zero return_code not present, so no return_code line.
+    assert "return_code:" not in end["result"]
+
+
+def test_text_editor_create_emits_kind_and_status(monkeypatch):
+    sse_events = [
+        {"type": "message_start", "message": {"usage": {}}},
+        {
+            "type": "content_block_start",
+            "index": 0,
+            "content_block": {
+                "type": "server_tool_use",
+                "id": "srvtoolu_2",
+                "name": "text_editor_code_execution",
+            },
+        },
+        {
+            "type": "content_block_delta",
+            "index": 0,
+            "delta": {
+                "type": "input_json_delta",
+                "partial_json": (
+                    '{"command": "create", "path": "new_file.txt", '
+                    '"file_text": "hi"}'
+                ),
+            },
+        },
+        {"type": "content_block_stop", "index": 0},
+        {
+            "type": "content_block_start",
+            "index": 1,
+            "content_block": {
+                "type": "text_editor_code_execution_tool_result",
+                "tool_use_id": "srvtoolu_2",
+                "content": {
+                    "type": "text_editor_code_execution_result",
+                    "is_file_update": False,
+                },
+            },
+        },
+        {"type": "content_block_stop", "index": 1},
+        {"type": "message_stop"},
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _anthropic_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_anthropic(
+                messages = [{"role": "user", "content": "write a file"}],
+                model = "claude-opus-4-7",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+
+    assert len(events) == 2
+    start, end = events
+    assert start["arguments"]["kind"] == "text_editor"
+    assert start["arguments"]["command"] == "create"
+    assert start["arguments"]["path"] == "new_file.txt"
+    assert end["result"] == "Created"
+
+
+def test_code_execution_error_renders_error_code(monkeypatch):
+    sse_events = [
+        {"type": "message_start", "message": {"usage": {}}},
+        {
+            "type": "content_block_start",
+            "index": 0,
+            "content_block": {
+                "type": "server_tool_use",
+                "id": "srvtoolu_3",
+                "name": "bash_code_execution",
+            },
+        },
+        {
+            "type": "content_block_delta",
+            "index": 0,
+            "delta": {
+                "type": "input_json_delta",
+                "partial_json": '{"command": "echo broken"}',
+            },
+        },
+        {"type": "content_block_stop", "index": 0},
+        {
+            "type": "content_block_start",
+            "index": 1,
+            "content_block": {
+                "type": "bash_code_execution_tool_result",
+                "tool_use_id": "srvtoolu_3",
+                "content": {
+                    "type": "bash_code_execution_tool_result_error",
+                    "error_code": "container_expired",
+                },
+            },
+        },
+        {"type": "content_block_stop", "index": 1},
+        {"type": "message_stop"},
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _anthropic_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_anthropic(
+                messages = [{"role": "user", "content": "run it"}],
+                model = "claude-opus-4-7",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+
+    assert len(events) == 2
+    end = events[1]
+    assert end["type"] == "tool_end"
+    assert end["result"] == "Error: container_expired"
diff --git a/studio/backend/tests/test_openai_code_execution.py b/studio/backend/tests/test_openai_code_execution.py
new file mode 100644
index 0000000000..88ff1171ef
--- /dev/null
+++ b/studio/backend/tests/test_openai_code_execution.py
@@ -0,0 +1,391 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+Unit tests for OpenAI's server-side `shell` tool translation in
+`_stream_openai_responses`.
+
+Covers:
+- Request body: ``enabled_tools=["code_execution"]`` on the OpenAI
+  cloud base_url appends ``{"type": "shell", "environment": {"type":
+  "container_auto"}}`` to ``tools``.
+- Container reuse: when ``openai_code_exec_container_id`` is provided,
+  the outgoing ``environment.type`` flips to ``"container_reference"``
+  and the id propagates.
+- Cloud guard: code_execution on a non-cloud base_url (e.g. a local
+  OpenAI-compat preset / ollama / llama.cpp / vLLM) does NOT add the
+  shell tool, preventing a guaranteed 400 from those servers.
+- SSE translation: a `shell_call` + `shell_call_output` pair emits one
+  ``_toolEvent`` `tool_start` (`tool_name="code_execution"`,
+  `arguments.kind="bash"`) and one `tool_end` whose `result` contains
+  the joined stdout from the shell_call_output entries.
+- Container surfacing: container_id captured from
+  `response.completed.container_id` is emitted as a synthetic
+  `container_ready` `_toolEvent` (only when it differs from the
+  inbound id).
+- Stale-container handling: 400 with "container expired" body emits a
+  `container_invalidated` event before propagating the error.
+"""
+
+import asyncio
+import json
+
+import httpx
+
+from core.inference import external_provider as ep_mod
+from core.inference.external_provider import ExternalProviderClient
+
+
+def _drive(coro):
+    return asyncio.new_event_loop().run_until_complete(coro)
+
+
+async def _collect(agen):
+    out = []
+    async for line in agen:
+        out.append(line)
+    return out
+
+
+def _mock_http_client(monkeypatch, handler):
+    transport = httpx.MockTransport(handler)
+    monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
+
+
+def _make_client(base_url: str = "https://api.openai.com/v1") -> ExternalProviderClient:
+    return ExternalProviderClient(
+        provider_type = "openai",
+        base_url = base_url,
+        api_key = "sk-test",
+    )
+
+
+def _openai_sse(events: list[dict]) -> bytes:
+    chunks: list[str] = []
+    for event in events:
+        chunks.append(f"event: {event['type']}")
+        chunks.append(f"data: {json.dumps(event)}")
+        chunks.append("")
+    return ("\n".join(chunks) + "\n").encode("utf-8")
+
+
+def _tool_events(lines: list[str]) -> list[dict]:
+    out: list[dict] = []
+    for line in lines:
+        if not line.startswith("data:"):
+            continue
+        raw = line[len("data:") :].strip()
+        if not raw or raw == "[DONE]":
+            continue
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError:
+            continue
+        if isinstance(parsed, dict) and "_toolEvent" in parsed:
+            out.append(parsed["_toolEvent"])
+    return out
+
+
+def test_shell_tool_added_on_cloud_with_container_auto(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(
+            200,
+            content = _openai_sse([{"type": "response.completed", "response": {}}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_openai_responses(
+            messages = [{"role": "user", "content": "compute 2+2"}],
+            model = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enable_thinking = None,
+            reasoning_effort = None,
+            enabled_tools = ["code_execution"],
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    assert {
+        "type": "shell",
+        "environment": {"type": "container_auto"},
+    } in tools
+
+
+def test_shell_tool_uses_container_reference_when_id_supplied(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(
+            200,
+            content = _openai_sse([{"type": "response.completed", "response": {}}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_openai_responses(
+            messages = [{"role": "user", "content": "what did i write earlier"}],
+            model = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enable_thinking = None,
+            reasoning_effort = None,
+            enabled_tools = ["code_execution"],
+            openai_code_exec_container_id = "cntr_abc123",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    assert {
+        "type": "shell",
+        "environment": {
+            "type": "container_reference",
+            "container_id": "cntr_abc123",
+        },
+    } in tools
+
+
+def test_shell_tool_refused_for_non_cloud_base_url(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(
+            200,
+            content = _openai_sse([{"type": "response.completed", "response": {}}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client(base_url = "http://localhost:11434/v1")
+        async for _ in client._stream_openai_responses(
+            messages = [{"role": "user", "content": "hi"}],
+            model = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enable_thinking = None,
+            reasoning_effort = None,
+            enabled_tools = ["code_execution"],
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    # Shell tool must NOT leak to local OpenAI-compat servers — those
+    # 400 on the unknown tool type.
+    assert all(t.get("type") != "shell" for t in tools)
+
+
+def test_shell_call_emits_tool_start_and_end(monkeypatch):
+    sse_events = [
+        {
+            "type": "response.output_item.added",
+            "item": {
+                "type": "shell_call",
+                "id": "scall_1",
+                "action": {"commands": ["ls -la"]},
+            },
+        },
+        {
+            "type": "response.output_item.done",
+            "item": {
+                "type": "shell_call",
+                "id": "scall_1",
+                "action": {"commands": ["ls -la"]},
+                "status": "completed",
+            },
+        },
+        {
+            "type": "response.output_item.done",
+            "item": {
+                "type": "shell_call_output",
+                "id": "scout_1",
+                "call_id": "scall_1",
+                "output": [
+                    {
+                        "stdout": "total 24\ndrwxr-xr-x .",
+                        "stderr": "",
+                        "outcome": {"type": "exit", "exit_code": 0},
+                    }
+                ],
+            },
+        },
+        {"type": "response.completed", "response": {}},
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _openai_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "list files"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enable_thinking = None,
+                reasoning_effort = None,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+    starts = [e for e in events if e["type"] == "tool_start"]
+    ends = [e for e in events if e["type"] == "tool_end"]
+    assert len(starts) == 1
+    assert len(ends) == 1
+    assert starts[0]["tool_name"] == "code_execution"
+    assert starts[0]["tool_call_id"] == "scall_1"
+    assert starts[0]["arguments"] == {"kind": "bash", "command": "ls -la"}
+    assert ends[0]["tool_call_id"] == "scall_1"
+    assert "total 24" in ends[0]["result"]
+
+
+def test_container_ready_emitted_when_new_id_surfaces(monkeypatch):
+    sse_events = [
+        {
+            "type": "response.completed",
+            "response": {"container_id": "cntr_new_456"},
+        },
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _openai_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "do stuff"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enable_thinking = None,
+                reasoning_effort = None,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+    ready = [e for e in events if e["type"] == "container_ready"]
+    assert len(ready) == 1
+    assert ready[0]["container_id"] == "cntr_new_456"
+
+
+def test_container_ready_not_emitted_when_id_unchanged(monkeypatch):
+    sse_events = [
+        {
+            "type": "response.completed",
+            "response": {"container_id": "cntr_same_789"},
+        },
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _openai_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "do stuff"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enable_thinking = None,
+                reasoning_effort = None,
+                enabled_tools = ["code_execution"],
+                openai_code_exec_container_id = "cntr_same_789",
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+    # No churn — id matches the one already on the thread record.
+    assert not any(e["type"] == "container_ready" for e in events)
+
+
+def test_stale_container_emits_invalidated(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            400,
+            content = json.dumps(
+                {
+                    "error": {
+                        "message": "container has expired",
+                        "type": "invalid_request_error",
+                    }
+                }
+            ).encode("utf-8"),
+            headers = {"content-type": "application/json"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "hi"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enable_thinking = None,
+                reasoning_effort = None,
+                enabled_tools = ["code_execution"],
+                openai_code_exec_container_id = "cntr_stale_999",
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+    invalidated = [e for e in events if e["type"] == "container_invalidated"]
+    assert len(invalidated) == 1
diff --git a/studio/backend/tests/test_openai_container_crud.py b/studio/backend/tests/test_openai_container_crud.py
new file mode 100644
index 0000000000..2965ec6649
--- /dev/null
+++ b/studio/backend/tests/test_openai_container_crud.py
@@ -0,0 +1,151 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Unit tests for the /v1/containers CRUD client methods.
+
+Covers:
+- All three calls (list / create / delete) send
+  ``OpenAI-Beta: containers=v1``. Without it, OpenAI silently no-ops
+  the DELETE while still returning 200 ``{"deleted": true}``.
+- ``delete_openai_container`` raises when the response body does not
+  report ``{"deleted": true}``, even on a 2xx response.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+
+import httpx
+import pytest
+
+from core.inference import external_provider as ep_mod
+from core.inference.external_provider import ExternalProviderClient
+
+
+def _drive(coro):
+    return asyncio.new_event_loop().run_until_complete(coro)
+
+
+def _mock_http_client(monkeypatch, handler):
+    transport = httpx.MockTransport(handler)
+    monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
+
+
+def _make_client() -> ExternalProviderClient:
+    return ExternalProviderClient(
+        provider_type = "openai",
+        base_url = "https://api.openai.com/v1",
+        api_key = "sk-test",
+    )
+
+
+def test_list_sends_openai_beta_header(monkeypatch):
+    seen: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        seen["headers"] = dict(request.headers)
+        seen["url"] = str(request.url)
+        return httpx.Response(
+            200,
+            json = {"data": [{"id": "cntr_x", "name": "auto"}]},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+    result = _drive(_make_client().list_openai_containers())
+
+    assert result == [{"id": "cntr_x", "name": "auto"}]
+    assert seen["headers"].get("openai-beta") == "containers=v1"
+    assert seen["url"] == "https://api.openai.com/v1/containers"
+
+
+def test_create_sends_openai_beta_header(monkeypatch):
+    seen: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        seen["headers"] = dict(request.headers)
+        seen["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(200, json = {"id": "cntr_new", "name": "analysis"})
+
+    _mock_http_client(monkeypatch, handler)
+    result = _drive(
+        _make_client().create_openai_container(name = "analysis", ttl_minutes = 30)
+    )
+
+    assert result == {"id": "cntr_new", "name": "analysis"}
+    assert seen["headers"].get("openai-beta") == "containers=v1"
+    assert seen["body"]["name"] == "analysis"
+    assert seen["body"]["expires_after"] == {
+        "anchor": "last_active_at",
+        "minutes": 30,
+    }
+
+
+def test_delete_sends_openai_beta_header_and_accepts_confirmation(monkeypatch):
+    seen: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        seen["headers"] = dict(request.headers)
+        seen["url"] = str(request.url)
+        seen["method"] = request.method
+        return httpx.Response(
+            200,
+            json = {"id": "cntr_x", "object": "container.deleted", "deleted": True},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+    _drive(_make_client().delete_openai_container("cntr_x"))
+
+    assert seen["method"] == "DELETE"
+    assert seen["url"] == "https://api.openai.com/v1/containers/cntr_x"
+    assert seen["headers"].get("openai-beta") == "containers=v1"
+
+
+def test_delete_raises_when_response_lacks_deleted_true(monkeypatch):
+    """OpenAI returns 200 ``{"deleted": true}`` even when the request is
+    silently rejected (e.g. before we started sending OpenAI-Beta).
+    Defensive guard: when the body omits ``deleted: true``, surface it
+    as an error so the UI can report the failure instead of falsely
+    reporting success."""
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        # 200 but no deleted flag — simulate an unexpected payload shape.
+        return httpx.Response(200, json = {"id": "cntr_x", "object": "container"})
+
+    _mock_http_client(monkeypatch, handler)
+
+    with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
+        _drive(_make_client().delete_openai_container("cntr_x"))
+
+
+def test_delete_raises_when_deleted_is_false(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            json = {"id": "cntr_x", "object": "container.deleted", "deleted": False},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
+        _drive(_make_client().delete_openai_container("cntr_x"))
+
+
+def test_delete_raises_when_body_is_not_json(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(200, content = b"OK")
+
+    _mock_http_client(monkeypatch, handler)
+
+    with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
+        _drive(_make_client().delete_openai_container("cntr_x"))
+
+
+def test_delete_propagates_openai_4xx(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(404, json = {"error": {"message": "not found"}})
+
+    _mock_http_client(monkeypatch, handler)
+
+    with pytest.raises(httpx.HTTPStatusError):
+        _drive(_make_client().delete_openai_container("cntr_missing"))
diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index 0579f84896..fb63748bf1 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -13,6 +13,7 @@ import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
 import { Sources, SourcesGroup } from "@/components/assistant-ui/sources";
 import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
 import { ToolGroup } from "@/components/assistant-ui/tool-group";
+import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
 import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
 import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
 import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
@@ -750,9 +751,18 @@ const CodeToolsToggle: FC = () => {
     (s) => !!s.params.checkpoint && !s.modelLoading,
   );
   const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
+  // External providers have no local tool runtime, but Anthropic's
+  // Claude 4.x dispatches code_execution_20250825 server-side. The
+  // chat-page resolver stashes that capability in the runtime store
+  // (next to supportsBuiltinWebSearch). Mirror of shared-composer's
+  // codeDisabled so this pill lights up in active threads too.
+  const supportsBuiltinCodeExecution = useChatRuntimeStore(
+    (s) => s.supportsBuiltinCodeExecution,
+  );
   const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
   const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
-  const disabled = !(modelLoaded && supportsTools);
+  const disabled =
+    !modelLoaded || !(supportsTools || supportsBuiltinCodeExecution);
 
   return (
     
+        
+        {/* When no containers exist yet, render a disabled placeholder
+            instead of the picker. The first one is created by the
+            chat-adapter on first send (lazy-create) and will appear
+            here after the next refresh. */}
+        {sortedContainers.length === 0 ? (
+          
+ (none yet — will be created on first send) +
+ ) : ( + + )} + + + {/* Container list with delete actions — labeled and visually + quieter so it's clearly the "all containers, manage them" + area rather than the active selector above. */} +
+ + All containers + + {isLoading && containers.length === 0 ? ( + + ) : containers.length > 0 ? ( +
    + {containers.map((c) => { + const isActive = c.id === activeContainerId; + return ( +
  • +
    + + {c.name ?? "(unnamed)"} + {isActive ? ( + + · active + + ) : null} + + + {c.id} · TTL{" "} + {c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES}m + +
    + +
  • + ); + })} +
+ ) : ( +

+ No saved containers yet. Use auto-create or create a named + one below. +

+ )} +
+ + {/* Create new */} + {createOpen ? ( +
+ setCreateName(e.target.value)} + className="h-8 text-sm" + /> +
+ { + const n = parseInt(e.target.value, 10); + if (!Number.isNaN(n)) + setCreateTtl(Math.min(Math.max(n, TTL_MIN), TTL_MAX)); + }} + className="h-8 w-24 text-sm" + aria-label="Idle timeout in minutes" + /> + min idle +
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/studio/frontend/src/features/chat/external-providers.ts b/studio/frontend/src/features/chat/external-providers.ts index 5f645042bf..89591d7b14 100644 --- a/studio/frontend/src/features/chat/external-providers.ts +++ b/studio/frontend/src/features/chat/external-providers.ts @@ -18,6 +18,13 @@ export interface ExternalProviderConfig { enablePromptCaching?: boolean; /** User-pinned: the loaded vLLM model supports `enable_thinking`. */ isReasoningModel?: boolean; + /** + * Default idle-timeout (in minutes) for newly created OpenAI shell + * containers. Pre-fills the "Create container" dialog and is the + * TTL the auto-create-per-thread path POSTs to /v1/containers with. + * OpenAI's hard default is 20. Only meaningful for OpenAI cloud. + */ + openaiContainerTtlMinutes?: number; createdAt: number; updatedAt: number; } @@ -226,6 +233,12 @@ function normalizeProvider(raw: ExternalProviderConfig): ExternalProviderConfig isReasoningModel: supportsProviderReasoningToggle(providerType) ? raw.isReasoningModel === true : undefined, + openaiContainerTtlMinutes: + providerType === "openai" && + typeof raw.openaiContainerTtlMinutes === "number" && + raw.openaiContainerTtlMinutes >= 1 + ? Math.min(raw.openaiContainerTtlMinutes, 10080) + : undefined, }; } diff --git a/studio/frontend/src/features/chat/lib/friendly-names.ts b/studio/frontend/src/features/chat/lib/friendly-names.ts new file mode 100644 index 0000000000..503008f761 --- /dev/null +++ b/studio/frontend/src/features/chat/lib/friendly-names.ts @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * Friendly default names for auto-created OpenAI shell containers. + * Used by the chat-adapter when the lazy-create path fires (Code pill + * on, no thread container yet, user has set a non-default TTL). The + * goal is a human-memorable label like "otter" or "harbor" instead of + * "chat-abc12345" — the user can still rename via the Studio-side + * alias map. + * + * The list is curated to: + * - Be unambiguous, non-offensive nouns from natural categories + * (animals, plants, geography, materials, weather). + * - Avoid technical / political / brand words that might read as + * odd in a chat UI. + * - Stay reasonably small so the bundle cost is negligible (~200 + * entries × ~7 bytes ≈ 1.5 KB). + * + * Collisions are tolerated — the container's real unique key is its + * ``cntr_*`` id, not its name. A short random hex suffix is appended + * to make accidental same-name collisions visually distinct in the + * picker list. + */ + +const WORDS = [ + // animals + "otter", + "falcon", + "heron", + "lynx", + "marten", + "stoat", + "raven", + "magpie", + "salmon", + "trout", + "perch", + "tortoise", + "gecko", + "iguana", + "axolotl", + "narwhal", + "manatee", + "dolphin", + "porpoise", + "octopus", + "cuttlefish", + "nautilus", + "starfish", + "urchin", + "anemone", + "coral", + "puffin", + "kestrel", + "osprey", + "buzzard", + "kingfisher", + "robin", + "wren", + "finch", + "sparrow", + "thrush", + "siskin", + "warbler", + "tanager", + "oriole", + "hare", + "badger", + "weasel", + "ferret", + "polecat", + "civet", + "tapir", + "okapi", + "ibex", + "chamois", + // plants & trees + "alder", + "aspen", + "birch", + "cedar", + "cypress", + "elder", + "elm", + "fir", + "ginkgo", + "hawthorn", + "hazel", + "hemlock", + "holly", + "juniper", + "larch", + "linden", + "maple", + "oak", + "olive", + "pine", + "rowan", + "spruce", + "sycamore", + "willow", + "yew", + "thistle", + "fern", + "moss", + "ivy", + "clover", + "heather", + "lavender", + "rosemary", + "sage", + "thyme", + "myrtle", + "laurel", + "magnolia", + // geography / landscape + "harbor", + "atoll", + "lagoon", + "estuary", + "fjord", + "delta", + "isthmus", + "mesa", + "plateau", + "valley", + "ridge", + "summit", + "glade", + "meadow", + "moor", + "heath", + "tundra", + "savanna", + "prairie", + "steppe", + "bayou", + "marsh", + "fen", + "grotto", + "cavern", + "canyon", + "ravine", + "gorge", + "knoll", + "dell", + "vale", + "coast", + // materials / minerals / colors + "amber", + "agate", + "onyx", + "opal", + "jade", + "quartz", + "obsidian", + "basalt", + "granite", + "marble", + "slate", + "flint", + "lapis", + "topaz", + "garnet", + "pearl", + "coral", + "ivory", + "ebony", + "copper", + "cobalt", + "indigo", + "saffron", + "vermilion", + "ochre", + "umber", + "sienna", + "russet", + // weather / sky / time + "aurora", + "comet", + "ember", + "frost", + "gale", + "harvest", + "monsoon", + "nebula", + "solstice", + "twilight", + "zephyr", + "drizzle", + "tempest", + "halcyon", + "equinox", + "rainbow", + "horizon", + "meridian", + "zenith", + "comet", + // misc tactile / cozy nouns + "lantern", + "kettle", + "compass", + "anchor", + "beacon", + "harbor", + "voyage", + "trellis", + "cottage", + "thicket", + "orchard", + "bramble", + "haystack", + "snowfall", + "campfire", +]; + +/** RFC 4122-ish 4-character lowercase hex suffix using crypto.randomUUID. */ +function randomHexSuffix(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID().replace(/-/g, "").slice(0, 4); + } + // Older browser fallback. Math.random is fine here — this is a + // display suffix, not a security token. + return Math.floor(Math.random() * 0xffff) + .toString(16) + .padStart(4, "0"); +} + +/** + * Returns a single English-word name with a short random hex suffix. + * + * Example output: "kestrel-3f9c", "harbor-a012". + * + * The suffix keeps containers visually distinguishable in the picker + * when the same word recurs across creations. + */ +export function pickFriendlyContainerName(): string { + const word = WORDS[Math.floor(Math.random() * WORDS.length)] ?? "container"; + return `${word}-${randomHexSuffix()}`; +} diff --git a/studio/frontend/src/features/chat/provider-capabilities.ts b/studio/frontend/src/features/chat/provider-capabilities.ts index 1c40a25773..3c9bff40b9 100644 --- a/studio/frontend/src/features/chat/provider-capabilities.ts +++ b/studio/frontend/src/features/chat/provider-capabilities.ts @@ -121,6 +121,88 @@ export function providerSupportsBuiltinWebSearch( ); } +/** + * Whether the selected external provider/model exposes a server-side + * code-execution tool. Two providers ship one today: + * + * - **Anthropic** (`code_execution_20250825`): Python + bash + + * str_replace-based file edits inside a 5 GB sandboxed container + * per request. Documented at + * https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool + * + * - **OpenAI cloud** (`shell` on /v1/responses): bash inside a + * reusable container; we auto-create one on the first turn of a + * chat thread and reference it on subsequent turns via the + * thread's stored `openaiCodeExecContainerId`. Documented at + * https://developers.openai.com/api/docs/guides/tools-shell + * + * Returns false for every other provider. The backend additionally + * gates the OpenAI shell tool on `is_openai_cloud` so custom + * OpenAI-compat servers (ollama / llama.cpp / vLLM) that also report + * `provider_type="openai"` never receive the tool — but in practice + * none of those catalogs surface the `gpt-5.5` ids anyway, so the + * frontend prefix match is enough. + * + * v1 wires the tools themselves; file uploads (Anthropic + * `container_upload` / OpenAI `input_file`) are a deliberate follow-up. + */ +const ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES = [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-haiku-4-5", + // Deprecated upstream but the registry still exposes the ids, so the + // pill should remain functional for users on those snapshots. + "claude-opus-4-1", + "claude-opus-4", + "claude-sonnet-4", +] as const; + +// OpenAI cloud shell-tool gating. Docs only explicitly demonstrate +// gpt-5.5; gpt-5.5-pro is included because the family share the same +// /v1/responses contract. `gpt-5.5-pro` is checked first so the prefix +// match doesn't collide with a hypothetical `gpt-5.5-turbo` etc. +const OPENAI_CODE_EXECUTION_MODEL_PREFIXES = [ + "gpt-5.5-pro", + "gpt-5.5", +] as const; + +/** + * Strict check that a provider configuration points at OpenAI's + * managed cloud (api.openai.com), as opposed to a custom OpenAI-compat + * backend (ollama / llama.cpp / vLLM / generic "custom" preset). The + * shell tool ONLY exists on OpenAI cloud; sending it to anything else + * 400s the request. Mirror of the backend's + * `is_openai_cloud = "api.openai.com" in self.base_url` guard. + */ +function isOpenAICloudBaseUrl(baseUrl: string | null | undefined): boolean { + if (!baseUrl) return true; // No override → uses the default openai.com base. + return baseUrl.trim().toLowerCase().includes("api.openai.com"); +} + +export function providerSupportsBuiltinCodeExecution( + providerType: string | null | undefined, + modelId: string | null | undefined, + baseUrl?: string | null, +): boolean { + const normalized = modelId?.trim().toLowerCase() ?? ""; + if (!normalized) return false; + if (providerType === "anthropic") { + return ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES.some((prefix) => + normalized.startsWith(prefix), + ); + } + if (providerType === "openai") { + if (!isOpenAICloudBaseUrl(baseUrl)) return false; + return OPENAI_CODE_EXECUTION_MODEL_PREFIXES.some((prefix) => + normalized.startsWith(prefix), + ); + } + return false; +} + /** * Per-provider minimum on the outbound max_tokens. Kimi's docs require * `max_tokens >= 16000` whenever a thinking model is in use so the diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 1133e759ac..b019fd2d4d 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -396,7 +396,7 @@ function toThreadMessage(m: MessageRecord): ThreadMessage { }; } -async function ensureThreadRecord({ +export async function ensureThreadRecord({ threadId, modelType, pairId, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index da32dee3a4..c4ffa98467 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -24,7 +24,10 @@ import { type ReasoningEffort, useChatRuntimeStore, } from "./stores/chat-runtime-store"; -import { getExternalReasoningCapabilities } from "./provider-capabilities"; +import { + getExternalReasoningCapabilities, + providerSupportsBuiltinCodeExecution, +} from "./provider-capabilities"; import { type CompositionEvent, type KeyboardEvent, @@ -367,13 +370,21 @@ export function SharedComposer({ // Two-pill gating: Search pill lights up when the runtime has either // a local tool runtime (supportsTools, gives us our Code/python + local // web_search) OR a server-side web_search the provider runs for us - // (supportsBuiltinWebSearch, currently just OpenAI's /v1/responses). - // Code pill is gated on `supportsTools` only — external providers - // never give us code execution, so the pill must stay disabled even - // when Search is available. + // (supportsBuiltinWebSearch, currently OpenAI / Anthropic / OpenRouter + // / Kimi). Code pill lights up on the local runtime OR when Anthropic + // is selected with a model that accepts the server-side + // code_execution_20250825 tool — see + // providerSupportsBuiltinCodeExecution. Anthropic is the only external + // provider that ships a code-execution tool today. + const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution( + selectedExternalProvider?.providerType, + effectiveExternalModelId, + selectedExternalProvider?.baseUrl, + ); const searchDisabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch); - const codeDisabled = !modelLoaded || !supportsTools; + const codeDisabled = + !modelLoaded || !(supportsTools || supportsBuiltinCodeExecution); // Backwards-compatible alias for any other call site that may still // reference `toolsDisabled` (rare; both pills used it before). const toolsDisabled = codeDisabled; diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 4cbed5899b..9a80d12da4 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -234,11 +234,19 @@ type ChatRuntimeStore = { * web_search tool (OpenAI's /v1/responses today). Distinct from * `supportsTools` — that flag governs the local tool runtime (Code, * python sandbox, our DuckDuckGo web_search). This one only enables - * the chat composer's Search pill for external models and leaves - * the Code pill disabled, because external providers do not give - * us code execution. Local models keep `supportsTools` only. + * the chat composer's Search pill for external models. Local models + * keep `supportsTools` only. */ supportsBuiltinWebSearch: boolean; + /** + * Whether the active external provider exposes a server-side + * code-execution tool (Anthropic's `code_execution_20250825` on the + * Claude 4.x family). Distinct from `supportsTools` for the same + * reason as `supportsBuiltinWebSearch`: external providers don't + * give us a local tool runtime, but Anthropic dispatches code + * execution server-side. Read by both composers' Code pill gate. + */ + supportsBuiltinCodeExecution: boolean; toolsEnabled: boolean; codeToolsEnabled: boolean; toolStatus: string | null; @@ -331,6 +339,7 @@ export const useChatRuntimeStore = create((set) => ({ preserveThinking: loadBool(PRESERVE_THINKING_KEY, false), supportsTools: false, supportsBuiltinWebSearch: false, + supportsBuiltinCodeExecution: false, toolsEnabled: false, codeToolsEnabled: false, toolStatus: null, @@ -442,6 +451,7 @@ export const useChatRuntimeStore = create((set) => ({ supportsPreserveThinking: false, supportsTools: false, supportsBuiltinWebSearch: false, + supportsBuiltinCodeExecution: false, toolsEnabled: false, codeToolsEnabled: false, toolStatus: null, diff --git a/studio/frontend/src/features/chat/types.ts b/studio/frontend/src/features/chat/types.ts index 1f370b6ac1..eb88da635d 100644 --- a/studio/frontend/src/features/chat/types.ts +++ b/studio/frontend/src/features/chat/types.ts @@ -15,6 +15,22 @@ export interface ThreadRecord { pairId?: string; archived: boolean; createdAt: number; + /** + * OpenAI shell tool container id captured from a prior response on + * this thread. When set, the next turn reuses it via + * `environment.type="container_reference"` so the model can read + * files it wrote earlier in the conversation. When null/undefined, + * the next turn auto-creates a fresh container. + * + * OpenAI containers expire after ~20 min of inactivity by default; + * if a stale id is sent, the backend surfaces an + * `_toolEvent.type="container_invalidated"` and the chat-adapter + * clears this field so the following turn falls back to auto-create. + * + * Anthropic's code-execution path doesn't need this — each turn + * gets a fresh container server-side. + */ + openaiCodeExecContainerId?: string | null; } export interface MessageRecord { diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 18270a7620..ec4a5ea355 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -225,6 +225,16 @@ export interface OpenAIChatCompletionsRequest { encrypted_api_key?: string; provider_base_url?: string | null; enable_prompt_caching?: boolean | null; + /** + * OpenAI shell-tool container id captured from the prior response in + * this chat thread. When set and the Code pill is on, the backend + * routes the next /v1/responses call with + * `environment.type="container_reference"` so filesystem state + * persists across turns. Unset → backend uses + * `environment.type="container_auto"` and OpenAI creates a fresh + * container. Only meaningful for OpenAI cloud + gpt-5.5 family. + */ + openai_code_exec_container_id?: string | null; } export interface OpenAIChatDelta {