mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
On /v1/responses the prompt template ran inside litellm.aresponses, after the router had already resolved a deployment and injected its api_key/api_base, so a prompt whose metadata.model pointed at another provider sent the old deployment's credentials cross-provider (401). The proxy now runs the prompt template for aresponses in the pre-call hook, before routing, so the router picks the deployment that matches the swapped model. As a backstop, the SDK refuses a cross-provider swap when explicit credentials are already present instead of forwarding them. ignore_prompt_manager_model and ignore_prompt_manager_optional_params saved on a prompt were only read by the generic manager, so dotprompt prompts ignored them on every endpoint. PromptManagementBase now merges the prompt spec's flags with the per-request ones for every manager, and the generic manager no longer drops caller flags when no spec is present.
743 lines
30 KiB
Python
743 lines
30 KiB
Python
import base64
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
import litellm
|
|
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
|
from litellm.responses.utils import ResponseAPILoggingUtils, ResponsesAPIRequestUtils
|
|
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIOptionalRequestParams
|
|
from litellm.types.utils import Usage
|
|
|
|
|
|
class TestResponsesAPIRequestUtils:
|
|
def test_get_optional_params_responses_api(self):
|
|
"""Test that optional parameters are correctly processed for responses API"""
|
|
# Setup
|
|
model = "gpt-4o"
|
|
config = OpenAIResponsesAPIConfig()
|
|
optional_params = ResponsesAPIOptionalRequestParams(
|
|
{
|
|
"temperature": 0.7,
|
|
"max_output_tokens": 100,
|
|
"prompt": {"id": "pmpt_123"},
|
|
}
|
|
)
|
|
|
|
# Execute
|
|
result = ResponsesAPIRequestUtils.get_optional_params_responses_api(
|
|
model=model,
|
|
responses_api_provider_config=config,
|
|
response_api_optional_params=optional_params,
|
|
)
|
|
|
|
# Assert
|
|
assert result == optional_params
|
|
assert "temperature" in result
|
|
assert result["temperature"] == 0.7
|
|
assert "max_output_tokens" in result
|
|
assert result["max_output_tokens"] == 100
|
|
assert "prompt" in result
|
|
assert result["prompt"] == {"id": "pmpt_123"}
|
|
|
|
def test_get_optional_params_responses_api_unsupported_param(self):
|
|
"""Test that unsupported parameters raise an error"""
|
|
# Setup
|
|
model = "gpt-4o"
|
|
config = OpenAIResponsesAPIConfig()
|
|
optional_params = ResponsesAPIOptionalRequestParams({"temperature": 0.7, "unsupported_param": "value"})
|
|
|
|
# Execute and Assert
|
|
with pytest.raises(litellm.UnsupportedParamsError) as excinfo:
|
|
ResponsesAPIRequestUtils.get_optional_params_responses_api(
|
|
model=model,
|
|
responses_api_provider_config=config,
|
|
response_api_optional_params=optional_params,
|
|
)
|
|
|
|
assert "unsupported_param" in str(excinfo.value)
|
|
assert model in str(excinfo.value)
|
|
|
|
def test_get_optional_params_responses_api_request_level_drop_params(self, monkeypatch):
|
|
"""Request-level drop_params must reach both _check_valid_arg and map_openai_params"""
|
|
monkeypatch.setattr(litellm, "drop_params", False)
|
|
config = MagicMock(spec=OpenAIResponsesAPIConfig)
|
|
config.get_supported_openai_params.return_value = ["temperature"]
|
|
config.custom_llm_provider = "openai"
|
|
config.map_openai_params.return_value = {"temperature": 0.7}
|
|
|
|
result = ResponsesAPIRequestUtils.get_optional_params_responses_api(
|
|
model="gpt-4o",
|
|
responses_api_provider_config=config,
|
|
response_api_optional_params=ResponsesAPIOptionalRequestParams(
|
|
{"temperature": 0.7, "service_tier": "priority"}
|
|
),
|
|
drop_params=True,
|
|
)
|
|
|
|
assert config.map_openai_params.call_args.kwargs["drop_params"] is True
|
|
assert result == {"temperature": 0.7}
|
|
|
|
@pytest.mark.parametrize("request_drop_params", [None, False])
|
|
def test_get_optional_params_responses_api_still_raises_without_drop(self, monkeypatch, request_drop_params):
|
|
"""Absent or False request-level drop_params must not suppress the unsupported-param error"""
|
|
monkeypatch.setattr(litellm, "drop_params", False)
|
|
config = OpenAIResponsesAPIConfig()
|
|
|
|
with pytest.raises(litellm.UnsupportedParamsError):
|
|
ResponsesAPIRequestUtils.get_optional_params_responses_api(
|
|
model="gpt-4o",
|
|
responses_api_provider_config=config,
|
|
response_api_optional_params=ResponsesAPIOptionalRequestParams(
|
|
{"temperature": 0.7, "unsupported_param": "value"}
|
|
),
|
|
drop_params=request_drop_params,
|
|
)
|
|
|
|
def test_get_requested_response_api_optional_param(self):
|
|
"""Test filtering parameters to only include those in ResponsesAPIOptionalRequestParams"""
|
|
# Setup
|
|
params = {
|
|
"temperature": 0.7,
|
|
"max_output_tokens": 100,
|
|
"prompt": {"id": "pmpt_456"},
|
|
"invalid_param": "value",
|
|
"model": "gpt-4o", # This is not in ResponsesAPIOptionalRequestParams
|
|
}
|
|
|
|
# Execute
|
|
result = ResponsesAPIRequestUtils.get_requested_response_api_optional_param(params)
|
|
|
|
# Assert
|
|
assert "temperature" in result
|
|
assert "max_output_tokens" in result
|
|
assert "invalid_param" not in result
|
|
assert "model" not in result
|
|
assert result["temperature"] == 0.7
|
|
assert result["max_output_tokens"] == 100
|
|
assert result["prompt"] == {"id": "pmpt_456"}
|
|
|
|
def test_decode_previous_response_id_to_original_previous_response_id(self):
|
|
"""Test decoding a LiteLLM encoded previous_response_id to the original previous_response_id"""
|
|
# Setup
|
|
test_provider = "openai"
|
|
test_model_id = "gpt-4o"
|
|
original_response_id = "resp_abc123"
|
|
|
|
# Use the helper method to build an encoded response ID
|
|
encoded_id = ResponsesAPIRequestUtils._build_responses_api_response_id(
|
|
custom_llm_provider=test_provider,
|
|
model_id=test_model_id,
|
|
response_id=original_response_id,
|
|
)
|
|
|
|
# Execute
|
|
result = ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(encoded_id)
|
|
|
|
# Assert
|
|
assert result == original_response_id
|
|
|
|
# Test with a non-encoded ID
|
|
plain_id = "resp_xyz789"
|
|
result_plain = ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(plain_id)
|
|
assert result_plain == plain_id
|
|
|
|
def test_update_responses_api_response_id_with_model_id_handles_dict(self):
|
|
"""Ensure _update_responses_api_response_id_with_model_id works with dict input"""
|
|
responses_api_response = {"id": "resp_abc123"}
|
|
litellm_metadata = {"model_info": {"id": "gpt-4o"}}
|
|
updated = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id(
|
|
responses_api_response=responses_api_response,
|
|
custom_llm_provider="openai",
|
|
litellm_metadata=litellm_metadata,
|
|
)
|
|
assert updated["id"] != "resp_abc123"
|
|
decoded = ResponsesAPIRequestUtils._decode_responses_api_response_id(updated["id"])
|
|
assert decoded.get("response_id") == "resp_abc123"
|
|
assert decoded.get("model_id") == "gpt-4o"
|
|
assert decoded.get("custom_llm_provider") == "openai"
|
|
|
|
def test_update_responses_api_response_id_with_model_id_is_idempotent_for_litellm_ids(self):
|
|
raw = "resp_" + "a" * 48
|
|
litellm_metadata = {"model_info": {"id": "model-123"}}
|
|
|
|
once = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id(
|
|
{"id": raw},
|
|
custom_llm_provider="openai",
|
|
litellm_metadata=litellm_metadata,
|
|
)
|
|
twice = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id(
|
|
{"id": once["id"]},
|
|
custom_llm_provider="openai",
|
|
litellm_metadata=litellm_metadata,
|
|
)
|
|
|
|
assert twice == once
|
|
assert ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(twice["id"]) == raw
|
|
assert ResponsesAPIRequestUtils._decode_responses_api_response_id(once["id"]).get("response_id") == raw
|
|
|
|
def test_build_decode_container_id_omits_none_model_id(self):
|
|
"""model_id=None must not round-trip as the truthy string 'None'."""
|
|
encoded = ResponsesAPIRequestUtils._build_container_id(
|
|
custom_llm_provider="azure",
|
|
model_id=None,
|
|
container_id="cntr_upstream_abc",
|
|
)
|
|
assert "None" not in base64.b64decode(encoded.replace("cntr_", "").encode("utf-8")).decode("utf-8")
|
|
decoded = ResponsesAPIRequestUtils._decode_container_id(encoded)
|
|
assert decoded.get("custom_llm_provider") == "azure"
|
|
assert decoded.get("model_id") is None
|
|
assert decoded.get("response_id") == "cntr_upstream_abc"
|
|
|
|
def test_decode_container_id_legacy_literal_none_model_id(self):
|
|
"""IDs encoded before the None fix should decode without a bogus model_id."""
|
|
legacy_inner = "litellm:custom_llm_provider:azure;model_id:None;container_id:cntr_x"
|
|
legacy_id = "cntr_" + base64.b64encode(legacy_inner.encode("utf-8")).decode("utf-8")
|
|
decoded = ResponsesAPIRequestUtils._decode_container_id(legacy_id)
|
|
assert decoded.get("model_id") is None
|
|
assert decoded.get("custom_llm_provider") == "azure"
|
|
assert decoded.get("response_id") == "cntr_x"
|
|
|
|
|
|
class TestResponseAPILoggingUtils:
|
|
def test_is_response_api_usage_true(self):
|
|
"""Test identification of Response API usage format"""
|
|
# Setup
|
|
usage = {"input_tokens": 10, "output_tokens": 20}
|
|
|
|
# Execute
|
|
result = ResponseAPILoggingUtils._is_response_api_usage(usage)
|
|
|
|
# Assert
|
|
assert result is True
|
|
|
|
def test_is_response_api_usage_false(self):
|
|
"""Test identification of non-Response API usage format"""
|
|
# Setup
|
|
usage = {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}
|
|
|
|
# Execute
|
|
result = ResponseAPILoggingUtils._is_response_api_usage(usage)
|
|
|
|
# Assert
|
|
assert result is False
|
|
|
|
def test_transform_response_api_usage_to_chat_usage(self):
|
|
"""Test transformation from Response API usage to Chat usage format"""
|
|
# Setup
|
|
usage = {
|
|
"input_tokens": 10,
|
|
"output_tokens": 20,
|
|
"total_tokens": 30,
|
|
"input_tokens_details": {"cached_tokens": 2},
|
|
"output_tokens_details": {"reasoning_tokens": 5},
|
|
}
|
|
|
|
# Execute
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
# Assert
|
|
assert isinstance(result, Usage)
|
|
assert result.prompt_tokens == 10
|
|
assert result.completion_tokens == 20
|
|
assert result.total_tokens == 30
|
|
assert result.prompt_tokens_details and result.prompt_tokens_details.cached_tokens == 2
|
|
|
|
def test_transform_response_api_usage_with_none_values(self):
|
|
"""Test transformation handles None values properly"""
|
|
# Setup
|
|
usage = {
|
|
"input_tokens": 0, # Changed from None to 0
|
|
"output_tokens": 20,
|
|
"total_tokens": 20,
|
|
"output_tokens_details": {"reasoning_tokens": 5},
|
|
}
|
|
|
|
# Execute
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
# Assert
|
|
assert result.prompt_tokens == 0
|
|
assert result.completion_tokens == 20
|
|
assert result.total_tokens == 20
|
|
|
|
def test_transform_response_api_usage_calculates_total_from_input_and_output_tokens_if_available(
|
|
self,
|
|
):
|
|
"""Test transformation calculates total_tokens when it's None and input / output tokens are present"""
|
|
# Setup
|
|
usage = {
|
|
"input_tokens": 15,
|
|
"output_tokens": 25,
|
|
"total_tokens": None,
|
|
}
|
|
|
|
# Execute
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
# Assert
|
|
assert result.prompt_tokens == 15
|
|
assert result.completion_tokens == 25
|
|
assert result.total_tokens == 40 # 15 + 25
|
|
|
|
def test_transform_response_api_usage_with_image_tokens(self):
|
|
"""Test transformation handles image_tokens from image generation responses.
|
|
|
|
Note: _transform_response_api_usage_to_chat_usage() is used by multiple
|
|
endpoints including /images/generations and Response API (/responses),
|
|
both of which use the input_tokens/output_tokens format.
|
|
|
|
This tests the fix for image generation responses that include image_tokens
|
|
in both input_tokens_details and output_tokens_details.
|
|
|
|
Example from gpt-image-1.5:
|
|
- input: text prompt with 13 tokens
|
|
- output: generated image with 272 image tokens + 100 text tokens
|
|
"""
|
|
# Setup - simulating image generation usage from OpenAI
|
|
usage = {
|
|
"input_tokens": 13,
|
|
"output_tokens": 372,
|
|
"total_tokens": 385,
|
|
"input_tokens_details": {
|
|
"image_tokens": 0,
|
|
"text_tokens": 13,
|
|
},
|
|
"output_tokens_details": {
|
|
"image_tokens": 272,
|
|
"text_tokens": 100,
|
|
},
|
|
}
|
|
|
|
# Execute
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
# Assert - verify basic token counts
|
|
assert isinstance(result, Usage)
|
|
assert result.prompt_tokens == 13
|
|
assert result.completion_tokens == 372
|
|
assert result.total_tokens == 385
|
|
|
|
# Assert - verify prompt_tokens_details includes image_tokens and text_tokens
|
|
assert result.prompt_tokens_details is not None
|
|
assert result.prompt_tokens_details.image_tokens == 0
|
|
assert result.prompt_tokens_details.text_tokens == 13
|
|
|
|
# Assert - verify completion_tokens_details includes image_tokens and text_tokens
|
|
assert result.completion_tokens_details is not None
|
|
assert result.completion_tokens_details.image_tokens == 272
|
|
assert result.completion_tokens_details.text_tokens == 100
|
|
|
|
def test_transform_response_api_usage_maps_cache_write_tokens(self):
|
|
"""Responses API (/v1/responses) cache-write tokens must survive the usage transform.
|
|
|
|
gpt-5.6 returns usage.input_tokens_details.cache_write_tokens (an extra field
|
|
not typed on InputTokensDetails). Before the fix the transform rebuilt the token
|
|
details and dropped it, leaving the cache-creation metric empty (LIT-4633).
|
|
"""
|
|
usage = {
|
|
"input_tokens": 10062,
|
|
"output_tokens": 16,
|
|
"total_tokens": 10078,
|
|
"input_tokens_details": {
|
|
"cached_tokens": 0,
|
|
"cache_write_tokens": 10059,
|
|
},
|
|
}
|
|
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
assert result.prompt_tokens_details is not None
|
|
assert result.prompt_tokens_details.cache_write_tokens == 10059
|
|
assert result.prompt_tokens_details.cache_creation_tokens == 10059
|
|
assert result.prompt_tokens_details.cached_tokens == 0
|
|
|
|
def test_transform_response_api_usage_mixed_details(self):
|
|
"""Test transformation handles mixed token details (cached + image + audio)."""
|
|
# Setup - hypothetical usage with mixed token types
|
|
usage = {
|
|
"input_tokens": 100,
|
|
"output_tokens": 200,
|
|
"total_tokens": 300,
|
|
"input_tokens_details": {
|
|
"cached_tokens": 50,
|
|
"audio_tokens": 10,
|
|
"image_tokens": 20,
|
|
"text_tokens": 20,
|
|
},
|
|
"output_tokens_details": {
|
|
"reasoning_tokens": 30,
|
|
"image_tokens": 100,
|
|
"text_tokens": 50,
|
|
"audio_tokens": 20,
|
|
},
|
|
}
|
|
|
|
# Execute
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
# Assert - all token detail types should be preserved
|
|
assert result.prompt_tokens_details is not None
|
|
assert result.prompt_tokens_details.cached_tokens == 50
|
|
assert result.prompt_tokens_details.audio_tokens == 10
|
|
assert result.prompt_tokens_details.image_tokens == 20
|
|
assert result.prompt_tokens_details.text_tokens == 20
|
|
|
|
assert result.completion_tokens_details is not None
|
|
assert result.completion_tokens_details.reasoning_tokens == 30
|
|
assert result.completion_tokens_details.image_tokens == 100
|
|
assert result.completion_tokens_details.text_tokens == 50
|
|
assert result.completion_tokens_details.audio_tokens == 20
|
|
|
|
def test_transform_response_api_usage_with_realtime_keys(self):
|
|
"""Realtime input_token_details / output_token_details normalize for Usage."""
|
|
usage = {
|
|
"input_tokens": 10,
|
|
"output_tokens": 20,
|
|
"total_tokens": 30,
|
|
"input_token_details": {
|
|
"text_tokens": 8,
|
|
"audio_tokens": 2,
|
|
"cached_tokens": 0,
|
|
},
|
|
"output_token_details": {
|
|
"text_tokens": 12,
|
|
"audio_tokens": 8,
|
|
},
|
|
}
|
|
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
assert result.prompt_tokens_details is not None
|
|
assert result.prompt_tokens_details.text_tokens == 8
|
|
assert result.prompt_tokens_details.audio_tokens == 2
|
|
|
|
assert result.completion_tokens_details is not None
|
|
assert result.completion_tokens_details.text_tokens == 12
|
|
assert result.completion_tokens_details.audio_tokens == 8
|
|
|
|
def test_transform_response_api_usage_tokens_details_keep_values(self):
|
|
"""Keeps input_tokens_details / output_tokens_details when singular keys are also present."""
|
|
usage = {
|
|
"input_tokens": 10,
|
|
"output_tokens": 20,
|
|
"total_tokens": 30,
|
|
"input_tokens_details": {"text_tokens": 10},
|
|
"output_tokens_details": {"text_tokens": 20},
|
|
"input_token_details": {"text_tokens": 1, "audio_tokens": 99},
|
|
"output_token_details": {"text_tokens": 2, "audio_tokens": 98},
|
|
}
|
|
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
assert result.prompt_tokens_details is not None
|
|
assert result.prompt_tokens_details.text_tokens == 10
|
|
assert result.prompt_tokens_details.audio_tokens is None
|
|
|
|
assert result.completion_tokens_details is not None
|
|
assert result.completion_tokens_details.text_tokens == 20
|
|
assert result.completion_tokens_details.audio_tokens is None
|
|
|
|
def test_transform_response_api_usage_carries_extra_provider_fields(self):
|
|
"""Non-standard usage fields (e.g. xAI tool details) must survive chat normalization."""
|
|
details = {"web_search_calls": 2, "x_search_calls": 0}
|
|
usage = ResponseAPIUsage(
|
|
input_tokens=100,
|
|
output_tokens=20,
|
|
total_tokens=120,
|
|
server_side_tool_usage_details=details,
|
|
)
|
|
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
assert isinstance(result, Usage)
|
|
assert result.prompt_tokens == 100
|
|
assert result.completion_tokens == 20
|
|
assert getattr(result, "server_side_tool_usage_details") == details
|
|
|
|
def test_transform_response_api_usage_ignores_chat_shaped_extras(self):
|
|
"""Gemini image usage carries chat-shaped keys as extras; they must not collide with explicit kwargs."""
|
|
usage = ResponseAPIUsage(
|
|
input_tokens=35,
|
|
output_tokens=1716,
|
|
total_tokens=1751,
|
|
prompt_tokens=35,
|
|
prompt_tokens_details={"image_tokens": 5, "text_tokens": 30},
|
|
completion_tokens=1716,
|
|
completion_tokens_details={"image_tokens": 1120, "text_tokens": 596},
|
|
server_side_tool_usage_details={"web_search_calls": 1},
|
|
)
|
|
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
assert result.prompt_tokens == 35
|
|
assert result.completion_tokens == 1716
|
|
assert getattr(result, "server_side_tool_usage_details") == {"web_search_calls": 1}
|
|
|
|
def test_transform_already_chat_usage_passthrough_keeps_tool_details(self):
|
|
"""Re-running the bridge on an already-converted chat Usage must not drop fields."""
|
|
details = {"web_search_calls": 2, "x_search_calls": 0}
|
|
usage = Usage(
|
|
prompt_tokens=100,
|
|
completion_tokens=20,
|
|
total_tokens=120,
|
|
prompt_tokens_details={"web_search_requests": 2},
|
|
)
|
|
setattr(usage, "server_side_tool_usage_details", details)
|
|
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
assert result is usage
|
|
assert getattr(result, "server_side_tool_usage_details") == details
|
|
assert result.prompt_tokens_details is not None
|
|
assert result.prompt_tokens_details.web_search_requests == 2
|
|
|
|
def test_transform_chat_shaped_usage_dict_keeps_tool_details(self):
|
|
"""Streaming chat bridge dumps already-converted Usage as a prompt_tokens dict."""
|
|
details = {
|
|
"web_search_calls": 3,
|
|
"x_search_calls": 0,
|
|
"code_interpreter_calls": 0,
|
|
"file_search_calls": 0,
|
|
"mcp_calls": 0,
|
|
"document_search_calls": 0,
|
|
"image_generation_calls": 0,
|
|
}
|
|
usage = {
|
|
"prompt_tokens": 50,
|
|
"completion_tokens": 10,
|
|
"total_tokens": 60,
|
|
"prompt_tokens_details": {"web_search_requests": 3, "cached_tokens": 8},
|
|
"completion_tokens_details": {"reasoning_tokens": 4},
|
|
"server_side_tool_usage_details": details,
|
|
}
|
|
|
|
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
|
|
|
assert isinstance(result, Usage)
|
|
assert result.prompt_tokens == 50
|
|
assert result.completion_tokens == 10
|
|
assert result.total_tokens == 60
|
|
assert getattr(result, "server_side_tool_usage_details") == details
|
|
assert result.prompt_tokens_details is not None
|
|
assert result.prompt_tokens_details.web_search_requests == 3
|
|
assert result.prompt_tokens_details.cached_tokens == 8
|
|
assert result.completion_tokens_details is not None
|
|
assert result.completion_tokens_details.reasoning_tokens == 4
|
|
|
|
|
|
class TestResponsesAPIProviderSpecificParams:
|
|
"""
|
|
Tests for fix #19782: provider-specific params (aws_*, vertex_*) should work
|
|
without explicitly passing custom_llm_provider.
|
|
"""
|
|
|
|
def test_provider_specific_params_no_crash_with_bedrock(self):
|
|
"""Test that processing aws_* params with bedrock provider doesn't crash."""
|
|
params = {
|
|
"temperature": 0.7,
|
|
"custom_llm_provider": "bedrock",
|
|
"kwargs": {"aws_region_name": "eu-central-1"},
|
|
}
|
|
|
|
# Should not raise any exception
|
|
result = ResponsesAPIRequestUtils.get_requested_response_api_optional_param(params)
|
|
assert "temperature" in result
|
|
|
|
def test_provider_specific_params_no_crash_with_openai(self):
|
|
"""Test that processing aws_* params with openai provider doesn't crash."""
|
|
params = {
|
|
"temperature": 0.7,
|
|
"custom_llm_provider": "openai",
|
|
"kwargs": {"aws_region_name": "eu-central-1"},
|
|
}
|
|
|
|
# Should not raise any exception
|
|
result = ResponsesAPIRequestUtils.get_requested_response_api_optional_param(params)
|
|
assert "temperature" in result
|
|
|
|
def test_provider_specific_params_no_crash_with_vertex_ai(self):
|
|
"""Test that processing vertex_* params with vertex_ai provider doesn't crash."""
|
|
params = {
|
|
"temperature": 0.7,
|
|
"custom_llm_provider": "vertex_ai",
|
|
"kwargs": {"vertex_project": "my-project"},
|
|
}
|
|
|
|
# Should not raise any exception
|
|
result = ResponsesAPIRequestUtils.get_requested_response_api_optional_param(params)
|
|
assert "temperature" in result
|
|
|
|
|
|
def test_responses_extra_body_forwarded_to_completion_transformation_handler():
|
|
"""
|
|
Regression test: extra_body must be forwarded to response_api_handler
|
|
when responses_api_provider_config is None (completion transformation path).
|
|
|
|
Before the fix, extra_body was a named parameter of responses() but was
|
|
not passed to litellm_completion_transformation_handler.response_api_handler(),
|
|
so it was silently dropped.
|
|
"""
|
|
with (
|
|
patch(
|
|
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler",
|
|
) as mock_handler,
|
|
):
|
|
mock_handler.return_value = MagicMock()
|
|
|
|
litellm.responses(
|
|
model="openai/gpt-4o",
|
|
input="Hello",
|
|
extra_body={"custom_key": "custom_value"},
|
|
)
|
|
|
|
mock_handler.assert_called_once()
|
|
call_kwargs = mock_handler.call_args
|
|
# extra_body can be a positional or keyword arg; check both
|
|
assert call_kwargs.kwargs.get("extra_body") == {"custom_key": "custom_value"}
|
|
|
|
|
|
def test_responses_maps_reasoning_effort_from_litellm_params_to_reasoning():
|
|
"""
|
|
Test that when reasoning_effort is passed in kwargs (e.g. from proxy litellm_params)
|
|
and reasoning is None, it is mapped to reasoning before the request.
|
|
|
|
Supports per-model reasoning_effort/summary config in proxy for clients like Open WebUI
|
|
that cannot set extra_body.
|
|
"""
|
|
with (
|
|
patch(
|
|
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler",
|
|
) as mock_handler,
|
|
):
|
|
mock_handler.return_value = MagicMock()
|
|
|
|
litellm.responses(
|
|
model="openai/gpt-4o",
|
|
input="Hello",
|
|
reasoning_effort={"effort": "high", "summary": "detailed"},
|
|
)
|
|
|
|
mock_handler.assert_called_once()
|
|
call_kwargs = mock_handler.call_args
|
|
responses_api_request = call_kwargs.kwargs.get("responses_api_request", {})
|
|
assert "reasoning" in responses_api_request
|
|
assert responses_api_request["reasoning"] == {
|
|
"effort": "high",
|
|
"summary": "detailed",
|
|
}
|
|
|
|
|
|
class TestMergePromptManagementInputReshape:
|
|
"""Chat-shaped text parts produced by prompt management hooks become input_text parts (#37509)."""
|
|
|
|
EXPLICIT = {"mode": "explicit"}
|
|
|
|
def _run_cache_hook(self, client_input, points, model="openai/gpt-5.6"):
|
|
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
|
|
|
|
_, merged, _ = AnthropicCacheControlHook().get_chat_completion_prompt(
|
|
model=model,
|
|
messages=client_input,
|
|
non_default_params={"cache_control_injection_points": points},
|
|
prompt_id=None,
|
|
prompt_variables=None,
|
|
dynamic_callback_params={},
|
|
)
|
|
return merged
|
|
|
|
def test_string_system_item_becomes_input_text_with_marker(self):
|
|
original_input = [{"role": "system", "content": "You are terse."}, {"role": "user", "content": "hi"}]
|
|
merged = self._run_cache_hook(list(original_input), [{"location": "message", "role": "system"}])
|
|
|
|
result = ResponsesAPIRequestUtils.merge_prompt_management_input(
|
|
original_input=original_input, client_input=list(original_input), merged_input=merged
|
|
)
|
|
|
|
assert result[0]["content"] == [
|
|
{"type": "input_text", "text": "You are terse.", "prompt_cache_breakpoint": self.EXPLICIT}
|
|
]
|
|
assert result[1] == {"role": "user", "content": "hi"}
|
|
|
|
def test_reshape_returns_copies_and_leaves_hook_output_untouched(self):
|
|
user_part = {"type": "text", "text": "follow-up"}
|
|
user_message = {"role": "user", "content": [user_part]}
|
|
merged = [user_message]
|
|
|
|
result = ResponsesAPIRequestUtils.merge_prompt_management_input(
|
|
original_input="ignored", client_input=[], merged_input=merged
|
|
)
|
|
|
|
assert result == [{"role": "user", "content": [{"type": "input_text", "text": "follow-up"}]}]
|
|
assert user_part == {"type": "text", "text": "follow-up"}
|
|
assert user_message == {"role": "user", "content": [user_part]}
|
|
assert result[0] is not user_message
|
|
|
|
def test_reshape_keeps_non_message_items_when_hook_returns_client_objects(self):
|
|
user_message = {"role": "user", "content": [{"type": "text", "text": "question"}]}
|
|
reference = {"type": "item_reference", "id": "msg_123"}
|
|
original_input = [reference, user_message]
|
|
|
|
result = ResponsesAPIRequestUtils.merge_prompt_management_input(
|
|
original_input=original_input, client_input=[user_message], merged_input=[user_message]
|
|
)
|
|
|
|
assert result == [reference, {"role": "user", "content": [{"type": "input_text", "text": "question"}]}]
|
|
assert result[0] is reference
|
|
assert user_message["content"] == [{"type": "text", "text": "question"}]
|
|
|
|
def test_assistant_text_parts_are_left_alone(self):
|
|
merged = [
|
|
{"role": "assistant", "content": [{"type": "text", "text": "earlier answer"}]},
|
|
{"role": "user", "content": [{"type": "text", "text": "follow-up"}]},
|
|
]
|
|
|
|
result = ResponsesAPIRequestUtils.merge_prompt_management_input(
|
|
original_input="ignored", client_input=[], merged_input=merged
|
|
)
|
|
|
|
assert result[0]["content"] == [{"type": "text", "text": "earlier answer"}]
|
|
assert result[1]["content"] == [{"type": "input_text", "text": "follow-up"}]
|
|
|
|
def test_parts_already_in_responses_shape_are_unchanged(self):
|
|
merged = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "input_text", "text": "a", "prompt_cache_breakpoint": self.EXPLICIT},
|
|
{"type": "input_image", "image_url": "https://example.com/a.png"},
|
|
],
|
|
}
|
|
]
|
|
|
|
result = ResponsesAPIRequestUtils.merge_prompt_management_input(
|
|
original_input="ignored", client_input=[], merged_input=merged
|
|
)
|
|
|
|
assert result == merged
|
|
|
|
|
|
class TestResponsesInputToChatMessages:
|
|
def test_none_input_returns_empty_list(self):
|
|
assert ResponsesAPIRequestUtils.responses_input_to_chat_messages(None) == []
|
|
|
|
def test_str_input_becomes_user_message(self):
|
|
assert ResponsesAPIRequestUtils.responses_input_to_chat_messages("hi") == [
|
|
{"role": "user", "content": "hi"}
|
|
]
|
|
|
|
def test_list_input_keeps_only_role_items(self):
|
|
reasoning_item = {"type": "reasoning", "id": "rs_1", "summary": []}
|
|
user_message = {"role": "user", "content": "hi"}
|
|
assert ResponsesAPIRequestUtils.responses_input_to_chat_messages(
|
|
[reasoning_item, user_message, "stray"]
|
|
) == [user_message]
|