litellm/tests/test_litellm/sandbox/test_sandbox_tools.py
Krrish Dholakia 84c1414aef
Some checks are pending
GitHub Actions Security Analysis / zizmor (push) Waiting to run
feat(sandbox): code interpreter interceptor on the Responses API (#30905)
* feat(sandbox): code interpreter interceptor on the Responses API

Route OpenAI's code interpreter to a configured sandbox (e2b) instead of
OpenAI's container, with no client change. A client calls /v1/responses with
a code_interpreter tool; the interceptor converts it to a function tool so the
model emits the code, runs that code in the sandbox via the phase 1 primitive,
feeds the result back, and lets the agentic loop continue.

Reuses the existing agentic-loop hooks (no new hook methods). The anthropic
agentic caller _call_agentic_completion_hooks gains an api_surface argument and
a responses execute path (_execute_responses_agentic_plan re-calls aresponses);
the responses handler invokes it after transforming the response. Web search and
compression interceptors are untouched.

Adds an api_base passthrough to the sandbox SDK and a sandbox_tools registry the
proxy parses, so the interceptor resolves a named tool to provider/key/base.

v0 limitation: no file upload or download yet; stdout and inline results flow
back, attaching input files and downloading produced files do not.

* feat(sandbox): re-inject code_interpreter_call so the response matches OpenAI

The native OpenAI Responses code interpreter returns a code_interpreter_call
output item (id, type, status, code, container_id, outputs) alongside the
message. The interceptor now re-injects an equivalent item via
async_post_agentic_loop_response_hook so a client gets the same response shape
whether the code ran in OpenAI's container or the sandbox: build_plan records
the executed code and the container id per call, and the post hook inserts the
code_interpreter_call before the message in the final response output.

* feat(sandbox): support streaming for the code interpreter interceptor

A stream:true /v1/responses request with code_interpreter previously broke,
because the agentic loop only runs on the non-streaming responses path. The
interceptor now forces stream=False in the pre-call hook (so the loop runs in
the sandbox) and the responses handler wraps the completed response back into a
synthetic stream via MockResponsesAPIStreamingIterator, so the caller still gets
SSE. The follow-up call and nested wrapping are guarded by stripping the
converted-stream flag from the follow-up request and only wrapping at the
outermost call (agentic loop depth 0).

* fix(lint): use builtin generics in code interpreter interceptor to satisfy UP006 budget

* fix(code-interpreter): gate sandbox execution, delete sandboxes, harden registry

Gate the agentic loop on a server-set interception marker and re-check
provider scope so an authenticated caller cannot trigger sandbox code
execution by naming their own function tool litellm_code_execution; the
marker is stripped from client requests at the proxy boundary and only
set when the pre-call hook actually converts a native code_interpreter
tool. Delete the sandbox once the final response is assembled instead of
leaking it until its own timeout, and prune expired cache entries by
deleting their containers too. Resolve sandbox params once at create time
and reuse them for run and delete. Clear the sandbox-tool registry before
re-registering so stale tools do not survive a config reload.

* fix(lint): use PEP 604 X | None unions to satisfy UP045 budget

* test(code-interpreter): cover execution-error and unparseable-argument tool-call paths

* fix(code-interpreter): rewrite forced code_interpreter tool_choice to the function tool

* test(sandbox): cover sandbox-tool registry resolution, reload clearing, and secret lookup

* test(code-interpreter): cover dict-shaped responses and object-attribute tool-call detection

* fix(proxy): strip client-supplied _code_interpreter_interception_converted_stream

A client could inject the converted-stream marker to force the completed
response to be re-wrapped as a synthetic SSE stream it never requested.
Add it to the untrusted root control fields alongside the other agentic
loop markers so the proxy strips it at the request boundary.

* fix(code-interpreter): isolate sandboxes by server-minted key and clear registry on tool removal

Key the per-request sandbox cache on a server-minted random token instead
of the caller-controlled litellm_call_id (sourced from the x-litellm-call-id
header). Two concurrent requests that send a colliding call id can no longer
share a sandbox container and read each other's code or files. The token is
minted in the pre-call hook when interception activates, stripped from client
requests at the proxy boundary, and survives the server-driven followups so a
single request still reuses one sandbox across the agentic loop.

Register sandbox tools unconditionally with an empty-list fallback so a config
reload that removes sandbox_tools clears the previously registered credentials
instead of leaving them resolvable in the process.

* refactor(sandbox): swap the tool registry atomically on reload

Build the new registry and rebind it in one assignment instead of clearing
then repopulating in place, so a concurrent resolve_sandbox_tool can never
observe a transiently empty or half-populated registry during a config
reload. clear_sandbox_tools now delegates to register_sandbox_tools([]).

* fix(code-interpreter): cap caller loop limit and emit OpenAI-shaped outputs

Strip max_agentic_loops at the proxy request boundary so an authenticated
caller cannot raise the agentic-loop ceiling to drive many upstream model
calls and sandbox executions from a single request; the loop stays bounded
by the server default.

Populate the re-injected code_interpreter_call.outputs with an OpenAI-shaped
logs array ([{"type": "logs", "logs": stdout}], or [] when there is no
stdout) instead of None, so clients that iterate over outputs or validate the
response through the OpenAI SDK's Pydantic model do not break.
2026-06-20 21:12:13 -07:00

181 lines
5.7 KiB
Python

"""Unit tests for the sandbox-tool registry."""
from litellm.sandbox import sandbox_tools
def _reset():
sandbox_tools.clear_sandbox_tools()
def test_register_resolves_provider_key_and_base():
_reset()
sandbox_tools.register_sandbox_tools(
[
{
"sandbox_tool_name": "e2b_default",
"litellm_params": {
"sandbox_provider": "e2b",
"api_key": "sk-literal",
"api_base": "https://sandbox.internal",
},
}
]
)
resolved = sandbox_tools.resolve_sandbox_tool("e2b_default")
assert resolved == {
"sandbox_provider": "e2b",
"api_key": "sk-literal",
"api_base": "https://sandbox.internal",
}
_reset()
def test_register_clears_stale_entries_on_reload():
"""A tool removed from the config must not survive a re-registration."""
_reset()
sandbox_tools.register_sandbox_tools(
[
{
"sandbox_tool_name": "old",
"litellm_params": {"sandbox_provider": "e2b"},
}
]
)
assert sandbox_tools.resolve_sandbox_tool("old") is not None
sandbox_tools.register_sandbox_tools(
[
{
"sandbox_tool_name": "new",
"litellm_params": {"sandbox_provider": "e2b"},
}
]
)
assert sandbox_tools.resolve_sandbox_tool("new") is not None
assert (
sandbox_tools.resolve_sandbox_tool("old") is None
), "stale tool must be gone after the config is reloaded"
_reset()
def test_register_empty_list_clears_removed_tools():
"""Reloading a config with sandbox_tools removed (the proxy passes an empty
list) must drop previously registered credentials from the process."""
_reset()
sandbox_tools.register_sandbox_tools(
[
{
"sandbox_tool_name": "e2b_default",
"litellm_params": {"sandbox_provider": "e2b", "api_key": "sk-x"},
}
]
)
assert sandbox_tools.resolve_sandbox_tool("e2b_default") is not None
sandbox_tools.register_sandbox_tools([])
assert (
sandbox_tools.resolve_sandbox_tool("e2b_default") is None
), "removing sandbox_tools from config must clear stale credentials"
_reset()
def test_register_resolves_secret_from_env(monkeypatch):
_reset()
monkeypatch.setenv("MY_SANDBOX_KEY", "sk-from-env")
sandbox_tools.register_sandbox_tools(
[
{
"sandbox_tool_name": "e2b_default",
"litellm_params": {
"sandbox_provider": "e2b",
"api_key": "os.environ/MY_SANDBOX_KEY",
},
}
]
)
resolved = sandbox_tools.resolve_sandbox_tool("e2b_default")
assert resolved is not None
assert resolved["api_key"] == "sk-from-env"
assert resolved["api_base"] is None
_reset()
def test_resolve_unknown_returns_none():
_reset()
assert sandbox_tools.resolve_sandbox_tool("nope") is None
def test_register_skips_malformed_entries_without_crashing():
"""A single malformed entry (missing sandbox_tool_name, or not a dict) must
not crash registration during proxy startup/hot-reload; valid entries in the
same list must still register."""
_reset()
sandbox_tools.register_sandbox_tools(
[
{"litellm_params": {"sandbox_provider": "e2b"}}, # missing name
"not-a-dict", # wrong type
{"sandbox_tool_name": "", "litellm_params": {}}, # empty name
{
"sandbox_tool_name": "good",
"litellm_params": {"sandbox_provider": "e2b"},
},
]
)
assert sandbox_tools.resolve_sandbox_tool("good") is not None
assert sandbox_tools.resolve_sandbox_tool("") is None
assert set(sandbox_tools._SANDBOX_TOOL_REGISTRY) == {"good"}
_reset()
def test_register_skips_entry_missing_sandbox_provider():
"""An entry with a name but no sandbox_provider must be skipped at
registration so it cannot later resolve and call acreate_sandbox(provider=None),
which fails with a cryptic runtime error instead of a clear startup warning."""
_reset()
sandbox_tools.register_sandbox_tools(
[
{"sandbox_tool_name": "no_provider", "litellm_params": {"api_key": "sk-x"}},
{
"sandbox_tool_name": "null_provider",
"litellm_params": {"sandbox_provider": None},
},
{
"sandbox_tool_name": "good",
"litellm_params": {"sandbox_provider": "e2b"},
},
]
)
assert sandbox_tools.resolve_sandbox_tool("no_provider") is None
assert sandbox_tools.resolve_sandbox_tool("null_provider") is None
assert set(sandbox_tools._SANDBOX_TOOL_REGISTRY) == {"good"}
_reset()
def test_register_swaps_registry_atomically():
"""register_sandbox_tools must replace the registry in one rebind so a
concurrent resolve never observes a half-populated or transiently empty
registry between clearing and repopulating."""
_reset()
sandbox_tools.register_sandbox_tools(
[{"sandbox_tool_name": "a", "litellm_params": {"sandbox_provider": "e2b"}}]
)
before = sandbox_tools._SANDBOX_TOOL_REGISTRY
sandbox_tools.register_sandbox_tools(
[
{"sandbox_tool_name": "b", "litellm_params": {"sandbox_provider": "e2b"}},
{"sandbox_tool_name": "c", "litellm_params": {"sandbox_provider": "e2b"}},
]
)
after = sandbox_tools._SANDBOX_TOOL_REGISTRY
assert after is not before, "the registry must be replaced, not mutated in place"
assert set(after) == {"b", "c"}
assert "a" not in after
_reset()