mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-21 08:26:34 +00:00
[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:
@@ -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))
|
||||
|
||||
@@ -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__(
|
||||
|
||||
Reference in New Issue
Block a user