From 53593f697d4b0366c560f8564a2a9c38ee709286 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Sat, 20 Jun 2026 16:30:01 -0700 Subject: [PATCH] feat(sandbox): e2b code execution primitive (#30898) * feat(sandbox): add e2b code execution primitive Add a provider-agnostic code execution primitive that runs model-generated code in an isolated sandbox and returns the output, with e2b as the first backend over raw httpx (no SDK dependency). Public API: litellm.acode_interpreter_tool (ephemeral create -> run -> delete) plus the low-level lifecycle litellm.acreate_sandbox / arun_code / adelete_sandbox. Each is @client-decorated so operations are logged like litellm.asearch. Backends implement BaseSandboxConfig; resolved via ProviderConfigManager.get_provider_sandbox_config. * fix(sandbox): address review feedback and CI gates - document e2b provider in provider_endpoints_support.json and add a sandbox endpoint definition - regenerate dashboard CallTypes after the sandbox call-type additions - guard explicit timeout=0 instead of coercing it to the default - require a ContainerHandle access token before running code; reject bare-id runs - return False on a 404 delete now that the shared http handler raises for status - skip non-JSON NDJSON lines and cap streamed output to bound memory - move the real-network integration tests out of tests/test_litellm into tests/integration/sandbox * fix(sandbox): satisfy strict ruff gate and scope star-exports - modernize annotations in the new sandbox modules to PEP 585/604 (list/dict, X | None) and drop the now-unnecessary quoted forward refs so the strict-rule budget delta for UP006/UP037/UP045 returns to zero - add __all__ to litellm/sandbox/main.py so 'import *' only re-exports the four public entrypoints instead of leaking module-level imports * fix(sandbox): drop quotes on sandbox config return annotation utils.py uses 'from __future__ import annotations', so the quoted forward ref tripped UP037; the unquoted union is lazily evaluated and keeps the strict-rule delta at zero * chore(sandbox): re-trigger automated review after addressing feedback --- litellm/__init__.py | 1 + litellm/llms/base_llm/sandbox/__init__.py | 0 .../llms/base_llm/sandbox/transformation.py | 79 +++++ litellm/llms/e2b/__init__.py | 0 litellm/llms/e2b/sandbox/__init__.py | 0 litellm/llms/e2b/sandbox/transformation.py | 219 +++++++++++++ litellm/sandbox/__init__.py | 0 litellm/sandbox/main.py | 145 +++++++++ litellm/types/llms/custom_http.py | 1 + litellm/types/utils.py | 17 + litellm/utils.py | 15 + provider_endpoints_support.json | 24 ++ tests/integration/sandbox/test_e2b_sandbox.py | 39 +++ .../test_litellm/sandbox/test_e2b_sandbox.py | 297 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 15 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 litellm/llms/base_llm/sandbox/__init__.py create mode 100644 litellm/llms/base_llm/sandbox/transformation.py create mode 100644 litellm/llms/e2b/__init__.py create mode 100644 litellm/llms/e2b/sandbox/__init__.py create mode 100644 litellm/llms/e2b/sandbox/transformation.py create mode 100644 litellm/sandbox/__init__.py create mode 100644 litellm/sandbox/main.py create mode 100644 tests/integration/sandbox/test_e2b_sandbox.py create mode 100644 tests/test_litellm/sandbox/test_e2b_sandbox.py diff --git a/litellm/__init__.py b/litellm/__init__.py index cffdbacf597..d21234d2a81 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1401,6 +1401,7 @@ from .skills.main import ( from .containers.main import * from .ocr.main import * from .rag.main import * +from .sandbox.main import * from .search.main import * from .realtime_api.main import ( _arealtime, diff --git a/litellm/llms/base_llm/sandbox/__init__.py b/litellm/llms/base_llm/sandbox/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/base_llm/sandbox/transformation.py b/litellm/llms/base_llm/sandbox/transformation.py new file mode 100644 index 00000000000..6ad945f47a3 --- /dev/null +++ b/litellm/llms/base_llm/sandbox/transformation.py @@ -0,0 +1,79 @@ +""" +Base Sandbox transformation configuration. + +A sandbox provider runs an executable string inside an isolated container and +returns whatever the sandbox produced. The lifecycle is create container -> +run code -> delete container; `code_interpreter_tool` combines all three. +""" + +from typing import Any, Union + +from pydantic import Field, PrivateAttr + +from litellm.types.llms.base import LiteLLMPydanticObjectBase + + +class ContainerHandle(LiteLLMPydanticObjectBase): + """A live sandbox container. Carries everything needed to reach it again.""" + + id: str + provider: str + domain: str | None = None + + model_config = {"extra": "allow"} + + _hidden_params: dict = PrivateAttr(default_factory=dict) + + +class CodeExecutionResult(LiteLLMPydanticObjectBase): + """Passthrough of the sandbox's own execution output.""" + + stdout: str = "" + stderr: str = "" + results: list[dict[str, Any]] = Field(default_factory=list) + error: dict[str, Any] | None = None + execution_count: int | None = None + object: str = "code_execution" + + model_config = {"extra": "allow"} + + _hidden_params: dict = PrivateAttr(default_factory=dict) + + +class BaseSandboxConfig: + """Provider-agnostic sandbox operations.""" + + def validate_environment(self, api_key: str | None = None, **kwargs) -> str: + raise NotImplementedError( + "validate_environment must be implemented by provider" + ) + + async def acreate_sandbox( + self, + *, + template: str | None = None, + timeout: int | None = None, + allow_internet_access: bool = True, + api_key: str | None = None, + **kwargs, + ) -> ContainerHandle: + raise NotImplementedError("acreate_sandbox must be implemented by provider") + + async def arun_code( + self, + *, + container: Union[ContainerHandle, str], + code: str, + api_key: str | None = None, + **kwargs, + ) -> CodeExecutionResult: + raise NotImplementedError("arun_code must be implemented by provider") + + async def adelete_sandbox( + self, + *, + container: Union[ContainerHandle, str], + api_key: str | None = None, + **kwargs, + ) -> bool: + raise NotImplementedError("adelete_sandbox must be implemented by provider") diff --git a/litellm/llms/e2b/__init__.py b/litellm/llms/e2b/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/e2b/sandbox/__init__.py b/litellm/llms/e2b/sandbox/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/e2b/sandbox/transformation.py b/litellm/llms/e2b/sandbox/transformation.py new file mode 100644 index 00000000000..1ce28bc55fb --- /dev/null +++ b/litellm/llms/e2b/sandbox/transformation.py @@ -0,0 +1,219 @@ +""" +e2b sandbox provider. + +Talks to e2b's REST API directly over httpx (no e2b SDK dependency): + - create: POST {api_base}/sandboxes + - run: POST https://{JUPYTER_PORT}-{sandboxID}.{domain}/execute (NDJSON stream) + - delete: DELETE {api_base}/sandboxes/{sandboxID} +""" + +import json +from typing import Union, cast + +import httpx + +from litellm.llms.base_llm.sandbox.transformation import ( + BaseSandboxConfig, + CodeExecutionResult, + ContainerHandle, +) +from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, + get_async_httpx_client, +) +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.custom_http import httpxSpecialProvider + +E2B_API_BASE = "https://api.e2b.app" +E2B_DEFAULT_TEMPLATE = "code-interpreter-v1" +E2B_DEFAULT_DOMAIN = "e2b.app" +JUPYTER_PORT = 49999 +DEFAULT_SANDBOX_TIMEOUT = 300 +MAX_OUTPUT_BYTES = 10 * 1024 * 1024 + + +class E2BSandboxConfig(BaseSandboxConfig): + def _http(self, client: AsyncHTTPHandler | None) -> AsyncHTTPHandler: + if client is not None: + return client + return get_async_httpx_client(llm_provider=httpxSpecialProvider.Sandbox) + + def validate_environment(self, api_key: str | None = None, **kwargs) -> str: + key = api_key or get_secret_str("E2B_API_KEY") + if not key: + raise ValueError("E2B API key not set. Set E2B_API_KEY or pass api_key=...") + return key + + async def acreate_sandbox( + self, + *, + template: str | None = None, + timeout: int | None = None, + allow_internet_access: bool = True, + api_key: str | None = None, + metadata: dict | None = None, + client: AsyncHTTPHandler | None = None, + **kwargs, + ) -> ContainerHandle: + key = self.validate_environment(api_key=api_key) + body = { + "templateID": template or E2B_DEFAULT_TEMPLATE, + "timeout": timeout if timeout is not None else DEFAULT_SANDBOX_TIMEOUT, + "secure": True, + "allow_internet_access": allow_internet_access, + } + if metadata: + body["metadata"] = metadata + + response = cast( + httpx.Response, + await self._http(client).post( + url=f"{E2B_API_BASE}/sandboxes", + headers={"X-API-Key": key, "Content-Type": "application/json"}, + json=body, + ), + ) + data = response.json() + + handle = ContainerHandle( + id=data["sandboxID"], + provider="e2b", + domain=data.get("domain") or E2B_DEFAULT_DOMAIN, + ) + handle._hidden_params = { + "envd_access_token": data.get("envdAccessToken"), + "traffic_access_token": data.get("trafficAccessToken"), + "api_key": key, + } + return handle + + async def arun_code( + self, + *, + container: Union[ContainerHandle, str], + code: str, + api_key: str | None = None, + env_vars: dict | None = None, + client: AsyncHTTPHandler | None = None, + **kwargs, + ) -> CodeExecutionResult: + handle = self._as_handle(container) + + token = handle._hidden_params.get("envd_access_token") + if not token: + raise ValueError( + "Cannot run code from a sandbox id alone. e2b secure sandboxes " + "require the access token returned by acreate_sandbox; pass the " + "ContainerHandle it returned instead of a bare sandbox id." + ) + + headers = {"Content-Type": "application/json", "X-Access-Token": token} + traffic_token = handle._hidden_params.get("traffic_access_token") + if traffic_token: + headers["E2B-Traffic-Access-Token"] = traffic_token + + url = f"https://{JUPYTER_PORT}-{handle.id}.{handle.domain}/execute" + response = cast( + httpx.Response, + await self._http(client).post( + url=url, + headers=headers, + json={"code": code, "context_id": None, "env_vars": env_vars}, + stream=True, + ), + ) + lines = await self._read_capped_lines(response) + return self._parse_lines(lines) + + async def adelete_sandbox( + self, + *, + container: Union[ContainerHandle, str], + api_key: str | None = None, + client: AsyncHTTPHandler | None = None, + **kwargs, + ) -> bool: + handle = self._as_handle(container) + key = ( + api_key + or handle._hidden_params.get("api_key") + or self.validate_environment() + ) + try: + response = cast( + httpx.Response, + await self._http(client).delete( + url=f"{E2B_API_BASE}/sandboxes/{handle.id}", + headers={"X-API-Key": key}, + ), + ) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return False + raise + return 200 <= response.status_code < 300 + + @staticmethod + def _as_handle(container: Union[ContainerHandle, str]) -> ContainerHandle: + if isinstance(container, ContainerHandle): + return container + handle = ContainerHandle( + id=str(container), provider="e2b", domain=E2B_DEFAULT_DOMAIN + ) + handle._hidden_params = {} + return handle + + @staticmethod + async def _read_capped_lines(response: httpx.Response) -> list[str]: + lines: list[str] = [] + total = 0 + async for line in response.aiter_lines(): + total += len(line.encode("utf-8")) + if total > MAX_OUTPUT_BYTES: + raise ValueError( + f"Sandbox output exceeded {MAX_OUTPUT_BYTES} bytes; aborting to " + "avoid unbounded memory use." + ) + lines.append(line) + return lines + + @staticmethod + def _parse_lines(lines: list[str]) -> CodeExecutionResult: + def _try_parse(stripped: str): + try: + return json.loads(stripped) + except json.JSONDecodeError: + return None + + messages = tuple( + parsed + for stripped in (line.strip() for line in lines) + if stripped + for parsed in (_try_parse(stripped),) + if parsed is not None + ) + + def of_type(message_type: str): + return (m for m in messages if m.get("type") == message_type) + + error = next( + ( + {key: m.get(key) for key in ("name", "value", "traceback")} + for m in of_type("error") + ), + None, + ) + execution_count = next( + (m.get("execution_count") for m in of_type("number_of_executions")), + None, + ) + + return CodeExecutionResult( + stdout="".join(m.get("text", "") for m in of_type("stdout")), + stderr="".join(m.get("text", "") for m in of_type("stderr")), + results=[ + {k: v for k, v in m.items() if k != "type"} for m in of_type("result") + ], + error=error, + execution_count=execution_count, + ) diff --git a/litellm/sandbox/__init__.py b/litellm/sandbox/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/sandbox/main.py b/litellm/sandbox/main.py new file mode 100644 index 00000000000..a3f5f3b4665 --- /dev/null +++ b/litellm/sandbox/main.py @@ -0,0 +1,145 @@ +""" +Public entrypoints for running model-generated code in a sandbox. + +Low-level lifecycle: + acreate_container -> arun_code -> adelete_container + +High-level convenience: + acode_interpreter_tool (ephemeral: create -> run -> delete) + +Each entrypoint is `@client`-decorated, so every operation is logged the same +way `litellm.asearch` is. +""" + +from typing import Union + +import litellm +from litellm.llms.base_llm.sandbox.transformation import ( + BaseSandboxConfig, + CodeExecutionResult, + ContainerHandle, +) +from litellm.types.utils import SandboxProviders +from litellm.utils import ProviderConfigManager, client + +__all__ = [ + "acreate_sandbox", + "arun_code", + "adelete_sandbox", + "acode_interpreter_tool", +] + +_LITELLM_INTERNAL_KWARGS = { + "litellm_logging_obj", + "litellm_call_id", + "litellm_trace_id", + "litellm_metadata", +} + + +def _get_config(provider: str) -> BaseSandboxConfig: + config = ProviderConfigManager.get_provider_sandbox_config( + SandboxProviders(provider) + ) + if config is None: + raise ValueError(f"Code execution is not supported for provider: {provider}") + return config + + +def _forward_kwargs(kwargs: dict) -> dict: + return {k: v for k, v in kwargs.items() if k not in _LITELLM_INTERNAL_KWARGS} + + +def _update_logging(kwargs: dict, provider: str, operation: str) -> None: + logging_obj = kwargs.get("litellm_logging_obj") + if logging_obj is None: + return + logging_obj.update_from_kwargs( + kwargs=kwargs, + model=f"{provider}/{operation}", + optional_params={}, + litellm_params={"litellm_call_id": kwargs.get("litellm_call_id")}, + custom_llm_provider=provider, + ) + + +@client +async def acreate_sandbox( + provider: str, + template: str | None = None, + timeout: int | None = None, + allow_internet_access: bool = True, + api_key: str | None = None, + **kwargs, +) -> ContainerHandle: + _update_logging(kwargs, provider, "create_sandbox") + return await _get_config(provider).acreate_sandbox( + template=template, + timeout=timeout, + allow_internet_access=allow_internet_access, + api_key=api_key, + **_forward_kwargs(kwargs), + ) + + +@client +async def arun_code( + provider: str, + container: Union[ContainerHandle, str], + code: str, + api_key: str | None = None, + **kwargs, +) -> CodeExecutionResult: + _update_logging(kwargs, provider, "run_code") + return await _get_config(provider).arun_code( + container=container, + code=code, + api_key=api_key, + **_forward_kwargs(kwargs), + ) + + +@client +async def adelete_sandbox( + provider: str, + container: Union[ContainerHandle, str], + api_key: str | None = None, + **kwargs, +) -> bool: + _update_logging(kwargs, provider, "delete_sandbox") + return await _get_config(provider).adelete_sandbox( + container=container, + api_key=api_key, + **_forward_kwargs(kwargs), + ) + + +@client +async def acode_interpreter_tool( + provider: str, + code: str, + template: str | None = None, + timeout: int | None = None, + api_key: str | None = None, + **kwargs, +) -> CodeExecutionResult: + _update_logging(kwargs, provider, "code_interpreter_tool") + config = _get_config(provider) + forwarded = _forward_kwargs(kwargs) + + container = await config.acreate_sandbox( + template=template, timeout=timeout, api_key=api_key, **forwarded + ) + try: + return await config.arun_code( + container=container, code=code, api_key=api_key, **forwarded + ) + finally: + try: + await config.adelete_sandbox( + container=container, api_key=api_key, **forwarded + ) + except Exception as e: + litellm._logging.verbose_logger.debug( + f"sandbox: failed to delete ephemeral container: {e}" + ) diff --git a/litellm/types/llms/custom_http.py b/litellm/types/llms/custom_http.py index 792adb4182d..d7dfc0e486b 100644 --- a/litellm/types/llms/custom_http.py +++ b/litellm/types/llms/custom_http.py @@ -29,6 +29,7 @@ class httpxSpecialProvider(str, Enum): A2A = "a2a" PromptManagement = "prompt_management" UI = "ui" + Sandbox = "sandbox" VerifyTypes = Union[str, bool, ssl.SSLContext] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index f7a6a9bd643..32bfc8835fe 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -435,6 +435,14 @@ class CallTypes(str, Enum): alist_container_files = "alist_container_files" upload_container_file = "upload_container_file" aupload_container_file = "aupload_container_file" + create_sandbox = "create_sandbox" + acreate_sandbox = "acreate_sandbox" + delete_sandbox = "delete_sandbox" + adelete_sandbox = "adelete_sandbox" + run_code = "run_code" + arun_code = "arun_code" + code_interpreter_tool = "code_interpreter_tool" + acode_interpreter_tool = "acode_interpreter_tool" acancel_fine_tuning_job = "acancel_fine_tuning_job" cancel_fine_tuning_job = "cancel_fine_tuning_job" @@ -3490,6 +3498,15 @@ class SearchProviders(str, Enum): SearchProvidersSet = {provider.value for provider in SearchProviders} +class SandboxProviders(str, Enum): + """ + Enum for code execution sandbox provider types. + Separate from LlmProviders for semantic clarity. + """ + + E2B = "e2b" + + class LiteLLMLoggingBaseClass: """ Base class for logging pre and post call diff --git a/litellm/utils.py b/litellm/utils.py index c9001e7d906..29f703104da 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -192,6 +192,7 @@ from litellm.types.utils import ( ProviderField, ProviderSpecificModelInfo, RawRequestTypedDict, + SandboxProviders, SearchProviders, SelectTokenizerResponse, StreamingChoices, @@ -301,6 +302,7 @@ if TYPE_CHECKING: BaseGoogleGenAIGenerateContentConfig, ) from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig + from litellm.llms.base_llm.sandbox.transformation import BaseSandboxConfig from litellm.llms.base_llm.search.transformation import BaseSearchConfig from litellm.llms.base_llm.text_to_speech.transformation import ( BaseTextToSpeechConfig, @@ -9723,6 +9725,19 @@ class ProviderConfigManager: return None return config_class() + @staticmethod + def get_provider_sandbox_config( + provider: SandboxProviders, + ) -> BaseSandboxConfig | None: + """ + Get sandbox (code execution) configuration for a given provider. + """ + from litellm.llms.e2b.sandbox.transformation import E2BSandboxConfig + + if provider == SandboxProviders.E2B: + return E2BSandboxConfig() + return None + @staticmethod def get_provider_text_to_speech_config( model: str, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index f15a20a0db8..7386ced3e6d 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -831,6 +831,23 @@ "search": true } }, + "e2b": { + "display_name": "E2B (`e2b`)", + "url": "https://docs.litellm.ai/docs/providers/e2b", + "endpoints": { + "chat_completions": false, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "sandbox": true + } + }, "elevenlabs": { "display_name": "ElevenLabs (`elevenlabs`)", "url": "https://docs.litellm.ai/docs/providers/elevenlabs", @@ -3056,6 +3073,13 @@ "provider_json_field": "compact", "url": "https://docs.litellm.ai/docs/response_api" }, + "sandbox": { + "docs_label": "sandbox", + "display_name": "Code Execution Sandbox API", + "leftnav_label": "/sandbox", + "provider_json_field": "sandbox", + "url": "https://docs.litellm.ai/docs/providers/e2b" + }, "search": { "docs_label": "search", "display_name": "Search API", diff --git a/tests/integration/sandbox/test_e2b_sandbox.py b/tests/integration/sandbox/test_e2b_sandbox.py new file mode 100644 index 00000000000..d1cff2ce178 --- /dev/null +++ b/tests/integration/sandbox/test_e2b_sandbox.py @@ -0,0 +1,39 @@ +""" +e2b code execution sandbox - end-to-end integration tests. + +These tests make REAL HTTP calls to the e2b API and are skipped automatically +unless E2B_API_KEY is set. Mock-only unit tests live in +tests/test_litellm/sandbox/test_e2b_sandbox.py. + +Run only these tests: + pytest tests/integration/sandbox/test_e2b_sandbox.py -v +""" + +import os + +import pytest + +import litellm + + +@pytest.mark.skipif("E2B_API_KEY" not in os.environ, reason="needs a real E2B_API_KEY") +@pytest.mark.asyncio +async def test_integration_ephemeral_real_e2b(): + result = await litellm.acode_interpreter_tool( + provider="e2b", code="print(sum(range(10)))" + ) + assert result.stdout.strip() == "45" + assert result.error is None + + +@pytest.mark.skipif("E2B_API_KEY" not in os.environ, reason="needs a real E2B_API_KEY") +@pytest.mark.asyncio +async def test_integration_lifecycle_roundtrip_real_e2b(): + container = await litellm.acreate_sandbox(provider="e2b") + try: + result = await litellm.arun_code( + provider="e2b", container=container, code="print(6*7)" + ) + assert result.stdout.strip() == "42" + finally: + assert await litellm.adelete_sandbox(provider="e2b", container=container) diff --git a/tests/test_litellm/sandbox/test_e2b_sandbox.py b/tests/test_litellm/sandbox/test_e2b_sandbox.py new file mode 100644 index 00000000000..948d39fac8f --- /dev/null +++ b/tests/test_litellm/sandbox/test_e2b_sandbox.py @@ -0,0 +1,297 @@ +""" +Tests for the e2b code execution sandbox primitive. + +Unit tests inject a fake async HTTP client (dependency injection, no +monkeypatching) and assert request shapes and result mapping. Real-network +integration tests live in tests/integration/sandbox/test_e2b_sandbox.py. +""" + +import json + +import httpx +import pytest + +import litellm +from litellm.llms.base_llm.sandbox.transformation import ContainerHandle +from litellm.llms.e2b.sandbox.transformation import ( + MAX_OUTPUT_BYTES, + E2BSandboxConfig, +) + + +class FakeResponse: + def __init__(self, *, json_data=None, lines=None, status_code=200): + self._json = json_data + self._lines = lines or [] + self.status_code = status_code + + def json(self): + return self._json + + async def aiter_lines(self): + for line in self._lines: + yield line + + +class FakeHTTPClient: + """Records outbound requests and returns canned responses keyed by URL.""" + + def __init__( + self, + *, + create_json=None, + execute_lines=None, + delete_status=204, + execute_raises=None, + ): + self.create_json = create_json or { + "sandboxID": "sbx_123", + "domain": "e2b.app", + "envdAccessToken": "tok_abc", + } + self.execute_lines = execute_lines or [] + self.delete_status = delete_status + self.execute_raises = execute_raises + self.calls = [] + + async def post(self, url, headers=None, json=None, stream=False, **kwargs): + self.calls.append(("POST", url, headers, json)) + if url.endswith("/sandboxes"): + return FakeResponse(json_data=self.create_json) + if url.endswith("/execute"): + if self.execute_raises is not None: + raise self.execute_raises + return FakeResponse(lines=self.execute_lines) + raise AssertionError(f"unexpected POST {url}") + + async def delete(self, url, headers=None, **kwargs): + self.calls.append(("DELETE", url, headers, None)) + if not (200 <= self.delete_status < 300): + raise httpx.HTTPStatusError( + f"status {self.delete_status}", + request=httpx.Request("DELETE", url), + response=httpx.Response(self.delete_status), + ) + return FakeResponse(status_code=self.delete_status) + + +# ---------- pure parser ---------- + + +def test_parse_lines_stdout_and_count(): + lines = [ + json.dumps({"type": "stdout", "text": "6\n", "timestamp": 1}), + json.dumps({"type": "number_of_executions", "execution_count": 1}), + ] + result = E2BSandboxConfig._parse_lines(lines) + assert result.stdout == "6\n" + assert result.execution_count == 1 + assert result.error is None + + +def test_parse_lines_error_surfaces_name_and_traceback(): + lines = [ + json.dumps( + { + "type": "error", + "name": "ZeroDivisionError", + "value": "division by zero", + "traceback": "Traceback (most recent call last): ...", + } + ) + ] + result = E2BSandboxConfig._parse_lines(lines) + assert result.error["name"] == "ZeroDivisionError" + assert "Traceback" in result.error["traceback"] + + +def test_parse_lines_result_carries_png(): + lines = [ + json.dumps({"type": "result", "png": "BASE64DATA", "is_main_result": True}) + ] + result = E2BSandboxConfig._parse_lines(lines) + assert result.results and result.results[0]["png"] == "BASE64DATA" + assert "type" not in result.results[0] + + +# ---------- request shapes ---------- + + +@pytest.mark.asyncio +async def test_template_flows_into_create_request_as_templateID(): + client = FakeHTTPClient() + cfg = E2BSandboxConfig() + handle = await cfg.acreate_sandbox( + template="my-custom-template", api_key="e2b_key", client=client + ) + + method, url, headers, body = client.calls[0] + assert method == "POST" + assert url.endswith("/sandboxes") + assert body["templateID"] == "my-custom-template" # not "template" + assert body["secure"] is True + assert headers["X-API-Key"] == "e2b_key" + assert handle.id == "sbx_123" + assert handle._hidden_params["envd_access_token"] == "tok_abc" + + +@pytest.mark.asyncio +async def test_create_defaults_template_when_omitted(): + client = FakeHTTPClient() + await E2BSandboxConfig().acreate_sandbox(api_key="e2b_key", client=client) + _, _, _, body = client.calls[0] + assert body["templateID"] == "code-interpreter-v1" + + +@pytest.mark.asyncio +async def test_run_code_targets_jupyter_host_with_access_token(): + client = FakeHTTPClient( + execute_lines=[json.dumps({"type": "stdout", "text": "42\n", "timestamp": 1})] + ) + handle = ContainerHandle(id="sbx_xyz", provider="e2b", domain="e2b.app") + handle._hidden_params = {"envd_access_token": "tok_run"} + + result = await E2BSandboxConfig().arun_code( + container=handle, code="print(6*7)", client=client + ) + + method, url, headers, body = client.calls[0] + assert url == "https://49999-sbx_xyz.e2b.app/execute" + assert headers["X-Access-Token"] == "tok_run" + assert body["code"] == "print(6*7)" + assert result.stdout.strip() == "42" + + +@pytest.mark.asyncio +async def test_delete_issues_delete_to_sandbox_id(): + client = FakeHTTPClient(delete_status=204) + handle = ContainerHandle(id="sbx_del", provider="e2b", domain="e2b.app") + handle._hidden_params = {"api_key": "e2b_key"} + + ok = await E2BSandboxConfig().adelete_sandbox(container=handle, client=client) + + method, url, headers, _ = client.calls[0] + assert method == "DELETE" + assert url.endswith("/sandboxes/sbx_del") + assert ok is True + + +@pytest.mark.asyncio +async def test_delete_returns_false_on_404(): + client = FakeHTTPClient(delete_status=404) + handle = ContainerHandle(id="sbx_gone", provider="e2b", domain="e2b.app") + handle._hidden_params = {"api_key": "e2b_key"} + ok = await E2BSandboxConfig().adelete_sandbox(container=handle, client=client) + assert ok is False + + +# ---------- ephemeral teardown ---------- + + +@pytest.mark.asyncio +async def test_code_interpreter_tool_deletes_even_when_run_raises(): + client = FakeHTTPClient(execute_raises=RuntimeError("boom")) + + with pytest.raises(RuntimeError, match="boom"): + await litellm.acode_interpreter_tool( + provider="e2b", code="1/0", api_key="e2b_key", client=client + ) + + methods = [c[0] for c in client.calls] + urls = [c[1] for c in client.calls] + assert methods == ["POST", "POST", "DELETE"] # create, run(raises), delete + assert urls[0].endswith("/sandboxes") + assert urls[1].endswith("/execute") + assert urls[2].endswith("/sandboxes/sbx_123") + + +# ---------- correctness guards ---------- + + +@pytest.mark.asyncio +async def test_delete_reraises_non_404_http_error(): + client = FakeHTTPClient(delete_status=500) + handle = ContainerHandle(id="sbx_err", provider="e2b", domain="e2b.app") + handle._hidden_params = {"api_key": "e2b_key"} + with pytest.raises(httpx.HTTPStatusError): + await E2BSandboxConfig().adelete_sandbox(container=handle, client=client) + + +@pytest.mark.asyncio +async def test_create_preserves_explicit_zero_timeout(): + client = FakeHTTPClient() + await E2BSandboxConfig().acreate_sandbox( + timeout=0, api_key="e2b_key", client=client + ) + _, _, _, body = client.calls[0] + assert body["timeout"] == 0 + + +@pytest.mark.asyncio +async def test_run_code_rejects_bare_id_without_access_token(): + client = FakeHTTPClient() + with pytest.raises(ValueError, match="access token"): + await E2BSandboxConfig().arun_code( + container="sbx_no_token", code="print(1)", client=client + ) + assert client.calls == [] # never reached the network + + +def test_parse_lines_skips_non_json_lines(): + lines = [ + "not-json-heartbeat", + json.dumps({"type": "stdout", "text": "ok\n"}), + "", + "{partial", + ] + result = E2BSandboxConfig._parse_lines(lines) + assert result.stdout == "ok\n" + assert result.error is None + + +@pytest.mark.asyncio +async def test_run_code_aborts_on_output_over_cap(): + big_line = "x" * (MAX_OUTPUT_BYTES + 1) + client = FakeHTTPClient(execute_lines=[big_line]) + handle = ContainerHandle(id="sbx_big", provider="e2b", domain="e2b.app") + handle._hidden_params = {"envd_access_token": "tok"} + with pytest.raises(ValueError, match="exceeded"): + await E2BSandboxConfig().arun_code( + container=handle, code="print('x'*999)", client=client + ) + + +# ---------- public entrypoints ---------- + + +@pytest.mark.asyncio +async def test_public_lifecycle_create_run_delete(): + client = FakeHTTPClient( + execute_lines=[json.dumps({"type": "stdout", "text": "42\n"})] + ) + container = await litellm.acreate_sandbox( + provider="e2b", api_key="e2b_key", client=client + ) + assert container.id == "sbx_123" + + result = await litellm.arun_code( + provider="e2b", + container=container, + api_key="e2b_key", + code="print(6*7)", + client=client, + ) + assert result.stdout.strip() == "42" + + assert ( + await litellm.adelete_sandbox( + provider="e2b", container=container, api_key="e2b_key", client=client + ) + is True + ) + + +@pytest.mark.asyncio +async def test_unsupported_provider_raises(): + with pytest.raises(ValueError): + await litellm.acreate_sandbox(provider="not-a-provider") diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6f25472db62..63e788b59c8 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -21352,7 +21352,7 @@ export interface components { * CallTypes * @enum {string} */ - CallTypes: "embedding" | "aembedding" | "completion" | "acompletion" | "atext_completion" | "text_completion" | "image_generation" | "aimage_generation" | "image_edit" | "aimage_edit" | "moderation" | "amoderation" | "atranscription" | "transcription" | "aspeech" | "speech" | "rerank" | "arerank" | "search" | "asearch" | "_arealtime" | "_aresponses_websocket" | "create_batch" | "acreate_batch" | "aretrieve_batch" | "retrieve_batch" | "acancel_batch" | "cancel_batch" | "pass_through_endpoint" | "anthropic_messages" | "get_assistants" | "aget_assistants" | "create_assistants" | "acreate_assistants" | "delete_assistant" | "adelete_assistant" | "acreate_thread" | "create_thread" | "aget_thread" | "get_thread" | "a_add_message" | "add_message" | "aget_messages" | "get_messages" | "arun_thread" | "run_thread" | "arun_thread_stream" | "run_thread_stream" | "afile_retrieve" | "file_retrieve" | "afile_delete" | "file_delete" | "afile_list" | "file_list" | "acreate_file" | "create_file" | "afile_content" | "file_content" | "create_fine_tuning_job" | "acreate_fine_tuning_job" | "create_video" | "acreate_video" | "avideo_retrieve" | "video_retrieve" | "avideo_content" | "video_content" | "video_remix" | "avideo_remix" | "video_list" | "avideo_list" | "video_retrieve_job" | "avideo_retrieve_job" | "video_delete" | "avideo_delete" | "video_create_character" | "avideo_create_character" | "video_get_character" | "avideo_get_character" | "video_edit" | "avideo_edit" | "video_extension" | "avideo_extension" | "vector_store_file_create" | "avector_store_file_create" | "vector_store_file_list" | "avector_store_file_list" | "vector_store_file_retrieve" | "avector_store_file_retrieve" | "vector_store_file_content" | "avector_store_file_content" | "vector_store_file_update" | "avector_store_file_update" | "vector_store_file_delete" | "avector_store_file_delete" | "vector_store_create" | "avector_store_create" | "vector_store_search" | "avector_store_search" | "create_container" | "acreate_container" | "list_containers" | "alist_containers" | "retrieve_container" | "aretrieve_container" | "delete_container" | "adelete_container" | "list_container_files" | "alist_container_files" | "upload_container_file" | "aupload_container_file" | "acancel_fine_tuning_job" | "cancel_fine_tuning_job" | "alist_fine_tuning_jobs" | "list_fine_tuning_jobs" | "aretrieve_fine_tuning_job" | "retrieve_fine_tuning_job" | "responses" | "aresponses" | "alist_input_items" | "llm_passthrough_route" | "allm_passthrough_route" | "generate_content" | "agenerate_content" | "generate_content_stream" | "agenerate_content_stream" | "ocr" | "aocr" | "call_mcp_tool" | "list_mcp_tools" | "asend_message" | "send_message" | "acreate_skill"; + CallTypes: "embedding" | "aembedding" | "completion" | "acompletion" | "atext_completion" | "text_completion" | "image_generation" | "aimage_generation" | "image_edit" | "aimage_edit" | "moderation" | "amoderation" | "atranscription" | "transcription" | "aspeech" | "speech" | "rerank" | "arerank" | "search" | "asearch" | "_arealtime" | "_aresponses_websocket" | "create_batch" | "acreate_batch" | "aretrieve_batch" | "retrieve_batch" | "acancel_batch" | "cancel_batch" | "pass_through_endpoint" | "anthropic_messages" | "get_assistants" | "aget_assistants" | "create_assistants" | "acreate_assistants" | "delete_assistant" | "adelete_assistant" | "acreate_thread" | "create_thread" | "aget_thread" | "get_thread" | "a_add_message" | "add_message" | "aget_messages" | "get_messages" | "arun_thread" | "run_thread" | "arun_thread_stream" | "run_thread_stream" | "afile_retrieve" | "file_retrieve" | "afile_delete" | "file_delete" | "afile_list" | "file_list" | "acreate_file" | "create_file" | "afile_content" | "file_content" | "create_fine_tuning_job" | "acreate_fine_tuning_job" | "create_video" | "acreate_video" | "avideo_retrieve" | "video_retrieve" | "avideo_content" | "video_content" | "video_remix" | "avideo_remix" | "video_list" | "avideo_list" | "video_retrieve_job" | "avideo_retrieve_job" | "video_delete" | "avideo_delete" | "video_create_character" | "avideo_create_character" | "video_get_character" | "avideo_get_character" | "video_edit" | "avideo_edit" | "video_extension" | "avideo_extension" | "vector_store_file_create" | "avector_store_file_create" | "vector_store_file_list" | "avector_store_file_list" | "vector_store_file_retrieve" | "avector_store_file_retrieve" | "vector_store_file_content" | "avector_store_file_content" | "vector_store_file_update" | "avector_store_file_update" | "vector_store_file_delete" | "avector_store_file_delete" | "vector_store_create" | "avector_store_create" | "vector_store_search" | "avector_store_search" | "create_container" | "acreate_container" | "list_containers" | "alist_containers" | "retrieve_container" | "aretrieve_container" | "delete_container" | "adelete_container" | "list_container_files" | "alist_container_files" | "upload_container_file" | "aupload_container_file" | "create_sandbox" | "acreate_sandbox" | "delete_sandbox" | "adelete_sandbox" | "run_code" | "arun_code" | "code_interpreter_tool" | "acode_interpreter_tool" | "acancel_fine_tuning_job" | "cancel_fine_tuning_job" | "alist_fine_tuning_jobs" | "list_fine_tuning_jobs" | "aretrieve_fine_tuning_job" | "retrieve_fine_tuning_job" | "responses" | "aresponses" | "alist_input_items" | "llm_passthrough_route" | "allm_passthrough_route" | "generate_content" | "agenerate_content" | "generate_content_stream" | "agenerate_content_stream" | "ocr" | "aocr" | "call_mcp_tool" | "list_mcp_tools" | "asend_message" | "send_message" | "acreate_skill"; /** CallbackDelete */ CallbackDelete: { /** Callback Name */