mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge PR #27244 into agent staging branch
This commit is contained in:
commit
936dc028cb
2 changed files with 158 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue