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 (