chore(proxy): split lazy openapi multi-method routes

This commit is contained in:
user 2026-04-30 13:41:38 -07:00
parent eca1f252e2
commit 6ca6220679
3 changed files with 89 additions and 22 deletions

View file

@ -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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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",

View file

@ -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():

View file

@ -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",
}