mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat: add opensandbox sandbox provider (#31024)
* feat: add opensandbox sandbox provider * fix: harden opensandbox sandbox startup * fix: address opensandbox review feedback * fix: address opensandbox sandbox review feedback * fix: address sandbox parser nits * fix(ci): clear opensandbox gates * fix(review): require opensandbox api base * chore(ci): rerun pass-through check
This commit is contained in:
parent
e73cbfb026
commit
c8a9618afd
15 changed files with 1598 additions and 42 deletions
1
.github/workflows/test-unit-misc.yml
vendored
1
.github/workflows/test-unit-misc.yml
vendored
|
|
@ -33,6 +33,7 @@ jobs:
|
|||
tests/test_litellm/images
|
||||
tests/test_litellm/interactions
|
||||
tests/test_litellm/passthrough
|
||||
tests/test_litellm/sandbox
|
||||
tests/test_litellm/vector_stores
|
||||
tests/test_litellm/test_*.py
|
||||
workers: 2
|
||||
|
|
|
|||
|
|
@ -201,6 +201,18 @@ DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET = int(
|
|||
|
||||
# Provider-specific API base URLs
|
||||
XAI_API_BASE = "https://api.x.ai/v1"
|
||||
OPEN_SANDBOX_API_BASE_ENV_VAR = "OPEN_SANDBOX_API_BASE"
|
||||
OPEN_SANDBOX_API_KEY_ENV_VAR = "OPEN_SANDBOX_API_KEY"
|
||||
OPEN_SANDBOX_DEFAULT_TEMPLATE = "opensandbox/code-interpreter:v1.1.0"
|
||||
_OPEN_SANDBOX_FALLBACK_ENTRYPOINT = "/opt/code-interpreter/code-interpreter.sh"
|
||||
OPEN_SANDBOX_DEFAULT_ENTRYPOINT = (_OPEN_SANDBOX_FALLBACK_ENTRYPOINT,)
|
||||
OPEN_SANDBOX_DEFAULT_LANGUAGE = "python"
|
||||
OPEN_SANDBOX_DEFAULT_CPU_LIMIT = "1"
|
||||
OPEN_SANDBOX_DEFAULT_MEMORY_LIMIT = "2Gi"
|
||||
OPEN_SANDBOX_EXECD_PORT = 44772
|
||||
OPEN_SANDBOX_DEFAULT_TIMEOUT = 300
|
||||
OPEN_SANDBOX_READY_TIMEOUT = 30.0
|
||||
OPEN_SANDBOX_POLL_INTERVAL = 0.2
|
||||
|
||||
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET = int(
|
||||
os.getenv("DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET", 1024)
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ run code -> delete container; `code_interpreter_tool` combines all three.
|
|||
|
||||
from typing import Any, Union
|
||||
|
||||
import httpx
|
||||
|
||||
from pydantic import Field, PrivateAttr
|
||||
|
||||
from litellm.types.llms.base import LiteLLMPydanticObjectBase
|
||||
|
||||
SANDBOX_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
class ContainerHandle(LiteLLMPydanticObjectBase):
|
||||
"""A live sandbox container. Carries everything needed to reach it again."""
|
||||
|
|
@ -53,7 +57,7 @@ class BaseSandboxConfig:
|
|||
*,
|
||||
template: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_internet_access: bool = True,
|
||||
allow_internet_access: bool | None = None,
|
||||
api_key: str | None = None,
|
||||
**kwargs,
|
||||
) -> ContainerHandle:
|
||||
|
|
@ -77,3 +81,16 @@ class BaseSandboxConfig:
|
|||
**kwargs,
|
||||
) -> bool:
|
||||
raise NotImplementedError("adelete_sandbox must be implemented by provider")
|
||||
|
||||
async def _read_capped_lines(self, 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 > SANDBOX_MAX_OUTPUT_BYTES:
|
||||
raise ValueError(
|
||||
f"Sandbox output exceeded {SANDBOX_MAX_OUTPUT_BYTES} bytes; aborting "
|
||||
"to avoid unbounded memory use."
|
||||
)
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import ssl
|
||||
from functools import lru_cache
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
@ -13,6 +14,7 @@ from typing import (
|
|||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
import httpx # type: ignore
|
||||
|
|
@ -26,6 +28,7 @@ from litellm._logging import _redact_string, verbose_logger
|
|||
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
|
||||
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
|
||||
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
|
||||
from litellm.litellm_core_utils.asyncify import run_async_function
|
||||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
from litellm.llms.base_llm.anthropic_messages.transformation import (
|
||||
BaseAnthropicMessagesConfig,
|
||||
|
|
@ -101,6 +104,7 @@ from litellm.types.llms.openai import (
|
|||
HttpxBinaryResponseContent,
|
||||
OpenAIFileObject,
|
||||
ResponseInputParam,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
)
|
||||
from litellm.types.rerank import RerankResponse
|
||||
|
|
@ -135,6 +139,7 @@ from litellm.utils import (
|
|||
ImageResponse,
|
||||
ModelResponse,
|
||||
ProviderConfigManager,
|
||||
async_pre_call_deployment_hook,
|
||||
)
|
||||
|
||||
from .http_handler import get_shared_realtime_ssl_context
|
||||
|
|
@ -184,6 +189,47 @@ def _google_genai_streaming_hidden_params(
|
|||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _responses_api_optional_request_param_names() -> frozenset[str]:
|
||||
return frozenset(get_type_hints(ResponsesAPIOptionalRequestParams).keys())
|
||||
|
||||
|
||||
def _custom_logger_callbacks(logging_obj: Any) -> list[Any]:
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_custom_logger_compatible_class,
|
||||
)
|
||||
|
||||
dynamic_success_callbacks = getattr(logging_obj, "dynamic_success_callbacks", None)
|
||||
callbacks = list(litellm.callbacks)
|
||||
if isinstance(dynamic_success_callbacks, (list, tuple)):
|
||||
callbacks.extend(dynamic_success_callbacks)
|
||||
|
||||
custom_loggers: list[Any] = []
|
||||
for cb in callbacks:
|
||||
if isinstance(cb, str):
|
||||
resolved = get_custom_logger_compatible_class(cb) # type: ignore[arg-type]
|
||||
if resolved is None:
|
||||
continue
|
||||
cb = resolved
|
||||
if isinstance(cb, CustomLogger):
|
||||
custom_loggers.append(cb)
|
||||
return custom_loggers
|
||||
|
||||
|
||||
def _has_pre_call_deployment_hook(logging_obj: Any) -> bool:
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
base_func = CustomLogger.async_pre_call_deployment_hook
|
||||
for cb in _custom_logger_callbacks(logging_obj):
|
||||
cb_func = getattr(type(cb), "async_pre_call_deployment_hook", base_func)
|
||||
if getattr(cb_func, "__func__", cb_func) is not getattr(
|
||||
base_func, "__func__", base_func
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class BaseLLMHTTPHandler:
|
||||
async def _make_common_async_call(
|
||||
self,
|
||||
|
|
@ -2224,12 +2270,92 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
raise ValueError("anthropic_messages_handler is not implemented for sync calls")
|
||||
|
||||
def _run_sync_responses_pre_call_deployment_hook(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
input: Union[str, ResponseInputParam],
|
||||
custom_llm_provider: str,
|
||||
response_api_optional_request_params: dict[str, Any],
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> tuple[
|
||||
str,
|
||||
Union[str, ResponseInputParam],
|
||||
str,
|
||||
dict[str, Any],
|
||||
GenericLiteLLMParams,
|
||||
]:
|
||||
if not _has_pre_call_deployment_hook(logging_obj):
|
||||
return (
|
||||
model,
|
||||
input,
|
||||
custom_llm_provider,
|
||||
response_api_optional_request_params,
|
||||
litellm_params,
|
||||
)
|
||||
|
||||
modified_kwargs = run_async_function(
|
||||
async_pre_call_deployment_hook,
|
||||
{
|
||||
**dict(litellm_params),
|
||||
**response_api_optional_request_params,
|
||||
"model": model,
|
||||
"input": input,
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
},
|
||||
CallTypes.responses.value,
|
||||
)
|
||||
if modified_kwargs is None:
|
||||
return (
|
||||
model,
|
||||
input,
|
||||
custom_llm_provider,
|
||||
response_api_optional_request_params,
|
||||
litellm_params,
|
||||
)
|
||||
|
||||
optional_param_names = _responses_api_optional_request_param_names()
|
||||
updated_response_params = {
|
||||
**response_api_optional_request_params,
|
||||
**{
|
||||
key: value
|
||||
for key, value in modified_kwargs.items()
|
||||
if key in optional_param_names
|
||||
},
|
||||
}
|
||||
updated_litellm_params = GenericLiteLLMParams(
|
||||
**{
|
||||
**dict(litellm_params),
|
||||
**{
|
||||
key: value
|
||||
for key, value in modified_kwargs.items()
|
||||
if key not in optional_param_names
|
||||
and key not in {"model", "input", "custom_llm_provider"}
|
||||
},
|
||||
}
|
||||
)
|
||||
return (
|
||||
str(modified_kwargs["model"]) if "model" in modified_kwargs else model,
|
||||
cast(
|
||||
Union[str, ResponseInputParam],
|
||||
modified_kwargs["input"] if "input" in modified_kwargs else input,
|
||||
),
|
||||
(
|
||||
str(modified_kwargs["custom_llm_provider"])
|
||||
if "custom_llm_provider" in modified_kwargs
|
||||
else custom_llm_provider
|
||||
),
|
||||
updated_response_params,
|
||||
updated_litellm_params,
|
||||
)
|
||||
|
||||
def response_api_handler(
|
||||
self,
|
||||
model: str,
|
||||
input: Union[str, ResponseInputParam],
|
||||
responses_api_provider_config: BaseResponsesAPIConfig,
|
||||
response_api_optional_request_params: Dict,
|
||||
response_api_optional_request_params: dict[str, Any],
|
||||
custom_llm_provider: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
|
|
@ -2276,6 +2402,21 @@ class BaseLLMHTTPHandler:
|
|||
shared_session=shared_session,
|
||||
)
|
||||
|
||||
(
|
||||
model,
|
||||
input,
|
||||
custom_llm_provider,
|
||||
response_api_optional_request_params,
|
||||
litellm_params,
|
||||
) = self._run_sync_responses_pre_call_deployment_hook(
|
||||
model=model,
|
||||
input=input,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
response_api_optional_request_params=response_api_optional_request_params,
|
||||
litellm_params=litellm_params,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
if client is None or not isinstance(client, HTTPHandler):
|
||||
sync_httpx_client = _get_httpx_client(
|
||||
params={"ssl_verify": litellm_params.get("ssl_verify", None)}
|
||||
|
|
@ -2414,9 +2555,27 @@ class BaseLLMHTTPHandler:
|
|||
logging_obj=logging_obj,
|
||||
)
|
||||
)
|
||||
# Responses agentic interception (e.g. code interpreter) runs the follow-up
|
||||
# loop via the async hook, so it is async-only for now; the sync path returns
|
||||
# the initial response unchanged.
|
||||
|
||||
if self._has_agentic_completion_hook(logging_obj):
|
||||
final_response = run_async_function(
|
||||
self._call_agentic_completion_hooks,
|
||||
response=initial_response,
|
||||
model=model,
|
||||
messages=(
|
||||
input
|
||||
if isinstance(input, list)
|
||||
else [{"role": "user", "content": input}]
|
||||
),
|
||||
anthropic_messages_provider_config=responses_api_provider_config,
|
||||
anthropic_messages_optional_request_params=response_api_optional_request_params,
|
||||
logging_obj=logging_obj,
|
||||
stream=False,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
kwargs=dict(litellm_params),
|
||||
api_surface="responses",
|
||||
)
|
||||
return final_response if final_response is not None else initial_response
|
||||
|
||||
return initial_response
|
||||
|
||||
async def async_response_api_handler(
|
||||
|
|
@ -4772,22 +4931,9 @@ class BaseLLMHTTPHandler:
|
|||
agentic callback is detected too.
|
||||
"""
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_custom_logger_compatible_class,
|
||||
)
|
||||
|
||||
base_func = CustomLogger.async_should_run_agentic_loop
|
||||
callbacks = litellm.callbacks + (
|
||||
getattr(logging_obj, "dynamic_success_callbacks", None) or []
|
||||
)
|
||||
for cb in callbacks:
|
||||
if isinstance(cb, str):
|
||||
resolved = get_custom_logger_compatible_class(cb) # type: ignore[arg-type]
|
||||
if resolved is None:
|
||||
continue
|
||||
cb = resolved
|
||||
if not isinstance(cb, CustomLogger):
|
||||
continue
|
||||
for cb in _custom_logger_callbacks(logging_obj):
|
||||
cb_func = getattr(type(cb), "async_should_run_agentic_loop", base_func)
|
||||
if getattr(cb_func, "__func__", cb_func) is not getattr(
|
||||
base_func, "__func__", base_func
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from litellm.llms.base_llm.sandbox.transformation import (
|
|||
BaseSandboxConfig,
|
||||
CodeExecutionResult,
|
||||
ContainerHandle,
|
||||
SANDBOX_MAX_OUTPUT_BYTES,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
|
|
@ -29,7 +30,7 @@ E2B_DEFAULT_TEMPLATE = "code-interpreter-v1"
|
|||
E2B_DEFAULT_DOMAIN = "e2b.app"
|
||||
JUPYTER_PORT = 49999
|
||||
DEFAULT_SANDBOX_TIMEOUT = 300
|
||||
MAX_OUTPUT_BYTES = 10 * 1024 * 1024
|
||||
MAX_OUTPUT_BYTES = SANDBOX_MAX_OUTPUT_BYTES
|
||||
|
||||
|
||||
class E2BSandboxConfig(BaseSandboxConfig):
|
||||
|
|
@ -49,7 +50,7 @@ class E2BSandboxConfig(BaseSandboxConfig):
|
|||
*,
|
||||
template: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_internet_access: bool = True,
|
||||
allow_internet_access: bool | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
|
|
@ -62,7 +63,9 @@ class E2BSandboxConfig(BaseSandboxConfig):
|
|||
"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,
|
||||
"allow_internet_access": (
|
||||
True if allow_internet_access is None else allow_internet_access
|
||||
),
|
||||
}
|
||||
if metadata:
|
||||
body["metadata"] = metadata
|
||||
|
|
@ -168,20 +171,6 @@ class E2BSandboxConfig(BaseSandboxConfig):
|
|||
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):
|
||||
|
|
@ -192,10 +181,9 @@ class E2BSandboxConfig(BaseSandboxConfig):
|
|||
|
||||
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
|
||||
for line in lines
|
||||
if (stripped := line.strip())
|
||||
if (parsed := _try_parse(stripped)) is not None
|
||||
)
|
||||
|
||||
def of_type(message_type: str):
|
||||
|
|
|
|||
1
litellm/llms/opensandbox/__init__.py
Normal file
1
litellm/llms/opensandbox/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1
litellm/llms/opensandbox/sandbox/__init__.py
Normal file
1
litellm/llms/opensandbox/sandbox/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
598
litellm/llms/opensandbox/sandbox/transformation.py
Normal file
598
litellm/llms/opensandbox/sandbox/transformation.py
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Union, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.constants import (
|
||||
OPEN_SANDBOX_API_BASE_ENV_VAR,
|
||||
OPEN_SANDBOX_API_KEY_ENV_VAR,
|
||||
OPEN_SANDBOX_DEFAULT_CPU_LIMIT,
|
||||
OPEN_SANDBOX_DEFAULT_ENTRYPOINT,
|
||||
OPEN_SANDBOX_DEFAULT_LANGUAGE,
|
||||
OPEN_SANDBOX_DEFAULT_MEMORY_LIMIT,
|
||||
OPEN_SANDBOX_DEFAULT_TEMPLATE,
|
||||
OPEN_SANDBOX_DEFAULT_TIMEOUT,
|
||||
OPEN_SANDBOX_EXECD_PORT,
|
||||
OPEN_SANDBOX_POLL_INTERVAL,
|
||||
OPEN_SANDBOX_READY_TIMEOUT,
|
||||
)
|
||||
from litellm.llms.base_llm.sandbox.transformation import (
|
||||
BaseSandboxConfig,
|
||||
CodeExecutionResult,
|
||||
ContainerHandle,
|
||||
SANDBOX_MAX_OUTPUT_BYTES,
|
||||
)
|
||||
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
|
||||
|
||||
DEFAULT_SANDBOX_TIMEOUT = OPEN_SANDBOX_DEFAULT_TIMEOUT
|
||||
DEFAULT_READY_TIMEOUT = OPEN_SANDBOX_READY_TIMEOUT
|
||||
DEFAULT_POLL_INTERVAL = OPEN_SANDBOX_POLL_INTERVAL
|
||||
MAX_OUTPUT_BYTES = SANDBOX_MAX_OUTPUT_BYTES
|
||||
|
||||
|
||||
class OpenSandboxSandboxConfig(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:
|
||||
if api_key is not None:
|
||||
return api_key
|
||||
return get_secret_str(OPEN_SANDBOX_API_KEY_ENV_VAR) or ""
|
||||
|
||||
async def acreate_sandbox(
|
||||
self,
|
||||
*,
|
||||
template: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_internet_access: bool | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
resource_limits: dict[str, str] | None = None,
|
||||
resource_requests: dict[str, str] | None = None,
|
||||
entrypoint: list[str] | tuple[str, ...] | None = None,
|
||||
network_policy: dict[str, object] | None = None,
|
||||
secure_access: bool = False,
|
||||
use_server_proxy: bool = False,
|
||||
ready_timeout: float | None = None,
|
||||
poll_interval: float | None = None,
|
||||
client: AsyncHTTPHandler | None = None,
|
||||
**kwargs,
|
||||
) -> ContainerHandle:
|
||||
key = self.validate_environment(api_key=api_key)
|
||||
base = self._api_base(api_base)
|
||||
ready_timeout_seconds = (
|
||||
float(ready_timeout) if ready_timeout is not None else DEFAULT_READY_TIMEOUT
|
||||
)
|
||||
poll_interval_seconds = (
|
||||
float(poll_interval) if poll_interval is not None else DEFAULT_POLL_INTERVAL
|
||||
)
|
||||
body = self._create_body(
|
||||
template=template,
|
||||
timeout=timeout,
|
||||
allow_internet_access=allow_internet_access,
|
||||
metadata=metadata,
|
||||
env_vars=env_vars,
|
||||
resource_limits=resource_limits,
|
||||
resource_requests=resource_requests,
|
||||
entrypoint=entrypoint,
|
||||
network_policy=network_policy,
|
||||
secure_access=secure_access,
|
||||
)
|
||||
|
||||
response = cast(
|
||||
httpx.Response,
|
||||
await self._http(client).post(
|
||||
url=f"{base}/sandboxes",
|
||||
headers=self._lifecycle_headers(key),
|
||||
json=body,
|
||||
),
|
||||
)
|
||||
data = response.json()
|
||||
sandbox_id = str(data["id"])
|
||||
|
||||
if self._sandbox_state(data) != "Running":
|
||||
await self._wait_until_running(
|
||||
sandbox_id=sandbox_id,
|
||||
api_base=base,
|
||||
headers=self._lifecycle_headers(key),
|
||||
client=client,
|
||||
ready_timeout=ready_timeout_seconds,
|
||||
poll_interval=poll_interval_seconds,
|
||||
)
|
||||
|
||||
endpoint, endpoint_headers = await self._wait_for_execd_endpoint(
|
||||
sandbox_id=sandbox_id,
|
||||
api_base=base,
|
||||
headers=self._lifecycle_headers(key),
|
||||
use_server_proxy=use_server_proxy,
|
||||
client=client,
|
||||
ready_timeout=ready_timeout_seconds,
|
||||
poll_interval=poll_interval_seconds,
|
||||
)
|
||||
|
||||
handle = ContainerHandle(id=sandbox_id, provider="opensandbox", domain=base)
|
||||
handle._hidden_params = {
|
||||
"api_base": base,
|
||||
"api_key": key,
|
||||
"execd_endpoint": endpoint,
|
||||
"execd_headers": endpoint_headers,
|
||||
"use_server_proxy": use_server_proxy,
|
||||
}
|
||||
return handle
|
||||
|
||||
async def arun_code(
|
||||
self,
|
||||
*,
|
||||
container: Union[ContainerHandle, str],
|
||||
code: str,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
language: str = OPEN_SANDBOX_DEFAULT_LANGUAGE,
|
||||
use_server_proxy: bool = False,
|
||||
ready_timeout: float | None = None,
|
||||
poll_interval: float | None = None,
|
||||
client: AsyncHTTPHandler | None = None,
|
||||
**kwargs,
|
||||
) -> CodeExecutionResult:
|
||||
handle = await self._ensure_handle(
|
||||
container=container,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
use_server_proxy=use_server_proxy,
|
||||
ready_timeout=(
|
||||
float(ready_timeout)
|
||||
if ready_timeout is not None
|
||||
else DEFAULT_READY_TIMEOUT
|
||||
),
|
||||
poll_interval=(
|
||||
float(poll_interval)
|
||||
if poll_interval is not None
|
||||
else DEFAULT_POLL_INTERVAL
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
endpoint = str(handle._hidden_params["execd_endpoint"])
|
||||
endpoint_headers = self._as_str_dict(handle._hidden_params.get("execd_headers"))
|
||||
base = str(
|
||||
handle._hidden_params.get("api_base")
|
||||
or handle.domain
|
||||
or self._api_base(api_base)
|
||||
)
|
||||
lines = await self._post_code(
|
||||
url=f"{self._endpoint_base_url(endpoint, base)}/code",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
**endpoint_headers,
|
||||
},
|
||||
body={
|
||||
"code": code,
|
||||
"context": {"language": language},
|
||||
},
|
||||
client=client,
|
||||
)
|
||||
return self._parse_lines(lines)
|
||||
|
||||
async def adelete_sandbox(
|
||||
self,
|
||||
*,
|
||||
container: Union[ContainerHandle, str],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
client: AsyncHTTPHandler | None = None,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
handle = self._as_handle(container, api_base=api_base)
|
||||
base = str(handle._hidden_params.get("api_base") or self._api_base(api_base))
|
||||
key = self._api_key(api_key=api_key, handle=handle)
|
||||
try:
|
||||
response = cast(
|
||||
httpx.Response,
|
||||
await self._http(client).delete(
|
||||
url=f"{base}/sandboxes/{handle.id}",
|
||||
headers=self._lifecycle_headers(key),
|
||||
),
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
return 200 <= response.status_code < 300
|
||||
|
||||
async def _ensure_handle(
|
||||
self,
|
||||
*,
|
||||
container: Union[ContainerHandle, str],
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
use_server_proxy: bool,
|
||||
ready_timeout: float,
|
||||
poll_interval: float,
|
||||
client: AsyncHTTPHandler | None,
|
||||
) -> ContainerHandle:
|
||||
handle = self._as_handle(container, api_base=api_base)
|
||||
if handle._hidden_params.get("execd_endpoint"):
|
||||
return handle
|
||||
|
||||
base = str(handle._hidden_params.get("api_base") or self._api_base(api_base))
|
||||
key = self._api_key(api_key=api_key, handle=handle)
|
||||
resolved_use_server_proxy = bool(
|
||||
handle._hidden_params.get("use_server_proxy", use_server_proxy)
|
||||
)
|
||||
endpoint, endpoint_headers = await self._wait_for_execd_endpoint(
|
||||
sandbox_id=handle.id,
|
||||
api_base=base,
|
||||
headers=self._lifecycle_headers(key),
|
||||
use_server_proxy=resolved_use_server_proxy,
|
||||
client=client,
|
||||
ready_timeout=ready_timeout,
|
||||
poll_interval=poll_interval,
|
||||
)
|
||||
handle.domain = base
|
||||
handle._hidden_params = {
|
||||
**handle._hidden_params,
|
||||
"api_base": base,
|
||||
"api_key": key,
|
||||
"execd_endpoint": endpoint,
|
||||
"execd_headers": endpoint_headers,
|
||||
"use_server_proxy": resolved_use_server_proxy,
|
||||
}
|
||||
return handle
|
||||
|
||||
async def _wait_until_running(
|
||||
self,
|
||||
*,
|
||||
sandbox_id: str,
|
||||
api_base: str,
|
||||
headers: dict[str, str],
|
||||
client: AsyncHTTPHandler | None,
|
||||
ready_timeout: float,
|
||||
poll_interval: float,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + ready_timeout
|
||||
while True:
|
||||
response = cast(
|
||||
httpx.Response,
|
||||
await self._http(client).get(
|
||||
url=f"{api_base}/sandboxes/{sandbox_id}",
|
||||
headers=headers,
|
||||
),
|
||||
)
|
||||
data = response.json()
|
||||
state = self._sandbox_state(data)
|
||||
if state == "Running":
|
||||
return
|
||||
if state in {"Failed", "Stopping", "Terminated"}:
|
||||
raise ValueError(f"OpenSandbox sandbox {sandbox_id} entered {state}")
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"OpenSandbox sandbox {sandbox_id} was not Running within "
|
||||
f"{ready_timeout} seconds"
|
||||
)
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
async def _wait_for_execd_endpoint(
|
||||
self,
|
||||
*,
|
||||
sandbox_id: str,
|
||||
api_base: str,
|
||||
headers: dict[str, str],
|
||||
use_server_proxy: bool,
|
||||
client: AsyncHTTPHandler | None,
|
||||
ready_timeout: float,
|
||||
poll_interval: float,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
deadline = time.monotonic() + ready_timeout
|
||||
last_error: Exception | None = None
|
||||
while True:
|
||||
try:
|
||||
return await self._get_execd_endpoint(
|
||||
sandbox_id=sandbox_id,
|
||||
api_base=api_base,
|
||||
headers=headers,
|
||||
use_server_proxy=use_server_proxy,
|
||||
client=client,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
raise
|
||||
last_error = e
|
||||
except ValueError as e:
|
||||
last_error = e
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"OpenSandbox execd endpoint for {sandbox_id} was not ready within "
|
||||
f"{ready_timeout} seconds"
|
||||
) from last_error
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
async def _get_execd_endpoint(
|
||||
self,
|
||||
*,
|
||||
sandbox_id: str,
|
||||
api_base: str,
|
||||
headers: dict[str, str],
|
||||
use_server_proxy: bool,
|
||||
client: AsyncHTTPHandler | None,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
response = cast(
|
||||
httpx.Response,
|
||||
await self._http(client).get(
|
||||
url=f"{api_base}/sandboxes/{sandbox_id}/endpoints/{OPEN_SANDBOX_EXECD_PORT}",
|
||||
headers=headers,
|
||||
params={"use_server_proxy": use_server_proxy},
|
||||
),
|
||||
)
|
||||
data = response.json()
|
||||
endpoint = data.get("endpoint")
|
||||
if not endpoint:
|
||||
raise ValueError(
|
||||
f"OpenSandbox did not return an execd endpoint for {sandbox_id}"
|
||||
)
|
||||
return str(endpoint), self._as_str_dict(data.get("headers"))
|
||||
|
||||
async def _post_code(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: dict[str, object],
|
||||
client: AsyncHTTPHandler | None,
|
||||
) -> list[str]:
|
||||
timeout = httpx.Timeout(connect=30.0, read=None, write=30.0, pool=None)
|
||||
response = cast(
|
||||
httpx.Response,
|
||||
await self._http(client).post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
json=body,
|
||||
stream=True,
|
||||
),
|
||||
)
|
||||
return await self._read_capped_lines(response)
|
||||
|
||||
def _api_key(self, *, api_key: str | None, handle: ContainerHandle) -> str:
|
||||
if api_key is not None:
|
||||
return api_key
|
||||
if "api_key" in handle._hidden_params:
|
||||
return str(handle._hidden_params["api_key"])
|
||||
return self.validate_environment()
|
||||
|
||||
@staticmethod
|
||||
def _create_body(
|
||||
*,
|
||||
template: str | None,
|
||||
timeout: int | None,
|
||||
allow_internet_access: bool | None,
|
||||
metadata: dict[str, str] | None,
|
||||
env_vars: dict[str, str] | None,
|
||||
resource_limits: dict[str, str] | None,
|
||||
resource_requests: dict[str, str] | None,
|
||||
entrypoint: list[str] | tuple[str, ...] | None,
|
||||
network_policy: dict[str, object] | None,
|
||||
secure_access: bool,
|
||||
) -> dict[str, object]:
|
||||
body: dict[str, object] = {
|
||||
"image": {"uri": template or OPEN_SANDBOX_DEFAULT_TEMPLATE},
|
||||
"entrypoint": list(entrypoint or OPEN_SANDBOX_DEFAULT_ENTRYPOINT),
|
||||
"timeout": timeout if timeout is not None else DEFAULT_SANDBOX_TIMEOUT,
|
||||
"resourceLimits": resource_limits
|
||||
or OpenSandboxSandboxConfig._default_resource_limits(),
|
||||
}
|
||||
if metadata:
|
||||
body["metadata"] = metadata
|
||||
if env_vars:
|
||||
body["env"] = env_vars
|
||||
if resource_requests:
|
||||
body["resourceRequests"] = resource_requests
|
||||
if network_policy is not None:
|
||||
body["networkPolicy"] = network_policy
|
||||
elif allow_internet_access is not True:
|
||||
body["networkPolicy"] = {"defaultAction": "deny", "egress": []}
|
||||
if secure_access:
|
||||
body["secureAccess"] = True
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def _default_resource_limits() -> dict[str, str]:
|
||||
return {
|
||||
"cpu": OPEN_SANDBOX_DEFAULT_CPU_LIMIT,
|
||||
"memory": OPEN_SANDBOX_DEFAULT_MEMORY_LIMIT,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sandbox_state(data: object) -> str | None:
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
status = data.get("status")
|
||||
if not isinstance(status, dict):
|
||||
return None
|
||||
state = status.get("state")
|
||||
return str(state) if state is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _as_str_dict(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {str(k): str(v) for k, v in value.items()}
|
||||
|
||||
@staticmethod
|
||||
def _api_base(api_base: str | None) -> str:
|
||||
base = api_base or get_secret_str(OPEN_SANDBOX_API_BASE_ENV_VAR)
|
||||
if not base:
|
||||
raise ValueError(
|
||||
"OpenSandbox api_base is required. Pass api_base or set "
|
||||
f"{OPEN_SANDBOX_API_BASE_ENV_VAR}."
|
||||
)
|
||||
return str(base).rstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _lifecycle_headers(api_key: str) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["OPEN-SANDBOX-API-KEY"] = api_key
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _endpoint_base_url(endpoint: str, api_base: str) -> str:
|
||||
normalized_endpoint = endpoint.rstrip("/")
|
||||
if normalized_endpoint.startswith(("http://", "https://")):
|
||||
return normalized_endpoint
|
||||
protocol = api_base.split("://", 1)[0] if "://" in api_base else "http"
|
||||
return f"{protocol}://{normalized_endpoint}"
|
||||
|
||||
@staticmethod
|
||||
def _as_handle(
|
||||
container: Union[ContainerHandle, str], *, api_base: str | None
|
||||
) -> ContainerHandle:
|
||||
if isinstance(container, ContainerHandle):
|
||||
return container
|
||||
handle = ContainerHandle(
|
||||
id=str(container),
|
||||
provider="opensandbox",
|
||||
domain=OpenSandboxSandboxConfig._api_base(api_base),
|
||||
)
|
||||
handle._hidden_params = {}
|
||||
return handle
|
||||
|
||||
@staticmethod
|
||||
def _parse_lines(lines: list[str]) -> CodeExecutionResult:
|
||||
messages = tuple(
|
||||
event
|
||||
for line in lines
|
||||
if (event := OpenSandboxSandboxConfig._parse_sse_line(line)) is not None
|
||||
)
|
||||
|
||||
def of_type(message_type: str):
|
||||
return (m for m in messages if m.get("type") == message_type)
|
||||
|
||||
error = next(
|
||||
(OpenSandboxSandboxConfig._normalize_error(m) for m in of_type("error")),
|
||||
None,
|
||||
)
|
||||
execution_count = next(
|
||||
(
|
||||
OpenSandboxSandboxConfig._as_int(m.get("execution_count"))
|
||||
for m in of_type("execution_count")
|
||||
if OpenSandboxSandboxConfig._as_int(m.get("execution_count"))
|
||||
is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
return CodeExecutionResult(
|
||||
stdout="".join(str(m.get("text", "")) for m in of_type("stdout")),
|
||||
stderr="".join(str(m.get("text", "")) for m in of_type("stderr")),
|
||||
results=[
|
||||
OpenSandboxSandboxConfig._normalize_result(m) for m in of_type("result")
|
||||
],
|
||||
error=error,
|
||||
execution_count=execution_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_sse_line(line: str) -> dict[str, object] | None:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(
|
||||
(
|
||||
":",
|
||||
"event:",
|
||||
"id:",
|
||||
"retry:",
|
||||
)
|
||||
):
|
||||
return None
|
||||
data = stripped[5:].strip() if stripped.startswith("data:") else stripped
|
||||
if not data:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
if "type" not in parsed and "code" in parsed and "message" in parsed:
|
||||
return {
|
||||
"type": "error",
|
||||
"error": {
|
||||
"ename": str(parsed["code"]),
|
||||
"evalue": str(parsed["message"]),
|
||||
"traceback": [],
|
||||
},
|
||||
}
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _normalize_result(message: dict[str, object]) -> dict[str, object]:
|
||||
results = message.get("results")
|
||||
if isinstance(results, dict):
|
||||
return {str(k): v for k, v in results.items()}
|
||||
return {
|
||||
str(k): v
|
||||
for k, v in message.items()
|
||||
if k not in {"type", "timestamp", "execution_count"}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_error(message: dict[str, object]) -> dict[str, object]:
|
||||
raw_error = message.get("error")
|
||||
if isinstance(raw_error, dict):
|
||||
name = OpenSandboxSandboxConfig._first_non_none_value(
|
||||
raw_error, "ename", "name", default=""
|
||||
)
|
||||
value = OpenSandboxSandboxConfig._first_non_none_value(
|
||||
raw_error, "evalue", "value", default=""
|
||||
)
|
||||
traceback = OpenSandboxSandboxConfig._first_non_none_value(
|
||||
raw_error, "traceback", default=[]
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"value": value,
|
||||
"traceback": traceback,
|
||||
}
|
||||
return {
|
||||
"name": OpenSandboxSandboxConfig._first_non_none_value(
|
||||
message, "name", default=""
|
||||
),
|
||||
"value": OpenSandboxSandboxConfig._first_non_none_value(
|
||||
message, "value", "text", default=""
|
||||
),
|
||||
"traceback": OpenSandboxSandboxConfig._first_non_none_value(
|
||||
message, "traceback", default=[]
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _as_int(value: object) -> int | None:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _first_non_none_value(
|
||||
values: dict[str, object], *keys: str, default: object
|
||||
) -> object:
|
||||
return next(
|
||||
(values[key] for key in keys if key in values and values[key] is not None),
|
||||
default,
|
||||
)
|
||||
|
|
@ -58,7 +58,10 @@ from litellm.llms.openai.data_residency import infer_openai_data_residency
|
|||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.responses.main import *
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.utils import ProviderConfigManager, client
|
||||
from litellm.utils import (
|
||||
ProviderConfigManager,
|
||||
client,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.types import Tool as MCPTool
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ async def acreate_sandbox(
|
|||
provider: str,
|
||||
template: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_internet_access: bool = True,
|
||||
allow_internet_access: bool | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
**kwargs,
|
||||
|
|
|
|||
|
|
@ -3521,6 +3521,7 @@ class SandboxProviders(str, Enum):
|
|||
"""
|
||||
|
||||
E2B = "e2b"
|
||||
OPENSANDBOX = "opensandbox"
|
||||
|
||||
|
||||
class LiteLLMLoggingBaseClass:
|
||||
|
|
|
|||
|
|
@ -9731,9 +9731,14 @@ class ProviderConfigManager:
|
|||
Get sandbox (code execution) configuration for a given provider.
|
||||
"""
|
||||
from litellm.llms.e2b.sandbox.transformation import E2BSandboxConfig
|
||||
from litellm.llms.opensandbox.sandbox.transformation import (
|
||||
OpenSandboxSandboxConfig,
|
||||
)
|
||||
|
||||
if provider == SandboxProviders.E2B:
|
||||
return E2BSandboxConfig()
|
||||
if provider == SandboxProviders.OPENSANDBOX:
|
||||
return OpenSandboxSandboxConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -1833,6 +1833,23 @@
|
|||
"text_completion": true
|
||||
}
|
||||
},
|
||||
"opensandbox": {
|
||||
"display_name": "OpenSandbox (`opensandbox`)",
|
||||
"url": "https://open-sandbox.ai/api/",
|
||||
"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
|
||||
}
|
||||
},
|
||||
"openai_like": {
|
||||
"display_name": "OpenAI-like (`openai_like`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/openai_compatible",
|
||||
|
|
|
|||
|
|
@ -10,13 +10,21 @@ sys.path.insert(
|
|||
0, os.path.abspath("../../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
import litellm
|
||||
from litellm.integrations.code_interpreter_interception.handler import (
|
||||
CodeInterpreterInterceptionLogger,
|
||||
LITELLM_CODE_EXECUTION_TOOL_NAME,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.custom_httpx.llm_http_handler import (
|
||||
BaseLLMHTTPHandler,
|
||||
_google_genai_streaming_hidden_params,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
_ACTIVE_KEY = "_code_interpreter_interception_active"
|
||||
_SANDBOX_KEY = "_code_interpreter_interception_sandbox_key"
|
||||
|
||||
|
||||
def test_prepare_fake_stream_request():
|
||||
# Initialize the BaseLLMHTTPHandler
|
||||
|
|
@ -116,6 +124,117 @@ def test_response_api_handler_streams_when_provider_transform_adds_stream():
|
|||
assert client.post.call_args.kwargs["json"]["stream"] is True
|
||||
|
||||
|
||||
def test_response_api_handler_runs_agentic_hooks_in_sync_path(monkeypatch):
|
||||
handler = BaseLLMHTTPHandler()
|
||||
config = Mock()
|
||||
config.validate_environment.return_value = {}
|
||||
config.get_complete_url.return_value = "https://chatgpt.example.com/responses"
|
||||
config.transform_responses_api_request.return_value = {
|
||||
"model": "gpt-5",
|
||||
"input": "hi",
|
||||
}
|
||||
config.sign_request.return_value = ({}, None)
|
||||
initial_response = Mock()
|
||||
final_response = Mock()
|
||||
config.transform_response_api_response.return_value = initial_response
|
||||
|
||||
client = HTTPHandler(client=httpx.Client())
|
||||
client.post = Mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", "https://chatgpt.example.com/responses"),
|
||||
)
|
||||
)
|
||||
logging_obj = Mock()
|
||||
|
||||
monkeypatch.setattr(handler, "_has_agentic_completion_hook", Mock(return_value=True))
|
||||
hook_mock = AsyncMock(return_value=final_response)
|
||||
monkeypatch.setattr(handler, "_call_agentic_completion_hooks", hook_mock)
|
||||
|
||||
response = handler.response_api_handler(
|
||||
model="gpt-5",
|
||||
input="hi",
|
||||
responses_api_provider_config=config,
|
||||
response_api_optional_request_params={},
|
||||
custom_llm_provider="openai",
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
logging_obj=logging_obj,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert response is final_response
|
||||
hook_mock.assert_awaited_once()
|
||||
assert hook_mock.call_args.kwargs["api_surface"] == "responses"
|
||||
assert hook_mock.call_args.kwargs["messages"] == [
|
||||
{"role": "user", "content": "hi"}
|
||||
]
|
||||
|
||||
|
||||
def test_response_api_handler_runs_responses_pre_call_hook_before_transform():
|
||||
handler = BaseLLMHTTPHandler()
|
||||
config = Mock()
|
||||
config.validate_environment.return_value = {}
|
||||
config.get_complete_url.return_value = "https://api.openai.com/v1/responses"
|
||||
config.sign_request.return_value = ({}, None)
|
||||
initial_response = ResponsesAPIResponse(
|
||||
id="resp_1",
|
||||
created_at=0,
|
||||
output=[],
|
||||
status="completed",
|
||||
model="gpt-5",
|
||||
)
|
||||
config.transform_response_api_response.return_value = initial_response
|
||||
|
||||
def transform_responses_api_request(**kwargs):
|
||||
return {
|
||||
"model": kwargs["model"],
|
||||
"input": kwargs["input"],
|
||||
**kwargs["response_api_optional_request_params"],
|
||||
}
|
||||
|
||||
config.transform_responses_api_request.side_effect = transform_responses_api_request
|
||||
client = HTTPHandler(client=httpx.Client())
|
||||
client.post = Mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
|
||||
)
|
||||
)
|
||||
logging_obj = Mock()
|
||||
logging_obj.dynamic_success_callbacks = []
|
||||
|
||||
old_callbacks = list(litellm.callbacks)
|
||||
litellm.callbacks = [CodeInterpreterInterceptionLogger()]
|
||||
try:
|
||||
response = handler.response_api_handler(
|
||||
model="gpt-5",
|
||||
input="use code",
|
||||
responses_api_provider_config=config,
|
||||
response_api_optional_request_params={
|
||||
"tools": [{"type": "code_interpreter", "container": {"type": "auto"}}]
|
||||
},
|
||||
custom_llm_provider="openai",
|
||||
litellm_params=GenericLiteLLMParams(api_key="sk-test"),
|
||||
logging_obj=logging_obj,
|
||||
client=client,
|
||||
)
|
||||
finally:
|
||||
litellm.callbacks = old_callbacks
|
||||
|
||||
assert response is initial_response
|
||||
transform_kwargs = config.transform_responses_api_request.call_args.kwargs
|
||||
tools = transform_kwargs["response_api_optional_request_params"]["tools"]
|
||||
assert not any(tool.get("type") == "code_interpreter" for tool in tools)
|
||||
assert any(
|
||||
tool.get("type") == "function"
|
||||
and tool.get("name") == LITELLM_CODE_EXECUTION_TOOL_NAME
|
||||
for tool in tools
|
||||
)
|
||||
hook_litellm_params = transform_kwargs["litellm_params"]
|
||||
assert hook_litellm_params.get(_ACTIVE_KEY) is True
|
||||
assert hook_litellm_params.get(_SANDBOX_KEY)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_response_api_handler_streams_when_provider_transform_adds_stream():
|
||||
handler = BaseLLMHTTPHandler()
|
||||
|
|
|
|||
647
tests/test_litellm/sandbox/test_opensandbox_sandbox.py
Normal file
647
tests/test_litellm/sandbox/test_opensandbox_sandbox.py
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.sandbox.transformation import ContainerHandle
|
||||
from litellm.llms.opensandbox.sandbox.transformation import (
|
||||
MAX_OUTPUT_BYTES,
|
||||
OPEN_SANDBOX_DEFAULT_TEMPLATE,
|
||||
OpenSandboxSandboxConfig,
|
||||
)
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
TEST_API_BASE = "https://sandbox.test/v1"
|
||||
|
||||
|
||||
def http_status_error(status_code, url="http://test"):
|
||||
return httpx.HTTPStatusError(
|
||||
f"status {status_code}",
|
||||
request=httpx.Request("GET", url),
|
||||
response=httpx.Response(status_code),
|
||||
)
|
||||
|
||||
|
||||
def sse(data):
|
||||
return f"data: {json.dumps(data)}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise http_status_error(self.status_code)
|
||||
|
||||
async def aiter_lines(self):
|
||||
for line in self._lines:
|
||||
yield line
|
||||
|
||||
|
||||
class FakeHTTPClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
create_json=None,
|
||||
sandbox_states=None,
|
||||
endpoint_json=None,
|
||||
endpoint_responses=None,
|
||||
execute_lines=None,
|
||||
delete_status=204,
|
||||
execute_raises=None,
|
||||
):
|
||||
self.create_json = create_json or {
|
||||
"id": "osb_123",
|
||||
"status": {"state": "Running"},
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"entrypoint": ["/opt/code-interpreter/code-interpreter.sh"],
|
||||
}
|
||||
self.sandbox_states = list(
|
||||
sandbox_states
|
||||
or [
|
||||
{
|
||||
"id": "osb_123",
|
||||
"status": {"state": "Running"},
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"entrypoint": ["/opt/code-interpreter/code-interpreter.sh"],
|
||||
}
|
||||
]
|
||||
)
|
||||
self.endpoint_json = endpoint_json or {
|
||||
"endpoint": "execd.local:44772",
|
||||
"headers": {"X-EXECD-ACCESS-TOKEN": "execd-token"},
|
||||
}
|
||||
self.endpoint_responses = (
|
||||
list(endpoint_responses) if endpoint_responses is not None else None
|
||||
)
|
||||
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, {"stream": stream}))
|
||||
if url.endswith("/sandboxes"):
|
||||
return FakeResponse(json_data=self.create_json)
|
||||
if url.endswith("/code"):
|
||||
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 get(self, url, headers=None, params=None, **kwargs):
|
||||
self.calls.append(("GET", url, headers, None, params))
|
||||
if "/endpoints/44772" in url:
|
||||
if self.endpoint_responses is not None and self.endpoint_responses:
|
||||
response = self.endpoint_responses.pop(0)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
if isinstance(response, FakeResponse):
|
||||
return response
|
||||
return FakeResponse(json_data=response)
|
||||
return FakeResponse(json_data=self.endpoint_json)
|
||||
if "/sandboxes/" in url:
|
||||
state = self.sandbox_states.pop(0)
|
||||
return FakeResponse(json_data=state)
|
||||
raise AssertionError(f"unexpected GET {url}")
|
||||
|
||||
async def delete(self, url, headers=None, **kwargs):
|
||||
self.calls.append(("DELETE", url, headers, None, None))
|
||||
if not (200 <= self.delete_status < 300):
|
||||
raise http_status_error(self.delete_status, url)
|
||||
return FakeResponse(status_code=self.delete_status)
|
||||
|
||||
|
||||
def test_parse_sse_lines_maps_output_result_count_and_error():
|
||||
lines = [
|
||||
sse({"type": "stdout", "text": "hello\n"}),
|
||||
sse({"type": "stderr", "text": "warn\n"}),
|
||||
sse({"type": "result", "results": {"text/plain": "4"}}),
|
||||
sse({"type": "execution_count", "execution_count": 7}),
|
||||
sse(
|
||||
{
|
||||
"type": "error",
|
||||
"error": {
|
||||
"ename": "ValueError",
|
||||
"evalue": "bad",
|
||||
"traceback": ["Traceback"],
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
result = OpenSandboxSandboxConfig._parse_lines(lines)
|
||||
|
||||
assert result.stdout == "hello\n"
|
||||
assert result.stderr == "warn\n"
|
||||
assert result.results == [{"text/plain": "4"}]
|
||||
assert result.execution_count == 7
|
||||
assert result.error == {
|
||||
"name": "ValueError",
|
||||
"value": "bad",
|
||||
"traceback": ["Traceback"],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_sse_lines_skips_non_json_and_control_lines():
|
||||
lines = [
|
||||
"event: message",
|
||||
"not-json",
|
||||
"",
|
||||
sse({"type": "stdout", "text": "ok\n"}),
|
||||
]
|
||||
|
||||
result = OpenSandboxSandboxConfig._parse_lines(lines)
|
||||
|
||||
assert result.stdout == "ok\n"
|
||||
assert result.error is None
|
||||
|
||||
|
||||
def test_parse_sse_lines_maps_fallback_shapes():
|
||||
lines = [
|
||||
"data:",
|
||||
sse(["not-a-dict"]),
|
||||
sse({"code": "BadRequest", "message": "nope"}),
|
||||
sse({"type": "result", "text/plain": "4"}),
|
||||
sse({"type": "error", "name": "RuntimeError", "text": "boom"}),
|
||||
sse({"type": "execution_count", "execution_count": "8"}),
|
||||
]
|
||||
|
||||
result = OpenSandboxSandboxConfig._parse_lines(lines)
|
||||
|
||||
assert result.results == [{"text/plain": "4"}]
|
||||
assert result.execution_count == 8
|
||||
assert result.error == {
|
||||
"name": "BadRequest",
|
||||
"value": "nope",
|
||||
"traceback": [],
|
||||
}
|
||||
fallback_error = OpenSandboxSandboxConfig._parse_lines(
|
||||
[sse({"type": "error", "name": "RuntimeError", "text": "boom"})]
|
||||
)
|
||||
assert fallback_error.error == {
|
||||
"name": "RuntimeError",
|
||||
"value": "boom",
|
||||
"traceback": [],
|
||||
}
|
||||
empty_string_error = OpenSandboxSandboxConfig._parse_lines(
|
||||
[
|
||||
sse(
|
||||
{
|
||||
"type": "error",
|
||||
"error": {
|
||||
"ename": "",
|
||||
"name": "FallbackName",
|
||||
"evalue": "",
|
||||
"value": "fallback value",
|
||||
"traceback": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
assert empty_string_error.error == {
|
||||
"name": "",
|
||||
"value": "",
|
||||
"traceback": [],
|
||||
}
|
||||
|
||||
|
||||
def test_static_helpers_cover_defaults_and_fallbacks(monkeypatch):
|
||||
def fake_secret(key):
|
||||
if key == "OPEN_SANDBOX_API_KEY":
|
||||
return "env-key"
|
||||
if key == "OPEN_SANDBOX_API_BASE":
|
||||
return TEST_API_BASE
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.llms.opensandbox.sandbox.transformation.get_secret_str",
|
||||
fake_secret,
|
||||
)
|
||||
config = OpenSandboxSandboxConfig()
|
||||
handle = ContainerHandle(id="osb", provider="opensandbox", domain="http://x/v1")
|
||||
|
||||
assert config.validate_environment() == "env-key"
|
||||
assert config.validate_environment(api_key="") == ""
|
||||
assert config._api_key(api_key=None, handle=handle) == "env-key"
|
||||
|
||||
handle._hidden_params = {"api_key": "stored-key"}
|
||||
assert config._api_key(api_key=None, handle=handle) == "stored-key"
|
||||
assert config._http(None) is not None
|
||||
|
||||
body = config._create_body(
|
||||
template=None,
|
||||
timeout=None,
|
||||
allow_internet_access=False,
|
||||
metadata=None,
|
||||
env_vars=None,
|
||||
resource_limits=None,
|
||||
resource_requests=None,
|
||||
entrypoint=None,
|
||||
network_policy={"egress": [{"domain": "example.com"}]},
|
||||
secure_access=True,
|
||||
)
|
||||
assert body["networkPolicy"] == {"egress": [{"domain": "example.com"}]}
|
||||
assert body["secureAccess"] is True
|
||||
|
||||
other_body = config._create_body(
|
||||
template=None,
|
||||
timeout=None,
|
||||
allow_internet_access=False,
|
||||
metadata=None,
|
||||
env_vars=None,
|
||||
resource_limits=None,
|
||||
resource_requests=None,
|
||||
entrypoint=None,
|
||||
network_policy=None,
|
||||
secure_access=False,
|
||||
)
|
||||
assert body["resourceLimits"] is not other_body["resourceLimits"]
|
||||
|
||||
assert config._sandbox_state(None) is None
|
||||
assert config._sandbox_state({"status": "Running"}) is None
|
||||
assert config._as_str_dict(None) == {}
|
||||
assert config._endpoint_base_url("http://execd.local", "https://api/v1") == (
|
||||
"http://execd.local"
|
||||
)
|
||||
assert config._api_base(None) == TEST_API_BASE
|
||||
assert config._api_base("https://direct.test/v1/") == "https://direct.test/v1"
|
||||
assert config._as_int("9") == 9
|
||||
assert config._as_int("nope") is None
|
||||
assert config._as_int(None) is None
|
||||
assert isinstance(
|
||||
ProviderConfigManager.get_provider_sandbox_config("opensandbox"),
|
||||
OpenSandboxSandboxConfig,
|
||||
)
|
||||
|
||||
|
||||
def test_api_base_requires_kwarg_or_env(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"litellm.llms.opensandbox.sandbox.transformation.get_secret_str",
|
||||
lambda key: None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="api_base is required"):
|
||||
OpenSandboxSandboxConfig._api_base(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_posts_default_body_and_omits_empty_api_key():
|
||||
client = FakeHTTPClient()
|
||||
|
||||
handle = await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
api_key="", api_base=TEST_API_BASE, client=client
|
||||
)
|
||||
|
||||
method, url, headers, body, _ = client.calls[0]
|
||||
assert method == "POST"
|
||||
assert url == f"{TEST_API_BASE}/sandboxes"
|
||||
assert "OPEN-SANDBOX-API-KEY" not in headers
|
||||
assert body["image"] == {"uri": OPEN_SANDBOX_DEFAULT_TEMPLATE}
|
||||
assert body["entrypoint"] == ["/opt/code-interpreter/code-interpreter.sh"]
|
||||
assert body["timeout"] == 300
|
||||
assert body["resourceLimits"] == {"cpu": "1", "memory": "2Gi"}
|
||||
assert body["networkPolicy"] == {"defaultAction": "deny", "egress": []}
|
||||
assert handle.id == "osb_123"
|
||||
assert handle._hidden_params["execd_endpoint"] == "execd.local:44772"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_can_opt_into_internet_access():
|
||||
client = FakeHTTPClient()
|
||||
|
||||
await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
api_key="",
|
||||
api_base=TEST_API_BASE,
|
||||
allow_internet_access=True,
|
||||
client=client,
|
||||
)
|
||||
|
||||
_, _, _, body, _ = client.calls[0]
|
||||
assert "networkPolicy" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_custom_options_poll_and_endpoint_resolution():
|
||||
client = FakeHTTPClient(
|
||||
create_json={
|
||||
"id": "osb_pending",
|
||||
"status": {"state": "Pending"},
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"entrypoint": ["/bin/sh"],
|
||||
},
|
||||
sandbox_states=[
|
||||
{
|
||||
"id": "osb_pending",
|
||||
"status": {"state": "Running"},
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"entrypoint": ["/bin/sh"],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
handle = await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
template="custom/image:latest",
|
||||
timeout=600,
|
||||
allow_internet_access=False,
|
||||
api_key="osb-key",
|
||||
api_base="https://sandbox.example/v1",
|
||||
metadata={"suite": "unit"},
|
||||
env_vars={"PYTHONUNBUFFERED": "1"},
|
||||
resource_limits={"cpu": "500m", "memory": "512Mi"},
|
||||
resource_requests={"cpu": "250m", "memory": "256Mi"},
|
||||
entrypoint=["/bin/sh", "-lc", "sleep 3600"],
|
||||
use_server_proxy=True,
|
||||
client=client,
|
||||
)
|
||||
|
||||
_, create_url, create_headers, body, _ = client.calls[0]
|
||||
_, poll_url, poll_headers, _, _ = client.calls[1]
|
||||
_, endpoint_url, endpoint_headers, _, endpoint_params = client.calls[2]
|
||||
|
||||
assert create_url == "https://sandbox.example/v1/sandboxes"
|
||||
assert create_headers["OPEN-SANDBOX-API-KEY"] == "osb-key"
|
||||
assert body["image"] == {"uri": "custom/image:latest"}
|
||||
assert body["entrypoint"] == ["/bin/sh", "-lc", "sleep 3600"]
|
||||
assert body["metadata"] == {"suite": "unit"}
|
||||
assert body["env"] == {"PYTHONUNBUFFERED": "1"}
|
||||
assert body["resourceLimits"] == {"cpu": "500m", "memory": "512Mi"}
|
||||
assert body["resourceRequests"] == {"cpu": "250m", "memory": "256Mi"}
|
||||
assert body["networkPolicy"] == {"defaultAction": "deny", "egress": []}
|
||||
assert poll_url == "https://sandbox.example/v1/sandboxes/osb_pending"
|
||||
assert poll_headers["OPEN-SANDBOX-API-KEY"] == "osb-key"
|
||||
assert endpoint_url.endswith("/sandboxes/osb_pending/endpoints/44772")
|
||||
assert endpoint_headers["OPEN-SANDBOX-API-KEY"] == "osb-key"
|
||||
assert endpoint_params == {"use_server_proxy": True}
|
||||
assert handle.id == "osb_pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_waits_across_pending_state(monkeypatch):
|
||||
client = FakeHTTPClient(
|
||||
create_json={
|
||||
"id": "osb_pending",
|
||||
"status": {"state": "Pending"},
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
sandbox_states=[
|
||||
{"id": "osb_pending", "status": {"state": "Pending"}},
|
||||
{"id": "osb_pending", "status": {"state": "Running"}},
|
||||
],
|
||||
)
|
||||
sleeps = []
|
||||
|
||||
async def fake_sleep(interval):
|
||||
sleeps.append(interval)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.llms.opensandbox.sandbox.transformation.asyncio.sleep", fake_sleep
|
||||
)
|
||||
|
||||
handle = await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
api_key="",
|
||||
api_base=TEST_API_BASE,
|
||||
ready_timeout=1,
|
||||
poll_interval=0.01,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert handle.id == "osb_pending"
|
||||
assert sleeps == [0.01]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_raises_for_terminal_state():
|
||||
client = FakeHTTPClient(
|
||||
create_json={"id": "osb_failed", "status": {"state": "Pending"}},
|
||||
sandbox_states=[
|
||||
{"id": "osb_failed", "status": {"state": "Failed"}},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="entered Failed"):
|
||||
await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
api_key="", api_base=TEST_API_BASE, client=client
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_times_out_waiting_for_running():
|
||||
client = FakeHTTPClient(
|
||||
create_json={"id": "osb_slow", "status": {"state": "Pending"}},
|
||||
sandbox_states=[
|
||||
{"id": "osb_slow", "status": {"state": "Pending"}},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(TimeoutError, match="was not Running"):
|
||||
await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
api_key="",
|
||||
api_base=TEST_API_BASE,
|
||||
ready_timeout=0,
|
||||
poll_interval=0,
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_waits_for_endpoint_resolution(monkeypatch):
|
||||
client = FakeHTTPClient(
|
||||
endpoint_responses=[
|
||||
http_status_error(404, f"{TEST_API_BASE}/sandboxes/osb_123"),
|
||||
{
|
||||
"endpoint": "execd.local:44772",
|
||||
"headers": {"X-EXECD-ACCESS-TOKEN": "execd-token"},
|
||||
},
|
||||
],
|
||||
)
|
||||
sleeps = []
|
||||
|
||||
async def fake_sleep(interval):
|
||||
sleeps.append(interval)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.llms.opensandbox.sandbox.transformation.asyncio.sleep", fake_sleep
|
||||
)
|
||||
|
||||
handle = await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
api_key="",
|
||||
api_base=TEST_API_BASE,
|
||||
ready_timeout=1,
|
||||
poll_interval=0.01,
|
||||
client=client,
|
||||
)
|
||||
|
||||
endpoint_calls = [call for call in client.calls if "/endpoints/44772" in call[1]]
|
||||
assert handle._hidden_params["execd_endpoint"] == "execd.local:44772"
|
||||
assert len(endpoint_calls) == 2
|
||||
assert sleeps == [0.01]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_raises_when_endpoint_is_missing():
|
||||
client = FakeHTTPClient(endpoint_json={"headers": {"X": "y"}})
|
||||
|
||||
with pytest.raises(TimeoutError, match="execd endpoint.*not ready"):
|
||||
await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
api_key="", api_base=TEST_API_BASE, ready_timeout=0, client=client
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_reraises_non_404_endpoint_error():
|
||||
client = FakeHTTPClient(endpoint_responses=[http_status_error(500)])
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await OpenSandboxSandboxConfig().acreate_sandbox(
|
||||
api_key="", api_base=TEST_API_BASE, client=client
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_code_resolves_bare_id_and_posts_sse_request():
|
||||
client = FakeHTTPClient(
|
||||
execute_lines=[
|
||||
sse({"type": "stdout", "text": "42\n"}),
|
||||
]
|
||||
)
|
||||
|
||||
result = await OpenSandboxSandboxConfig().arun_code(
|
||||
container="osb_bare",
|
||||
code="print(6*7)",
|
||||
language="python",
|
||||
api_key="",
|
||||
api_base="http://sandbox.local/v1",
|
||||
client=client,
|
||||
)
|
||||
|
||||
endpoint_call = client.calls[0]
|
||||
run_call = client.calls[1]
|
||||
assert endpoint_call[0] == "GET"
|
||||
assert (
|
||||
endpoint_call[1] == "http://sandbox.local/v1/sandboxes/osb_bare/endpoints/44772"
|
||||
)
|
||||
assert run_call[0] == "POST"
|
||||
assert run_call[1] == "http://execd.local:44772/code"
|
||||
assert run_call[2]["X-EXECD-ACCESS-TOKEN"] == "execd-token"
|
||||
assert run_call[3] == {
|
||||
"code": "print(6*7)",
|
||||
"context": {"language": "python"},
|
||||
}
|
||||
assert run_call[4] == {"stream": True}
|
||||
assert result.stdout == "42\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_code_uses_https_for_scheme_less_endpoint_when_api_base_is_https():
|
||||
client = FakeHTTPClient()
|
||||
handle = ContainerHandle(
|
||||
id="osb_https", provider="opensandbox", domain="https://sandbox.example/v1"
|
||||
)
|
||||
handle._hidden_params = {
|
||||
"execd_endpoint": "execd.example/route/44772",
|
||||
"execd_headers": {},
|
||||
}
|
||||
|
||||
await OpenSandboxSandboxConfig().arun_code(
|
||||
container=handle, code="print(1)", client=client
|
||||
)
|
||||
|
||||
assert client.calls[0][1] == "https://execd.example/route/44772/code"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_code_aborts_on_output_over_cap():
|
||||
client = FakeHTTPClient(execute_lines=["x" * (MAX_OUTPUT_BYTES + 1)])
|
||||
handle = ContainerHandle(id="osb_big", provider="opensandbox", domain="http://x/v1")
|
||||
handle._hidden_params = {"execd_endpoint": "execd.local:44772", "execd_headers": {}}
|
||||
|
||||
with pytest.raises(ValueError, match="exceeded"):
|
||||
await OpenSandboxSandboxConfig().arun_code(
|
||||
container=handle, code="print('x')", client=client
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_returns_false_on_404():
|
||||
client = FakeHTTPClient(delete_status=404)
|
||||
|
||||
ok = await OpenSandboxSandboxConfig().adelete_sandbox(
|
||||
container="osb_gone",
|
||||
api_key="",
|
||||
api_base="http://sandbox.local/v1",
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_reraises_non_404_http_error():
|
||||
client = FakeHTTPClient(delete_status=500)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await OpenSandboxSandboxConfig().adelete_sandbox(
|
||||
container="osb_err",
|
||||
api_key="",
|
||||
api_base="http://sandbox.local/v1",
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_lifecycle_create_run_delete():
|
||||
client = FakeHTTPClient(
|
||||
execute_lines=[
|
||||
sse({"type": "stdout", "text": "42\n"}),
|
||||
]
|
||||
)
|
||||
|
||||
container = await litellm.acreate_sandbox(
|
||||
provider="opensandbox", api_key="", api_base=TEST_API_BASE, client=client
|
||||
)
|
||||
result = await litellm.arun_code(
|
||||
provider="opensandbox",
|
||||
container=container,
|
||||
code="print(6*7)",
|
||||
api_key="",
|
||||
client=client,
|
||||
)
|
||||
ok = await litellm.adelete_sandbox(
|
||||
provider="opensandbox",
|
||||
container=container,
|
||||
api_key="",
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert container.id == "osb_123"
|
||||
assert result.stdout == "42\n"
|
||||
assert ok is True
|
||||
|
||||
|
||||
@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="opensandbox",
|
||||
code="1/0",
|
||||
api_key="",
|
||||
api_base=TEST_API_BASE,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert [call[0] for call in client.calls] == ["POST", "GET", "POST", "DELETE"]
|
||||
assert client.calls[0][1].endswith("/sandboxes")
|
||||
assert client.calls[1][1].endswith("/endpoints/44772")
|
||||
assert client.calls[2][1].endswith("/code")
|
||||
assert client.calls[3][1].endswith("/sandboxes/osb_123")
|
||||
Loading…
Add table
Reference in a new issue