diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a5905765c6e4..b136464fd2df 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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: @@ -1097,17 +1083,15 @@ 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('/', '_')}", @@ -1115,7 +1099,37 @@ def get_openapi_schema(): "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 diff --git a/tests/test_litellm/proxy/test_openapi_schema_validation.py b/tests/test_litellm/proxy/test_openapi_schema_validation.py index 68d537b2593e..b44edc8a3bc5 100644 --- a/tests/test_litellm/proxy/test_openapi_schema_validation.py +++ b/tests/test_litellm/proxy/test_openapi_schema_validation.py @@ -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"}