From 736daf0a7daf9a44f7101d98794bd691e017e9fa Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 12 Feb 2026 13:04:29 -0800 Subject: [PATCH] [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 --- docs/my-website/docs/response_api.md | 58 ++++++++ .../llms/openai/responses/transformation.py | 15 +-- litellm/types/llms/openai.py | 38 +++++- .../base_responses_api.py | 127 +++++++++++++++--- .../test_openai_responses_api.py | 3 + 5 files changed, 209 insertions(+), 32 deletions(-) diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 49ac78b52e..dd2b77712c 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -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. diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index cc2439b431..5c87071106 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -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)) diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 6925e2327c..4ab81f8fd5 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -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): diff --git a/tests/llm_responses_api_testing/base_responses_api.py b/tests/llm_responses_api_testing/base_responses_api.py index b6bcc1664a..7ac83753d1 100644 --- a/tests/llm_responses_api_testing/base_responses_api.py +++ b/tests/llm_responses_api_testing/base_responses_api.py @@ -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 \ No newline at end of file + 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}" + ) diff --git a/tests/llm_responses_api_testing/test_openai_responses_api.py b/tests/llm_responses_api_testing/test_openai_responses_api.py index 5f35d6837c..4972aa385c 100644 --- a/tests/llm_responses_api_testing/test_openai_responses_api.py +++ b/tests/llm_responses_api_testing/test_openai_responses_api.py @@ -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__(