From 2622b79606bf0ed57844cede926562b827cd6fca Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Fri, 15 May 2026 23:39:06 +0400 Subject: [PATCH] studio/chat: built-in code execution for OpenAI + Anthropic (#5461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * studio/chat: built-in code execution for Anthropic Claude 4.x Wire Anthropic's server-side code_execution_20250825 tool to the existing Code pill in the composer. Pill lights up only for Claude Opus/Sonnet/Haiku 4.x models that the docs list as compatible; pairs independently with Search. Backend appends the tool entry plus the code-execution-2025-08-25 beta header, and translates the SSE server_tool_use / *_tool_result blocks (bash + text_editor sub-tools) into the _toolEvent shape the frontend renderer consumes. File uploads via the Files API are a deliberate follow-up. * studio/chat: enable code execution pill in in-thread composer too thread.tsx renders its own composer with a separate CodeToolsToggle that was still gated on supportsTools only, so the pill stayed disabled inside an active thread even after picking Anthropic 4.x. Surface the capability through the runtime store (supportsBuiltinCodeExecution, set from chat-page alongside supportsBuiltinWebSearch) and read it in the toggle. * studio/chat: built-in code execution for OpenAI cloud gpt-5.5 Extend the Code pill to OpenAI cloud's gpt-5.5 / gpt-5.5-pro via the shell tool on /v1/responses. Per-thread container reuse: capture the container_id from each response on a synthetic container_ready event, persist it onto the ThreadRecord, and pass it back as environment.type="container_reference" on follow-up turns so the model sees filesystem state from prior turns until OpenAI's idle expiry. Stale ids surface a container_invalidated event that clears the thread record so the next turn falls back to container_auto. Gated strictly on OpenAI cloud (api.openai.com base URL) — Ollama, llama.cpp, vLLM, and custom OpenAI-compat presets won't see the shell tool entry even when their providerType collapses to "openai". * studio/chat: OpenAI shell-tool container management UI Side-panel section (settings sheet → Code Execution) for managing OpenAI's shell-tool containers per thread. Three controls: - New-container idle timeout (provider-level default, pre-fills the create dialog and is used by the lazy-create path on a thread's first turn when set to a non-default value). - Active container picker for the active thread — pick any existing container or stay on "Auto-create per thread". - Inline create form (name + idle TTL) and per-row delete actions. Three new backend endpoints under /api/inference/external/openai/ containers/{list,create,delete} proxy to OpenAI /v1/containers using the encrypted API key. All three reject non-cloud base URLs up front so the picker stays scoped to api.openai.com. Deleting a container clears all thread bindings pointing at it; the next turn falls back to auto-create. * studio/chat: inherit container across threads + styled active picker New threads on the same OpenAI provider now default to the most recently used container instead of "Auto-create per thread" — both in the chat-adapter (so a send works even if the side panel was never opened) and in the side panel itself (auto-binds the active thread when the dropdown loads on a thread that has no container). Picker is visually emphasized with an accent panel and the currently-active row in the list below is highlighted with the same accent so the two views stay in sync. * studio/chat: friendly English-word names for auto-created containers Replaces the "chat-" auto-name with a random English-word + short hex suffix (e.g. "kestrel-3f9c"). Applies only to the chat-adapter's lazy-create path; the OpenAI container_auto path stays unnamed (only fires when no custom TTL is set). * studio/chat: always pre-create OpenAI containers via frontend Drops the TTL-based gate on the chat-adapter's lazy-create path so every code-execution container the user ever sees in the picker has a friendly English-word name. The backend's container_auto fallback stays as a safety net (used only if the POST /v1/containers call fails); in practice that branch should be rare. * studio/chat: send OpenAI-Beta header for /v1/containers CRUD Without OpenAI-Beta: containers=v1, OpenAI returns 200 {"deleted": true} for DELETE /v1/containers/{id} but does not actually remove the container. The list call then keeps returning it, making it look like Studio's "Delete container" button is broken. Verified 2026-05-15 against api.openai.com: DELETE with the beta header returns 200 and removes the container; the same DELETE without the header returns the same 200 deleted:true body but the container stays alive. - Add _container_headers() that merges OpenAI-Beta on top of the shared auth headers; route list / create / delete through it. - Verify the DELETE response body reports {"deleted": true}; raise httpx.HTTPError otherwise so the route surfaces a 5xx instead of silently reporting success on a silent no-op. - Add tests covering header propagation and the deleted-flag guard (true, false, missing key, non-JSON body, 4xx passthrough). * studio/chat: surface unpersisted-thread picker no-op as a toast The "Active for this thread" container picker uses db.threads.update(activeThreadId, ...), which silently returns 0 rows affected when the thread record isn't yet in IndexedDB. That happens on a brand-new thread where the user toggles code execution on and opens settings before sending the first message — the chat adapter only materializes the thread row on first send. The picker would appear to ignore the user's selection and snap back to "Auto-create per thread". - onPick now awaits the update and toasts an actionable hint ("Send a message first to pin a container to this thread.") when the update affected zero rows. - Auto-bind effect comment clarifies why it stays best-effort silent. The auto-bind effect itself is unchanged: it's a heuristic that should not nag the user when it can't apply. * studio/chat: let user pick OpenAI container before first send Previously the picker silently no-op'd until the user sent the first message, because Dexie's ThreadRecord is only materialized inside the runtime-provider's `initialize` hook (assistant-ui's first-message callback). That kept users from binding a thread to an existing OpenAI container up front; they had to either send a message and risk the chat adapter auto-creating one, or accept the cross-thread inheritance default. - Export `ensureThreadRecord` from runtime-provider so other surfaces can materialize the row idempotently. - In OpenAICodeExecSection.onPick, await ensureThreadRecord before the update, with modelType="base" (the settings sheet that hosts this section is only rendered in single-thread mode). Behaviour after this commit: - New thread + user picks a container in the sidebar → thread row is created with that container_id; first send uses it, no auto-create. - New thread + user does nothing → row still absent; first send goes through the existing inherit/lazy-create path as before. - The auto-bind effect remains silent best-effort: it does not eagerly create the thread row, so it cannot pre-empt the user's pick on a fresh thread. * studio/chat: drop "Auto-create per thread" option, default to latest The dropdown previously offered "Auto-create per thread" as an explicit value (null in storage), with the chat-adapter then inheriting from the most recent container at send-time. That made the picker display disagree with what the backend would actually do: the picker said "auto", but the backend was reusing an existing container. Behaviour after this commit, when code execution is enabled on an OpenAI cloud provider: - Containers list non-empty: dropdown defaults to the container with the latest lastActiveAt, eagerly bound via ensureThreadRecord + db.threads.update so the bind survives even when the thread row has not been materialized by the chat adapter yet. User can pick any other container in the list. - Containers list empty: render a disabled placeholder "(none yet — will be created on first send)". The chat-adapter's lazy-create path (chat-adapter.ts:1040-1082) mints the first container on first send and writes it back to the thread; the next refresh surfaces it in the picker. Expiration mid-operation is unchanged: the existing container_invalidated _toolEvent clears the thread's stored id and the next turn re-creates. * studio/chat: fix picker stuck on "Selecting most recent…" + manual-create binding Two follow-up fixes to the picker rework in d0cbeb99b. 1) The dropdown was getting stuck on the "Selecting most recent…" placeholder option even after the auto-bind write completed, because the select was controlled by `activeContainerId` (whatever sits in Dexie) and there's a brief window between the auto-bind firing and useLiveQuery propagating the new row back. Decoupled the rendered value from the Dexie state: compute the displayed id locally as `activeContainerId ?? sortedContainers[0]?.id`, so the most-recent container's name shows up immediately. The auto-bind effect still writes the bind to Dexie so the chat adapter sees it on send. Dropped the placeholder option entirely. 2) The manual "Create container" flow (`onCreate`) bound the new container to the active thread with a bare `db.threads.update`. On a brand-new thread that hadn't been materialized yet, the update affected 0 rows; the user's next send then went through cross-thread inheritance / lazy-create and could land on a stale container, surfacing as "container does not exist". Same fix as `onPick`: ensureThreadRecord before update so the bind lands. --- .../core/inference/external_provider.py | 594 +++++++++++++++++- studio/backend/models/inference.py | 75 +++ studio/backend/routes/inference.py | 158 +++++ .../tests/test_anthropic_code_execution.py | 419 ++++++++++++ .../tests/test_openai_code_execution.py | 391 ++++++++++++ .../tests/test_openai_container_crud.py | 151 +++++ .../src/components/assistant-ui/thread.tsx | 13 +- .../assistant-ui/tool-ui-code-execution.tsx | 126 ++++ .../src/features/chat/api/chat-adapter.ts | 178 +++++- .../features/chat/api/openai-containers.ts | 121 ++++ .../frontend/src/features/chat/chat-page.tsx | 36 +- .../src/features/chat/chat-settings-sheet.tsx | 30 + .../components/openai-code-exec-section.tsx | 477 ++++++++++++++ .../src/features/chat/external-providers.ts | 13 + .../src/features/chat/lib/friendly-names.ts | 244 +++++++ .../features/chat/provider-capabilities.ts | 82 +++ .../src/features/chat/runtime-provider.tsx | 2 +- .../src/features/chat/shared-composer.tsx | 23 +- .../chat/stores/chat-runtime-store.ts | 16 +- studio/frontend/src/features/chat/types.ts | 16 + .../frontend/src/features/chat/types/api.ts | 10 + 21 files changed, 3128 insertions(+), 47 deletions(-) create mode 100644 studio/backend/tests/test_anthropic_code_execution.py create mode 100644 studio/backend/tests/test_openai_code_execution.py create mode 100644 studio/backend/tests/test_openai_container_crud.py create mode 100644 studio/frontend/src/components/assistant-ui/tool-ui-code-execution.tsx create mode 100644 studio/frontend/src/features/chat/api/openai-containers.ts create mode 100644 studio/frontend/src/features/chat/components/openai-code-exec-section.tsx create mode 100644 studio/frontend/src/features/chat/lib/friendly-names.ts 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 {