mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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
This commit is contained in:
parent
b16cfd7de9
commit
53593f697d
15 changed files with 838 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
0
litellm/llms/base_llm/sandbox/__init__.py
Normal file
0
litellm/llms/base_llm/sandbox/__init__.py
Normal file
79
litellm/llms/base_llm/sandbox/transformation.py
Normal file
79
litellm/llms/base_llm/sandbox/transformation.py
Normal file
|
|
@ -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")
|
||||
0
litellm/llms/e2b/__init__.py
Normal file
0
litellm/llms/e2b/__init__.py
Normal file
0
litellm/llms/e2b/sandbox/__init__.py
Normal file
0
litellm/llms/e2b/sandbox/__init__.py
Normal file
219
litellm/llms/e2b/sandbox/transformation.py
Normal file
219
litellm/llms/e2b/sandbox/transformation.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
0
litellm/sandbox/__init__.py
Normal file
0
litellm/sandbox/__init__.py
Normal file
145
litellm/sandbox/main.py
Normal file
145
litellm/sandbox/main.py
Normal file
|
|
@ -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}"
|
||||
)
|
||||
|
|
@ -29,6 +29,7 @@ class httpxSpecialProvider(str, Enum):
|
|||
A2A = "a2a"
|
||||
PromptManagement = "prompt_management"
|
||||
UI = "ui"
|
||||
Sandbox = "sandbox"
|
||||
|
||||
|
||||
VerifyTypes = Union[str, bool, ssl.SSLContext]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
39
tests/integration/sandbox/test_e2b_sandbox.py
Normal file
39
tests/integration/sandbox/test_e2b_sandbox.py
Normal file
|
|
@ -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)
|
||||
297
tests/test_litellm/sandbox/test_e2b_sandbox.py
Normal file
297
tests/test_litellm/sandbox/test_e2b_sandbox.py
Normal file
|
|
@ -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")
|
||||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue