feat(openai_like): add Responses API support to JSON provider system

Add infrastructure for JSON-declared providers to support /v1/responses
via `supported_endpoints` field in providers.json. Simplify Perplexity
responses config from 410 to 40 lines by moving cost dict→float parsing
to generic validators in ResponseAPIUsage and Usage.

- Add `supported_endpoints` field to SimpleProviderConfig (default: [])
- Add `supports_responses_api()` to JSONProviderRegistry
- Create OpenAILikeResponsesConfig base class for responses API
- Add `create_responses_config_class()` with class caching
- ProviderConfigManager: Python classes take priority over JSON fallback
- Fix ResponseAPIUsage.cost field_validator to handle dict cost objects
- Fix Usage.__init__ to handle dict cost from chat completions
- Simplify PerplexityResponsesConfig with get_supported_openai_params guard
- Add 20 unit tests including Python-over-JSON priority test
This commit is contained in:
Chesars 2026-02-17 16:15:00 -03:00
parent 809838042e
commit 0c27f20692
14 changed files with 639 additions and 376 deletions

View file

@ -80,6 +80,36 @@ That's it! The provider is now available.
}
```
## Responses API Support
If your provider also supports the OpenAI Responses API (`/v1/responses`), add `supported_endpoints`:
```json
{
"your_provider": {
"base_url": "https://api.yourprovider.com/v1",
"api_key_env": "YOUR_PROVIDER_API_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
}
}
```
This enables `litellm.responses()` with zero additional code:
```python
import litellm
response = litellm.responses(
model="your_provider/model-name",
input="Hello, what can you do?",
)
print(response.output)
```
If `supported_endpoints` is omitted, it defaults to `[]`. Chat completions is always enabled for JSON providers regardless of this field.
The provider inherits all request/response handling from OpenAI's Responses API — streaming, tools, and all standard parameters work out of the box.
## Usage
```python
@ -89,11 +119,17 @@ import os
# Set your API key
os.environ["YOUR_PROVIDER_API_KEY"] = "your-key-here"
# Use the provider
# Chat completions
response = litellm.completion(
model="your_provider/model-name",
messages=[{"role": "user", "content": "Hello"}],
)
# Responses API (if supported_endpoints includes "/v1/responses")
response = litellm.responses(
model="your_provider/model-name",
input="Hello",
)
```
## When to Use Python Instead
@ -105,7 +141,9 @@ Use a Python config class if you need:
- Provider-specific streaming logic
- Advanced tool calling modifications
For these cases, create a config class in `litellm/llms/your_provider/chat/transformation.py` that inherits from `OpenAIGPTConfig` or `OpenAILikeChatConfig`.
For chat completions, create a config class in `litellm/llms/your_provider/chat/transformation.py` that inherits from `OpenAIGPTConfig` or `OpenAILikeChatConfig`.
For responses API with small overrides, inherit from `OpenAIResponsesAPIConfig` and override only what's needed. See `litellm/llms/perplexity/responses/transformation.py` for a minimal example (~40 lines vs 400+).
## Testing

View file

@ -10,8 +10,9 @@ Instead of creating a full Python module for simple OpenAI-compatible providers,
- `providers.json` - Configuration file for all JSON-based providers
- `json_loader.py` - Loads and parses the JSON configuration
- `dynamic_config.py` - Generates Python config classes from JSON
- `chat/` - Existing OpenAI-like chat completion handlers
- `dynamic_config.py` - Generates Python config classes from JSON (chat + responses)
- `chat/` - OpenAI-like chat completion handlers
- `responses/` - OpenAI-like Responses API handlers
## Adding a New Provider
@ -96,6 +97,32 @@ response = litellm.completion(
)
```
## Responses API Support
Providers that support the OpenAI Responses API (`/v1/responses`) can declare it via `supported_endpoints`:
```json
{
"your_provider": {
"base_url": "https://api.yourprovider.com/v1",
"api_key_env": "YOUR_PROVIDER_API_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
}
}
```
This enables `litellm.responses(model="your_provider/model-name", ...)` with zero Python code.
The provider inherits all request/response handling from OpenAI's Responses API config.
If `supported_endpoints` is omitted, it defaults to `[]` (only chat completions, which is always enabled for JSON providers).
### How It Works
1. `json_loader.py` checks `supported_endpoints` for `/v1/responses`
2. `dynamic_config.py` generates a responses config class (inherits from `OpenAIResponsesAPIConfig`)
3. `ProviderConfigManager.get_provider_responses_api_config()` returns the generated config
4. Request/response transformation is inherited from OpenAI — no custom code needed
## Benefits
- **Simple**: 2-5 lines of JSON vs 100+ lines of Python
@ -112,6 +139,10 @@ Use a Python config class if you need:
- Provider-specific streaming logic
- Advanced tool calling transformations
For providers that are *mostly* OpenAI-compatible but need small overrides (e.g. preset model handling),
you can inherit from `OpenAIResponsesAPIConfig` and override only what's needed — see
`litellm/llms/perplexity/responses/transformation.py` for a minimal example (~40 lines).
## Implementation Details
### How It Works
@ -125,5 +156,6 @@ Use a Python config class if you need:
The JSON system is integrated at:
- `litellm/litellm_core_utils/get_llm_provider_logic.py` - Provider resolution
- `litellm/utils.py` - ProviderConfigManager
- `litellm/utils.py` - ProviderConfigManager (chat + responses)
- `litellm/responses/main.py` - Responses API routing
- `litellm/constants.py` - openai_compatible_providers list

View file

@ -166,3 +166,63 @@ def create_config_class(provider: SimpleProviderConfig):
return provider.slug
return JSONProviderConfig
_responses_config_cache: dict = {}
def create_responses_config_class(provider: SimpleProviderConfig):
"""Generate a Responses API config class dynamically from JSON configuration.
Parallel to create_config_class() but for /v1/responses endpoints.
Classes are cached per provider slug to avoid regeneration on every request.
"""
if provider.slug in _responses_config_cache:
return _responses_config_cache[provider.slug]
from litellm.llms.openai_like.responses.transformation import (
OpenAILikeResponsesConfig,
)
from litellm.types.router import GenericLiteLLMParams
class JSONProviderResponsesConfig(OpenAILikeResponsesConfig):
@property
def custom_llm_provider(self): # type: ignore[override]
return provider.slug
def validate_environment(
self,
headers: dict,
model: str,
litellm_params: Optional[GenericLiteLLMParams],
) -> dict:
litellm_params = litellm_params or GenericLiteLLMParams()
api_key = (
litellm_params.api_key
or get_secret_str(provider.api_key_env)
)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
if not api_base:
if provider.api_base_env:
api_base = get_secret_str(provider.api_base_env)
if not api_base:
api_base = provider.base_url
if api_base is None:
raise ValueError(
f"api_base is required for provider {provider.slug}"
)
api_base = api_base.rstrip("/")
return f"{api_base}/responses"
_responses_config_cache[provider.slug] = JSONProviderResponsesConfig
return JSONProviderResponsesConfig

View file

@ -21,6 +21,7 @@ class SimpleProviderConfig:
self.param_mappings = data.get("param_mappings", {})
self.constraints = data.get("constraints", {})
self.special_handling = data.get("special_handling", {})
self.supported_endpoints = data.get("supported_endpoints", [])
class JSONProviderRegistry:
@ -64,6 +65,14 @@ class JSONProviderRegistry:
"""Check if a provider is defined via JSON"""
return slug in cls._providers
@classmethod
def supports_responses_api(cls, slug: str) -> bool:
"""Check if a JSON provider supports the Responses API"""
provider = cls._providers.get(slug)
if provider is None:
return False
return "/v1/responses" in provider.supported_endpoints
@classmethod
def list_providers(cls) -> list:
"""List all registered provider slugs"""

View file

@ -80,6 +80,11 @@
"base_url": "https://api.gmi-serving.com/v1",
"api_key_env": "GMI_API_KEY"
},
"perplexity": {
"base_url": "https://api.perplexity.ai",
"api_key_env": "PERPLEXITYAI_API_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
},
"sarvam": {
"base_url": "https://api.sarvam.ai/v1",
"api_key_env": "SARVAM_API_KEY",

View file

@ -0,0 +1,5 @@
from litellm.llms.openai_like.responses.transformation import (
OpenAILikeResponsesConfig,
)
__all__ = ["OpenAILikeResponsesConfig"]

View file

@ -0,0 +1,51 @@
"""
OpenAI-like Responses API transformation.
Base class for JSON-declared providers that support the /v1/responses endpoint.
Inherits everything from OpenAIResponsesAPIConfig; subclasses only override
provider-specific resolution (slug, API key env var, base URL).
"""
from typing import Optional, Union
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
class OpenAILikeResponsesConfig(OpenAIResponsesAPIConfig):
"""
Responses API config for OpenAI-compatible providers declared via JSON.
Concrete per-provider classes are generated dynamically in dynamic_config.py.
This base provides the three overridable hooks that the dynamic generator
fills in: custom_llm_provider, validate_environment, get_complete_url.
"""
@property
def custom_llm_provider(self) -> Union[str, LlmProviders]: # type: ignore[override]
return "openai_like"
def validate_environment(
self,
headers: dict,
model: str,
litellm_params: Optional[GenericLiteLLMParams],
) -> dict:
litellm_params = litellm_params or GenericLiteLLMParams()
api_key = litellm_params.api_key or get_secret_str("OPENAI_LIKE_API_KEY")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
api_base = api_base or get_secret_str("OPENAI_LIKE_API_BASE")
if not api_base:
raise ValueError("api_base is required for openai_like provider")
api_base = api_base.rstrip("/")
return f"{api_base}/responses"

View file

@ -1,53 +1,26 @@
"""
Transformation logic for Perplexity Agentic Research API (Responses API)
Perplexity Responses API — OpenAI-compatible.
This module handles the translation between OpenAI's Responses API format
and Perplexity's Responses API format, which supports:
- Third-party model access (OpenAI, Anthropic, Google, xAI, etc.)
- Presets for optimized configurations
- Web search and URL fetching tools
- Reasoning effort control
- Instructions parameter for system-level guidance
The only provider quirks:
- cost returned as dict → handled by ResponseAPIUsage.parse_cost validator
- preset models (preset/pro-search) → handled by transform_responses_api_request
Ref: https://docs.perplexity.ai/api-reference/responses-post
"""
from typing import Any, Dict, List, Optional, Union
from typing import Dict, Optional, Union
import httpx
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import (
ResponseAPIUsage,
ResponseInputParam,
ResponsesAPIOptionalRequestParams,
ResponsesAPIResponse,
ResponsesAPIStreamingResponse,
)
from litellm.types.llms.openai import ResponseInputParam
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
"""
Configuration for Perplexity Agentic Research API (Responses API)
Reference: https://docs.perplexity.ai/agentic-research/quickstart
"""
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.PERPLEXITY
def get_supported_openai_params(self, model: str) -> list:
"""
Perplexity Responses API supports a different set of parameters
Ref: https://docs.perplexity.ai/api-reference/responses-post
"""
"""Ref: https://docs.perplexity.ai/api-reference/responses-post"""
return [
"max_output_tokens",
"stream",
@ -55,124 +28,29 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
"top_p",
"tools",
"reasoning",
"preset",
"instructions",
"models", # Model fallback support
]
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.PERPLEXITY
def validate_environment(
self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams]
) -> dict:
"""Validate environment and set up headers"""
# Get API key from environment
litellm_params = litellm_params or GenericLiteLLMParams()
api_key = (
get_secret_str("PERPLEXITYAI_API_KEY")
litellm_params.api_key
or get_secret_str("PERPLEXITYAI_API_KEY")
or get_secret_str("PERPLEXITY_API_KEY")
)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
"""Get the complete URL for the Perplexity Responses API"""
if api_base is None:
api_base = get_secret_str("PERPLEXITY_API_BASE") or "https://api.perplexity.ai"
# Ensure api_base doesn't end with a slash
api_base = api_base.rstrip("/")
# Add the responses endpoint
return f"{api_base}/v1/responses"
def map_openai_params(
self,
response_api_optional_params: ResponsesAPIOptionalRequestParams,
model: str,
drop_params: bool,
) -> Dict:
"""
Map OpenAI Responses API parameters to Perplexity format
Key differences:
- Supports 'preset' parameter for predefined configurations
- Supports 'instructions' parameter for system-level guidance
- Tools are specified differently (web_search, fetch_url)
"""
mapped_params: Dict[str, Any] = {}
# Map standard parameters
if response_api_optional_params.get("max_output_tokens"):
mapped_params["max_output_tokens"] = response_api_optional_params["max_output_tokens"]
if response_api_optional_params.get("temperature"):
mapped_params["temperature"] = response_api_optional_params["temperature"]
if response_api_optional_params.get("top_p"):
mapped_params["top_p"] = response_api_optional_params["top_p"]
if response_api_optional_params.get("stream"):
mapped_params["stream"] = response_api_optional_params["stream"]
if response_api_optional_params.get("stream_options"):
mapped_params["stream_options"] = response_api_optional_params["stream_options"]
# Map Perplexity-specific parameters (using .get() with Any dict access)
preset = response_api_optional_params.get("preset") # type: ignore
if preset:
mapped_params["preset"] = preset
instructions = response_api_optional_params.get("instructions") # type: ignore
if instructions:
mapped_params["instructions"] = instructions
if response_api_optional_params.get("reasoning"):
mapped_params["reasoning"] = response_api_optional_params["reasoning"]
tools = response_api_optional_params.get("tools")
if tools:
# Convert tools to list of dicts for transformation
tools_list = [dict(tool) if hasattr(tool, '__dict__') else tool for tool in tools] # type: ignore
mapped_params["tools"] = self._transform_tools(tools_list) # type: ignore
return mapped_params
def _transform_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Transform tools to Perplexity format
Perplexity supports:
- web_search: Performs web searches
- fetch_url: Fetches content from URLs
"""
perplexity_tools = []
for tool in tools:
if isinstance(tool, dict):
tool_type = tool.get("type")
# Direct Perplexity tool format
if tool_type in ["web_search", "fetch_url"]:
perplexity_tools.append(tool)
# OpenAI function format - try to map to Perplexity tools
elif tool_type == "function":
function = tool.get("function", {})
function_name = function.get("name", "")
if function_name == "web_search" or "search" in function_name.lower():
perplexity_tools.append({"type": "web_search"})
elif function_name == "fetch_url" or "fetch" in function_name.lower():
perplexity_tools.append({"type": "fetch_url"})
return perplexity_tools
def get_complete_url(self, api_base: Optional[str], litellm_params: dict) -> str:
api_base = api_base or get_secret_str("PERPLEXITY_API_BASE") or "https://api.perplexity.ai"
return f"{api_base.rstrip('/')}/v1/responses"
def transform_responses_api_request(
self,
@ -182,228 +60,19 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Dict:
"""
Transform request to Perplexity Responses API format
"""
# Check if the model is a preset (format: preset/preset-name)
"""Handle preset/ model prefix: send as {"preset": name} instead of {"model": name}."""
if model.startswith("preset/"):
preset_name = model.replace("preset/", "")
data = {
"preset": preset_name,
"input": self._format_input(input),
input = self._validate_input_param(input)
data: Dict = {
"preset": model[len("preset/"):],
"input": input,
}
# Check if preset is explicitly provided in params
elif response_api_optional_request_params.get("preset"):
data = {
"preset": response_api_optional_request_params.pop("preset"),
"input": self._format_input(input),
}
else:
# Full request format for third-party models
data = {
"model": model,
"input": self._format_input(input),
}
# Add all optional parameters
for key, value in response_api_optional_request_params.items():
data[key] = value
return data
def _format_input(self, input: Union[str, ResponseInputParam]) -> Union[str, List[Dict[str, Any]]]:
"""
Format input for Perplexity Responses API
The API accepts either:
- A simple string for single-turn queries
- An array of message objects for multi-turn conversations
"""
if isinstance(input, str):
return input
# Handle ResponseInputParam format
if isinstance(input, list):
formatted_messages = []
for item in input:
if isinstance(item, dict):
formatted_message = {
"type": "message",
"role": item.get("role"),
"content": item.get("content", ""),
}
formatted_messages.append(formatted_message)
return formatted_messages
return str(input)
def transform_response_api_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> ResponsesAPIResponse:
"""
Transform Perplexity Responses API response to OpenAI Responses API format
"""
try:
raw_response_json = raw_response.json()
except Exception as e:
raise BaseLLMException(
status_code=raw_response.status_code,
message=f"Failed to parse response: {str(e)}",
)
# Check for error status
status = raw_response_json.get("status")
if status == "failed":
error = raw_response_json.get("error", {})
error_message = error.get("message", "Unknown error")
raise BaseLLMException(
status_code=raw_response.status_code,
message=error_message,
)
# Transform usage to handle Perplexity's cost structure
usage_data = raw_response_json.get("usage", {})
transformed_usage_dict = self._transform_usage(usage_data)
# Convert usage dict to ResponseAPIUsage object
usage_obj = ResponseAPIUsage(**transformed_usage_dict) if transformed_usage_dict else None
# Map Perplexity response to OpenAI Responses API format
response = ResponsesAPIResponse(
id=raw_response_json.get("id", ""),
object="response",
created_at=raw_response_json.get("created_at", 0),
status=raw_response_json.get("status", "completed"),
model=raw_response_json.get("model", model),
output=raw_response_json.get("output", []),
usage=usage_obj,
data.update(response_api_optional_request_params)
return data
return super().transform_responses_api_request(
model=model,
input=input,
response_api_optional_request_params=response_api_optional_request_params,
litellm_params=litellm_params,
headers=headers,
)
return response
def _transform_usage(self, usage_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Transform Perplexity usage data to OpenAI format
Perplexity returns:
{
"input_tokens": 100,
"output_tokens": 200,
"total_tokens": 300,
"cost": {
"currency": "USD",
"input_cost": 0.0001,
"output_cost": 0.0002,
"total_cost": 0.0003
}
}
OpenAI expects:
{
"input_tokens": 100,
"output_tokens": 200,
"total_tokens": 300,
"cost": 0.0003
}
"""
transformed = {
"input_tokens": usage_data.get("input_tokens", 0),
"output_tokens": usage_data.get("output_tokens", 0),
"total_tokens": usage_data.get("total_tokens", 0),
}
# Transform cost from Perplexity format (dict) to OpenAI format (float)
cost_obj = usage_data.get("cost")
if isinstance(cost_obj, dict) and "total_cost" in cost_obj:
transformed["cost"] = cost_obj["total_cost"]
verbose_logger.debug(
"Transformed Perplexity cost object to float: %s -> %s",
cost_obj,
cost_obj["total_cost"]
)
elif cost_obj is not None:
# If cost is already a float/number, use it as-is
transformed["cost"] = cost_obj
# Add input_tokens_details if present
if "input_tokens_details" in usage_data:
transformed["input_tokens_details"] = usage_data["input_tokens_details"]
# Add output_tokens_details if present
if "output_tokens_details" in usage_data:
transformed["output_tokens_details"] = usage_data["output_tokens_details"]
return transformed
def transform_streaming_response(
self,
model: str,
parsed_chunk: dict,
logging_obj: LiteLLMLoggingObj,
) -> ResponsesAPIStreamingResponse:
"""
Transform a parsed streaming response chunk into a ResponsesAPIStreamingResponse
"""
# Get the event type from the chunk
verbose_logger.debug("Raw Perplexity Chunk=%s", parsed_chunk)
event_type = str(parsed_chunk.get("type"))
event_pydantic_model = PerplexityResponsesConfig.get_event_model_class(
event_type=event_type
)
# Transform Perplexity-specific fields to OpenAI format
parsed_chunk = self._transform_perplexity_chunk(parsed_chunk)
# Defensive: Handle error.code being null (similar to OpenAI implementation)
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)
def _transform_perplexity_chunk(self, chunk: dict) -> dict:
"""
Transform Perplexity-specific fields in a streaming chunk to OpenAI format.
This handles:
- Converting Perplexity's cost object to a simple float
"""
# Make a copy to avoid modifying the original
chunk = dict(chunk)
# Transform usage.cost from Perplexity format to OpenAI format
# Perplexity: {"currency": "USD", "input_cost": 0.0001, "output_cost": 0.0002, "total_cost": 0.0003}
# OpenAI: 0.0003 (just the total_cost as a float)
try:
response_obj = chunk.get("response")
if isinstance(response_obj, dict):
usage_obj = response_obj.get("usage")
if isinstance(usage_obj, dict):
cost_obj = usage_obj.get("cost")
if isinstance(cost_obj, dict) and "total_cost" in cost_obj:
# Replace the cost object with just the total_cost value
chunk = dict(chunk)
chunk["response"] = dict(response_obj)
chunk["response"]["usage"] = dict(usage_obj)
chunk["response"]["usage"]["cost"] = cost_obj["total_cost"]
verbose_logger.debug(
"Transformed Perplexity cost object to float: %s -> %s",
cost_obj,
cost_obj["total_cost"]
)
except Exception as e:
# If transformation fails, log and continue with original chunk
verbose_logger.debug("Failed to transform Perplexity cost object: %s", e)
return chunk

View file

@ -662,7 +662,7 @@ def responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=model,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
local_vars.update(kwargs)
@ -862,7 +862,7 @@ def delete_responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=None,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
@ -1042,7 +1042,7 @@ def get_responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=None,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
@ -1199,7 +1199,7 @@ def list_input_items(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=None,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
@ -1357,7 +1357,7 @@ def cancel_responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=None,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
@ -1544,7 +1544,7 @@ def compact_responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=model,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:

View file

@ -1176,6 +1176,14 @@ class ResponseAPIUsage(BaseLiteLLMOpenAIResponseObject):
cost: Optional[float] = None
"""The cost of the request."""
@field_validator("cost", mode="before")
@classmethod
def parse_cost(cls, v: Any) -> Optional[float]:
"""Accept cost as a dict (e.g. Perplexity's {total_cost: 0.01}) and extract the float."""
if isinstance(v, dict):
return v.get("total_cost")
return v
model_config = {"extra": "allow"}

View file

@ -1537,7 +1537,13 @@ class Usage(SafeAttributeModel, CompletionUsage):
del self.server_tool_use
if cost is not None:
self.cost = cost
# Some providers (e.g. Perplexity) return cost as a dict with total_cost
if isinstance(cost, dict):
cost = cost.get("total_cost")
if cost is not None:
self.cost = cost
else:
del self.cost
else:
del self.cost

View file

@ -8226,9 +8226,52 @@ class ProviderConfigManager:
@staticmethod
def get_provider_responses_api_config(
provider: LlmProviders,
provider: Union[LlmProviders, str],
model: Optional[str] = None,
) -> Optional[BaseResponsesAPIConfig]:
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
# Resolve provider string for JSON lookup
provider_str = provider.value if isinstance(provider, LlmProviders) else str(provider)
# Try to convert to enum for Python class lookup first.
# Python classes take priority over JSON (they have custom overrides).
provider_enum: Optional[LlmProviders] = None
if isinstance(provider, LlmProviders):
provider_enum = provider
else:
try:
provider_enum = LlmProviders(provider)
except ValueError:
pass
# Check Python classes first (custom overrides take priority)
result = ProviderConfigManager._get_python_responses_api_config(
provider_enum, model
)
if result is not None:
return result
# Fall back to JSON providers (generic OpenAI-compatible)
if JSONProviderRegistry.exists(provider_str) and JSONProviderRegistry.supports_responses_api(provider_str):
provider_config = JSONProviderRegistry.get(provider_str)
if provider_config is not None:
return create_responses_config_class(provider_config)()
return None
@staticmethod
def _get_python_responses_api_config(
provider: Optional[LlmProviders],
model: Optional[str] = None,
) -> Optional[BaseResponsesAPIConfig]:
"""Check for Python-class-based responses API configs (custom overrides)."""
if provider is None:
return None
if litellm.LlmProviders.OPENAI == provider:
return litellm.OpenAIResponsesAPIConfig()
elif litellm.LlmProviders.AZURE == provider:

View file

@ -0,0 +1,337 @@
"""
Tests for OpenAI-like Responses API support in the JSON provider system.
"""
import os
import sys
from unittest.mock import patch
import pytest
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))
)
class TestSimpleProviderConfigSupportedEndpoints:
"""Test the supported_endpoints field on SimpleProviderConfig."""
def test_default_supported_endpoints(self):
"""supported_endpoints defaults to [] (chat always enabled, nothing else)"""
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
config = SimpleProviderConfig("test", {"base_url": "https://example.com", "api_key_env": "TEST_KEY"})
assert config.supported_endpoints == []
def test_custom_supported_endpoints(self):
"""supported_endpoints can be set explicitly"""
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
config = SimpleProviderConfig(
"test",
{
"base_url": "https://example.com",
"api_key_env": "TEST_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
},
)
assert "/v1/responses" in config.supported_endpoints
assert "/v1/chat/completions" in config.supported_endpoints
def test_responses_only_endpoint(self):
"""A provider can support only responses"""
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
config = SimpleProviderConfig(
"test",
{
"base_url": "https://example.com",
"api_key_env": "TEST_KEY",
"supported_endpoints": ["/v1/responses"],
},
)
assert config.supported_endpoints == ["/v1/responses"]
class TestJSONProviderRegistryResponsesAPI:
"""Test supports_responses_api on JSONProviderRegistry."""
def test_existing_provider_no_responses(self):
"""Existing providers without supported_endpoints don't support responses"""
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
# publicai has no supported_endpoints in JSON, defaults to []
assert JSONProviderRegistry.supports_responses_api("publicai") is False
def test_nonexistent_provider(self):
"""Non-existent provider returns False"""
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
assert JSONProviderRegistry.supports_responses_api("nonexistent_provider_xyz") is False
def test_provider_with_responses_endpoint(self):
"""A provider with /v1/responses in supported_endpoints returns True"""
from litellm.llms.openai_like.json_loader import (
JSONProviderRegistry,
SimpleProviderConfig,
)
# Temporarily inject a test provider
test_config = SimpleProviderConfig(
"test_responses_provider",
{
"base_url": "https://test.example.com",
"api_key_env": "TEST_API_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
},
)
JSONProviderRegistry._providers["test_responses_provider"] = test_config
try:
assert JSONProviderRegistry.supports_responses_api("test_responses_provider") is True
finally:
del JSONProviderRegistry._providers["test_responses_provider"]
class TestCreateResponsesConfigClass:
"""Test dynamic responses config class generation."""
def _make_test_provider(self):
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
return SimpleProviderConfig(
"test_resp",
{
"base_url": "https://api.testresp.com/v1",
"api_key_env": "TEST_RESP_API_KEY",
"api_base_env": "TEST_RESP_API_BASE",
"supported_endpoints": ["/v1/responses"],
},
)
def test_generated_class_custom_llm_provider(self):
"""Generated class returns the provider slug"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
assert config.custom_llm_provider == "test_resp"
def test_generated_class_get_complete_url(self):
"""Generated class builds correct responses URL"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == "https://api.testresp.com/v1/responses"
def test_generated_class_get_complete_url_with_override(self):
"""api_base override takes precedence"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
url = config.get_complete_url(api_base="https://custom.api.com/v1", litellm_params={})
assert url == "https://custom.api.com/v1/responses"
def test_generated_class_get_complete_url_strips_trailing_slash(self):
"""Trailing slashes are stripped from base URL"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
url = config.get_complete_url(api_base="https://custom.api.com/v1/", litellm_params={})
assert url == "https://custom.api.com/v1/responses"
def test_generated_class_validate_environment(self):
"""validate_environment sets Authorization header from env"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
with patch(
"litellm.llms.openai_like.dynamic_config.get_secret_str",
return_value="sk-test-key-123",
):
headers = config.validate_environment(headers={}, model="test-model", litellm_params=None)
assert headers["Authorization"] == "Bearer sk-test-key-123"
def test_generated_class_validate_environment_litellm_params_override(self):
"""api_key from litellm_params takes precedence over env"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
from litellm.types.router import GenericLiteLLMParams
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
litellm_params = GenericLiteLLMParams(api_key="sk-override-key")
headers = config.validate_environment(
headers={}, model="test-model", litellm_params=litellm_params
)
assert headers["Authorization"] == "Bearer sk-override-key"
def test_generated_class_inherits_openai_responses_methods(self):
"""Generated class inherits OpenAI Responses API transformation methods"""
from litellm.llms.openai.responses.transformation import (
OpenAIResponsesAPIConfig,
)
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
# Should have inherited methods from OpenAIResponsesAPIConfig
assert hasattr(config, "get_supported_openai_params")
assert hasattr(config, "map_openai_params")
assert hasattr(config, "transform_responses_api_request")
assert hasattr(config, "transform_response_api_response")
assert hasattr(config, "transform_streaming_response")
# Verify inheritance chain
assert isinstance(config, OpenAIResponsesAPIConfig)
def test_generated_class_get_complete_url_uses_api_base_env(self):
"""get_complete_url falls back to api_base_env when api_base is None"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
with patch(
"litellm.llms.openai_like.dynamic_config.get_secret_str",
return_value="https://env-override.example.com/v1",
):
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == "https://env-override.example.com/v1/responses"
class TestProviderConfigManagerResponsesAPI:
"""Test that ProviderConfigManager integrates JSON responses providers."""
def test_json_provider_with_responses_returns_config(self):
"""A JSON provider with /v1/responses returns a responses config"""
from litellm.llms.openai_like.json_loader import (
JSONProviderRegistry,
SimpleProviderConfig,
)
from litellm.utils import ProviderConfigManager
test_config = SimpleProviderConfig(
"test_pcm_resp",
{
"base_url": "https://api.testpcm.com/v1",
"api_key_env": "TEST_PCM_KEY",
"supported_endpoints": ["/v1/responses"],
},
)
JSONProviderRegistry._providers["test_pcm_resp"] = test_config
try:
config = ProviderConfigManager.get_provider_responses_api_config(
provider="test_pcm_resp",
model="some-model",
)
assert config is not None
assert config.custom_llm_provider == "test_pcm_resp"
finally:
del JSONProviderRegistry._providers["test_pcm_resp"]
def test_json_provider_without_responses_returns_none(self):
"""A JSON provider without /v1/responses returns None"""
from litellm.utils import ProviderConfigManager
# publicai only supports chat completions
config = ProviderConfigManager.get_provider_responses_api_config(
provider="publicai",
model="some-model",
)
assert config is None
def test_unknown_provider_returns_none(self):
"""A completely unknown provider returns None"""
from litellm.utils import ProviderConfigManager
config = ProviderConfigManager.get_provider_responses_api_config(
provider="totally_unknown_provider_xyz",
model="some-model",
)
assert config is None
def test_standard_providers_still_work(self):
"""Existing enum-based providers still resolve correctly"""
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENAI,
model="gpt-4o",
)
assert config is not None
def test_standard_provider_as_string_still_works(self):
"""Passing 'openai' as a string also works"""
from litellm.utils import ProviderConfigManager
config = ProviderConfigManager.get_provider_responses_api_config(
provider="openai",
model="gpt-4o",
)
assert config is not None
def test_python_class_takes_priority_over_json(self):
"""If a provider has both a Python class and JSON config, Python wins"""
from litellm.llms.openai_like.json_loader import (
JSONProviderRegistry,
SimpleProviderConfig,
)
from litellm.llms.perplexity.responses.transformation import (
PerplexityResponsesConfig,
)
from litellm.utils import ProviderConfigManager
# Inject perplexity into JSON registry with responses support
test_config = SimpleProviderConfig(
"perplexity",
{
"base_url": "https://api.perplexity.ai",
"api_key_env": "PERPLEXITY_API_KEY",
"supported_endpoints": ["/v1/responses"],
},
)
JSONProviderRegistry._providers["perplexity"] = test_config
try:
config = ProviderConfigManager.get_provider_responses_api_config(
provider="perplexity",
model="some-model",
)
# Should be the Python class, not the JSON-generated one
assert isinstance(config, PerplexityResponsesConfig)
finally:
del JSONProviderRegistry._providers["perplexity"]