Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 45 additions & 31 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1057,34 +1057,20 @@ def ensure_unique_openapi_operation_ids(
from fastapi.routing import APIWebSocketRoute


def get_openapi_schema():
if app.openapi_schema:
return app.openapi_schema

# Use compatibility wrapper for FastAPI 0.120+ schema generation
from litellm.proxy.common_utils.openapi_schema_compat import (
get_openapi_schema_with_compat,
)

openapi_schema = get_openapi_schema_with_compat(
get_openapi_func=get_openapi,
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)

# Find all WebSocket routes
websocket_routes = [
route for route in app.routes if isinstance(route, APIWebSocketRoute)
]
def _inject_websocket_stubs_into_openapi_schema(
openapi_schema: dict, websocket_routes: list
) -> dict:
"""
Add a synthetic GET stub for each WebSocket route so it appears in Swagger UI.

# Add each WebSocket route to the schema
Merges into any existing path entry rather than replacing it — a WebSocket route
that shares its path with an HTTP route must not erase the HTTP operation. If
a "get" operation is already documented on the path, the WebSocket stub is
skipped to preserve the real GET.
"""
for route in websocket_routes:
# Get the base path without query parameters
base_path = route.path.split("{")[0].rstrip("?")

# Extract parameters from the route
parameters = []
try:
if hasattr(route, "dependant") and route.dependant is not None:
Expand All @@ -1097,25 +1083,53 @@ def get_openapi_schema():
"name": param.name,
"in": "query",
"required": param.required,
"schema": {
"type": "string"
}, # You can make this more specific if needed
"schema": {"type": "string"},
}
)
except (AttributeError, TypeError):
# If we can't access query_params, continue without them
pass

openapi_schema["paths"][base_path] = {
"get": {
path_entry = openapi_schema["paths"].setdefault(base_path, {})
if "get" not in path_entry:
path_entry["get"] = {
"summary": f"WebSocket: {route.name or base_path}",
"description": "WebSocket connection endpoint",
"operationId": f"websocket_{route.name or base_path.replace('/', '_')}",
"parameters": parameters,
"responses": {"101": {"description": "WebSocket Protocol Switched"}},
"tags": ["WebSocket"],
}
}

return openapi_schema


def get_openapi_schema():
if app.openapi_schema:
return app.openapi_schema

# Use compatibility wrapper for FastAPI 0.120+ schema generation
from litellm.proxy.common_utils.openapi_schema_compat import (
get_openapi_schema_with_compat,
)

openapi_schema = get_openapi_schema_with_compat(
get_openapi_func=get_openapi,
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)

# Find all WebSocket routes
websocket_routes = [
route for route in app.routes if isinstance(route, APIWebSocketRoute)
]

# Add a synthetic GET stub for each so they render in Swagger UI,
# without clobbering existing HTTP operations on the same path.
openapi_schema = _inject_websocket_stubs_into_openapi_schema(
openapi_schema, websocket_routes
)

# Add LLM API request schema bodies for documentation
from litellm.proxy.common_utils.custom_openapi_spec import CustomOpenAPISpec
Expand Down
107 changes: 107 additions & 0 deletions tests/test_litellm/proxy/test_openapi_schema_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,110 @@ def test_by_name_has_credential_name_path_param(self):
assert (
"credential_name" in sig.parameters
), "get_credential_by_name must have a credential_name parameter"


class TestWebSocketStubInjection:
"""
Regression test for the v1.82.3 bug where adding a WebSocket route on a path
that already had an HTTP route silently dropped the HTTP operation from the
OpenAPI schema.

Related case: 2026-05-05-madhu-swagger-responses-missing
"""

def _make_fake_ws_route(self, path: str, name: str = "fake_ws"):
"""Minimal stand-in for fastapi.routing.APIWebSocketRoute for the helper's purposes."""
from types import SimpleNamespace

return SimpleNamespace(path=path, name=name, dependant=None)

def test_websocket_stub_does_not_clobber_existing_post(self):
"""
When a WebSocket route shares its path with an existing POST operation,
the POST must survive — the WebSocket stub is added alongside, not on top.
"""
from litellm.proxy.proxy_server import (
_inject_websocket_stubs_into_openapi_schema,
)

schema = {
"paths": {
"/v1/responses": {
"post": {"summary": "responses_api", "operationId": "responses_api"}
}
}
}
ws_routes = [self._make_fake_ws_route("/v1/responses", name="responses_ws")]

result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes)

assert (
"post" in result["paths"]["/v1/responses"]
), "POST operation must be preserved when a WebSocket route shares the path"
assert (
result["paths"]["/v1/responses"]["post"]["operationId"] == "responses_api"
)
assert (
"get" in result["paths"]["/v1/responses"]
), "WebSocket stub should also be added under 'get'"
assert result["paths"]["/v1/responses"]["get"]["tags"] == ["WebSocket"]

def test_websocket_stub_added_when_path_is_new(self):
"""
When a WebSocket route's path is not already in the schema, the stub
creates a fresh entry — preserving the original behavior for WebSocket-only
paths.
"""
from litellm.proxy.proxy_server import (
_inject_websocket_stubs_into_openapi_schema,
)

schema = {"paths": {}}
ws_routes = [self._make_fake_ws_route("/ws_only", name="ws_only")]

result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes)

assert "/ws_only" in result["paths"]
assert "get" in result["paths"]["/ws_only"]
assert result["paths"]["/ws_only"]["get"]["tags"] == ["WebSocket"]

def test_websocket_stub_skipped_when_existing_get(self):
"""
If a real GET is already documented on the path, the WebSocket stub is
skipped — a real operation always wins over the synthetic stub. This
closes the same trap for future GET-vs-WebSocket collisions.
"""
from litellm.proxy.proxy_server import (
_inject_websocket_stubs_into_openapi_schema,
)

schema = {
"paths": {
"/health": {
"get": {"summary": "health_check", "operationId": "real_get"}
}
}
}
ws_routes = [self._make_fake_ws_route("/health", name="health_ws")]

result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes)

assert (
result["paths"]["/health"]["get"]["operationId"] == "real_get"
), "Real GET must take precedence over WebSocket stub"

def test_responses_post_routes_registered_on_router(self):
"""
Sanity check: the three POST routes for the responses API are still wired
on the responses router. Guards against accidental removal at the source.
"""
from litellm.proxy.response_api_endpoints.endpoints import router

post_paths = {
route.path
for route in router.routes
if hasattr(route, "methods")
and "POST" in (route.methods or set())
and route.path in {"/v1/responses", "/responses", "/openai/v1/responses"}
}
assert post_paths == {"/v1/responses", "/responses", "/openai/v1/responses"}
Loading