mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
513 lines
19 KiB
Python
513 lines
19 KiB
Python
"""
|
|
Unit test to validate that /chat/completions has the expected schema in Swagger after add_llm_api_request_schema_body runs.
|
|
|
|
This test ensures that the ProxyChatCompletionRequest Pydantic model is properly added to the OpenAPI schema
|
|
for the /chat/completions endpoint, showing all expected fields in the Swagger documentation.
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.common_utils.custom_openapi_spec import CustomOpenAPISpec
|
|
from litellm.proxy.proxy_server import app
|
|
|
|
|
|
@pytest.mark.xdist_group("swagger")
|
|
class TestSwaggerChatCompletions:
|
|
"""Test suite for validating /chat/completions schema in Swagger documentation."""
|
|
|
|
def setup_method(self):
|
|
app.openapi_schema = None
|
|
|
|
def teardown_method(self):
|
|
app.openapi_schema = None
|
|
|
|
@pytest.fixture
|
|
def client(self):
|
|
"""FastAPI test client for the proxy server."""
|
|
return TestClient(app)
|
|
|
|
def test_openapi_schema_includes_chat_completions_request_body(self, client):
|
|
"""
|
|
Test that the OpenAPI schema includes ProxyChatCompletionRequest schema
|
|
for /chat/completions endpoints after add_llm_api_request_schema_body runs.
|
|
"""
|
|
# Clear any cached schema to ensure we get the latest version
|
|
from litellm.proxy.proxy_server import app
|
|
|
|
app.openapi_schema = None
|
|
|
|
# Get the OpenAPI schema from the running app
|
|
response = client.get("/openapi.json")
|
|
assert response.status_code == 200
|
|
|
|
openapi_schema = response.json()
|
|
|
|
# Verify the schema has the expected structure
|
|
assert "openapi" in openapi_schema
|
|
assert "paths" in openapi_schema
|
|
assert "components" in openapi_schema
|
|
assert "schemas" in openapi_schema["components"]
|
|
|
|
# Check that ProxyChatCompletionRequest schema is in components
|
|
assert "ProxyChatCompletionRequest" in openapi_schema["components"]["schemas"]
|
|
|
|
# Get the ProxyChatCompletionRequest schema
|
|
chat_completion_schema = openapi_schema["components"]["schemas"][
|
|
"ProxyChatCompletionRequest"
|
|
]
|
|
|
|
# Verify it has the expected properties structure
|
|
assert "properties" in chat_completion_schema
|
|
properties = chat_completion_schema["properties"]
|
|
|
|
# Check for core OpenAI chat completion fields
|
|
expected_core_fields = [
|
|
"model",
|
|
"messages",
|
|
"temperature",
|
|
"top_p",
|
|
"max_tokens",
|
|
"stream",
|
|
"stop",
|
|
"presence_penalty",
|
|
"frequency_penalty",
|
|
"logit_bias",
|
|
"user",
|
|
"response_format",
|
|
"seed",
|
|
"tools",
|
|
"tool_choice",
|
|
"logprobs",
|
|
"top_logprobs",
|
|
]
|
|
|
|
for field in expected_core_fields:
|
|
assert (
|
|
field in properties
|
|
), f"Expected field '{field}' not found in ProxyChatCompletionRequest schema"
|
|
|
|
# Check for LiteLLM-specific fields added by ProxyChatCompletionRequest
|
|
expected_litellm_fields = [
|
|
"guardrails",
|
|
"caching",
|
|
"num_retries",
|
|
"context_window_fallback_dict",
|
|
"fallbacks",
|
|
]
|
|
|
|
for field in expected_litellm_fields:
|
|
assert (
|
|
field in properties
|
|
), f"Expected LiteLLM field '{field}' not found in ProxyChatCompletionRequest schema"
|
|
|
|
# Verify model and messages are required fields
|
|
if "required" in chat_completion_schema:
|
|
required_fields = chat_completion_schema["required"]
|
|
assert "model" in required_fields, "Field 'model' should be required"
|
|
assert "messages" in required_fields, "Field 'messages' should be required"
|
|
|
|
def test_chat_completions_endpoints_have_expanded_request_body(self, client):
|
|
"""
|
|
Test that /chat/completions endpoint has an expanded request body schema
|
|
with all individual fields visible (not just a $ref).
|
|
"""
|
|
# Clear any cached schema to ensure we get the latest version
|
|
from litellm.proxy.proxy_server import app
|
|
|
|
app.openapi_schema = None
|
|
|
|
# Get the OpenAPI schema
|
|
response = client.get("/openapi.json")
|
|
assert response.status_code == 200
|
|
|
|
openapi_schema = response.json()
|
|
paths = openapi_schema["paths"]
|
|
|
|
# Check main chat completion path
|
|
path_to_check = "/chat/completions"
|
|
assert (
|
|
path_to_check in paths
|
|
), f"Path {path_to_check} not found in OpenAPI schema"
|
|
assert (
|
|
"post" in paths[path_to_check]
|
|
), f"POST method not found for path {path_to_check}"
|
|
|
|
post_spec = paths[path_to_check]["post"]
|
|
|
|
# Should have request body with expanded schema (not just $ref)
|
|
assert (
|
|
"requestBody" in post_spec
|
|
), f"Path {path_to_check} should have requestBody"
|
|
request_body = post_spec["requestBody"]
|
|
|
|
# Check request body structure
|
|
assert "content" in request_body
|
|
assert "application/json" in request_body["content"]
|
|
json_content = request_body["content"]["application/json"]
|
|
assert "schema" in json_content
|
|
|
|
schema_def = json_content["schema"]
|
|
|
|
# Should be an expanded object schema, not a $ref
|
|
assert (
|
|
schema_def.get("type") == "object"
|
|
), "Schema should be an expanded object type"
|
|
assert "properties" in schema_def, "Schema should have expanded properties"
|
|
assert (
|
|
"$ref" not in schema_def
|
|
), "Schema should not be a reference (should be expanded inline)"
|
|
|
|
# Should have all Pydantic fields as individual properties
|
|
properties = schema_def["properties"]
|
|
assert (
|
|
len(properties) >= 25
|
|
), f"Expected at least 25 properties, got {len(properties)}"
|
|
|
|
# Should have core OpenAI fields
|
|
core_fields = ["model", "messages", "temperature", "max_tokens", "stream"]
|
|
for field in core_fields:
|
|
assert (
|
|
field in properties
|
|
), f"Core field '{field}' should be in expanded properties"
|
|
|
|
# Should have LiteLLM-specific fields
|
|
litellm_fields = ["guardrails", "caching", "fallbacks", "num_retries"]
|
|
for field in litellm_fields:
|
|
assert (
|
|
field in properties
|
|
), f"LiteLLM field '{field}' should be in expanded properties"
|
|
|
|
# Check required fields
|
|
required_fields = schema_def.get("required", [])
|
|
assert "model" in required_fields, "Model should be marked as required"
|
|
assert "messages" in required_fields, "Messages should be marked as required"
|
|
|
|
# Should have minimal parameters (only path parameters)
|
|
parameters = post_spec.get("parameters", [])
|
|
# All parameters should be path parameters, no query parameters
|
|
for param in parameters:
|
|
assert (
|
|
param.get("in") == "path"
|
|
), f"Only path parameters expected, found {param.get('in')} parameter: {param.get('name')}"
|
|
|
|
@patch(
|
|
"litellm.proxy.common_utils.custom_openapi_spec.CustomOpenAPISpec.add_chat_completion_request_schema"
|
|
)
|
|
def test_add_llm_api_request_schema_body_calls_chat_completion_method(
|
|
self, mock_add_chat
|
|
):
|
|
"""
|
|
Test that add_llm_api_request_schema_body calls add_chat_completion_request_schema.
|
|
"""
|
|
# Create a mock schema
|
|
mock_schema = {
|
|
"openapi": "3.0.0",
|
|
"info": {"title": "Test API", "version": "1.0.0"},
|
|
"paths": {},
|
|
}
|
|
|
|
# Configure the mock to return the schema
|
|
mock_add_chat.return_value = mock_schema
|
|
|
|
# Call the main method
|
|
result = CustomOpenAPISpec.add_llm_api_request_schema_body(mock_schema)
|
|
|
|
# Verify the chat completion method was called
|
|
mock_add_chat.assert_called_once_with(mock_schema)
|
|
assert result == mock_schema
|
|
|
|
def test_custom_openapi_spec_chat_completion_paths_constant(self):
|
|
"""
|
|
Test that the CHAT_COMPLETION_PATHS constant includes all expected endpoints.
|
|
"""
|
|
expected_paths = [
|
|
"/v1/chat/completions",
|
|
"/chat/completions",
|
|
"/engines/{model}/chat/completions",
|
|
"/openai/deployments/{model}/chat/completions",
|
|
]
|
|
|
|
assert hasattr(CustomOpenAPISpec, "CHAT_COMPLETION_PATHS")
|
|
actual_paths = CustomOpenAPISpec.CHAT_COMPLETION_PATHS
|
|
|
|
for expected_path in expected_paths:
|
|
assert (
|
|
expected_path in actual_paths
|
|
), f"Expected path '{expected_path}' not found in CHAT_COMPLETION_PATHS"
|
|
|
|
def test_proxy_chat_completion_request_pydantic_model_works(self):
|
|
"""
|
|
Test that ProxyChatCompletionRequest properly generates schemas
|
|
and includes the expected LiteLLM-specific fields.
|
|
"""
|
|
from litellm.proxy._types import ProxyChatCompletionRequest
|
|
|
|
# Check that we can get the schema
|
|
try:
|
|
# Try Pydantic v2 method first
|
|
schema = ProxyChatCompletionRequest.model_json_schema()
|
|
except AttributeError:
|
|
try:
|
|
# Fallback to Pydantic v1 method
|
|
schema = ProxyChatCompletionRequest.schema()
|
|
except AttributeError:
|
|
pytest.fail(
|
|
"Could not get schema from ProxyChatCompletionRequest using either Pydantic v1 or v2 methods"
|
|
)
|
|
|
|
# Verify schema has properties
|
|
assert "properties" in schema
|
|
properties = schema["properties"]
|
|
|
|
# Check for core required fields
|
|
assert "model" in properties, "Field 'model' should be in schema"
|
|
assert "messages" in properties, "Field 'messages' should be in schema"
|
|
|
|
# Check for LiteLLM-specific fields
|
|
litellm_fields = [
|
|
"guardrails",
|
|
"caching",
|
|
"num_retries",
|
|
"context_window_fallback_dict",
|
|
"fallbacks",
|
|
]
|
|
for field in litellm_fields:
|
|
assert (
|
|
field in properties
|
|
), f"LiteLLM field '{field}' should be in ProxyChatCompletionRequest schema"
|
|
|
|
def test_messages_field_has_example(self, client):
|
|
"""
|
|
Test that the messages field in the expanded request body includes a helpful example.
|
|
"""
|
|
# Clear any cached schema to ensure we get the latest version
|
|
from litellm.proxy.proxy_server import app
|
|
|
|
app.openapi_schema = None
|
|
|
|
# Get the OpenAPI schema
|
|
response = client.get("/openapi.json")
|
|
assert response.status_code == 200
|
|
|
|
openapi_schema = response.json()
|
|
|
|
# Navigate to the chat completions request body schema
|
|
chat_completions_post = openapi_schema["paths"]["/chat/completions"]["post"]
|
|
request_body = chat_completions_post["requestBody"]
|
|
schema_def = request_body["content"]["application/json"]["schema"]
|
|
|
|
# Check that messages field has an example
|
|
messages_field = schema_def["properties"]["messages"]
|
|
assert "example" in messages_field, "Messages field should have an example"
|
|
|
|
# Verify the example structure
|
|
example = messages_field["example"]
|
|
assert isinstance(example, list), "Messages example should be a list"
|
|
assert len(example) >= 1, "Messages example should have at least 1 message"
|
|
|
|
# Check that example messages have proper structure
|
|
for message in example:
|
|
assert "role" in message, "Each example message should have a role"
|
|
assert "content" in message, "Each example message should have content"
|
|
assert message["role"] in [
|
|
"user",
|
|
"assistant",
|
|
"system",
|
|
], f"Invalid role: {message['role']}"
|
|
assert isinstance(
|
|
message["content"], str
|
|
), "Message content should be a string"
|
|
|
|
def test_request_body_accepts_actual_chat_request(self, client):
|
|
"""
|
|
Test that the expanded request body schema accepts a real chat completion request.
|
|
This ensures our schema modifications don't break actual API functionality.
|
|
"""
|
|
# Test data that should be valid according to our expanded schema
|
|
test_request = {
|
|
"model": "gpt-4o",
|
|
"messages": [
|
|
{"role": "user", "content": "Hello, how are you?"},
|
|
{"role": "assistant", "content": "I'm doing well, thank you!"},
|
|
],
|
|
"temperature": 0.7,
|
|
"max_tokens": 100,
|
|
"guardrails": ["no-harmful-content"],
|
|
"caching": True,
|
|
}
|
|
|
|
# This should validate against our schema without errors
|
|
# Note: We're not actually calling the endpoint (which would require API keys)
|
|
# but testing that the request structure is accepted by the schema
|
|
|
|
# Get the OpenAPI schema to verify our test data matches
|
|
response = client.get("/openapi.json")
|
|
assert response.status_code == 200
|
|
|
|
openapi_schema = response.json()
|
|
chat_completions_post = openapi_schema["paths"]["/chat/completions"]["post"]
|
|
|
|
# Should have expanded request body (not just $ref)
|
|
assert "requestBody" in chat_completions_post
|
|
request_body = chat_completions_post["requestBody"]
|
|
schema_def = request_body["content"]["application/json"]["schema"]
|
|
|
|
# Verify our test request has fields that exist in the schema
|
|
properties = schema_def["properties"]
|
|
for field_name in test_request.keys():
|
|
assert (
|
|
field_name in properties
|
|
), f"Field '{field_name}' should be in expanded schema properties"
|
|
|
|
# Verify required fields are present in test request
|
|
required_fields = schema_def.get("required", [])
|
|
for required_field in required_fields:
|
|
assert (
|
|
required_field in test_request
|
|
), f"Required field '{required_field}' should be in test request"
|
|
|
|
def test_openapi_schema_servers_url_with_root_path(self):
|
|
"""
|
|
Test that OpenAPI schema includes correct servers URL when server_root_path is set.
|
|
This ensures Swagger UI works correctly with reverse proxies and subpath deployments.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from litellm.proxy.proxy_server import app, custom_openapi, get_openapi_schema
|
|
|
|
# Test cases: (server_root_path, expected_servers_url)
|
|
# Note: empty string is falsy in Python, so servers won't be set
|
|
test_cases = [
|
|
("/litellm", "/litellm"),
|
|
("/litellm/", "/litellm"), # trailing slash should be removed
|
|
("litellm", "/litellm"), # missing leading slash should be added
|
|
("/api/v1", "/api/v1"),
|
|
]
|
|
|
|
for root_path, expected_url in test_cases:
|
|
# Clear cached schema
|
|
app.openapi_schema = None
|
|
|
|
with patch("litellm.proxy.proxy_server.server_root_path", root_path):
|
|
# Test get_openapi_schema
|
|
schema = get_openapi_schema()
|
|
|
|
# Should have servers field with correct URL
|
|
assert (
|
|
"servers" in schema
|
|
), f"servers field should exist when server_root_path={root_path}"
|
|
assert (
|
|
schema["servers"][0]["url"] == expected_url
|
|
), f"Expected servers URL '{expected_url}', got '{schema['servers'][0]['url']}' for root_path '{root_path}'"
|
|
|
|
# Test custom_openapi as well
|
|
app.openapi_schema = None
|
|
with patch("litellm.proxy.proxy_server.server_root_path", root_path):
|
|
schema = custom_openapi()
|
|
|
|
assert (
|
|
"servers" in schema
|
|
), f"servers field should exist in custom_openapi when server_root_path={root_path}"
|
|
assert (
|
|
schema["servers"][0]["url"] == expected_url
|
|
), f"Expected servers URL '{expected_url}' in custom_openapi, got '{schema['servers'][0]['url']}'"
|
|
|
|
def test_should_make_duplicate_operation_ids_unique_by_method(self):
|
|
from litellm.proxy.proxy_server import ensure_unique_openapi_operation_ids
|
|
|
|
schema = {
|
|
"paths": {
|
|
"/anthropic/{endpoint}": {
|
|
"delete": {
|
|
"operationId": "anthropic_proxy_route_anthropic__endpoint__delete"
|
|
},
|
|
"get": {
|
|
"operationId": "anthropic_proxy_route_anthropic__endpoint__delete"
|
|
},
|
|
"post": {
|
|
"operationId": "anthropic_proxy_route_anthropic__endpoint__delete"
|
|
},
|
|
},
|
|
"/models": {
|
|
"get": {
|
|
"operationId": "list_models_models_get",
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
result = ensure_unique_openapi_operation_ids(schema)
|
|
|
|
assert (
|
|
result["paths"]["/anthropic/{endpoint}"]["delete"]["operationId"]
|
|
== "anthropic_proxy_route_anthropic__endpoint__delete"
|
|
)
|
|
assert (
|
|
result["paths"]["/anthropic/{endpoint}"]["get"]["operationId"]
|
|
== "anthropic_proxy_route_anthropic__endpoint__get"
|
|
)
|
|
assert (
|
|
result["paths"]["/anthropic/{endpoint}"]["post"]["operationId"]
|
|
== "anthropic_proxy_route_anthropic__endpoint__post"
|
|
)
|
|
operation_ids = [
|
|
operation["operationId"]
|
|
for path_item in result["paths"].values()
|
|
for operation in path_item.values()
|
|
]
|
|
assert len(operation_ids) == len(set(operation_ids))
|
|
|
|
def test_should_not_add_method_suffix_to_multi_method_route_base_id(self):
|
|
from types import SimpleNamespace
|
|
|
|
from litellm.proxy.proxy_server import _generate_stable_operation_id
|
|
|
|
multi_method_route = SimpleNamespace(
|
|
name="anthropic_proxy_route",
|
|
path_format="/anthropic/{endpoint}",
|
|
methods={"DELETE", "GET", "POST"},
|
|
)
|
|
single_method_route = SimpleNamespace(
|
|
name="list_models",
|
|
path_format="/models",
|
|
methods={"GET"},
|
|
)
|
|
|
|
assert (
|
|
_generate_stable_operation_id(multi_method_route)
|
|
== "anthropic_proxy_route_anthropic__endpoint_"
|
|
)
|
|
assert (
|
|
_generate_stable_operation_id(single_method_route)
|
|
== "list_models_models_get"
|
|
)
|
|
|
|
def test_should_reserve_operation_ids_across_lazy_fragments(self):
|
|
from litellm.proxy.proxy_server import ensure_unique_openapi_operation_ids
|
|
|
|
used_operation_ids = set()
|
|
first_schema = {
|
|
"paths": {
|
|
"/tools/list": {
|
|
"get": {"operationId": "list_tool_rest_api_mcp_rest_tools_list_get"}
|
|
}
|
|
}
|
|
}
|
|
second_schema = {
|
|
"paths": {
|
|
"/v1/tools/list": {
|
|
"get": {"operationId": "list_tool_rest_api_mcp_rest_tools_list_get"}
|
|
}
|
|
}
|
|
}
|
|
|
|
ensure_unique_openapi_operation_ids(first_schema, used_operation_ids)
|
|
result = ensure_unique_openapi_operation_ids(second_schema, used_operation_ids)
|
|
|
|
assert (
|
|
result["paths"]["/v1/tools/list"]["get"]["operationId"]
|
|
== "list_tool_rest_api_mcp_rest_tools_list_get_2"
|
|
)
|