[Feat] Adds Shell tool support for the OpenAI Responses API (#21063)

* test_responses_api_context_management_server_side_compaction

* Server-side compaction

* docs fix

* test_responses_api_shell_tool

* add SHELL tool

* test_responses_api_shell_tool

* add SHELL_CALL_IN_PROGRESS

* add SHELL_CALL_IN_PROGRESS events

* TestOpenAIResponsesAPITest

* transform_streaming_response

* test_responses_api_shell_tool_streaming_sees_shell_output

* test_responses_api_shell_tool_streaming_sees_shell_output

* test_responses_api_shell_tool

* docs fix
This commit is contained in:
Ishaan Jaff
2026-02-12 13:04:29 -08:00
committed by GitHub
parent 5a0db00d8a
commit 736daf0a7d
5 changed files with 209 additions and 32 deletions
+58
View File
@@ -1093,6 +1093,64 @@ curl -X POST "http://localhost:4000/v1/responses" \
}'
```
## Shell tool
The **Shell tool** lets the model run commands in a hosted container or local runtime (OpenAI Responses API). You pass `tools=[{"type": "shell", "environment": {...}}]`; the `environment` object configures the runtime (e.g. `type: "container_auto"` for auto-provisioned containers). See [OpenAI Shell tool guide](https://developers.openai.com/api/docs/guides/tools-shell) for full options.
Supported when using the `openai` or `azure` provider with a model that supports the Shell tool.
### Python SDK
```python showLineNumbers title="Shell tool with LiteLLM Python SDK"
import litellm
response = litellm.responses(
model="openai/gpt-5.2",
input="List files in /mnt/data and run python --version.",
tools=[{"type": "shell", "environment": {"type": "container_auto"}}],
tool_choice="auto",
max_output_tokens=1024,
)
```
### LiteLLM Proxy (AI Gateway)
Use the OpenAI SDK with your proxy as `base_url`, or call the proxy with curl. The proxy forwards `tools` (including `type: "shell"`) to the provider.
**OpenAI Python SDK (proxy as base_url):**
```python showLineNumbers title="Shell tool via LiteLLM Proxy"
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:4000",
api_key="your-proxy-api-key",
)
response = client.responses.create(
model="openai/gpt-5.2",
input="List files in /mnt/data.",
tools=[{"type": "shell", "environment": {"type": "container_auto"}}],
tool_choice="auto",
max_output_tokens=1024,
)
```
**curl:**
```bash title="Shell tool via curl to LiteLLM Proxy"
curl -X POST "http://localhost:4000/v1/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-proxy-api-key" \
-d '{
"model": "openai/gpt-5.2",
"input": "List files in /mnt/data.",
"tools": [{"type": "shell", "environment": {"type": "container_auto"}}],
"tool_choice": "auto",
"max_output_tokens": 1024
}'
```
## Session Management
LiteLLM Proxy supports session management for all supported models. This allows you to store and fetch conversation history (state) in LiteLLM Proxy.
@@ -240,24 +240,15 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
event_pydantic_model = OpenAIResponsesAPIConfig.get_event_model_class(
event_type=event_type
)
# Defensive: Some OpenAI-compatible providers may send `error.code: null`.
# Pydantic will raise a ValidationError when it expects a string but gets None.
# Coalesce a None `error.code` to a stable default string so streaming
# iteration does not crash (see issue report). This keeps behavior similar
# to previous fixes (coalesce before validation) and lets higher-level
# handlers still receive an `ErrorEvent` object.
# Some OpenAI-compatible providers send error.code: null; coalesce so validation succeeds.
try:
error_obj = parsed_chunk.get("error")
if isinstance(error_obj, dict) and error_obj.get("code") is None:
# Preserve other fields, but ensure `code` is a non-null string
parsed_chunk = dict(parsed_chunk)
parsed_chunk["error"] = dict(error_obj)
parsed_chunk["error"]["code"] = "unknown_error"
except Exception:
# If anything unexpected happens here, fall back to attempting
# instantiation and let higher-level handlers manage errors.
verbose_logger.debug("Failed to coalesce error.code in parsed_chunk")
return event_pydantic_model(**parsed_chunk)
@staticmethod
@@ -307,6 +298,10 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
ResponsesAPIStreamEvents.MCP_CALL_FAILED: MCPCallFailedEvent,
ResponsesAPIStreamEvents.IMAGE_GENERATION_PARTIAL_IMAGE: ImageGenerationPartialImageEvent,
ResponsesAPIStreamEvents.ERROR: ErrorEvent,
# Shell tool events: passthrough as GenericEvent so payload is preserved
ResponsesAPIStreamEvents.SHELL_CALL_IN_PROGRESS: GenericEvent,
ResponsesAPIStreamEvents.SHELL_CALL_COMPLETED: GenericEvent,
ResponsesAPIStreamEvents.SHELL_CALL_OUTPUT: GenericEvent,
}
model_class = event_models.get(cast(ResponsesAPIStreamEvents, event_type))
+34 -4
View File
@@ -1058,7 +1058,20 @@ class ComputerToolParam(TypedDict, total=False):
type: Required[Union[Literal["computer_use_preview"], str]]
ALL_RESPONSES_API_TOOL_PARAMS = Union[ToolParam, ComputerToolParam]
class ShellToolParam(TypedDict, total=False):
"""
Shell tool for Responses API: run commands in hosted containers or local runtime.
See https://developers.openai.com/api/docs/guides/tools-shell.
"""
type: Required[Union[Literal["shell"], str]]
"""The type of tool. Use ``\"shell\"``."""
environment: Required[Dict[str, Any]]
"""Environment config: ``type`` (e.g. ``\"container_auto\"``, ``\"container_reference\"``, ``\"local\"``), optional ``container_id``, ``network_policy``, ``domain_secrets``, ``skills``."""
ALL_RESPONSES_API_TOOL_PARAMS = Union[ToolParam, ComputerToolParam, ShellToolParam]
class PromptObject(TypedDict, total=False):
@@ -1204,7 +1217,7 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
top_p: Optional[float] = None
max_output_tokens: Optional[int] = None
previous_response_id: Optional[str] = None
reasoning: Optional[Reasoning] = None
reasoning: Optional[Dict[str, Any]] = None
status: Optional[str] = None
text: Optional[Union["ResponseText", Dict[str, Any]]] = None
truncation: Optional[Literal["auto", "disabled"]] = None
@@ -1214,6 +1227,18 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
# Define private attributes using PrivateAttr
_hidden_params: dict = PrivateAttr(default_factory=dict)
@field_validator("reasoning", mode="before")
@classmethod
def validate_reasoning_to_dict(cls, value: Any) -> Optional[Dict[str, Any]]:
"""Accept API reasoning dict (including effort 'none'/'xhigh'); always store as dict."""
if value is None:
return None
if isinstance(value, dict):
return value
if hasattr(value, "model_dump"):
return value.model_dump()
return value
@field_validator("usage", mode="before")
@classmethod
def validate_usage(cls, value):
@@ -1322,6 +1347,11 @@ class ResponsesAPIStreamEvents(str, Enum):
# Image generation events
IMAGE_GENERATION_PARTIAL_IMAGE = "image_generation.partial_image"
# Shell tool events (Responses API; passthrough via GenericEvent)
SHELL_CALL_IN_PROGRESS = "response.shell_call.in_progress"
SHELL_CALL_COMPLETED = "response.shell_call.completed"
SHELL_CALL_OUTPUT = "response.shell_call_output.done"
# Error event
ERROR = "error"
@@ -1608,12 +1638,12 @@ class ImageGenerationPartialImageEvent(BaseLiteLLMOpenAIResponseObject):
class ErrorEventError(BaseLiteLLMOpenAIResponseObject):
"""Nested error object within ErrorEvent"""
"""Nested error object within ErrorEvent."""
type: str # e.g., 'invalid_request_error'
code: str # e.g., 'context_length_exceeded'
message: str
param: Optional[str]
param: Optional[str] = None
class ErrorEvent(BaseLiteLLMOpenAIResponseObject):
@@ -2,7 +2,7 @@ import httpx
import json
import pytest
import sys
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock, Mock, patch
import os
from litellm._uuid import uuid
@@ -114,6 +114,10 @@ class BaseResponsesAPITest(ABC):
"""Must return the base completion reasoning call args"""
return None
def get_advanced_model_for_shell_tool(self) -> Optional[str]:
"""If specified, overrides the model used by test_responses_api_shell_tool_streaming_sees_shell_output (e.g. openai/gpt-5.2 for shell support)."""
return None
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
async def test_basic_openai_responses_api(self, sync_mode):
@@ -681,9 +685,8 @@ class BaseResponsesAPITest(ABC):
response_id="invalid_response_id_12345", **base_completion_call_args
)
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
async def test_responses_api_context_management_server_side_compaction(self, sync_mode):
async def test_responses_api_context_management_server_side_compaction(self):
"""
E2E test for server-side compaction (context_management) on OpenAI Responses API.
Passes context_management with compact_threshold; validates that the request is
@@ -698,22 +701,110 @@ class BaseResponsesAPITest(ABC):
)
context_management = [{"type": "compaction", "compact_threshold": 200000}]
try:
if sync_mode:
response = litellm.responses(
input="Short ping to verify context_management is accepted.",
max_output_tokens=20,
context_management=context_management,
**base_completion_call_args,
)
else:
response = await litellm.aresponses(
input="Short ping to verify context_management is accepted.",
max_output_tokens=20,
context_management=context_management,
**base_completion_call_args,
)
response = await litellm.aresponses(
input="Short ping to verify context_management is accepted.",
max_output_tokens=20,
context_management=context_management,
**base_completion_call_args,
)
except litellm.InternalServerError:
pytest.skip("Skipping test due to litellm.InternalServerError")
validate_responses_api_response(response, final_chunk=True)
assert response.get("id") is not None
assert response.get("status") is not None
assert response.get("status") is not None
@pytest.mark.asyncio
async def test_responses_api_shell_tool(self):
"""
E2E test for Shell tool on OpenAI Responses API.
Passes tools=[{"type": "shell", "environment": {"type": "container_auto"}}];
validates that the request is accepted and returns a valid response.
Only runs for OpenAI/Azure (Responses API with shell support).
"""
base_completion_call_args = self.get_base_completion_call_args()
model = self.get_advanced_model_for_shell_tool() or base_completion_call_args.get(
"model"
) or ""
if "openai/" not in str(model) and "azure/" not in str(model):
pytest.skip(
"Shell tool e2e is only run for OpenAI/Azure Responses API"
)
tools = [{"type": "shell", "environment": {"type": "container_auto"}}]
input_msg = "List files in /mnt/data and show python --version."
try:
response = await litellm.aresponses(
**{**base_completion_call_args, "model": model},
input=input_msg,
max_output_tokens=256,
tools=tools,
tool_choice="auto",
)
except litellm.InternalServerError:
pytest.skip("Skipping test due to litellm.InternalServerError")
except litellm.BadRequestError as e:
if "shell" in str(e).lower() and "not supported" in str(e).lower():
pytest.skip(
"Shell tool is not supported for this model (e.g. gpt-4o); use a model that supports shell"
)
raise
validate_responses_api_response(response, final_chunk=True)
assert response.get("id") is not None
assert response.get("status") is not None
@pytest.mark.asyncio
async def test_responses_api_shell_tool_streaming_sees_shell_output(self):
"""
E2E streaming call with Shell tool; validate we can see shell output in the stream.
Calls aresponses(..., tools=[shell], stream=True), then iterates the stream and
asserts at least one event is shell-related or response output contains shell_call.
Skips when model does not support shell (e.g. gpt-4o).
"""
base_completion_call_args = self.get_base_completion_call_args()
model = self.get_advanced_model_for_shell_tool() or base_completion_call_args.get(
"model"
) or "openai/gpt-5.2"
tools = [{"type": "shell", "environment": {"type": "container_auto"}}]
input_msg = "List files in /mnt/data and run python --version."
stream = await litellm.aresponses(
**{**base_completion_call_args, "model": model},
input=input_msg,
max_output_tokens=512,
tools=tools,
tool_choice="auto",
stream=True,
)
event_types_seen = []
output_items_with_shell = []
async for event in stream:
print("event=", json.dumps(event, indent=4, default=str))
event_type = getattr(event, "type", None) or (
event.get("type") if isinstance(event, dict) else None
)
if event_type is not None:
event_types_seen.append(str(event_type))
if "shell" in str(event_type or "").lower():
output_items_with_shell.append(event_type)
response_obj = getattr(event, "response", None) or (
event.get("response") if isinstance(event, dict) else None
)
if response_obj is not None:
output = getattr(response_obj, "output", None) or (
response_obj.get("output") if isinstance(response_obj, dict) else None
)
if isinstance(output, list):
for item in output:
item_type = getattr(item, "type", None) or (
item.get("type") if isinstance(item, dict) else None
)
if item_type and "shell" in str(item_type).lower():
output_items_with_shell.append(item_type)
assert len(event_types_seen) > 0, "Expected at least one stream event"
assert len(output_items_with_shell) > 0, (
f"Expected to see shell output in stream; event types seen: {event_types_seen!r}"
)
@@ -36,6 +36,9 @@ class TestOpenAIResponsesAPITest(BaseResponsesAPITest):
"model": "openai/gpt-5-mini",
}
def get_advanced_model_for_shell_tool(self):
return "openai/gpt-5.2"
class TestCustomLogger(CustomLogger):
def __init__(