diff --git a/Makefile b/Makefile index 6183dff1556..27150aec938 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \ info lint lint-dev format \ lint-basedpyright lint-basedpyright-budget-update \ - lint-ruff-budget lint-ruff-budget-update lint-budget-update \ + lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \ install-dev install-proxy-dev install-test-deps install-hooks \ install-helm-unittest check-circular-imports check-import-safety @@ -28,6 +28,7 @@ help: @echo " make lint-basedpyright-budget-update - Re-capture the basedpyright per-rule budget (ratchet)" @echo " make lint-black - Check Black formatting (matches CI)" @echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its ceiling" + @echo " make lint-gate - Strict ruff gate in CI-parity mode (fetches staging, simulates the merge)" @echo " make lint-ruff-budget-update - Re-capture per-rule baselines in ruff-strict-budget.json (ratchet)" @echo " make lint-budget-update - Re-capture all ratchet budgets (ruff + basedpyright)" @echo " make check-circular-imports - Check for circular imports" @@ -134,6 +135,12 @@ lint-black: format-check lint-ruff-budget: install-dev $(UV_RUN) python scripts/ruff_strict_gate.py +# Strict gate, invoked the same way CI does in test-linting.yml so a local pass +# means the CI check will pass too. +lint-gate: install-dev + git fetch origin litellm_internal_staging + $(UV_RUN) python scripts/ruff_strict_gate.py --base origin/litellm_internal_staging + lint-ruff-budget-update: install-dev $(UV_RUN) python scripts/ruff_strict_gate.py --update diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index d0432448433..b032942427c 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.42" +version = "0.1.43" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.42" +version = "0.1.43" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/litellm/__init__.py b/litellm/__init__.py index cffdbacf597..d21234d2a81 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1401,6 +1401,7 @@ from .skills.main import ( from .containers.main import * from .ocr.main import * from .rag.main import * +from .sandbox.main import * from .search.main import * from .realtime_api.main import ( _arealtime, diff --git a/litellm/llms/base_llm/sandbox/__init__.py b/litellm/llms/base_llm/sandbox/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/base_llm/sandbox/transformation.py b/litellm/llms/base_llm/sandbox/transformation.py new file mode 100644 index 00000000000..6ad945f47a3 --- /dev/null +++ b/litellm/llms/base_llm/sandbox/transformation.py @@ -0,0 +1,79 @@ +""" +Base Sandbox transformation configuration. + +A sandbox provider runs an executable string inside an isolated container and +returns whatever the sandbox produced. The lifecycle is create container -> +run code -> delete container; `code_interpreter_tool` combines all three. +""" + +from typing import Any, Union + +from pydantic import Field, PrivateAttr + +from litellm.types.llms.base import LiteLLMPydanticObjectBase + + +class ContainerHandle(LiteLLMPydanticObjectBase): + """A live sandbox container. Carries everything needed to reach it again.""" + + id: str + provider: str + domain: str | None = None + + model_config = {"extra": "allow"} + + _hidden_params: dict = PrivateAttr(default_factory=dict) + + +class CodeExecutionResult(LiteLLMPydanticObjectBase): + """Passthrough of the sandbox's own execution output.""" + + stdout: str = "" + stderr: str = "" + results: list[dict[str, Any]] = Field(default_factory=list) + error: dict[str, Any] | None = None + execution_count: int | None = None + object: str = "code_execution" + + model_config = {"extra": "allow"} + + _hidden_params: dict = PrivateAttr(default_factory=dict) + + +class BaseSandboxConfig: + """Provider-agnostic sandbox operations.""" + + def validate_environment(self, api_key: str | None = None, **kwargs) -> str: + raise NotImplementedError( + "validate_environment must be implemented by provider" + ) + + async def acreate_sandbox( + self, + *, + template: str | None = None, + timeout: int | None = None, + allow_internet_access: bool = True, + api_key: str | None = None, + **kwargs, + ) -> ContainerHandle: + raise NotImplementedError("acreate_sandbox must be implemented by provider") + + async def arun_code( + self, + *, + container: Union[ContainerHandle, str], + code: str, + api_key: str | None = None, + **kwargs, + ) -> CodeExecutionResult: + raise NotImplementedError("arun_code must be implemented by provider") + + async def adelete_sandbox( + self, + *, + container: Union[ContainerHandle, str], + api_key: str | None = None, + **kwargs, + ) -> bool: + raise NotImplementedError("adelete_sandbox must be implemented by provider") diff --git a/litellm/llms/e2b/__init__.py b/litellm/llms/e2b/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/e2b/sandbox/__init__.py b/litellm/llms/e2b/sandbox/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/e2b/sandbox/transformation.py b/litellm/llms/e2b/sandbox/transformation.py new file mode 100644 index 00000000000..1ce28bc55fb --- /dev/null +++ b/litellm/llms/e2b/sandbox/transformation.py @@ -0,0 +1,219 @@ +""" +e2b sandbox provider. + +Talks to e2b's REST API directly over httpx (no e2b SDK dependency): + - create: POST {api_base}/sandboxes + - run: POST https://{JUPYTER_PORT}-{sandboxID}.{domain}/execute (NDJSON stream) + - delete: DELETE {api_base}/sandboxes/{sandboxID} +""" + +import json +from typing import Union, cast + +import httpx + +from litellm.llms.base_llm.sandbox.transformation import ( + BaseSandboxConfig, + CodeExecutionResult, + ContainerHandle, +) +from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, + get_async_httpx_client, +) +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.custom_http import httpxSpecialProvider + +E2B_API_BASE = "https://api.e2b.app" +E2B_DEFAULT_TEMPLATE = "code-interpreter-v1" +E2B_DEFAULT_DOMAIN = "e2b.app" +JUPYTER_PORT = 49999 +DEFAULT_SANDBOX_TIMEOUT = 300 +MAX_OUTPUT_BYTES = 10 * 1024 * 1024 + + +class E2BSandboxConfig(BaseSandboxConfig): + def _http(self, client: AsyncHTTPHandler | None) -> AsyncHTTPHandler: + if client is not None: + return client + return get_async_httpx_client(llm_provider=httpxSpecialProvider.Sandbox) + + def validate_environment(self, api_key: str | None = None, **kwargs) -> str: + key = api_key or get_secret_str("E2B_API_KEY") + if not key: + raise ValueError("E2B API key not set. Set E2B_API_KEY or pass api_key=...") + return key + + async def acreate_sandbox( + self, + *, + template: str | None = None, + timeout: int | None = None, + allow_internet_access: bool = True, + api_key: str | None = None, + metadata: dict | None = None, + client: AsyncHTTPHandler | None = None, + **kwargs, + ) -> ContainerHandle: + key = self.validate_environment(api_key=api_key) + body = { + "templateID": template or E2B_DEFAULT_TEMPLATE, + "timeout": timeout if timeout is not None else DEFAULT_SANDBOX_TIMEOUT, + "secure": True, + "allow_internet_access": allow_internet_access, + } + if metadata: + body["metadata"] = metadata + + response = cast( + httpx.Response, + await self._http(client).post( + url=f"{E2B_API_BASE}/sandboxes", + headers={"X-API-Key": key, "Content-Type": "application/json"}, + json=body, + ), + ) + data = response.json() + + handle = ContainerHandle( + id=data["sandboxID"], + provider="e2b", + domain=data.get("domain") or E2B_DEFAULT_DOMAIN, + ) + handle._hidden_params = { + "envd_access_token": data.get("envdAccessToken"), + "traffic_access_token": data.get("trafficAccessToken"), + "api_key": key, + } + return handle + + async def arun_code( + self, + *, + container: Union[ContainerHandle, str], + code: str, + api_key: str | None = None, + env_vars: dict | None = None, + client: AsyncHTTPHandler | None = None, + **kwargs, + ) -> CodeExecutionResult: + handle = self._as_handle(container) + + token = handle._hidden_params.get("envd_access_token") + if not token: + raise ValueError( + "Cannot run code from a sandbox id alone. e2b secure sandboxes " + "require the access token returned by acreate_sandbox; pass the " + "ContainerHandle it returned instead of a bare sandbox id." + ) + + headers = {"Content-Type": "application/json", "X-Access-Token": token} + traffic_token = handle._hidden_params.get("traffic_access_token") + if traffic_token: + headers["E2B-Traffic-Access-Token"] = traffic_token + + url = f"https://{JUPYTER_PORT}-{handle.id}.{handle.domain}/execute" + response = cast( + httpx.Response, + await self._http(client).post( + url=url, + headers=headers, + json={"code": code, "context_id": None, "env_vars": env_vars}, + stream=True, + ), + ) + lines = await self._read_capped_lines(response) + return self._parse_lines(lines) + + async def adelete_sandbox( + self, + *, + container: Union[ContainerHandle, str], + api_key: str | None = None, + client: AsyncHTTPHandler | None = None, + **kwargs, + ) -> bool: + handle = self._as_handle(container) + key = ( + api_key + or handle._hidden_params.get("api_key") + or self.validate_environment() + ) + try: + response = cast( + httpx.Response, + await self._http(client).delete( + url=f"{E2B_API_BASE}/sandboxes/{handle.id}", + headers={"X-API-Key": key}, + ), + ) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return False + raise + return 200 <= response.status_code < 300 + + @staticmethod + def _as_handle(container: Union[ContainerHandle, str]) -> ContainerHandle: + if isinstance(container, ContainerHandle): + return container + handle = ContainerHandle( + id=str(container), provider="e2b", domain=E2B_DEFAULT_DOMAIN + ) + handle._hidden_params = {} + return handle + + @staticmethod + async def _read_capped_lines(response: httpx.Response) -> list[str]: + lines: list[str] = [] + total = 0 + async for line in response.aiter_lines(): + total += len(line.encode("utf-8")) + if total > MAX_OUTPUT_BYTES: + raise ValueError( + f"Sandbox output exceeded {MAX_OUTPUT_BYTES} bytes; aborting to " + "avoid unbounded memory use." + ) + lines.append(line) + return lines + + @staticmethod + def _parse_lines(lines: list[str]) -> CodeExecutionResult: + def _try_parse(stripped: str): + try: + return json.loads(stripped) + except json.JSONDecodeError: + return None + + messages = tuple( + parsed + for stripped in (line.strip() for line in lines) + if stripped + for parsed in (_try_parse(stripped),) + if parsed is not None + ) + + def of_type(message_type: str): + return (m for m in messages if m.get("type") == message_type) + + error = next( + ( + {key: m.get(key) for key in ("name", "value", "traceback")} + for m in of_type("error") + ), + None, + ) + execution_count = next( + (m.get("execution_count") for m in of_type("number_of_executions")), + None, + ) + + return CodeExecutionResult( + stdout="".join(m.get("text", "") for m in of_type("stdout")), + stderr="".join(m.get("text", "") for m in of_type("stderr")), + results=[ + {k: v for k, v in m.items() if k != "type"} for m in of_type("result") + ], + error=error, + execution_count=execution_count, + ) diff --git a/litellm/llms/watsonx/embed/transformation.py b/litellm/llms/watsonx/embed/transformation.py index 930212e3ef3..ae873d63fe9 100644 --- a/litellm/llms/watsonx/embed/transformation.py +++ b/litellm/llms/watsonx/embed/transformation.py @@ -43,8 +43,19 @@ class IBMWatsonXEmbeddingConfig(IBMWatsonXMixin, BaseEmbeddingConfig): api_params=watsonx_api_params, ) + if isinstance(input, str): + inputs: list[str] = [input] + elif isinstance(input, list): + if len(input) > 0 and isinstance(input[0], (list, int)): + raise ValueError( + "WatsonX embeddings require a string or list of strings" + ) + inputs = input + else: + inputs = [input] + return { - "inputs": input, + "inputs": inputs, "parameters": optional_params, **watsonx_auth_payload, } diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404.html index 45de348c4d5..d96cf497e18 100644 --- a/litellm/proxy/_experimental/out/404.html +++ b/litellm/proxy/_experimental/out/404.html @@ -1 +1 @@ -