mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-10 22:24:51 +00:00
[Feat] v2 Pass through endpoints - Add support for subroutes for pass through endpoints + Cleaned up UI (#11827)
* fix: add construct_target_url_with_subpath * add InitPassThroughEndpointHelpers * added debugging for pass through routes * add PassThroughGenericEndpoint to include subpath and input_cost_per_request * polish page * Add Pass-Through Endpoint * polish pass through ui * fixes for initialize_pass_through_endpoints * PassThroughGenericEndpoint * test_add_subpath_route * test_initialize_pass_through_endpoints_with_include_subpath * fix code QA check
This commit is contained in:
+10
-1
@@ -1449,7 +1449,16 @@ class PassThroughGenericEndpoint(LiteLLMPydanticObjectBase):
|
||||
description="The URL to which requests for this path should be forwarded."
|
||||
)
|
||||
headers: dict = Field(
|
||||
description="Key-value pairs of headers to be forwarded with the request. You can set any key value pair here and it will be forwarded to your target endpoint"
|
||||
default={},
|
||||
description="Key-value pairs of headers to be forwarded with the request. You can set any key value pair here and it will be forwarded to your target endpoint",
|
||||
)
|
||||
include_subpath: bool = Field(
|
||||
default=False,
|
||||
description="If True, requests to subpaths of the path will be forwarded to the target endpoint. For example, if the path is /bria and include_subpath is True, requests to /bria/v1/text-to-image/base/2.3 will be forwarded to the target endpoint.",
|
||||
)
|
||||
input_cost_per_request: float = Field(
|
||||
default=0.0,
|
||||
description="The USD cost per request to the target endpoint. This is used to calculate the cost of the request to the target endpoint.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import traceback
|
||||
import uuid
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
@@ -413,10 +413,10 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
||||
|
||||
for field_name, field_value in form_data.items():
|
||||
if isinstance(field_value, (StarletteUploadFile, UploadFile)):
|
||||
files[
|
||||
field_name
|
||||
] = await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file(
|
||||
upload_file=field_value
|
||||
files[field_name] = (
|
||||
await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file(
|
||||
upload_file=field_value
|
||||
)
|
||||
)
|
||||
else:
|
||||
form_data_dict[field_name] = field_value
|
||||
@@ -473,12 +473,41 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
||||
"passthrough_logging_payload": passthrough_logging_payload,
|
||||
}
|
||||
|
||||
logging_obj.model_call_details[
|
||||
"passthrough_logging_payload"
|
||||
] = passthrough_logging_payload
|
||||
logging_obj.model_call_details["passthrough_logging_payload"] = (
|
||||
passthrough_logging_payload
|
||||
)
|
||||
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
def construct_target_url_with_subpath(
|
||||
base_target: str, subpath: str, include_subpath: Optional[bool]
|
||||
) -> str:
|
||||
"""
|
||||
Helper function to construct the full target URL with subpath handling.
|
||||
|
||||
Args:
|
||||
base_target: The base target URL
|
||||
subpath: The captured subpath from the request
|
||||
include_subpath: Whether to include the subpath in the target URL
|
||||
|
||||
Returns:
|
||||
The constructed full target URL
|
||||
"""
|
||||
if not include_subpath:
|
||||
return base_target
|
||||
|
||||
if not subpath:
|
||||
return base_target
|
||||
|
||||
# Ensure base_target ends with / and subpath doesn't start with /
|
||||
if not base_target.endswith("/"):
|
||||
base_target = base_target + "/"
|
||||
if subpath.startswith("/"):
|
||||
subpath = subpath[1:]
|
||||
|
||||
return base_target + subpath
|
||||
|
||||
|
||||
async def pass_through_request( # noqa: PLR0915
|
||||
request: Request,
|
||||
@@ -818,6 +847,7 @@ def create_pass_through_route(
|
||||
_forward_headers: Optional[bool] = False,
|
||||
_merge_query_params: Optional[bool] = False,
|
||||
dependencies: Optional[List] = None,
|
||||
include_subpath: Optional[bool] = False,
|
||||
):
|
||||
# check if target is an adapter.py or a url
|
||||
import uuid
|
||||
@@ -836,6 +866,7 @@ def create_pass_through_route(
|
||||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
subpath: str = "", # captures sub-paths when include_subpath=True
|
||||
):
|
||||
return await chat_completion_pass_through_endpoint(
|
||||
fastapi_response=fastapi_response,
|
||||
@@ -856,10 +887,18 @@ def create_pass_through_route(
|
||||
stream: Optional[
|
||||
bool
|
||||
] = None, # if pass-through endpoint is a streaming request
|
||||
subpath: str = "", # captures sub-paths when include_subpath=True
|
||||
):
|
||||
# Construct the full target URL with subpath if needed
|
||||
full_target = (
|
||||
HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target=target, subpath=subpath, include_subpath=include_subpath
|
||||
)
|
||||
)
|
||||
|
||||
return await pass_through_request( # type: ignore
|
||||
request=request,
|
||||
target=target,
|
||||
target=full_target,
|
||||
custom_headers=custom_headers or {},
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
forward_headers=_forward_headers,
|
||||
@@ -879,14 +918,95 @@ def _is_streaming_response(response: httpx.Response) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def initialize_pass_through_endpoints(pass_through_endpoints: list):
|
||||
class InitPassThroughEndpointHelpers:
|
||||
@staticmethod
|
||||
def add_exact_path_route(
|
||||
app,
|
||||
path: str,
|
||||
target: str,
|
||||
custom_headers: Optional[dict],
|
||||
forward_headers: Optional[bool],
|
||||
merge_query_params: Optional[bool],
|
||||
dependencies: Optional[List],
|
||||
):
|
||||
"""Add exact path route for pass-through endpoint"""
|
||||
verbose_proxy_logger.debug(
|
||||
"adding exact pass through endpoint: %s, dependencies: %s",
|
||||
path,
|
||||
dependencies,
|
||||
)
|
||||
|
||||
app.add_api_route( # type: ignore
|
||||
path=path,
|
||||
endpoint=create_pass_through_route( # type: ignore
|
||||
path,
|
||||
target,
|
||||
custom_headers,
|
||||
forward_headers,
|
||||
merge_query_params,
|
||||
dependencies,
|
||||
),
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def add_subpath_route(
|
||||
app,
|
||||
path: str,
|
||||
target: str,
|
||||
custom_headers: Optional[dict],
|
||||
forward_headers: Optional[bool],
|
||||
merge_query_params: Optional[bool],
|
||||
dependencies: Optional[List],
|
||||
):
|
||||
"""Add wildcard route for sub-paths"""
|
||||
wildcard_path = f"{path}/{{subpath:path}}"
|
||||
verbose_proxy_logger.debug(
|
||||
"adding wildcard pass through endpoint: %s, dependencies: %s",
|
||||
wildcard_path,
|
||||
dependencies,
|
||||
)
|
||||
|
||||
app.add_api_route( # type: ignore
|
||||
path=wildcard_path,
|
||||
endpoint=create_pass_through_route( # type: ignore
|
||||
path,
|
||||
target,
|
||||
custom_headers,
|
||||
forward_headers,
|
||||
merge_query_params,
|
||||
dependencies,
|
||||
include_subpath=True,
|
||||
),
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
|
||||
async def initialize_pass_through_endpoints(
|
||||
pass_through_endpoints: Union[List[Dict], List[PassThroughGenericEndpoint]],
|
||||
):
|
||||
"""
|
||||
Initialize a list of pass-through endpoints by adding them to the FastAPI app routes
|
||||
|
||||
Args:
|
||||
pass_through_endpoints: List of pass-through endpoints to initialize
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
verbose_proxy_logger.debug("initializing pass through endpoints")
|
||||
from litellm.proxy._types import CommonProxyErrors, LiteLLMRoutes
|
||||
from litellm.proxy.proxy_server import app, premium_user
|
||||
|
||||
for endpoint in pass_through_endpoints:
|
||||
if isinstance(endpoint, PassThroughGenericEndpoint):
|
||||
endpoint = endpoint.model_dump()
|
||||
_target = endpoint.get("target", None)
|
||||
_path = endpoint.get("path", None)
|
||||
_path: Optional[str] = endpoint.get("path", None)
|
||||
if _path is None:
|
||||
raise ValueError("Path is required for pass-through endpoint")
|
||||
_custom_headers = endpoint.get("headers", None)
|
||||
_custom_headers = await set_env_variables_in_header(
|
||||
custom_headers=_custom_headers
|
||||
@@ -908,55 +1028,51 @@ async def initialize_pass_through_endpoints(pass_through_endpoints: list):
|
||||
if _target is None:
|
||||
continue
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"adding pass through endpoint: %s, dependencies: %s", _path, _dependencies
|
||||
)
|
||||
app.add_api_route( # type: ignore
|
||||
# Add exact path route
|
||||
verbose_proxy_logger.debug("Initializing pass through endpoint: %s", _path)
|
||||
InitPassThroughEndpointHelpers.add_exact_path_route(
|
||||
app=app,
|
||||
path=_path,
|
||||
endpoint=create_pass_through_route( # type: ignore
|
||||
_path,
|
||||
_target,
|
||||
_custom_headers,
|
||||
_forward_headers,
|
||||
_merge_query_params,
|
||||
_dependencies,
|
||||
),
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
target=_target,
|
||||
custom_headers=_custom_headers,
|
||||
forward_headers=_forward_headers,
|
||||
merge_query_params=_merge_query_params,
|
||||
dependencies=_dependencies,
|
||||
)
|
||||
|
||||
# Add wildcard route for sub-paths
|
||||
if endpoint.get("include_subpath", False) is True:
|
||||
InitPassThroughEndpointHelpers.add_subpath_route(
|
||||
app=app,
|
||||
path=_path,
|
||||
target=_target,
|
||||
custom_headers=_custom_headers,
|
||||
forward_headers=_forward_headers,
|
||||
merge_query_params=_merge_query_params,
|
||||
dependencies=_dependencies,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug("Added new pass through endpoint: %s", _path)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/config/pass_through_endpoint",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=PassThroughEndpointResponse,
|
||||
)
|
||||
async def get_pass_through_endpoints(
|
||||
async def _get_pass_through_endpoints_from_db(
|
||||
endpoint_id: Optional[str] = None,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
GET configured pass through endpoint.
|
||||
|
||||
If no endpoint_id given, return all configured endpoints.
|
||||
"""
|
||||
) -> List[PassThroughGenericEndpoint]:
|
||||
from litellm.proxy.proxy_server import get_config_general_settings
|
||||
|
||||
## Get existing pass-through endpoint field value
|
||||
try:
|
||||
response: ConfigFieldInfo = await get_config_general_settings(
|
||||
field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict
|
||||
)
|
||||
except Exception:
|
||||
return PassThroughEndpointResponse(endpoints=[])
|
||||
return []
|
||||
|
||||
pass_through_endpoint_data: Optional[List] = response.field_value
|
||||
if pass_through_endpoint_data is None:
|
||||
return PassThroughEndpointResponse(endpoints=[])
|
||||
return []
|
||||
|
||||
returned_endpoints = []
|
||||
returned_endpoints: List[PassThroughGenericEndpoint] = []
|
||||
if endpoint_id is None:
|
||||
for endpoint in pass_through_endpoint_data:
|
||||
if isinstance(endpoint, dict):
|
||||
@@ -973,8 +1089,27 @@ async def get_pass_through_endpoints(
|
||||
|
||||
if _endpoint is not None and _endpoint.path == endpoint_id:
|
||||
returned_endpoints.append(_endpoint)
|
||||
return returned_endpoints
|
||||
|
||||
return PassThroughEndpointResponse(endpoints=returned_endpoints)
|
||||
|
||||
@router.get(
|
||||
"/config/pass_through_endpoint",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=PassThroughEndpointResponse,
|
||||
)
|
||||
async def get_pass_through_endpoints(
|
||||
endpoint_id: Optional[str] = None,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
GET configured pass through endpoint.
|
||||
|
||||
If no endpoint_id given, return all configured endpoints.
|
||||
""" ## Get existing pass-through endpoint field value
|
||||
pass_through_endpoints = await _get_pass_through_endpoints_from_db(
|
||||
endpoint_id=endpoint_id, user_api_key_dict=user_api_key_dict
|
||||
)
|
||||
return PassThroughEndpointResponse(endpoints=pass_through_endpoints)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -1107,3 +1242,13 @@ async def delete_pass_through_endpoints(
|
||||
},
|
||||
)
|
||||
return PassThroughEndpointResponse(endpoints=[response_obj])
|
||||
|
||||
|
||||
async def initialize_pass_through_endpoints_in_db():
|
||||
"""
|
||||
Gets all pass-through endpoints from db and initializes them in the proxy server.
|
||||
"""
|
||||
pass_through_endpoints = await _get_pass_through_endpoints_from_db()
|
||||
await initialize_pass_through_endpoints(
|
||||
pass_through_endpoints=pass_through_endpoints
|
||||
)
|
||||
|
||||
@@ -2780,6 +2780,7 @@ class ProxyConfig:
|
||||
await self._init_guardrails_in_db(prisma_client=prisma_client)
|
||||
await self._init_vector_stores_in_db(prisma_client=prisma_client)
|
||||
await self._init_mcp_servers_in_db()
|
||||
await self._init_pass_through_endpoints_in_db()
|
||||
|
||||
async def _init_guardrails_in_db(self, prisma_client: PrismaClient):
|
||||
from litellm.proxy.guardrails.guardrail_registry import (
|
||||
@@ -2857,6 +2858,13 @@ class ProxyConfig:
|
||||
)
|
||||
)
|
||||
|
||||
async def _init_pass_through_endpoints_in_db(self):
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
initialize_pass_through_endpoints_in_db,
|
||||
)
|
||||
|
||||
await initialize_pass_through_endpoints_in_db()
|
||||
|
||||
def decrypt_credentials(self, credential: Union[dict, BaseModel]) -> CredentialItem:
|
||||
if isinstance(credential, dict):
|
||||
credential_object = CredentialItem(**credential)
|
||||
|
||||
@@ -47,7 +47,7 @@ async def test_build_request_files_from_upload_file():
|
||||
file_content = b"test content"
|
||||
file = BytesIO(file_content)
|
||||
# Create SpooledTemporaryFile with content type headers
|
||||
headers = {"content-type": "text/plain"}
|
||||
headers = Headers({"content-type": "text/plain"})
|
||||
upload_file = UploadFile(file=file, filename="test.txt", headers=headers)
|
||||
upload_file.read = AsyncMock(return_value=file_content)
|
||||
|
||||
@@ -82,7 +82,7 @@ async def test_make_multipart_http_request():
|
||||
file_content = b"test file content"
|
||||
file = BytesIO(file_content)
|
||||
# Create SpooledTemporaryFile with content type headers
|
||||
headers = {"content-type": "text/plain"}
|
||||
headers = Headers({"content-type": "text/plain"})
|
||||
upload_file = UploadFile(file=file, filename="test.txt", headers=headers)
|
||||
upload_file.read = AsyncMock(return_value=file_content)
|
||||
|
||||
@@ -268,3 +268,227 @@ async def test_langfuse_passthrough_no_logging():
|
||||
mock_logging_obj.model_call_details["passthrough_logging_payload"]
|
||||
== passthrough_logging_payload
|
||||
)
|
||||
|
||||
|
||||
def test_construct_target_url_with_subpath():
|
||||
"""
|
||||
Test that construct_target_url_with_subpath correctly constructs target URLs
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
HttpPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
# Test with include_subpath=False
|
||||
result = HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target="http://example.com", subpath="api/v1", include_subpath=False
|
||||
)
|
||||
assert result == "http://example.com"
|
||||
|
||||
# Test with include_subpath=True and no subpath
|
||||
result = HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target="http://example.com", subpath="", include_subpath=True
|
||||
)
|
||||
assert result == "http://example.com"
|
||||
|
||||
# Test with include_subpath=True and subpath
|
||||
result = HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target="http://example.com", subpath="api/v1", include_subpath=True
|
||||
)
|
||||
assert result == "http://example.com/api/v1"
|
||||
|
||||
# Test with base_target already ending with /
|
||||
result = HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target="http://example.com/", subpath="api/v1", include_subpath=True
|
||||
)
|
||||
assert result == "http://example.com/api/v1"
|
||||
|
||||
# Test with subpath starting with /
|
||||
result = HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target="http://example.com", subpath="/api/v1", include_subpath=True
|
||||
)
|
||||
assert result == "http://example.com/api/v1"
|
||||
|
||||
# Test with both conditions
|
||||
result = HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target="http://example.com/", subpath="/api/v1", include_subpath=True
|
||||
)
|
||||
assert result == "http://example.com/api/v1"
|
||||
|
||||
|
||||
def test_add_exact_path_route():
|
||||
"""
|
||||
Test that add_exact_path_route correctly adds exact path routes
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
# Mock FastAPI app
|
||||
mock_app = MagicMock()
|
||||
|
||||
# Test data
|
||||
path = "/test/path"
|
||||
target = "http://example.com"
|
||||
custom_headers = {"x-custom": "header"}
|
||||
forward_headers = True
|
||||
merge_query_params = False
|
||||
dependencies = []
|
||||
|
||||
# Call the function
|
||||
InitPassThroughEndpointHelpers.add_exact_path_route(
|
||||
app=mock_app,
|
||||
path=path,
|
||||
target=target,
|
||||
custom_headers=custom_headers,
|
||||
forward_headers=forward_headers,
|
||||
merge_query_params=merge_query_params,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
# Verify add_api_route was called with correct parameters
|
||||
mock_app.add_api_route.assert_called_once()
|
||||
call_args = mock_app.add_api_route.call_args[1]
|
||||
|
||||
assert call_args["path"] == path
|
||||
assert call_args["methods"] == ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
assert call_args["dependencies"] == dependencies
|
||||
assert callable(call_args["endpoint"])
|
||||
|
||||
|
||||
def test_add_subpath_route():
|
||||
"""
|
||||
Test that add_subpath_route correctly adds wildcard routes for sub-paths
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
# Mock FastAPI app
|
||||
mock_app = MagicMock()
|
||||
|
||||
# Test data
|
||||
path = "/test/path"
|
||||
target = "http://example.com"
|
||||
custom_headers = {"x-custom": "header"}
|
||||
forward_headers = True
|
||||
merge_query_params = False
|
||||
dependencies = []
|
||||
|
||||
# Call the function
|
||||
InitPassThroughEndpointHelpers.add_subpath_route(
|
||||
app=mock_app,
|
||||
path=path,
|
||||
target=target,
|
||||
custom_headers=custom_headers,
|
||||
forward_headers=forward_headers,
|
||||
merge_query_params=merge_query_params,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
# Verify add_api_route was called with correct parameters
|
||||
mock_app.add_api_route.assert_called_once()
|
||||
call_args = mock_app.add_api_route.call_args[1]
|
||||
|
||||
# Should have wildcard path
|
||||
expected_wildcard_path = f"{path}/{{subpath:path}}"
|
||||
assert call_args["path"] == expected_wildcard_path
|
||||
assert call_args["methods"] == ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
assert call_args["dependencies"] == dependencies
|
||||
assert callable(call_args["endpoint"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_pass_through_endpoints_with_include_subpath():
|
||||
"""
|
||||
Test that initialize_pass_through_endpoints adds wildcard routes when include_subpath is True
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
initialize_pass_through_endpoints,
|
||||
)
|
||||
|
||||
# Mock the helper functions directly
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.add_exact_path_route"
|
||||
) as mock_add_exact_route:
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.add_subpath_route"
|
||||
) as mock_add_subpath_route:
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.premium_user",
|
||||
True,
|
||||
):
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.set_env_variables_in_header"
|
||||
) as mock_set_env:
|
||||
mock_set_env.return_value = {}
|
||||
|
||||
# Test endpoint with include_subpath=True
|
||||
endpoints = [
|
||||
{
|
||||
"path": "/test/endpoint",
|
||||
"target": "http://example.com",
|
||||
"include_subpath": True,
|
||||
}
|
||||
]
|
||||
|
||||
await initialize_pass_through_endpoints(endpoints)
|
||||
|
||||
# Should be called once for exact path and once for subpath
|
||||
mock_add_exact_route.assert_called_once()
|
||||
mock_add_subpath_route.assert_called_once()
|
||||
|
||||
# Verify exact path route call
|
||||
exact_call_args = mock_add_exact_route.call_args[1]
|
||||
assert exact_call_args["path"] == "/test/endpoint"
|
||||
assert exact_call_args["target"] == "http://example.com"
|
||||
|
||||
# Verify subpath route call
|
||||
subpath_call_args = mock_add_subpath_route.call_args[1]
|
||||
assert subpath_call_args["path"] == "/test/endpoint"
|
||||
assert subpath_call_args["target"] == "http://example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_pass_through_endpoints_without_include_subpath():
|
||||
"""
|
||||
Test that initialize_pass_through_endpoints only adds exact route when include_subpath is False
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
initialize_pass_through_endpoints,
|
||||
)
|
||||
|
||||
# Mock the helper functions directly
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.add_exact_path_route"
|
||||
) as mock_add_exact_route:
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.add_subpath_route"
|
||||
) as mock_add_subpath_route:
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.premium_user",
|
||||
True,
|
||||
):
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.set_env_variables_in_header"
|
||||
) as mock_set_env:
|
||||
mock_set_env.return_value = {}
|
||||
|
||||
# Test endpoint with include_subpath=False (default)
|
||||
endpoints = [
|
||||
{
|
||||
"path": "/test/endpoint",
|
||||
"target": "http://example.com",
|
||||
"include_subpath": False,
|
||||
}
|
||||
]
|
||||
|
||||
await initialize_pass_through_endpoints(endpoints)
|
||||
|
||||
# Should be called only once for exact path
|
||||
mock_add_exact_route.assert_called_once()
|
||||
mock_add_subpath_route.assert_not_called()
|
||||
|
||||
# Verify exact path route call
|
||||
exact_call_args = mock_add_exact_route.call_args[1]
|
||||
assert exact_call_args["path"] == "/test/endpoint"
|
||||
assert exact_call_args["target"] == "http://example.com"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button, TextInput, Grid, Col } from "@tremor/react";
|
||||
import { Button, TextInput, Grid, Col, Switch } from "@tremor/react";
|
||||
import { Select, SelectItem, MultiSelect, MultiSelectItem, Card, Metric, Text, Title, Subtitle, Accordion, AccordionHeader, AccordionBody, } from "@tremor/react";
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { createPassThroughEndpoint } from "./networking";
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
InputNumber,
|
||||
Select as Select2,
|
||||
message,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import { InfoCircleOutlined, ApiOutlined } from "@ant-design/icons";
|
||||
import { keyCreateCall, slackBudgetAlertsHealthCheck, modelAvailableCall } from "./networking";
|
||||
import { list } from "postcss";
|
||||
import KeyValueInput from "./key_value_input";
|
||||
@@ -36,110 +38,182 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const handleOk = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const addPassThrough = (formValues: Record<string, any>) => {
|
||||
// Print the received value
|
||||
console.log(formValues);
|
||||
const addPassThrough = async (formValues: Record<string, any>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log(`formValues: ${JSON.stringify(formValues)}`);
|
||||
|
||||
// // Extract model_name and models from formValues
|
||||
// const { model_name, models } = formValues;
|
||||
|
||||
// // Create new fallback
|
||||
// const newFallback = { [model_name]: models };
|
||||
|
||||
// // Get current fallbacks, or an empty array if it's null
|
||||
// const currentFallbacks = routerSettings.fallbacks || [];
|
||||
|
||||
// // Add new fallback to the current fallbacks
|
||||
// const updatedFallbacks = [...currentFallbacks, newFallback];
|
||||
|
||||
// // Create a new routerSettings object with updated fallbacks
|
||||
// const updatedRouterSettings = { ...routerSettings, fallbacks: updatedFallbacks };
|
||||
|
||||
const newPassThroughItem: passThroughItem = {
|
||||
const newPassThroughItem: passThroughItem = {
|
||||
"headers": formValues["headers"],
|
||||
"path": formValues["path"],
|
||||
"target": formValues["target"]
|
||||
}
|
||||
const updatedPassThroughSettings = [...passThroughItems, newPassThroughItem]
|
||||
|
||||
|
||||
try {
|
||||
createPassThroughEndpoint(accessToken, formValues);
|
||||
setPassThroughItems(updatedPassThroughSettings)
|
||||
"target": formValues["target"],
|
||||
"include_subpath": formValues["include_subpath"] || false,
|
||||
"input_cost_per_request": formValues["input_cost_per_request"] || 0
|
||||
}
|
||||
|
||||
await createPassThroughEndpoint(accessToken, formValues);
|
||||
|
||||
const updatedPassThroughSettings = [...passThroughItems, newPassThroughItem]
|
||||
setPassThroughItems(updatedPassThroughSettings)
|
||||
|
||||
message.success("Pass-through endpoint created successfully");
|
||||
form.resetFields();
|
||||
setIsModalVisible(false);
|
||||
} catch (error) {
|
||||
message.error("Failed to update router settings: " + error, 20);
|
||||
message.error("Error creating pass-through endpoint: " + error, 20);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
message.success("Pass through endpoint successfully added");
|
||||
|
||||
setIsModalVisible(false)
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button className="mx-auto" onClick={() => setIsModalVisible(true)}>
|
||||
<Button
|
||||
className="mx-auto mb-4"
|
||||
onClick={() => setIsModalVisible(true)}
|
||||
>
|
||||
+ Add Pass-Through Endpoint
|
||||
</Button>
|
||||
<Modal
|
||||
title="Add Pass-Through Endpoint"
|
||||
visible={isModalVisible}
|
||||
width={800}
|
||||
footer={null}
|
||||
onOk={handleOk}
|
||||
title={
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
|
||||
<ApiOutlined className="text-xl text-blue-500" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Add Pass-Through Endpoint</h2>
|
||||
</div>
|
||||
}
|
||||
open={isModalVisible}
|
||||
width={1000}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: '24px' },
|
||||
header: { padding: '24px 24px 0 24px', border: 'none' },
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={addPassThrough}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<>
|
||||
<Form.Item
|
||||
label="Path"
|
||||
<div className="mt-6">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={addPassThrough}
|
||||
layout="vertical"
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Path
|
||||
<Tooltip title="The route to be added to the LiteLLM Proxy Server (e.g., /my-endpoint)">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="path"
|
||||
rules={[{ required: true, message: 'The route to be added to the LiteLLM Proxy Server.' }]}
|
||||
help="required"
|
||||
rules={[{ required: true, message: 'Please enter the endpoint path' }]}
|
||||
>
|
||||
<TextInput/>
|
||||
<TextInput
|
||||
placeholder="e.g., /my-endpoint"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Target"
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Target URL
|
||||
<Tooltip title="The URL to which requests for this path should be forwarded">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="target"
|
||||
rules={[{ required: true, message: 'The URL to which requests for this path should be forwarded.' }]}
|
||||
help="required"
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter the target URL' },
|
||||
{ type: 'url', message: 'Please enter a valid URL' }
|
||||
]}
|
||||
>
|
||||
<TextInput/>
|
||||
<TextInput
|
||||
placeholder="https://your-service.com/api"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Headers"
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Headers
|
||||
<Tooltip title="Key-value pairs of headers to be forwarded with the request">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="headers"
|
||||
rules={[{ required: true, message: 'Key-value pairs of headers to be forwarded with the request. You can set any key value pair here and it will be forwarded to your target endpoint' }]}
|
||||
help="required"
|
||||
rules={[{ required: true, message: 'Please configure the headers' }]}
|
||||
>
|
||||
<KeyValueInput/>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button2 htmlType="submit">Add Pass-Through Endpoint</Button2>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Include Subpath
|
||||
<Tooltip title="If enabled, requests to subpaths will also be forwarded to the target endpoint">
|
||||
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="include_subpath"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Cost Per Request (USD)
|
||||
<Tooltip title="The cost in USD per request to the target endpoint">
|
||||
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="input_cost_per_request"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.001}
|
||||
precision={6}
|
||||
placeholder="0.000000"
|
||||
size="large"
|
||||
className="rounded-lg w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Add Pass-Through Endpoint'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Title,
|
||||
Subtitle,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Badge,
|
||||
TableHeaderCell,
|
||||
TableCell,
|
||||
TableBody,
|
||||
Metric,
|
||||
Text,
|
||||
Grid,
|
||||
@@ -22,14 +13,8 @@ import {
|
||||
AccordionBody,
|
||||
AccordionHeader,
|
||||
AccordionList,
|
||||
} from "@tremor/react";
|
||||
import {
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
TabGroup,
|
||||
TabList,
|
||||
Tab,
|
||||
Icon,
|
||||
Title,
|
||||
} from "@tremor/react";
|
||||
import {
|
||||
getCallbacksCall,
|
||||
@@ -65,6 +50,10 @@ import AddFallbacks from "./add_fallbacks";
|
||||
import AddPassThroughEndpoint from "./add_pass_through";
|
||||
import openai from "openai";
|
||||
import Paragraph from "antd/es/skeleton/Paragraph";
|
||||
import { DataTable } from "./view_logs/table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface GeneralSettingsPageProps {
|
||||
accessToken: string | null;
|
||||
userRole: string | null;
|
||||
@@ -72,7 +61,6 @@ interface GeneralSettingsPageProps {
|
||||
modelData: any;
|
||||
}
|
||||
|
||||
|
||||
interface routingStrategyArgs {
|
||||
ttl?: number;
|
||||
lowest_latency_buffer?: number;
|
||||
@@ -90,10 +78,34 @@ export interface passThroughItem {
|
||||
path: string
|
||||
target: string
|
||||
headers: object
|
||||
include_subpath?: boolean
|
||||
input_cost_per_request?: number
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Password field component for headers
|
||||
const PasswordField: React.FC<{ value: object }> = ({ value }) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const headerString = JSON.stringify(value);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-mono text-xs">
|
||||
{showPassword ? headerString : "••••••••"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
type="button"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
|
||||
accessToken,
|
||||
@@ -104,6 +116,7 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
|
||||
const [generalSettings, setGeneralSettings] = useState<passThroughItem[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
return;
|
||||
@@ -114,7 +127,6 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
|
||||
});
|
||||
}, [accessToken, userRole, userID]);
|
||||
|
||||
|
||||
const handleResetField = (fieldName: string, idx: number) => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
@@ -122,72 +134,84 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
|
||||
|
||||
try {
|
||||
deletePassThroughEndpointsCall(accessToken, fieldName);
|
||||
// update value in state
|
||||
|
||||
|
||||
const updatedSettings = generalSettings.filter((setting) => setting.path !== fieldName);
|
||||
setGeneralSettings(updatedSettings);
|
||||
|
||||
message.success("Endpoint deleted successfully.");
|
||||
|
||||
} catch (error) {
|
||||
// do something
|
||||
}
|
||||
};
|
||||
|
||||
// Define columns for the DataTable
|
||||
const columns: ColumnDef<passThroughItem>[] = [
|
||||
{
|
||||
header: "Path",
|
||||
accessorKey: "path",
|
||||
cell: (info: any) => (
|
||||
<Text className="font-mono">{info.getValue()}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Target",
|
||||
accessorKey: "target",
|
||||
cell: (info: any) => (
|
||||
<Text>{info.getValue()}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Headers",
|
||||
accessorKey: "headers",
|
||||
cell: (info: any) => (
|
||||
<PasswordField value={info.getValue() || {}} />
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
color="red"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleResetField(row.original.path, row.index)}
|
||||
>
|
||||
Delete
|
||||
</Icon>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full mx-4">
|
||||
<TabGroup className="gap-2 p-8 h-[75vh] w-full mt-2">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Path</TableHeaderCell>
|
||||
<TableHeaderCell>Target</TableHeaderCell>
|
||||
<TableHeaderCell>Headers</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{generalSettings.map((value, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text>{value.path}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
value.target
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
JSON.stringify(value.headers)
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
color="red"
|
||||
onClick={() =>
|
||||
handleResetField(value.path, index)
|
||||
}
|
||||
>
|
||||
Reset
|
||||
</Icon>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<AddPassThroughEndpoint accessToken={accessToken} setPassThroughItems={setGeneralSettings} passThroughItems={generalSettings}/>
|
||||
</Card>
|
||||
</TabGroup>
|
||||
<div className="w-full h-[75vh] p-6">
|
||||
<div className="mb-2 mt-4">
|
||||
<div>
|
||||
<Title>Pass Through Endpoints</Title>
|
||||
<Text className="text-tremor-content">
|
||||
Configure and manage your pass-through endpoints
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddPassThroughEndpoint
|
||||
accessToken={accessToken}
|
||||
setPassThroughItems={setGeneralSettings}
|
||||
passThroughItems={generalSettings}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
data={generalSettings}
|
||||
columns={columns}
|
||||
renderSubComponent={() => <div></div>}
|
||||
getRowCanExpand={() => false}
|
||||
isLoading={false}
|
||||
noDataMessage="No pass-through endpoints configured"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user