From 6ca6220679da5bb9d324cf21efd1f6cca3120739 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:41:38 -0700 Subject: [PATCH] chore(proxy): split lazy openapi multi-method routes --- litellm/proxy/_lazy_openapi_snapshot.json | 28 ++++++------ litellm/proxy/_lazy_openapi_snapshot.py | 44 +++++++++++++++---- .../proxy/test_lazy_openapi_snapshot.py | 39 ++++++++++++++++ 3 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 tests/test_litellm/proxy/test_lazy_openapi_snapshot.py diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index b8e9eb6c261..46a514c0870 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3616,7 +3616,7 @@ }, "get": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", + "operationId": "anthropic_proxy_route_anthropic__endpoint__get", "parameters": [ { "in": "path", @@ -3660,7 +3660,7 @@ }, "patch": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", + "operationId": "anthropic_proxy_route_anthropic__endpoint__patch", "parameters": [ { "in": "path", @@ -3704,7 +3704,7 @@ }, "post": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", + "operationId": "anthropic_proxy_route_anthropic__endpoint__post", "parameters": [ { "in": "path", @@ -3748,7 +3748,7 @@ }, "put": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", + "operationId": "anthropic_proxy_route_anthropic__endpoint__put", "parameters": [ { "in": "path", @@ -13299,7 +13299,7 @@ }, "get": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", + "operationId": "langfuse_proxy_route_langfuse__endpoint__get", "parameters": [ { "in": "path", @@ -13338,7 +13338,7 @@ }, "patch": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", + "operationId": "langfuse_proxy_route_langfuse__endpoint__patch", "parameters": [ { "in": "path", @@ -13377,7 +13377,7 @@ }, "post": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", + "operationId": "langfuse_proxy_route_langfuse__endpoint__post", "parameters": [ { "in": "path", @@ -13416,7 +13416,7 @@ }, "put": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", + "operationId": "langfuse_proxy_route_langfuse__endpoint__put", "parameters": [ { "in": "path", @@ -26922,7 +26922,7 @@ }, "get": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_get", "parameters": [ { "in": "path", @@ -26961,7 +26961,7 @@ }, "head": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", @@ -27000,7 +27000,7 @@ }, "options": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_options", "parameters": [ { "in": "path", @@ -27039,7 +27039,7 @@ }, "patch": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_patch", "parameters": [ { "in": "path", @@ -27078,7 +27078,7 @@ }, "post": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_post", "parameters": [ { "in": "path", @@ -27117,7 +27117,7 @@ }, "put": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", "parameters": [ { "in": "path", diff --git a/litellm/proxy/_lazy_openapi_snapshot.py b/litellm/proxy/_lazy_openapi_snapshot.py index 4b137cb70d1..dca54eb7fac 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.py +++ b/litellm/proxy/_lazy_openapi_snapshot.py @@ -10,10 +10,12 @@ any drift as a neutral check. import json import re import sys +from copy import copy from pathlib import Path -from typing import Dict, Optional +from typing import Dict, List, Optional from fastapi.routing import APIRoute +from starlette.routing import BaseRoute SNAPSHOT_FILE = Path(__file__).parent / "_lazy_openapi_snapshot.json" @@ -28,11 +30,36 @@ def load_snapshot() -> Optional[Dict[str, Dict]]: return None -def _stable_unique_id(route: APIRoute) -> str: +def _stable_unique_id(route: APIRoute, method: str) -> str: operation_id = f"{route.name}{route.path_format}" operation_id = re.sub(r"\W", "_", operation_id) - method = sorted(route.methods or [""])[0].lower() - return f"{operation_id}_{method}" + return f"{operation_id}_{method.lower()}" + + +def _routes_with_stable_unique_ids(routes: List[BaseRoute]) -> List[BaseRoute]: + stable_routes: List[BaseRoute] = [] + for route in routes: + if not isinstance(route, APIRoute) or not route.methods: + stable_routes.append(route) + continue + + methods = sorted(route.methods) + has_multiple_methods = len(methods) > 1 + for method in methods: + method_route = copy(route) + method_route.methods = {method} + if route.operation_id is not None: + method_route.operation_id = ( + f"{route.operation_id}_{method.lower()}" + if has_multiple_methods + else route.operation_id + ) + method_route.unique_id = method_route.operation_id + else: + method_route.unique_id = _stable_unique_id(route, method) + stable_routes.append(method_route) + + return stable_routes def generate_snapshot() -> Dict[str, Dict]: @@ -61,10 +88,11 @@ def generate_snapshot() -> Dict[str, Dict]: ] if not feat_routes: continue - for route in feat_routes: - if isinstance(route, APIRoute): - route.unique_id = _stable_unique_id(route) - full = get_openapi(title=app.title, version=app.version, routes=feat_routes) + full = get_openapi( + title=app.title, + version=app.version, + routes=_routes_with_stable_unique_ids(feat_routes), + ) # Group all of a feature's routes under one tag. for path_ops in full.get("paths", {}).values(): for op in path_ops.values(): diff --git a/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py b/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py new file mode 100644 index 00000000000..a5605a281f2 --- /dev/null +++ b/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi +from fastapi.routing import APIRoute + +from litellm.proxy._lazy_openapi_snapshot import _routes_with_stable_unique_ids + + +def test_routes_with_stable_unique_ids_splits_multi_method_routes() -> None: + app = FastAPI() + + async def proxy_route() -> dict: + return {} + + app.add_api_route( + "/proxy/{endpoint:path}", + proxy_route, + methods=["GET", "POST", "DELETE"], + ) + + routes = [route for route in app.routes if isinstance(route, APIRoute)] + stable_routes = _routes_with_stable_unique_ids(routes) + + assert [route.methods for route in stable_routes] == [ + {"DELETE"}, + {"GET"}, + {"POST"}, + ] + + openapi = get_openapi(title="test", version="1", routes=stable_routes) + path_ops = openapi["paths"]["/proxy/{endpoint}"] + + operation_ids = { + method: operation["operationId"] for method, operation in path_ops.items() + } + assert operation_ids == { + "delete": "proxy_route_proxy__endpoint__delete", + "get": "proxy_route_proxy__endpoint__get", + "post": "proxy_route_proxy__endpoint__post", + }