Merge PR #27244 into agent staging branch

This commit is contained in:
oss-pr-review-agent-shin[bot] 2026-05-06 01:36:54 +00:00 committed by GitHub
commit 936dc028cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 158 additions and 37 deletions

View file

@ -1057,6 +1057,52 @@ vertex_live_passthrough_vertex_base = VertexBase()
from fastapi.routing import 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.
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:
base_path = route.path.split("{")[0].rstrip("?")
parameters = []
try:
if hasattr(route, "dependant") and route.dependant is not None:
# Handle both FastAPI <0.120 and >=0.120
query_params = getattr(route.dependant, "query_params", [])
if query_params:
for param in query_params:
parameters.append(
{
"name": param.name,
"in": "query",
"required": param.required,
"schema": {"type": "string"},
}
)
except (AttributeError, TypeError):
pass
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
@ -1079,43 +1125,11 @@ def get_openapi_schema():
route for route in app.routes if isinstance(route, APIWebSocketRoute)
]
# Add each WebSocket route to the schema
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:
# Handle both FastAPI <0.120 and >=0.120
query_params = getattr(route.dependant, "query_params", [])
if query_params:
for param in query_params:
parameters.append(
{
"name": param.name,
"in": "query",
"required": param.required,
"schema": {
"type": "string"
}, # You can make this more specific if needed
}
)
except (AttributeError, TypeError):
# If we can't access query_params, continue without them
pass
openapi_schema["paths"][base_path] = {
"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"],
}
}
# 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

View file

@ -140,3 +140,110 @@ class TestCredentialEndpointsOpenAPISchema:
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"}